svelte-flow について
svelte-flow は、@xyflow/svelte でノードベースの UI を構築する開発者向けガイドです。基本設定に加え、カスタムノードやエッジ、イベント、レイアウト、性能上の注意点を扱います。
主な機能
- 01ノードとエッジの設定
- 02カスタムノードとエッジ型の作成
- 03グラフ操作イベントの処理
- 04Dagre によるノード配置
- 05グラフ用ユーティリティとコンポーネント
- 06状態管理と画像のエクスポート
活用シーン
- 01Svelte のノードエディター構築
- 02独自のグラフ操作の追加
- 03グラフ要素の階層配置
- 04グラフ表示の画像エクスポート
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ freightcognition/ linehaulai-claude-marketplace --skill svelte-flowインストール後、Claude Code で/svelte-flowを入力して読み込まれたか確認します。
詳細
Svelte Flow
Svelte Flow(@xyflow/svelte)でノードベースの UI を構築するためのエキスパートガイドです。
インストール
npm install @xyflow/svelte
基本的なセットアップ
<script lang="ts">
import { SvelteFlow, Background } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
// Use $state.raw for nodes and edges (Svelte 5)
let nodes = $state.raw([
{ id: '1', data: { label: 'Node 1' }, position: { x: 0, y: 0 } },
{ id: '2', data: { label: 'Node 2' }, position: { x: 0, y: 100 } }
]);
let edges = $state.raw([
{ id: 'e1-2', source: '1', target: '2' }
]);
</script>
<SvelteFlow bind:nodes bind:edges fitView>
<Background />
</SvelteFlow>
主な概念
ノード
各ノードには以下が必要です。
id:一意の識別子position:{ x: number, y: number }data:カスタムプロパティを持つオブジェクト(通常はlabelを含みます)- 任意:
type、style、hidden、width、height、sourcePosition、targetPosition
const node = {
id: '1',
type: 'custom',
position: { x: 100, y: 100 },
data: { label: 'My Node', color: '#ff0000' },
style: 'background: #f0f0f0',
width: 150,
height: 100
};
エッジ
各エッジには以下が必要です。
id:一意の識別子source:ソースノードの IDtarget:ターゲットノードの ID- 任意:
type、label、animated、markerEnd、markerStart、style、hidden
const edge = {
id: 'e1-2',
source: '1',
target: '2',
type: 'smoothstep',
label: 'connects to',
animated: true,
markerEnd: { type: MarkerType.ArrowClosed }
};
組み込みの型
エッジの型:
default:直線smoothstep:滑らかな 90 度の折れ線step:鋭角な 90 度の折れ線straight:defaultの別名bezier:ベジェ曲線
ノードの型:
default:基本的な長方形のノードinput:ソースハンドルのみを持つノードoutput:ターゲットハンドルのみを持つノード
カスタムノード
<script lang="ts" module>
import type { Node, NodeProps } from '@xyflow/svelte';
export type CustomNodeType = Node<{
label: string;
color?: string;
}>;
</script>
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte';
let { data }: NodeProps<CustomNodeType> = $props();
</script>
<div class="custom-node" style:background={data.color}>
<Handle type="target" position={Position.Left} />
<div>{data.label}</div>
<Handle type="source" position={Position.Right} />
</div>
<style>
.custom-node {
padding: 10px;
border-radius: 5px;
border: 1px solid #ddd;
}
</style>
カスタムノードを登録します。
<script lang="ts">
import CustomNode from './CustomNode.svelte';
const nodeTypes = {
custom: CustomNode
};
let nodes = $state.raw([
{ id: '1', type: 'custom', data: { label: 'Custom', color: '#ff7000' }, position: { x: 0, y: 0 } }
]);
</script>
<SvelteFlow bind:nodes {nodeTypes} bind:edges fitView />
カスタムエッジ
<script lang="ts">
import { BaseEdge, EdgeLabel, getStraightPath, type EdgeProps } from '@xyflow/svelte';
let { id, sourceX, sourceY, targetX, targetY, label }: EdgeProps = $props();
let [edgePath, labelX, labelY] = $derived(
getStraightPath({ sourceX, sourceY, targetX, targetY })
);
</script>
<BaseEdge {id} path={edgePath} />
{#if label}
<EdgeLabel x={labelX} y={labelY}>
<div class="edge-label">{label}</div>
</EdgeLabel>
{/if}
利用できるパスヘルパー:
getStraightPath()getBezierPath()getSmoothStepPath()
カスタムエッジを登録します。
<script lang="ts">
const edgeTypes = {
custom: CustomEdge
};
</script>
<SvelteFlow bind:nodes bind:edges {edgeTypes} fitView />
状態の更新
重要: ノードとエッジはイミュータブルです。更新を反映するには、新しいオブジェクトを作成してください。
<script lang="ts">
// ❌ Won't work - direct mutation
nodes[0].position.x = 100;
// ✅ Works - create new array
nodes = nodes.map((node) => {
if (node.id === '1') {
return { ...node, position: { ...node.position, x: 100 } };
}
return node;
});
// ✅ Also works - using helper from useSvelteFlow
import { useSvelteFlow } from '@xyflow/svelte';
const { updateNode } = useSvelteFlow();
updateNode('1', (node) => ({
...node,
position: { ...node.position, x: 100 }
}));
</script>
イベント処理
一般的なイベントハンドラー:
<SvelteFlow
bind:nodes
bind:edges
onnodeclick={(event) => console.log('node clicked', event.targetNode)}
onnodedrag={(event) => console.log('node dragging', event.targetNode)}
onnodedragstop={(event) => console.log('drag ended', event.targetNode)}
onedgeclick={(event) => console.log('edge clicked', event.edge)}
onconnect={(params) => {
edges = [...edges, { id: `e${params.source}-${params.target}`, ...params }];
}}
/>
利用可能なイベント:
onnodeclick、onnodedoubleclickonnodedrag、onnodedragstart、onnodedragstoponedgeclick、onedgedoubleclickonconnect、onconnectstart、onconnectendonbeforedelete(非同期。削除を防止できます)oninit、onmove、onmovestart、onmoveend
レイアウトアルゴリズム
Dagre(階層型)
import dagre from '@dagrejs/dagre';
import { Position } from '@xyflow/svelte';
function getLayoutedElements(nodes: Node[], edges: Edge[], direction = 'TB') {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const isHorizontal = direction === 'LR';
dagreGraph.setGraph({ rankdir: direction });
const nodeWidth = 172;
const nodeHeight = 36;
nodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
return {
...node,
targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
},
};
});
return { nodes: layoutedNodes, edges };
}
高度な機能
フローティングエッジ
ノード境界上の最も近い点に動的に接続するエッジです。
<script lang="ts">
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/svelte';
import { getEdgeParams } from './utils';
let { id, source, target, markerEnd }: EdgeProps = $props();
const { sx, sy, tx, ty, sourcePos, targetPos } = $derived(
getEdgeParams(source, target)
);
let [edgePath] = $derived(
getBezierPath({
sourceX: sx,
sourceY: sy,
sourcePosition: sourcePos,
targetX: tx,
targetY: ty,
targetPosition: targetPos,
})
);
</script>
<BaseEdge {id} path={edgePath} {markerEnd} />
ConnectionMode.Loose を指定して登録します。
<script lang="ts">
import { ConnectionMode } from '@xyflow/svelte';
</script>
<SvelteFlow
bind:nodes
bind:edges
edgeTypes={{ floating: FloatingEdge }}
connectionMode={ConnectionMode.Loose}
fitView
/>
近接接続
ノードを近づけてドラッグすると、自動的に接続します。
<script lang="ts">
const MIN_DISTANCE = 150;
function getClosestEdge(node: Node, nodes: Node[]) {
const closestNode = nodes.reduce(
(res, n) => {
if (n.id !== node.id) {
const dx = n.position.x - node.position.x;
const dy = n.position.y - node.position.y;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < res.distance && d < MIN_DISTANCE) {
res.distance = d;
res.node = n;
}
}
return res;
},
{ distance: Number.MAX_VALUE, node: null }
);
if (!closestNode.node) return null;
const closeNodeIsSource = closestNode.node.position.x < node.position.x;
return {
id: closeNodeIsSource
? `${node.id}-${closestNode.node.id}`
: `${closestNode.node.id}-${node.id}`,
source: closeNodeIsSource ? closestNode.node.id : node.id,
target: closeNodeIsSource ? node.id : closestNode.node.id,
class: 'temp',
};
}
function onNodeDrag({ targetNode: node }) {
const closestEdge = getClosestEdge(node, nodes);
edges = edges.filter(e => e.class !== 'temp');
if (closestEdge && !edges.some(e => e.source === closestEdge.source && e.target === closestEdge.target)) {
edges = [...edges, closestEdge];
}
}
function onNodeDragStop() {
edges = edges.map((edge) =>
edge.class === 'temp' ? { ...edge, class: '' } : edge
);
}
</script>
<SvelteFlow
bind:nodes
bind:edges
onnodedrag={onNodeDrag}
onnodedragstop={onNodeDragStop}
fitView
/>
エッジの再接続
エッジの端点を別のノードにドラッグできるようにします。
<script lang="ts">
import {
BaseEdge,
EdgeReconnectAnchor,
getBezierPath,
type EdgeProps
} from '@xyflow/svelte';
let { sourceX, sourceY, targetX, targetY, selected, ...props }: EdgeProps = $props();
const [edgePath] = $derived(
getBezierPath({ sourceX, sourceY, targetX, targetY })
);
let reconnecting = $state(false);
</script>
{#if !reconnecting}
<BaseEdge path={edgePath} {...props} />
{/if}
{#if selected}
<EdgeReconnectAnchor
bind:reconnecting
type="source"
position={{ x: sourceX, y: sourceY }}
/>
<EdgeReconnectAnchor
bind:reconnecting
type="target"
position={{ x: targetX, y: targetY }}
/>
{/if}
ノードを削除してエッジを再接続
<script lang="ts">
import { getIncomers, getOutgoers, getConnectedEdges, type OnBeforeDelete } from '@xyflow/svelte';
const onbeforedelete: OnBeforeDelete = async ({ nodes: deletedNodes }) => {
let remainingNodes = [...nodes];
edges = deletedNodes.reduce((acc, node) => {
const incomers = getIncomers(node, remainingNodes, acc);
const outgoers = getOutgoers(node, remainingNodes, acc);
const connectedEdges = getConnectedEdges([node], acc);
const remainingEdges = acc.filter((edge) => !connectedEdges.includes(edge));
const createdEdges = incomers.flatMap(({ id: source }) =>
outgoers.map(({ id: target }) => ({
id: `${source}->${target}`,
source,
target,
}))
);
remainingNodes = remainingNodes.filter((rn) => rn.id !== node.id);
return [...remainingEdges, ...createdEdges];
}, edges);
nodes = remainingNodes;
return true;
};
</script>
<SvelteFlow bind:nodes bind:edges {onbeforedelete} fitView />
コンポーネント
コントロール
<script lang="ts">
import { SvelteFlow, Controls } from '@xyflow/svelte';
</script>
<SvelteFlow bind:nodes bind:edges fitView>
<Controls showLock={false} showFitView={true} showZoom={true} />
</SvelteFlow>
ミニマップ
<script lang="ts">
import { SvelteFlow, MiniMap } from '@xyflow/svelte';
</script>
<SvelteFlow bind:nodes bind:edges fitView>
<MiniMap />
</SvelteFlow>
背景
<script lang="ts">
import { SvelteFlow, Background, BackgroundVariant } from '@xyflow/svelte';
</script>
<SvelteFlow bind:nodes bind:edges fitView>
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
</SvelteFlow>
バリエーション:Dots、Lines、Cross
パネル(カスタムコントロール)
<script lang="ts">
import { SvelteFlow, Panel } from '@xyflow/svelte';
</script>
<SvelteFlow bind:nodes bind:edges fitView>
<Panel position="top-left">
<button>Custom Button</button>
</Panel>
</SvelteFlow>
位置:top-left、top-center、top-right、bottom-left、bottom-center、bottom-right
ユーティリティ関数
import {
getConnectedEdges,
getIncomers,
getOutgoers,
addEdge,
applyEdgeChanges,
applyNodeChanges
} from '@xyflow/svelte';
// Get edges connected to nodes
const connectedEdges = getConnectedEdges(nodes, edges);
// Get nodes that connect TO a node
const incomers = getIncomers(targetNode, nodes, edges);
// Get nodes that a node connects TO
const outgoers = getOutgoers(sourceNode, nodes, edges);
// Add edge with validation
edges = addEdge(connection, edges);
ストア管理(代替手段)
状態を一元管理する場合:
// store.svelte.js
let nodes = $state.raw([...]);
let edges = $state.raw([...]);
export const getNodes = () => nodes;
export const getEdges = () => edges;
export const setNodes = (newNodes) => nodes = newNodes;
export const setEdges = (newEdges) => edges = newEdges;
<script>
import { getNodes, getEdges, setNodes, setEdges } from './store.svelte.js';
</script>
<SvelteFlow bind:nodes={getNodes, setNodes} bind:edges={getEdges, setEdges} />
画像へのエクスポート
<script lang="ts">
import { useSvelteFlow } from '@xyflow/svelte';
const { toObject, getViewport } = useSvelteFlow();
async function downloadImage() {
const { toPng } = await import('html-to-image');
const dataUrl = await toPng(document.querySelector('.svelte-flow'));
const a = document.createElement('a');
a.setAttribute('download', 'flow.png');
a.setAttribute('href', dataUrl);
a.click();
}
</script>
パフォーマンスのヒント
- ノードとエッジには
$state.rawを使用します(深いリアクティビティを防ぎます) - 大規模なグラフ(ノード 1000 個以上)では、
elevateNodesOnSelect={false}とelevateEdgesOnSelect={false}を設定します minZoomとmaxZoomを使用して、ズームレベルを制限します- 非常に大規模なグラフでは、仮想化を検討してください
よく使う Props
<SvelteFlow
bind:nodes
bind:edges
nodeTypes={customNodeTypes}
edgeTypes={customEdgeTypes}
defaultEdgeOptions={{ animated: true, type: 'smoothstep' }}
connectionLineType={ConnectionLineType.Straight}
connectionMode={ConnectionMode.Loose}
fitView
minZoom={0.1}
maxZoom={2}
snapToGrid={true}
snapGrid={[15, 15]}
elevateNodesOnSelect={true}
elevateEdgesOnSelect={true}
deleteKeyCode="Delete"
selectionKeyCode="Shift"
multiSelectionKeyCode="Meta"
panOnScroll={true}
zoomOnScroll={true}
zoomOnDoubleClick={true}
zoomOnPinch={true}
panOnDrag={true}
/>
コメント
Markdown 対応