advent of code 2025 in ts and nix
at main 23 kB view raw
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`);