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,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
|
+
}
|