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,699 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL, fileURLToPath } from 'node:url';
3
+ import { toDagMapInput } from './layout.js';
4
+ import { buildSceneData } from './scene-data.js';
5
+ import { TRANSIT_LAYOUT_THEME } from './theme.js';
6
+ import { taskColor } from './replay.js';
7
+ async function loadVendor() {
8
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const metroUrl = pathToFileURL(path.join(pkgRoot, 'vendor/dag-map/src/layout-metro.js')).href;
10
+ const { layoutMetro } = (await import(metroUrl));
11
+ return { layoutMetro };
12
+ }
13
+ function esc(s) {
14
+ return s
15
+ .replace(/&/g, '&')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;');
19
+ }
20
+ function taskChips(turbo) {
21
+ const tasks = Object.keys(turbo);
22
+ return tasks
23
+ .map((t) => {
24
+ const c = taskColor(t, tasks);
25
+ return `<span class="chip"><i style="background:${c}"></i>${esc(t)}</span>`;
26
+ })
27
+ .join('');
28
+ }
29
+ export async function renderMetroHtml(opts) {
30
+ const { layoutMetro } = await loadVendor();
31
+ const dag = toDagMapInput(opts.graph);
32
+ const layout = layoutMetro(dag, {
33
+ routing: 'angular',
34
+ theme: TRANSIT_LAYOUT_THEME,
35
+ scale: 2.15,
36
+ layerSpacing: 48,
37
+ mainSpacing: 46,
38
+ subSpacing: 22,
39
+ });
40
+ const scene = buildSceneData({
41
+ title: opts.title,
42
+ graph: opts.graph,
43
+ layout,
44
+ turbo: opts.turbo,
45
+ replay: opts.replay,
46
+ });
47
+ const sceneJson = JSON.stringify(scene).replace(/</g, '\\u003c');
48
+ const stationCount = opts.graph.nodes.length;
49
+ const railCount = opts.graph.edges.length;
50
+ return `<!DOCTYPE html>
51
+ <html lang="en">
52
+ <head>
53
+ <meta charset="utf-8" />
54
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
55
+ <title>turbometro — ${esc(opts.title)}</title>
56
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600;700&family=Syne:wght@700;800&display=swap" rel="stylesheet" />
57
+ <script type="importmap">
58
+ {"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}
59
+ </script>
60
+ <style>
61
+ :root{--bg:#0c0e12;--panel:rgba(14,16,20,.92);--ink:#e6e9ef;--muted:#8b93a1;--border:#2a303b;--accent:#5b8def;--accent-2:#8fa3bf}
62
+ *{box-sizing:border-box} html,body{height:100%;margin:0;background:var(--bg);color:var(--ink);font-family:Syne,system-ui,sans-serif;overflow:hidden}
63
+ .app{height:100%;display:grid;grid-template-rows:auto 1fr auto}
64
+ .top,.bottom{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.7rem 1rem;background:var(--panel);backdrop-filter:blur(12px);z-index:2}
65
+ .top{border-bottom:1px solid var(--border)}.bottom{border-top:1px solid var(--border);flex-wrap:wrap}
66
+ .logo{font-weight:800;font-size:1.2rem;letter-spacing:-.04em;background:linear-gradient(110deg,var(--accent),var(--accent-2));-webkit-background-clip:text;background-clip:text;color:transparent}
67
+ .repo{font-family:"IBM Plex Mono",monospace;font-size:.72rem;color:var(--muted)}
68
+ .toolbar{display:flex;gap:.35rem}
69
+ .toolbar button{font-family:"IBM Plex Mono",monospace;font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--ink);background:transparent;border:1px solid var(--border);padding:.35rem .6rem;cursor:pointer}
70
+ .toolbar button:hover{border-color:var(--accent);color:var(--accent)}
71
+ #view{position:relative;min-height:0}
72
+ #view canvas{display:block;width:100%!important;height:100%!important}
73
+ .hud{position:absolute;left:1rem;top:1rem;z-index:1;pointer-events:none;max-width:min(340px,80vw)}
74
+ .hud h1{margin:0;font-size:clamp(1.15rem,2.2vw,1.55rem);letter-spacing:-.03em;text-shadow:0 2px 12px rgba(0,0,0,.25)}
75
+ .hud p{margin:.35rem 0 0;font-family:"IBM Plex Mono",monospace;font-size:.7rem;color:var(--muted);line-height:1.45}
76
+ .stats{position:absolute;right:1rem;top:1rem;z-index:1;display:flex;gap:.8rem;font-family:"IBM Plex Mono",monospace;font-size:.65rem;color:var(--muted);pointer-events:none}
77
+ .stats strong{display:block;color:var(--ink);font-family:Syne,sans-serif;font-size:.95rem}
78
+ .hint{position:absolute;left:50%;bottom:1rem;transform:translateX(-50%);font-family:"IBM Plex Mono",monospace;font-size:.65rem;color:var(--muted);background:var(--panel);border:1px solid var(--border);padding:.3rem .65rem;z-index:1;pointer-events:none}
79
+ .chips{display:flex;flex-wrap:wrap;gap:.35rem}
80
+ .chip{display:inline-flex;align-items:center;gap:.3rem;font-family:"IBM Plex Mono",monospace;font-size:.68rem;border:1px solid var(--border);padding:.18rem .45rem;color:var(--muted)}
81
+ .chip i{width:.9rem;height:.22rem;display:inline-block}
82
+ .note{font-family:"IBM Plex Mono",monospace;font-size:.62rem;color:var(--muted);max-width:26rem;line-height:1.4}
83
+ #boot{position:absolute;inset:0;display:grid;place-items:center;background:#0c0e12;z-index:3;font-family:"IBM Plex Mono",monospace;font-size:.78rem;color:#8b93a1}
84
+ </style>
85
+ </head>
86
+ <body>
87
+ <div class="app">
88
+ <header class="top">
89
+ <div style="display:flex;gap:.7rem;align-items:baseline;min-width:0">
90
+ <div class="logo">turbometro</div>
91
+ <div class="repo">${esc(opts.title)}</div>
92
+ </div>
93
+ <div class="toolbar">
94
+ <button type="button" id="btn-play" aria-pressed="true">Pause</button>
95
+ <button type="button" id="btn-clear">Clear train</button>
96
+ <button type="button" id="btn-reset">Reset cam</button>
97
+ </div>
98
+ </header>
99
+ <div id="view">
100
+ <div id="boot">loading model…</div>
101
+ <div class="hud">
102
+ <h1 id="hud-title">Select a station</h1>
103
+ <p id="hud-sub">Click a stop · pieces assemble once, then the train rides</p>
104
+ </div>
105
+ <div class="stats">
106
+ <div><strong>${stationCount}</strong>stations</div>
107
+ <div><strong>${railCount}</strong>rails</div>
108
+ <div><strong id="stat-ride">0</strong>active</div>
109
+ </div>
110
+ <div class="hint">click station · drag orbit · scroll zoom</div>
111
+ </div>
112
+ <footer class="bottom">
113
+ <div class="chips">${taskChips(opts.turbo)}</div>
114
+ <p class="note">${esc(opts.disclaimer)}</p>
115
+ </footer>
116
+ </div>
117
+ <script type="module">
118
+ import * as THREE from 'three';
119
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
120
+ import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
121
+
122
+ const SCENE = ${sceneJson};
123
+ const view = document.getElementById('view');
124
+ document.getElementById('boot').remove();
125
+ let playing = true;
126
+
127
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
128
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
129
+ renderer.setSize(view.clientWidth, view.clientHeight);
130
+ renderer.shadowMap.enabled = true;
131
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
132
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
133
+ renderer.toneMappingExposure = 1.02;
134
+ view.appendChild(renderer.domElement);
135
+
136
+ const scene = new THREE.Scene();
137
+ scene.background = new THREE.Color(0x12151a);
138
+ scene.fog = new THREE.Fog(0x12151a, 28, 70);
139
+
140
+ const camera = new THREE.PerspectiveCamera(36, view.clientWidth / view.clientHeight, 0.1, 200);
141
+ const controls = new OrbitControls(camera, renderer.domElement);
142
+ controls.enableDamping = true;
143
+ controls.dampingFactor = 0.05;
144
+ controls.maxPolarAngle = Math.PI * 0.45;
145
+ controls.minDistance = 8;
146
+ controls.maxDistance = 40;
147
+
148
+ scene.add(new THREE.HemisphereLight(0xb8c4d4, 0x2a2e36, 0.55));
149
+ const sun = new THREE.DirectionalLight(0xf0f2f5, 1.35);
150
+ sun.position.set(14, 24, 12);
151
+ sun.castShadow = true;
152
+ sun.shadow.mapSize.set(2048, 2048);
153
+ sun.shadow.camera.near = 2; sun.shadow.camera.far = 80;
154
+ sun.shadow.camera.left = sun.shadow.camera.bottom = -30;
155
+ sun.shadow.camera.right = sun.shadow.camera.top = 30;
156
+ sun.shadow.bias = -0.0002;
157
+ scene.add(sun);
158
+ const fill = new THREE.DirectionalLight(0x6a7a90, 0.45);
159
+ fill.position.set(-14, 10, -8); scene.add(fill);
160
+
161
+ const allX = SCENE.stations.map(s => s.x);
162
+ const allZ = SCENE.stations.map(s => s.z);
163
+ const cx = (Math.min(...allX) + Math.max(...allX)) / 2 || 0;
164
+ const cz = (Math.min(...allZ) + Math.max(...allZ)) / 2 || 0;
165
+ const span = Math.max(Math.max(...allX) - Math.min(...allX), Math.max(...allZ) - Math.min(...allZ), 8);
166
+ const islandR = span * 0.055 * 0.55 + 6.8;
167
+ function world(p) { return new THREE.Vector3(p.x - cx, p.y, p.z - cz); }
168
+ function mulberry32(a){return function(){let t=a+=0x6D2B79F5;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296}}
169
+ const rnd = mulberry32(SCENE.stations.length * 9973 + SCENE.rails.length * 17);
170
+
171
+ const mats = {
172
+ slab: new THREE.MeshStandardMaterial({ color: 0x2c3138, roughness: 0.82, metalness: 0.12 }),
173
+ edge: new THREE.MeshStandardMaterial({ color: 0x1a1d22, roughness: 0.75, metalness: 0.2 }),
174
+ asphalt: new THREE.MeshStandardMaterial({ color: 0x3a4048, roughness: 0.9, metalness: 0.05 }),
175
+ plaza: new THREE.MeshStandardMaterial({ color: 0x4a5058, roughness: 0.7, metalness: 0.15 }),
176
+ concrete: new THREE.MeshStandardMaterial({ color: 0x6a7078, roughness: 0.65, metalness: 0.18 }),
177
+ steel: new THREE.MeshStandardMaterial({ color: 0x8a929c, roughness: 0.35, metalness: 0.75 }),
178
+ glass: new THREE.MeshStandardMaterial({ color: 0x1c2836, roughness: 0.15, metalness: 0.85, transparent: true, opacity: 0.85 }),
179
+ dark: new THREE.MeshStandardMaterial({ color: 0x14171c, roughness: 0.6, metalness: 0.3 }),
180
+ road: new THREE.MeshStandardMaterial({ color: 0x32383f, roughness: 0.92, metalness: 0.08 }),
181
+ buildings: [0x3d434c,0x2f353d,0x454c56,0x252a31,0x505862].map(c =>
182
+ new THREE.MeshStandardMaterial({ color: c, roughness: 0.55, metalness: 0.25 }))
183
+ };
184
+ const rideByPkg = new Map(SCENE.trains.map(t => [t.package, t]));
185
+ const stationMeshes = [];
186
+ let activeTrain = null;
187
+ let selectedId = null;
188
+ const hudTitle = document.getElementById('hud-title');
189
+ const hudSub = document.getElementById('hud-sub');
190
+ const statRide = document.getElementById('stat-ride');
191
+
192
+ const worldRoot = new THREE.Group();
193
+ scene.add(worldRoot);
194
+
195
+ // Architectural maquette base — concrete slab, not toy island
196
+ const cliff = new THREE.Mesh(new THREE.BoxGeometry(islandR*2.05, 1.1, islandR*2.05), mats.edge);
197
+ cliff.position.y = -0.55; cliff.castShadow = true; cliff.receiveShadow = true; worldRoot.add(cliff);
198
+ const top = new THREE.Mesh(new THREE.BoxGeometry(islandR*2, 0.22, islandR*2), mats.slab);
199
+ top.position.y = 0.05; top.receiveShadow = true; top.castShadow = true; worldRoot.add(top);
200
+ const pedestal = new THREE.Mesh(new THREE.BoxGeometry(islandR*1.15, 0.45, islandR*1.15), mats.dark);
201
+ pedestal.position.y = -1.25; pedestal.castShadow = true; worldRoot.add(pedestal);
202
+
203
+ // sparse volume blocks (massing study, not candy houses)
204
+ function building(x,z,w,d,h,mi){
205
+ const mesh=new THREE.Mesh(new THREE.BoxGeometry(w,h,d), mats.buildings[mi%mats.buildings.length]);
206
+ mesh.position.set(x,h/2+0.16,z); mesh.castShadow=true; mesh.receiveShadow=true;
207
+ const win=new THREE.Mesh(new THREE.BoxGeometry(w*0.72,h*0.55,0.03), mats.glass);
208
+ win.position.set(0,0.02,d/2+0.02); mesh.add(win); return mesh;
209
+ }
210
+ for(let i=0;i<18;i++){
211
+ const a=rnd()*Math.PI*2, r=islandR*(0.35+rnd()*0.5);
212
+ const x=Math.cos(a)*r, z=Math.sin(a)*r;
213
+ let ok=true;
214
+ for(const st of SCENE.stations){ const p=world(st); if(Math.hypot(p.x-x,p.z-z)<1.8){ok=false;break;} }
215
+ if(!ok) continue;
216
+ worldRoot.add(building(x,z, 0.45+rnd()*0.7, 0.4+rnd()*0.55, 0.7+rnd()*2.2, Math.floor(rnd()*5)));
217
+ }
218
+
219
+ const railY = 1.05;
220
+ for (const rail of SCENE.rails) {
221
+ const pts = rail.points.map(p => { const v=world(p); v.y=railY; return v; });
222
+ if (pts.length<2) continue;
223
+ const curve = new THREE.CatmullRomCurve3(pts, false, 'catmullrom', 0.22);
224
+ const segs = Math.max(36, pts.length*16);
225
+ const radius = Math.max(0.055, rail.width*0.5);
226
+ const tube = new THREE.Mesh(new THREE.TubeGeometry(curve, segs, radius, 12, false),
227
+ new THREE.MeshStandardMaterial({ color: new THREE.Color(rail.color), metalness:0.3, roughness:0.35, emissive:new THREE.Color(rail.color), emissiveIntensity:0.2 }));
228
+ tube.castShadow=true; tube.receiveShadow=true; worldRoot.add(tube);
229
+ const deck = new THREE.Mesh(new THREE.TubeGeometry(curve, segs, radius*1.75, 8, false), mats.steel);
230
+ deck.position.y=-0.05; deck.castShadow=true; worldRoot.add(deck);
231
+ const n = Math.max(3, Math.floor(curve.getLength()/1.55));
232
+ for(let i=0;i<=n;i++){
233
+ const p=curve.getPointAt(i/n);
234
+ const pillar=new THREE.Mesh(new THREE.BoxGeometry(0.1,Math.max(0.2,p.y-0.19),0.1), mats.concrete);
235
+ pillar.position.set(p.x,(p.y-0.19)/2+0.19,p.z); pillar.castShadow=true; worldRoot.add(pillar);
236
+ }
237
+ }
238
+
239
+ for (const st of SCENE.stations) {
240
+ const neon = new THREE.Color(st.color);
241
+ const g=new THREE.Group(); const p=world(st); g.position.set(p.x,0.19,p.z);
242
+ g.userData.stationId = st.id; g.userData.label = st.label; g.userData.color = st.color;
243
+ const plaza=new THREE.Mesh(new THREE.BoxGeometry(st.interchange?2.0:1.55, 0.08, st.interchange?2.0:1.55), mats.asphalt);
244
+ plaza.receiveShadow=true; plaza.castShadow=true; plaza.userData.pick=true; g.add(plaza);
245
+ // neon ground ring
246
+ const ring=new THREE.Mesh(new THREE.TorusGeometry(st.interchange?0.95:0.72, 0.035, 10, 48),
247
+ new THREE.MeshStandardMaterial({ color:neon, emissive:neon, emissiveIntensity:1.35, roughness:0.25, metalness:0.4 }));
248
+ ring.rotation.x=Math.PI/2; ring.position.y=0.06; ring.userData.pick=true; g.add(ring);
249
+ const ringInner=new THREE.Mesh(new THREE.TorusGeometry(st.interchange?0.55:0.42, 0.018, 8, 40),
250
+ new THREE.MeshStandardMaterial({ color:neon, emissive:neon, emissiveIntensity:2.1, roughness:0.2, metalness:0.35 }));
251
+ ringInner.rotation.x=Math.PI/2; ringInner.position.y=0.07; g.add(ringInner);
252
+ const platform=new THREE.Mesh(new THREE.BoxGeometry(st.interchange?1.5:1.15, 0.12, 0.5), mats.concrete);
253
+ platform.position.y=railY-0.35; platform.castShadow=true; platform.userData.pick=true; g.add(platform);
254
+ const canopy=new THREE.Mesh(new THREE.BoxGeometry(st.interchange?1.3:1.0, 0.05, 0.48), mats.steel);
255
+ canopy.position.y=railY+0.2; canopy.castShadow=true; g.add(canopy);
256
+ const accent=new THREE.Mesh(new THREE.BoxGeometry(st.interchange?1.3:1.0, 0.04, 0.05),
257
+ new THREE.MeshStandardMaterial({ color:neon, emissive:neon, emissiveIntensity:1.8, roughness:0.25, metalness:0.45 }));
258
+ accent.position.y=railY+0.16; g.add(accent);
259
+ // neon beacon mast
260
+ const mast=new THREE.Mesh(new THREE.CylinderGeometry(0.035,0.045,1.35,10),
261
+ new THREE.MeshStandardMaterial({ color:0x1a1e24, roughness:0.4, metalness:0.7 }));
262
+ mast.position.set(st.interchange?0.55:0.42, 0.75, 0.35); g.add(mast);
263
+ const beacon=new THREE.Mesh(new THREE.SphereGeometry(0.09,12,12),
264
+ new THREE.MeshStandardMaterial({ color:neon, emissive:neon, emissiveIntensity:2.4, roughness:0.15 }));
265
+ beacon.position.set(st.interchange?0.55:0.42, 1.45, 0.35); beacon.userData.pick=true; g.add(beacon);
266
+ const glow=new THREE.PointLight(neon, 0.55, 4.5, 2);
267
+ glow.position.copy(beacon.position); g.add(glow);
268
+ g.userData.neonMats=[ring.material, ringInner.material, accent.material, beacon.material];
269
+ g.userData.glow=glow;
270
+ const nBuild=st.interchange?3:2;
271
+ const props=[];
272
+ for(let i=0;i<nBuild;i++){
273
+ const ang=(i/nBuild)*Math.PI*2+0.4, rr=1.55+rnd()*0.35;
274
+ const b=building(Math.cos(ang)*rr, Math.sin(ang)*rr, 0.4+rnd()*0.35, 0.35+rnd()*0.25, 0.9+rnd()*1.6, Math.floor(rnd()*5));
275
+ b.userData.baseY=b.position.y;
276
+ b.userData.baseScale=1;
277
+ b.scale.setScalar(0);
278
+ b.visible=false;
279
+ props.push(b); g.add(b);
280
+ }
281
+ // platform kit pieces also stagger in on first visit
282
+ [accent, canopy, mast, beacon].forEach(piece=>{
283
+ piece.userData.baseY=piece.position.y;
284
+ piece.userData.baseScale=1;
285
+ piece.scale.setScalar(0);
286
+ piece.visible=false;
287
+ props.push(piece);
288
+ });
289
+ g.userData.props=props;
290
+ g.userData.revealed=false;
291
+ const canvas=document.createElement('canvas'); canvas.width=256; canvas.height=64;
292
+ const ctx=canvas.getContext('2d');
293
+ ctx.fillStyle='rgba(10,12,16,0.9)';
294
+ if(ctx.roundRect){ctx.roundRect(10,14,236,36,8);ctx.fill()} else ctx.fillRect(10,14,236,36);
295
+ ctx.strokeStyle=st.color; ctx.lineWidth=3;
296
+ if(ctx.roundRect){ctx.beginPath();ctx.roundRect(10,14,236,36,8);ctx.stroke()}
297
+ ctx.font='700 22px IBM Plex Mono, monospace'; ctx.fillStyle=st.color; ctx.textAlign='center'; ctx.textBaseline='middle';
298
+ ctx.fillText(st.label,128,34);
299
+ const sprite=new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(canvas), transparent:true, depthTest:false}));
300
+ sprite.scale.set(2.15,0.54,1); sprite.position.set(0,2.1,0); g.add(sprite);
301
+ stationMeshes.push(g); worldRoot.add(g);
302
+ }
303
+
304
+ for(let i=0;i<SCENE.stations.length-1;i++){
305
+ if(rnd()>0.5) continue;
306
+ const a=world(SCENE.stations[i]), b=world(SCENE.stations[i+1]);
307
+ const mid=a.clone().lerp(b,0.5), len=a.distanceTo(b);
308
+ const road=new THREE.Mesh(new THREE.BoxGeometry(0.26,0.03,len), mats.road);
309
+ road.position.set(mid.x,0.205,mid.z); road.lookAt(b.x,0.205,b.z); road.receiveShadow=true; worldRoot.add(road);
310
+ }
311
+
312
+ function easeOutCubic(x){ return 1-Math.pow(1-x,3); }
313
+ function easeOutBack(x){
314
+ const c1=1.70158, c3=c1+1;
315
+ return 1+c3*Math.pow(x-1,3)+c1*Math.pow(x-1,2);
316
+ }
317
+ function easeInBack(x){
318
+ const c1=1.70158, c3=c1+1;
319
+ return c3*x*x*x-c1*x*x;
320
+ }
321
+
322
+ // bokoko-style: each piece pops in on its own timeline (scale/Y only — never mutate shared material opacity)
323
+ function updateStaggerParts(parts, elapsed, partDur, stagger){
324
+ let allDone=true;
325
+ for(let i=0;i<parts.length;i++){
326
+ const part=parts[i];
327
+ const local=Math.min(1, Math.max(0, (elapsed - i*stagger)/partDur));
328
+ if(local<1) allDone=false;
329
+ if(local<=0){
330
+ part.visible=false;
331
+ part.scale.setScalar(0);
332
+ continue;
333
+ }
334
+ part.visible=true;
335
+ const s=Math.max(0.001, easeOutBack(local));
336
+ part.scale.setScalar(s * (part.userData.baseScale||1));
337
+ if(part.userData.baseY!=null){
338
+ part.position.y = part.userData.baseY + (1-easeOutCubic(local))*0.55;
339
+ }
340
+ }
341
+ return allDone;
342
+ }
343
+
344
+ function snapPartsIn(parts){
345
+ for(const part of parts){
346
+ part.visible=true;
347
+ part.scale.setScalar(part.userData.baseScale||1);
348
+ if(part.userData.baseY!=null) part.position.y=part.userData.baseY;
349
+ }
350
+ }
351
+
352
+ function makeTrain(colorHex){
353
+ const color=new THREE.Color(colorHex); const g=new THREE.Group();
354
+ const parts=[];
355
+ function part(){
356
+ const pg=new THREE.Group();
357
+ pg.userData.baseScale=1;
358
+ pg.userData.baseY=0;
359
+ pg.position.set(0,0,0);
360
+ parts.push(pg); g.add(pg); return pg;
361
+ }
362
+ function windowPane(x,y,z){
363
+ // BasicMaterial + depthWrite false: never get buried by body / lighting
364
+ const mat=new THREE.MeshBasicMaterial({
365
+ color:0x7ecbff, transparent:false, depthWrite:false
366
+ });
367
+ const w=new THREE.Mesh(new THREE.BoxGeometry(0.2,0.14,0.05), mat);
368
+ w.position.set(x,y,z);
369
+ w.renderOrder=10;
370
+ return w;
371
+ }
372
+ // bokoko reveal order: rear → body → windows → headlight
373
+ const rear=part();
374
+ const car2=new THREE.Mesh(new RoundedBoxGeometry(0.55,0.34,0.4,2,0.06),
375
+ new THREE.MeshStandardMaterial({color:color.clone().offsetHSL(0,-0.05,0.06), roughness:0.35, metalness:0.2, emissive:color, emissiveIntensity:0.15}));
376
+ car2.position.x=-0.78; car2.castShadow=true; car2.name='car2'; rear.add(car2);
377
+
378
+ const mid=part();
379
+ const body=new THREE.Mesh(new RoundedBoxGeometry(0.95,0.36,0.42,2,0.07),
380
+ new THREE.MeshStandardMaterial({color, metalness:0.25, roughness:0.3, emissive:color, emissiveIntensity:0.35}));
381
+ body.castShadow=true; body.name='body'; mid.add(body);
382
+
383
+ const glass=part();
384
+ glass.name='windows';
385
+ // both flanks, clearly outside body half-depth (0.21)
386
+ [[0.18,0.05,0.28],[-0.18,0.05,0.28],[0.18,0.05,-0.28],[-0.18,0.05,-0.28],
387
+ [-0.78,0.05,0.26],[-0.78,0.05,-0.26]].forEach(([x,y,z])=> glass.add(windowPane(x,y,z)));
388
+
389
+ const nose=part();
390
+ const light=new THREE.Mesh(new THREE.SphereGeometry(0.05,10,10),
391
+ new THREE.MeshStandardMaterial({color:0xfff2c4, emissive:0xffe08a, emissiveIntensity:1.1}));
392
+ light.position.set(0.52,0,0); nose.add(light);
393
+
394
+ g.userData.parts=parts;
395
+ g.userData.windows=glass;
396
+ return g;
397
+ }
398
+
399
+ function recolorTrain(mesh, colorHex){
400
+ const color=new THREE.Color(colorHex);
401
+ const body=mesh.getObjectByName('body');
402
+ const car2=mesh.getObjectByName('car2');
403
+ if(body && body.material){ body.material.color.copy(color); body.material.emissive.copy(color); }
404
+ if(car2 && car2.material){
405
+ const c2=color.clone().offsetHSL(0,-0.05,0.06);
406
+ car2.material.color.copy(c2); car2.material.emissive.copy(color);
407
+ }
408
+ }
409
+
410
+ const clock=new THREE.Clock();
411
+ const revealedStations=new Set();
412
+ let sharedTrain=null;
413
+ let trainEverDeparted=false;
414
+
415
+ function ensureTrain(colorHex){
416
+ if(!sharedTrain){
417
+ sharedTrain=makeTrain(colorHex);
418
+ sharedTrain.visible=false;
419
+ worldRoot.add(sharedTrain);
420
+ } else {
421
+ recolorTrain(sharedTrain, colorHex);
422
+ }
423
+ return sharedTrain;
424
+ }
425
+
426
+ function disposeObject(obj){
427
+ worldRoot.remove(obj);
428
+ obj.traverse(o=>{ if(o.geometry) o.geometry.dispose(); if(o.material){
429
+ if(Array.isArray(o.material)) o.material.forEach(m=>m.dispose()); else o.material.dispose();
430
+ }});
431
+ }
432
+
433
+ function finishPropsIfNeeded(tr){
434
+ if(!tr || tr.phase!=='props' || !tr.stGroup) return;
435
+ snapPartsIn(tr.stGroup.userData.props||[]);
436
+ revealedStations.add(tr.pkgId);
437
+ tr.stGroup.userData.revealed=true;
438
+ }
439
+
440
+ function disposeTrainNow(){
441
+ if(!activeTrain) return;
442
+ finishPropsIfNeeded(activeTrain);
443
+ // only Clear removes the mesh; station switches reuse sharedTrain
444
+ if(sharedTrain && activeTrain.mesh===sharedTrain){
445
+ sharedTrain.visible=false;
446
+ } else if(activeTrain.mesh && activeTrain.mesh!==sharedTrain){
447
+ disposeObject(activeTrain.mesh);
448
+ }
449
+ activeTrain=null; statRide.textContent='0';
450
+ }
451
+
452
+ function clearTrain(animated){
453
+ if(!activeTrain && !sharedTrain) return;
454
+ if(sharedTrain){
455
+ disposeObject(sharedTrain);
456
+ sharedTrain=null;
457
+ trainEverDeparted=false;
458
+ }
459
+ activeTrain=null;
460
+ statRide.textContent='0';
461
+ }
462
+
463
+ function setSelection(stGroup){
464
+ for(const s of stationMeshes){
465
+ const on=s===stGroup;
466
+ if(s.userData.neonMats[0]) s.userData.neonMats[0].emissiveIntensity=on?2.8:1.35;
467
+ if(s.userData.neonMats[1]) s.userData.neonMats[1].emissiveIntensity=on?3.4:2.1;
468
+ if(s.userData.neonMats[2]) s.userData.neonMats[2].emissiveIntensity=on?3.0:1.8;
469
+ if(s.userData.neonMats[3]) s.userData.neonMats[3].emissiveIntensity=on?4.2:2.4;
470
+ if(s.userData.glow) s.userData.glow.intensity=on?1.35:0.55;
471
+ }
472
+ }
473
+
474
+ const _fwd=new THREE.Vector3(1,0,0);
475
+ const _tan=new THREE.Vector3();
476
+ const _q=new THREE.Quaternion();
477
+ function placeTrainOnCurve(mesh, curve, u){
478
+ // stay slightly before the true end so tangent stays stable (no 90° flip)
479
+ const uu=Math.min(Math.max(u,0), 0.995);
480
+ const p=curve.getPointAt(uu);
481
+ curve.getTangentAt(uu, _tan);
482
+ if(_tan.lengthSq()<1e-10) _tan.set(1,0,0); else _tan.normalize();
483
+ mesh.position.copy(p);
484
+ // train mesh faces +X — align +X to path tangent (no lookAt+rotateY hack)
485
+ _q.setFromUnitVectors(_fwd, _tan);
486
+ mesh.quaternion.copy(_q);
487
+ }
488
+
489
+ function beginRide(tr){
490
+ placeTrainOnCurve(tr.mesh, tr.curve, 0);
491
+ tr.mesh.visible=true;
492
+ tr.mesh.scale.setScalar(1);
493
+ playing=true;
494
+ const playBtn=document.getElementById('btn-play');
495
+ playBtn.textContent='Pause'; playBtn.setAttribute('aria-pressed','true');
496
+ statRide.textContent='1';
497
+ // first appearance only — bokoko: parts assemble one-by-one
498
+ if(!trainEverDeparted){
499
+ const parts=tr.mesh.userData.parts||[];
500
+ for(const part of parts){
501
+ part.visible=false;
502
+ part.scale.setScalar(0);
503
+ part.position.y=part.userData.baseY||0;
504
+ }
505
+ tr.phase='intro';
506
+ tr.t0=clock.getElapsedTime();
507
+ tr.partDur=0.52;
508
+ tr.partStagger=0.12;
509
+ hudSub.textContent='task · '+tr.task+' · train assembling…';
510
+ return;
511
+ }
512
+ snapPartsIn(tr.mesh.userData.parts||[]);
513
+ tr.phase='ride';
514
+ tr.t0=clock.getElapsedTime();
515
+ hudSub.textContent='task · '+tr.task+' · riding dep path';
516
+ }
517
+
518
+ function spawnRide(pkgId, label){
519
+ selectedId=pkgId;
520
+ const stGroup=stationMeshes.find(s=>s.userData.stationId===pkgId) || null;
521
+ setSelection(stGroup);
522
+ hudTitle.textContent=label;
523
+
524
+ // same station — leave train exactly as-is (incl. first intro)
525
+ if(activeTrain && activeTrain.pkgId===pkgId) return;
526
+
527
+ // switching stations: finish previous assemble, but NEVER dispose/hide the shared train
528
+ if(activeTrain) finishPropsIfNeeded(activeTrain);
529
+
530
+ const ride=rideByPkg.get(pkgId);
531
+ if(!ride){
532
+ hudSub.textContent='Terminal stop · no dep ride path';
533
+ if(stGroup && !revealedStations.has(pkgId)){
534
+ activeTrain={
535
+ pkgId, mesh:sharedTrain || new THREE.Group(), curve:null, speed:0, task:'',
536
+ phase:'props', t0:clock.getElapsedTime(), stGroup,
537
+ propDur:0.55, propStagger:0.13
538
+ };
539
+ if(activeTrain.mesh!==sharedTrain) worldRoot.add(activeTrain.mesh);
540
+ } else if(activeTrain){
541
+ activeTrain.pkgId=pkgId;
542
+ activeTrain.stGroup=stGroup;
543
+ activeTrain.curve=null;
544
+ activeTrain.phase='arrived';
545
+ }
546
+ return;
547
+ }
548
+
549
+ const firstVisit=!revealedStations.has(pkgId);
550
+ const pts=ride.points.map(p=>{ const v=world(p); v.y=railY+0.24; return v; });
551
+ const curve=new THREE.CatmullRomCurve3(pts,false,'catmullrom',0.25);
552
+ const mesh=ensureTrain(ride.color);
553
+
554
+ activeTrain={
555
+ pkgId, curve, mesh, speed:ride.speed, task:ride.task, stGroup,
556
+ phase: firstVisit ? 'props' : 'ride',
557
+ t0:clock.getElapsedTime(),
558
+ propDur:0.55, propStagger:0.13,
559
+ outroDur:0.35
560
+ };
561
+ hudTitle.textContent=label;
562
+ statRide.textContent=trainEverDeparted || mesh.visible ? '1' : '0';
563
+
564
+ if(firstVisit){
565
+ // hide ONLY before the very first departure in the session
566
+ if(!trainEverDeparted) mesh.visible=false;
567
+ hudSub.textContent='task · '+ride.task+' · assembling stop…';
568
+ playing=true;
569
+ } else {
570
+ // other stop / revisit — retarget path, stay visible, no pop
571
+ beginRide(activeTrain);
572
+ }
573
+ }
574
+
575
+ const box=new THREE.Box3().setFromObject(worldRoot);
576
+ const center=box.getCenter(new THREE.Vector3());
577
+ const size=box.getSize(new THREE.Vector3());
578
+ controls.target.copy(center);
579
+ const dist=Math.max(size.x,size.z,10)*1.2;
580
+ camera.position.set(center.x+dist*0.9, center.y+dist*0.78, center.z+dist*0.72);
581
+ controls.update();
582
+ const homeCam=camera.position.clone(), homeTarget=controls.target.clone();
583
+
584
+ const raycaster=new THREE.Raycaster();
585
+ const pointer=new THREE.Vector2();
586
+ let down={x:0,y:0};
587
+
588
+ renderer.domElement.addEventListener('pointerdown', e=>{
589
+ down={x:e.clientX,y:e.clientY};
590
+ });
591
+ renderer.domElement.addEventListener('pointerup', e=>{
592
+ if(Math.hypot(e.clientX-down.x, e.clientY-down.y)>6) return;
593
+ const rect=renderer.domElement.getBoundingClientRect();
594
+ pointer.x=((e.clientX-rect.left)/rect.width)*2-1;
595
+ pointer.y=-((e.clientY-rect.top)/rect.height)*2+1;
596
+ raycaster.setFromCamera(pointer, camera);
597
+ const hits=raycaster.intersectObjects(stationMeshes, true);
598
+ if(!hits.length) return;
599
+ let obj=hits[0].object;
600
+ while(obj && !obj.userData.stationId) obj=obj.parent;
601
+ if(!obj) return;
602
+ spawnRide(obj.userData.stationId, obj.userData.label);
603
+ });
604
+ renderer.domElement.style.cursor='pointer';
605
+
606
+ document.getElementById('btn-play').addEventListener('click', e=>{
607
+ playing=!playing; e.currentTarget.textContent=playing?'Pause':'Play';
608
+ e.currentTarget.setAttribute('aria-pressed', playing?'true':'false');
609
+ });
610
+ document.getElementById('btn-clear').addEventListener('click', ()=>{
611
+ clearTrain(true); selectedId=null; setSelection(null);
612
+ hudTitle.textContent='Select a station';
613
+ hudSub.textContent='Click a neon stop · buildings assemble once, then ride';
614
+ });
615
+ document.getElementById('btn-reset').addEventListener('click', ()=>{
616
+ camera.position.copy(homeCam); controls.target.copy(homeTarget);
617
+ });
618
+
619
+ (function tick(){
620
+ const t=clock.getElapsedTime();
621
+ controls.update();
622
+ for(const s of stationMeshes){
623
+ const pulse=0.85+0.15*Math.sin(t*2.2 + s.position.x);
624
+ const sel=s.userData.stationId===selectedId;
625
+ if(s.userData.neonMats[1]) s.userData.neonMats[1].emissiveIntensity=(sel?3.4:2.1)*pulse;
626
+ if(s.userData.glow) s.userData.glow.intensity=(sel?1.35:0.55)*(0.9+0.1*Math.sin(t*3+s.position.z));
627
+ }
628
+ if(activeTrain){
629
+ const tr=activeTrain;
630
+ if(tr.phase==='props'){
631
+ const props=(tr.stGroup && tr.stGroup.userData.props) || [];
632
+ const done=updateStaggerParts(props, t-tr.t0, tr.propDur, tr.propStagger);
633
+ if(done){
634
+ revealedStations.add(tr.pkgId);
635
+ if(tr.stGroup) tr.stGroup.userData.revealed=true;
636
+ if(!tr.curve){
637
+ tr.phase='arrived';
638
+ hudSub.textContent='Terminal stop · assembled';
639
+ } else {
640
+ // buildings done — first show only if never departed; else stay visible
641
+ beginRide(tr);
642
+ }
643
+ }
644
+ } else if(tr.phase==='intro'){
645
+ placeTrainOnCurve(tr.mesh, tr.curve, 0);
646
+ const parts=tr.mesh.userData.parts||[];
647
+ const done=updateStaggerParts(parts, t-tr.t0, tr.partDur, tr.partStagger);
648
+ if(done){
649
+ snapPartsIn(parts);
650
+ if(tr.mesh.userData.windows){
651
+ tr.mesh.userData.windows.visible=true;
652
+ tr.mesh.userData.windows.scale.setScalar(1);
653
+ tr.mesh.userData.windows.position.y=0;
654
+ }
655
+ trainEverDeparted=true;
656
+ tr.phase='ride';
657
+ tr.t0=t;
658
+ hudSub.textContent='task · '+tr.task+' · riding dep path';
659
+ }
660
+ } else if(tr.phase==='outro'){
661
+ const raw=Math.min(1, (t-tr.t0)/tr.outroDur);
662
+ const s=Math.max(0.001, 1-easeInBack(raw));
663
+ tr.mesh.scale.setScalar(s);
664
+ if(raw>=1) disposeTrainNow();
665
+ } else if(tr.phase==='arrived'){
666
+ // parked — no animation
667
+ } else if(playing && tr.phase==='ride'){
668
+ if(tr.mesh.userData.windows && !tr.mesh.userData.windows.visible){
669
+ snapPartsIn(tr.mesh.userData.parts||[]);
670
+ }
671
+ const u=Math.min(1, (t-tr.t0)*tr.speed);
672
+ placeTrainOnCurve(tr.mesh, tr.curve, u);
673
+ if(u>=1){
674
+ placeTrainOnCurve(tr.mesh, tr.curve, 0.995);
675
+ tr.phase='arrived';
676
+ hudSub.textContent='task · '+tr.task+' · arrived';
677
+ }
678
+ }
679
+ }
680
+ renderer.render(scene,camera);
681
+ requestAnimationFrame(tick);
682
+ })();
683
+ window.addEventListener('resize', ()=>{
684
+ const w=view.clientWidth, h=view.clientHeight;
685
+ camera.aspect=w/h; camera.updateProjectionMatrix(); renderer.setSize(w,h);
686
+ });
687
+
688
+ // optional capture hook: metro.html?capture=1
689
+ if(location.search.includes('capture')){
690
+ window.__turbometro = {
691
+ stations: SCENE.stations.map(s=>({id:s.id,label:s.label})),
692
+ spawn:(id,label)=>spawnRide(id,label||id),
693
+ };
694
+ }
695
+ </script>
696
+ </body>
697
+ </html>
698
+ `;
699
+ }