turbometro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/bin/turbometro.js +2 -0
  4. package/dist/cli.d.ts +1 -0
  5. package/dist/cli.js +107 -0
  6. package/dist/find-root.d.ts +2 -0
  7. package/dist/find-root.js +14 -0
  8. package/dist/graph.d.ts +14 -0
  9. package/dist/graph.js +74 -0
  10. package/dist/layout.d.ts +19 -0
  11. package/dist/layout.js +15 -0
  12. package/dist/parse-turbo.d.ts +5 -0
  13. package/dist/parse-turbo.js +29 -0
  14. package/dist/parse-workspace.d.ts +4 -0
  15. package/dist/parse-workspace.js +100 -0
  16. package/dist/render-html.d.ts +8 -0
  17. package/dist/render-html.js +699 -0
  18. package/dist/render-map.d.ts +29 -0
  19. package/dist/render-map.js +287 -0
  20. package/dist/replay.d.ts +9 -0
  21. package/dist/replay.js +74 -0
  22. package/dist/scale.d.ts +7 -0
  23. package/dist/scale.js +19 -0
  24. package/dist/scene-data.d.ts +40 -0
  25. package/dist/scene-data.js +146 -0
  26. package/dist/theme.d.ts +15 -0
  27. package/dist/theme.js +15 -0
  28. package/dist/types.d.ts +30 -0
  29. package/dist/types.js +1 -0
  30. package/fixtures/mini-mono/apps/web/package.json +11 -0
  31. package/fixtures/mini-mono/package.json +5 -0
  32. package/fixtures/mini-mono/packages/ui/package.json +11 -0
  33. package/fixtures/mini-mono/packages/utils/package.json +8 -0
  34. package/fixtures/mini-mono/pnpm-workspace.yaml +3 -0
  35. package/fixtures/mini-mono/turbo.json +10 -0
  36. package/package.json +44 -0
  37. package/vendor/dag-map/LICENSE +201 -0
  38. package/vendor/dag-map/NOTICE +6 -0
  39. package/vendor/dag-map/src/color-scales.js +61 -0
  40. package/vendor/dag-map/src/dag-map.css +63 -0
  41. package/vendor/dag-map/src/events.js +108 -0
  42. package/vendor/dag-map/src/graph-utils.js +185 -0
  43. package/vendor/dag-map/src/hasse.css +61 -0
  44. package/vendor/dag-map/src/index.js +55 -0
  45. package/vendor/dag-map/src/layout-flow.js +1066 -0
  46. package/vendor/dag-map/src/layout-hasse.js +485 -0
  47. package/vendor/dag-map/src/layout-metro.js +542 -0
  48. package/vendor/dag-map/src/occupancy.js +138 -0
  49. package/vendor/dag-map/src/render-flow-station.js +132 -0
  50. package/vendor/dag-map/src/render.js +360 -0
  51. package/vendor/dag-map/src/route-angular.js +137 -0
  52. package/vendor/dag-map/src/route-bezier.js +51 -0
  53. package/vendor/dag-map/src/route-metro.js +122 -0
  54. package/vendor/dag-map/src/themes.js +49 -0
@@ -0,0 +1,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
+ }