/** * Dagre-based auto-layout for workflow flow graphs. * * Wraps dagre with LR (left-to-right) and TB (top-to-bottom) presets, * assigning non-overlapping positions to xyflow nodes. Nodes with explicit * positions can be preserved (`preservePositions`), and edgeless / orphan * nodes are laid out by dagre alongside connected ones (dagre places * disconnected components without overlap). * * Pure-TS — no React. Operates on the structural FlowNode/FlowEdge shapes * from serialization.ts. */ import dagre from 'dagre'; import type { FlowNode, FlowEdge } from '../serialization'; export type LayoutDirection = 'LR' | 'TB'; export interface AutoLayoutOptions { /** Layout direction. Defaults to 'TB'. */ direction?: LayoutDirection; /** Default node width (px) for layout sizing. */ nodeWidth?: number; /** Default node height (px) for layout sizing. */ nodeHeight?: number; /** Horizontal spacing between ranks/nodes (px). */ rankSep?: number; /** Spacing between nodes in the same rank (px). */ nodeSep?: number; /** * When true, nodes whose source data already has an explicit position * keep it; only positionless nodes are laid out by dagre. */ preservePositions?: boolean; } const DEFAULT_NODE_WIDTH = 180; const DEFAULT_NODE_HEIGHT = 60; const DEFAULT_RANK_SEP = 80; const DEFAULT_NODE_SEP = 50; /** Whether this flow node carries an explicit (user-set) position. */ function hasExplicitPosition(node: FlowNode): boolean { return Boolean(node.data?.node?.position); } /** * Compute non-overlapping positions for the given nodes/edges. * * Returns new node objects with updated `position`; edges are returned * unchanged. The original arrays are not mutated. */ export function autoLayout( nodes: FlowNode[], edges: FlowEdge[], options: AutoLayoutOptions = {} ): { nodes: FlowNode[]; edges: FlowEdge[] } { const { direction = 'TB', nodeWidth = DEFAULT_NODE_WIDTH, nodeHeight = DEFAULT_NODE_HEIGHT, rankSep = DEFAULT_RANK_SEP, nodeSep = DEFAULT_NODE_SEP, preservePositions = false, } = options; if (nodes.length === 0) { return { nodes: [], edges }; } const g = new dagre.graphlib.Graph(); g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep, marginx: 20, marginy: 20, }); g.setDefaultEdgeLabel(() => ({})); const pinned = new Set(); for (const node of nodes) { const w = node.width ?? nodeWidth; const h = node.height ?? nodeHeight; g.setNode(node.id, { width: w, height: h }); if (preservePositions && hasExplicitPosition(node)) { pinned.add(node.id); } } for (const edge of edges) { // Only add edges whose endpoints are present (defensive). if (g.hasNode(edge.source) && g.hasNode(edge.target)) { g.setEdge(edge.source, edge.target); } } dagre.layout(g); const laidNodes: FlowNode[] = nodes.map((node) => { if (pinned.has(node.id)) { // Keep the explicit position verbatim. return { ...node, position: { ...(node.data.node.position as { x: number; y: number }) }, }; } const pos = g.node(node.id); const w = node.width ?? nodeWidth; const h = node.height ?? nodeHeight; // dagre returns center coords; convert to top-left for xyflow. return { ...node, position: { x: (pos?.x ?? 0) - w / 2, y: (pos?.y ?? 0) - h / 2, }, }; }); return { nodes: laidNodes, edges }; } /** Convenience: left-to-right layout. */ export function layoutLR( nodes: FlowNode[], edges: FlowEdge[], options: Omit = {} ) { return autoLayout(nodes, edges, { ...options, direction: 'LR' }); } /** Convenience: top-to-bottom layout. */ export function layoutTB( nodes: FlowNode[], edges: FlowEdge[], options: Omit = {} ) { return autoLayout(nodes, edges, { ...options, direction: 'TB' }); }