@prisma-next/cli 0.12.0-dev.67 → 0.12.0-dev.68
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 +4 -4
- package/dist/commands/migrate.d.mts.map +1 -1
- package/dist/commands/migrate.mjs +17 -11
- package/dist/commands/migrate.mjs.map +1 -1
- package/dist/commands/migration-graph.mjs +2 -2
- package/dist/commands/migration-graph.mjs.map +1 -1
- package/dist/commands/migration-list.mjs +1 -1
- package/dist/commands/migration-log.mjs +1 -1
- package/dist/commands/migration-status.d.mts.map +1 -1
- package/dist/commands/migration-status.mjs +1 -1
- package/dist/migration-graph-command-render-BAOzyYF6.mjs +1822 -0
- package/dist/migration-graph-command-render-BAOzyYF6.mjs.map +1 -0
- package/dist/{migration-list-CyLslAtv.mjs → migration-list-CihF6w5z.mjs} +2 -2
- package/dist/migration-list-CihF6w5z.mjs.map +1 -0
- package/dist/{migration-log-BYt18y2H.mjs → migration-log-B75IArji.mjs} +2 -2
- package/dist/{migration-log-BYt18y2H.mjs.map → migration-log-B75IArji.mjs.map} +1 -1
- package/dist/{migration-status-ciYpjhtu.mjs → migration-status-Di82DGvo.mjs} +3 -3
- package/dist/migration-status-Di82DGvo.mjs.map +1 -0
- package/package.json +19 -18
- package/src/commands/migrate.ts +35 -26
- package/src/commands/migration-graph.ts +1 -1
- package/src/commands/migration-list.ts +1 -1
- package/src/commands/migration-status-overlay.ts +1 -1
- package/src/commands/migration-status.ts +4 -2
- package/src/utils/formatters/migration-graph-command-render.ts +239 -0
- package/src/utils/formatters/migration-graph-grid-layout.ts +857 -0
- package/src/utils/formatters/migration-graph-labels.ts +406 -0
- package/src/utils/formatters/migration-graph-model.ts +94 -0
- package/src/utils/formatters/migration-graph-occlusion-render.ts +245 -0
- package/src/utils/formatters/migration-graph-space-render.ts +73 -33
- package/src/utils/formatters/migration-list-render.ts +1 -1
- package/dist/migration-graph-space-render-Cpg0ql8v.mjs +0 -2370
- package/dist/migration-graph-space-render-Cpg0ql8v.mjs.map +0 -1
- package/dist/migration-list-CyLslAtv.mjs.map +0 -1
- package/dist/migration-status-ciYpjhtu.mjs.map +0 -1
- package/src/utils/formatters/migration-graph-lane-colors.ts +0 -194
- package/src/utils/formatters/migration-graph-layout.ts +0 -1308
- package/src/utils/formatters/migration-graph-tree-render.ts +0 -1337
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid layout for the line/plane/occlusion migration-graph renderer.
|
|
3
|
+
*
|
|
4
|
+
* Produces a Grid (rows × cells) from a MigrationGraphRowModel. Each node
|
|
5
|
+
* emits: fork connector, self-loop rows, node row, merge connector, and
|
|
6
|
+
* inbound migration rows — in display order (tips first, then roots).
|
|
7
|
+
*/
|
|
8
|
+
|
|
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,
|
|
20
|
+
} from './migration-graph-model';
|
|
21
|
+
import type { ClassifiedEdge, MigrationGraphRowModel } from './migration-graph-rows';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Internal: lane + rank assignment
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
interface LaneAssignment {
|
|
28
|
+
nodeLane: Map<string, number>;
|
|
29
|
+
nodeRank: Map<string, number>;
|
|
30
|
+
/** Total number of lanes allocated. */
|
|
31
|
+
numLanes: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildLaneAssignment(
|
|
35
|
+
nodes: readonly (string | null)[],
|
|
36
|
+
edges: readonly ClassifiedEdge[],
|
|
37
|
+
): LaneAssignment {
|
|
38
|
+
const allNodes = new Set<string>();
|
|
39
|
+
for (const n of nodes) {
|
|
40
|
+
if (n !== null) allNodes.add(n);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Separate forward (non-self) edges
|
|
44
|
+
const fwdEdges = edges.filter((e) => e.kind === 'forward' && e.from !== e.to);
|
|
45
|
+
|
|
46
|
+
// Build adjacency: outbound forward edges per node, sorted lex by migrationHash
|
|
47
|
+
const outbound = new Map<string, ClassifiedEdge[]>();
|
|
48
|
+
const inbound = new Map<string, ClassifiedEdge[]>();
|
|
49
|
+
for (const edge of fwdEdges) {
|
|
50
|
+
const ob = outbound.get(edge.from);
|
|
51
|
+
if (ob) ob.push(edge);
|
|
52
|
+
else outbound.set(edge.from, [edge]);
|
|
53
|
+
|
|
54
|
+
const ib = inbound.get(edge.to);
|
|
55
|
+
if (ib) ib.push(edge);
|
|
56
|
+
else inbound.set(edge.to, [edge]);
|
|
57
|
+
}
|
|
58
|
+
for (const list of outbound.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
59
|
+
for (const list of inbound.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
60
|
+
|
|
61
|
+
// Compute longest-forward-path rank from roots (tips get highest rank)
|
|
62
|
+
const nodeRank = new Map<string, number>();
|
|
63
|
+
for (const n of allNodes) nodeRank.set(n, 0);
|
|
64
|
+
for (let pass = 0; pass < allNodes.size; pass++) {
|
|
65
|
+
let changed = false;
|
|
66
|
+
for (const [from, edges] of outbound) {
|
|
67
|
+
const base = nodeRank.get(from) ?? 0;
|
|
68
|
+
for (const e of edges) {
|
|
69
|
+
const next = base + 1;
|
|
70
|
+
if (next > (nodeRank.get(e.to) ?? 0)) {
|
|
71
|
+
nodeRank.set(e.to, next);
|
|
72
|
+
changed = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!changed) break;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Lane assignment: BFS from roots, trunk keeps parent's lane
|
|
80
|
+
const nodeLane = new Map<string, number>();
|
|
81
|
+
let nextLane = 0;
|
|
82
|
+
|
|
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
|
+
});
|
|
93
|
+
|
|
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 });
|
|
115
|
+
}
|
|
116
|
+
first = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Isolated nodes (no edges) get their own lane
|
|
121
|
+
for (const n of allNodes) {
|
|
122
|
+
if (!nodeLane.has(n)) nodeLane.set(n, nextLane++);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { nodeLane, nodeRank, numLanes: nextLane };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Internal: display order
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
interface NodeDisplay {
|
|
133
|
+
hash: string;
|
|
134
|
+
lane: number;
|
|
135
|
+
rank: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function computeDisplayOrder(
|
|
139
|
+
nodes: readonly (string | null)[],
|
|
140
|
+
nodeLane: Map<string, number>,
|
|
141
|
+
nodeRank: Map<string, number>,
|
|
142
|
+
): NodeDisplay[] {
|
|
143
|
+
const seen = new Set<string>();
|
|
144
|
+
const result: NodeDisplay[] = [];
|
|
145
|
+
for (const n of nodes) {
|
|
146
|
+
if (n === null || seen.has(n)) continue;
|
|
147
|
+
seen.add(n);
|
|
148
|
+
result.push({ hash: n, lane: nodeLane.get(n) ?? 0, rank: nodeRank.get(n) ?? 0 });
|
|
149
|
+
}
|
|
150
|
+
// Tips first (rank desc), within same rank lane asc
|
|
151
|
+
result.sort((a, b) => b.rank - a.rank || a.lane - b.lane);
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Internal: grid row builder
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
type CellsRow = Cell[];
|
|
160
|
+
|
|
161
|
+
/** Create an empty cell. */
|
|
162
|
+
function emptyCell(): Cell {
|
|
163
|
+
return { lines: [] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// buildGrid — main entry point
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export function buildGrid(
|
|
171
|
+
rowModel: MigrationGraphRowModel,
|
|
172
|
+
opts: GridOptions = {},
|
|
173
|
+
highlight: Highlight = { mode: 'flat', onPath: new Set() },
|
|
174
|
+
): Grid {
|
|
175
|
+
const colsPerLane = opts.colsPerLane ?? 2;
|
|
176
|
+
const isFocus = highlight.mode === 'focus';
|
|
177
|
+
|
|
178
|
+
const { nodeLane, nodeRank, numLanes } = buildLaneAssignment(rowModel.nodes, rowModel.edges);
|
|
179
|
+
|
|
180
|
+
const displayOrder = computeDisplayOrder(rowModel.nodes, nodeLane, nodeRank);
|
|
181
|
+
|
|
182
|
+
// Display index per node (0 = topmost row).
|
|
183
|
+
const displayIndex = new Map<string, number>();
|
|
184
|
+
displayOrder.forEach((d, i) => {
|
|
185
|
+
displayIndex.set(d.hash, i);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── Back-arc planning ────────────────────────────────────────────────────
|
|
189
|
+
// Each rollback edge runs against the forward grain. An *adjacent* rollback
|
|
190
|
+
// (target is the display-neighbour directly below the source) is a plain ↓ in
|
|
191
|
+
// the source's own lane. A *node-skipping* rollback is routed on its own
|
|
192
|
+
// back-lane to the right: it tees off the source node row (○─╮), runs a
|
|
193
|
+
// vertical │ down its back-lane, and lands into the target node (◂╯).
|
|
194
|
+
//
|
|
195
|
+
// Two independent numbers per routed back-arc:
|
|
196
|
+
// geomLane — the column its rail occupies. Outermost (largest) goes to the
|
|
197
|
+
// arc reaching the lowest target (ties: higher source first), so
|
|
198
|
+
// 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.
|
|
205
|
+
interface RoutedBackArc {
|
|
206
|
+
readonly edge: ClassifiedEdge;
|
|
207
|
+
readonly sourceIndex: number;
|
|
208
|
+
readonly targetIndex: number;
|
|
209
|
+
readonly geomLane: number;
|
|
210
|
+
readonly colourLane: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const rollbackEdges = rowModel.edges.filter((e) => e.kind === 'rollback' && e.from !== e.to);
|
|
214
|
+
|
|
215
|
+
const adjacentRollbacks: ClassifiedEdge[] = [];
|
|
216
|
+
const skippingRollbacks: ClassifiedEdge[] = [];
|
|
217
|
+
for (const e of rollbackEdges) {
|
|
218
|
+
const si = displayIndex.get(e.from);
|
|
219
|
+
const ti = displayIndex.get(e.to);
|
|
220
|
+
if (si === undefined || ti === undefined) continue;
|
|
221
|
+
// Adjacent: target sits directly below the source in display order.
|
|
222
|
+
if (ti === si + 1) adjacentRollbacks.push(e);
|
|
223
|
+
else skippingRollbacks.push(e);
|
|
224
|
+
}
|
|
225
|
+
|
|
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
|
|
244
|
+
});
|
|
245
|
+
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);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const routedBackArcs: RoutedBackArc[] = skippingRollbacks.map((e) => ({
|
|
252
|
+
edge: e,
|
|
253
|
+
sourceIndex: displayIndex.get(e.from) ?? 0,
|
|
254
|
+
targetIndex: displayIndex.get(e.to) ?? 0,
|
|
255
|
+
geomLane: geomLaneOf.get(e.migrationHash) ?? numLanes,
|
|
256
|
+
colourLane: colourLaneOf.get(e.migrationHash) ?? numLanes,
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
const backArcsBySource = new Map<string, RoutedBackArc[]>();
|
|
260
|
+
const backArcsByTarget = new Map<string, RoutedBackArc[]>();
|
|
261
|
+
for (const arc of routedBackArcs) {
|
|
262
|
+
const sb = backArcsBySource.get(arc.edge.from);
|
|
263
|
+
if (sb) sb.push(arc);
|
|
264
|
+
else backArcsBySource.set(arc.edge.from, [arc]);
|
|
265
|
+
const tb = backArcsByTarget.get(arc.edge.to);
|
|
266
|
+
if (tb) tb.push(arc);
|
|
267
|
+
else backArcsByTarget.set(arc.edge.to, [arc]);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const adjacentBySource = new Map<string, ClassifiedEdge[]>();
|
|
271
|
+
const adjacentByTarget = new Map<string, ClassifiedEdge[]>();
|
|
272
|
+
for (const e of adjacentRollbacks) {
|
|
273
|
+
const b = adjacentBySource.get(e.from);
|
|
274
|
+
if (b) b.push(e);
|
|
275
|
+
else adjacentBySource.set(e.from, [e]);
|
|
276
|
+
const t = adjacentByTarget.get(e.to);
|
|
277
|
+
if (t) t.push(e);
|
|
278
|
+
else adjacentByTarget.set(e.to, [e]);
|
|
279
|
+
}
|
|
280
|
+
for (const list of adjacentBySource.values())
|
|
281
|
+
list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
282
|
+
|
|
283
|
+
const numBackLanes = skippingRollbacks.length;
|
|
284
|
+
const totalCols = (numLanes + numBackLanes) * colsPerLane;
|
|
285
|
+
|
|
286
|
+
// Build edge lookup maps (classified)
|
|
287
|
+
const fwdEdges = rowModel.edges.filter((e) => e.kind === 'forward' && e.from !== e.to);
|
|
288
|
+
const selfEdges = rowModel.edges.filter((e) => e.kind === 'self');
|
|
289
|
+
|
|
290
|
+
// outbound sorted by migrationHash
|
|
291
|
+
const outboundFwd = new Map<string, ClassifiedEdge[]>();
|
|
292
|
+
const inboundFwd = new Map<string, ClassifiedEdge[]>();
|
|
293
|
+
for (const e of fwdEdges) {
|
|
294
|
+
const ob = outboundFwd.get(e.from);
|
|
295
|
+
if (ob) ob.push(e);
|
|
296
|
+
else outboundFwd.set(e.from, [e]);
|
|
297
|
+
const ib = inboundFwd.get(e.to);
|
|
298
|
+
if (ib) ib.push(e);
|
|
299
|
+
else inboundFwd.set(e.to, [e]);
|
|
300
|
+
}
|
|
301
|
+
for (const list of outboundFwd.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
302
|
+
for (const list of inboundFwd.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
303
|
+
|
|
304
|
+
const selfEdgesByNode = new Map<string, ClassifiedEdge[]>();
|
|
305
|
+
for (const e of selfEdges) {
|
|
306
|
+
const bucket = selfEdgesByNode.get(e.from);
|
|
307
|
+
if (bucket) bucket.push(e);
|
|
308
|
+
else selfEdgesByNode.set(e.from, [e]);
|
|
309
|
+
}
|
|
310
|
+
for (const list of selfEdgesByNode.values())
|
|
311
|
+
list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
312
|
+
|
|
313
|
+
// ── Role + plane: mode/z-order seam ──────────────────────────────────────
|
|
314
|
+
// role(migrationHash): focus → on-path/off-path from highlight.onPath; flat → undefined.
|
|
315
|
+
function roleOf(migrationHash: string): PathRole | undefined {
|
|
316
|
+
if (!isFocus) return undefined;
|
|
317
|
+
return highlight.onPath.has(migrationHash) ? 'on-path' : 'off-path';
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// On-path node set: a node is on-path iff an on-path edge touches it (from or
|
|
321
|
+
// to) — forward, self, OR rollback (a back-arc's endpoints are on its route).
|
|
322
|
+
const onPathNodes = new Set<string>();
|
|
323
|
+
if (isFocus) {
|
|
324
|
+
for (const e of [...fwdEdges, ...selfEdges, ...rollbackEdges]) {
|
|
325
|
+
if (highlight.onPath.has(e.migrationHash)) {
|
|
326
|
+
onPathNodes.add(e.from);
|
|
327
|
+
onPathNodes.add(e.to);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function nodeRoleOf(hash: string): PathRole | undefined {
|
|
332
|
+
if (!isFocus) return undefined;
|
|
333
|
+
return onPathNodes.has(hash) ? 'on-path' : 'off-path';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// planeOf — z-order. Lower number = drawn on top.
|
|
337
|
+
// flat: trunk on top → plane = lane (lane 0 topmost).
|
|
338
|
+
// focus: on-path on top → on-path = plane 0; off-path sits beneath it,
|
|
339
|
+
// ordered by lane so a deterministic owner survives among off-path lines.
|
|
340
|
+
function planeOf(lane: number, role: PathRole | undefined): number {
|
|
341
|
+
if (!isFocus) return lane;
|
|
342
|
+
return role === 'on-path' ? 0 : lane + 1;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── LineRef + cell builders (role-aware) ─────────────────────────────────
|
|
346
|
+
function lineRefFor(edge: ClassifiedEdge, lane: number): LineRef {
|
|
347
|
+
return {
|
|
348
|
+
migrationHash: edge.migrationHash,
|
|
349
|
+
dirName: edge.dirName,
|
|
350
|
+
lane,
|
|
351
|
+
role: roleOf(edge.migrationHash),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Synthetic LineRef for a lane carrying a representative edge's role (pass-through). */
|
|
356
|
+
function passLineRef(lane: number, dirName: string, migHash: string): LineRef {
|
|
357
|
+
return { migrationHash: migHash, dirName, lane, role: roleOf(migHash) };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function vertCell(line: LineRef): Cell {
|
|
361
|
+
return {
|
|
362
|
+
lines: [
|
|
363
|
+
{
|
|
364
|
+
line,
|
|
365
|
+
directions: new Set<Direction>(['up', 'down']),
|
|
366
|
+
plane: planeOf(line.lane, line.role),
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function dirCell(line: LineRef, dirs: ReadonlySet<Direction>): Cell {
|
|
373
|
+
return { lines: [{ line, directions: dirs, plane: planeOf(line.lane, line.role) }] };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function nodeCell(nodeRef: NodeRef): Cell {
|
|
377
|
+
return { node: nodeRef, lines: [] };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Pass-through colour follows the edge CURRENTLY occupying a lane at this row,
|
|
381
|
+
// not a lane-wide average. A single lane carries different edges (with different
|
|
382
|
+
// roles) over its vertical extent — e.g. lane 0 below a fork carries the trunk
|
|
383
|
+
// branch (off-path) above the fork node and the trunk's parent edge (on-path)
|
|
384
|
+
// below it. We track the active edge per lane as we descend top-to-bottom and
|
|
385
|
+
// colour pass-through verticals from it. `laneCurrentEdge[L]` = the edge whose
|
|
386
|
+
// vertical body currently runs through lane L at the row being emitted.
|
|
387
|
+
const laneCurrentEdge = new Map<number, ClassifiedEdge>();
|
|
388
|
+
|
|
389
|
+
function getRepLine(lane: number): LineRef {
|
|
390
|
+
const e = laneCurrentEdge.get(lane);
|
|
391
|
+
if (e) return lineRefFor(e, lane);
|
|
392
|
+
return passLineRef(lane, `lane${lane}`, `lane${lane}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Active lanes: set of lane indices currently visible (vertical passes through them)
|
|
396
|
+
const activeLanes = new Set<number>();
|
|
397
|
+
|
|
398
|
+
const grid: Cell[][] = [];
|
|
399
|
+
|
|
400
|
+
function makeRow(): CellsRow {
|
|
401
|
+
return Array.from({ length: totalCols }, () => emptyCell());
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Place vertical pass-throughs for all active lanes in a row, skipping specified lanes.
|
|
405
|
+
function placeVerticals(row: CellsRow, skip: Set<number>): void {
|
|
406
|
+
for (const lane of activeLanes) {
|
|
407
|
+
if (skip.has(lane)) continue;
|
|
408
|
+
const railCol = lane * colsPerLane;
|
|
409
|
+
const cell = row[railCol];
|
|
410
|
+
if (cell !== undefined && cell.lines.length === 0 && !cell.node) {
|
|
411
|
+
row[railCol] = vertCell(getRepLine(lane));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── Back-arc helpers ──────────────────────────────────────────────────────
|
|
417
|
+
// Active routed back-arcs whose vertical currently runs through their geomLane.
|
|
418
|
+
const activeBackArcs = new Set<RoutedBackArc>();
|
|
419
|
+
|
|
420
|
+
// A back-arc's LineRef carries its colourLane (not its geomLane) so colour is
|
|
421
|
+
// read off the lane that drives the rotation, independent of column placement.
|
|
422
|
+
function backArcLine(arc: RoutedBackArc): LineRef {
|
|
423
|
+
return {
|
|
424
|
+
migrationHash: arc.edge.migrationHash,
|
|
425
|
+
dirName: arc.edge.dirName,
|
|
426
|
+
lane: arc.colourLane,
|
|
427
|
+
role: roleOf(arc.edge.migrationHash),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function backArcPlane(arc: RoutedBackArc): number {
|
|
432
|
+
const role = roleOf(arc.edge.migrationHash);
|
|
433
|
+
if (!isFocus) return arc.colourLane;
|
|
434
|
+
return role === 'on-path' ? 0 : arc.colourLane + 1;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Compose a CellLine into a row cell (never overwrite — occlusion arbitrates).
|
|
438
|
+
function composeLine(
|
|
439
|
+
row: CellsRow,
|
|
440
|
+
col: number,
|
|
441
|
+
line: LineRef,
|
|
442
|
+
dirs: ReadonlySet<Direction>,
|
|
443
|
+
plane: number,
|
|
444
|
+
extra?: { landingArrow?: boolean },
|
|
445
|
+
): void {
|
|
446
|
+
const existing = row[col];
|
|
447
|
+
const cellLine: CellLine = {
|
|
448
|
+
line,
|
|
449
|
+
directions: dirs,
|
|
450
|
+
plane,
|
|
451
|
+
...(extra?.landingArrow ? { landingArrow: true } : {}),
|
|
452
|
+
};
|
|
453
|
+
if (existing && (existing.lines.length > 0 || existing.node)) {
|
|
454
|
+
row[col] = { ...existing, lines: [...existing.lines, cellLine] };
|
|
455
|
+
} else {
|
|
456
|
+
row[col] = { lines: [cellLine] };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Place verticals for every active back-arc on this row (in its geomLane rail).
|
|
461
|
+
function placeBackVerticals(row: CellsRow): void {
|
|
462
|
+
for (const arc of activeBackArcs) {
|
|
463
|
+
const railCol = arc.geomLane * colsPerLane;
|
|
464
|
+
composeLine(
|
|
465
|
+
row,
|
|
466
|
+
railCol,
|
|
467
|
+
backArcLine(arc),
|
|
468
|
+
new Set<Direction>(['up', 'down']),
|
|
469
|
+
backArcPlane(arc),
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
placeAdjacentOverlays(row);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Adjacent rollbacks share the source's own lane: their vertical body overlays
|
|
476
|
+
// the forward trunk between source and target. In focus, an on-path adjacent
|
|
477
|
+
// rollback lifts that segment of the trunk to the top plane (drawn green); in
|
|
478
|
+
// flat it sits at the same plane/colour as the trunk, so it is a no-op there.
|
|
479
|
+
interface ActiveAdjacent {
|
|
480
|
+
readonly lane: number;
|
|
481
|
+
readonly edge: ClassifiedEdge;
|
|
482
|
+
}
|
|
483
|
+
const activeAdjacent = new Set<ActiveAdjacent>();
|
|
484
|
+
|
|
485
|
+
function placeAdjacentOverlays(row: CellsRow): void {
|
|
486
|
+
for (const adj of activeAdjacent) {
|
|
487
|
+
const railCol = adj.lane * colsPerLane;
|
|
488
|
+
const cell = row[railCol];
|
|
489
|
+
if (cell?.node) continue; // never overlay a node marker
|
|
490
|
+
const line = lineRefFor(adj.edge, adj.lane);
|
|
491
|
+
composeLine(
|
|
492
|
+
row,
|
|
493
|
+
railCol,
|
|
494
|
+
line,
|
|
495
|
+
new Set<Direction>(['up', 'down']),
|
|
496
|
+
planeOf(adj.lane, line.role),
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Tee a routed back-arc off its source node row: a horizontal bridge from the
|
|
502
|
+
// node's connector column across to the back-lane rail, ending in a ╮ corner
|
|
503
|
+
// (down+left). Composed (not overwritten) so it occludes / is occluded by any
|
|
504
|
+
// back-arc vertical it crosses.
|
|
505
|
+
function emitBackArcTee(row: CellsRow, nodeLaneNum: number, arc: RoutedBackArc): void {
|
|
506
|
+
const nodeRail = nodeLaneNum * colsPerLane;
|
|
507
|
+
const geomRail = arc.geomLane * colsPerLane;
|
|
508
|
+
const line = backArcLine(arc);
|
|
509
|
+
const plane = backArcPlane(arc);
|
|
510
|
+
for (let col = nodeRail + 1; col < geomRail; col++) {
|
|
511
|
+
composeLine(row, col, line, new Set<Direction>(['left', 'right']), plane);
|
|
512
|
+
}
|
|
513
|
+
composeLine(row, geomRail, line, new Set<Direction>(['down', 'left']), plane);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Land a routed back-arc into its target node row: a ◂ arrowhead in the node's
|
|
517
|
+
// connector column, a horizontal bridge across to the back-lane rail, ending in
|
|
518
|
+
// a ╯ corner (up+left). Composed so the on-top arc draws the anchor and the
|
|
519
|
+
// others yield their corners beneath it (occlusion arbitrates).
|
|
520
|
+
function emitBackArcLanding(row: CellsRow, nodeLaneNum: number, arc: RoutedBackArc): void {
|
|
521
|
+
const nodeRail = nodeLaneNum * colsPerLane;
|
|
522
|
+
const geomRail = arc.geomLane * colsPerLane;
|
|
523
|
+
const line = backArcLine(arc);
|
|
524
|
+
const plane = backArcPlane(arc);
|
|
525
|
+
composeLine(row, nodeRail + 1, line, new Set<Direction>(['left', 'right']), plane, {
|
|
526
|
+
landingArrow: true,
|
|
527
|
+
});
|
|
528
|
+
for (let col = nodeRail + 2; col < geomRail; col++) {
|
|
529
|
+
composeLine(row, col, line, new Set<Direction>(['left', 'right']), plane);
|
|
530
|
+
}
|
|
531
|
+
composeLine(row, geomRail, line, new Set<Direction>(['up', 'left']), plane);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Emit a connector row (fork or merge).
|
|
535
|
+
//
|
|
536
|
+
// The CONTINUOUS lane gets the unbroken vertical/sweep; every other
|
|
537
|
+
// participating lane yields into its own corner. In flat mode the continuous
|
|
538
|
+
// lane is the trunk (lane of the node); in focus mode it is the on-path lane
|
|
539
|
+
// (the inbound/outbound edge whose migration is on-path), so the chosen route
|
|
540
|
+
// is drawn as one continuous green line sweeping the merge/fork.
|
|
541
|
+
//
|
|
542
|
+
// Geometry is identical regardless of which lane is continuous; only the
|
|
543
|
+
// NODE-ANCHOR glyph at the trunk rail changes:
|
|
544
|
+
// continuous == trunk → │ (vertical, the trunk passes straight through)
|
|
545
|
+
// continuous == a branch → corner toward that branch
|
|
546
|
+
// merge: ╰ (up+right) fork: ╭ (down+right)
|
|
547
|
+
// The branch's own rail always carries its yield corner (merge ╮ / fork ╯), and
|
|
548
|
+
// the cells between carry horizontals. The continuous (on-path) sweep is placed
|
|
549
|
+
// on the top plane so it occludes the trunk's vertical at the node anchor.
|
|
550
|
+
function emitConnectorRow(
|
|
551
|
+
trunkLane: number,
|
|
552
|
+
branchEntries: readonly { lane: number; edge: ClassifiedEdge }[],
|
|
553
|
+
connectorType: 'fork' | 'merge',
|
|
554
|
+
trunkEdge: ClassifiedEdge | undefined,
|
|
555
|
+
): CellsRow {
|
|
556
|
+
const row = makeRow();
|
|
557
|
+
const sorted = [...branchEntries].sort((a, b) => a.lane - b.lane);
|
|
558
|
+
if (sorted.length === 0) return row;
|
|
559
|
+
|
|
560
|
+
const branchByLane = new Map<number, ClassifiedEdge>();
|
|
561
|
+
for (const b of sorted) branchByLane.set(b.lane, b.edge);
|
|
562
|
+
|
|
563
|
+
// Continuous lane: the on-path participant in focus, else the trunk.
|
|
564
|
+
let continuousLane = trunkLane;
|
|
565
|
+
if (isFocus) {
|
|
566
|
+
if (trunkEdge && highlight.onPath.has(trunkEdge.migrationHash)) {
|
|
567
|
+
continuousLane = trunkLane;
|
|
568
|
+
} else {
|
|
569
|
+
const onPathBranch = sorted.find((b) => highlight.onPath.has(b.edge.migrationHash));
|
|
570
|
+
if (onPathBranch) continuousLane = onPathBranch.lane;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const trunkRailCol = trunkLane * colsPerLane;
|
|
575
|
+
const continuousRailCol = continuousLane * colsPerLane;
|
|
576
|
+
|
|
577
|
+
// Add a CellLine to a cell (compose, don't overwrite) so occlusion arbitrates.
|
|
578
|
+
function addLine(col: number, line: LineRef, dirs: ReadonlySet<Direction>): void {
|
|
579
|
+
const existing = row[col];
|
|
580
|
+
const cellLine: CellLine = { line, directions: dirs, plane: planeOf(line.lane, line.role) };
|
|
581
|
+
row[col] =
|
|
582
|
+
existing && existing.lines.length > 0
|
|
583
|
+
? { ...existing, lines: [...existing.lines, cellLine] }
|
|
584
|
+
: { lines: [cellLine] };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const cornerLeftDown: ReadonlySet<Direction> =
|
|
588
|
+
connectorType === 'merge'
|
|
589
|
+
? new Set<Direction>(['left', 'down'])
|
|
590
|
+
: new Set<Direction>(['left', 'up']);
|
|
591
|
+
|
|
592
|
+
// ── Base plane: every yielding branch lays its own corner + the horizontal
|
|
593
|
+
// segment to its left (up to the previous branch's rail). These sit on the
|
|
594
|
+
// branch's lane plane; where the continuous sweep crosses them it occludes.
|
|
595
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
596
|
+
const b = sorted[i]!;
|
|
597
|
+
if (b.lane === continuousLane) continue; // continuous drawn separately, on top
|
|
598
|
+
const branchLine = lineRefFor(b.edge, b.lane);
|
|
599
|
+
const railCol = b.lane * colsPerLane;
|
|
600
|
+
addLine(railCol, branchLine, cornerLeftDown);
|
|
601
|
+
const leftBound = i === 0 ? trunkRailCol + 1 : sorted[i - 1]!.lane * colsPerLane + 1;
|
|
602
|
+
for (let col = leftBound; col < railCol; col++) {
|
|
603
|
+
addLine(col, branchLine, new Set<Direction>(['left', 'right']));
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── The continuous line ──────────────────────────────────────────────────
|
|
608
|
+
const continuousLine: LineRef =
|
|
609
|
+
continuousLane === trunkLane
|
|
610
|
+
? trunkEdge
|
|
611
|
+
? lineRefFor(trunkEdge, trunkLane)
|
|
612
|
+
: getRepLine(trunkLane)
|
|
613
|
+
: lineRefFor(branchByLane.get(continuousLane)!, continuousLane);
|
|
614
|
+
|
|
615
|
+
if (continuousLane === trunkLane) {
|
|
616
|
+
// Trunk passes straight through the node anchor (│), branches yield to it.
|
|
617
|
+
addLine(trunkRailCol, continuousLine, new Set<Direction>(['up', 'down']));
|
|
618
|
+
} else {
|
|
619
|
+
// A branch is continuous: it sweeps from the node anchor across to its own
|
|
620
|
+
// rail, on the TOP plane, occluding the trunk vertical and any intermediate
|
|
621
|
+
// yielding branch corners it passes over.
|
|
622
|
+
const anchorDirs: ReadonlySet<Direction> =
|
|
623
|
+
connectorType === 'merge'
|
|
624
|
+
? new Set<Direction>(['up', 'right'])
|
|
625
|
+
: new Set<Direction>(['down', 'right']);
|
|
626
|
+
addLine(trunkRailCol, continuousLine, anchorDirs);
|
|
627
|
+
for (let col = trunkRailCol + 1; col < continuousRailCol; col++) {
|
|
628
|
+
addLine(col, continuousLine, new Set<Direction>(['left', 'right']));
|
|
629
|
+
}
|
|
630
|
+
addLine(continuousRailCol, continuousLine, cornerLeftDown);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Other active lanes (not trunk, not branch): vertical pass-through.
|
|
634
|
+
const skipSet = new Set<number>([trunkLane, ...sorted.map((b) => b.lane)]);
|
|
635
|
+
placeVerticals(row, skipSet);
|
|
636
|
+
placeBackVerticals(row);
|
|
637
|
+
|
|
638
|
+
return row;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Process each node in display order
|
|
642
|
+
for (const nodeDisplay of displayOrder) {
|
|
643
|
+
const { hash: nodeHash } = nodeDisplay;
|
|
644
|
+
const nodeLaneNum = nodeLane.get(nodeHash) ?? 0;
|
|
645
|
+
|
|
646
|
+
activeLanes.add(nodeLaneNum);
|
|
647
|
+
|
|
648
|
+
// ── 1. Fork connector (BEFORE the node row) ──────────────────────────
|
|
649
|
+
const outEdges = outboundFwd.get(nodeHash) ?? [];
|
|
650
|
+
if (outEdges.length > 1) {
|
|
651
|
+
const trunkChildLane = nodeLane.get(outEdges[0]!.to) ?? nodeLaneNum;
|
|
652
|
+
const branchEntries = outEdges
|
|
653
|
+
.slice(1)
|
|
654
|
+
.map((e) => ({ lane: nodeLane.get(e.to) ?? 0, edge: e }))
|
|
655
|
+
.filter((b) => b.lane !== trunkChildLane && activeLanes.has(b.lane));
|
|
656
|
+
|
|
657
|
+
if (branchEntries.length > 0) {
|
|
658
|
+
const trunkEdge = outEdges[0];
|
|
659
|
+
const connRow = emitConnectorRow(nodeLaneNum, branchEntries, 'fork', trunkEdge);
|
|
660
|
+
grid.push(connRow);
|
|
661
|
+
assertSingleOwner(connRow, isFocus);
|
|
662
|
+
|
|
663
|
+
for (const b of branchEntries) activeLanes.delete(b.lane);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── 2. Self-loop rows (BEFORE the node row) ───────────────────────────
|
|
668
|
+
const selfMigrations = selfEdgesByNode.get(nodeHash) ?? [];
|
|
669
|
+
for (const selfEdge of selfMigrations) {
|
|
670
|
+
const row = makeRow();
|
|
671
|
+
const railCol = nodeLaneNum * colsPerLane;
|
|
672
|
+
const connCol = nodeLaneNum * colsPerLane + 1;
|
|
673
|
+
const line = lineRefFor(selfEdge, nodeLaneNum);
|
|
674
|
+
row[railCol] = vertCell(line);
|
|
675
|
+
row[connCol] = {
|
|
676
|
+
lines: [
|
|
677
|
+
{
|
|
678
|
+
line,
|
|
679
|
+
directions: new Set<Direction>(),
|
|
680
|
+
plane: planeOf(nodeLaneNum, line.role),
|
|
681
|
+
selfLoop: true,
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
};
|
|
685
|
+
placeVerticals(row, new Set([nodeLaneNum]));
|
|
686
|
+
placeBackVerticals(row);
|
|
687
|
+
grid.push(row);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ── 3. Node row ────────────────────────────────────────────────────────
|
|
691
|
+
{
|
|
692
|
+
const row = makeRow();
|
|
693
|
+
const railCol = nodeLaneNum * colsPerLane;
|
|
694
|
+
const nodeRef: NodeRef = {
|
|
695
|
+
contractHash: nodeHash,
|
|
696
|
+
isEmpty: nodeHash === EMPTY_CONTRACT_HASH,
|
|
697
|
+
lane: nodeLaneNum,
|
|
698
|
+
role: nodeRoleOf(nodeHash),
|
|
699
|
+
};
|
|
700
|
+
row[railCol] = nodeCell(nodeRef);
|
|
701
|
+
placeVerticals(row, new Set([nodeLaneNum]));
|
|
702
|
+
|
|
703
|
+
// A back-arc landing ends its vertical at this row, replacing it with a ╯
|
|
704
|
+
// corner — so deactivate landing arcs BEFORE placing back verticals. An
|
|
705
|
+
// adjacent rollback's overlay likewise ends at its target node.
|
|
706
|
+
const landingArcs = backArcsByTarget.get(nodeHash) ?? [];
|
|
707
|
+
for (const arc of landingArcs) activeBackArcs.delete(arc);
|
|
708
|
+
for (const adj of [...activeAdjacent]) {
|
|
709
|
+
if (adj.edge.to === nodeHash) activeAdjacent.delete(adj);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
placeBackVerticals(row);
|
|
713
|
+
|
|
714
|
+
// Back-arc landing: arcs targeting this node sweep from the node anchor
|
|
715
|
+
// (◂ arrowhead) across to their own rail corner (╯). The on-top arc draws
|
|
716
|
+
// the anchor; others yield their corners beneath (occlusion arbitrates).
|
|
717
|
+
for (const arc of landingArcs) {
|
|
718
|
+
emitBackArcLanding(row, nodeLaneNum, arc);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Back-arc tee: arcs sourced at this node tee off the node row into their
|
|
722
|
+
// back-lane (─ bridge + ╮ corner). The vertical begins on the next row.
|
|
723
|
+
const teeArcs = backArcsBySource.get(nodeHash) ?? [];
|
|
724
|
+
for (const arc of teeArcs) {
|
|
725
|
+
emitBackArcTee(row, nodeLaneNum, arc);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
grid.push(row);
|
|
729
|
+
|
|
730
|
+
// Activate the back-arc verticals AFTER the node row so the rail runs from
|
|
731
|
+
// the next row down to (but not including) the target landing row.
|
|
732
|
+
for (const arc of teeArcs) activeBackArcs.add(arc);
|
|
733
|
+
|
|
734
|
+
// Activate adjacent-rollback overlays sourced here (their trunk overlay
|
|
735
|
+
// runs from the next row down to the target node).
|
|
736
|
+
for (const adj of adjacentBySource.get(nodeHash) ?? []) {
|
|
737
|
+
activeAdjacent.add({ lane: nodeLaneNum, edge: adj });
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Inbound forward edges run down their lanes below this node. Record each as
|
|
742
|
+
// its lane's current edge NOW (before emitting the back-arc arrow rows, merge
|
|
743
|
+
// connector, and migration rows) so pass-through verticals colour from the
|
|
744
|
+
// forward edge actually occupying the trunk below this node.
|
|
745
|
+
const inEdges = inboundFwd.get(nodeHash) ?? [];
|
|
746
|
+
inEdges.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
747
|
+
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);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ── 3b. Back-arc arrow rows ──────────────────────────────────────────────
|
|
753
|
+
// For each routed arc sourced here, a │↓ arrow row in its back-lane sits
|
|
754
|
+
// directly below the source node (before the source node's forward inbound
|
|
755
|
+
// migration rows).
|
|
756
|
+
{
|
|
757
|
+
const teeArcs = backArcsBySource.get(nodeHash) ?? [];
|
|
758
|
+
for (const arc of teeArcs) {
|
|
759
|
+
const row = makeRow();
|
|
760
|
+
const railCol = arc.geomLane * colsPerLane;
|
|
761
|
+
const connCol = railCol + 1;
|
|
762
|
+
const line = backArcLine(arc);
|
|
763
|
+
const plane = backArcPlane(arc);
|
|
764
|
+
composeLine(row, railCol, line, new Set<Direction>(['up', 'down']), plane);
|
|
765
|
+
composeLine(row, connCol, line, new Set<Direction>(['down']), plane);
|
|
766
|
+
placeVerticals(row, new Set<number>());
|
|
767
|
+
placeBackVerticals(row);
|
|
768
|
+
grid.push(row);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ── 4. Merge connector (AFTER the node row) ────────────────────────────
|
|
773
|
+
if (inEdges.length > 1) {
|
|
774
|
+
const branchEntries = inEdges
|
|
775
|
+
.slice(1)
|
|
776
|
+
.map((e) => ({ lane: nodeLane.get(e.from) ?? 0, edge: e }));
|
|
777
|
+
|
|
778
|
+
const trunkEdge = inEdges[0];
|
|
779
|
+
const connRow = emitConnectorRow(nodeLaneNum, branchEntries, 'merge', trunkEdge);
|
|
780
|
+
grid.push(connRow);
|
|
781
|
+
assertSingleOwner(connRow, isFocus);
|
|
782
|
+
|
|
783
|
+
for (const b of branchEntries) activeLanes.add(b.lane);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ── 5. Migration rows (one per inbound edge, ordered by migration hash) ─
|
|
787
|
+
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);
|
|
791
|
+
const row = makeRow();
|
|
792
|
+
const railCol = edgeLane * colsPerLane;
|
|
793
|
+
const connCol = edgeLane * colsPerLane + 1;
|
|
794
|
+
const line = lineRefFor(edge, edgeLane);
|
|
795
|
+
|
|
796
|
+
row[railCol] = vertCell(line);
|
|
797
|
+
row[connCol] = dirCell(line, new Set<Direction>(['up']));
|
|
798
|
+
|
|
799
|
+
placeVerticals(row, new Set([edgeLane]));
|
|
800
|
+
placeBackVerticals(row);
|
|
801
|
+
grid.push(row);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ── 5b. Adjacent rollback ↓ rows ─────────────────────────────────────────
|
|
805
|
+
// An adjacent rollback (target is the display-neighbour directly below) is a
|
|
806
|
+
// plain ↓ in the source's own lane — mirror of the forward ↑ — emitted after
|
|
807
|
+
// the source node's forward inbound rows, directly above the target node.
|
|
808
|
+
{
|
|
809
|
+
const adjacents = adjacentBySource.get(nodeHash) ?? [];
|
|
810
|
+
for (const adj of adjacents) {
|
|
811
|
+
const row = makeRow();
|
|
812
|
+
const connCol = nodeLaneNum * colsPerLane + 1;
|
|
813
|
+
const line = lineRefFor(adj, nodeLaneNum);
|
|
814
|
+
const plane = planeOf(nodeLaneNum, line.role);
|
|
815
|
+
// The rail │ belongs to the trunk passing through (drawn by placeVerticals
|
|
816
|
+
// from the lane's current forward edge); only the ↓ arrow is the rollback.
|
|
817
|
+
composeLine(row, connCol, line, new Set<Direction>(['down']), plane);
|
|
818
|
+
placeVerticals(row, new Set<number>());
|
|
819
|
+
placeBackVerticals(row);
|
|
820
|
+
grid.push(row);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ── 6. Root lane deactivation ─────────────────────────────────────────
|
|
825
|
+
if (inEdges.length === 0) {
|
|
826
|
+
activeLanes.delete(nodeLaneNum);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return grid;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ---------------------------------------------------------------------------
|
|
834
|
+
// Single-owner invariant — after building a connector row, assert that every
|
|
835
|
+
// cell has at most one DRAWABLE owner once occlusion (topmost plane) is applied.
|
|
836
|
+
// In focus mode a tie at the same plane between an on-path and an off-path line
|
|
837
|
+
// would be a colour ambiguity, so we additionally assert that at the top plane
|
|
838
|
+
// of each cell exactly one role survives.
|
|
839
|
+
// ---------------------------------------------------------------------------
|
|
840
|
+
function assertSingleOwner(row: CellsRow, isFocus: boolean): void {
|
|
841
|
+
for (const cell of row) {
|
|
842
|
+
if (cell.lines.length <= 1) continue;
|
|
843
|
+
let topPlane = Number.POSITIVE_INFINITY;
|
|
844
|
+
for (const cl of cell.lines) if (cl.plane < topPlane) topPlane = cl.plane;
|
|
845
|
+
const top = cell.lines.filter((cl: CellLine) => cl.plane === topPlane);
|
|
846
|
+
if (top.length > 1) {
|
|
847
|
+
if (isFocus) {
|
|
848
|
+
const roles = new Set(top.map((cl) => cl.line.role));
|
|
849
|
+
if (roles.size > 1) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
'migration-graph layout: single-owner invariant violated — two differently-roled lines share the top plane in one cell',
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|