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,542 @@
1
+ // ================================================================
2
+ // layout.js — Shared layout engine for dag-map
3
+ // ================================================================
4
+ // Topological sort, route extraction via greedy longest-path,
5
+ // Y-position assignment with occupancy tracking, node positioning,
6
+ // and route/extra-edge path building with pluggable routing.
7
+
8
+ import { bezierPath } from './route-bezier.js';
9
+ import { angularPath } from './route-angular.js';
10
+ import { metroPath } from './route-metro.js';
11
+ import { resolveTheme } from './themes.js';
12
+ import { assertValidDag, buildGraph, topoSortAndRank, swapPathXY } from './graph-utils.js';
13
+
14
+ /**
15
+ * Determine the dominant node class among a set of node IDs.
16
+ * @param {string[]} nodeIds
17
+ * @param {Map} nodeMap - Map from id to node object
18
+ * @returns {string}
19
+ */
20
+ export function dominantClass(nodeIds, nodeMap) {
21
+ const counts = {};
22
+ nodeIds.forEach(id => {
23
+ const cls = nodeMap.get(id)?.cls || 'pure';
24
+ counts[cls] = (counts[cls] || 0) + 1;
25
+ });
26
+ let best = 'pure', bestCount = 0;
27
+ for (const [cls, count] of Object.entries(counts)) {
28
+ if (count > bestCount) { best = cls; bestCount = count; }
29
+ }
30
+ return best;
31
+ }
32
+
33
+ /**
34
+ * Compute the full metro-map layout for a DAG.
35
+ *
36
+ * @param {object} dag - { nodes: [{id, label, cls}], edges: [[from, to]] }
37
+ * @param {object} [options]
38
+ * @param {'bezier'|'angular'} [options.routing='bezier'] - routing style
39
+ * @param {number} [options.trunkY=160] - absolute Y for trunk route
40
+ * @param {number} [options.mainSpacing=34] - px between depth-1 branch lanes
41
+ * @param {number} [options.subSpacing=16] - px between depth-2+ sub-branch lanes
42
+ * @param {number} [options.layerSpacing=38] - px between topological layers
43
+ * @param {number} [options.progressivePower=2.2] - power for progressive curves
44
+ * @param {number} [options.scale=1.5] - scale multiplier for all spatial values
45
+ * @param {'ltr'|'ttb'} [options.direction='ltr'] - layout direction
46
+ * @returns {object} { positions, routePaths, extraEdges, width, height, routes, ... }
47
+ */
48
+ export function layoutMetro(dag, options = {}) {
49
+ const routing = options.routing || 'bezier';
50
+ const direction = options.direction || 'ltr';
51
+ const isTTB = direction === 'ttb';
52
+ const theme = resolveTheme(options.theme);
53
+ // Build classColor from all theme classes (not just hardcoded four)
54
+ const classColor = { ...theme.classes };
55
+ const s = options.scale ?? 1.5;
56
+ const TRUNK_Y = (options.trunkY ?? 160) * s;
57
+ const MAIN_SPACING = (options.mainSpacing ?? 34) * s;
58
+ const SUB_SPACING = (options.subSpacing ?? 16) * s;
59
+ const layerSpacing = (options.layerSpacing ?? 38) * s;
60
+ const progressivePower = options.progressivePower ?? 2.2;
61
+ const cornerRadius = (options.cornerRadius ?? 8) * s;
62
+ const dimOpacity = options.dimOpacity ?? 0.25;
63
+ const maxLanes = options.maxLanes ?? null;
64
+ const hasProvidedRoutes = !!(options.routes && options.routes.length > 0);
65
+
66
+ const { nodes, edges } = dag;
67
+ assertValidDag(nodes, edges, 'layoutMetro');
68
+ const { nodeMap, childrenOf, parentsOf } = buildGraph(nodes, edges);
69
+
70
+ // ── STEP 1: Topological sort + layer assignment ──
71
+ const { topo, rank: layer, maxRank: maxLayer } = topoSortAndRank(nodes, childrenOf, parentsOf);
72
+
73
+ // ── STEP 2: Extract routes ──
74
+ // Either use consumer-provided routes or auto-discover via greedy longest-path.
75
+ // lineGap is set after route discovery (needs route count)
76
+
77
+ function longestPathIn(nodeSet) {
78
+ const dist = new Map(), prev = new Map();
79
+ nodeSet.forEach(id => { dist.set(id, 0); prev.set(id, null); });
80
+ for (const u of topo) {
81
+ if (!nodeSet.has(u)) continue;
82
+ for (const v of childrenOf.get(u)) {
83
+ if (!nodeSet.has(v)) continue;
84
+ if (dist.get(u) + 1 > dist.get(v)) {
85
+ dist.set(v, dist.get(u) + 1); prev.set(v, u);
86
+ }
87
+ }
88
+ }
89
+ let best = -1, end = null;
90
+ nodeSet.forEach(id => { if (dist.get(id) > best) { best = dist.get(id); end = id; } });
91
+ if (end === null) return [];
92
+ const path = [];
93
+ for (let c = end; c !== null; c = prev.get(c)) path.unshift(c);
94
+ return path;
95
+ }
96
+
97
+ const routes = [];
98
+ const assigned = new Set();
99
+ const nodeRoute = new Map();
100
+ const nodeRoutes = new Map(); // node → Set<routeIdx> (all routes through this node)
101
+ nodes.forEach(nd => nodeRoutes.set(nd.id, new Set()));
102
+
103
+ if (options.routes && options.routes.length > 0) {
104
+ // ── Consumer-provided routes ──
105
+ // Sort by length descending — longest route becomes trunk
106
+ const provided = options.routes
107
+ .map((r, i) => ({ ...r, originalIndex: i }))
108
+ .sort((a, b) => b.nodes.length - a.nodes.length);
109
+
110
+ provided.forEach((pr, i) => {
111
+ // Determine parent route: the earlier route that shares the most nodes
112
+ let parentRouteIdx = -1;
113
+ let bestOverlap = 0;
114
+ const prNodeSet = new Set(pr.nodes);
115
+ for (let j = 0; j < i; j++) {
116
+ const overlap = routes[j].nodes.filter(id => prNodeSet.has(id)).length;
117
+ if (overlap > bestOverlap) { bestOverlap = overlap; parentRouteIdx = j; }
118
+ }
119
+ if (i === 0) parentRouteIdx = -1;
120
+ const depth = parentRouteIdx >= 0 ? routes[parentRouteIdx].depth + 1 : 0;
121
+
122
+ routes.push({
123
+ nodes: pr.nodes,
124
+ lane: 0,
125
+ parentRoute: parentRouteIdx >= 0 ? parentRouteIdx : (i === 0 ? -1 : 0),
126
+ depth,
127
+ cls: pr.cls || null,
128
+ id: pr.id || null,
129
+ });
130
+
131
+ const ri = routes.length - 1;
132
+ pr.nodes.forEach(id => {
133
+ if (!assigned.has(id)) { assigned.add(id); nodeRoute.set(id, ri); }
134
+ nodeRoutes.get(id)?.add(ri);
135
+ });
136
+ });
137
+
138
+ // Any nodes not in any route get assigned to route 0
139
+ nodes.forEach(nd => {
140
+ if (!assigned.has(nd.id)) {
141
+ assigned.add(nd.id);
142
+ nodeRoute.set(nd.id, 0);
143
+ }
144
+ });
145
+ } else {
146
+ // ── Auto-discover routes via greedy longest-path ──
147
+ const trunk = longestPathIn(new Set(topo));
148
+ routes.push({ nodes: trunk, lane: 0, parentRoute: -1, depth: 0 });
149
+ trunk.forEach(id => { assigned.add(id); nodeRoute.set(id, 0); nodeRoutes.get(id)?.add(0); });
150
+
151
+ let safety = 0;
152
+ while (assigned.size < nodes.length && safety++ < 300) {
153
+ const unassigned = [];
154
+ nodes.forEach(nd => { if (!assigned.has(nd.id)) unassigned.push(nd.id); });
155
+ if (unassigned.length === 0) break;
156
+
157
+ const unassignedSet = new Set(unassigned);
158
+ let bestPath = longestPathIn(unassignedSet);
159
+ if (bestPath.length === 0) {
160
+ unassigned.forEach(id => { assigned.add(id); nodeRoute.set(id, 0); });
161
+ break;
162
+ }
163
+
164
+ const firstNode = bestPath[0];
165
+ const assignedParents = parentsOf.get(firstNode).filter(p => assigned.has(p));
166
+ let parentRouteIdx = 0;
167
+ if (assignedParents.length > 0) {
168
+ bestPath.unshift(assignedParents[0]);
169
+ parentRouteIdx = nodeRoute.get(assignedParents[0]) ?? 0;
170
+ }
171
+
172
+ const lastNode = bestPath[bestPath.length - 1];
173
+ const assignedChildren = childrenOf.get(lastNode).filter(c => assigned.has(c));
174
+ if (assignedChildren.length > 0) {
175
+ bestPath.push(assignedChildren[0]);
176
+ }
177
+
178
+ const ri = routes.length;
179
+ const parentDepth = routes[parentRouteIdx]?.depth ?? 0;
180
+ routes.push({ nodes: bestPath, lane: 0, parentRoute: parentRouteIdx, depth: parentDepth + 1 });
181
+ bestPath.forEach(id => {
182
+ if (!assigned.has(id)) { assigned.add(id); nodeRoute.set(id, ri); }
183
+ nodeRoutes.get(id)?.add(ri);
184
+ });
185
+ }
186
+ }
187
+
188
+ // ── Build shared segment map for parallel offset rendering ──
189
+ // segmentRoutes: "A→B" → [routeIdx, ...] (ordered)
190
+ const segmentRoutes = new Map();
191
+ routes.forEach((route, ri) => {
192
+ for (let i = 1; i < route.nodes.length; i++) {
193
+ const key = `${route.nodes[i - 1]}\u2192${route.nodes[i]}`;
194
+ if (!segmentRoutes.has(key)) segmentRoutes.set(key, []);
195
+ segmentRoutes.get(key).push(ri);
196
+ }
197
+ });
198
+
199
+ // lineGap: perpendicular gap between parallel lines at shared nodes.
200
+ // Only non-zero when consumer provides multiple routes (visible parallel lines).
201
+ // Auto-discovered routes are internal — they don't need visual separation.
202
+ const lineGap = (options.lineGap ?? (hasProvidedRoutes && routes.length > 1 ? 5 : 0)) * s;
203
+
204
+ // ── STEP 3: Y-position assignment with occupancy tracking ──
205
+ const routeChildren = new Map();
206
+ routes.forEach((_, i) => routeChildren.set(i, []));
207
+ for (let ri = 1; ri < routes.length; ri++) {
208
+ const pi = routes[ri].parentRoute;
209
+ if (routeChildren.has(pi)) routeChildren.get(pi).push(ri);
210
+ else routeChildren.set(pi, [ri]);
211
+ }
212
+
213
+ const routeLayerRange = routes.map(route => {
214
+ let min = Infinity, max = -Infinity;
215
+ route.nodes.forEach(id => {
216
+ const l = layer.get(id);
217
+ if (l < min) min = l;
218
+ if (l > max) max = l;
219
+ });
220
+ return [min, max];
221
+ });
222
+
223
+ const routeOwnLength = routes.map((route, ri) => {
224
+ return route.nodes.filter(id => nodeRoute.get(id) === ri).length;
225
+ });
226
+
227
+ const routeDomClass = routes.map((route, ri) => {
228
+ const ownNodes = route.nodes.filter(id => nodeRoute.get(id) === ri);
229
+ return dominantClass(ownNodes, nodeMap);
230
+ });
231
+
232
+ // Y occupancy tracker: tracks used Y ranges per layer range
233
+ const yOccupancy = []; // [{y, sL, eL}]
234
+ function canUseY(y, sL, eL, minGap) {
235
+ for (const occ of yOccupancy) {
236
+ if (sL <= occ.eL + 1 && eL >= occ.sL - 1) {
237
+ if (Math.abs(y - occ.y) < minGap) return false;
238
+ }
239
+ }
240
+ return true;
241
+ }
242
+ function claimY(y, sL, eL) {
243
+ yOccupancy.push({ y, sL, eL });
244
+ }
245
+
246
+ // Assign trunk
247
+ const routeY = new Map();
248
+ routeY.set(0, TRUNK_Y);
249
+ claimY(TRUNK_Y, routeLayerRange[0][0], routeLayerRange[0][1]);
250
+
251
+ // BFS from trunk
252
+ const laneQueue = [0];
253
+ const assignedRoutes = new Set([0]);
254
+
255
+ while (laneQueue.length > 0) {
256
+ const pi = laneQueue.shift();
257
+ const parentY = routeY.get(pi);
258
+ const children = routeChildren.get(pi) || [];
259
+
260
+ // With provided routes, keep route order (gives consumer control over above/below).
261
+ // With auto-discovered routes, sort longest first.
262
+ if (!hasProvidedRoutes) {
263
+ children.sort((a, b) => routeOwnLength[b] - routeOwnLength[a]);
264
+ }
265
+
266
+ let childAbove = 0, childBelow = 0;
267
+
268
+ for (const ci of children) {
269
+ if (assignedRoutes.has(ci)) continue;
270
+ const [sL, eL] = routeLayerRange[ci];
271
+ const cls = routeDomClass[ci];
272
+ const depth = routes[ci].depth;
273
+ const ownLength = routeOwnLength[ci];
274
+
275
+ // Spacing depends on depth and route length
276
+ const spacing = (depth <= 1 && ownLength > 2) ? MAIN_SPACING : SUB_SPACING;
277
+
278
+ // With provided routes, alternate strictly: first child above, second below, etc.
279
+ // With auto-discovered routes, use class-based heuristics.
280
+ let preferBelow;
281
+ if (hasProvidedRoutes) {
282
+ preferBelow = childBelow <= childAbove;
283
+ } else if (cls === 'side_effecting') {
284
+ preferBelow = true;
285
+ } else if (cls === 'recordable' && depth === 1) {
286
+ preferBelow = false;
287
+ } else {
288
+ preferBelow = childBelow <= childAbove;
289
+ }
290
+
291
+ // Search for an available Y position
292
+ const maxDist = maxLanes ? maxLanes : 8;
293
+ let y = null;
294
+ for (let dist = 1; dist <= maxDist; dist++) {
295
+ const tryY = parentY + (preferBelow ? dist * spacing : -dist * spacing);
296
+ if (canUseY(tryY, sL, eL, spacing * 0.8)) {
297
+ y = tryY; break;
298
+ }
299
+ const tryAlt = parentY + (preferBelow ? -dist * spacing : dist * spacing);
300
+ if (canUseY(tryAlt, sL, eL, spacing * 0.8)) {
301
+ y = tryAlt; break;
302
+ }
303
+ }
304
+ if (y === null) {
305
+ y = parentY + (preferBelow ? (childBelow + 1) * spacing : -(childAbove + 1) * spacing);
306
+ }
307
+
308
+ routeY.set(ci, y);
309
+ claimY(y, sL, eL);
310
+ assignedRoutes.add(ci);
311
+ laneQueue.push(ci);
312
+
313
+ if (y > parentY) childBelow++;
314
+ else childAbove++;
315
+ }
316
+ }
317
+
318
+ // ── STEP 4: Position nodes ──
319
+ const margin = { top: 0, left: 50 * s, bottom: 0, right: 40 * s };
320
+
321
+ // Each node's Y comes from its route's Y
322
+ const nodeYDirect = new Map();
323
+ nodes.forEach(nd => {
324
+ const ri = nodeRoute.get(nd.id);
325
+ nodeYDirect.set(nd.id, (ri !== undefined) ? routeY.get(ri) : TRUNK_Y);
326
+ });
327
+
328
+ // Find Y bounds
329
+ let minY = Infinity, maxY = -Infinity;
330
+ nodes.forEach(nd => {
331
+ const y = nodeYDirect.get(nd.id);
332
+ if (y < minY) minY = y;
333
+ if (y > maxY) maxY = y;
334
+ });
335
+
336
+ // Add padding
337
+ const topPad = 50 * s;
338
+ const bottomPad = 80 * s;
339
+
340
+ const positions = new Map();
341
+ nodes.forEach(nd => {
342
+ positions.set(nd.id, {
343
+ x: margin.left + layer.get(nd.id) * layerSpacing,
344
+ y: topPad + (nodeYDirect.get(nd.id) - minY),
345
+ });
346
+ });
347
+
348
+ const width = margin.left + (maxLayer + 1) * layerSpacing + margin.right;
349
+ const height = topPad + (maxY - minY) + bottomPad;
350
+
351
+ // Compute screen Y for each route (after topPad/minY shift)
352
+ const routeYScreen = new Map();
353
+ for (const [ri, y] of routeY.entries()) {
354
+ routeYScreen.set(ri, topPad + (y - minY));
355
+ }
356
+ const trunkYScreen = topPad + (TRUNK_Y - minY);
357
+
358
+ // ── STEP 5: Build route paths ──
359
+ const pathFn = routing === 'metro' ? metroPath : routing === 'bezier' ? bezierPath : angularPath;
360
+ const opBoost = theme.lineOpacity ?? 1.0;
361
+
362
+ const routePaths = routes.map((route, ri) => {
363
+ const pts = route.nodes.map(id => ({ ...positions.get(id), id }));
364
+ const ownNodes = route.nodes.filter(id => nodeRoute.get(id) === ri);
365
+
366
+ // Route color: use route's cls if provided, else dominant class
367
+ const routeCls = route.cls || dominantClass(ownNodes, nodeMap);
368
+ const color = classColor[routeCls] || classColor.pure || Object.values(classColor)[0];
369
+
370
+ let thickness, opacity;
371
+ if (hasProvidedRoutes) {
372
+ // With provided routes, all lines are equal weight
373
+ thickness = 3 * s;
374
+ opacity = Math.min(0.55 * opBoost, 1);
375
+ } else if (ri === 0) {
376
+ thickness = 5 * s;
377
+ opacity = Math.min(0.6 * opBoost, 1);
378
+ } else if (ownNodes.length > 5) {
379
+ thickness = 3.5 * s;
380
+ opacity = Math.min(0.45 * opBoost, 1);
381
+ } else if (ownNodes.length > 2) {
382
+ thickness = 2.5 * s;
383
+ opacity = Math.min(0.35 * opBoost, 1);
384
+ } else {
385
+ thickness = 2 * s;
386
+ opacity = Math.min(0.28 * opBoost, 1);
387
+ }
388
+
389
+ // Precompute per-node offset for this route.
390
+ // At each node, find all routes passing through it and assign a consistent
391
+ // slot so the line enters and exits at the same Y-offset.
392
+ const nodeOffsetY = new Map();
393
+ for (const id of route.nodes) {
394
+ const nr = nodeRoutes.get(id);
395
+ if (nr && nr.size > 1) {
396
+ const allRoutes = [...nr].sort((a, b) => a - b); // stable order
397
+ const idx = allRoutes.indexOf(ri);
398
+ const n = allRoutes.length;
399
+ nodeOffsetY.set(id, (idx - (n - 1) / 2) * lineGap);
400
+ } else {
401
+ nodeOffsetY.set(id, 0);
402
+ }
403
+ }
404
+
405
+ const segments = [];
406
+ for (let i = 1; i < pts.length; i++) {
407
+ const p = pts[i - 1], q = pts[i];
408
+
409
+ // Use node-based offsets for continuity through stations
410
+ const offPy = nodeOffsetY.get(p.id) || 0;
411
+ const offQy = nodeOffsetY.get(q.id) || 0;
412
+
413
+ const px = p.x, py = p.y + offPy;
414
+ const qx = q.x, qy = q.y + offQy;
415
+
416
+ // Segment color: use route color for provided routes, else source node class
417
+ const srcNode = nodeMap.get(p.id);
418
+ const segColor = hasProvidedRoutes ? color : (classColor[srcNode?.cls] || color);
419
+ const segDashed = srcNode?.cls === 'gate' || route.cls === 'gate';
420
+
421
+ // Determine reference Y for convergence/divergence detection
422
+ let segRefY;
423
+ if (routing === 'angular') {
424
+ const srcIsOwn = nodeRoute.get(p.id) === ri;
425
+ const dstIsOwn = nodeRoute.get(q.id) === ri;
426
+
427
+ if (!srcIsOwn && dstIsOwn) {
428
+ segRefY = py;
429
+ } else if (srcIsOwn && !dstIsOwn) {
430
+ segRefY = qy;
431
+ } else {
432
+ segRefY = trunkYScreen;
433
+ }
434
+ } else {
435
+ segRefY = trunkYScreen;
436
+ }
437
+
438
+ const d = `M ${px} ${py} ` + pathFn(px, py, qx, qy, ri, i, segRefY, { progressivePower, cornerRadius, bendStyle: isTTB ? 'v-first' : 'h-first' });
439
+ const dstNode = nodeMap.get(q.id);
440
+ const srcDim = srcNode?.dim === true;
441
+ const dstDim = dstNode?.dim === true;
442
+ const segOpacity = (srcDim || dstDim) ? Math.min(opacity, dimOpacity * 0.48) : opacity;
443
+ segments.push({ d, color: segColor, thickness, opacity: segOpacity, dashed: segDashed });
444
+ }
445
+ return segments;
446
+ });
447
+
448
+ // ── STEP 6: Extra edges (cross-route connections) ──
449
+ const routeEdgeSet = new Set();
450
+ routes.forEach(route => {
451
+ for (let i = 1; i < route.nodes.length; i++)
452
+ routeEdgeSet.add(`${route.nodes[i - 1]}\u2192${route.nodes[i]}`);
453
+ });
454
+
455
+ const extraEdges = [];
456
+ edges.forEach(([f, t]) => {
457
+ if (routeEdgeSet.has(`${f}\u2192${t}`)) return;
458
+ const p = positions.get(f), q = positions.get(t);
459
+ if (!p || !q) return;
460
+ const srcNode = nodeMap.get(f);
461
+ const color = classColor[srcNode?.cls] || classColor.pure;
462
+ const extraIdx = (f.length * 3 + t.length * 7) % 17;
463
+
464
+ // Extra edges always use trunkScreenY as reference
465
+ const refY = trunkYScreen;
466
+
467
+ const d = `M ${p.x} ${p.y} ` + pathFn(p.x, p.y, q.x, q.y, extraIdx, 0, refY, { progressivePower, cornerRadius, bendStyle: isTTB ? 'v-first' : 'h-first' });
468
+ const dstNode = nodeMap.get(t);
469
+ const extraDim = srcNode?.dim === true || dstNode?.dim === true;
470
+ const extraOpacity = extraDim ? Math.min(dimOpacity * 0.32, Math.min(0.22 * opBoost, 1)) : Math.min(0.22 * opBoost, 1);
471
+ extraEdges.push({ d, color, thickness: 1.8 * s, opacity: extraOpacity, dashed: srcNode?.cls === 'gate' });
472
+ });
473
+
474
+ // Node lane info (for compatibility)
475
+ const nodeLane = new Map();
476
+ nodes.forEach(nd => {
477
+ const ri = nodeRoute.get(nd.id);
478
+ nodeLane.set(nd.id, ri !== undefined ? routes[ri].lane : 0);
479
+ });
480
+
481
+ if (direction === 'ttb') {
482
+ // Swap X↔Y in all positions
483
+ for (const [id, pos] of positions) {
484
+ positions.set(id, { x: pos.y, y: pos.x });
485
+ }
486
+
487
+ // Rewrite SVG path data: swap all coordinate pairs
488
+ for (const segments of routePaths) {
489
+ for (const seg of segments) {
490
+ seg.d = swapPathXY(seg.d);
491
+ }
492
+ }
493
+ for (const seg of extraEdges) {
494
+ seg.d = swapPathXY(seg.d);
495
+ }
496
+
497
+ return {
498
+ positions,
499
+ routePaths,
500
+ extraEdges,
501
+ width: height,
502
+ height: width,
503
+ maxLayer,
504
+ routes,
505
+ nodeLane,
506
+ nodeRoute,
507
+ nodeRoutes,
508
+ segmentRoutes,
509
+ laneSpacing: MAIN_SPACING,
510
+ layerSpacing,
511
+ minY,
512
+ maxY,
513
+ routeYScreen,
514
+ trunkYScreen,
515
+ scale: s,
516
+ theme,
517
+ orientation: 'ttb',
518
+ };
519
+ }
520
+
521
+ return {
522
+ positions,
523
+ routePaths,
524
+ extraEdges,
525
+ width,
526
+ height,
527
+ maxLayer,
528
+ routes,
529
+ nodeLane,
530
+ nodeRoute,
531
+ nodeRoutes,
532
+ segmentRoutes,
533
+ laneSpacing: MAIN_SPACING,
534
+ layerSpacing,
535
+ minY,
536
+ maxY,
537
+ routeYScreen,
538
+ trunkYScreen,
539
+ scale: s,
540
+ theme,
541
+ };
542
+ }
@@ -0,0 +1,138 @@
1
+ // ================================================================
2
+ // occupancy.js — Spatial occupancy tracker for collision detection
3
+ // ================================================================
4
+ // Tracks placed rectangles in 2D space. Used by layoutFlow to
5
+ // detect and avoid collisions between tracks, cards, and labels.
6
+ //
7
+ // Uses a simple array of axis-aligned bounding boxes (AABBs).
8
+ // For our graph sizes (<100 items), brute-force AABB checks are fast enough.
9
+
10
+ /**
11
+ * @typedef {Object} Rect
12
+ * @property {number} x - left edge
13
+ * @property {number} y - top edge
14
+ * @property {number} w - width
15
+ * @property {number} h - height
16
+ * @property {string} [type] - 'card'|'track'|'badge'|'dot'
17
+ * @property {string} [owner] - node/edge/route id
18
+ */
19
+
20
+ export class OccupancyGrid {
21
+ constructor(padding = 2) {
22
+ /** @type {Rect[]} */
23
+ this.items = [];
24
+ this.padding = padding;
25
+ }
26
+
27
+ /**
28
+ * Check if a rect can be placed without collision.
29
+ * @param {Rect} rect
30
+ * @param {string|Set<string>} [ignoreOwner] - ignore items with this owner (string or Set)
31
+ * @returns {boolean}
32
+ */
33
+ canPlace(rect, ignoreOwner) {
34
+ const p = this.padding;
35
+ for (const item of this.items) {
36
+ if (this._ignored(item, ignoreOwner)) continue;
37
+ if (this._overlaps(rect, item, p)) return false;
38
+ }
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * Place a rect in the grid.
44
+ * @param {Rect} rect
45
+ */
46
+ place(rect) {
47
+ this.items.push(rect);
48
+ }
49
+
50
+ /**
51
+ * Place if no collision, return success.
52
+ * @param {Rect} rect
53
+ * @param {string} [ignoreOwner]
54
+ * @returns {boolean}
55
+ */
56
+ tryPlace(rect, ignoreOwner) {
57
+ if (this.canPlace(rect, ignoreOwner)) {
58
+ this.place(rect);
59
+ return true;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Find all items that overlap with a given rect.
66
+ * @param {Rect} rect
67
+ * @returns {Rect[]}
68
+ */
69
+ query(rect) {
70
+ const p = this.padding;
71
+ return this.items.filter(item => this._overlaps(rect, item, p));
72
+ }
73
+
74
+ /**
75
+ * Count overlaps for a candidate rect (for scoring).
76
+ * @param {Rect} rect
77
+ * @param {string|Set<string>} [ignoreOwner]
78
+ * @returns {number}
79
+ */
80
+ overlapCount(rect, ignoreOwner) {
81
+ const p = this.padding;
82
+ let count = 0;
83
+ for (const item of this.items) {
84
+ if (this._ignored(item, ignoreOwner)) continue;
85
+ if (this._overlaps(rect, item, p)) count++;
86
+ }
87
+ return count;
88
+ }
89
+
90
+ /**
91
+ * Register a line segment as a thin rectangle in the grid.
92
+ * @param {number} x1
93
+ * @param {number} y1
94
+ * @param {number} x2
95
+ * @param {number} y2
96
+ * @param {number} thickness
97
+ * @param {string} [owner]
98
+ */
99
+ placeLine(x1, y1, x2, y2, thickness, owner) {
100
+ const t = thickness / 2;
101
+ const rect = {
102
+ x: Math.min(x1, x2) - t,
103
+ y: Math.min(y1, y2) - t,
104
+ w: Math.abs(x2 - x1) + thickness,
105
+ h: Math.abs(y2 - y1) + thickness,
106
+ type: 'track',
107
+ owner,
108
+ };
109
+ this.items.push(rect);
110
+ }
111
+
112
+ /**
113
+ * Remove all items with a given owner.
114
+ * @param {string} owner
115
+ */
116
+ removeOwner(owner) {
117
+ this.items = this.items.filter(item => item.owner !== owner);
118
+ }
119
+
120
+ /** @private */
121
+ _ignored(item, ignoreOwner) {
122
+ if (!ignoreOwner || !item.owner) return false;
123
+ if (typeof ignoreOwner === 'string') return item.owner === ignoreOwner;
124
+ return ignoreOwner.has(item.owner);
125
+ }
126
+
127
+ /**
128
+ * @private
129
+ */
130
+ _overlaps(a, b, padding) {
131
+ return !(
132
+ a.x + a.w + padding <= b.x ||
133
+ b.x + b.w + padding <= a.x ||
134
+ a.y + a.h + padding <= b.y ||
135
+ b.y + b.h + padding <= a.y
136
+ );
137
+ }
138
+ }