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;
}