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,29 @@
|
|
|
1
|
+
import type { Graph, Replay, TurboTasks } from './types.js';
|
|
2
|
+
export type LayoutResult = {
|
|
3
|
+
positions: Map<string, {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
}>;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
routePaths: Array<Array<{
|
|
10
|
+
d: string;
|
|
11
|
+
color: string;
|
|
12
|
+
thickness: number;
|
|
13
|
+
opacity: number;
|
|
14
|
+
}>>;
|
|
15
|
+
extraEdges: Array<{
|
|
16
|
+
d: string;
|
|
17
|
+
color: string;
|
|
18
|
+
thickness: number;
|
|
19
|
+
opacity: number;
|
|
20
|
+
}>;
|
|
21
|
+
scale: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function buildMapSvg(opts: {
|
|
24
|
+
title: string;
|
|
25
|
+
graph: Graph;
|
|
26
|
+
layout: LayoutResult;
|
|
27
|
+
turbo: TurboTasks;
|
|
28
|
+
replay: Replay;
|
|
29
|
+
}): string;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Showcase map SVG — layoutMetro for geometry; SMIL trains; shallow 3D cues in glyph/shadow.
|
|
3
|
+
*/
|
|
4
|
+
import { shortestPathEndpoints } from './graph.js';
|
|
5
|
+
import { taskColor } from './replay.js';
|
|
6
|
+
function esc(s) {
|
|
7
|
+
return s
|
|
8
|
+
.replace(/&/g, '&')
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/"/g, '"');
|
|
12
|
+
}
|
|
13
|
+
function shortLabel(name) {
|
|
14
|
+
const parts = name.split('/');
|
|
15
|
+
return parts[parts.length - 1] || name;
|
|
16
|
+
}
|
|
17
|
+
function roundPath(d) {
|
|
18
|
+
return d.replace(/-?\d+\.?\d*(?:e[+-]?\d+)?/gi, (n) => {
|
|
19
|
+
const v = Number(n);
|
|
20
|
+
return Number.isFinite(v) ? String(Math.round(v * 10) / 10) : n;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function offsetPath(d, pad) {
|
|
24
|
+
return roundPath(d.replace(/(-?\d+\.?\d*(?:e[+-]?\d+)?)\s+(-?\d+\.?\d*(?:e[+-]?\d+)?)/gi, (_, x, y) => {
|
|
25
|
+
return `${Number(x) + pad} ${Number(y) + pad}`;
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
/** Extract polyline points from simple M/L paths. */
|
|
29
|
+
function pathPoints(d) {
|
|
30
|
+
const pts = [];
|
|
31
|
+
const re = /([ML])\s*(-?\d+\.?\d*)\s+(-?\d+\.?\d*)/gi;
|
|
32
|
+
let m;
|
|
33
|
+
while ((m = re.exec(d))) {
|
|
34
|
+
pts.push({ x: Number(m[2]), y: Number(m[3]) });
|
|
35
|
+
}
|
|
36
|
+
// also catch bare pairs after first M
|
|
37
|
+
if (pts.length < 2) {
|
|
38
|
+
const nums = [...d.matchAll(/(-?\d+\.?\d*)/g)].map((x) => Number(x[1]));
|
|
39
|
+
for (let i = 0; i + 1 < nums.length; i += 2) {
|
|
40
|
+
pts.push({ x: nums[i], y: nums[i + 1] });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return pts;
|
|
44
|
+
}
|
|
45
|
+
function pointsToPath(pts) {
|
|
46
|
+
if (!pts.length)
|
|
47
|
+
return 'M 0 0';
|
|
48
|
+
return pts
|
|
49
|
+
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${roundNum(p.x)} ${roundNum(p.y)}`)
|
|
50
|
+
.join(' ');
|
|
51
|
+
}
|
|
52
|
+
function roundNum(n) {
|
|
53
|
+
return Math.round(n * 10) / 10;
|
|
54
|
+
}
|
|
55
|
+
function reversePath(d) {
|
|
56
|
+
return pointsToPath(pathPoints(d).reverse());
|
|
57
|
+
}
|
|
58
|
+
function dist(a, b) {
|
|
59
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
60
|
+
}
|
|
61
|
+
function collectSegments(layout) {
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const route of layout.routePaths) {
|
|
64
|
+
for (const seg of route) {
|
|
65
|
+
out.push({
|
|
66
|
+
d: seg.d,
|
|
67
|
+
color: seg.color,
|
|
68
|
+
thickness: Math.max(seg.thickness, 7),
|
|
69
|
+
kind: 'route',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const seg of layout.extraEdges) {
|
|
74
|
+
out.push({
|
|
75
|
+
d: seg.d,
|
|
76
|
+
color: seg.color,
|
|
77
|
+
thickness: Math.max(seg.thickness * 1.4, 4.5),
|
|
78
|
+
kind: 'extra',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** Path from `fromId` → `toId` (direction corrected). */
|
|
84
|
+
function directedPath(layout, fromId, toId) {
|
|
85
|
+
const a = layout.positions.get(fromId);
|
|
86
|
+
const b = layout.positions.get(toId);
|
|
87
|
+
if (!a || !b)
|
|
88
|
+
return 'M 0 0';
|
|
89
|
+
const segs = collectSegments(layout);
|
|
90
|
+
let best = null;
|
|
91
|
+
let bestScore = Infinity;
|
|
92
|
+
let bestReversed = false;
|
|
93
|
+
for (const s of segs) {
|
|
94
|
+
const pts = pathPoints(s.d);
|
|
95
|
+
if (pts.length < 2)
|
|
96
|
+
continue;
|
|
97
|
+
const start = pts[0];
|
|
98
|
+
const end = pts[pts.length - 1];
|
|
99
|
+
const fwd = dist(start, a) + dist(end, b);
|
|
100
|
+
const rev = dist(start, b) + dist(end, a);
|
|
101
|
+
if (fwd < bestScore) {
|
|
102
|
+
bestScore = fwd;
|
|
103
|
+
best = s.d;
|
|
104
|
+
bestReversed = false;
|
|
105
|
+
}
|
|
106
|
+
if (rev < bestScore) {
|
|
107
|
+
bestScore = rev;
|
|
108
|
+
best = s.d;
|
|
109
|
+
bestReversed = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (best && bestScore < 55) {
|
|
113
|
+
return bestReversed ? reversePath(best) : best;
|
|
114
|
+
}
|
|
115
|
+
return `M ${roundNum(a.x)} ${roundNum(a.y)} L ${roundNum(b.x)} ${roundNum(b.y)}`;
|
|
116
|
+
}
|
|
117
|
+
/** Isometric-ish 3-face metro car. */
|
|
118
|
+
function trainGlyph3d(color) {
|
|
119
|
+
const side = shade(color, -0.28);
|
|
120
|
+
const top = shade(color, 0.18);
|
|
121
|
+
return `
|
|
122
|
+
<g class="tm-car-3d">
|
|
123
|
+
<!-- shadow on "ground" -->
|
|
124
|
+
<ellipse cx="2" cy="9" rx="16" ry="3.2" fill="#000" opacity="0.28"/>
|
|
125
|
+
<!-- left/side face -->
|
|
126
|
+
<path d="M -14,2 L -10,-4 L 8,-4 L 4,2 Z" fill="${side}" stroke="var(--map-ink)" stroke-width="0.9"/>
|
|
127
|
+
<!-- front face -->
|
|
128
|
+
<path d="M 4,2 L 8,-4 L 20,-1 L 16,5 Z" fill="${shade(color, -0.12)}" stroke="var(--map-ink)" stroke-width="0.9"/>
|
|
129
|
+
<!-- roof / top -->
|
|
130
|
+
<path d="M -10,-4 L -4,-8 L 14,-5 L 8,-4 Z" fill="${top}" stroke="var(--map-ink)" stroke-width="0.9"/>
|
|
131
|
+
<!-- windows -->
|
|
132
|
+
<rect x="-8" y="-2.2" width="5" height="2.8" rx="0.5" fill="#E8F4FF" opacity="0.9"/>
|
|
133
|
+
<rect x="-1" y="-2.2" width="5" height="2.8" rx="0.5" fill="#E8F4FF" opacity="0.9"/>
|
|
134
|
+
<path d="M 9,-1.5 L 12,-0.2 L 12,1.6 L 9,0.4 Z" fill="#E8F4FF" opacity="0.85"/>
|
|
135
|
+
</g>`;
|
|
136
|
+
}
|
|
137
|
+
function shade(hex, amt) {
|
|
138
|
+
const h = hex.replace('#', '');
|
|
139
|
+
if (h.length !== 6)
|
|
140
|
+
return hex;
|
|
141
|
+
const n = (i) => {
|
|
142
|
+
const v = Math.max(0, Math.min(255, parseInt(h.slice(i, i + 2), 16) * (1 + amt)));
|
|
143
|
+
return Math.round(v).toString(16).padStart(2, '0');
|
|
144
|
+
};
|
|
145
|
+
return `#${n(0)}${n(2)}${n(4)}`;
|
|
146
|
+
}
|
|
147
|
+
export function buildMapSvg(opts) {
|
|
148
|
+
const { layout, graph, turbo, replay, title } = opts;
|
|
149
|
+
const pad = 64;
|
|
150
|
+
const vbW = layout.width + pad * 2;
|
|
151
|
+
const vbH = layout.height + pad * 2 + 20;
|
|
152
|
+
const s = layout.scale || 2;
|
|
153
|
+
const segs = collectSegments(layout);
|
|
154
|
+
const deg = new Map();
|
|
155
|
+
for (const n of graph.nodes)
|
|
156
|
+
deg.set(n.name, 0);
|
|
157
|
+
for (const [a, b] of graph.edges) {
|
|
158
|
+
deg.set(a, (deg.get(a) ?? 0) + 1);
|
|
159
|
+
deg.set(b, (deg.get(b) ?? 0) + 1);
|
|
160
|
+
}
|
|
161
|
+
const railsCasing = segs
|
|
162
|
+
.map((seg, i) => `<path class="rail-case" data-rail="${i}" d="${offsetPath(seg.d, pad)}" fill="none" stroke="var(--rail-case)" stroke-width="${seg.thickness + 5.5}" stroke-linecap="round" stroke-linejoin="round"/>`)
|
|
163
|
+
.join('\n');
|
|
164
|
+
const railsColor = segs
|
|
165
|
+
.map((seg, i) => `<path class="rail-line" data-rail="${i}" d="${offsetPath(seg.d, pad)}" fill="none" stroke="${seg.color}" stroke-width="${seg.thickness}" stroke-linecap="round" stroke-linejoin="round" opacity="${seg.kind === 'extra' ? 0.75 : 0.98}"/>`)
|
|
166
|
+
.join('\n');
|
|
167
|
+
const stations = graph.nodes
|
|
168
|
+
.map((n) => {
|
|
169
|
+
const p = layout.positions.get(n.name);
|
|
170
|
+
if (!p)
|
|
171
|
+
return '';
|
|
172
|
+
const x = p.x + pad;
|
|
173
|
+
const y = p.y + pad;
|
|
174
|
+
const d = deg.get(n.name) ?? 0;
|
|
175
|
+
const interchange = d >= 2;
|
|
176
|
+
const r = interchange ? 10 : 7.5;
|
|
177
|
+
const label = shortLabel(n.name);
|
|
178
|
+
const lw = Math.max(label.length * 7.1 + 12, 40);
|
|
179
|
+
return `
|
|
180
|
+
<g class="station" data-station="${esc(n.name)}" tabindex="0" transform="translate(${x},${y})">
|
|
181
|
+
<ellipse class="station-shadow" cx="1.5" cy="11" rx="${interchange ? 14 : 11}" ry="3.5" fill="#000" opacity="0.22"/>
|
|
182
|
+
<circle class="station-hit" r="20" fill="transparent"/>
|
|
183
|
+
${interchange
|
|
184
|
+
? `<rect class="station-capsule" x="-15" y="-10" width="30" height="20" rx="10" fill="var(--station-fill)" stroke="var(--map-ink)" stroke-width="2.6"/>`
|
|
185
|
+
: `<circle class="station-ring" r="${r}" fill="var(--station-fill)" stroke="var(--map-ink)" stroke-width="2.7"/>`}
|
|
186
|
+
<circle class="station-core" r="2.8" fill="var(--map-ink)"/>
|
|
187
|
+
<g class="station-label" transform="translate(14,-12) rotate(-26)">
|
|
188
|
+
<rect class="label-bg" x="-4" y="-12" width="${lw}" height="17" rx="3" fill="var(--label-bg)"/>
|
|
189
|
+
<text class="label-text" x="2" y="1" font-size="${11 * (s / 2)}">${esc(label)}</text>
|
|
190
|
+
</g>
|
|
191
|
+
<title>${esc(n.name)}</title>
|
|
192
|
+
</g>`;
|
|
193
|
+
})
|
|
194
|
+
.join('\n');
|
|
195
|
+
// Prefer packages that have a ride path; max 4 trains to avoid chaos
|
|
196
|
+
const byPkg = new Map();
|
|
197
|
+
for (const ev of replay.events) {
|
|
198
|
+
const prev = byPkg.get(ev.package);
|
|
199
|
+
if (!prev || ev.status === 'running')
|
|
200
|
+
byPkg.set(ev.package, ev);
|
|
201
|
+
}
|
|
202
|
+
const tasks = Object.keys(turbo);
|
|
203
|
+
const candidates = [...byPkg.values()]
|
|
204
|
+
.map((ev) => ({ ev, ends: shortestPathEndpoints(graph, ev.package) }))
|
|
205
|
+
.filter((x) => x.ends)
|
|
206
|
+
.slice(0, 4);
|
|
207
|
+
const guidePaths = [];
|
|
208
|
+
const trains = candidates
|
|
209
|
+
.map(({ ev, ends }, i) => {
|
|
210
|
+
const color = taskColor(ev.task, tasks);
|
|
211
|
+
const raw = directedPath(layout, ends.from, ends.to);
|
|
212
|
+
const d = offsetPath(raw, pad);
|
|
213
|
+
const pathId = `ride-path-${i}`;
|
|
214
|
+
const dur = 3.2 + (i % 3) * 0.55;
|
|
215
|
+
const begin = i * 0.85;
|
|
216
|
+
guidePaths.push(`<path id="${pathId}" d="${d}" fill="none" stroke="none" pointer-events="none"/>`);
|
|
217
|
+
return `
|
|
218
|
+
<g class="tm-train" data-turbometro="train" data-package="${esc(ev.package)}" data-task="${esc(ev.task)}">
|
|
219
|
+
${trainGlyph3d(color)}
|
|
220
|
+
<animateMotion
|
|
221
|
+
dur="${dur}s"
|
|
222
|
+
begin="${begin}s"
|
|
223
|
+
repeatCount="indefinite"
|
|
224
|
+
rotate="auto"
|
|
225
|
+
calcMode="linear">
|
|
226
|
+
<mpath href="#${pathId}" xlink:href="#${pathId}"/>
|
|
227
|
+
</animateMotion>
|
|
228
|
+
</g>`;
|
|
229
|
+
})
|
|
230
|
+
.join('\n');
|
|
231
|
+
return `
|
|
232
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 ${vbW} ${vbH}" width="100%" height="auto" class="metro-svg" role="img" aria-label="Monorepo subway map for ${esc(title)}">
|
|
233
|
+
<defs>
|
|
234
|
+
<radialGradient id="boardGlow" cx="48%" cy="36%" r="70%">
|
|
235
|
+
<stop offset="0%" stop-color="var(--glow-center)"/>
|
|
236
|
+
<stop offset="100%" stop-color="var(--glow-edge)"/>
|
|
237
|
+
</radialGradient>
|
|
238
|
+
<pattern id="microGrid" width="28" height="28" patternUnits="userSpaceOnUse">
|
|
239
|
+
<path d="M 28 0 L 0 0 0 28" fill="none" stroke="var(--grid-line)" stroke-width="0.7"/>
|
|
240
|
+
</pattern>
|
|
241
|
+
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
242
|
+
<feDropShadow dx="0" dy="1.2" stdDeviation="2" flood-opacity="0.28"/>
|
|
243
|
+
</filter>
|
|
244
|
+
<filter id="boardDepth" x="-10%" y="-10%" width="120%" height="130%">
|
|
245
|
+
<feDropShadow dx="0" dy="18" stdDeviation="16" flood-opacity="0.35"/>
|
|
246
|
+
</filter>
|
|
247
|
+
</defs>
|
|
248
|
+
<style>
|
|
249
|
+
.metro-svg { font-family: "IBM Plex Mono", ui-monospace, monospace; }
|
|
250
|
+
.station { cursor: pointer; outline: none; }
|
|
251
|
+
.station .label-text { fill: var(--map-ink); font-weight: 600; letter-spacing: -0.02em; }
|
|
252
|
+
.station-label { pointer-events: none; }
|
|
253
|
+
.station:hover .station-ring, .station:focus-visible .station-ring,
|
|
254
|
+
.station.is-active .station-ring,
|
|
255
|
+
.station:hover .station-capsule, .station.is-active .station-capsule {
|
|
256
|
+
stroke: var(--accent);
|
|
257
|
+
}
|
|
258
|
+
.station:hover .label-bg, .station.is-active .label-bg { fill: var(--label-bg-active); }
|
|
259
|
+
.map.is-dim .rail-line, .map.is-dim .rail-case { opacity: 0.18; }
|
|
260
|
+
.map.is-dim .station { opacity: 0.28; }
|
|
261
|
+
.map.is-dim .station.is-active,
|
|
262
|
+
.map.is-dim .station.is-neighbor { opacity: 1; }
|
|
263
|
+
.map.is-dim .tm-train { opacity: 0.2; }
|
|
264
|
+
.tm-train { pointer-events: none; }
|
|
265
|
+
.cartouche .title { fill: var(--map-ink); font-weight: 700; letter-spacing: 0.08em; }
|
|
266
|
+
.cartouche .sub { fill: var(--map-muted); }
|
|
267
|
+
.paused .tm-train animateMotion { animation-play-state: paused; }
|
|
268
|
+
@media (prefers-reduced-motion: reduce) {
|
|
269
|
+
.tm-train animateMotion { repeatCount: 0; }
|
|
270
|
+
}
|
|
271
|
+
</style>
|
|
272
|
+
<rect width="100%" height="100%" fill="url(#boardGlow)"/>
|
|
273
|
+
<rect width="100%" height="100%" fill="url(#microGrid)" opacity="0.7"/>
|
|
274
|
+
<g class="cartouche" transform="translate(24,20)">
|
|
275
|
+
<rect width="228" height="48" rx="6" fill="var(--cartouche-bg)" stroke="var(--map-border)" stroke-width="1.2" filter="url(#softShadow)"/>
|
|
276
|
+
<text class="title" x="14" y="20" font-size="10">TURBOMETRO NETWORK</text>
|
|
277
|
+
<text class="sub" x="14" y="36" font-size="9">${esc(title)} · ${graph.nodes.length} stations · ${graph.edges.length} rails</text>
|
|
278
|
+
</g>
|
|
279
|
+
<g class="rails">
|
|
280
|
+
${railsCasing}
|
|
281
|
+
${railsColor}
|
|
282
|
+
</g>
|
|
283
|
+
<g class="guides" aria-hidden="true">${guidePaths.join('\n')}</g>
|
|
284
|
+
<g class="stations">${stations}</g>
|
|
285
|
+
<g class="trains">${trains}</g>
|
|
286
|
+
</svg>`;
|
|
287
|
+
}
|
package/dist/replay.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Graph, Replay, TurboTasks } from './types.js';
|
|
2
|
+
export declare function taskColor(task: string, tasks: string[]): string;
|
|
3
|
+
/** Synthetic timeline: walk packages in name order, emit running→pass per task. */
|
|
4
|
+
export declare function buildSyntheticReplay(graph: Graph, turbo: TurboTasks): Replay;
|
|
5
|
+
export declare function loadReplay(filePath: string): Replay;
|
|
6
|
+
export declare function demoGraphAndTurbo(): {
|
|
7
|
+
graph: Graph;
|
|
8
|
+
turbo: TurboTasks;
|
|
9
|
+
};
|
package/dist/replay.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
const TASK_COLORS = [
|
|
3
|
+
'#FF5C5C',
|
|
4
|
+
'#3DDC97',
|
|
5
|
+
'#4C8DFF',
|
|
6
|
+
'#F5A200',
|
|
7
|
+
'#FB923C',
|
|
8
|
+
'#2DD4BF',
|
|
9
|
+
];
|
|
10
|
+
export function taskColor(task, tasks) {
|
|
11
|
+
const i = Math.max(0, tasks.indexOf(task));
|
|
12
|
+
return TASK_COLORS[i % TASK_COLORS.length];
|
|
13
|
+
}
|
|
14
|
+
/** Synthetic timeline: walk packages in name order, emit running→pass per task. */
|
|
15
|
+
export function buildSyntheticReplay(graph, turbo) {
|
|
16
|
+
const taskNames = Object.keys(turbo);
|
|
17
|
+
const primary = taskNames.includes('build')
|
|
18
|
+
? 'build'
|
|
19
|
+
: taskNames[0] ?? 'build';
|
|
20
|
+
const events = [];
|
|
21
|
+
let t = 0;
|
|
22
|
+
for (const node of graph.nodes) {
|
|
23
|
+
events.push({
|
|
24
|
+
t,
|
|
25
|
+
task: primary,
|
|
26
|
+
package: node.name,
|
|
27
|
+
status: 'running',
|
|
28
|
+
});
|
|
29
|
+
t += 800;
|
|
30
|
+
events.push({
|
|
31
|
+
t,
|
|
32
|
+
task: primary,
|
|
33
|
+
package: node.name,
|
|
34
|
+
status: 'pass',
|
|
35
|
+
});
|
|
36
|
+
t += 400;
|
|
37
|
+
}
|
|
38
|
+
return { events };
|
|
39
|
+
}
|
|
40
|
+
export function loadReplay(filePath) {
|
|
41
|
+
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
42
|
+
if (!raw.events || !Array.isArray(raw.events)) {
|
|
43
|
+
throw new Error(`Invalid replay file: ${filePath}`);
|
|
44
|
+
}
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
export function demoGraphAndTurbo() {
|
|
48
|
+
return {
|
|
49
|
+
graph: {
|
|
50
|
+
nodes: [
|
|
51
|
+
{ name: '@demo/web', dir: 'apps/web', kind: 'apps' },
|
|
52
|
+
{ name: '@demo/admin', dir: 'apps/admin', kind: 'apps' },
|
|
53
|
+
{ name: '@demo/ui', dir: 'packages/ui', kind: 'packages' },
|
|
54
|
+
{ name: '@demo/api-client', dir: 'packages/api-client', kind: 'packages' },
|
|
55
|
+
{ name: '@demo/utils', dir: 'packages/utils', kind: 'packages' },
|
|
56
|
+
{ name: '@demo/config', dir: 'packages/config', kind: 'packages' },
|
|
57
|
+
],
|
|
58
|
+
edges: [
|
|
59
|
+
['@demo/web', '@demo/ui'],
|
|
60
|
+
['@demo/web', '@demo/api-client'],
|
|
61
|
+
['@demo/admin', '@demo/ui'],
|
|
62
|
+
['@demo/admin', '@demo/api-client'],
|
|
63
|
+
['@demo/ui', '@demo/utils'],
|
|
64
|
+
['@demo/api-client', '@demo/utils'],
|
|
65
|
+
['@demo/utils', '@demo/config'],
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
turbo: {
|
|
69
|
+
build: { dependsOn: ['^build'] },
|
|
70
|
+
test: { dependsOn: ['build'] },
|
|
71
|
+
lint: {},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
package/dist/scale.d.ts
ADDED
package/dist/scale.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const WARN_PACKAGES = 40;
|
|
2
|
+
export const FAIL_PACKAGES = 150;
|
|
3
|
+
export function scaleGuard(n, force) {
|
|
4
|
+
if (n >= FAIL_PACKAGES && !force) {
|
|
5
|
+
return {
|
|
6
|
+
ok: false,
|
|
7
|
+
warn: null,
|
|
8
|
+
error: `Refusing ${n} packages (cap ${FAIL_PACKAGES}). Pass --force to override. turbometro v0.1 is aimed at showcase-sized monorepos.`,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
if (n >= WARN_PACKAGES) {
|
|
12
|
+
return {
|
|
13
|
+
ok: true,
|
|
14
|
+
warn: `Large workspace (${n} packages). Layout may be dense.`,
|
|
15
|
+
error: null,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return { ok: true, warn: null, error: null };
|
|
19
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Graph, Replay, TurboTasks } from './types.js';
|
|
2
|
+
import type { LayoutResult } from './render-map.js';
|
|
3
|
+
export type ScenePoint = {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
z: number;
|
|
7
|
+
};
|
|
8
|
+
export type SceneData = {
|
|
9
|
+
title: string;
|
|
10
|
+
stations: Array<{
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
z: number;
|
|
16
|
+
color: string;
|
|
17
|
+
interchange: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
rails: Array<{
|
|
20
|
+
points: ScenePoint[];
|
|
21
|
+
color: string;
|
|
22
|
+
width: number;
|
|
23
|
+
}>;
|
|
24
|
+
trains: Array<{
|
|
25
|
+
id: string;
|
|
26
|
+
package: string;
|
|
27
|
+
task: string;
|
|
28
|
+
color: string;
|
|
29
|
+
points: ScenePoint[];
|
|
30
|
+
speed: number;
|
|
31
|
+
phase: number;
|
|
32
|
+
}>;
|
|
33
|
+
};
|
|
34
|
+
export declare function buildSceneData(opts: {
|
|
35
|
+
title: string;
|
|
36
|
+
graph: Graph;
|
|
37
|
+
layout: LayoutResult;
|
|
38
|
+
turbo: TurboTasks;
|
|
39
|
+
replay: Replay;
|
|
40
|
+
}): SceneData;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flatten layoutMetro output into a Three.js-ready scene payload.
|
|
3
|
+
*/
|
|
4
|
+
import { shortestPathEndpoints } from './graph.js';
|
|
5
|
+
import { taskColor } from './replay.js';
|
|
6
|
+
function shortLabel(name) {
|
|
7
|
+
const parts = name.split('/');
|
|
8
|
+
return parts[parts.length - 1] || name;
|
|
9
|
+
}
|
|
10
|
+
function pathPoints2d(d) {
|
|
11
|
+
const pts = [];
|
|
12
|
+
const re = /([ML])\s*(-?\d+\.?\d*)\s+(-?\d+\.?\d*)/gi;
|
|
13
|
+
let m;
|
|
14
|
+
while ((m = re.exec(d))) {
|
|
15
|
+
pts.push({ x: Number(m[2]), y: Number(m[3]) });
|
|
16
|
+
}
|
|
17
|
+
return pts;
|
|
18
|
+
}
|
|
19
|
+
function reversePts(arr) {
|
|
20
|
+
return [...arr].reverse();
|
|
21
|
+
}
|
|
22
|
+
function dist(a, b) {
|
|
23
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
24
|
+
}
|
|
25
|
+
function collectSegments(layout) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const route of layout.routePaths) {
|
|
28
|
+
for (const seg of route) {
|
|
29
|
+
out.push({
|
|
30
|
+
d: seg.d,
|
|
31
|
+
color: seg.color,
|
|
32
|
+
thickness: Math.max(seg.thickness, 7),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const seg of layout.extraEdges) {
|
|
37
|
+
out.push({
|
|
38
|
+
d: seg.d,
|
|
39
|
+
color: seg.color,
|
|
40
|
+
thickness: Math.max(seg.thickness * 1.4, 4.5),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/** Map layout 2D → world 3D (x, elevation, z). */
|
|
46
|
+
function to3(x, y, elev = 0, scale = 0.06) {
|
|
47
|
+
return { x: x * scale, y: elev, z: y * scale };
|
|
48
|
+
}
|
|
49
|
+
function directedPoints(layout, fromId, toId) {
|
|
50
|
+
const a = layout.positions.get(fromId);
|
|
51
|
+
const b = layout.positions.get(toId);
|
|
52
|
+
if (!a || !b)
|
|
53
|
+
return [];
|
|
54
|
+
const segs = collectSegments(layout);
|
|
55
|
+
let best = null;
|
|
56
|
+
let bestScore = Infinity;
|
|
57
|
+
for (const s of segs) {
|
|
58
|
+
const pts = pathPoints2d(s.d);
|
|
59
|
+
if (pts.length < 2)
|
|
60
|
+
continue;
|
|
61
|
+
const start = pts[0];
|
|
62
|
+
const end = pts[pts.length - 1];
|
|
63
|
+
const fwd = dist(start, a) + dist(end, b);
|
|
64
|
+
const rev = dist(start, b) + dist(end, a);
|
|
65
|
+
if (fwd < bestScore) {
|
|
66
|
+
bestScore = fwd;
|
|
67
|
+
best = pts;
|
|
68
|
+
}
|
|
69
|
+
if (rev < bestScore) {
|
|
70
|
+
bestScore = rev;
|
|
71
|
+
best = reversePts(pts);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (best && bestScore < 55)
|
|
75
|
+
return best;
|
|
76
|
+
return [a, b];
|
|
77
|
+
}
|
|
78
|
+
const KIND_COLOR = {
|
|
79
|
+
apps: '#39FF14',
|
|
80
|
+
packages: '#00F0FF',
|
|
81
|
+
other: '#FF2BD6',
|
|
82
|
+
};
|
|
83
|
+
export function buildSceneData(opts) {
|
|
84
|
+
const { layout, graph, turbo, replay, title } = opts;
|
|
85
|
+
const scale = 0.055;
|
|
86
|
+
const deg = new Map();
|
|
87
|
+
for (const n of graph.nodes)
|
|
88
|
+
deg.set(n.name, 0);
|
|
89
|
+
for (const [a, b] of graph.edges) {
|
|
90
|
+
deg.set(a, (deg.get(a) ?? 0) + 1);
|
|
91
|
+
deg.set(b, (deg.get(b) ?? 0) + 1);
|
|
92
|
+
}
|
|
93
|
+
const stations = graph.nodes.map((n) => {
|
|
94
|
+
const p = layout.positions.get(n.name);
|
|
95
|
+
return {
|
|
96
|
+
id: n.name,
|
|
97
|
+
label: shortLabel(n.name),
|
|
98
|
+
...to3(p.x, p.y, 0.35, scale),
|
|
99
|
+
color: KIND_COLOR[n.kind] ?? '#4C8DFF',
|
|
100
|
+
interchange: (deg.get(n.name) ?? 0) >= 2,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
const rails = collectSegments(layout).map((seg) => {
|
|
104
|
+
const pts2 = pathPoints2d(seg.d);
|
|
105
|
+
return {
|
|
106
|
+
color: seg.color,
|
|
107
|
+
width: seg.thickness * scale * 0.45,
|
|
108
|
+
points: pts2.map((p) => to3(p.x, p.y, 0.12, scale)),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
// Ride catalog (one path per package) — viewer spawns a train only on station click.
|
|
112
|
+
const byPkg = new Map();
|
|
113
|
+
for (const ev of replay.events) {
|
|
114
|
+
const prev = byPkg.get(ev.package);
|
|
115
|
+
if (!prev || ev.status === 'running')
|
|
116
|
+
byPkg.set(ev.package, ev);
|
|
117
|
+
}
|
|
118
|
+
const tasks = Object.keys(turbo);
|
|
119
|
+
const primary = tasks.includes('build') ? 'build' : tasks[0] ?? 'build';
|
|
120
|
+
const trains = [];
|
|
121
|
+
graph.nodes.forEach((n) => {
|
|
122
|
+
const ends = shortestPathEndpoints(graph, n.name);
|
|
123
|
+
if (!ends)
|
|
124
|
+
return;
|
|
125
|
+
const pts2 = directedPoints(layout, ends.from, ends.to);
|
|
126
|
+
if (pts2.length < 2)
|
|
127
|
+
return;
|
|
128
|
+
const ev = byPkg.get(n.name);
|
|
129
|
+
const task = ev?.task ?? primary;
|
|
130
|
+
trains.push({
|
|
131
|
+
id: `ride-${n.name}`,
|
|
132
|
+
package: n.name,
|
|
133
|
+
task,
|
|
134
|
+
color: taskColor(task, tasks),
|
|
135
|
+
points: pts2.map((p) => to3(p.x, p.y, 0.55, scale)),
|
|
136
|
+
speed: 0.22,
|
|
137
|
+
phase: 0,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
title,
|
|
142
|
+
stations,
|
|
143
|
+
rails: rails.filter((r) => r.points.length >= 2),
|
|
144
|
+
trains,
|
|
145
|
+
};
|
|
146
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Theme passed into vendored layoutMetro (line colors on routes). */
|
|
2
|
+
export declare const TRANSIT_LAYOUT_THEME: {
|
|
3
|
+
paper: string;
|
|
4
|
+
ink: string;
|
|
5
|
+
muted: string;
|
|
6
|
+
border: string;
|
|
7
|
+
classes: {
|
|
8
|
+
pure: string;
|
|
9
|
+
recordable: string;
|
|
10
|
+
side_effecting: string;
|
|
11
|
+
gate: string;
|
|
12
|
+
pending: string;
|
|
13
|
+
};
|
|
14
|
+
lineOpacity: number;
|
|
15
|
+
};
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Theme passed into vendored layoutMetro (line colors on routes). */
|
|
2
|
+
export const TRANSIT_LAYOUT_THEME = {
|
|
3
|
+
paper: '#0B0F14',
|
|
4
|
+
ink: '#E8EEF7',
|
|
5
|
+
muted: '#8B97AB',
|
|
6
|
+
border: '#2A3545',
|
|
7
|
+
classes: {
|
|
8
|
+
pure: '#3DDC97',
|
|
9
|
+
recordable: '#4C8DFF',
|
|
10
|
+
side_effecting: '#F5A200',
|
|
11
|
+
gate: '#FF5C5C',
|
|
12
|
+
pending: '#5A6578',
|
|
13
|
+
},
|
|
14
|
+
lineOpacity: 1.65,
|
|
15
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type PackageNode = {
|
|
2
|
+
name: string;
|
|
3
|
+
dir: string;
|
|
4
|
+
/** apps | packages | other — used as dag-map cls */
|
|
5
|
+
kind: string;
|
|
6
|
+
};
|
|
7
|
+
export type Graph = {
|
|
8
|
+
nodes: PackageNode[];
|
|
9
|
+
/** directed workspace dependency edges [from, to] meaning from depends on to */
|
|
10
|
+
edges: Array<[string, string]>;
|
|
11
|
+
};
|
|
12
|
+
export type TurboTasks = Record<string, {
|
|
13
|
+
dependsOn?: string[];
|
|
14
|
+
}>;
|
|
15
|
+
export type ReplayEvent = {
|
|
16
|
+
t: number;
|
|
17
|
+
task: string;
|
|
18
|
+
package: string;
|
|
19
|
+
status: 'running' | 'pass' | 'fail';
|
|
20
|
+
};
|
|
21
|
+
export type Replay = {
|
|
22
|
+
events: ReplayEvent[];
|
|
23
|
+
};
|
|
24
|
+
export type CliOptions = {
|
|
25
|
+
cwd: string;
|
|
26
|
+
out: string;
|
|
27
|
+
demo: boolean;
|
|
28
|
+
replayPath: string | null;
|
|
29
|
+
force: boolean;
|
|
30
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|