advent of code 2025 in ts and nix
1const scriptDir = import.meta.dir;
2const file = await Bun.file(`${scriptDir}/../../shared/08/input.txt`).text();
3
4// Parse junction coordinates
5const junctions = file
6 .trim()
7 .split("\n")
8 .map((line) => {
9 const [x, y, z] = line.split(",").map(Number);
10 return { x, y, z };
11 });
12
13// Calculate all pairwise distances
14function distance(a, b) {
15 return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2 + (a.z - b.z) ** 2);
16}
17
18const pairs = [];
19for (let i = 0; i < junctions.length; i++) {
20 for (let j = i + 1; j < junctions.length; j++) {
21 pairs.push({
22 i,
23 j,
24 distance: distance(junctions[i], junctions[j]),
25 });
26 }
27}
28
29// Sort by distance
30pairs.sort((a, b) => a.distance - b.distance);
31
32// Union-Find for circuit tracking
33class UnionFind {
34 constructor(n) {
35 this.parent = Array.from({ length: n }, (_, i) => i);
36 this.size = Array(n).fill(1);
37 }
38
39 find(x) {
40 if (this.parent[x] !== x) {
41 this.parent[x] = this.find(this.parent[x]);
42 }
43 return this.parent[x];
44 }
45
46 union(x, y) {
47 const rootX = this.find(x);
48 const rootY = this.find(y);
49
50 if (rootX === rootY) return false;
51
52 if (this.size[rootX] < this.size[rootY]) {
53 this.parent[rootX] = rootY;
54 this.size[rootY] += this.size[rootX];
55 } else {
56 this.parent[rootY] = rootX;
57 this.size[rootX] += this.size[rootY];
58 }
59
60 return true;
61 }
62
63 getCircuitSizes() {
64 const circuits = new Map();
65 for (let i = 0; i < this.parent.length; i++) {
66 const root = this.find(i);
67 circuits.set(root, this.size[root]);
68 }
69 return Array.from(circuits.values()).sort((a, b) => b - a);
70 }
71
72 getCircuitCount() {
73 const roots = new Set();
74 for (let i = 0; i < this.parent.length; i++) {
75 roots.add(this.find(i));
76 }
77 return roots.size;
78 }
79}
80
81// Build animation stages for Part 1 (first 1000 connections) and Part 2 (until single circuit)
82const stages = [];
83const uf = new UnionFind(junctions.length);
84const connections = [];
85let part2ConnectionIndex = -1;
86let part2Product = 0;
87
88let step = 0;
89while (true) {
90 const circuitSizes = uf.getCircuitSizes();
91 const top3 = circuitSizes.slice(0, 3);
92 const product = top3.length >= 3 ? top3[0] * top3[1] * top3[2] : 0;
93 const circuitCount = uf.getCircuitCount();
94
95 stages.push({
96 connections: [...connections],
97 circuits: circuitCount,
98 largest: circuitSizes[0] || 1,
99 product,
100 circuitSizes: [...circuitSizes],
101 part2Product: part2Product,
102 });
103
104 // Check if we've reached a single circuit (Part 2 complete)
105 if (circuitCount === 1 && part2ConnectionIndex === -1) {
106 part2ConnectionIndex = step - 1; // The previous connection completed it
107 if (part2ConnectionIndex >= 0) {
108 const lastPair = pairs[part2ConnectionIndex];
109 part2Product = junctions[lastPair.i].x * junctions[lastPair.j].x;
110 }
111 // Update the current stage with the part2Product
112 stages[stages.length - 1].part2Product = part2Product;
113 }
114
115 // Stop after reaching single circuit, or if we've exhausted pairs
116 if (circuitCount === 1 || step >= pairs.length) {
117 break;
118 }
119
120 const pair = pairs[step];
121 uf.union(pair.i, pair.j);
122 connections.push(pair);
123 step++;
124}
125
126console.log(
127 "Part 1: After 1000 connections, product =",
128 stages[Math.min(1000, stages.length - 1)].product,
129);
130console.log(
131 "Part 2: Single circuit at connection",
132 part2ConnectionIndex,
133 ", product =",
134 part2Product,
135);
136
137const inputData = JSON.stringify(junctions);
138const maxConnections = stages[stages.length - 1].connections.length;
139
140const html = `<!DOCTYPE html>
141<html lang="en">
142<head>
143 <meta charset="UTF-8">
144 <meta name="viewport" content="width=device-width, initial-scale=1.0">
145 <title>AoC 2025 Day 8 - Playground Junction Boxes</title>
146 <style>
147 * {
148 box-sizing: border-box;
149 margin: 0;
150 padding: 0;
151 }
152 body {
153 background: #1e1e2e;
154 color: #cdd6f4;
155 font-family: "Source Code Pro", monospace;
156 font-size: 14pt;
157 font-weight: 300;
158 overflow: hidden;
159 margin: 0;
160 padding: 0;
161 }
162 #container {
163 width: 100vw;
164 height: 100vh;
165 position: relative;
166 }
167 #canvas {
168 width: 100%;
169 height: 100%;
170 display: block;
171 position: absolute;
172 top: 0;
173 left: 0;
174 z-index: 0;
175 }
176 .ui {
177 position: absolute;
178 top: 0;
179 left: 0;
180 right: 0;
181 padding: 20px;
182 pointer-events: none;
183 z-index: 1;
184 }
185 .ui > * {
186 pointer-events: auto;
187 }
188 h1 {
189 color: #a6e3a1;
190 text-shadow: 0 0 2px #a6e3a1, 0 0 5px #a6e3a1;
191 margin-bottom: 10px;
192 font-size: 1em;
193 font-weight: normal;
194 text-align: center;
195 }
196 .controls {
197 background: rgba(17, 17, 27, 0.9);
198 border: 1px solid #313244;
199 padding: 15px;
200 margin: 15px auto;
201 max-width: 800px;
202 border-radius: 4px;
203 }
204 .timeline-container {
205 background: rgba(17, 17, 27, 0.9);
206 border: 1px solid #313244;
207 padding: 20px;
208 margin: 10px auto;
209 max-width: 800px;
210 border-radius: 4px;
211 }
212 .timeline-label {
213 color: #a6adc8;
214 font-size: 12px;
215 margin-bottom: 10px;
216 display: flex;
217 justify-content: space-between;
218 align-items: center;
219 }
220 .timeline-markers {
221 position: relative;
222 margin-top: 8px;
223 font-size: 11px;
224 color: #6c7086;
225 height: 30px;
226 }
227 .timeline-marker {
228 position: absolute;
229 text-align: center;
230 transform: translateX(-50%);
231 white-space: nowrap;
232 }
233 .timeline-marker.start {
234 left: 0%;
235 transform: translateX(0);
236 }
237 .timeline-marker.end {
238 right: 0%;
239 transform: translateX(0);
240 text-align: right;
241 }
242 .timeline-marker.highlight {
243 color: #a6e3a1;
244 font-weight: bold;
245 }
246 .timeline-slider {
247 width: 100% !important;
248 -webkit-appearance: none;
249 appearance: none;
250 height: 12px;
251 background: linear-gradient(to right, #313244 0%, #313244 100%);
252 outline: none;
253 border-radius: 6px;
254 cursor: pointer;
255 position: relative;
256 }
257 .timeline-slider::-webkit-slider-thumb {
258 -webkit-appearance: none;
259 appearance: none;
260 width: 24px;
261 height: 24px;
262 background: #a6e3a1;
263 cursor: grab;
264 border-radius: 50%;
265 border: 3px solid #11111b;
266 box-shadow: 0 2px 8px rgba(166, 227, 161, 0.4);
267 transition: all 0.2s ease;
268 }
269 .timeline-slider::-moz-range-thumb {
270 width: 24px;
271 height: 24px;
272 background: #a6e3a1;
273 cursor: grab;
274 border-radius: 50%;
275 border: 3px solid #11111b;
276 box-shadow: 0 2px 8px rgba(166, 227, 161, 0.4);
277 transition: all 0.2s ease;
278 }
279 .timeline-slider::-webkit-slider-thumb:hover {
280 background: #b4e7b9;
281 transform: scale(1.1);
282 box-shadow: 0 4px 12px rgba(166, 227, 161, 0.6);
283 }
284 .timeline-slider::-moz-range-thumb:hover {
285 background: #b4e7b9;
286 transform: scale(1.1);
287 box-shadow: 0 4px 12px rgba(166, 227, 161, 0.6);
288 }
289 .timeline-slider:active::-webkit-slider-thumb {
290 cursor: grabbing;
291 transform: scale(0.95);
292 }
293 .timeline-slider:active::-moz-range-thumb {
294 cursor: grabbing;
295 transform: scale(0.95);
296 }
297 .control-row {
298 display: flex;
299 gap: 15px;
300 align-items: center;
301 margin-bottom: -1rem;
302 flex-wrap: wrap;
303 justify-content: center;
304 }
305 .control-row:last-child {
306 margin-bottom: 0;
307 }
308 button {
309 background: #11111b;
310 color: #a6e3a1;
311 border: 1px solid #313244;
312 padding: 8px 16px;
313 cursor: pointer;
314 font-family: inherit;
315 font-size: 14px;
316 border-radius: 3px;
317 }
318 button:hover {
319 background: #181825;
320 }
321 button:disabled {
322 opacity: 0.5;
323 cursor: not-allowed;
324 }
325 .info {
326 color: #f9e2af;
327 font-size: 14px;
328 }
329 .stats {
330 background: rgba(17, 17, 27, 0.9);
331 border: 1px solid #313244;
332 padding: 10px 15px;
333 margin: 0 auto;
334 max-width: 800px;
335 border-radius: 4px;
336 text-align: center;
337 font-size: 13px;
338 color: #a6adc8;
339 position: fixed;
340 bottom: 20px;
341 left: 50%;
342 transform: translateX(-50%);
343 z-index: 1;
344 }
345 .legend {
346 display: flex;
347 gap: 15px;
348 margin-top: 10px;
349 flex-wrap: wrap;
350 justify-content: center;
351 }
352 .legend-item {
353 display: flex;
354 align-items: center;
355 gap: 6px;
356 font-size: 12px;
357 color: #a6adc8;
358 }
359 .legend-box {
360 width: 12px;
361 height: 12px;
362 border-radius: 50%;
363 }
364 .legend-box.junction { background: #89b4fa; }
365 .legend-box.connection { background: #a6e3a1; }
366 .legend-box.circuit { background: #f9e2af; }
367 input[type="range"] {
368 -webkit-appearance: none;
369 appearance: none;
370 width: 120px;
371 height: 6px;
372 background: #313244;
373 outline: none;
374 border: 1px solid #313244;
375 }
376 input[type="range"]::-webkit-slider-thumb {
377 -webkit-appearance: none;
378 appearance: none;
379 width: 16px;
380 height: 16px;
381 background: #a6e3a1;
382 cursor: pointer;
383 border: 1px solid #313244;
384 border-radius: 50%;
385 }
386 input[type="range"]::-moz-range-thumb {
387 width: 16px;
388 height: 16px;
389 background: #a6e3a1;
390 cursor: pointer;
391 border: 1px solid #313244;
392 border-radius: 50%;
393 }
394 label {
395 color: #a6adc8;
396 font-size: 13px;
397 }
398 a {
399 text-decoration: none;
400 color: #a6e3a1;
401 outline: 0;
402 }
403 a:hover, a:focus {
404 text-decoration: underline;
405 }
406 </style>
407</head>
408<body>
409 <div id="container">
410 <canvas id="canvas"></canvas>
411 <div class="ui">
412 <h1>AoC 2025 Day 8 - Playground Junction Boxes</h1>
413
414 <div class="controls">
415 <div class="control-row">
416 <button id="prev">← Previous</button>
417 <button id="play">▶ Play</button>
418 <button id="next">Next →</button>
419 <button id="reset">↺ Reset</button>
420 </div>
421 <div class="timeline-label">
422 <span>Timeline</span>
423 <span id="timelineStep">Step 0 of ${stages.length - 1}</span>
424 </div>
425 <input type="range" id="timeline" class="timeline-slider" min="0" max="${stages.length - 1}" value="0" step="1">
426 <div class="timeline-markers">
427 <div class="timeline-marker start">Start<br>0</div>
428 <div class="timeline-marker highlight" style="left: ${(1000 / (stages.length - 1)) * 100}%;">Part 1<br>1000</div>
429 <div class="timeline-marker highlight end">Part 2<br>${stages.length - 1}</div>
430 </div>
431 <div class="legend">
432 <div class="legend-item"><div class="legend-box junction"></div> Isolated Junction (small)</div>
433 <div class="legend-item"><div class="legend-box connection"></div> Connected Junction (large)</div>
434 <div class="legend-item"><div class="legend-box circuit"></div> Circuit (color-coded)</div>
435 </div>
436 </div>
437
438 <div class="stats">
439 <div id="statsInfo">Circuits: ${junctions.length} | Largest: 0 | Part 1: 0 | Part 2: 0</div>
440 <div style="margin-top: 5px; font-size: 11px;"><a href="../index.html">[Return to Index]</a></div>
441 </div>
442 </div>
443 </div>
444
445 <script type="importmap">
446 {
447 "imports": {
448 "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
449 "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
450 }
451 }
452 </script>
453
454 <script type="module">
455 import * as THREE from 'three';
456 import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
457
458 // Input data embedded
459 const junctions = ${inputData};
460
461 // Normalize coordinates to fit in view
462 const maxCoord = Math.max(...junctions.flatMap(j => [j.x, j.y, j.z]));
463 const scale = 20 / maxCoord;
464
465 // Calculate all pairwise distances
466 function distance(a, b) {
467 return Math.sqrt(
468 (a.x - b.x) ** 2 +
469 (a.y - b.y) ** 2 +
470 (a.z - b.z) ** 2
471 );
472 }
473
474 const pairs = [];
475 for (let i = 0; i < junctions.length; i++) {
476 for (let j = i + 1; j < junctions.length; j++) {
477 pairs.push({
478 i,
479 j,
480 distance: distance(junctions[i], junctions[j])
481 });
482 }
483 }
484
485 // Sort by distance
486 pairs.sort((a, b) => a.distance - b.distance);
487
488 // Union-Find for circuit tracking
489 class UnionFind {
490 constructor(n) {
491 this.parent = Array.from({ length: n }, (_, i) => i);
492 this.size = Array(n).fill(1);
493 }
494
495 find(x) {
496 if (this.parent[x] !== x) {
497 this.parent[x] = this.find(this.parent[x]);
498 }
499 return this.parent[x];
500 }
501
502 union(x, y) {
503 const rootX = this.find(x);
504 const rootY = this.find(y);
505
506 if (rootX === rootY) return false;
507
508 if (this.size[rootX] < this.size[rootY]) {
509 this.parent[rootX] = rootY;
510 this.size[rootY] += this.size[rootX];
511 } else {
512 this.parent[rootY] = rootX;
513 this.size[rootX] += this.size[rootY];
514 }
515
516 return true;
517 }
518
519 getCircuitSizes() {
520 const circuits = new Map();
521 for (let i = 0; i < this.parent.length; i++) {
522 const root = this.find(i);
523 circuits.set(root, this.size[root]);
524 }
525 return Array.from(circuits.values()).sort((a, b) => b - a);
526 }
527
528 getCircuitCount() {
529 const roots = new Set();
530 for (let i = 0; i < this.parent.length; i++) {
531 roots.add(this.find(i));
532 }
533 return roots.size;
534 }
535 }
536
537 // Build animation stages for Part 1 (first 1000 connections) and Part 2 (until single circuit)
538 const stages = [];
539 const uf = new UnionFind(junctions.length);
540 const connections = [];
541 let part2ConnectionIndex = -1;
542 let part2Product = 0;
543
544 let step = 0;
545 while (true) {
546 const circuitSizes = uf.getCircuitSizes();
547 const top3 = circuitSizes.slice(0, 3);
548 const product = top3.length >= 3 ? top3[0] * top3[1] * top3[2] : 0;
549 const circuitCount = uf.getCircuitCount();
550
551 stages.push({
552 connections: [...connections],
553 circuits: circuitCount,
554 largest: circuitSizes[0] || 1,
555 product,
556 circuitSizes: [...circuitSizes],
557 part2Product: part2Product
558 });
559
560 // Check if we've reached a single circuit (Part 2 complete)
561 if (circuitCount === 1 && part2ConnectionIndex === -1) {
562 part2ConnectionIndex = step - 1; // The previous connection completed it
563 if (part2ConnectionIndex >= 0) {
564 const lastPair = pairs[part2ConnectionIndex];
565 part2Product = junctions[lastPair.i].x * junctions[lastPair.j].x;
566 }
567 // Update the current stage with the part2Product
568 stages[stages.length - 1].part2Product = part2Product;
569 }
570
571 // Stop after reaching single circuit, or if we've exhausted pairs
572 if (circuitCount === 1 || step >= pairs.length) {
573 break;
574 }
575
576 const pair = pairs[step];
577 uf.union(pair.i, pair.j);
578 connections.push(pair);
579 step++;
580 }
581
582 console.log('Part 1: After 1000 connections, product =', stages[Math.min(1000, stages.length - 1)].product);
583 console.log('Part 2: Single circuit at connection', part2ConnectionIndex, ', product =', part2Product);
584
585 // Three.js setup
586 const scene = new THREE.Scene();
587 scene.background = new THREE.Color(0x0d0d15);
588
589 const camera = new THREE.PerspectiveCamera(
590 75,
591 window.innerWidth / window.innerHeight,
592 0.1,
593 1000
594 );
595 camera.position.set(15, 15, 15);
596
597 const renderer = new THREE.WebGLRenderer({
598 canvas: document.getElementById('canvas'),
599 antialias: true
600 });
601 renderer.setSize(window.innerWidth, window.innerHeight);
602 renderer.setPixelRatio(window.devicePixelRatio);
603
604 const controls = new OrbitControls(camera, renderer.domElement);
605 controls.enableDamping = true;
606 controls.dampingFactor = 0.05;
607 controls.target.set(0, 3, 0); // Shift the view down by 3 units
608 controls.autoRotate = true;
609 controls.autoRotateSpeed = 0.5; // Slow rotation (0.5 degrees per frame at 60fps = 30 seconds per rotation)
610 controls.update();
611
612 // Lighting
613 const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
614 scene.add(ambientLight);
615
616 const pointLight1 = new THREE.PointLight(0xffffff, 0.6);
617 pointLight1.position.set(10, 10, 10);
618 scene.add(pointLight1);
619
620 const pointLight2 = new THREE.PointLight(0xffffff, 0.4);
621 pointLight2.position.set(-10, -10, -10);
622 scene.add(pointLight2);
623
624 // Create junction boxes (smaller)
625 const junctionGeometry = new THREE.SphereGeometry(0.1, 12, 12);
626 const junctionMaterial = new THREE.MeshPhongMaterial({
627 color: 0x89b4fa,
628 emissive: 0x89b4fa,
629 emissiveIntensity: 0.2
630 });
631
632 const junctionMeshes = junctions.map((j, idx) => {
633 const mesh = new THREE.Mesh(junctionGeometry, junctionMaterial);
634 mesh.position.set(
635 (j.x * scale) - 10,
636 (j.y * scale) - 10,
637 (j.z * scale) - 10
638 );
639 mesh.userData.index = idx;
640 scene.add(mesh);
641 return mesh;
642 });
643
644 // Connection lines - pre-render all cylinders (enough to reach single circuit)
645 let connectionCylinders = [];
646
647 // Determine how many connections we need (until single circuit)
648 const maxConnections = Math.min(pairs.length, stages[stages.length - 1].connections.length);
649
650 // Pre-create all connection cylinders
651 console.log('Pre-rendering', maxConnections, 'connections...');
652 pairs.slice(0, maxConnections).forEach((pair, idx) => {
653 const j1 = junctions[pair.i];
654 const j2 = junctions[pair.j];
655
656 const p1 = new THREE.Vector3(
657 (j1.x * scale) - 10,
658 (j1.y * scale) - 10,
659 (j1.z * scale) - 10
660 );
661 const p2 = new THREE.Vector3(
662 (j2.x * scale) - 10,
663 (j2.y * scale) - 10,
664 (j2.z * scale) - 10
665 );
666
667 // Create cylinder between points
668 const direction = new THREE.Vector3().subVectors(p2, p1);
669 const length = direction.length();
670 const cylinderGeometry = new THREE.CylinderGeometry(0.015, 0.015, length, 4);
671 const cylinderMaterial = new THREE.MeshBasicMaterial({
672 transparent: true,
673 opacity: 0.8
674 });
675
676 const cylinder = new THREE.Mesh(cylinderGeometry, cylinderMaterial);
677
678 // Position at midpoint
679 cylinder.position.copy(p1).add(direction.multiplyScalar(0.5));
680
681 // Orient cylinder to connect the two points
682 cylinder.quaternion.setFromUnitVectors(
683 new THREE.Vector3(0, 1, 0),
684 direction.normalize()
685 );
686
687 // Initially invisible
688 cylinder.visible = false;
689 cylinder.userData.pairIndex = idx;
690 cylinder.userData.i = pair.i;
691 cylinder.userData.j = pair.j;
692
693 scene.add(cylinder);
694 connectionCylinders.push(cylinder);
695 });
696 console.log('Pre-rendering complete!');
697
698 // Pre-generate stable colors for each junction based on their index
699 const junctionColors = [];
700 const hues = [];
701 for (let i = 0; i < junctions.length; i++) {
702 const hue = (i * 137.508) % 360; // Golden angle for good distribution
703 junctionColors.push(new THREE.Color().setHSL(hue / 360, 0.8, 0.55));
704 }
705
706 function updateConnections(stage) {
707 const numConnections = stage.connections.length;
708
709 if (numConnections === 0) {
710 // No connections yet - make all junctions small and hide all cylinders
711 junctionMeshes.forEach(mesh => {
712 mesh.scale.set(0.33, 0.33, 0.33);
713 mesh.material = new THREE.MeshPhongMaterial({
714 color: 0x89b4fa,
715 emissive: 0x89b4fa,
716 emissiveIntensity: 0.2
717 });
718 });
719 connectionCylinders.forEach(cyl => cyl.visible = false);
720 return;
721 }
722
723 // Color junctions by circuit - use lowest junction index as stable color
724 const uf = new UnionFind(junctions.length);
725 stage.connections.forEach(conn => uf.union(conn.i, conn.j));
726
727 // Map each root to the lowest junction index in that circuit for stable colors
728 const circuitColors = new Map();
729 for (let i = 0; i < junctions.length; i++) {
730 const root = uf.find(i);
731 if (!circuitColors.has(root)) {
732 // Find the lowest index in this circuit
733 let lowestInCircuit = i;
734 for (let j = 0; j < i; j++) {
735 if (uf.find(j) === root) {
736 lowestInCircuit = j;
737 break;
738 }
739 }
740 circuitColors.set(root, junctionColors[lowestInCircuit]);
741 }
742 }
743
744 // Track which junctions are connected
745 const connectedJunctions = new Set();
746 stage.connections.forEach(conn => {
747 connectedJunctions.add(conn.i);
748 connectedJunctions.add(conn.j);
749 });
750
751 junctionMeshes.forEach((mesh, idx) => {
752 const root = uf.find(idx);
753 const color = circuitColors.get(root);
754
755 mesh.material = new THREE.MeshPhongMaterial({
756 color: color,
757 emissive: color,
758 emissiveIntensity: 0.4
759 });
760
761 // Make unconnected junctions smaller
762 if (connectedJunctions.has(idx)) {
763 mesh.scale.set(1, 1, 1);
764 } else {
765 mesh.scale.set(0.33, 0.33, 0.33);
766 }
767 });
768
769 // Show/hide cylinders and update their colors based on current stage
770 connectionCylinders.forEach((cylinder, idx) => {
771 if (idx < numConnections) {
772 cylinder.visible = true;
773 // Update color to match circuit
774 const root = uf.find(cylinder.userData.i);
775 const lineColor = circuitColors.get(root);
776 cylinder.material.color = lineColor;
777 } else {
778 cylinder.visible = false;
779 }
780 });
781 }
782
783 // Animation state
784 let currentStage = 0;
785 let isPlaying = false;
786 let lastTime = 0;
787
788 const playBtn = document.getElementById('play');
789 const prevBtn = document.getElementById('prev');
790 const nextBtn = document.getElementById('next');
791 const resetBtn = document.getElementById('reset');
792 const timelineSlider = document.getElementById('timeline');
793 const timelineStep = document.getElementById('timelineStep');
794 const statsInfo = document.getElementById('statsInfo');
795
796 function updateUI() {
797 const stage = stages[currentStage];
798 timelineStep.textContent = \`Step \${currentStage} of \${stages.length - 1}\`;
799
800 const part1Result = stages[Math.min(1000, stages.length - 1)].product;
801 const part2Result = stage.part2Product || 0;
802
803 statsInfo.textContent = \`Circuits: \${stage.circuits} | Largest: \${stage.largest} | Part 1: \${part1Result.toLocaleString()} | Part 2: \${part2Result.toLocaleString()}\`;
804
805 prevBtn.disabled = currentStage === 0;
806 nextBtn.disabled = currentStage === stages.length - 1;
807
808 // Update timeline slider and gradient
809 timelineSlider.value = currentStage;
810 const percent = (currentStage / (stages.length - 1)) * 100;
811 timelineSlider.style.background = \`linear-gradient(to right, #a6e3a1 0%, #a6e3a1 \${percent}%, #313244 \${percent}%, #313244 100%)\`;
812
813 updateConnections(stage);
814 }
815
816 function goToStage(index) {
817 currentStage = Math.max(0, Math.min(stages.length - 1, index));
818 updateUI();
819 }
820
821 prevBtn.addEventListener('click', () => goToStage(currentStage - 1));
822 nextBtn.addEventListener('click', () => goToStage(currentStage + 1));
823 resetBtn.addEventListener('click', () => goToStage(0));
824
825 // Timeline slider scrubbing
826 timelineSlider.addEventListener('input', (e) => {
827 goToStage(parseInt(e.target.value));
828 });
829
830 playBtn.addEventListener('click', () => {
831 isPlaying = !isPlaying;
832 playBtn.textContent = isPlaying ? '⏸ Pause' : '▶ Play';
833 if (isPlaying && currentStage === stages.length - 1) {
834 goToStage(0);
835 }
836 });
837
838 document.addEventListener('keydown', (e) => {
839 if (e.key === 'ArrowLeft') prevBtn.click();
840 if (e.key === 'ArrowRight') nextBtn.click();
841 if (e.key === ' ') {
842 e.preventDefault();
843 playBtn.click();
844 }
845 if (e.key === 'r' || e.key === 'R') resetBtn.click();
846 });
847
848 window.addEventListener('resize', () => {
849 camera.aspect = window.innerWidth / window.innerHeight;
850 camera.updateProjectionMatrix();
851 renderer.setSize(window.innerWidth, window.innerHeight);
852 });
853
854 // Animation loop
855 function animate(time) {
856 requestAnimationFrame(animate);
857
858 // Auto-advance if playing (zero delay - advance every frame)
859 if (isPlaying) {
860 if (currentStage < stages.length - 1) {
861 goToStage(currentStage + 1);
862 } else {
863 isPlaying = false;
864 playBtn.textContent = '▶ Play';
865 }
866 }
867
868 controls.update();
869 renderer.render(scene, camera);
870 }
871
872 // Initialize - start at step 0
873 currentStage = 0;
874 updateUI();
875 animate(0);
876 </script>
877</body>
878</html>`;
879
880await Bun.write(`${scriptDir}/index.html`, html);
881console.log(`Generated index.html with ${junctions.length} junction boxes`);