autoLayout.ts3.2 KBView on GitHub /**
* One-shot layout for position-less graphs (agent/config/MCP-authored flows carry
* no `defaults.layout`; a graph document carries no `graphLayout` entry). This is a
* standalone `@dagrejs/dagre` pass — deliberately NOT FileGraph's live d3-force
* simulation. The canvas is an editor with persisted positions: we lay out once,
* then the user drags and we persist; nothing re-flows on its own.
*
* Shared by the outbound flow canvas (`modules/outbound/.../graph/stagesToFlow.ts`,
* top-to-bottom) and the graph document canvas (`view.mode: 'dagre-lr'`, which passes
* `rankdir: 'LR'`). It imports `@dagrejs/dagre` plus `@xyflow/react` TYPES only, so it
* never puts React Flow on a route's critical path — keep the `import type` type-only.
*
* ref: gumloop uses static positions (no force sim); dagre is our analog only for
* the initial auto-layout of graphs that have never been opened on a canvas.
*/
import dagre from '@dagrejs/dagre';
import type { Edge, Node } from '@xyflow/react';
export const NODE_WIDTH = 200;
export const NODE_HEIGHT = 72;
export interface AutoLayoutOptions {
/**
* Rank direction: `'TB'` lays the graph out top-to-bottom, `'LR'` left-to-right.
* Absent (or any value outside the union, e.g. a `view.mode` string that does not
* map to a direction) falls back to `'TB'`, which is what every flow-canvas call
* site relies on.
*/
rankdir?: 'TB' | 'LR';
/**
* The node box dagre reserves, and the air around it.
*
* All four default to the flow canvas's numbers, so that caller is unaffected. A GRAPH card
* is a different size — it stacks a name and up to four field rows — and dagre spaces ranks
* by `ranksep + height`, so passing the flow canvas's 72px for a 150px card left the rows
* nearly touching. Sized by the caller because only the caller knows what it is drawing.
*/
nodeWidth?: number;
nodeHeight?: number;
nodesep?: number;
ranksep?: number;
}
/**
* Compute a `{ [nodeId]: {x,y} }` map laying `nodes` out along `edges`, top-to-bottom
* by default or left-to-right with `{ rankdir: 'LR' }`. Coordinates are the node's
* top-left (React Flow's origin), converted from dagre's center origin. Edges naming
* a node that is not in `nodes` are ignored.
*/
export function autoLayout(
nodes: Pick<Node, 'id'>[],
edges: Pick<Edge, 'source' | 'target'>[],
opts?: AutoLayoutOptions,
): Record<string, { x: number; y: number }> {
const rankdir = opts?.rankdir === 'LR' ? 'LR' : 'TB';
const width = opts?.nodeWidth ?? NODE_WIDTH;
const height = opts?.nodeHeight ?? NODE_HEIGHT;
const g = new dagre.graphlib.Graph();
g.setGraph({
rankdir,
nodesep: opts?.nodesep ?? 60,
ranksep: opts?.ranksep ?? 80,
marginx: 20,
marginy: 20,
});
g.setDefaultEdgeLabel(() => ({}));
for (const n of nodes) g.setNode(n.id, { width, height });
for (const e of edges) {
if (g.hasNode(e.source) && g.hasNode(e.target)) g.setEdge(e.source, e.target);
}
dagre.layout(g);
const positions: Record<string, { x: number; y: number }> = {};
for (const n of nodes) {
const { x, y } = g.node(n.id);
positions[n.id] = { x: x - width / 2, y: y - height / 2 };
}
return positions;
}