@prisma-next/cli 0.12.0-dev.75 → 0.12.0-dev.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/dist/cli.mjs +5 -5
  2. package/dist/commands/migrate.mjs +2 -2
  3. package/dist/commands/migration-check.mjs +1 -1
  4. package/dist/commands/migration-graph.mjs +2 -2
  5. package/dist/commands/migration-list.mjs +1 -1
  6. package/dist/commands/migration-log.mjs +1 -1
  7. package/dist/commands/migration-status.mjs +1 -1
  8. package/dist/{migration-check-VwM8xCZV.mjs → migration-check-soB5uZEQ.mjs} +1 -2
  9. package/dist/{migration-check-VwM8xCZV.mjs.map → migration-check-soB5uZEQ.mjs.map} +1 -1
  10. package/dist/{migration-graph-command-render-BAOzyYF6.mjs → migration-graph-command-render-CEez7YUK.mjs} +217 -79
  11. package/dist/migration-graph-command-render-CEez7YUK.mjs.map +1 -0
  12. package/dist/{migration-list-CihF6w5z.mjs → migration-list-DlJJ_38Z.mjs} +2 -2
  13. package/dist/{migration-list-CihF6w5z.mjs.map → migration-list-DlJJ_38Z.mjs.map} +1 -1
  14. package/dist/{migration-log-B75IArji.mjs → migration-log-CG0qQAFm.mjs} +2 -2
  15. package/dist/{migration-log-B75IArji.mjs.map → migration-log-CG0qQAFm.mjs.map} +1 -1
  16. package/dist/{migration-status-CSVe6ZlD.mjs → migration-status-CgWSoI_g.mjs} +3 -4
  17. package/dist/{migration-status-CSVe6ZlD.mjs.map → migration-status-CgWSoI_g.mjs.map} +1 -1
  18. package/package.json +18 -18
  19. package/src/utils/formatters/migration-graph-grid-layout.ts +396 -119
  20. package/src/utils/formatters/migration-graph-labels.ts +7 -5
  21. package/src/utils/formatters/migration-graph-model.ts +9 -0
  22. package/src/utils/formatters/migration-graph-occlusion-render.ts +18 -5
  23. package/dist/migration-graph-command-render-BAOzyYF6.mjs.map +0 -1
@@ -7,16 +7,17 @@
7
7
  */
8
8
 
9
9
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
10
- import type {
11
- Cell,
12
- CellLine,
13
- Direction,
14
- Grid,
15
- GridOptions,
16
- Highlight,
17
- LineRef,
18
- NodeRef,
19
- PathRole,
10
+ import {
11
+ type Cell,
12
+ type CellLine,
13
+ DEFAULT_COLS_PER_LANE,
14
+ type Direction,
15
+ type Grid,
16
+ type GridOptions,
17
+ type Highlight,
18
+ type LineRef,
19
+ type NodeRef,
20
+ type PathRole,
20
21
  } from './migration-graph-model';
21
22
  import type { ClassifiedEdge, MigrationGraphRowModel } from './migration-graph-rows';
22
23
 
@@ -27,6 +28,14 @@ import type { ClassifiedEdge, MigrationGraphRowModel } from './migration-graph-r
27
28
  interface LaneAssignment {
28
29
  nodeLane: Map<string, number>;
29
30
  nodeRank: Map<string, number>;
31
+ /**
32
+ * Per-edge lane override. Set for "direct fork-to-merge" branch edges whose
33
+ * endpoints both land on lane 0 after merge reconciliation, but whose BFS
34
+ * traversal allocated them a non-zero branch lane. Using this override lets
35
+ * the branch edge render in its branch column even when the merge tip was
36
+ * pulled back to the trunk lane.
37
+ */
38
+ edgeLane: Map<string, number>;
30
39
  /** Total number of lanes allocated. */
31
40
  numLanes: number;
32
41
  }
@@ -35,15 +44,10 @@ function buildLaneAssignment(
35
44
  nodes: readonly (string | null)[],
36
45
  edges: readonly ClassifiedEdge[],
37
46
  ): LaneAssignment {
38
- const allNodes = new Set<string>();
39
- for (const n of nodes) {
40
- if (n !== null) allNodes.add(n);
41
- }
42
-
43
47
  // Separate forward (non-self) edges
44
48
  const fwdEdges = edges.filter((e) => e.kind === 'forward' && e.from !== e.to);
45
49
 
46
- // Build adjacency: outbound forward edges per node, sorted lex by migrationHash
50
+ // Build outbound/inbound adjacency sorted by dirName
47
51
  const outbound = new Map<string, ClassifiedEdge[]>();
48
52
  const inbound = new Map<string, ClassifiedEdge[]>();
49
53
  for (const edge of fwdEdges) {
@@ -58,14 +62,32 @@ function buildLaneAssignment(
58
62
  for (const list of outbound.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
59
63
  for (const list of inbound.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
60
64
 
61
- // Compute longest-forward-path rank from roots (tips get highest rank)
65
+ // Split nodes into per-component groups (null sentinels separate components)
66
+ const components: string[][] = [];
67
+ let current: string[] = [];
68
+ for (const n of nodes) {
69
+ if (n === null) {
70
+ if (current.length > 0) components.push(current);
71
+ current = [];
72
+ } else {
73
+ current.push(n);
74
+ }
75
+ }
76
+ if (current.length > 0) components.push(current);
77
+
78
+ // Global rank map (longest-forward-path; computed across all nodes together
79
+ // so rollback edges crossing components don't interfere with rank within each)
80
+ const allNodes = new Set<string>();
81
+ for (const n of nodes) {
82
+ if (n !== null) allNodes.add(n);
83
+ }
62
84
  const nodeRank = new Map<string, number>();
63
85
  for (const n of allNodes) nodeRank.set(n, 0);
64
86
  for (let pass = 0; pass < allNodes.size; pass++) {
65
87
  let changed = false;
66
- for (const [from, edges] of outbound) {
88
+ for (const [from, es] of outbound) {
67
89
  const base = nodeRank.get(from) ?? 0;
68
- for (const e of edges) {
90
+ for (const e of es) {
69
91
  const next = base + 1;
70
92
  if (next > (nodeRank.get(e.to) ?? 0)) {
71
93
  nodeRank.set(e.to, next);
@@ -76,53 +98,139 @@ function buildLaneAssignment(
76
98
  if (!changed) break;
77
99
  }
78
100
 
79
- // Lane assignment: BFS from roots, trunk keeps parent's lane
101
+ // Lane assignment: BFS per component, resetting nextLane to 0 for each.
102
+ // Each component's roots start at lane 0, so disconnected components never
103
+ // interleave lanes.
80
104
  const nodeLane = new Map<string, number>();
81
- let nextLane = 0;
105
+ // Per-edge lane: records the BFS-allocated branch lane for each edge. Used
106
+ // to preserve branch-column rendering even after merge-tip reconciliation.
107
+ const edgeLane = new Map<string, number>();
108
+ let totalLanes = 0;
109
+
110
+ for (const componentNodes of components) {
111
+ const componentSet = new Set(componentNodes);
112
+ let nextLane = 0;
113
+
114
+ const roots: string[] = [];
115
+ for (const n of componentNodes) {
116
+ if ((inbound.get(n) ?? []).length === 0) roots.push(n);
117
+ }
118
+ roots.sort((a, b) => {
119
+ if (a === EMPTY_CONTRACT_HASH) return -1;
120
+ if (b === EMPTY_CONTRACT_HASH) return 1;
121
+ return a.localeCompare(b);
122
+ });
82
123
 
83
- // Roots: nodes with no inbound forward edges
84
- const roots: string[] = [];
85
- for (const n of allNodes) {
86
- if ((inbound.get(n) ?? []).length === 0) roots.push(n);
87
- }
88
- roots.sort((a, b) => {
89
- if (a === EMPTY_CONTRACT_HASH) return -1;
90
- if (b === EMPTY_CONTRACT_HASH) return 1;
91
- return a.localeCompare(b);
92
- });
124
+ const bfsQueue: Array<{ node: string; lane: number }> = [];
125
+ for (const root of roots) {
126
+ if (!nodeLane.has(root)) {
127
+ nodeLane.set(root, nextLane++);
128
+ bfsQueue.push({ node: root, lane: nodeLane.get(root)! });
129
+ }
130
+ }
93
131
 
94
- const bfsQueue: Array<{ node: string; lane: number }> = [];
95
- for (const root of roots) {
96
- if (!nodeLane.has(root)) {
97
- nodeLane.set(root, nextLane++);
98
- bfsQueue.push({ node: root, lane: nodeLane.get(root)! });
99
- }
100
- }
101
-
102
- // BFS expansion
103
- let head = 0;
104
- while (head < bfsQueue.length) {
105
- const item = bfsQueue[head++]!;
106
- const { node, lane } = item;
107
- const children = outbound.get(node) ?? [];
108
- let first = true;
109
- for (const childEdge of children) {
110
- const child = childEdge.to;
111
- if (!nodeLane.has(child)) {
112
- const childLane = first ? lane : nextLane++;
113
- nodeLane.set(child, childLane);
114
- bfsQueue.push({ node: child, lane: childLane });
132
+ let head = 0;
133
+ while (head < bfsQueue.length) {
134
+ const item = bfsQueue[head++]!;
135
+ const { node, lane } = item;
136
+ const children = outbound.get(node) ?? [];
137
+ let first = true;
138
+ for (const childEdge of children) {
139
+ const child = childEdge.to;
140
+ if (!componentSet.has(child)) continue;
141
+ if (!nodeLane.has(child)) {
142
+ const childLane = first ? lane : nextLane++;
143
+ nodeLane.set(child, childLane);
144
+ bfsQueue.push({ node: child, lane: childLane });
145
+ edgeLane.set(childEdge.migrationHash, childLane);
146
+ } else {
147
+ // Child already assigned — record this edge's lane as the max of the
148
+ // parent's lane and the child's current lane (same as the original
149
+ // Math.max formula). May be updated by reconciliation below for trunk
150
+ // edges into reconciled merge nodes.
151
+ edgeLane.set(childEdge.migrationHash, Math.max(lane, nodeLane.get(child)!));
152
+ }
153
+ first = false;
154
+ }
155
+ }
156
+
157
+ // Isolated nodes within the component
158
+ for (const n of componentNodes) {
159
+ if (!nodeLane.has(n)) nodeLane.set(n, nextLane++);
160
+ }
161
+
162
+ // Merge-node lane reconciliation: a node with multiple inbound forward edges
163
+ // should sit on the lane of its highest-rank parent (furthest along the
164
+ // longest path). When a short arm and a long arm converge, the merge node
165
+ // follows the long arm's lane.
166
+ //
167
+ // When a merge node's lane changes, update the edgeLane for all edges
168
+ // pointing TO that node so they reflect the reconciled column. Edges from
169
+ // nodes that were on a BRANCH (non-trunk) lane keep their original branch
170
+ // lane so the branch column renders correctly.
171
+ for (const n of componentNodes) {
172
+ const parents = inbound.get(n);
173
+ if (!parents || parents.length <= 1) continue;
174
+ let trunkParent = parents[0]!.from;
175
+ let trunkRank = nodeRank.get(trunkParent) ?? 0;
176
+ let trunkLane = nodeLane.get(trunkParent) ?? 0;
177
+ for (let i = 1; i < parents.length; i++) {
178
+ const parent = parents[i]!.from;
179
+ const rank = nodeRank.get(parent) ?? 0;
180
+ const lane = nodeLane.get(parent) ?? 0;
181
+ if (rank > trunkRank || (rank === trunkRank && lane < trunkLane)) {
182
+ trunkParent = parent;
183
+ trunkRank = rank;
184
+ trunkLane = lane;
185
+ }
186
+ }
187
+ const trunkParentLane = nodeLane.get(trunkParent) ?? 0;
188
+ const currentNodeLane = nodeLane.get(n) ?? 0;
189
+ if (currentNodeLane === trunkParentLane) continue;
190
+
191
+ nodeLane.set(n, trunkParentLane);
192
+
193
+ // Update edgeLane for each inbound edge:
194
+ // - Trunk edge (from the highest-rank parent): use the trunk lane
195
+ // - Branch edges: keep the ORIGINAL edgeLane (the branch column), so
196
+ // the branch edge still renders in its allocated branch column.
197
+ for (const parentEdge of parents) {
198
+ const isFromTrunkParent = parentEdge.from === trunkParent;
199
+ if (isFromTrunkParent) {
200
+ edgeLane.set(parentEdge.migrationHash, trunkParentLane);
201
+ }
202
+ // Branch edges keep whatever lane they were assigned during BFS.
203
+ }
204
+
205
+ // Propagate the lane change to forward descendants that inherited the
206
+ // old lane. BFS from this merge node through outbound edges: any
207
+ // descendant still on oldLane moves to the new (trunk) lane. Stop the
208
+ // traversal at nodes that are already on a different lane — they belong
209
+ // to branches that forked independently and must not move.
210
+ const bfsDescendants: string[] = [n];
211
+ let descHead = 0;
212
+ while (descHead < bfsDescendants.length) {
213
+ const current = bfsDescendants[descHead++]!;
214
+ const children = outbound.get(current) ?? [];
215
+ for (const childEdge of children) {
216
+ const child = childEdge.to;
217
+ if (!componentSet.has(child)) continue;
218
+ if ((nodeLane.get(child) ?? 0) !== currentNodeLane) continue;
219
+ nodeLane.set(child, trunkParentLane);
220
+ // Update the edge lane for the edge from current→child
221
+ const existingEdgeLane = edgeLane.get(childEdge.migrationHash);
222
+ if (existingEdgeLane !== undefined && existingEdgeLane === currentNodeLane) {
223
+ edgeLane.set(childEdge.migrationHash, trunkParentLane);
224
+ }
225
+ bfsDescendants.push(child);
226
+ }
115
227
  }
116
- first = false;
117
228
  }
118
- }
119
229
 
120
- // Isolated nodes (no edges) get their own lane
121
- for (const n of allNodes) {
122
- if (!nodeLane.has(n)) nodeLane.set(n, nextLane++);
230
+ if (nextLane > totalLanes) totalLanes = nextLane;
123
231
  }
124
232
 
125
- return { nodeLane, nodeRank, numLanes: nextLane };
233
+ return { nodeLane, nodeRank, edgeLane, numLanes: totalLanes };
126
234
  }
127
235
 
128
236
  // ---------------------------------------------------------------------------
@@ -135,20 +243,42 @@ interface NodeDisplay {
135
243
  rank: number;
136
244
  }
137
245
 
246
+ /**
247
+ * A `null` sentinel in the display order marks a component boundary.
248
+ * The grid builder emits a separator row at each boundary.
249
+ */
250
+ type NodeDisplayOrSeparator = NodeDisplay | null;
251
+
138
252
  function computeDisplayOrder(
139
253
  nodes: readonly (string | null)[],
140
254
  nodeLane: Map<string, number>,
141
255
  nodeRank: Map<string, number>,
142
- ): NodeDisplay[] {
256
+ ): NodeDisplayOrSeparator[] {
143
257
  const seen = new Set<string>();
144
- const result: NodeDisplay[] = [];
258
+ const result: NodeDisplayOrSeparator[] = [];
259
+
260
+ // Collect each component's nodes then sort within it (rank desc, lane asc).
261
+ // null sentinels mark component boundaries; they become separator entries.
262
+ let componentBuffer: NodeDisplay[] = [];
263
+
264
+ function flushComponent(): void {
265
+ componentBuffer.sort((a, b) => b.rank - a.rank || a.lane - b.lane);
266
+ for (const d of componentBuffer) result.push(d);
267
+ componentBuffer = [];
268
+ }
269
+
145
270
  for (const n of nodes) {
146
- if (n === null || seen.has(n)) continue;
271
+ if (n === null) {
272
+ flushComponent();
273
+ result.push(null);
274
+ continue;
275
+ }
276
+ if (seen.has(n)) continue;
147
277
  seen.add(n);
148
- result.push({ hash: n, lane: nodeLane.get(n) ?? 0, rank: nodeRank.get(n) ?? 0 });
278
+ componentBuffer.push({ hash: n, lane: nodeLane.get(n) ?? 0, rank: nodeRank.get(n) ?? 0 });
149
279
  }
150
- // Tips first (rank desc), within same rank lane asc
151
- result.sort((a, b) => b.rank - a.rank || a.lane - b.lane);
280
+ flushComponent();
281
+
152
282
  return result;
153
283
  }
154
284
 
@@ -172,18 +302,24 @@ export function buildGrid(
172
302
  opts: GridOptions = {},
173
303
  highlight: Highlight = { mode: 'flat', onPath: new Set() },
174
304
  ): Grid {
175
- const colsPerLane = opts.colsPerLane ?? 2;
305
+ const colsPerLane = opts.colsPerLane ?? DEFAULT_COLS_PER_LANE;
176
306
  const isFocus = highlight.mode === 'focus';
177
307
 
178
- const { nodeLane, nodeRank, numLanes } = buildLaneAssignment(rowModel.nodes, rowModel.edges);
308
+ const { nodeLane, nodeRank, edgeLane, numLanes } = buildLaneAssignment(
309
+ rowModel.nodes,
310
+ rowModel.edges,
311
+ );
179
312
 
180
313
  const displayOrder = computeDisplayOrder(rowModel.nodes, nodeLane, nodeRank);
181
314
 
182
- // Display index per node (0 = topmost row).
315
+ // Display index per node (0 = topmost position; nulls skipped).
183
316
  const displayIndex = new Map<string, number>();
184
- displayOrder.forEach((d, i) => {
185
- displayIndex.set(d.hash, i);
186
- });
317
+ let nodeIdx = 0;
318
+ for (const d of displayOrder) {
319
+ if (d !== null) {
320
+ displayIndex.set(d.hash, nodeIdx++);
321
+ }
322
+ }
187
323
 
188
324
  // ── Back-arc planning ────────────────────────────────────────────────────
189
325
  // Each rollback edge runs against the forward grain. An *adjacent* rollback
@@ -192,22 +328,26 @@ export function buildGrid(
192
328
  // back-lane to the right: it tees off the source node row (○─╮), runs a
193
329
  // vertical │ down its back-lane, and lands into the target node (◂╯).
194
330
  //
195
- // Two independent numbers per routed back-arc:
331
+ // Three independent numbers per routed back-arc:
196
332
  // geomLane — the column its rail occupies. Outermost (largest) goes to the
197
333
  // arc reaching the lowest target (ties: higher source first), so
198
334
  // interleaving spans cross and nested spans nest cleanly.
199
- // colourLane — the lane index used purely for colour. Assigned by
200
- // migrationHash order, continuing after the forward lanes, so the
201
- // first rollback is lane numLanes, the next numLanes+1, etc.
202
- // These differ whenever two arcs interleave (rollback-cross): the inner column
203
- // may carry the higher colour. Colour is read off LineRef.lane; the column is
204
- // where the cell is placed.
335
+ // colourLane — the lane index used purely for colour (flat mode). Assigned
336
+ // by greedy colouring (bottom-up walk; see below) so that no
337
+ // two concurrently-active lanes/arcs share a palette colour,
338
+ // and no arc reuses its origin branch's colour or green.
339
+ // planeLane — the z-order index for occlusion within a shared back-lane.
340
+ // Arcs sharing the same geomLane are sorted by sourceIndex
341
+ // descending: the arc whose source is lowest in display
342
+ // (largest sourceIndex = bottom-most visually) draws on top
343
+ // (smallest planeLane number). Decoupled from colourLane.
205
344
  interface RoutedBackArc {
206
345
  readonly edge: ClassifiedEdge;
207
346
  readonly sourceIndex: number;
208
347
  readonly targetIndex: number;
209
348
  readonly geomLane: number;
210
349
  readonly colourLane: number;
350
+ readonly planeLane: number;
211
351
  }
212
352
 
213
353
  const rollbackEdges = rowModel.edges.filter((e) => e.kind === 'rollback' && e.from !== e.to);
@@ -223,37 +363,146 @@ export function buildGrid(
223
363
  else skippingRollbacks.push(e);
224
364
  }
225
365
 
226
- // colourLane by migration NAME (dirName) order chronological, not hash.
227
- const colourLaneOf = new Map<string, number>();
228
- [...skippingRollbacks]
229
- .sort((a, b) => a.dirName.localeCompare(b.dirName))
230
- .forEach((e, i) => {
231
- colourLaneOf.set(e.migrationHash, numLanes + i);
232
- });
233
-
234
- // geomLane: outermost rail to the arc with the lowest target (largest target
235
- // index); ties broken by the highest source (smallest source index). The first
236
- // in this order gets the outermost (largest) geometric lane.
237
- const geomOrder = [...skippingRollbacks].sort((a, b) => {
238
- const ta = displayIndex.get(a.to) ?? 0;
239
- const tb = displayIndex.get(b.to) ?? 0;
240
- if (ta !== tb) return tb - ta; // lower target (larger index) first
241
- const sa = displayIndex.get(a.from) ?? 0;
242
- const sb = displayIndex.get(b.from) ?? 0;
243
- return sa - sb; // higher source (smaller index) first
366
+ // Convergence: group skipping rollbacks by their target node. Arcs sharing a
367
+ // target share one geometric lane (rail column). Each distinct target gets its
368
+ // own rail; arcs within the group compose via occlusion.
369
+ //
370
+ // geomLane ordering: outermost rail goes to the group whose target is lowest
371
+ // in display order (largest target index — deepest in the chain). Within a
372
+ // group, the group's representative target index drives the ordering.
373
+ const targetGroups = new Map<string, ClassifiedEdge[]>();
374
+ for (const e of skippingRollbacks) {
375
+ const group = targetGroups.get(e.to);
376
+ if (group) group.push(e);
377
+ else targetGroups.set(e.to, [e]);
378
+ }
379
+ // Sort target-group keys: largest target index (lowest in display) outermost lane.
380
+ const sortedTargetKeys = [...targetGroups.keys()].sort((a, b) => {
381
+ const ta = displayIndex.get(a) ?? 0;
382
+ const tb = displayIndex.get(b) ?? 0;
383
+ return tb - ta; // largest index first = outermost
244
384
  });
385
+ const numTargetGroups = sortedTargetKeys.length;
245
386
  const geomLaneOf = new Map<string, number>();
246
- const outermost = numLanes + skippingRollbacks.length - 1;
247
- geomOrder.forEach((e, i) => {
248
- geomLaneOf.set(e.migrationHash, outermost - i);
387
+ const outermostGroup = numLanes + numTargetGroups - 1;
388
+ sortedTargetKeys.forEach((targetHash, i) => {
389
+ const groupGeomLane = outermostGroup - i;
390
+ for (const e of targetGroups.get(targetHash)!) {
391
+ geomLaneOf.set(e.migrationHash, groupGeomLane);
392
+ }
249
393
  });
250
394
 
395
+ // ── planeLane: z-order for back-arcs ────────────────────────────────────
396
+ // The arc whose source is furthest down the display (largest sourceIndex)
397
+ // draws on top (lowest planeLane). This applies both within shared back-lanes
398
+ // and at crossing points where arcs on different geomLanes overlap.
399
+ // planeLane = totalNodes - sourceIndex gives: larger sourceIndex → smaller value.
400
+ const totalDisplayNodes = displayOrder.filter((d) => d !== null).length;
401
+ const planeLaneOf = new Map<string, number>();
402
+ for (const e of skippingRollbacks) {
403
+ const si = displayIndex.get(e.from) ?? 0;
404
+ planeLaneOf.set(e.migrationHash, totalDisplayNodes - si);
405
+ }
406
+
407
+ // ── colourLane: greedy assignment (flat mode) ─────────────────────────────
408
+ // Walk displayOrder bottom → top. Maintain the set of concurrently-active
409
+ // palette-colour indices (forward lanes + active back-arc assignments). When
410
+ // a new arc first becomes visible (at its target node, going upward), pick the
411
+ // lowest palette index not in use. Additionally exclude:
412
+ // - the arc's origin lane's colour (nodeLane.get(from) % PALETTE_SIZE)
413
+ // - index 5 (green — reserved for focus on-path)
414
+ // When the arc's source node is processed, release its colour.
415
+ //
416
+ // Forward lanes hold colour = laneIndex % PALETTE_SIZE (unchanged); back-arc
417
+ // colourLane is set to the chosen palette index directly (0–5), so that
418
+ // `colourLane % 6 == chosenIndex`.
419
+ const PALETTE_SIZE = 6;
420
+ const GREEN_PALETTE_IDX = 5;
421
+
422
+ // Precompute per-arc display indices for the walk.
423
+ const arcSourceIndex = new Map<string, number>();
424
+ const arcTargetIndex = new Map<string, number>();
425
+ for (const e of skippingRollbacks) {
426
+ arcSourceIndex.set(e.migrationHash, displayIndex.get(e.from) ?? 0);
427
+ arcTargetIndex.set(e.migrationHash, displayIndex.get(e.to) ?? 0);
428
+ }
429
+
430
+ // Build lookup: arcs by target node hash and source node hash.
431
+ const arcsByTarget = new Map<string, ClassifiedEdge[]>();
432
+ const arcsBySource = new Map<string, ClassifiedEdge[]>();
433
+ for (const e of skippingRollbacks) {
434
+ const tb = arcsByTarget.get(e.to);
435
+ if (tb) tb.push(e);
436
+ else arcsByTarget.set(e.to, [e]);
437
+ const sb = arcsBySource.get(e.from);
438
+ if (sb) sb.push(e);
439
+ else arcsBySource.set(e.from, [e]);
440
+ }
441
+
442
+ // Greedy walk: bottom → top through displayOrder.
443
+ const colourLaneOf = new Map<string, number>();
444
+ // activeArcColours: migHash → palette index currently in use by that arc.
445
+ const activeArcColours = new Map<string, number>();
446
+ // activeFwdLaneColours: set of palette indices held by currently-active forward lanes.
447
+ const activeFwdLaneColours = new Set<number>();
448
+
449
+ for (let i = displayOrder.length - 1; i >= 0; i--) {
450
+ const nd = displayOrder[i];
451
+ if (nd === null || nd === undefined) continue; // separator or missing — skip
452
+
453
+ const { hash: nodeHash } = nd;
454
+ const nodeFwdLane = nodeLane.get(nodeHash) ?? 0;
455
+
456
+ // 1. Activate this node's forward lane (if not already active from a lower node).
457
+ activeFwdLaneColours.add(nodeFwdLane % PALETTE_SIZE);
458
+
459
+ // 2. Assign colour to arcs that TARGET this node. They become visible
460
+ // starting here, running upward to their source.
461
+ const incomingArcs = arcsByTarget.get(nodeHash) ?? [];
462
+ // Process in a stable order (dirName) for determinism.
463
+ const sortedIncoming = [...incomingArcs].sort((a, b) => a.dirName.localeCompare(b.dirName));
464
+ for (const arc of sortedIncoming) {
465
+ const originLaneColour = (nodeLane.get(arc.from) ?? 0) % PALETTE_SIZE;
466
+ // Colours currently occupied.
467
+ const occupied = new Set<number>(activeFwdLaneColours);
468
+ for (const c of activeArcColours.values()) occupied.add(c);
469
+ occupied.add(GREEN_PALETTE_IDX);
470
+ occupied.add(originLaneColour);
471
+ // Pick the lowest free index; if all are taken, pick lowest excluding green.
472
+ let chosen = -1;
473
+ for (let ci = 0; ci < PALETTE_SIZE; ci++) {
474
+ if (!occupied.has(ci)) {
475
+ chosen = ci;
476
+ break;
477
+ }
478
+ }
479
+ if (chosen === -1) {
480
+ // Palette exhausted — forced reuse. Pick lowest excluding green.
481
+ for (let ci = 0; ci < PALETTE_SIZE; ci++) {
482
+ if (ci !== GREEN_PALETTE_IDX) {
483
+ chosen = ci;
484
+ break;
485
+ }
486
+ }
487
+ }
488
+ colourLaneOf.set(arc.migrationHash, chosen === -1 ? 0 : chosen);
489
+ activeArcColours.set(arc.migrationHash, chosen === -1 ? 0 : chosen);
490
+ }
491
+
492
+ // 3. Release arcs that SOURCE at this node. Their rail runs from here
493
+ // downward; above this node they're gone.
494
+ for (const arc of arcsBySource.get(nodeHash) ?? []) {
495
+ activeArcColours.delete(arc.migrationHash);
496
+ }
497
+ }
498
+
251
499
  const routedBackArcs: RoutedBackArc[] = skippingRollbacks.map((e) => ({
252
500
  edge: e,
253
501
  sourceIndex: displayIndex.get(e.from) ?? 0,
254
502
  targetIndex: displayIndex.get(e.to) ?? 0,
255
503
  geomLane: geomLaneOf.get(e.migrationHash) ?? numLanes,
256
- colourLane: colourLaneOf.get(e.migrationHash) ?? numLanes,
504
+ colourLane: colourLaneOf.get(e.migrationHash) ?? 0,
505
+ planeLane: planeLaneOf.get(e.migrationHash) ?? numLanes,
257
506
  }));
258
507
 
259
508
  const backArcsBySource = new Map<string, RoutedBackArc[]>();
@@ -280,7 +529,7 @@ export function buildGrid(
280
529
  for (const list of adjacentBySource.values())
281
530
  list.sort((a, b) => a.dirName.localeCompare(b.dirName));
282
531
 
283
- const numBackLanes = skippingRollbacks.length;
532
+ const numBackLanes = numTargetGroups;
284
533
  const totalCols = (numLanes + numBackLanes) * colsPerLane;
285
534
 
286
535
  // Build edge lookup maps (classified)
@@ -430,8 +679,8 @@ export function buildGrid(
430
679
 
431
680
  function backArcPlane(arc: RoutedBackArc): number {
432
681
  const role = roleOf(arc.edge.migrationHash);
433
- if (!isFocus) return arc.colourLane;
434
- return role === 'on-path' ? 0 : arc.colourLane + 1;
682
+ if (!isFocus) return arc.planeLane;
683
+ return role === 'on-path' ? 0 : arc.planeLane + 1;
435
684
  }
436
685
 
437
686
  // Compose a CellLine into a row cell (never overwrite — occlusion arbitrates).
@@ -638,8 +887,16 @@ export function buildGrid(
638
887
  return row;
639
888
  }
640
889
 
641
- // Process each node in display order
890
+ // Process each node in display order; null = component boundary → separator row
642
891
  for (const nodeDisplay of displayOrder) {
892
+ if (nodeDisplay === null) {
893
+ // Emit one blank separator row between disconnected components.
894
+ const sepRow = makeRow();
895
+ sepRow[0] = { lines: [], separator: true };
896
+ grid.push(sepRow);
897
+ continue;
898
+ }
899
+
643
900
  const { hash: nodeHash } = nodeDisplay;
644
901
  const nodeLaneNum = nodeLane.get(nodeHash) ?? 0;
645
902
 
@@ -648,10 +905,17 @@ export function buildGrid(
648
905
  // ── 1. Fork connector (BEFORE the node row) ──────────────────────────
649
906
  const outEdges = outboundFwd.get(nodeHash) ?? [];
650
907
  if (outEdges.length > 1) {
651
- const trunkChildLane = nodeLane.get(outEdges[0]!.to) ?? nodeLaneNum;
908
+ // Use the per-edge lane for branch children so that "direct fork-to-merge"
909
+ // edges (whose target was reconciled back to trunk lane) still appear in
910
+ // their allocated branch column.
911
+ const trunkEdgeForFork = outEdges[0]!;
912
+ const trunkChildLane =
913
+ edgeLane.get(trunkEdgeForFork.migrationHash) ??
914
+ nodeLane.get(trunkEdgeForFork.to) ??
915
+ nodeLaneNum;
652
916
  const branchEntries = outEdges
653
917
  .slice(1)
654
- .map((e) => ({ lane: nodeLane.get(e.to) ?? 0, edge: e }))
918
+ .map((e) => ({ lane: edgeLane.get(e.migrationHash) ?? nodeLane.get(e.to) ?? 0, edge: e }))
655
919
  .filter((b) => b.lane !== trunkChildLane && activeLanes.has(b.lane));
656
920
 
657
921
  if (branchEntries.length > 0) {
@@ -742,11 +1006,28 @@ export function buildGrid(
742
1006
  // its lane's current edge NOW (before emitting the back-arc arrow rows, merge
743
1007
  // connector, and migration rows) so pass-through verticals colour from the
744
1008
  // forward edge actually occupying the trunk below this node.
1009
+ //
1010
+ // edgeLaneFor: resolve the lane for an inbound forward edge. Uses the
1011
+ // per-edge override from edgeLane (set during BFS for branch edges) when
1012
+ // available; falls back to Max(fromLane, toLane) for edges not in the map.
1013
+ function edgeLaneFor(edge: ClassifiedEdge): number {
1014
+ const override = edgeLane.get(edge.migrationHash);
1015
+ if (override !== undefined) return override;
1016
+ return Math.max(nodeLane.get(edge.from) ?? 0, nodeLane.get(edge.to) ?? 0);
1017
+ }
1018
+
1019
+ // Sort inEdges so the trunk edge (lowest edgeLane = trunk column) comes
1020
+ // first. Ties broken by dirName. This ensures the merge connector treats
1021
+ // the trunk-column edge as the trunk regardless of alphabetical order.
745
1022
  const inEdges = inboundFwd.get(nodeHash) ?? [];
746
- inEdges.sort((a, b) => a.dirName.localeCompare(b.dirName));
1023
+ inEdges.sort((a, b) => {
1024
+ const aLane = edgeLaneFor(a);
1025
+ const bLane = edgeLaneFor(b);
1026
+ if (aLane !== bLane) return aLane - bLane;
1027
+ return a.dirName.localeCompare(b.dirName);
1028
+ });
747
1029
  for (const edge of inEdges) {
748
- const edgeLane = Math.max(nodeLane.get(edge.from) ?? 0, nodeLane.get(edge.to) ?? 0);
749
- laneCurrentEdge.set(edgeLane, edge);
1030
+ laneCurrentEdge.set(edgeLaneFor(edge), edge);
750
1031
  }
751
1032
 
752
1033
  // ── 3b. Back-arc arrow rows ──────────────────────────────────────────────
@@ -771,9 +1052,7 @@ export function buildGrid(
771
1052
 
772
1053
  // ── 4. Merge connector (AFTER the node row) ────────────────────────────
773
1054
  if (inEdges.length > 1) {
774
- const branchEntries = inEdges
775
- .slice(1)
776
- .map((e) => ({ lane: nodeLane.get(e.from) ?? 0, edge: e }));
1055
+ const branchEntries = inEdges.slice(1).map((e) => ({ lane: edgeLaneFor(e), edge: e }));
777
1056
 
778
1057
  const trunkEdge = inEdges[0];
779
1058
  const connRow = emitConnectorRow(nodeLaneNum, branchEntries, 'merge', trunkEdge);
@@ -783,20 +1062,18 @@ export function buildGrid(
783
1062
  for (const b of branchEntries) activeLanes.add(b.lane);
784
1063
  }
785
1064
 
786
- // ── 5. Migration rows (one per inbound edge, ordered by migration hash)
1065
+ // ── 5. Migration rows (one per inbound edge, ordered by edge lane) ─────
787
1066
  for (const edge of inEdges) {
788
- const fromLane = nodeLane.get(edge.from) ?? 0;
789
- const toLane = nodeLane.get(edge.to) ?? 0;
790
- const edgeLane = Math.max(fromLane, toLane);
1067
+ const eLane = edgeLaneFor(edge);
791
1068
  const row = makeRow();
792
- const railCol = edgeLane * colsPerLane;
793
- const connCol = edgeLane * colsPerLane + 1;
794
- const line = lineRefFor(edge, edgeLane);
1069
+ const railCol = eLane * colsPerLane;
1070
+ const connCol = eLane * colsPerLane + 1;
1071
+ const line = lineRefFor(edge, eLane);
795
1072
 
796
1073
  row[railCol] = vertCell(line);
797
1074
  row[connCol] = dirCell(line, new Set<Direction>(['up']));
798
1075
 
799
- placeVerticals(row, new Set([edgeLane]));
1076
+ placeVerticals(row, new Set([eLane]));
800
1077
  placeBackVerticals(row);
801
1078
  grid.push(row);
802
1079
  }