/** * Dagre-based auto-layout for the flow canvas. * * The canvas reads `flow_nodes.position_x` / `position_y` (added in * migration 010 as `INTEGER NOT NULL DEFAULT 0` — reserved precisely * for this view). Brand-new flows and every flow authored before the * canvas shipped have all-zero positions, which would render as a * single overlapping pile at the origin. This module computes * reasonable starting positions in those cases. * * Why dagre over a hand-rolled BFS layout: branches with multiple * outgoing edges (send_buttons, condition, send_list) need horizontal * spread to be readable, and dagre's `rank`+`order` pass handles edge * crossings far better than anything we'd write by hand. ~30 KB gz * for the standalone wrapper, but the canvas already pulls in * @xyflow/react so this is incremental. * * What we do NOT do here: re-layout on every edit. The canvas * persists the user's drag positions, and we only ever auto-layout * once when `shouldAutoLayout()` returns true. Otherwise a user who * carefully arranged a flow would have their work overwritten on * reload. */ import Dagre from "@dagrejs/dagre"; export interface LayoutNode { id: string; /** Optional measured size — falls back to defaults if not provided. */ width?: number; height?: number; } export interface LayoutEdge { source: string; target: string; } export interface LayoutPosition { x: number; y: number; } export interface LayoutOptions { /** Top-to-bottom is the natural reading order for conversation flows. */ direction?: "TB" | "LR"; /** Gap between rows (TB) / columns (LR). */ rankSep?: number; /** Gap between sibling nodes within the same rank. */ nodeSep?: number; /** Default node width when a node's width isn't measured yet. */ defaultWidth?: number; /** Default node height when a node's height isn't measured yet. */ defaultHeight?: number; } const DEFAULTS: Required = { direction: "TB", rankSep: 80, nodeSep: 60, defaultWidth: 240, defaultHeight: 90, }; /** * True iff every node sits at the origin — the signal that no human * has positioned this flow yet and auto-layout is safe to run. * * Why `every`, not `some`: a partially-laid-out flow (some nodes at * 0,0, others positioned) is almost certainly mid-edit. Re-running * dagre would shuffle the positioned ones the user already chose. * Better to leave the new nodes at 0,0 and let the user drag them. */ export function shouldAutoLayout( nodes: Array<{ position_x?: number | null; position_y?: number | null }>, ): boolean { if (nodes.length === 0) return false; return nodes.every( (n) => (n.position_x ?? 0) === 0 && (n.position_y ?? 0) === 0, ); } /** * Compute positions for every node id. Returns a map keyed by node * id; consumers merge it into their React-Flow nodes array. The * returned coordinates are the TOP-LEFT corner (matches React-Flow's * coordinate space — dagre internally tracks centers, we translate). */ export function autoLayout( nodes: LayoutNode[], edges: LayoutEdge[], options: LayoutOptions = {}, ): Map { const opts = { ...DEFAULTS, ...options }; const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); g.setGraph({ rankdir: opts.direction, ranksep: opts.rankSep, nodesep: opts.nodeSep, }); for (const n of nodes) { g.setNode(n.id, { width: n.width ?? opts.defaultWidth, height: n.height ?? opts.defaultHeight, }); } for (const e of edges) { // Dagre tolerates edges to/from non-existent nodes by inserting // them as zero-size — that would silently warp the layout. Skip // dangling edges instead; the canvas's edge derivation already // filters them but defending here keeps this helper standalone. if (g.node(e.source) && g.node(e.target)) { g.setEdge(e.source, e.target); } } Dagre.layout(g); const positions = new Map(); for (const n of nodes) { const laid = g.node(n.id); if (!laid) continue; // Dagre returns the center; React-Flow wants the top-left. positions.set(n.id, { x: laid.x - (n.width ?? opts.defaultWidth) / 2, y: laid.y - (n.height ?? opts.defaultHeight) / 2, }); } return positions; }