@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.
- package/dist/cli.mjs +5 -5
- package/dist/commands/migrate.mjs +2 -2
- package/dist/commands/migration-check.mjs +1 -1
- package/dist/commands/migration-graph.mjs +2 -2
- package/dist/commands/migration-list.mjs +1 -1
- package/dist/commands/migration-log.mjs +1 -1
- package/dist/commands/migration-status.mjs +1 -1
- package/dist/{migration-check-VwM8xCZV.mjs → migration-check-soB5uZEQ.mjs} +1 -2
- package/dist/{migration-check-VwM8xCZV.mjs.map → migration-check-soB5uZEQ.mjs.map} +1 -1
- package/dist/{migration-graph-command-render-BAOzyYF6.mjs → migration-graph-command-render-CEez7YUK.mjs} +217 -79
- package/dist/migration-graph-command-render-CEez7YUK.mjs.map +1 -0
- package/dist/{migration-list-CihF6w5z.mjs → migration-list-DlJJ_38Z.mjs} +2 -2
- package/dist/{migration-list-CihF6w5z.mjs.map → migration-list-DlJJ_38Z.mjs.map} +1 -1
- package/dist/{migration-log-B75IArji.mjs → migration-log-CG0qQAFm.mjs} +2 -2
- package/dist/{migration-log-B75IArji.mjs.map → migration-log-CG0qQAFm.mjs.map} +1 -1
- package/dist/{migration-status-CSVe6ZlD.mjs → migration-status-CgWSoI_g.mjs} +3 -4
- package/dist/{migration-status-CSVe6ZlD.mjs.map → migration-status-CgWSoI_g.mjs.map} +1 -1
- package/package.json +18 -18
- package/src/utils/formatters/migration-graph-grid-layout.ts +396 -119
- package/src/utils/formatters/migration-graph-labels.ts +7 -5
- package/src/utils/formatters/migration-graph-model.ts +9 -0
- package/src/utils/formatters/migration-graph-occlusion-render.ts +18 -5
- 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
|
|
11
|
-
Cell,
|
|
12
|
-
CellLine,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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
|
-
//
|
|
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,
|
|
88
|
+
for (const [from, es] of outbound) {
|
|
67
89
|
const base = nodeRank.get(from) ?? 0;
|
|
68
|
-
for (const e of
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
):
|
|
256
|
+
): NodeDisplayOrSeparator[] {
|
|
143
257
|
const seen = new Set<string>();
|
|
144
|
-
const result:
|
|
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
|
|
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
|
-
|
|
278
|
+
componentBuffer.push({ hash: n, lane: nodeLane.get(n) ?? 0, rank: nodeRank.get(n) ?? 0 });
|
|
149
279
|
}
|
|
150
|
-
|
|
151
|
-
|
|
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 ??
|
|
305
|
+
const colsPerLane = opts.colsPerLane ?? DEFAULT_COLS_PER_LANE;
|
|
176
306
|
const isFocus = highlight.mode === 'focus';
|
|
177
307
|
|
|
178
|
-
const { nodeLane, nodeRank, numLanes } = buildLaneAssignment(
|
|
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
|
|
315
|
+
// Display index per node (0 = topmost position; nulls skipped).
|
|
183
316
|
const displayIndex = new Map<string, number>();
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
//
|
|
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
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
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
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
return
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
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) ??
|
|
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 =
|
|
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.
|
|
434
|
-
return role === 'on-path' ? 0 : arc.
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
1065
|
+
// ── 5. Migration rows (one per inbound edge, ordered by edge lane) ─────
|
|
787
1066
|
for (const edge of inEdges) {
|
|
788
|
-
const
|
|
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 =
|
|
793
|
-
const connCol =
|
|
794
|
-
const line = lineRefFor(edge,
|
|
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([
|
|
1076
|
+
placeVerticals(row, new Set([eLane]));
|
|
800
1077
|
placeBackVerticals(row);
|
|
801
1078
|
grid.push(row);
|
|
802
1079
|
}
|