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.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/bin/turbometro.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +107 -0
- package/dist/find-root.d.ts +2 -0
- package/dist/find-root.js +14 -0
- package/dist/graph.d.ts +14 -0
- package/dist/graph.js +74 -0
- package/dist/layout.d.ts +19 -0
- package/dist/layout.js +15 -0
- package/dist/parse-turbo.d.ts +5 -0
- package/dist/parse-turbo.js +29 -0
- package/dist/parse-workspace.d.ts +4 -0
- package/dist/parse-workspace.js +100 -0
- package/dist/render-html.d.ts +8 -0
- package/dist/render-html.js +699 -0
- package/dist/render-map.d.ts +29 -0
- package/dist/render-map.js +287 -0
- package/dist/replay.d.ts +9 -0
- package/dist/replay.js +74 -0
- package/dist/scale.d.ts +7 -0
- package/dist/scale.js +19 -0
- package/dist/scene-data.d.ts +40 -0
- package/dist/scene-data.js +146 -0
- package/dist/theme.d.ts +15 -0
- package/dist/theme.js +15 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +1 -0
- package/fixtures/mini-mono/apps/web/package.json +11 -0
- package/fixtures/mini-mono/package.json +5 -0
- package/fixtures/mini-mono/packages/ui/package.json +11 -0
- package/fixtures/mini-mono/packages/utils/package.json +8 -0
- package/fixtures/mini-mono/pnpm-workspace.yaml +3 -0
- package/fixtures/mini-mono/turbo.json +10 -0
- package/package.json +44 -0
- package/vendor/dag-map/LICENSE +201 -0
- package/vendor/dag-map/NOTICE +6 -0
- package/vendor/dag-map/src/color-scales.js +61 -0
- package/vendor/dag-map/src/dag-map.css +63 -0
- package/vendor/dag-map/src/events.js +108 -0
- package/vendor/dag-map/src/graph-utils.js +185 -0
- package/vendor/dag-map/src/hasse.css +61 -0
- package/vendor/dag-map/src/index.js +55 -0
- package/vendor/dag-map/src/layout-flow.js +1066 -0
- package/vendor/dag-map/src/layout-hasse.js +485 -0
- package/vendor/dag-map/src/layout-metro.js +542 -0
- package/vendor/dag-map/src/occupancy.js +138 -0
- package/vendor/dag-map/src/render-flow-station.js +132 -0
- package/vendor/dag-map/src/render.js +360 -0
- package/vendor/dag-map/src/route-angular.js +137 -0
- package/vendor/dag-map/src/route-bezier.js +51 -0
- package/vendor/dag-map/src/route-metro.js +122 -0
- 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
|
+
}
|