@prisma-next/cli 0.12.0 → 0.13.0-dev.1
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/README.md +2 -2
- package/dist/cli.mjs +180 -163
- package/dist/cli.mjs.map +1 -1
- package/dist/{client-KgJorIvG.mjs → client-CJzuo5wX.mjs} +222 -107
- package/dist/client-CJzuo5wX.mjs.map +1 -0
- package/dist/{command-helpers-Bbw1GbwL.mjs → command-helpers-DGMvGBeX.mjs} +318 -25
- package/dist/command-helpers-DGMvGBeX.mjs.map +1 -0
- package/dist/commands/contract-emit.d.mts.map +1 -1
- package/dist/commands/contract-emit.mjs +1 -1
- package/dist/commands/contract-infer.d.mts.map +1 -1
- package/dist/commands/contract-infer.mjs +1 -1
- package/dist/commands/db-init.d.mts.map +1 -1
- package/dist/commands/db-init.mjs +4 -5
- package/dist/commands/db-init.mjs.map +1 -1
- package/dist/commands/db-schema.d.mts.map +1 -1
- package/dist/commands/db-schema.mjs +3 -3
- package/dist/commands/db-schema.mjs.map +1 -1
- package/dist/commands/db-sign.d.mts.map +1 -1
- package/dist/commands/db-sign.mjs +6 -6
- package/dist/commands/db-sign.mjs.map +1 -1
- package/dist/commands/db-update.d.mts.map +1 -1
- package/dist/commands/db-update.mjs +10 -7
- package/dist/commands/db-update.mjs.map +1 -1
- package/dist/commands/db-verify.d.mts.map +1 -1
- package/dist/commands/db-verify.mjs +1 -1
- package/dist/commands/migrate.d.mts +37 -3
- package/dist/commands/migrate.d.mts.map +1 -1
- package/dist/commands/migrate.mjs +298 -12
- package/dist/commands/migrate.mjs.map +1 -1
- package/dist/commands/migration-check.d.mts +55 -13
- package/dist/commands/migration-check.d.mts.map +1 -1
- package/dist/commands/migration-check.mjs +3 -2
- package/dist/commands/migration-graph.d.mts +17 -8
- package/dist/commands/migration-graph.d.mts.map +1 -1
- package/dist/commands/migration-graph.mjs +185 -2
- package/dist/commands/migration-graph.mjs.map +1 -0
- package/dist/commands/migration-list.d.mts +26 -27
- package/dist/commands/migration-list.d.mts.map +1 -1
- package/dist/commands/migration-list.mjs +2 -190
- package/dist/commands/migration-log.d.mts +9 -19
- package/dist/commands/migration-log.d.mts.map +1 -1
- package/dist/commands/migration-log.mjs +1 -137
- package/dist/commands/migration-new.d.mts.map +1 -1
- package/dist/commands/migration-new.mjs +6 -5
- package/dist/commands/migration-new.mjs.map +1 -1
- package/dist/commands/migration-plan.d.mts +1 -1
- package/dist/commands/migration-plan.d.mts.map +1 -1
- package/dist/commands/migration-plan.mjs +1 -1
- package/dist/commands/migration-show.d.mts +17 -21
- package/dist/commands/migration-show.d.mts.map +1 -1
- package/dist/commands/migration-show.mjs +24 -36
- package/dist/commands/migration-show.mjs.map +1 -1
- package/dist/commands/migration-status.d.mts +42 -144
- package/dist/commands/migration-status.d.mts.map +1 -1
- package/dist/commands/migration-status.mjs +3 -759
- package/dist/commands/ref.d.mts +1 -1
- package/dist/commands/ref.d.mts.map +1 -1
- package/dist/commands/ref.mjs +4 -4
- package/dist/commands/ref.mjs.map +1 -1
- package/dist/commands/telemetry/index.d.mts +7 -0
- package/dist/commands/telemetry/index.d.mts.map +1 -0
- package/dist/commands/telemetry/index.mjs +2 -0
- package/dist/{config-loader-B6sJjXTv.mjs → config-loader-p9JMrekQ.mjs} +1 -1
- package/dist/{config-loader-B6sJjXTv.mjs.map → config-loader-p9JMrekQ.mjs.map} +1 -1
- package/dist/config-loader.mjs +1 -1
- package/dist/{contract-at-errors-BxP-TOMl.mjs → contract-at-errors-CFXsstzm.mjs} +2 -2
- package/dist/{contract-at-errors-BxP-TOMl.mjs.map → contract-at-errors-CFXsstzm.mjs.map} +1 -1
- package/dist/{contract-emit-DxcGl4Uq.mjs → contract-emit-B_qriF8B.mjs} +5 -5
- package/dist/{contract-emit-DxcGl4Uq.mjs.map → contract-emit-B_qriF8B.mjs.map} +1 -1
- package/dist/{contract-emit-D-4jrNve.mjs → contract-emit-C8HmtboH.mjs} +12 -7
- package/dist/contract-emit-C8HmtboH.mjs.map +1 -0
- package/dist/{contract-enrichment-a0V5Y_mL.mjs → contract-enrichment-gn9sWbPw.mjs} +1 -1
- package/dist/{contract-enrichment-a0V5Y_mL.mjs.map → contract-enrichment-gn9sWbPw.mjs.map} +1 -1
- package/dist/{contract-infer-D8uEbJuu.mjs → contract-infer-BYT_ra_U.mjs} +5 -5
- package/dist/contract-infer-BYT_ra_U.mjs.map +1 -0
- package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs → contract-space-aggregate-loader-ClI1KN6d.mjs} +5 -5
- package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs.map → contract-space-aggregate-loader-ClI1KN6d.mjs.map} +1 -1
- package/dist/{db-verify-v_vUKXTU.mjs → db-verify-C24FKhb7.mjs} +6 -6
- package/dist/{db-verify-v_vUKXTU.mjs.map → db-verify-C24FKhb7.mjs.map} +1 -1
- package/dist/exports/control-api.d.mts +5 -3
- package/dist/exports/control-api.d.mts.map +1 -1
- package/dist/exports/control-api.mjs +3 -3
- package/dist/exports/index.mjs +1 -1
- package/dist/exports/index.mjs.map +1 -1
- package/dist/exports/init-output.d.mts +1 -3
- package/dist/exports/init-output.d.mts.map +1 -1
- package/dist/exports/init-output.mjs +1 -1
- package/dist/{extension-pack-inputs-IDvjRCi3.mjs → extension-pack-inputs-1ySHqxKG.mjs} +1 -1
- package/dist/{extension-pack-inputs-IDvjRCi3.mjs.map → extension-pack-inputs-1ySHqxKG.mjs.map} +1 -1
- package/dist/{framework-components-fYXjz_in.mjs → framework-components-YVQHhPH7.mjs} +2 -2
- package/dist/{framework-components-fYXjz_in.mjs.map → framework-components-YVQHhPH7.mjs.map} +1 -1
- package/dist/{global-flags-DEHjV8_s.d.mts → global-flags-BpoOYtNZ.d.mts} +1 -1
- package/dist/{global-flags-DEHjV8_s.d.mts.map → global-flags-BpoOYtNZ.d.mts.map} +1 -1
- package/dist/{init-Cv9UzWL5.mjs → init-0HwB-Vh8.mjs} +5 -58
- package/dist/init-0HwB-Vh8.mjs.map +1 -0
- package/dist/{inspect-live-schema-C6ohV_oQ.mjs → inspect-live-schema-DF6IwcDl.mjs} +7 -5
- package/dist/inspect-live-schema-DF6IwcDl.mjs.map +1 -0
- package/dist/migration-check-soB5uZEQ.mjs +573 -0
- package/dist/migration-check-soB5uZEQ.mjs.map +1 -0
- package/dist/migration-cli.mjs +1 -1
- package/dist/migration-cli.mjs.map +1 -1
- package/dist/{migration-command-scaffold-CjvwO6at.mjs → migration-command-scaffold-DA-Lhx6o.mjs} +5 -5
- package/dist/{migration-command-scaffold-CjvwO6at.mjs.map → migration-command-scaffold-DA-Lhx6o.mjs.map} +1 -1
- package/dist/migration-graph-command-render-CEez7YUK.mjs +1960 -0
- package/dist/migration-graph-command-render-CEez7YUK.mjs.map +1 -0
- package/dist/migration-list-DlJJ_38Z.mjs +230 -0
- package/dist/migration-list-DlJJ_38Z.mjs.map +1 -0
- package/dist/migration-log-CG0qQAFm.mjs +222 -0
- package/dist/migration-log-CG0qQAFm.mjs.map +1 -0
- package/dist/migration-path-target-Ce6OZImp.mjs +38 -0
- package/dist/migration-path-target-Ce6OZImp.mjs.map +1 -0
- package/dist/{migration-plan-9DJ7q7_z.mjs → migration-plan-z5Ing-TD.mjs} +9 -8
- package/dist/migration-plan-z5Ing-TD.mjs.map +1 -0
- package/dist/migration-status-CgWSoI_g.mjs +446 -0
- package/dist/migration-status-CgWSoI_g.mjs.map +1 -0
- package/dist/{output-B60Gw5fu.mjs → output-mEQ74_nd.mjs} +1 -1
- package/dist/{output-B60Gw5fu.mjs.map → output-mEQ74_nd.mjs.map} +1 -1
- package/dist/{progress-adapter-C644QK8l.mjs → progress-adapter-CjAeTxY_.mjs} +1 -1
- package/dist/{progress-adapter-C644QK8l.mjs.map → progress-adapter-CjAeTxY_.mjs.map} +1 -1
- package/dist/{ref-advancement-DUZqsue6.mjs → ref-advancement-BkXlikCA.mjs} +1 -1
- package/dist/{ref-advancement-DUZqsue6.mjs.map → ref-advancement-BkXlikCA.mjs.map} +1 -1
- package/dist/schemas-CeGMYFYX.d.mts +191 -0
- package/dist/schemas-CeGMYFYX.d.mts.map +1 -0
- package/dist/schemas-KhXMzNA_.mjs +112 -0
- package/dist/schemas-KhXMzNA_.mjs.map +1 -0
- package/dist/telemetry-BIM4beEO.mjs +122 -0
- package/dist/telemetry-BIM4beEO.mjs.map +1 -0
- package/dist/{terminal-ui-5Y6mrg93.d.mts → terminal-ui-DGRNFWna.d.mts} +1 -1
- package/dist/terminal-ui-DGRNFWna.d.mts.map +1 -0
- package/dist/{types-Dt_SfqFm.d.mts → types-C_tYiJYx.d.mts} +53 -31
- package/dist/types-C_tYiJYx.d.mts.map +1 -0
- package/dist/{verify-DCA9Sldu.mjs → verify-DcOYZ1tH.mjs} +2 -2
- package/dist/{verify-DCA9Sldu.mjs.map → verify-DcOYZ1tH.mjs.map} +1 -1
- package/package.json +26 -22
- package/src/cli.ts +5 -0
- package/src/commands/contract-infer.ts +2 -2
- package/src/commands/db-update.ts +7 -1
- package/src/commands/init/index.ts +6 -35
- package/src/commands/init/init.ts +1 -14
- package/src/commands/init/inputs.ts +0 -75
- package/src/commands/inspect-live-schema.ts +10 -0
- package/src/commands/json/schemas.ts +195 -0
- package/src/commands/migrate.ts +527 -8
- package/src/commands/migration-check.ts +469 -134
- package/src/commands/migration-graph.ts +164 -91
- package/src/commands/migration-list.ts +72 -39
- package/src/commands/migration-log.ts +52 -102
- package/src/commands/migration-new.ts +2 -1
- package/src/commands/migration-plan.ts +2 -1
- package/src/commands/migration-show.ts +31 -66
- package/src/commands/migration-status-overlay.ts +61 -0
- package/src/commands/migration-status.ts +458 -1066
- package/src/commands/telemetry/index.ts +107 -0
- package/src/commands/telemetry/status.ts +67 -0
- package/src/control-api/client.ts +70 -9
- package/src/control-api/operations/contract-emit.ts +22 -2
- package/src/control-api/operations/db-init.ts +6 -3
- package/src/control-api/operations/{db-apply.ts → db-run.ts} +55 -14
- package/src/control-api/operations/db-update.ts +7 -4
- package/src/control-api/operations/db-verify.ts +15 -5
- package/src/control-api/operations/{migration-apply.ts → migrate.ts} +181 -80
- package/src/control-api/operations/{apply.ts → run-migration.ts} +33 -27
- package/src/control-api/types.ts +56 -29
- package/src/utils/cli-errors.ts +70 -2
- package/src/utils/formatters/errors.ts +11 -0
- package/src/utils/formatters/migration-graph-command-render.ts +239 -0
- package/src/utils/formatters/migration-graph-grid-layout.ts +1134 -0
- package/src/utils/formatters/migration-graph-labels.ts +408 -0
- package/src/utils/formatters/migration-graph-model.ts +103 -0
- package/src/utils/formatters/migration-graph-occlusion-render.ts +258 -0
- package/src/utils/formatters/migration-graph-rows.ts +128 -15
- package/src/utils/formatters/migration-graph-space-render.ts +188 -0
- package/src/utils/formatters/migration-list-data-column.ts +4 -91
- package/src/utils/formatters/migration-list-graph-topology.ts +72 -94
- package/src/utils/formatters/migration-list-render.ts +135 -71
- package/src/utils/formatters/migration-list-styler.ts +46 -5
- package/src/utils/formatters/migration-list-types.ts +5 -21
- package/src/utils/formatters/migration-log-table.ts +205 -0
- package/src/utils/formatters/migrations.ts +33 -11
- package/src/utils/global-flags.ts +35 -0
- package/src/utils/integrity-violation-to-check-failure.ts +28 -19
- package/src/utils/legend.ts +38 -0
- package/src/utils/migration-path-target.ts +60 -0
- package/src/utils/telemetry.ts +68 -32
- package/dist/client-KgJorIvG.mjs.map +0 -1
- package/dist/command-helpers-Bbw1GbwL.mjs.map +0 -1
- package/dist/commands/migration-list.mjs.map +0 -1
- package/dist/commands/migration-log.mjs.map +0 -1
- package/dist/commands/migration-status.mjs.map +0 -1
- package/dist/contract-emit-D-4jrNve.mjs.map +0 -1
- package/dist/contract-infer-D8uEbJuu.mjs.map +0 -1
- package/dist/graph-render-rFAqZujX.mjs +0 -1081
- package/dist/graph-render-rFAqZujX.mjs.map +0 -1
- package/dist/init-Cv9UzWL5.mjs.map +0 -1
- package/dist/inspect-live-schema-C6ohV_oQ.mjs.map +0 -1
- package/dist/migration-check-BiBJoYYW.mjs +0 -341
- package/dist/migration-check-BiBJoYYW.mjs.map +0 -1
- package/dist/migration-graph-D7DVUElV.mjs +0 -1232
- package/dist/migration-graph-D7DVUElV.mjs.map +0 -1
- package/dist/migration-list-styler-BRwF4-gy.mjs +0 -399
- package/dist/migration-list-styler-BRwF4-gy.mjs.map +0 -1
- package/dist/migration-plan-9DJ7q7_z.mjs.map +0 -1
- package/dist/migration-types-D2FW63pr.d.mts +0 -15
- package/dist/migration-types-D2FW63pr.d.mts.map +0 -1
- package/dist/migrations-Cv2jxNNK.mjs +0 -228
- package/dist/migrations-Cv2jxNNK.mjs.map +0 -1
- package/dist/terminal-ui-5Y6mrg93.d.mts.map +0 -1
- package/dist/types-Dt_SfqFm.d.mts.map +0 -1
- package/src/utils/formatters/graph-migration-mapper.ts +0 -235
- package/src/utils/formatters/graph-render.ts +0 -1323
- package/src/utils/formatters/graph-types.ts +0 -120
- package/src/utils/formatters/migration-graph-layout.ts +0 -1119
- package/src/utils/formatters/migration-graph-tree-render.ts +0 -459
|
@@ -0,0 +1,1134 @@
|
|
|
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 {
|
|
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,
|
|
21
|
+
} from './migration-graph-model';
|
|
22
|
+
import type { ClassifiedEdge, MigrationGraphRowModel } from './migration-graph-rows';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Internal: lane + rank assignment
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
interface LaneAssignment {
|
|
29
|
+
nodeLane: Map<string, number>;
|
|
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>;
|
|
39
|
+
/** Total number of lanes allocated. */
|
|
40
|
+
numLanes: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildLaneAssignment(
|
|
44
|
+
nodes: readonly (string | null)[],
|
|
45
|
+
edges: readonly ClassifiedEdge[],
|
|
46
|
+
): LaneAssignment {
|
|
47
|
+
// Separate forward (non-self) edges
|
|
48
|
+
const fwdEdges = edges.filter((e) => e.kind === 'forward' && e.from !== e.to);
|
|
49
|
+
|
|
50
|
+
// Build outbound/inbound adjacency sorted by dirName
|
|
51
|
+
const outbound = new Map<string, ClassifiedEdge[]>();
|
|
52
|
+
const inbound = new Map<string, ClassifiedEdge[]>();
|
|
53
|
+
for (const edge of fwdEdges) {
|
|
54
|
+
const ob = outbound.get(edge.from);
|
|
55
|
+
if (ob) ob.push(edge);
|
|
56
|
+
else outbound.set(edge.from, [edge]);
|
|
57
|
+
|
|
58
|
+
const ib = inbound.get(edge.to);
|
|
59
|
+
if (ib) ib.push(edge);
|
|
60
|
+
else inbound.set(edge.to, [edge]);
|
|
61
|
+
}
|
|
62
|
+
for (const list of outbound.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
63
|
+
for (const list of inbound.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
64
|
+
|
|
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
|
+
}
|
|
84
|
+
const nodeRank = new Map<string, number>();
|
|
85
|
+
for (const n of allNodes) nodeRank.set(n, 0);
|
|
86
|
+
for (let pass = 0; pass < allNodes.size; pass++) {
|
|
87
|
+
let changed = false;
|
|
88
|
+
for (const [from, es] of outbound) {
|
|
89
|
+
const base = nodeRank.get(from) ?? 0;
|
|
90
|
+
for (const e of es) {
|
|
91
|
+
const next = base + 1;
|
|
92
|
+
if (next > (nodeRank.get(e.to) ?? 0)) {
|
|
93
|
+
nodeRank.set(e.to, next);
|
|
94
|
+
changed = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!changed) break;
|
|
99
|
+
}
|
|
100
|
+
|
|
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.
|
|
104
|
+
const nodeLane = new Map<string, number>();
|
|
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
|
+
});
|
|
123
|
+
|
|
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
|
+
}
|
|
131
|
+
|
|
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
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (nextLane > totalLanes) totalLanes = nextLane;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { nodeLane, nodeRank, edgeLane, numLanes: totalLanes };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Internal: display order
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
interface NodeDisplay {
|
|
241
|
+
hash: string;
|
|
242
|
+
lane: number;
|
|
243
|
+
rank: number;
|
|
244
|
+
}
|
|
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
|
+
|
|
252
|
+
function computeDisplayOrder(
|
|
253
|
+
nodes: readonly (string | null)[],
|
|
254
|
+
nodeLane: Map<string, number>,
|
|
255
|
+
nodeRank: Map<string, number>,
|
|
256
|
+
): NodeDisplayOrSeparator[] {
|
|
257
|
+
const seen = new Set<string>();
|
|
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
|
+
|
|
270
|
+
for (const n of nodes) {
|
|
271
|
+
if (n === null) {
|
|
272
|
+
flushComponent();
|
|
273
|
+
result.push(null);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (seen.has(n)) continue;
|
|
277
|
+
seen.add(n);
|
|
278
|
+
componentBuffer.push({ hash: n, lane: nodeLane.get(n) ?? 0, rank: nodeRank.get(n) ?? 0 });
|
|
279
|
+
}
|
|
280
|
+
flushComponent();
|
|
281
|
+
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Internal: grid row builder
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
type CellsRow = Cell[];
|
|
290
|
+
|
|
291
|
+
/** Create an empty cell. */
|
|
292
|
+
function emptyCell(): Cell {
|
|
293
|
+
return { lines: [] };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// buildGrid — main entry point
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
export function buildGrid(
|
|
301
|
+
rowModel: MigrationGraphRowModel,
|
|
302
|
+
opts: GridOptions = {},
|
|
303
|
+
highlight: Highlight = { mode: 'flat', onPath: new Set() },
|
|
304
|
+
): Grid {
|
|
305
|
+
const colsPerLane = opts.colsPerLane ?? DEFAULT_COLS_PER_LANE;
|
|
306
|
+
const isFocus = highlight.mode === 'focus';
|
|
307
|
+
|
|
308
|
+
const { nodeLane, nodeRank, edgeLane, numLanes } = buildLaneAssignment(
|
|
309
|
+
rowModel.nodes,
|
|
310
|
+
rowModel.edges,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const displayOrder = computeDisplayOrder(rowModel.nodes, nodeLane, nodeRank);
|
|
314
|
+
|
|
315
|
+
// Display index per node (0 = topmost position; nulls skipped).
|
|
316
|
+
const displayIndex = new Map<string, number>();
|
|
317
|
+
let nodeIdx = 0;
|
|
318
|
+
for (const d of displayOrder) {
|
|
319
|
+
if (d !== null) {
|
|
320
|
+
displayIndex.set(d.hash, nodeIdx++);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Back-arc planning ────────────────────────────────────────────────────
|
|
325
|
+
// Each rollback edge runs against the forward grain. An *adjacent* rollback
|
|
326
|
+
// (target is the display-neighbour directly below the source) is a plain ↓ in
|
|
327
|
+
// the source's own lane. A *node-skipping* rollback is routed on its own
|
|
328
|
+
// back-lane to the right: it tees off the source node row (○─╮), runs a
|
|
329
|
+
// vertical │ down its back-lane, and lands into the target node (◂╯).
|
|
330
|
+
//
|
|
331
|
+
// Three independent numbers per routed back-arc:
|
|
332
|
+
// geomLane — the column its rail occupies. Outermost (largest) goes to the
|
|
333
|
+
// arc reaching the lowest target (ties: higher source first), so
|
|
334
|
+
// interleaving spans cross and nested spans nest cleanly.
|
|
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.
|
|
344
|
+
interface RoutedBackArc {
|
|
345
|
+
readonly edge: ClassifiedEdge;
|
|
346
|
+
readonly sourceIndex: number;
|
|
347
|
+
readonly targetIndex: number;
|
|
348
|
+
readonly geomLane: number;
|
|
349
|
+
readonly colourLane: number;
|
|
350
|
+
readonly planeLane: number;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const rollbackEdges = rowModel.edges.filter((e) => e.kind === 'rollback' && e.from !== e.to);
|
|
354
|
+
|
|
355
|
+
const adjacentRollbacks: ClassifiedEdge[] = [];
|
|
356
|
+
const skippingRollbacks: ClassifiedEdge[] = [];
|
|
357
|
+
for (const e of rollbackEdges) {
|
|
358
|
+
const si = displayIndex.get(e.from);
|
|
359
|
+
const ti = displayIndex.get(e.to);
|
|
360
|
+
if (si === undefined || ti === undefined) continue;
|
|
361
|
+
// Adjacent: target sits directly below the source in display order.
|
|
362
|
+
if (ti === si + 1) adjacentRollbacks.push(e);
|
|
363
|
+
else skippingRollbacks.push(e);
|
|
364
|
+
}
|
|
365
|
+
|
|
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
|
|
384
|
+
});
|
|
385
|
+
const numTargetGroups = sortedTargetKeys.length;
|
|
386
|
+
const geomLaneOf = new Map<string, number>();
|
|
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
|
+
}
|
|
393
|
+
});
|
|
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
|
+
|
|
499
|
+
const routedBackArcs: RoutedBackArc[] = skippingRollbacks.map((e) => ({
|
|
500
|
+
edge: e,
|
|
501
|
+
sourceIndex: displayIndex.get(e.from) ?? 0,
|
|
502
|
+
targetIndex: displayIndex.get(e.to) ?? 0,
|
|
503
|
+
geomLane: geomLaneOf.get(e.migrationHash) ?? numLanes,
|
|
504
|
+
colourLane: colourLaneOf.get(e.migrationHash) ?? 0,
|
|
505
|
+
planeLane: planeLaneOf.get(e.migrationHash) ?? numLanes,
|
|
506
|
+
}));
|
|
507
|
+
|
|
508
|
+
const backArcsBySource = new Map<string, RoutedBackArc[]>();
|
|
509
|
+
const backArcsByTarget = new Map<string, RoutedBackArc[]>();
|
|
510
|
+
for (const arc of routedBackArcs) {
|
|
511
|
+
const sb = backArcsBySource.get(arc.edge.from);
|
|
512
|
+
if (sb) sb.push(arc);
|
|
513
|
+
else backArcsBySource.set(arc.edge.from, [arc]);
|
|
514
|
+
const tb = backArcsByTarget.get(arc.edge.to);
|
|
515
|
+
if (tb) tb.push(arc);
|
|
516
|
+
else backArcsByTarget.set(arc.edge.to, [arc]);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const adjacentBySource = new Map<string, ClassifiedEdge[]>();
|
|
520
|
+
const adjacentByTarget = new Map<string, ClassifiedEdge[]>();
|
|
521
|
+
for (const e of adjacentRollbacks) {
|
|
522
|
+
const b = adjacentBySource.get(e.from);
|
|
523
|
+
if (b) b.push(e);
|
|
524
|
+
else adjacentBySource.set(e.from, [e]);
|
|
525
|
+
const t = adjacentByTarget.get(e.to);
|
|
526
|
+
if (t) t.push(e);
|
|
527
|
+
else adjacentByTarget.set(e.to, [e]);
|
|
528
|
+
}
|
|
529
|
+
for (const list of adjacentBySource.values())
|
|
530
|
+
list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
531
|
+
|
|
532
|
+
const numBackLanes = numTargetGroups;
|
|
533
|
+
const totalCols = (numLanes + numBackLanes) * colsPerLane;
|
|
534
|
+
|
|
535
|
+
// Build edge lookup maps (classified)
|
|
536
|
+
const fwdEdges = rowModel.edges.filter((e) => e.kind === 'forward' && e.from !== e.to);
|
|
537
|
+
const selfEdges = rowModel.edges.filter((e) => e.kind === 'self');
|
|
538
|
+
|
|
539
|
+
// outbound sorted by migrationHash
|
|
540
|
+
const outboundFwd = new Map<string, ClassifiedEdge[]>();
|
|
541
|
+
const inboundFwd = new Map<string, ClassifiedEdge[]>();
|
|
542
|
+
for (const e of fwdEdges) {
|
|
543
|
+
const ob = outboundFwd.get(e.from);
|
|
544
|
+
if (ob) ob.push(e);
|
|
545
|
+
else outboundFwd.set(e.from, [e]);
|
|
546
|
+
const ib = inboundFwd.get(e.to);
|
|
547
|
+
if (ib) ib.push(e);
|
|
548
|
+
else inboundFwd.set(e.to, [e]);
|
|
549
|
+
}
|
|
550
|
+
for (const list of outboundFwd.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
551
|
+
for (const list of inboundFwd.values()) list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
552
|
+
|
|
553
|
+
const selfEdgesByNode = new Map<string, ClassifiedEdge[]>();
|
|
554
|
+
for (const e of selfEdges) {
|
|
555
|
+
const bucket = selfEdgesByNode.get(e.from);
|
|
556
|
+
if (bucket) bucket.push(e);
|
|
557
|
+
else selfEdgesByNode.set(e.from, [e]);
|
|
558
|
+
}
|
|
559
|
+
for (const list of selfEdgesByNode.values())
|
|
560
|
+
list.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
561
|
+
|
|
562
|
+
// ── Role + plane: mode/z-order seam ──────────────────────────────────────
|
|
563
|
+
// role(migrationHash): focus → on-path/off-path from highlight.onPath; flat → undefined.
|
|
564
|
+
function roleOf(migrationHash: string): PathRole | undefined {
|
|
565
|
+
if (!isFocus) return undefined;
|
|
566
|
+
return highlight.onPath.has(migrationHash) ? 'on-path' : 'off-path';
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// On-path node set: a node is on-path iff an on-path edge touches it (from or
|
|
570
|
+
// to) — forward, self, OR rollback (a back-arc's endpoints are on its route).
|
|
571
|
+
const onPathNodes = new Set<string>();
|
|
572
|
+
if (isFocus) {
|
|
573
|
+
for (const e of [...fwdEdges, ...selfEdges, ...rollbackEdges]) {
|
|
574
|
+
if (highlight.onPath.has(e.migrationHash)) {
|
|
575
|
+
onPathNodes.add(e.from);
|
|
576
|
+
onPathNodes.add(e.to);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function nodeRoleOf(hash: string): PathRole | undefined {
|
|
581
|
+
if (!isFocus) return undefined;
|
|
582
|
+
return onPathNodes.has(hash) ? 'on-path' : 'off-path';
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// planeOf — z-order. Lower number = drawn on top.
|
|
586
|
+
// flat: trunk on top → plane = lane (lane 0 topmost).
|
|
587
|
+
// focus: on-path on top → on-path = plane 0; off-path sits beneath it,
|
|
588
|
+
// ordered by lane so a deterministic owner survives among off-path lines.
|
|
589
|
+
function planeOf(lane: number, role: PathRole | undefined): number {
|
|
590
|
+
if (!isFocus) return lane;
|
|
591
|
+
return role === 'on-path' ? 0 : lane + 1;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ── LineRef + cell builders (role-aware) ─────────────────────────────────
|
|
595
|
+
function lineRefFor(edge: ClassifiedEdge, lane: number): LineRef {
|
|
596
|
+
return {
|
|
597
|
+
migrationHash: edge.migrationHash,
|
|
598
|
+
dirName: edge.dirName,
|
|
599
|
+
lane,
|
|
600
|
+
role: roleOf(edge.migrationHash),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Synthetic LineRef for a lane carrying a representative edge's role (pass-through). */
|
|
605
|
+
function passLineRef(lane: number, dirName: string, migHash: string): LineRef {
|
|
606
|
+
return { migrationHash: migHash, dirName, lane, role: roleOf(migHash) };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function vertCell(line: LineRef): Cell {
|
|
610
|
+
return {
|
|
611
|
+
lines: [
|
|
612
|
+
{
|
|
613
|
+
line,
|
|
614
|
+
directions: new Set<Direction>(['up', 'down']),
|
|
615
|
+
plane: planeOf(line.lane, line.role),
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function dirCell(line: LineRef, dirs: ReadonlySet<Direction>): Cell {
|
|
622
|
+
return { lines: [{ line, directions: dirs, plane: planeOf(line.lane, line.role) }] };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function nodeCell(nodeRef: NodeRef): Cell {
|
|
626
|
+
return { node: nodeRef, lines: [] };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Pass-through colour follows the edge CURRENTLY occupying a lane at this row,
|
|
630
|
+
// not a lane-wide average. A single lane carries different edges (with different
|
|
631
|
+
// roles) over its vertical extent — e.g. lane 0 below a fork carries the trunk
|
|
632
|
+
// branch (off-path) above the fork node and the trunk's parent edge (on-path)
|
|
633
|
+
// below it. We track the active edge per lane as we descend top-to-bottom and
|
|
634
|
+
// colour pass-through verticals from it. `laneCurrentEdge[L]` = the edge whose
|
|
635
|
+
// vertical body currently runs through lane L at the row being emitted.
|
|
636
|
+
const laneCurrentEdge = new Map<number, ClassifiedEdge>();
|
|
637
|
+
|
|
638
|
+
function getRepLine(lane: number): LineRef {
|
|
639
|
+
const e = laneCurrentEdge.get(lane);
|
|
640
|
+
if (e) return lineRefFor(e, lane);
|
|
641
|
+
return passLineRef(lane, `lane${lane}`, `lane${lane}`);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Active lanes: set of lane indices currently visible (vertical passes through them)
|
|
645
|
+
const activeLanes = new Set<number>();
|
|
646
|
+
|
|
647
|
+
const grid: Cell[][] = [];
|
|
648
|
+
|
|
649
|
+
function makeRow(): CellsRow {
|
|
650
|
+
return Array.from({ length: totalCols }, () => emptyCell());
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Place vertical pass-throughs for all active lanes in a row, skipping specified lanes.
|
|
654
|
+
function placeVerticals(row: CellsRow, skip: Set<number>): void {
|
|
655
|
+
for (const lane of activeLanes) {
|
|
656
|
+
if (skip.has(lane)) continue;
|
|
657
|
+
const railCol = lane * colsPerLane;
|
|
658
|
+
const cell = row[railCol];
|
|
659
|
+
if (cell !== undefined && cell.lines.length === 0 && !cell.node) {
|
|
660
|
+
row[railCol] = vertCell(getRepLine(lane));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ── Back-arc helpers ──────────────────────────────────────────────────────
|
|
666
|
+
// Active routed back-arcs whose vertical currently runs through their geomLane.
|
|
667
|
+
const activeBackArcs = new Set<RoutedBackArc>();
|
|
668
|
+
|
|
669
|
+
// A back-arc's LineRef carries its colourLane (not its geomLane) so colour is
|
|
670
|
+
// read off the lane that drives the rotation, independent of column placement.
|
|
671
|
+
function backArcLine(arc: RoutedBackArc): LineRef {
|
|
672
|
+
return {
|
|
673
|
+
migrationHash: arc.edge.migrationHash,
|
|
674
|
+
dirName: arc.edge.dirName,
|
|
675
|
+
lane: arc.colourLane,
|
|
676
|
+
role: roleOf(arc.edge.migrationHash),
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function backArcPlane(arc: RoutedBackArc): number {
|
|
681
|
+
const role = roleOf(arc.edge.migrationHash);
|
|
682
|
+
if (!isFocus) return arc.planeLane;
|
|
683
|
+
return role === 'on-path' ? 0 : arc.planeLane + 1;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Compose a CellLine into a row cell (never overwrite — occlusion arbitrates).
|
|
687
|
+
function composeLine(
|
|
688
|
+
row: CellsRow,
|
|
689
|
+
col: number,
|
|
690
|
+
line: LineRef,
|
|
691
|
+
dirs: ReadonlySet<Direction>,
|
|
692
|
+
plane: number,
|
|
693
|
+
extra?: { landingArrow?: boolean },
|
|
694
|
+
): void {
|
|
695
|
+
const existing = row[col];
|
|
696
|
+
const cellLine: CellLine = {
|
|
697
|
+
line,
|
|
698
|
+
directions: dirs,
|
|
699
|
+
plane,
|
|
700
|
+
...(extra?.landingArrow ? { landingArrow: true } : {}),
|
|
701
|
+
};
|
|
702
|
+
if (existing && (existing.lines.length > 0 || existing.node)) {
|
|
703
|
+
row[col] = { ...existing, lines: [...existing.lines, cellLine] };
|
|
704
|
+
} else {
|
|
705
|
+
row[col] = { lines: [cellLine] };
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Place verticals for every active back-arc on this row (in its geomLane rail).
|
|
710
|
+
function placeBackVerticals(row: CellsRow): void {
|
|
711
|
+
for (const arc of activeBackArcs) {
|
|
712
|
+
const railCol = arc.geomLane * colsPerLane;
|
|
713
|
+
composeLine(
|
|
714
|
+
row,
|
|
715
|
+
railCol,
|
|
716
|
+
backArcLine(arc),
|
|
717
|
+
new Set<Direction>(['up', 'down']),
|
|
718
|
+
backArcPlane(arc),
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
placeAdjacentOverlays(row);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Adjacent rollbacks share the source's own lane: their vertical body overlays
|
|
725
|
+
// the forward trunk between source and target. In focus, an on-path adjacent
|
|
726
|
+
// rollback lifts that segment of the trunk to the top plane (drawn green); in
|
|
727
|
+
// flat it sits at the same plane/colour as the trunk, so it is a no-op there.
|
|
728
|
+
interface ActiveAdjacent {
|
|
729
|
+
readonly lane: number;
|
|
730
|
+
readonly edge: ClassifiedEdge;
|
|
731
|
+
}
|
|
732
|
+
const activeAdjacent = new Set<ActiveAdjacent>();
|
|
733
|
+
|
|
734
|
+
function placeAdjacentOverlays(row: CellsRow): void {
|
|
735
|
+
for (const adj of activeAdjacent) {
|
|
736
|
+
const railCol = adj.lane * colsPerLane;
|
|
737
|
+
const cell = row[railCol];
|
|
738
|
+
if (cell?.node) continue; // never overlay a node marker
|
|
739
|
+
const line = lineRefFor(adj.edge, adj.lane);
|
|
740
|
+
composeLine(
|
|
741
|
+
row,
|
|
742
|
+
railCol,
|
|
743
|
+
line,
|
|
744
|
+
new Set<Direction>(['up', 'down']),
|
|
745
|
+
planeOf(adj.lane, line.role),
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Tee a routed back-arc off its source node row: a horizontal bridge from the
|
|
751
|
+
// node's connector column across to the back-lane rail, ending in a ╮ corner
|
|
752
|
+
// (down+left). Composed (not overwritten) so it occludes / is occluded by any
|
|
753
|
+
// back-arc vertical it crosses.
|
|
754
|
+
function emitBackArcTee(row: CellsRow, nodeLaneNum: number, arc: RoutedBackArc): void {
|
|
755
|
+
const nodeRail = nodeLaneNum * colsPerLane;
|
|
756
|
+
const geomRail = arc.geomLane * colsPerLane;
|
|
757
|
+
const line = backArcLine(arc);
|
|
758
|
+
const plane = backArcPlane(arc);
|
|
759
|
+
for (let col = nodeRail + 1; col < geomRail; col++) {
|
|
760
|
+
composeLine(row, col, line, new Set<Direction>(['left', 'right']), plane);
|
|
761
|
+
}
|
|
762
|
+
composeLine(row, geomRail, line, new Set<Direction>(['down', 'left']), plane);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Land a routed back-arc into its target node row: a ◂ arrowhead in the node's
|
|
766
|
+
// connector column, a horizontal bridge across to the back-lane rail, ending in
|
|
767
|
+
// a ╯ corner (up+left). Composed so the on-top arc draws the anchor and the
|
|
768
|
+
// others yield their corners beneath it (occlusion arbitrates).
|
|
769
|
+
function emitBackArcLanding(row: CellsRow, nodeLaneNum: number, arc: RoutedBackArc): void {
|
|
770
|
+
const nodeRail = nodeLaneNum * colsPerLane;
|
|
771
|
+
const geomRail = arc.geomLane * colsPerLane;
|
|
772
|
+
const line = backArcLine(arc);
|
|
773
|
+
const plane = backArcPlane(arc);
|
|
774
|
+
composeLine(row, nodeRail + 1, line, new Set<Direction>(['left', 'right']), plane, {
|
|
775
|
+
landingArrow: true,
|
|
776
|
+
});
|
|
777
|
+
for (let col = nodeRail + 2; col < geomRail; col++) {
|
|
778
|
+
composeLine(row, col, line, new Set<Direction>(['left', 'right']), plane);
|
|
779
|
+
}
|
|
780
|
+
composeLine(row, geomRail, line, new Set<Direction>(['up', 'left']), plane);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Emit a connector row (fork or merge).
|
|
784
|
+
//
|
|
785
|
+
// The CONTINUOUS lane gets the unbroken vertical/sweep; every other
|
|
786
|
+
// participating lane yields into its own corner. In flat mode the continuous
|
|
787
|
+
// lane is the trunk (lane of the node); in focus mode it is the on-path lane
|
|
788
|
+
// (the inbound/outbound edge whose migration is on-path), so the chosen route
|
|
789
|
+
// is drawn as one continuous green line sweeping the merge/fork.
|
|
790
|
+
//
|
|
791
|
+
// Geometry is identical regardless of which lane is continuous; only the
|
|
792
|
+
// NODE-ANCHOR glyph at the trunk rail changes:
|
|
793
|
+
// continuous == trunk → │ (vertical, the trunk passes straight through)
|
|
794
|
+
// continuous == a branch → corner toward that branch
|
|
795
|
+
// merge: ╰ (up+right) fork: ╭ (down+right)
|
|
796
|
+
// The branch's own rail always carries its yield corner (merge ╮ / fork ╯), and
|
|
797
|
+
// the cells between carry horizontals. The continuous (on-path) sweep is placed
|
|
798
|
+
// on the top plane so it occludes the trunk's vertical at the node anchor.
|
|
799
|
+
function emitConnectorRow(
|
|
800
|
+
trunkLane: number,
|
|
801
|
+
branchEntries: readonly { lane: number; edge: ClassifiedEdge }[],
|
|
802
|
+
connectorType: 'fork' | 'merge',
|
|
803
|
+
trunkEdge: ClassifiedEdge | undefined,
|
|
804
|
+
): CellsRow {
|
|
805
|
+
const row = makeRow();
|
|
806
|
+
const sorted = [...branchEntries].sort((a, b) => a.lane - b.lane);
|
|
807
|
+
if (sorted.length === 0) return row;
|
|
808
|
+
|
|
809
|
+
const branchByLane = new Map<number, ClassifiedEdge>();
|
|
810
|
+
for (const b of sorted) branchByLane.set(b.lane, b.edge);
|
|
811
|
+
|
|
812
|
+
// Continuous lane: the on-path participant in focus, else the trunk.
|
|
813
|
+
let continuousLane = trunkLane;
|
|
814
|
+
if (isFocus) {
|
|
815
|
+
if (trunkEdge && highlight.onPath.has(trunkEdge.migrationHash)) {
|
|
816
|
+
continuousLane = trunkLane;
|
|
817
|
+
} else {
|
|
818
|
+
const onPathBranch = sorted.find((b) => highlight.onPath.has(b.edge.migrationHash));
|
|
819
|
+
if (onPathBranch) continuousLane = onPathBranch.lane;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const trunkRailCol = trunkLane * colsPerLane;
|
|
824
|
+
const continuousRailCol = continuousLane * colsPerLane;
|
|
825
|
+
|
|
826
|
+
// Add a CellLine to a cell (compose, don't overwrite) so occlusion arbitrates.
|
|
827
|
+
function addLine(col: number, line: LineRef, dirs: ReadonlySet<Direction>): void {
|
|
828
|
+
const existing = row[col];
|
|
829
|
+
const cellLine: CellLine = { line, directions: dirs, plane: planeOf(line.lane, line.role) };
|
|
830
|
+
row[col] =
|
|
831
|
+
existing && existing.lines.length > 0
|
|
832
|
+
? { ...existing, lines: [...existing.lines, cellLine] }
|
|
833
|
+
: { lines: [cellLine] };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const cornerLeftDown: ReadonlySet<Direction> =
|
|
837
|
+
connectorType === 'merge'
|
|
838
|
+
? new Set<Direction>(['left', 'down'])
|
|
839
|
+
: new Set<Direction>(['left', 'up']);
|
|
840
|
+
|
|
841
|
+
// ── Base plane: every yielding branch lays its own corner + the horizontal
|
|
842
|
+
// segment to its left (up to the previous branch's rail). These sit on the
|
|
843
|
+
// branch's lane plane; where the continuous sweep crosses them it occludes.
|
|
844
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
845
|
+
const b = sorted[i]!;
|
|
846
|
+
if (b.lane === continuousLane) continue; // continuous drawn separately, on top
|
|
847
|
+
const branchLine = lineRefFor(b.edge, b.lane);
|
|
848
|
+
const railCol = b.lane * colsPerLane;
|
|
849
|
+
addLine(railCol, branchLine, cornerLeftDown);
|
|
850
|
+
const leftBound = i === 0 ? trunkRailCol + 1 : sorted[i - 1]!.lane * colsPerLane + 1;
|
|
851
|
+
for (let col = leftBound; col < railCol; col++) {
|
|
852
|
+
addLine(col, branchLine, new Set<Direction>(['left', 'right']));
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// ── The continuous line ──────────────────────────────────────────────────
|
|
857
|
+
const continuousLine: LineRef =
|
|
858
|
+
continuousLane === trunkLane
|
|
859
|
+
? trunkEdge
|
|
860
|
+
? lineRefFor(trunkEdge, trunkLane)
|
|
861
|
+
: getRepLine(trunkLane)
|
|
862
|
+
: lineRefFor(branchByLane.get(continuousLane)!, continuousLane);
|
|
863
|
+
|
|
864
|
+
if (continuousLane === trunkLane) {
|
|
865
|
+
// Trunk passes straight through the node anchor (│), branches yield to it.
|
|
866
|
+
addLine(trunkRailCol, continuousLine, new Set<Direction>(['up', 'down']));
|
|
867
|
+
} else {
|
|
868
|
+
// A branch is continuous: it sweeps from the node anchor across to its own
|
|
869
|
+
// rail, on the TOP plane, occluding the trunk vertical and any intermediate
|
|
870
|
+
// yielding branch corners it passes over.
|
|
871
|
+
const anchorDirs: ReadonlySet<Direction> =
|
|
872
|
+
connectorType === 'merge'
|
|
873
|
+
? new Set<Direction>(['up', 'right'])
|
|
874
|
+
: new Set<Direction>(['down', 'right']);
|
|
875
|
+
addLine(trunkRailCol, continuousLine, anchorDirs);
|
|
876
|
+
for (let col = trunkRailCol + 1; col < continuousRailCol; col++) {
|
|
877
|
+
addLine(col, continuousLine, new Set<Direction>(['left', 'right']));
|
|
878
|
+
}
|
|
879
|
+
addLine(continuousRailCol, continuousLine, cornerLeftDown);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Other active lanes (not trunk, not branch): vertical pass-through.
|
|
883
|
+
const skipSet = new Set<number>([trunkLane, ...sorted.map((b) => b.lane)]);
|
|
884
|
+
placeVerticals(row, skipSet);
|
|
885
|
+
placeBackVerticals(row);
|
|
886
|
+
|
|
887
|
+
return row;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Process each node in display order; null = component boundary → separator row
|
|
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
|
+
|
|
900
|
+
const { hash: nodeHash } = nodeDisplay;
|
|
901
|
+
const nodeLaneNum = nodeLane.get(nodeHash) ?? 0;
|
|
902
|
+
|
|
903
|
+
activeLanes.add(nodeLaneNum);
|
|
904
|
+
|
|
905
|
+
// ── 1. Fork connector (BEFORE the node row) ──────────────────────────
|
|
906
|
+
const outEdges = outboundFwd.get(nodeHash) ?? [];
|
|
907
|
+
if (outEdges.length > 1) {
|
|
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;
|
|
916
|
+
const branchEntries = outEdges
|
|
917
|
+
.slice(1)
|
|
918
|
+
.map((e) => ({ lane: edgeLane.get(e.migrationHash) ?? nodeLane.get(e.to) ?? 0, edge: e }))
|
|
919
|
+
.filter((b) => b.lane !== trunkChildLane && activeLanes.has(b.lane));
|
|
920
|
+
|
|
921
|
+
if (branchEntries.length > 0) {
|
|
922
|
+
const trunkEdge = outEdges[0];
|
|
923
|
+
const connRow = emitConnectorRow(nodeLaneNum, branchEntries, 'fork', trunkEdge);
|
|
924
|
+
grid.push(connRow);
|
|
925
|
+
assertSingleOwner(connRow, isFocus);
|
|
926
|
+
|
|
927
|
+
for (const b of branchEntries) activeLanes.delete(b.lane);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ── 2. Self-loop rows (BEFORE the node row) ───────────────────────────
|
|
932
|
+
const selfMigrations = selfEdgesByNode.get(nodeHash) ?? [];
|
|
933
|
+
for (const selfEdge of selfMigrations) {
|
|
934
|
+
const row = makeRow();
|
|
935
|
+
const railCol = nodeLaneNum * colsPerLane;
|
|
936
|
+
const connCol = nodeLaneNum * colsPerLane + 1;
|
|
937
|
+
const line = lineRefFor(selfEdge, nodeLaneNum);
|
|
938
|
+
row[railCol] = vertCell(line);
|
|
939
|
+
row[connCol] = {
|
|
940
|
+
lines: [
|
|
941
|
+
{
|
|
942
|
+
line,
|
|
943
|
+
directions: new Set<Direction>(),
|
|
944
|
+
plane: planeOf(nodeLaneNum, line.role),
|
|
945
|
+
selfLoop: true,
|
|
946
|
+
},
|
|
947
|
+
],
|
|
948
|
+
};
|
|
949
|
+
placeVerticals(row, new Set([nodeLaneNum]));
|
|
950
|
+
placeBackVerticals(row);
|
|
951
|
+
grid.push(row);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ── 3. Node row ────────────────────────────────────────────────────────
|
|
955
|
+
{
|
|
956
|
+
const row = makeRow();
|
|
957
|
+
const railCol = nodeLaneNum * colsPerLane;
|
|
958
|
+
const nodeRef: NodeRef = {
|
|
959
|
+
contractHash: nodeHash,
|
|
960
|
+
isEmpty: nodeHash === EMPTY_CONTRACT_HASH,
|
|
961
|
+
lane: nodeLaneNum,
|
|
962
|
+
role: nodeRoleOf(nodeHash),
|
|
963
|
+
};
|
|
964
|
+
row[railCol] = nodeCell(nodeRef);
|
|
965
|
+
placeVerticals(row, new Set([nodeLaneNum]));
|
|
966
|
+
|
|
967
|
+
// A back-arc landing ends its vertical at this row, replacing it with a ╯
|
|
968
|
+
// corner — so deactivate landing arcs BEFORE placing back verticals. An
|
|
969
|
+
// adjacent rollback's overlay likewise ends at its target node.
|
|
970
|
+
const landingArcs = backArcsByTarget.get(nodeHash) ?? [];
|
|
971
|
+
for (const arc of landingArcs) activeBackArcs.delete(arc);
|
|
972
|
+
for (const adj of [...activeAdjacent]) {
|
|
973
|
+
if (adj.edge.to === nodeHash) activeAdjacent.delete(adj);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
placeBackVerticals(row);
|
|
977
|
+
|
|
978
|
+
// Back-arc landing: arcs targeting this node sweep from the node anchor
|
|
979
|
+
// (◂ arrowhead) across to their own rail corner (╯). The on-top arc draws
|
|
980
|
+
// the anchor; others yield their corners beneath (occlusion arbitrates).
|
|
981
|
+
for (const arc of landingArcs) {
|
|
982
|
+
emitBackArcLanding(row, nodeLaneNum, arc);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Back-arc tee: arcs sourced at this node tee off the node row into their
|
|
986
|
+
// back-lane (─ bridge + ╮ corner). The vertical begins on the next row.
|
|
987
|
+
const teeArcs = backArcsBySource.get(nodeHash) ?? [];
|
|
988
|
+
for (const arc of teeArcs) {
|
|
989
|
+
emitBackArcTee(row, nodeLaneNum, arc);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
grid.push(row);
|
|
993
|
+
|
|
994
|
+
// Activate the back-arc verticals AFTER the node row so the rail runs from
|
|
995
|
+
// the next row down to (but not including) the target landing row.
|
|
996
|
+
for (const arc of teeArcs) activeBackArcs.add(arc);
|
|
997
|
+
|
|
998
|
+
// Activate adjacent-rollback overlays sourced here (their trunk overlay
|
|
999
|
+
// runs from the next row down to the target node).
|
|
1000
|
+
for (const adj of adjacentBySource.get(nodeHash) ?? []) {
|
|
1001
|
+
activeAdjacent.add({ lane: nodeLaneNum, edge: adj });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Inbound forward edges run down their lanes below this node. Record each as
|
|
1006
|
+
// its lane's current edge NOW (before emitting the back-arc arrow rows, merge
|
|
1007
|
+
// connector, and migration rows) so pass-through verticals colour from the
|
|
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.
|
|
1022
|
+
const inEdges = inboundFwd.get(nodeHash) ?? [];
|
|
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
|
+
});
|
|
1029
|
+
for (const edge of inEdges) {
|
|
1030
|
+
laneCurrentEdge.set(edgeLaneFor(edge), edge);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ── 3b. Back-arc arrow rows ──────────────────────────────────────────────
|
|
1034
|
+
// For each routed arc sourced here, a │↓ arrow row in its back-lane sits
|
|
1035
|
+
// directly below the source node (before the source node's forward inbound
|
|
1036
|
+
// migration rows).
|
|
1037
|
+
{
|
|
1038
|
+
const teeArcs = backArcsBySource.get(nodeHash) ?? [];
|
|
1039
|
+
for (const arc of teeArcs) {
|
|
1040
|
+
const row = makeRow();
|
|
1041
|
+
const railCol = arc.geomLane * colsPerLane;
|
|
1042
|
+
const connCol = railCol + 1;
|
|
1043
|
+
const line = backArcLine(arc);
|
|
1044
|
+
const plane = backArcPlane(arc);
|
|
1045
|
+
composeLine(row, railCol, line, new Set<Direction>(['up', 'down']), plane);
|
|
1046
|
+
composeLine(row, connCol, line, new Set<Direction>(['down']), plane);
|
|
1047
|
+
placeVerticals(row, new Set<number>());
|
|
1048
|
+
placeBackVerticals(row);
|
|
1049
|
+
grid.push(row);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ── 4. Merge connector (AFTER the node row) ────────────────────────────
|
|
1054
|
+
if (inEdges.length > 1) {
|
|
1055
|
+
const branchEntries = inEdges.slice(1).map((e) => ({ lane: edgeLaneFor(e), edge: e }));
|
|
1056
|
+
|
|
1057
|
+
const trunkEdge = inEdges[0];
|
|
1058
|
+
const connRow = emitConnectorRow(nodeLaneNum, branchEntries, 'merge', trunkEdge);
|
|
1059
|
+
grid.push(connRow);
|
|
1060
|
+
assertSingleOwner(connRow, isFocus);
|
|
1061
|
+
|
|
1062
|
+
for (const b of branchEntries) activeLanes.add(b.lane);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ── 5. Migration rows (one per inbound edge, ordered by edge lane) ─────
|
|
1066
|
+
for (const edge of inEdges) {
|
|
1067
|
+
const eLane = edgeLaneFor(edge);
|
|
1068
|
+
const row = makeRow();
|
|
1069
|
+
const railCol = eLane * colsPerLane;
|
|
1070
|
+
const connCol = eLane * colsPerLane + 1;
|
|
1071
|
+
const line = lineRefFor(edge, eLane);
|
|
1072
|
+
|
|
1073
|
+
row[railCol] = vertCell(line);
|
|
1074
|
+
row[connCol] = dirCell(line, new Set<Direction>(['up']));
|
|
1075
|
+
|
|
1076
|
+
placeVerticals(row, new Set([eLane]));
|
|
1077
|
+
placeBackVerticals(row);
|
|
1078
|
+
grid.push(row);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// ── 5b. Adjacent rollback ↓ rows ─────────────────────────────────────────
|
|
1082
|
+
// An adjacent rollback (target is the display-neighbour directly below) is a
|
|
1083
|
+
// plain ↓ in the source's own lane — mirror of the forward ↑ — emitted after
|
|
1084
|
+
// the source node's forward inbound rows, directly above the target node.
|
|
1085
|
+
{
|
|
1086
|
+
const adjacents = adjacentBySource.get(nodeHash) ?? [];
|
|
1087
|
+
for (const adj of adjacents) {
|
|
1088
|
+
const row = makeRow();
|
|
1089
|
+
const connCol = nodeLaneNum * colsPerLane + 1;
|
|
1090
|
+
const line = lineRefFor(adj, nodeLaneNum);
|
|
1091
|
+
const plane = planeOf(nodeLaneNum, line.role);
|
|
1092
|
+
// The rail │ belongs to the trunk passing through (drawn by placeVerticals
|
|
1093
|
+
// from the lane's current forward edge); only the ↓ arrow is the rollback.
|
|
1094
|
+
composeLine(row, connCol, line, new Set<Direction>(['down']), plane);
|
|
1095
|
+
placeVerticals(row, new Set<number>());
|
|
1096
|
+
placeBackVerticals(row);
|
|
1097
|
+
grid.push(row);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// ── 6. Root lane deactivation ─────────────────────────────────────────
|
|
1102
|
+
if (inEdges.length === 0) {
|
|
1103
|
+
activeLanes.delete(nodeLaneNum);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
return grid;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// ---------------------------------------------------------------------------
|
|
1111
|
+
// Single-owner invariant — after building a connector row, assert that every
|
|
1112
|
+
// cell has at most one DRAWABLE owner once occlusion (topmost plane) is applied.
|
|
1113
|
+
// In focus mode a tie at the same plane between an on-path and an off-path line
|
|
1114
|
+
// would be a colour ambiguity, so we additionally assert that at the top plane
|
|
1115
|
+
// of each cell exactly one role survives.
|
|
1116
|
+
// ---------------------------------------------------------------------------
|
|
1117
|
+
function assertSingleOwner(row: CellsRow, isFocus: boolean): void {
|
|
1118
|
+
for (const cell of row) {
|
|
1119
|
+
if (cell.lines.length <= 1) continue;
|
|
1120
|
+
let topPlane = Number.POSITIVE_INFINITY;
|
|
1121
|
+
for (const cl of cell.lines) if (cl.plane < topPlane) topPlane = cl.plane;
|
|
1122
|
+
const top = cell.lines.filter((cl: CellLine) => cl.plane === topPlane);
|
|
1123
|
+
if (top.length > 1) {
|
|
1124
|
+
if (isFocus) {
|
|
1125
|
+
const roles = new Set(top.map((cl) => cl.line.role));
|
|
1126
|
+
if (roles.size > 1) {
|
|
1127
|
+
throw new Error(
|
|
1128
|
+
'migration-graph layout: single-owner invariant violated — two differently-roled lines share the top plane in one cell',
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|