turbometro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/bin/turbometro.js +2 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +107 -0
  6. package/dist/find-root.d.ts +2 -0
  7. package/dist/find-root.js +14 -0
  8. package/dist/graph.d.ts +14 -0
  9. package/dist/graph.js +74 -0
  10. package/dist/layout.d.ts +19 -0
  11. package/dist/layout.js +15 -0
  12. package/dist/parse-turbo.d.ts +5 -0
  13. package/dist/parse-turbo.js +29 -0
  14. package/dist/parse-workspace.d.ts +4 -0
  15. package/dist/parse-workspace.js +100 -0
  16. package/dist/render-html.d.ts +8 -0
  17. package/dist/render-html.js +699 -0
  18. package/dist/render-map.d.ts +29 -0
  19. package/dist/render-map.js +287 -0
  20. package/dist/replay.d.ts +9 -0
  21. package/dist/replay.js +74 -0
  22. package/dist/scale.d.ts +7 -0
  23. package/dist/scale.js +19 -0
  24. package/dist/scene-data.d.ts +40 -0
  25. package/dist/scene-data.js +146 -0
  26. package/dist/theme.d.ts +15 -0
  27. package/dist/theme.js +15 -0
  28. package/dist/types.d.ts +30 -0
  29. package/dist/types.js +1 -0
  30. package/fixtures/mini-mono/apps/web/package.json +11 -0
  31. package/fixtures/mini-mono/package.json +5 -0
  32. package/fixtures/mini-mono/packages/ui/package.json +11 -0
  33. package/fixtures/mini-mono/packages/utils/package.json +8 -0
  34. package/fixtures/mini-mono/pnpm-workspace.yaml +3 -0
  35. package/fixtures/mini-mono/turbo.json +10 -0
  36. package/package.json +44 -0
  37. package/vendor/dag-map/LICENSE +201 -0
  38. package/vendor/dag-map/NOTICE +6 -0
  39. package/vendor/dag-map/src/color-scales.js +61 -0
  40. package/vendor/dag-map/src/dag-map.css +63 -0
  41. package/vendor/dag-map/src/events.js +108 -0
  42. package/vendor/dag-map/src/graph-utils.js +185 -0
  43. package/vendor/dag-map/src/hasse.css +61 -0
  44. package/vendor/dag-map/src/index.js +55 -0
  45. package/vendor/dag-map/src/layout-flow.js +1066 -0
  46. package/vendor/dag-map/src/layout-hasse.js +485 -0
  47. package/vendor/dag-map/src/layout-metro.js +542 -0
  48. package/vendor/dag-map/src/occupancy.js +138 -0
  49. package/vendor/dag-map/src/render-flow-station.js +132 -0
  50. package/vendor/dag-map/src/render.js +360 -0
  51. package/vendor/dag-map/src/route-angular.js +137 -0
  52. package/vendor/dag-map/src/route-bezier.js +51 -0
  53. package/vendor/dag-map/src/route-metro.js +122 -0
  54. package/vendor/dag-map/src/themes.js +49 -0
@@ -0,0 +1,1066 @@
1
+ // ================================================================
2
+ // layout-flow.js — Obstacle-aware process flow layout
3
+ // ================================================================
4
+ //
5
+ // Lays down routes one at a time, trunk-first with obstacle avoidance.
6
+ // Each element (track segment, station card, edge label) is placed
7
+ // into an occupancy grid. Subsequent elements route around obstacles.
8
+ //
9
+ // Algorithm:
10
+ // 1. Topological sort, layer assignment, column assignment (topo sort + layers)
11
+ // 2. Order routes by length (longest = trunk, laid first)
12
+ // 3. For each route:
13
+ // a. Place station dots + cards (try RIGHT, then LEFT, then fallback)
14
+ // b. Route segments between stations (V-H-V with collision avoidance)
15
+ // c. Place edge labels on straight runs
16
+ // 4. Tracks that share stations maintain neighbor adjacency
17
+ //
18
+ // Routes to the RIGHT of the trunk stay right. Parallel tracks through
19
+ // shared stations maintain their relative order.
20
+
21
+ import { resolveTheme } from './themes.js';
22
+ import { OccupancyGrid } from './occupancy.js';
23
+ import { assertValidDag, buildGraph, topoSortAndRank, swapPathXY } from './graph-utils.js';
24
+
25
+ export function layoutFlow(dag, options = {}) {
26
+ const { nodes, edges } = dag;
27
+ const theme = resolveTheme(options.theme);
28
+ const s = options.scale ?? 1.5;
29
+ const layerSpacing = (options.layerSpacing ?? 55) * s;
30
+ const columnSpacing = (options.columnSpacing ?? 90) * s;
31
+ const dotSpacing = (options.dotSpacing ?? 12) * s;
32
+ const cornerRadius = (options.cornerRadius ?? 5) * s;
33
+ const lineThickness = (options.lineThickness ?? 3) * s;
34
+ const lineOpacity = Math.min((theme.lineOpacity ?? 1.0) * 0.7, 1);
35
+ const labelSize = (options.labelSize ?? 3.6) * s; // station card label font size
36
+ const routes = options.routes;
37
+ const direction = options.direction || 'ttb';
38
+ const cardSide = options.cardSide ?? 'right'; // default card placement
39
+ if (!Array.isArray(routes) || routes.length === 0) {
40
+ throw new Error('layoutFlow: routes is required and must contain at least one route');
41
+ }
42
+
43
+ // ── Orientation abstraction ──
44
+ // CK = column key (secondary/spread axis): 'x' for TTB, 'y' for LTR
45
+ // LK = layer key (primary/flow axis): 'y' for TTB, 'x' for LTR
46
+ const isLTR = direction === 'ltr';
47
+ const CK = isLTR ? 'y' : 'x'; // column key
48
+ const LK = isLTR ? 'x' : 'y'; // layer key
49
+
50
+ assertValidDag(nodes, edges, 'layoutFlow');
51
+ const { nodeMap, childrenOf, parentsOf } = buildGraph(nodes, edges);
52
+ const classColor = {};
53
+ for (const [cls, hex] of Object.entries(theme.classes)) classColor[cls] = hex;
54
+
55
+ // ── STEP 1: Topological sort + layers ──
56
+ const { topo, rank: layer } = topoSortAndRank(nodes, childrenOf, parentsOf);
57
+
58
+ // ── STEP 2: Route membership + primary type ──
59
+ const nodeRoutes = new Map();
60
+ nodes.forEach(n => nodeRoutes.set(n.id, new Set()));
61
+ routes.forEach((route, ri) => {
62
+ route.nodes.forEach(id => nodeRoutes.get(id)?.add(ri));
63
+ });
64
+
65
+ const nodePrimary = new Map();
66
+ nodes.forEach(nd => {
67
+ const memberRoutes = nodeRoutes.get(nd.id);
68
+ if (memberRoutes.size === 0) { nodePrimary.set(nd.id, 0); return; }
69
+ if (memberRoutes.size === 1) { nodePrimary.set(nd.id, [...memberRoutes][0]); return; }
70
+ // Primary = route with most edges through this node
71
+ const routeEdgeCount = new Map();
72
+ routes.forEach((route, ri) => {
73
+ if (!memberRoutes.has(ri)) return;
74
+ const idx = route.nodes.indexOf(nd.id);
75
+ if (idx >= 0) {
76
+ let count = 0;
77
+ if (idx > 0) count++;
78
+ if (idx < route.nodes.length - 1) count++;
79
+ routeEdgeCount.set(ri, (routeEdgeCount.get(ri) || 0) + count);
80
+ }
81
+ });
82
+ let bestRi = [...memberRoutes][0], bestCount = -1;
83
+ for (const [ri, count] of routeEdgeCount) {
84
+ if (count > bestCount || (count === bestCount && ri < bestRi)) {
85
+ bestRi = ri; bestCount = count;
86
+ }
87
+ }
88
+ nodePrimary.set(nd.id, bestRi);
89
+ });
90
+
91
+ // ── STEP 3: Topology-based column assignment ──
92
+ // Instead of assigning columns by route membership, position nodes
93
+ // based on DAG structure: the backbone (longest path) gets X=0,
94
+ // and other nodes offset based on their distance from the backbone.
95
+
96
+ // 3a. Find the DAG backbone — longest path from any source to any sink
97
+ const backbone = [];
98
+ {
99
+ // Dynamic programming: for each node, compute longest path ending there
100
+ const longestTo = new Map(); // nodeId → { length, prev }
101
+ for (const id of topo) {
102
+ const parents = parentsOf.get(id);
103
+ if (parents.length === 0) {
104
+ longestTo.set(id, { length: 0, prev: null });
105
+ } else {
106
+ let best = { length: -1, prev: null };
107
+ for (const p of parents) {
108
+ const pl = longestTo.get(p);
109
+ if (pl && pl.length > best.length) best = { length: pl.length, prev: p };
110
+ }
111
+ longestTo.set(id, { length: best.length + 1, prev: best.prev });
112
+ }
113
+ }
114
+ // Find the sink with longest path
115
+ let endNode = topo[0], maxLen = -1;
116
+ for (const [id, info] of longestTo) {
117
+ if (info.length > maxLen) { maxLen = info.length; endNode = id; }
118
+ }
119
+ // Trace back to build backbone
120
+ let cur = endNode;
121
+ while (cur) {
122
+ backbone.unshift(cur);
123
+ cur = longestTo.get(cur)?.prev;
124
+ }
125
+ }
126
+ const backboneSet = new Set(backbone);
127
+
128
+ // 3b. Route-based columns (same as before) but with a width cap
129
+ const columns = routes.map(() => []);
130
+ nodes.forEach(nd => columns[nodePrimary.get(nd.id)]?.push(nd.id));
131
+ columns.forEach(col => col.sort((a, b) => layer.get(a) - layer.get(b)));
132
+
133
+ const activeColumns = [];
134
+ columns.forEach((col, ri) => { if (col.length > 0) activeColumns.push({ ri, nodes: col }); });
135
+ const nCols = activeColumns.length;
136
+ const columnCol = new Map(); // column-axis value per route
137
+ activeColumns.forEach((col, ci) => columnCol.set(col.ri, (ci - (nCols - 1) / 2) * columnSpacing));
138
+
139
+ // 3c. Adaptive layer spacing — detect congested gaps, give them more room
140
+ const maxLayer = Math.max(...[...layer.values()], 0);
141
+ const layerPos = new Array(maxLayer + 1); // layer-axis positions
142
+ {
143
+ // For each gap between layer L and L+1, count complexity:
144
+ // - routes that pass through (have nodes in both layers or straddle)
145
+ // - routes that bend (different column at source vs dest)
146
+ // - nodes that merge (multiple parents) or fork (multiple children)
147
+ const layerNodeIds = new Map(); // layer → [nodeId]
148
+ nodes.forEach(nd => {
149
+ const l = layer.get(nd.id);
150
+ if (!layerNodeIds.has(l)) layerNodeIds.set(l, []);
151
+ layerNodeIds.get(l).push(nd.id);
152
+ });
153
+
154
+ // Compute raw column-axis value per node for congestion analysis (before positions exist)
155
+ const rawCol = new Map();
156
+ nodes.forEach(nd => {
157
+ const memberRoutes = nodeRoutes.get(nd.id);
158
+ let col;
159
+ if (memberRoutes.size <= 1) {
160
+ col = columnCol.get(nodePrimary.get(nd.id)) ?? 0;
161
+ } else {
162
+ const colVals = [...memberRoutes].map(ri => columnCol.get(ri)).filter(v => v !== undefined);
163
+ const uniqueVals = [...new Set(colVals)];
164
+ col = uniqueVals.length > 0 ? uniqueVals.reduce((a, b) => a + b, 0) / uniqueVals.length : 0;
165
+ }
166
+ rawCol.set(nd.id, col);
167
+ });
168
+
169
+ layerPos[0] = 0;
170
+ for (let l = 0; l < maxLayer; l++) {
171
+ const topNodes = layerNodeIds.get(l) || [];
172
+ const botNodes = layerNodeIds.get(l + 1) || [];
173
+
174
+ // Count routes that cross this gap (have a node in layer l and l+1)
175
+ const topRouteSet = new Set();
176
+ const botRouteSet = new Set();
177
+ topNodes.forEach(id => nodeRoutes.get(id)?.forEach(ri => topRouteSet.add(ri)));
178
+ botNodes.forEach(id => nodeRoutes.get(id)?.forEach(ri => botRouteSet.add(ri)));
179
+ const crossingRoutes = [...topRouteSet].filter(ri => botRouteSet.has(ri));
180
+
181
+ // Count bending routes (different column value at top vs bottom of this gap)
182
+ let benders = 0;
183
+ for (const ri of crossingRoutes) {
184
+ const route = routes[ri];
185
+ for (let i = 1; i < route.nodes.length; i++) {
186
+ const fId = route.nodes[i - 1], tId = route.nodes[i];
187
+ const fL = layer.get(fId), tL = layer.get(tId);
188
+ if (fL === l && tL === l + 1) {
189
+ const fc = rawCol.get(fId) ?? 0, tc = rawCol.get(tId) ?? 0;
190
+ if (Math.abs(tc - fc) > dotSpacing) benders++;
191
+ }
192
+ }
193
+ }
194
+
195
+ // Count merge/fork complexity at bottom layer nodes
196
+ let mergeFork = 0;
197
+ for (const id of botNodes) {
198
+ const pCount = parentsOf.get(id)?.length ?? 0;
199
+ if (pCount > 1) mergeFork += pCount - 1;
200
+ }
201
+ for (const id of topNodes) {
202
+ const cCount = childrenOf.get(id)?.length ?? 0;
203
+ if (cCount > 1) mergeFork += cCount - 1;
204
+ }
205
+
206
+ // Compute multiplier: base 1.0, +0.25 per bender, +0.15 per merge/fork, capped at 2.0
207
+ const multiplier = Math.min(2.0, 1.0 + benders * 0.25 + mergeFork * 0.15);
208
+ layerPos[l + 1] = layerPos[l] + layerSpacing * multiplier;
209
+ }
210
+ }
211
+
212
+ // 3d. Compute raw positions (centroid-based) with adaptive layer positions
213
+ const positions = new Map();
214
+ nodes.forEach(nd => {
215
+ const memberRoutes = nodeRoutes.get(nd.id);
216
+ let colVal;
217
+ if (memberRoutes.size <= 1) {
218
+ colVal = columnCol.get(nodePrimary.get(nd.id)) ?? 0;
219
+ } else {
220
+ const colVals = [...memberRoutes].map(ri => columnCol.get(ri)).filter(v => v !== undefined);
221
+ const uniqueVals = [...new Set(colVals)];
222
+ colVal = uniqueVals.length > 0 ? uniqueVals.reduce((a, b) => a + b, 0) / uniqueVals.length : 0;
223
+ }
224
+ const layerVal = layerPos[layer.get(nd.id)] ?? (layer.get(nd.id) * layerSpacing);
225
+ positions.set(nd.id, { [CK]: colVal, [LK]: layerVal });
226
+ });
227
+
228
+ // 3d. Pull backbone nodes toward their spine (reduces drift)
229
+ if (backbone.length >= 3) {
230
+ const boneCols = backbone.map(id => positions.get(id)?.[CK]).filter(v => v !== undefined);
231
+ const boneSpan = Math.max(...boneCols) - Math.min(...boneCols);
232
+ if (boneSpan > columnSpacing * 1.5) {
233
+ boneCols.sort((a, b) => a - b);
234
+ const spineCol = boneCols[Math.floor(boneCols.length / 2)];
235
+ // Pull strength proportional to how badly it drifts
236
+ const pull = Math.min(0.6, boneSpan / (columnSpacing * 8));
237
+ for (const id of backbone) {
238
+ const pos = positions.get(id);
239
+ if (!pos) continue;
240
+ pos[CK] = pos[CK] * (1 - pull) + spineCol * pull;
241
+ }
242
+ }
243
+ }
244
+
245
+ // ── STEP 4: Separate same-layer nodes that overlap in column axis ──
246
+ const layerNodes = new Map();
247
+ nodes.forEach(nd => {
248
+ const l = layer.get(nd.id);
249
+ if (!layerNodes.has(l)) layerNodes.set(l, []);
250
+ layerNodes.get(l).push(nd.id);
251
+ });
252
+ for (const [, ids] of layerNodes) {
253
+ if (ids.length < 2) continue;
254
+ ids.sort((a, b) => positions.get(a)[CK] - positions.get(b)[CK]);
255
+ for (let i = 1; i < ids.length; i++) {
256
+ const prev = positions.get(ids[i - 1]);
257
+ const curr = positions.get(ids[i]);
258
+ const minGap = columnSpacing * 0.5;
259
+ if (curr[CK] - prev[CK] < minGap) {
260
+ curr[CK] = prev[CK] + minGap;
261
+ }
262
+ }
263
+ }
264
+
265
+ // Normalize — margins are orientation-aware
266
+ const margin = isLTR
267
+ ? { top: 80 * s, left: 50 * s, bottom: 140 * s, right: 40 * s }
268
+ : { top: 50 * s, left: 80 * s, bottom: 40 * s, right: 140 * s };
269
+ let minCK = Infinity, maxCK = -Infinity, minLK = Infinity, maxLK = -Infinity;
270
+ positions.forEach(pos => {
271
+ if (pos[CK] < minCK) minCK = pos[CK]; if (pos[CK] > maxCK) maxCK = pos[CK];
272
+ if (pos[LK] < minLK) minLK = pos[LK]; if (pos[LK] > maxLK) maxLK = pos[LK];
273
+ });
274
+ // For CK (column axis): shift by left margin (TTB) or top margin (LTR)
275
+ // For LK (layer axis): shift by top margin (TTB) or left margin (LTR)
276
+ const ckShift = -minCK + (isLTR ? margin.top : margin.left);
277
+ const lkShift = -minLK + (isLTR ? margin.left : margin.top);
278
+ positions.forEach(pos => { pos[CK] += ckShift; pos[LK] = pos[LK] - minLK + (isLTR ? margin.left : margin.top); });
279
+
280
+ // ── STEP 5: Flow layout — sequential, obstacle-aware ──
281
+ const grid = new OccupancyGrid(2); // tracks + cards + dots
282
+ const badgeGrid = new OccupancyGrid(2); // edge labels only (don't block routes)
283
+
284
+ // Sort routes: longest first (trunk gets best placement)
285
+ const routeOrder = routes.map((_, ri) => ri)
286
+ .sort((a, b) => routes[b].nodes.length - routes[a].nodes.length);
287
+
288
+ // Track waypoint column for each route at each node (for parallel adjacency)
289
+ const waypointX = new Map(); // "nodeId:routeIdx" → column value
290
+
291
+ // Track card placements
292
+ const cardPlacements = new Map(); // nodeId → { rect, side }
293
+ const placedNodes = new Set();
294
+
295
+ // For each route at a node, compute the average column-axis value of
296
+ // neighboring nodes in that route (prev + next). Used to order dots
297
+ // so lines don't cross.
298
+ function neighborCol(nodeId, ri) {
299
+ const route = routes[ri];
300
+ if (!route) return positions.get(nodeId)?.[CK] ?? 0;
301
+ const idx = route.nodes.indexOf(nodeId);
302
+ if (idx < 0) return positions.get(nodeId)?.[CK] ?? 0;
303
+ let sum = 0, count = 0;
304
+ if (idx > 0) {
305
+ const p = positions.get(route.nodes[idx - 1]);
306
+ if (p) { sum += p[CK]; count++; }
307
+ }
308
+ if (idx < route.nodes.length - 1) {
309
+ const p = positions.get(route.nodes[idx + 1]);
310
+ if (p) { sum += p[CK]; count++; }
311
+ }
312
+ return count > 0 ? sum / count : (positions.get(nodeId)?.[CK] ?? 0);
313
+ }
314
+
315
+ // Global side assignment: each non-trunk route gets a FIXED side
316
+ // (left or right of the trunk) that it maintains at every node.
317
+ // This prevents crossings — once a route is on the left, it stays left.
318
+ const trunkRi = routeOrder[0]; // longest route
319
+
320
+ // Compute the trunk's average column value as the spine reference
321
+ const trunkAvgCol = (() => {
322
+ const cols = routes[trunkRi].nodes.map(id => positions.get(id)?.[CK]).filter(v => v !== undefined);
323
+ return cols.length > 0 ? cols.reduce((a, b) => a + b, 0) / cols.length : 0;
324
+ })();
325
+
326
+ // For each non-trunk route, determine its side by where its nodes
327
+ // tend to be relative to the trunk spine.
328
+ const routeSide = new Map(); // ri → -1 (left) | 0 (on trunk) | 1 (right)
329
+ routeSide.set(trunkRi, 0);
330
+
331
+ routes.forEach((route, ri) => {
332
+ if (ri === trunkRi) return;
333
+ // Compute avg column of this route's nodes that are NOT shared with trunk
334
+ const trunkNodeSet = new Set(routes[trunkRi].nodes);
335
+ const uniqueNodes = route.nodes.filter(id => !trunkNodeSet.has(id));
336
+ let avgCol;
337
+ if (uniqueNodes.length > 0) {
338
+ const cols = uniqueNodes.map(id => positions.get(id)?.[CK]).filter(v => v !== undefined);
339
+ avgCol = cols.length > 0 ? cols.reduce((a, b) => a + b, 0) / cols.length : trunkAvgCol;
340
+ } else {
341
+ // All nodes shared with trunk — use neighbor direction at first shared node
342
+ const firstShared = route.nodes.find(id => trunkNodeSet.has(id));
343
+ avgCol = firstShared ? neighborCol(firstShared, ri) : trunkAvgCol;
344
+ }
345
+ routeSide.set(ri, avgCol < trunkAvgCol - 1 ? -1 : avgCol > trunkAvgCol + 1 ? 1 : 1);
346
+ });
347
+
348
+ // Assign a global sort key: left routes get negative keys, trunk=0, right=positive
349
+ // Within same side, sort by route index for consistency
350
+ const routeSortKey = new Map();
351
+ {
352
+ const leftRoutes = [...routeSide.entries()].filter(([, s]) => s < 0).map(([ri]) => ri).sort((a, b) => a - b);
353
+ const rightRoutes = [...routeSide.entries()].filter(([, s]) => s > 0).map(([ri]) => ri).sort((a, b) => a - b);
354
+ leftRoutes.forEach((ri, i) => routeSortKey.set(ri, -(leftRoutes.length - i)));
355
+ routeSortKey.set(trunkRi, 0);
356
+ rightRoutes.forEach((ri, i) => routeSortKey.set(ri, i + 1));
357
+ }
358
+
359
+ const dotOrderCache = new Map();
360
+ function getDotOrder(nodeId) {
361
+ if (dotOrderCache.has(nodeId)) return dotOrderCache.get(nodeId);
362
+ const memberRoutes = nodeRoutes.get(nodeId);
363
+ if (!memberRoutes || memberRoutes.size <= 1) {
364
+ const list = memberRoutes ? [...memberRoutes] : [];
365
+ dotOrderCache.set(nodeId, list);
366
+ return list;
367
+ }
368
+
369
+ // Sort by global side assignment — consistent at every node
370
+ const sorted = [...memberRoutes].sort((a, b) => {
371
+ const ka = routeSortKey.get(a) ?? a;
372
+ const kb = routeSortKey.get(b) ?? b;
373
+ return ka !== kb ? ka - kb : a - b;
374
+ });
375
+
376
+ dotOrderCache.set(nodeId, sorted);
377
+ return sorted;
378
+ }
379
+
380
+ // Precompute trunk's ABSOLUTE column position: propagate from node to node
381
+ // so the trunk forms a perfectly straight spine. At single-route nodes
382
+ // the trunk is at pos[CK]. Once established, the absolute column propagates
383
+ // forward regardless of column changes at merge/fork points.
384
+ const trunkAbsCol = new Map(); // nodeId → absolute column for trunk dot
385
+ {
386
+ let prevAbsCol = null;
387
+ for (const nodeId of routes[trunkRi].nodes) {
388
+ const pos = positions.get(nodeId);
389
+ if (!pos) continue;
390
+ const memberRoutes = nodeRoutes.get(nodeId);
391
+
392
+ if (!memberRoutes || memberRoutes.size <= 1) {
393
+ // Single-route node: trunk at node center
394
+ const absCol = pos[CK];
395
+ trunkAbsCol.set(nodeId, absCol);
396
+ prevAbsCol = absCol;
397
+ } else if (prevAbsCol !== null) {
398
+ // Propagate previous absolute column — trunk stays straight
399
+ trunkAbsCol.set(nodeId, prevAbsCol);
400
+ } else {
401
+ // First multi-route node: compute default position
402
+ const sorted = getDotOrder(nodeId);
403
+ const localIdx = sorted.indexOf(trunkRi);
404
+ const n = sorted.length;
405
+ const absCol = pos[CK] + (localIdx - (n - 1) / 2) * dotSpacing;
406
+ trunkAbsCol.set(nodeId, absCol);
407
+ prevAbsCol = absCol;
408
+ }
409
+ }
410
+ }
411
+
412
+ // Precompute dot positions for all routes at each node.
413
+ // The trunk gets its propagated fixed position. Other routes are
414
+ // spaced evenly around it, maintaining consistent dotSpacing.
415
+ const nodeDotPositions = new Map(); // nodeId → Map<ri, columnValue>
416
+
417
+ for (const [nodeId, memberRoutes] of nodeRoutes) {
418
+ const pos = positions.get(nodeId);
419
+ if (!pos) continue;
420
+ const dotMap = new Map();
421
+
422
+ if (memberRoutes.size <= 1) {
423
+ for (const ri of memberRoutes) dotMap.set(ri, pos[CK]);
424
+ } else {
425
+ const sorted = getDotOrder(nodeId);
426
+ const hasTrunk = sorted.includes(trunkRi) && trunkAbsCol.has(nodeId);
427
+
428
+ const trunkCol = trunkAbsCol.get(nodeId);
429
+ if (hasTrunk && trunkCol !== undefined) {
430
+ // Anchor: trunk at its fixed absolute position. Pack others around it.
431
+ const trunkIdx = sorted.indexOf(trunkRi);
432
+ for (let i = 0; i < sorted.length; i++) {
433
+ dotMap.set(sorted[i], trunkCol + (i - trunkIdx) * dotSpacing);
434
+ }
435
+ } else {
436
+ // No trunk — standard dense centering
437
+ const n = sorted.length;
438
+ const center = (n - 1) / 2;
439
+ for (let i = 0; i < sorted.length; i++) {
440
+ dotMap.set(sorted[i], pos[CK] + (i - center) * dotSpacing);
441
+ }
442
+ }
443
+ }
444
+
445
+ nodeDotPositions.set(nodeId, dotMap);
446
+ }
447
+
448
+ // dotCol returns the column-axis coordinate of a dot
449
+ function dotCol(nodeId, ri) {
450
+ const dotMap = nodeDotPositions.get(nodeId);
451
+ if (dotMap && dotMap.has(ri)) return dotMap.get(ri);
452
+ return positions.get(nodeId)?.[CK] ?? 0;
453
+ }
454
+
455
+ // dotX returns the X-coordinate of a dot (regardless of orientation)
456
+ function dotX(nodeId, ri) {
457
+ if (isLTR) {
458
+ // In LTR: column axis is Y, layer axis is X
459
+ // dotX should return the X-coordinate, which is the layer position
460
+ return positions.get(nodeId)?.x ?? 0;
461
+ }
462
+ // In TTB: column axis is X, so dotCol = X
463
+ return dotCol(nodeId, ri);
464
+ }
465
+
466
+ // dotPos returns {x, y} for a dot — the actual screen coordinates
467
+ function dotPos(nodeId, ri) {
468
+ const dc = dotCol(nodeId, ri);
469
+ const pos = positions.get(nodeId);
470
+ if (!pos) return { x: 0, y: 0 };
471
+ if (isLTR) {
472
+ return { x: pos.x, y: dc };
473
+ } else {
474
+ return { x: dc, y: pos.y };
475
+ }
476
+ }
477
+
478
+ // Place a station card, trying multiple positions
479
+ function placeCard(nodeId, fsLabel, fsData) {
480
+ if (placedNodes.has(nodeId)) return;
481
+ placedNodes.add(nodeId);
482
+
483
+ const nd = nodeMap.get(nodeId);
484
+ const pos = positions.get(nodeId);
485
+ if (!nd || !pos) return;
486
+
487
+ const memberRoutes = nodeRoutes.get(nodeId);
488
+ const routeIndices = [...memberRoutes].sort((a, b) => a - b);
489
+ const n = routeIndices.length;
490
+
491
+ // Compute dots span (in column-axis)
492
+ const dcs = routeIndices.map(ri => dotCol(nodeId, ri));
493
+ const rightmostDot = Math.max(...dcs);
494
+ const leftmostDot = Math.min(...dcs);
495
+ const dotR = 3.2 * s;
496
+
497
+ // Card dimensions (always in screen w/h)
498
+ const labelW = nd.label.length * fsLabel * 0.52;
499
+ const indicatorW = n * 5 * s;
500
+ const metricValue = nd.times ?? nd.count;
501
+ const metricText = metricValue === undefined || metricValue === null ? '' : String(metricValue);
502
+ const dataW = metricText.length * fsData * 0.55;
503
+ const contentW = Math.max(labelW, indicatorW + dataW + 4 * s);
504
+ const cardPadX = 5 * s;
505
+ const cardPadY = 3 * s;
506
+ const cardW = contentW + cardPadX * 2;
507
+ const cardH = fsLabel + fsData + cardPadY * 2 + 3 * s;
508
+ const cardGap = 4 * s;
509
+
510
+ let candidates;
511
+ if (isLTR) {
512
+ // LTR: cards above/below dots (column axis is Y), centered at pos.x
513
+ const baseAbove = leftmostDot - dotR - cardGap - cardH;
514
+ const baseBelow = rightmostDot + dotR + cardGap;
515
+ const xCenter = pos.x - cardW / 2;
516
+ const xShiftAmt = cardW + 4 * s;
517
+ candidates = [
518
+ { side: 'right', x: xCenter, y: baseBelow }, // below
519
+ { side: 'left', x: xCenter, y: baseAbove }, // above
520
+ { side: 'right', x: xCenter - xShiftAmt, y: baseBelow }, // below, left
521
+ { side: 'right', x: xCenter + xShiftAmt, y: baseBelow }, // below, right
522
+ { side: 'left', x: xCenter - xShiftAmt, y: baseAbove }, // above, left
523
+ { side: 'left', x: xCenter + xShiftAmt, y: baseAbove }, // above, right
524
+ ];
525
+ } else {
526
+ // TTB: cards to right/left of dots (column axis is X), centered at pos.y
527
+ const baseRight = rightmostDot + dotR + cardGap;
528
+ const baseLeft = leftmostDot - dotR - cardGap - cardW;
529
+ const yCenter = pos.y - cardH / 2;
530
+ const yShift = cardH + 4 * s;
531
+ candidates = [
532
+ { side: 'right', x: baseRight, y: yCenter },
533
+ { side: 'left', x: baseLeft, y: yCenter },
534
+ { side: 'right', x: baseRight, y: yCenter - yShift },
535
+ { side: 'right', x: baseRight, y: yCenter + yShift },
536
+ { side: 'left', x: baseLeft, y: yCenter - yShift },
537
+ { side: 'left', x: baseLeft, y: yCenter + yShift },
538
+ ];
539
+ }
540
+
541
+ let placed = false;
542
+ for (const c of candidates) {
543
+ const rect = { x: c.x, y: c.y, w: cardW, h: cardH, type: 'card', owner: `card_${nodeId}` };
544
+ if (grid.tryPlace(rect)) {
545
+ cardPlacements.set(nodeId, { rect, side: c.side, cardW, cardH, cardPadX, cardPadY });
546
+ placed = true;
547
+ break;
548
+ }
549
+ }
550
+
551
+ // Fallback: place first candidate regardless of collision (better than nothing)
552
+ if (!placed) {
553
+ const c = candidates[0];
554
+ const rect = { x: c.x, y: c.y, w: cardW, h: cardH, type: 'card', owner: `card_${nodeId}` };
555
+ grid.place(rect);
556
+ cardPlacements.set(nodeId, { rect, side: candidates[0].side, cardW, cardH, cardPadX, cardPadY });
557
+ }
558
+ }
559
+
560
+ // Build route path string with rounded elbows — orientation-aware
561
+ // TTB: V-H-V paths. LTR: H-V-H paths.
562
+ // px,py,qx,qy are always screen coordinates.
563
+ // midFrac applies to the primary axis (layer axis).
564
+ function buildRoute(px, py, qx, qy, midFrac, r) {
565
+ const dx = qx - px, dy = qy - py;
566
+ // "same column" check: column-axis difference < 1
567
+ const colDiff = isLTR ? Math.abs(dy) : Math.abs(dx);
568
+ const layerDiff = isLTR ? Math.abs(dx) : Math.abs(dy);
569
+ if (colDiff < 1) return { d: `M ${px.toFixed(1)} ${py.toFixed(1)} L ${qx.toFixed(1)} ${qy.toFixed(1)}`, jogPos: null };
570
+ if (layerDiff < 1) return { d: `M ${px.toFixed(1)} ${py.toFixed(1)} L ${qx.toFixed(1)} ${qy.toFixed(1)}`, jogPos: null };
571
+
572
+ if (isLTR) {
573
+ // H-V-H path: horizontal run → vertical jog at midX → horizontal run
574
+ const cr = Math.min(r, Math.abs(dy) / 2, Math.abs(dx) / 2);
575
+ const midX = px + dx * midFrac;
576
+ const sx = Math.sign(dx), sy = Math.sign(dy);
577
+
578
+ // First elbow at (midX, py)
579
+ const e1x = midX - sx * cr;
580
+ const e1ey = py + sy * cr;
581
+ // Second elbow at (midX, qy)
582
+ const e2y = qy - sy * cr;
583
+ const e2ex = midX + sx * cr;
584
+
585
+ let d = `M ${px.toFixed(1)} ${py.toFixed(1)} `;
586
+ d += `L ${e1x.toFixed(1)} ${py.toFixed(1)} `;
587
+ d += `Q ${midX.toFixed(1)} ${py.toFixed(1)} ${midX.toFixed(1)} ${e1ey.toFixed(1)} `;
588
+ d += `L ${midX.toFixed(1)} ${e2y.toFixed(1)} `;
589
+ d += `Q ${midX.toFixed(1)} ${qy.toFixed(1)} ${e2ex.toFixed(1)} ${qy.toFixed(1)} `;
590
+ d += `L ${qx.toFixed(1)} ${qy.toFixed(1)}`;
591
+
592
+ return { d, jogPos: midX };
593
+ } else {
594
+ // V-H-V path: vertical run → horizontal jog at midY → vertical run
595
+ const cr = Math.min(r, Math.abs(dx) / 2, Math.abs(dy) / 2);
596
+ const midY = py + dy * midFrac;
597
+ const sy = Math.sign(dy), sx = Math.sign(dx);
598
+
599
+ // First elbow at (px, midY)
600
+ const e1y = midY - sy * cr;
601
+ const e1ex = px + sx * cr;
602
+ // Second elbow at (qx, midY)
603
+ const e2x = qx - sx * cr;
604
+ const e2ey = midY + sy * cr;
605
+
606
+ let d = `M ${px.toFixed(1)} ${py.toFixed(1)} `;
607
+ d += `L ${px.toFixed(1)} ${e1y.toFixed(1)} `;
608
+ d += `Q ${px.toFixed(1)} ${midY.toFixed(1)} ${e1ex.toFixed(1)} ${midY.toFixed(1)} `;
609
+ d += `L ${e2x.toFixed(1)} ${midY.toFixed(1)} `;
610
+ d += `Q ${qx.toFixed(1)} ${midY.toFixed(1)} ${qx.toFixed(1)} ${e2ey.toFixed(1)} `;
611
+ d += `L ${qx.toFixed(1)} ${qy.toFixed(1)}`;
612
+
613
+ return { d, jogPos: midY };
614
+ }
615
+ }
616
+
617
+ // Check collision for all 3 segments of a route path — orientation-aware
618
+ function scoreRoute(px, py, qx, qy, jogPos, ignore) {
619
+ const t = lineThickness;
620
+ if (isLTR) {
621
+ // H-V-H: horiz run 1, vert jog, horiz run 2
622
+ const h1 = { x: Math.min(px, jogPos) - t, y: py - t * 2, w: Math.abs(jogPos - px) + t * 2, h: t * 4, type: 'track' };
623
+ const vj = { x: jogPos - t, y: Math.min(py, qy), w: t * 2, h: Math.abs(qy - py), type: 'track' };
624
+ const h2 = { x: Math.min(jogPos, qx) - t, y: qy - t * 2, w: Math.abs(qx - jogPos) + t * 2, h: t * 4, type: 'track' };
625
+ return grid.overlapCount(h1, ignore) + grid.overlapCount(vj, ignore) + grid.overlapCount(h2, ignore);
626
+ } else {
627
+ // V-H-V: vert run 1, horiz jog, vert run 2
628
+ const v1 = { x: px - t, y: Math.min(py, jogPos), w: t * 2, h: Math.abs(jogPos - py), type: 'track' };
629
+ const hj = { x: Math.min(px, qx) - t, y: jogPos - t * 2, w: Math.abs(qx - px) + t * 2, h: t * 4, type: 'track' };
630
+ const v2 = { x: qx - t, y: Math.min(jogPos, qy), w: t * 2, h: Math.abs(qy - jogPos), type: 'track' };
631
+ return grid.overlapCount(v1, ignore) + grid.overlapCount(hj, ignore) + grid.overlapCount(v2, ignore);
632
+ }
633
+ }
634
+
635
+ // Register all 3 segments of a route path in the grid — orientation-aware
636
+ function registerRoute(px, py, qx, qy, jogPos, owner) {
637
+ if (isLTR) {
638
+ // H-V-H
639
+ grid.placeLine(px, py, jogPos, py, lineThickness, owner);
640
+ grid.placeLine(jogPos, py, jogPos, qy, lineThickness, owner);
641
+ grid.placeLine(jogPos, qy, qx, qy, lineThickness, owner);
642
+ } else {
643
+ // V-H-V
644
+ grid.placeLine(px, py, px, jogPos, lineThickness, owner);
645
+ grid.placeLine(px, jogPos, qx, jogPos, lineThickness, owner);
646
+ grid.placeLine(qx, jogPos, qx, qy, lineThickness, owner);
647
+ }
648
+ }
649
+
650
+ // Route a segment with collision avoidance.
651
+ // Returns { d, jogPos } — jogPos is the jog coordinate on the primary axis (null for straight).
652
+ // ignore: Set of owners to ignore in collision checks (segment + endpoint nodes)
653
+ function routeSegment(px, py, qx, qy, ri, owner, ignore, assignedMidFrac) {
654
+ const r = cornerRadius;
655
+
656
+ // "Same column" check: column-axis difference < 1
657
+ const colDiff = isLTR ? Math.abs(qy - py) : Math.abs(qx - px);
658
+ const layerDiff = isLTR ? Math.abs(qx - px) : Math.abs(qy - py);
659
+
660
+ // Straight along primary axis — check for card collisions (excluding endpoint nodes)
661
+ if (colDiff < 1) {
662
+ // Shrink along layer axis by lineThickness at each end to avoid false positives
663
+ const shrink = lineThickness;
664
+ if (isLTR) {
665
+ // Straight horizontal line (same Y)
666
+ const checkX = Math.min(px, qx) + shrink;
667
+ const checkW = Math.abs(qx - px) - 2 * shrink;
668
+ if (checkW <= 0) {
669
+ grid.placeLine(px, py, qx, qy, lineThickness, owner);
670
+ return { d: `M ${px.toFixed(1)} ${py.toFixed(1)} L ${qx.toFixed(1)} ${qy.toFixed(1)}`, jogPos: null };
671
+ }
672
+ const hRect = { x: checkX, y: py - lineThickness, w: checkW, h: lineThickness * 2, type: 'track' };
673
+ const collisions = grid.overlapCount(hRect, ignore);
674
+
675
+ if (collisions === 0) {
676
+ grid.placeLine(px, py, qx, qy, lineThickness, owner);
677
+ return { d: `M ${px.toFixed(1)} ${py.toFixed(1)} L ${qx.toFixed(1)} ${qy.toFixed(1)}`, jogPos: null };
678
+ }
679
+
680
+ // Straight horizontal segment hits obstacle — detour up/down
681
+ const detourDist = 15 * s;
682
+ const upY = py - detourDist;
683
+ const downY = py + detourDist;
684
+
685
+ const upScore = scoreRoute(px, py, qx, upY, (px + qx) / 2, ignore)
686
+ + scoreRoute(qx, upY, qx, qy, (px + qx) * 0.7, ignore);
687
+ const downScore = scoreRoute(px, py, qx, downY, (px + qx) / 2, ignore)
688
+ + scoreRoute(qx, downY, qx, qy, (px + qx) * 0.7, ignore);
689
+
690
+ const detourY = upScore <= downScore ? upY : downY;
691
+ const midX1 = px + (qx - px) * 0.3;
692
+ const midX2 = px + (qx - px) * 0.7;
693
+
694
+ const cr = Math.min(r, detourDist / 2, Math.abs(midX1 - px) / 2);
695
+ if (cr < 1) {
696
+ grid.placeLine(px, py, qx, qy, lineThickness, owner);
697
+ return { d: `M ${px.toFixed(1)} ${py.toFixed(1)} L ${qx.toFixed(1)} ${qy.toFixed(1)}`, jogPos: null };
698
+ }
699
+
700
+ // H-V-H-V-H detour path
701
+ const sx = Math.sign(qx - px);
702
+ const sy = Math.sign(detourY - py);
703
+ let d = `M ${px.toFixed(1)} ${py.toFixed(1)} `;
704
+ d += `L ${(midX1 - sx * cr).toFixed(1)} ${py.toFixed(1)} `;
705
+ d += `Q ${midX1.toFixed(1)} ${py.toFixed(1)} ${midX1.toFixed(1)} ${(py + sy * cr).toFixed(1)} `;
706
+ d += `L ${midX1.toFixed(1)} ${(detourY - sy * cr).toFixed(1)} `;
707
+ d += `Q ${midX1.toFixed(1)} ${detourY.toFixed(1)} ${(midX1 + sx * cr).toFixed(1)} ${detourY.toFixed(1)} `;
708
+ d += `L ${(midX2 - sx * cr).toFixed(1)} ${detourY.toFixed(1)} `;
709
+ d += `Q ${midX2.toFixed(1)} ${detourY.toFixed(1)} ${midX2.toFixed(1)} ${(detourY - sy * cr).toFixed(1)} `;
710
+ d += `L ${midX2.toFixed(1)} ${(qy + sy * cr).toFixed(1)} `;
711
+ d += `Q ${midX2.toFixed(1)} ${qy.toFixed(1)} ${(midX2 + sx * cr).toFixed(1)} ${qy.toFixed(1)} `;
712
+ d += `L ${qx.toFixed(1)} ${qy.toFixed(1)}`;
713
+
714
+ grid.placeLine(px, py, midX1, py, lineThickness, owner);
715
+ grid.placeLine(midX1, py, midX1, detourY, lineThickness, owner);
716
+ grid.placeLine(midX1, detourY, midX2, detourY, lineThickness, owner);
717
+ grid.placeLine(midX2, detourY, midX2, qy, lineThickness, owner);
718
+ grid.placeLine(midX2, qy, qx, qy, lineThickness, owner);
719
+ return { d, jogPos: midX1 };
720
+ } else {
721
+ // TTB: Straight vertical line (same X)
722
+ const checkY = Math.min(py, qy) + shrink;
723
+ const checkH = Math.abs(qy - py) - 2 * shrink;
724
+ if (checkH <= 0) {
725
+ grid.placeLine(px, py, qx, qy, lineThickness, owner);
726
+ return { d: `M ${px.toFixed(1)} ${py.toFixed(1)} L ${qx.toFixed(1)} ${qy.toFixed(1)}`, jogPos: null };
727
+ }
728
+ const vRect = { x: px - lineThickness, y: checkY, w: lineThickness * 2, h: checkH, type: 'track' };
729
+ const collisions = grid.overlapCount(vRect, ignore);
730
+
731
+ if (collisions === 0) {
732
+ grid.placeLine(px, py, qx, qy, lineThickness, owner);
733
+ return { d: `M ${px.toFixed(1)} ${py.toFixed(1)} L ${qx.toFixed(1)} ${qy.toFixed(1)}`, jogPos: null };
734
+ }
735
+
736
+ // Vertical segment hits a real obstacle — detour left/right
737
+ const detourDist = 15 * s;
738
+ const leftX = px - detourDist;
739
+ const rightX = px + detourDist;
740
+
741
+ const leftScore = scoreRoute(px, py, leftX, qy, (py + qy) / 2, ignore)
742
+ + scoreRoute(leftX, (py + qy) / 2, qx, qy, (py + qy) * 0.7, ignore);
743
+ const rightScore = scoreRoute(px, py, rightX, qy, (py + qy) / 2, ignore)
744
+ + scoreRoute(rightX, (py + qy) / 2, qx, qy, (py + qy) * 0.7, ignore);
745
+
746
+ const detourX = leftScore <= rightScore ? leftX : rightX;
747
+ const midY1 = py + (qy - py) * 0.3;
748
+ const midY2 = py + (qy - py) * 0.7;
749
+
750
+ const cr = Math.min(r, detourDist / 2, Math.abs(midY1 - py) / 2);
751
+ if (cr < 1) {
752
+ grid.placeLine(px, py, qx, qy, lineThickness, owner);
753
+ return { d: `M ${px.toFixed(1)} ${py.toFixed(1)} L ${qx.toFixed(1)} ${qy.toFixed(1)}`, jogPos: null };
754
+ }
755
+
756
+ // V-H-V-H-V detour path
757
+ const sx = Math.sign(detourX - px);
758
+ const sy = Math.sign(qy - py);
759
+ let d = `M ${px.toFixed(1)} ${py.toFixed(1)} `;
760
+ d += `L ${px.toFixed(1)} ${(midY1 - sy * cr).toFixed(1)} `;
761
+ d += `Q ${px.toFixed(1)} ${midY1.toFixed(1)} ${(px + sx * cr).toFixed(1)} ${midY1.toFixed(1)} `;
762
+ d += `L ${(detourX - sx * cr).toFixed(1)} ${midY1.toFixed(1)} `;
763
+ d += `Q ${detourX.toFixed(1)} ${midY1.toFixed(1)} ${detourX.toFixed(1)} ${(midY1 + sy * cr).toFixed(1)} `;
764
+ d += `L ${detourX.toFixed(1)} ${(midY2 - sy * cr).toFixed(1)} `;
765
+ d += `Q ${detourX.toFixed(1)} ${midY2.toFixed(1)} ${(detourX - sx * cr).toFixed(1)} ${midY2.toFixed(1)} `;
766
+ d += `L ${(qx + sx * cr).toFixed(1)} ${midY2.toFixed(1)} `;
767
+ d += `Q ${qx.toFixed(1)} ${midY2.toFixed(1)} ${qx.toFixed(1)} ${(midY2 + sy * cr).toFixed(1)} `;
768
+ d += `L ${qx.toFixed(1)} ${qy.toFixed(1)}`;
769
+
770
+ grid.placeLine(px, py, px, midY1, lineThickness, owner);
771
+ grid.placeLine(px, midY1, detourX, midY1, lineThickness, owner);
772
+ grid.placeLine(detourX, midY1, detourX, midY2, lineThickness, owner);
773
+ grid.placeLine(detourX, midY2, qx, midY2, lineThickness, owner);
774
+ grid.placeLine(qx, midY2, qx, qy, lineThickness, owner);
775
+ return { d, jogPos: midY1 };
776
+ }
777
+ }
778
+
779
+ // Non-straight: try multiple midFrac values, score ALL segments.
780
+ // For small column diff (dot centering shifts), prefer extreme midFrac to push
781
+ // the jog close to a node — makes the short cross run less visible.
782
+ const dotR = 3.2 * s;
783
+ const hiddenFrac = layerDiff > 0 ? Math.max(0.5, 1 - dotR / layerDiff) : 0.5;
784
+ // Use pre-assigned staggered midFrac first (crossing avoidance),
785
+ // then fall back to defaults
786
+ const baseFracs = colDiff <= dotSpacing
787
+ ? [hiddenFrac, 1 - hiddenFrac, 0.85, 0.15]
788
+ : [0.5, 0.35, 0.65, 0.25, 0.75, 0.15, 0.85];
789
+ const midFracs = assignedMidFrac !== undefined
790
+ ? [assignedMidFrac, ...baseFracs.filter(f => Math.abs(f - assignedMidFrac) > 0.05)]
791
+ : baseFracs;
792
+ let bestD = null;
793
+ let bestMf = 0.5;
794
+ let bestCollisions = Infinity;
795
+
796
+ for (const mf of midFracs) {
797
+ const { d, jogPos } = buildRoute(px, py, qx, qy, mf, r);
798
+ if (jogPos === null) return { d, jogPos: null };
799
+
800
+ const collisions = scoreRoute(px, py, qx, qy, jogPos, ignore);
801
+ if (collisions === 0) {
802
+ registerRoute(px, py, qx, qy, jogPos, owner);
803
+ return { d, jogPos };
804
+ }
805
+ if (collisions < bestCollisions) {
806
+ bestCollisions = collisions;
807
+ bestD = d;
808
+ bestMf = mf;
809
+ }
810
+ }
811
+
812
+ // Register the best option even if it has collisions
813
+ // Compute jogPos from midFrac along the primary axis
814
+ const bestJogPos = isLTR
815
+ ? px + (qx - px) * bestMf
816
+ : py + (qy - py) * bestMf;
817
+ registerRoute(px, py, qx, qy, bestJogPos, owner);
818
+ return { d: bestD, jogPos: bestJogPos };
819
+ }
820
+
821
+ // ── STEP 6: Lay routes sequentially ──
822
+ const fsLabel = labelSize;
823
+ const fsData = labelSize * 0.78; // data text slightly smaller than label
824
+ const routePaths = routes.map(() => []);
825
+ const edgeLabelPositions = new Map(); // "from→to" → {x, y, color}
826
+
827
+ // Phase A: Register all dots + place ALL cards BEFORE routing.
828
+ // This ensures routes will avoid all cards.
829
+ const dotR = 3.2 * s;
830
+ for (const ri of routeOrder) {
831
+ for (const nodeId of routes[ri].nodes) {
832
+ if (!placedNodes.has(nodeId)) {
833
+ const dcs = [...nodeRoutes.get(nodeId)].map(r => dotCol(nodeId, r));
834
+ dcs.forEach(dc => {
835
+ const pos = positions.get(nodeId);
836
+ if (!pos) return;
837
+ const dotRect = isLTR
838
+ ? { x: pos.x - dotR, y: dc - dotR, w: dotR * 2, h: dotR * 2, type: 'dot', owner: nodeId }
839
+ : { x: dc - dotR, y: pos.y - dotR, w: dotR * 2, h: dotR * 2, type: 'dot', owner: nodeId };
840
+ grid.place(dotRect);
841
+ });
842
+ placedNodes.add(nodeId);
843
+ }
844
+ }
845
+ }
846
+ placedNodes.clear(); // reset for card placement
847
+ for (const ri of routeOrder) {
848
+ for (const nodeId of routes[ri].nodes) {
849
+ placeCard(nodeId, fsLabel, fsData);
850
+ }
851
+ }
852
+
853
+ // Pre-compute staggered jog assignments for crossing avoidance.
854
+ // For each layer gap, routes that bend are assigned different midFrac
855
+ // values so their horizontal jogs don't overlap.
856
+ const jogAssignments = new Map(); // "fromLayer→toLayer" → Map<ri, midFrac>
857
+ {
858
+ const gapBenders = new Map(); // "layerA→layerB" → [{ri, fromCol, toCol}]
859
+ routes.forEach((route, ri) => {
860
+ for (let i = 1; i < route.nodes.length; i++) {
861
+ const fromId = route.nodes[i - 1], toId = route.nodes[i];
862
+ const fromPos = positions.get(fromId), toPos = positions.get(toId);
863
+ if (!fromPos || !toPos) continue;
864
+ const fromLayer = layer.get(fromId), toLayer = layer.get(toId);
865
+ const fc = dotCol(fromId, ri), tc = dotCol(toId, ri);
866
+ if (Math.abs(tc - fc) < 1) continue; // straight, no bend
867
+ const gapKey = `${fromLayer}\u2192${toLayer}`;
868
+ if (!gapBenders.has(gapKey)) gapBenders.set(gapKey, []);
869
+ gapBenders.get(gapKey).push({ ri, fromCol: fc, toCol: tc });
870
+ }
871
+ });
872
+ for (const [gapKey, benders] of gapBenders) {
873
+ if (benders.length < 2) continue;
874
+
875
+ // Only stagger when routes bend in OPPOSITE directions.
876
+ // Routes going the same direction should stay parallel.
877
+ const hasLeft = benders.some(b => b.toCol < b.fromCol);
878
+ const hasRight = benders.some(b => b.toCol > b.fromCol);
879
+ if (!hasLeft || !hasRight) continue; // all same direction — skip
880
+
881
+ // Sort by destination column: leftmost dest jogs near source,
882
+ // rightmost dest jogs near destination. This prevents crossings.
883
+ benders.sort((a, b) => a.toCol - b.toCol);
884
+ const n = benders.length;
885
+ const assignment = new Map();
886
+ benders.forEach((b, i) => {
887
+ const frac = n === 1 ? 0.5 : 0.25 + (i / (n - 1)) * 0.5;
888
+ assignment.set(b.ri, frac);
889
+ });
890
+ jogAssignments.set(gapKey, assignment);
891
+ }
892
+ }
893
+
894
+ // Phase B: Route ALL segments (grid has dots + cards as obstacles)
895
+ for (const ri of routeOrder) {
896
+ const route = routes[ri];
897
+ const color = classColor[route.cls] || Object.values(classColor)[0];
898
+ const waypoints = route.nodes.map(id => {
899
+ const pos = positions.get(id);
900
+ if (!pos) return null;
901
+ const dc = dotCol(id, ri);
902
+ // Waypoint in screen coordinates
903
+ if (isLTR) {
904
+ return { id, x: pos.x, y: dc };
905
+ } else {
906
+ return { id, x: dc, y: pos.y };
907
+ }
908
+ }).filter(Boolean);
909
+
910
+ const routeOwner = `route${ri}`;
911
+ const segments = [];
912
+ for (let i = 1; i < waypoints.length; i++) {
913
+ const p = waypoints[i - 1], q = waypoints[i];
914
+ // "small column diff" check uses the column-axis distance
915
+ const smallColDiff = isLTR ? Math.abs(q.y - p.y) <= dotSpacing : Math.abs(q.x - p.x) <= dotSpacing;
916
+ const ignoreSet = smallColDiff
917
+ ? new Set([routeOwner, p.id, q.id, `card_${p.id}`, `card_${q.id}`])
918
+ : new Set([routeOwner, p.id, q.id]);
919
+
920
+ // Use pre-assigned staggered midFrac for crossing avoidance
921
+ const fromLayer = layer.get(p.id), toLayer = layer.get(q.id);
922
+ const gapKey = `${fromLayer}\u2192${toLayer}`;
923
+ const gapAssign = jogAssignments.get(gapKey);
924
+ const assignedMidFrac = gapAssign?.get(ri);
925
+
926
+ const result = routeSegment(p.x, p.y, q.x, q.y, ri, routeOwner, ignoreSet, assignedMidFrac);
927
+ const srcDim = nodeMap.get(p.id)?.dim === true;
928
+ const dstDim = nodeMap.get(q.id)?.dim === true;
929
+ const segOpacity = (srcDim || dstDim) ? Math.min(lineOpacity, 0.12) : lineOpacity;
930
+ segments.push({ d: result.d, color, thickness: lineThickness, opacity: segOpacity, dashed: false });
931
+
932
+ // Try to place edge label — per route, on straight runs along the primary axis
933
+ const edgeKey = `${ri}:${p.id}\u2192${q.id}`;
934
+ if (!edgeLabelPositions.has(edgeKey)) {
935
+ const fs = 2.4 * s;
936
+ const tw = 12 * s;
937
+ const th = fs + 2.5 * s;
938
+
939
+ const candidates = [];
940
+ if (result.jogPos !== null) {
941
+ if (isLTR) {
942
+ // H-V-H: straight runs are horizontal
943
+ const jp = result.jogPos; // midX
944
+ candidates.push({ x: (p.x + jp) / 2, y: p.y - th / 2 }); // on first horiz run
945
+ candidates.push({ x: (jp + q.x) / 2, y: q.y - th / 2 }); // on second horiz run
946
+ candidates.push({ x: jp - tw / 2, y: (p.y + q.y) / 2 - th / 2 }); // on vertical jog
947
+ } else {
948
+ // V-H-V: straight runs are vertical
949
+ const jp = result.jogPos; // midY
950
+ candidates.push({ x: p.x, y: (p.y + jp) / 2 - th / 2 });
951
+ candidates.push({ x: q.x, y: (jp + q.y) / 2 - th / 2 });
952
+ candidates.push({ x: (p.x + q.x) / 2, y: jp - th / 2 });
953
+ }
954
+ } else {
955
+ if (isLTR) {
956
+ candidates.push({ x: (p.x + q.x) / 2, y: p.y - th / 2 });
957
+ } else {
958
+ candidates.push({ x: p.x, y: (p.y + q.y) / 2 - th / 2 });
959
+ }
960
+ }
961
+
962
+ let placed = false;
963
+ for (const c of candidates) {
964
+ const labelY = c.y + th / 2;
965
+ const rect = { x: c.x - tw / 2, y: c.y, w: tw, h: th, type: 'badge', owner: edgeKey };
966
+ if (badgeGrid.tryPlace(rect)) {
967
+ edgeLabelPositions.set(edgeKey, { x: c.x, y: labelY, color });
968
+ placed = true;
969
+ break;
970
+ }
971
+ }
972
+ if (!placed) {
973
+ const c = candidates[0];
974
+ edgeLabelPositions.set(edgeKey, { x: c.x, y: c.y + th / 2, color });
975
+ }
976
+ }
977
+ }
978
+
979
+ routePaths[ri] = segments;
980
+ }
981
+
982
+ // ── STEP 7: Extra edges (DAG edges not covered by any route) ──
983
+ const routeEdgeSet = new Set();
984
+ routes.forEach(route => {
985
+ for (let i = 1; i < route.nodes.length; i++)
986
+ routeEdgeSet.add(`${route.nodes[i - 1]}\u2192${route.nodes[i]}`);
987
+ });
988
+
989
+ // For each node, track how many extra-edge slots have been assigned.
990
+ // Extra dots go on the "left" side (lower column value) of route dots.
991
+ const extraSlotCount = new Map();
992
+ function extraDotCol(nodeId) {
993
+ const pos = positions.get(nodeId);
994
+ if (!pos) return 0;
995
+ const memberRoutes = nodeRoutes.get(nodeId);
996
+ if (!memberRoutes || memberRoutes.size === 0) return pos[CK];
997
+ const leftmost = Math.min(...[...memberRoutes].map(ri => dotCol(nodeId, ri)));
998
+ const slotIdx = extraSlotCount.get(nodeId) || 0;
999
+ extraSlotCount.set(nodeId, slotIdx + 1);
1000
+ return leftmost - (slotIdx + 1) * dotSpacing;
1001
+ }
1002
+
1003
+ const extraEdges = [];
1004
+ const extraDotPositions = new Map(); // "from→to" → {fromX, fromY, toX, toY}
1005
+ edges.forEach(([f, t]) => {
1006
+ if (routeEdgeSet.has(`${f}\u2192${t}`)) return;
1007
+ const pBase = positions.get(f), qBase = positions.get(t);
1008
+ if (!pBase || !qBase) return;
1009
+ const fc = extraDotCol(f), tc = extraDotCol(t);
1010
+ // Convert to screen coordinates
1011
+ let fx, fy, tx, ty;
1012
+ if (isLTR) {
1013
+ fx = pBase.x; fy = fc;
1014
+ tx = qBase.x; ty = tc;
1015
+ } else {
1016
+ fx = fc; fy = pBase.y;
1017
+ tx = tc; ty = qBase.y;
1018
+ }
1019
+ const extraOwner = `extra_${f}_${t}`;
1020
+ const result = routeSegment(fx, fy, tx, ty, 999, extraOwner, new Set([extraOwner, f, t]));
1021
+ extraEdges.push({ d: result.d, color: theme.muted, thickness: 1.5 * s, opacity: 0.3, dashed: true });
1022
+ extraDotPositions.set(`${f}\u2192${t}`, { fromX: fx, fromY: fy, toX: tx, toY: ty });
1023
+ });
1024
+
1025
+ // Compute bounds from actual positions for width/height
1026
+ let actualMinX = Infinity, actualMaxX = -Infinity, actualMinY = Infinity, actualMaxY = -Infinity;
1027
+ positions.forEach(pos => {
1028
+ if (pos.x < actualMinX) actualMinX = pos.x;
1029
+ if (pos.x > actualMaxX) actualMaxX = pos.x;
1030
+ if (pos.y < actualMinY) actualMinY = pos.y;
1031
+ if (pos.y > actualMaxY) actualMaxY = pos.y;
1032
+ });
1033
+
1034
+ const width = (actualMaxX - actualMinX) + margin.left + margin.right;
1035
+ const height = (actualMaxY - actualMinY) + margin.top + margin.bottom;
1036
+
1037
+ // Compute minY/maxY on the layer axis for scroll/viewport logic
1038
+ const lkMarginStart = isLTR ? margin.left : margin.top;
1039
+ const finalMaxLayerPos = layerPos[maxLayer] ?? maxLayer * layerSpacing;
1040
+ const minLayerScreen = lkMarginStart;
1041
+ const maxLayerScreen = lkMarginStart + finalMaxLayerPos;
1042
+
1043
+ return {
1044
+ positions,
1045
+ routePaths,
1046
+ extraEdges,
1047
+ width,
1048
+ height,
1049
+ routes,
1050
+ nodeRoute: new Map([...nodes.map(nd => [nd.id, nodePrimary.get(nd.id)])]),
1051
+ nodeRoutes,
1052
+ nodePrimary,
1053
+ dotSpacing,
1054
+ dotX,
1055
+ dotPos,
1056
+ cardPlacements,
1057
+ edgeLabelPositions,
1058
+ extraDotPositions,
1059
+ scale: s,
1060
+ labelSize,
1061
+ theme,
1062
+ orientation: direction,
1063
+ minY: isLTR ? actualMinY : minLayerScreen,
1064
+ maxY: isLTR ? actualMaxY : maxLayerScreen,
1065
+ };
1066
+ }