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,485 @@
|
|
|
1
|
+
// ================================================================
|
|
2
|
+
// layout-hasse.js — Hasse diagram layout engine for dag-map
|
|
3
|
+
// ================================================================
|
|
4
|
+
// Sugiyama-style layered layout for partial orders / lattices.
|
|
5
|
+
// Top-to-bottom: ⊤ (top) at the top, ⊥ (bottom) at the bottom.
|
|
6
|
+
// Edges represent covering relations pointing downward.
|
|
7
|
+
//
|
|
8
|
+
// Algorithm phases:
|
|
9
|
+
// 1. Rank assignment (longest path from sources)
|
|
10
|
+
// 2. Virtual node insertion for long edges
|
|
11
|
+
// 3. Crossing reduction (barycenter heuristic, multi-pass)
|
|
12
|
+
// 4. X-coordinate assignment (barycenter positioning + spacing)
|
|
13
|
+
// 5. Y-coordinate assignment (rank × spacing)
|
|
14
|
+
// 6. Edge path generation
|
|
15
|
+
|
|
16
|
+
import { resolveTheme } from './themes.js';
|
|
17
|
+
import { assertValidDag, buildGraph, topoSortAndRank } from './graph-utils.js';
|
|
18
|
+
|
|
19
|
+
// ================================================================
|
|
20
|
+
// PHASE 2: Virtual node insertion
|
|
21
|
+
// ================================================================
|
|
22
|
+
|
|
23
|
+
function insertVirtualNodes(edges, rank, childrenOf, parentsOf) {
|
|
24
|
+
const virtualNodes = []; // { id, rank }
|
|
25
|
+
const expandedEdges = []; // all edges after splitting
|
|
26
|
+
const virtualChains = new Map(); // original edge key -> [virtual node ids]
|
|
27
|
+
|
|
28
|
+
for (const [from, to] of edges) {
|
|
29
|
+
const rFrom = rank.get(from);
|
|
30
|
+
const rTo = rank.get(to);
|
|
31
|
+
const span = rTo - rFrom;
|
|
32
|
+
|
|
33
|
+
if (span <= 1) {
|
|
34
|
+
expandedEdges.push([from, to]);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Insert virtual nodes at each intermediate rank
|
|
39
|
+
const chain = [];
|
|
40
|
+
let prev = from;
|
|
41
|
+
for (let r = rFrom + 1; r < rTo; r++) {
|
|
42
|
+
const vid = `__v_${from}_${to}_${r}`;
|
|
43
|
+
virtualNodes.push({ id: vid, rank: r });
|
|
44
|
+
chain.push(vid);
|
|
45
|
+
expandedEdges.push([prev, vid]);
|
|
46
|
+
|
|
47
|
+
// Register in adjacency
|
|
48
|
+
if (!childrenOf.has(vid)) childrenOf.set(vid, []);
|
|
49
|
+
if (!parentsOf.has(vid)) parentsOf.set(vid, []);
|
|
50
|
+
childrenOf.get(prev).push(vid);
|
|
51
|
+
parentsOf.get(vid).push(prev);
|
|
52
|
+
|
|
53
|
+
prev = vid;
|
|
54
|
+
}
|
|
55
|
+
expandedEdges.push([prev, to]);
|
|
56
|
+
childrenOf.get(prev).push(to);
|
|
57
|
+
parentsOf.get(to).push(prev);
|
|
58
|
+
|
|
59
|
+
virtualChains.set(`${from}->${to}`, chain);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Update rank map for virtual nodes
|
|
63
|
+
for (const vn of virtualNodes) {
|
|
64
|
+
rank.set(vn.id, vn.rank);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { virtualNodes, expandedEdges, virtualChains };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ================================================================
|
|
71
|
+
// PHASE 3: Crossing reduction (barycenter heuristic)
|
|
72
|
+
// ================================================================
|
|
73
|
+
|
|
74
|
+
function buildLayers(nodeIds, rank, maxRank) {
|
|
75
|
+
const layers = [];
|
|
76
|
+
for (let r = 0; r <= maxRank; r++) layers.push([]);
|
|
77
|
+
for (const id of nodeIds) {
|
|
78
|
+
const r = rank.get(id);
|
|
79
|
+
if (r !== undefined) layers[r].push(id);
|
|
80
|
+
}
|
|
81
|
+
return layers;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function countCrossings(layers, childrenOf) {
|
|
85
|
+
let total = 0;
|
|
86
|
+
for (let r = 0; r < layers.length - 1; r++) {
|
|
87
|
+
const upper = layers[r];
|
|
88
|
+
const lower = layers[r + 1];
|
|
89
|
+
const posInLower = new Map();
|
|
90
|
+
lower.forEach((id, i) => posInLower.set(id, i));
|
|
91
|
+
|
|
92
|
+
// Collect edges as (upper_pos, lower_pos) pairs
|
|
93
|
+
const edgePairs = [];
|
|
94
|
+
for (let ui = 0; ui < upper.length; ui++) {
|
|
95
|
+
const children = childrenOf.get(upper[ui]) || [];
|
|
96
|
+
for (const child of children) {
|
|
97
|
+
const li = posInLower.get(child);
|
|
98
|
+
if (li !== undefined) edgePairs.push([ui, li]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Count inversions
|
|
103
|
+
for (let i = 0; i < edgePairs.length; i++) {
|
|
104
|
+
for (let j = i + 1; j < edgePairs.length; j++) {
|
|
105
|
+
if ((edgePairs[i][0] - edgePairs[j][0]) * (edgePairs[i][1] - edgePairs[j][1]) < 0) {
|
|
106
|
+
total++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return total;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function barycenterSort(layer, getNeighborPositions) {
|
|
115
|
+
const barycenters = new Map();
|
|
116
|
+
for (const id of layer) {
|
|
117
|
+
const positions = getNeighborPositions(id);
|
|
118
|
+
if (positions.length > 0) {
|
|
119
|
+
const avg = positions.reduce((a, b) => a + b, 0) / positions.length;
|
|
120
|
+
barycenters.set(id, avg);
|
|
121
|
+
} else {
|
|
122
|
+
barycenters.set(id, Infinity); // keep original position
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Stable sort by barycenter
|
|
127
|
+
const indexed = layer.map((id, i) => ({ id, bc: barycenters.get(id), orig: i }));
|
|
128
|
+
indexed.sort((a, b) => {
|
|
129
|
+
if (a.bc !== b.bc) return a.bc - b.bc;
|
|
130
|
+
return a.orig - b.orig; // stable tie-break
|
|
131
|
+
});
|
|
132
|
+
return indexed.map(e => e.id);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function reduceCrossings(layers, childrenOf, parentsOf, passes) {
|
|
136
|
+
let best = layers.map(l => [...l]);
|
|
137
|
+
let bestCrossings = countCrossings(best, childrenOf);
|
|
138
|
+
|
|
139
|
+
const current = layers.map(l => [...l]);
|
|
140
|
+
|
|
141
|
+
for (let pass = 0; pass < passes; pass++) {
|
|
142
|
+
if (pass % 2 === 0) {
|
|
143
|
+
// Top-down sweep
|
|
144
|
+
for (let r = 1; r < current.length; r++) {
|
|
145
|
+
const upperPos = new Map();
|
|
146
|
+
current[r - 1].forEach((id, i) => upperPos.set(id, i));
|
|
147
|
+
|
|
148
|
+
current[r] = barycenterSort(current[r], (id) => {
|
|
149
|
+
const parents = parentsOf.get(id) || [];
|
|
150
|
+
return parents.map(p => upperPos.get(p)).filter(p => p !== undefined);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
// Bottom-up sweep
|
|
155
|
+
for (let r = current.length - 2; r >= 0; r--) {
|
|
156
|
+
const lowerPos = new Map();
|
|
157
|
+
current[r + 1].forEach((id, i) => lowerPos.set(id, i));
|
|
158
|
+
|
|
159
|
+
current[r] = barycenterSort(current[r], (id) => {
|
|
160
|
+
const children = childrenOf.get(id) || [];
|
|
161
|
+
return children.map(c => lowerPos.get(c)).filter(c => c !== undefined);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const crossings = countCrossings(current, childrenOf);
|
|
167
|
+
if (crossings < bestCrossings) {
|
|
168
|
+
bestCrossings = crossings;
|
|
169
|
+
for (let r = 0; r < current.length; r++) best[r] = [...current[r]];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return best;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ================================================================
|
|
177
|
+
// PHASE 4: X-coordinate assignment
|
|
178
|
+
// ================================================================
|
|
179
|
+
|
|
180
|
+
function assignXCoordinates(layers, childrenOf, parentsOf, nodeSpacing) {
|
|
181
|
+
const x = new Map();
|
|
182
|
+
|
|
183
|
+
// Initialize: evenly spaced within each layer
|
|
184
|
+
for (const layer of layers) {
|
|
185
|
+
layer.forEach((id, i) => {
|
|
186
|
+
x.set(id, i * nodeSpacing);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Iterative refinement: move each node toward the barycenter of its neighbors
|
|
191
|
+
for (let iter = 0; iter < 12; iter++) {
|
|
192
|
+
// Top-down pass
|
|
193
|
+
for (let r = 1; r < layers.length; r++) {
|
|
194
|
+
for (const id of layers[r]) {
|
|
195
|
+
const parents = (parentsOf.get(id) || []).filter(p => x.has(p));
|
|
196
|
+
const children = (childrenOf.get(id) || []).filter(c => x.has(c));
|
|
197
|
+
const neighbors = [...parents, ...children];
|
|
198
|
+
if (neighbors.length > 0) {
|
|
199
|
+
const avg = neighbors.reduce((sum, n) => sum + x.get(n), 0) / neighbors.length;
|
|
200
|
+
x.set(id, avg);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Enforce minimum spacing
|
|
204
|
+
enforceSpacing(layers[r], x, nodeSpacing);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Bottom-up pass
|
|
208
|
+
for (let r = layers.length - 2; r >= 0; r--) {
|
|
209
|
+
for (const id of layers[r]) {
|
|
210
|
+
const parents = (parentsOf.get(id) || []).filter(p => x.has(p));
|
|
211
|
+
const children = (childrenOf.get(id) || []).filter(c => x.has(c));
|
|
212
|
+
const neighbors = [...parents, ...children];
|
|
213
|
+
if (neighbors.length > 0) {
|
|
214
|
+
const avg = neighbors.reduce((sum, n) => sum + x.get(n), 0) / neighbors.length;
|
|
215
|
+
x.set(id, avg);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
enforceSpacing(layers[r], x, nodeSpacing);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Center all layers around the same midpoint
|
|
223
|
+
centerLayers(layers, x);
|
|
224
|
+
|
|
225
|
+
return x;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function enforceSpacing(layer, x, minSpacing) {
|
|
229
|
+
// Sort layer by current x position (maintain layer order)
|
|
230
|
+
const sorted = [...layer].sort((a, b) => x.get(a) - x.get(b));
|
|
231
|
+
|
|
232
|
+
// Left-to-right sweep: push right if too close
|
|
233
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
234
|
+
const prev = x.get(sorted[i - 1]);
|
|
235
|
+
const curr = x.get(sorted[i]);
|
|
236
|
+
if (curr - prev < minSpacing) {
|
|
237
|
+
x.set(sorted[i], prev + minSpacing);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Right-to-left sweep: push left if too close (balance)
|
|
242
|
+
for (let i = sorted.length - 2; i >= 0; i--) {
|
|
243
|
+
const next = x.get(sorted[i + 1]);
|
|
244
|
+
const curr = x.get(sorted[i]);
|
|
245
|
+
if (next - curr < minSpacing) {
|
|
246
|
+
x.set(sorted[i], next - minSpacing);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function centerLayers(layers, x) {
|
|
252
|
+
// Find the global center
|
|
253
|
+
let globalMin = Infinity, globalMax = -Infinity;
|
|
254
|
+
for (const layer of layers) {
|
|
255
|
+
for (const id of layer) {
|
|
256
|
+
const val = x.get(id);
|
|
257
|
+
if (val < globalMin) globalMin = val;
|
|
258
|
+
if (val > globalMax) globalMax = val;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const globalCenter = (globalMin + globalMax) / 2;
|
|
262
|
+
|
|
263
|
+
// Center each layer
|
|
264
|
+
for (const layer of layers) {
|
|
265
|
+
if (layer.length === 0) continue;
|
|
266
|
+
let layerMin = Infinity, layerMax = -Infinity;
|
|
267
|
+
for (const id of layer) {
|
|
268
|
+
const val = x.get(id);
|
|
269
|
+
if (val < layerMin) layerMin = val;
|
|
270
|
+
if (val > layerMax) layerMax = val;
|
|
271
|
+
}
|
|
272
|
+
const layerCenter = (layerMin + layerMax) / 2;
|
|
273
|
+
const shift = globalCenter - layerCenter;
|
|
274
|
+
for (const id of layer) {
|
|
275
|
+
x.set(id, x.get(id) + shift);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ================================================================
|
|
281
|
+
// PHASE 5+6: Edge path generation
|
|
282
|
+
// ================================================================
|
|
283
|
+
|
|
284
|
+
function hasseEdgePath(points, edgeStyle) {
|
|
285
|
+
if (points.length < 2) return '';
|
|
286
|
+
|
|
287
|
+
if (points.length === 2) {
|
|
288
|
+
const [p, q] = points;
|
|
289
|
+
const dx = Math.abs(q.x - p.x);
|
|
290
|
+
|
|
291
|
+
if (edgeStyle === 'straight' || dx < 2) {
|
|
292
|
+
return `M ${p.x} ${p.y} L ${q.x} ${q.y}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Gentle vertical cubic bezier
|
|
296
|
+
const dy = q.y - p.y;
|
|
297
|
+
const cp1y = p.y + dy * 0.4;
|
|
298
|
+
const cp2y = p.y + dy * 0.6;
|
|
299
|
+
return `M ${p.x} ${p.y} C ${p.x} ${cp1y}, ${q.x} ${cp2y}, ${q.x} ${q.y}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Multi-segment through virtual nodes
|
|
303
|
+
if (edgeStyle === 'straight') {
|
|
304
|
+
let d = `M ${points[0].x} ${points[0].y}`;
|
|
305
|
+
for (let i = 1; i < points.length; i++) {
|
|
306
|
+
d += ` L ${points[i].x} ${points[i].y}`;
|
|
307
|
+
}
|
|
308
|
+
return d;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Smooth multi-segment: cubic bezier through control points
|
|
312
|
+
// Use Catmull-Rom-like approach: bezier between consecutive points
|
|
313
|
+
let d = `M ${points[0].x} ${points[0].y}`;
|
|
314
|
+
for (let i = 1; i < points.length; i++) {
|
|
315
|
+
const p = points[i - 1];
|
|
316
|
+
const q = points[i];
|
|
317
|
+
const dy = q.y - p.y;
|
|
318
|
+
const cp1y = p.y + dy * 0.4;
|
|
319
|
+
const cp2y = p.y + dy * 0.6;
|
|
320
|
+
d += ` C ${p.x} ${cp1y}, ${q.x} ${cp2y}, ${q.x} ${q.y}`;
|
|
321
|
+
}
|
|
322
|
+
return d;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ================================================================
|
|
326
|
+
// PUBLIC API
|
|
327
|
+
// ================================================================
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Compute a Hasse diagram layout for a DAG (lattice / partial order).
|
|
331
|
+
*
|
|
332
|
+
* Edges point downward: [a, b] means a ≥ b (a covers b).
|
|
333
|
+
* Layout is top-to-bottom: rank 0 at top, max rank at bottom.
|
|
334
|
+
*
|
|
335
|
+
* @param {object} dag - { nodes: [{id, label, cls}], edges: [[from, to]] }
|
|
336
|
+
* @param {object} [options]
|
|
337
|
+
* @param {number} [options.rankSpacing=80] - vertical distance between layers (before scale)
|
|
338
|
+
* @param {number} [options.nodeSpacing=60] - horizontal distance between nodes (before scale)
|
|
339
|
+
* @param {number} [options.scale=1.5] - global size multiplier
|
|
340
|
+
* @param {number} [options.crossingPasses=24] - barycenter sweep iterations
|
|
341
|
+
* @param {'straight'|'bezier'} [options.edgeStyle='bezier'] - edge rendering style
|
|
342
|
+
* @param {string|object} [options.theme='mono'] - theme name or custom object
|
|
343
|
+
* @returns {object} layout compatible with renderSVG()
|
|
344
|
+
*/
|
|
345
|
+
export function layoutHasse(dag, options = {}) {
|
|
346
|
+
const theme = resolveTheme(options.theme ?? 'mono');
|
|
347
|
+
const s = options.scale ?? 1.5;
|
|
348
|
+
const rankSpacing = (options.rankSpacing ?? 80) * s;
|
|
349
|
+
const nodeSpacing = (options.nodeSpacing ?? 60) * s;
|
|
350
|
+
const crossingPasses = options.crossingPasses ?? 24;
|
|
351
|
+
const edgeStyle = options.edgeStyle ?? 'bezier';
|
|
352
|
+
|
|
353
|
+
const { nodes, edges } = dag;
|
|
354
|
+
assertValidDag(nodes, edges, 'layoutHasse');
|
|
355
|
+
const { nodeMap, childrenOf, parentsOf } = buildGraph(nodes, edges);
|
|
356
|
+
|
|
357
|
+
// Phase 1: Rank assignment
|
|
358
|
+
const { topo, rank, maxRank } = topoSortAndRank(nodes, childrenOf, parentsOf);
|
|
359
|
+
|
|
360
|
+
// Phase 2: Virtual nodes for long edges
|
|
361
|
+
// Work on copies of adjacency so we don't mutate the originals
|
|
362
|
+
const expandedChildren = new Map();
|
|
363
|
+
const expandedParents = new Map();
|
|
364
|
+
for (const [k, v] of childrenOf) expandedChildren.set(k, [...v]);
|
|
365
|
+
for (const [k, v] of parentsOf) expandedParents.set(k, [...v]);
|
|
366
|
+
|
|
367
|
+
const { virtualNodes, expandedEdges, virtualChains } =
|
|
368
|
+
insertVirtualNodes(edges, rank, expandedChildren, expandedParents);
|
|
369
|
+
|
|
370
|
+
// All node IDs (real + virtual) for layering
|
|
371
|
+
const allIds = [...topo, ...virtualNodes.map(v => v.id)];
|
|
372
|
+
|
|
373
|
+
// Phase 3: Crossing reduction
|
|
374
|
+
let layers = buildLayers(allIds, rank, maxRank);
|
|
375
|
+
layers = reduceCrossings(layers, expandedChildren, expandedParents, crossingPasses);
|
|
376
|
+
|
|
377
|
+
// Phase 4: X-coordinate assignment
|
|
378
|
+
const xCoord = assignXCoordinates(layers, expandedChildren, expandedParents, nodeSpacing);
|
|
379
|
+
|
|
380
|
+
// Phase 5: Compute positions
|
|
381
|
+
const topPad = 50 * s;
|
|
382
|
+
const leftPad = 50 * s;
|
|
383
|
+
|
|
384
|
+
// Shift X so minimum is at leftPad
|
|
385
|
+
let minX = Infinity;
|
|
386
|
+
for (const id of allIds) {
|
|
387
|
+
const val = xCoord.get(id);
|
|
388
|
+
if (val < minX) minX = val;
|
|
389
|
+
}
|
|
390
|
+
const xShift = leftPad - minX;
|
|
391
|
+
|
|
392
|
+
const allPositions = new Map(); // includes virtual nodes
|
|
393
|
+
for (const id of allIds) {
|
|
394
|
+
allPositions.set(id, {
|
|
395
|
+
x: xCoord.get(id) + xShift,
|
|
396
|
+
y: topPad + rank.get(id) * rankSpacing,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Real node positions only (for renderSVG)
|
|
401
|
+
const positions = new Map();
|
|
402
|
+
for (const nd of nodes) {
|
|
403
|
+
const pos = allPositions.get(nd.id);
|
|
404
|
+
if (pos) positions.set(nd.id, pos);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Compute dimensions
|
|
408
|
+
let maxX = 0, maxY = 0, layoutMinY = Infinity, layoutMaxY = -Infinity;
|
|
409
|
+
for (const nd of nodes) {
|
|
410
|
+
const pos = positions.get(nd.id);
|
|
411
|
+
if (!pos) continue;
|
|
412
|
+
if (pos.x > maxX) maxX = pos.x;
|
|
413
|
+
if (pos.y > maxY) maxY = pos.y;
|
|
414
|
+
if (pos.y < layoutMinY) layoutMinY = pos.y;
|
|
415
|
+
if (pos.y > layoutMaxY) layoutMaxY = pos.y;
|
|
416
|
+
}
|
|
417
|
+
const rightPad = 50 * s;
|
|
418
|
+
const bottomPad = 80 * s;
|
|
419
|
+
const width = maxX + rightPad;
|
|
420
|
+
const height = maxY + bottomPad;
|
|
421
|
+
|
|
422
|
+
// Phase 6: Build edge paths
|
|
423
|
+
// Hasse diagrams use uniform edge color (the structure IS the information)
|
|
424
|
+
const edgeColor = theme.ink;
|
|
425
|
+
const opBoost = theme.lineOpacity ?? 1.0;
|
|
426
|
+
const edgeThickness = 2.2 * s;
|
|
427
|
+
const edgeOpacity = Math.min(0.35 * opBoost, 1);
|
|
428
|
+
|
|
429
|
+
// Build one segment per original edge
|
|
430
|
+
const segments = [];
|
|
431
|
+
for (const [from, to] of edges) {
|
|
432
|
+
// Collect path points: source -> virtual nodes -> target
|
|
433
|
+
const chainKey = `${from}->${to}`;
|
|
434
|
+
const chain = virtualChains.get(chainKey);
|
|
435
|
+
|
|
436
|
+
let pathPoints;
|
|
437
|
+
if (chain) {
|
|
438
|
+
pathPoints = [
|
|
439
|
+
allPositions.get(from),
|
|
440
|
+
...chain.map(vid => allPositions.get(vid)),
|
|
441
|
+
allPositions.get(to),
|
|
442
|
+
];
|
|
443
|
+
} else {
|
|
444
|
+
pathPoints = [allPositions.get(from), allPositions.get(to)];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const d = hasseEdgePath(pathPoints, edgeStyle);
|
|
448
|
+
segments.push({ d, color: edgeColor, thickness: edgeThickness, opacity: edgeOpacity, dashed: false });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Package as routePaths: single route containing all segments
|
|
452
|
+
const routePaths = [segments];
|
|
453
|
+
|
|
454
|
+
// Route metadata for renderSVG compatibility
|
|
455
|
+
const routes = [{ nodes: topo, lane: 0, parentRoute: -1, depth: 0 }];
|
|
456
|
+
const nodeRoute = new Map();
|
|
457
|
+
const nodeLane = new Map();
|
|
458
|
+
for (const nd of nodes) {
|
|
459
|
+
nodeRoute.set(nd.id, 0);
|
|
460
|
+
nodeLane.set(nd.id, 0);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const centerY = (layoutMinY + layoutMaxY) / 2;
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
positions,
|
|
467
|
+
routePaths,
|
|
468
|
+
extraEdges: [],
|
|
469
|
+
width,
|
|
470
|
+
height,
|
|
471
|
+
maxLayer: maxRank,
|
|
472
|
+
routes,
|
|
473
|
+
nodeLane,
|
|
474
|
+
nodeRoute,
|
|
475
|
+
laneSpacing: nodeSpacing,
|
|
476
|
+
layerSpacing: rankSpacing,
|
|
477
|
+
minY: layoutMinY,
|
|
478
|
+
maxY: layoutMaxY,
|
|
479
|
+
routeYScreen: new Map([[0, centerY]]),
|
|
480
|
+
trunkYScreen: centerY,
|
|
481
|
+
scale: s,
|
|
482
|
+
theme,
|
|
483
|
+
orientation: 'ttb',
|
|
484
|
+
};
|
|
485
|
+
}
|