1111 */
1212
1313import dagre from "@dagrejs/dagre" ;
14- import type { Edge , Node } from "@xyflow/react" ;
14+ import { type Edge , MarkerType , type Node } from "@xyflow/react" ;
1515import type { GraphData , GraphEdge } from "./types" ;
1616
1717// ---------------------------------------------------------------------------
1818// Sizing — must match the React node components in `assets/src/react/nodes`.
19- // Every node is a uniform 88×96 cell (64×64 icon box + label region below)
20- // for the n8n-inspired rhythm. See DESIGN.md §11.
19+ // Horizontal status cards (Argo/Dagster/GitHub-style), uniform height so the
20+ // LR ranks read like an execution timeline. `child_workflow` nodes share the
21+ // step cell's exact footprint — they're the same card with a stacked sheet
22+ // behind it (the drill-in signature), which only peeks a few px beyond the
23+ // box and so doesn't change the dagre footprint. See DESIGN.md §11.
2124// ---------------------------------------------------------------------------
22- const CELL_WIDTH = 88 ;
23- const CELL_HEIGHT = 96 ;
24-
25- const NODESEP = 40 ;
26- const RANKSEP = 80 ;
27- const MARGIN = 32 ;
25+ const CELL_WIDTH = 200 ;
26+ const CELL_HEIGHT = 56 ;
27+
28+ // Start/End render as small terminal pills, not full cards.
29+ const TERMINAL_WIDTH = 52 ;
30+ const TERMINAL_HEIGHT = 28 ;
31+
32+ // Spacing chosen to keep neighbouring rows visually separate even when
33+ // goto branches force dagre to splay nodes onto extra rows. Previous
34+ // 40/80 was too tight (sub-row goto targets dipped *into* the main
35+ // lane); 60/110 was an improvement but still left branch-merge
36+ // patterns reading as "node sitting in a half-row dip." The current
37+ // 100/120 makes a goto sub-lane read as deliberate vertical
38+ // separation rather than misalignment.
39+ // Cards are wider + uniform-height now, so they need less vertical splay;
40+ // near-equal rank/node gaps read grid-like (Argo's ranksep≈nodesep insight).
41+ const NODESEP = 70 ;
42+ const RANKSEP = 110 ;
43+ const MARGIN = 40 ;
2844
2945const START_NODE_ID = "__durable_start__" ;
3046const END_NODE_ID = "__durable_end__" ;
3147
48+ const ARROW_MARKER = {
49+ type : MarkerType . ArrowClosed ,
50+ width : 14 ,
51+ height : 14 ,
52+ color : "var(--muted-foreground)" ,
53+ } ;
54+
3255interface Dim {
3356 width : number ;
3457 height : number ;
3558}
3659
37- // Every node renders as the same 88×96 cell (64 icon + 32 label). Keeps
38- // the visual rhythm and makes dagre's collision math trivial.
39- function dimsFor ( _type : string ) : Dim {
60+ function dimsFor ( type : string ) : Dim {
61+ if ( type === "start" || type === "end" ) {
62+ return { width : TERMINAL_WIDTH , height : TERMINAL_HEIGHT } ;
63+ }
64+ // child_workflow shares the step cell footprint (see sizing note above).
4065 return { width : CELL_WIDTH , height : CELL_HEIGHT } ;
4166}
4267
4368// ---------------------------------------------------------------------------
4469// Public entry point
4570// ---------------------------------------------------------------------------
4671
47- export function layoutGraph ( graph : GraphData ) : { nodes : Node [ ] ; edges : Edge [ ] } {
72+ type PositionMap = Map < string , { x : number ; y : number } > ;
73+
74+ /**
75+ * Lay out a graph for ReactFlow.
76+ *
77+ * `prevPositions` is the position map from a previous layout of the SAME
78+ * topology. When supplied and complete (covers every node we're about to
79+ * render, including the synthesized start/end markers), dagre is skipped
80+ * and the cached coordinates are reused verbatim. This is what stops the
81+ * canvas from re-laying-out and snapping nodes on every realtime status
82+ * tick during an active run — the bug that made the graph "jump around"
83+ * while a workflow was executing. The caller only passes `prevPositions`
84+ * when the topology signature (node ids + types + edge ids) is unchanged,
85+ * so reusing positions is always geometrically correct here.
86+ */
87+ export function layoutGraph (
88+ graph : GraphData ,
89+ prevPositions ?: PositionMap ,
90+ ) : { nodes : Node [ ] ; edges : Edge [ ] ; positions : PositionMap } {
4891 // Synthesize start/end markers wrapping the whole flow.
4992 const allNodes = [
5093 ...graph . nodes . map ( ( n ) => ( { id : n . id , type : n . type , data : n . data } ) ) ,
@@ -60,7 +103,8 @@ export function layoutGraph(graph: GraphData): { nodes: Node[]; edges: Edge[] }
60103 ...leafIds . map ( ( l ) => boundaryEdge ( l , END_NODE_ID ) ) ,
61104 ] ;
62105
63- const positions = layoutDagre ( allNodes , allEdges ) ;
106+ const canReuse = prevPositions !== undefined && allNodes . every ( ( n ) => prevPositions . has ( n . id ) ) ;
107+ const positions = canReuse ? ( prevPositions as PositionMap ) : layoutDagre ( allNodes , allEdges ) ;
64108
65109 const nodes : Node [ ] = allNodes . map ( ( n ) => {
66110 const pos = positions . get ( n . id ) || { x : 0 , y : 0 } ;
@@ -81,9 +125,13 @@ export function layoutGraph(graph: GraphData): { nodes: Node[]; edges: Edge[] }
81125 label : e . label ,
82126 className : e . className ,
83127 type : "animated_flow" ,
128+ // A subtle arrowhead reinforces execution direction. Neutral fill (a CSS
129+ // var so it tracks the theme) keeps status legible on the stroke, not the
130+ // marker. Suppressed into the END terminal so arrows don't pile on it.
131+ markerEnd : e . target === END_NODE_ID ? undefined : ARROW_MARKER ,
84132 } ) ) ;
85133
86- return { nodes, edges } ;
134+ return { nodes, edges, positions } ;
87135}
88136
89137// ---------------------------------------------------------------------------
@@ -135,6 +183,12 @@ function layoutDagre(
135183 ranksep : RANKSEP ,
136184 marginx : MARGIN ,
137185 marginy : MARGIN ,
186+ // `tight-tree` produces visibly cleaner layouts for branch-and-merge
187+ // workflows than the default `network-simplex`: it keeps merging
188+ // siblings on a clearly-distinct sub-lane rather than dropping them
189+ // into a half-row dip below the main flow. n8n's editor uses a
190+ // similar layered approach.
191+ ranker : "tight-tree" ,
138192 } ) ;
139193
140194 for ( const n of nodes ) {
0 commit comments