React Flowのdagre layoutはvirtual rootよりconnected componentsで分ける
複数の独立したグラフ領域をdagreで並べる時、virtual rootを追加したくなります。
隠しrootを1つ作り、すべてのroot nodeにつなげてlayoutする方法です。動きますが、概念的に別の領域まで同じ親の子として扱われます。その結果、平たく横長いlayoutになります。
React Flowでは、先にconnected componentを分け、componentごとにdagre layoutをかけてから横に並べるほうが安定します。
症状: すべてのsectionが同じrankに並ぶ
たとえばgraphに次があるとします。
- startup flow
- event handlers
- background jobs
- dead nodes
- independent state-machine regions
virtual rootを入れると、dagreからはこう見えます。
__root__
-> startup
-> handler_a
-> handler_b
-> orphan
これは1つの浅い階層です。意味のあるgroupingではありません。
weakly connected componentsを探す
groupingのためだけに、edgeを無向として扱います。
type NodeLike = { id: string };
type EdgeLike = { source: string; target: string };
function findComponents(nodes: NodeLike[], edges: EdgeLike[]) {
const neighbors = new Map<string, Set<string>>();
nodes.forEach((node) => neighbors.set(node.id, new Set()));
edges.forEach((edge) => {
neighbors.get(edge.source)?.add(edge.target);
neighbors.get(edge.target)?.add(edge.source);
});
const visited = new Set<string>();
const components: string[][] = [];
for (const node of nodes) {
if (visited.has(node.id)) continue;
const stack = [node.id];
const current: string[] = [];
visited.add(node.id);
while (stack.length) {
const id = stack.pop()!;
current.push(id);
for (const next of neighbors.get(id) ?? []) {
if (!visited.has(next)) {
visited.add(next);
stack.push(next);
}
}
}
components.push(current);
}
return components;
}
これで独立したsectionごとにlayoutできます。
componentごとにX座標を正規化する
Dagreは絶対座標を返します。横に並べる前に、component内の中心を0に寄せます。
function normalizeX<T extends { position: { x: number; y: number } }>(nodes: T[]): T[] {
const minX = Math.min(...nodes.map((node) => node.position.x));
const maxX = Math.max(...nodes.map((node) => node.position.x));
const centerX = (minX + maxX) / 2;
return nodes.map((node) => ({
...node,
position: {
x: node.position.x - centerX,
y: node.position.y,
},
}));
}
これをしないと、大きいcomponentと単一nodeのcomponentが視覚的にズレます。
sectionごとに横へ配置する
layoutと正規化の後でoffsetを足します。
const SECTION_GAP = 420;
const positioned = components.flatMap((component, index) => {
const layouted = layoutWithDagre(component);
const normalized = normalizeX(layouted);
return normalized.map((node) => ({
...node,
position: {
x: node.position.x + index * SECTION_GAP,
y: node.position.y,
},
}));
});
最初は固定幅で十分です。大きなgraphではcomponentのbounding boxから幅を計算します。
使わないほうがよい場合
次の場合は向きません。
- ユーザーが手動配置し、その位置を保存したい
- cyclic graphで別のlayout modelが必要
- force-directed layoutが要件
- component分離が読者に意味を持たない
このpatternは、call graph、dependency graph、state-machine、解析ツールのように、分離したsectionそのものが説明になるUIで強いです。
検証チェック
- 各connected componentが1回だけ検出される
- component間にedgeが残っていない
- 大小のcomponentが中央揃えに見える
fitView()が全体を収める- 同じgraphでlayoutが再現する
- screenshotで「平たい1列」ではなくgroupingが見える