the home site for me: also iteration 3 or 4 of my site
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add rebuilt calculator

+1324
+49
content/blog/2026-01-11_frc-rebuilt-calculator.md
··· 1 + +++ 2 + title = "FRC REBUILT Points Calculator" 3 + date = 2026-01-11 4 + slug = "frc-rebuilt-calculator" 5 + description = "Interactive calculator for the 2026 FRC game REBUILT" 6 + 7 + [taxonomies] 8 + tags = ["frc", "robotics", "calculator"] 9 + 10 + [extra] 11 + has_toc = false 12 + +++ 13 + 14 + I was manually doing bps calculations yesterday at kickoff and figured there must be a better way so here you go :) 15 + 16 + <!-- more --> 17 + 18 + ### Match Timeline 19 + 20 + A match lasts **2 minutes and 40 seconds** (160 seconds total): 21 + 22 + | Period | Duration | Hub Status | 23 + |--------|----------|------------| 24 + | **Autonomous** | 20s | Both Hubs Active | 25 + | **Transition Shift** | 10s | Both Hubs Active | 26 + | **Shift 1** | 25s | One Active / One Inactive | 27 + | **Shift 2** | 25s | One Active / One Inactive | 28 + | **Shift 3** | 25s | One Active / One Inactive | 29 + | **Shift 4** | 25s | One Active / One Inactive | 30 + | **End Game** | 30s | Both Hubs Active | 31 + 32 + Winning autonomous affects your Hub status so if your alliance scores the most Fuel in AUTO, your Hub is **Inactive** for Shifts 1 & 3, and **Active** for Shifts 2 & 4. 33 + 34 + ### Ranking points 35 + 36 + For Regional/District events: 37 + - **Energized RP:** Score **100 Fuel** (1 RP) 38 + - **Supercharged RP:** Score **360 Fuel** (1 RP) 39 + - **Traversal RP:** Earn **50 Tower points** (1 RP) 40 + 41 + ### BPS calculator 42 + 43 + Use this calculator to determine the balls per second (BPS) your robot needs to achieve ranking point thresholds. Adjust parameters based on your robot's capabilities and strategy. 44 + 45 + The max bps is only used in the simulation of the match while the results section is doing back propagation to figure out the necessary BPS needed to hit that ranking point no matter how high that is. If one of the results says "N/A" that means that your reload time is too high and eats up enough shooting time its no longer possible to hit that ranking point threshold. 46 + 47 + {{ frcRebuilt() }} 48 + 49 + Hopefully this can help your team! May your BPS be ever optimal.
+1275
templates/shortcodes/frcRebuilt.html
··· 1 + <div id="frc-calculator" class="frc-calculator"> 2 + <div class="frc-calculator-inner"> 3 + <div class="calc-section"> 4 + <h3>Robot Parameters</h3> 5 + 6 + <div class="input-group"> 7 + <label for="ballCapacity">Ball Capacity:</label> 8 + <input type="number" id="ballCapacity" value="8" min="1" max="10"> 9 + <span class="unit">balls</span> 10 + </div> 11 + 12 + <div class="input-group"> 13 + <label for="reloadTime">Reload Time:</label> 14 + <input type="number" id="reloadTime" value="3" min="0" step="0.5"> 15 + <span class="unit">seconds</span> 16 + </div> 17 + 18 + <div class="input-group"> 19 + <label for="shooterBPS">Shooter BPS (max):</label> 20 + <input type="number" id="shooterBPS" value="4" min="0.1" step="0.1"> 21 + <span class="unit">balls/s</span> 22 + </div> 23 + 24 + <div class="input-group"> 25 + <label for="numRobots">Number of Robots:</label> 26 + <input type="number" id="numRobots" value="3" min="1" max="3"> 27 + <span class="unit">robots</span> 28 + </div> 29 + 30 + <div class="input-group"> 31 + <label for="graceTime">Hub Grace Period:</label> 32 + <input type="number" id="graceTime" value="3" min="0" max="10" step="0.5"> 33 + <span class="unit">seconds</span> 34 + </div> 35 + </div> 36 + 37 + <div class="calc-section"> 38 + <h3>Match Strategy</h3> 39 + 40 + <div class="input-group checkbox-group"> 41 + <input type="checkbox" id="includeAuto" checked> 42 + <label for="includeAuto">Include Autonomous Period</label> 43 + </div> 44 + 45 + <div class="input-group indent-group" id="autoTimeGroup"> 46 + <label for="autoShootTime">Auto Shooting Time:</label> 47 + <input type="number" id="autoShootTime" value="20" min="0" max="20" step="0.5"> 48 + <span class="unit">seconds</span> 49 + </div> 50 + 51 + <div class="input-group checkbox-group"> 52 + <input type="checkbox" id="includeEndgame" checked> 53 + <label for="includeEndgame">Include Endgame Period</label> 54 + </div> 55 + 56 + <div class="input-group indent-group" id="endgameTimeGroup"> 57 + <label for="endgameShootTime">Endgame Shooting Time:</label> 58 + <input type="number" id="endgameShootTime" value="30" min="0" max="30" step="0.5"> 59 + <span class="unit">seconds</span> 60 + </div> 61 + 62 + <div class="input-group checkbox-group"> 63 + <input type="checkbox" id="wonAuto"> 64 + <label for="wonAuto">Won Autonomous Period</label> 65 + </div> 66 + </div> 67 + 68 + <div class="calc-section"> 69 + <h3>Ranking Point Thresholds</h3> 70 + 71 + <div class="input-group"> 72 + <label for="energizedThreshold">Energized Threshold:</label> 73 + <input type="number" id="energizedThreshold" value="100" min="1" max="500"> 74 + <span class="unit">fuel</span> 75 + </div> 76 + 77 + <div class="input-group"> 78 + <label for="superchargedThreshold">Supercharged Threshold:</label> 79 + <input type="number" id="superchargedThreshold" value="360" min="1" max="1000"> 80 + <span class="unit">fuel</span> 81 + </div> 82 + </div> 83 + 84 + <div class="calc-section animation"> 85 + <h3>Match Simulation</h3> 86 + 87 + <div class="simulation-container"> 88 + <div class="simulation-header"> 89 + <div class="sim-stat"> 90 + <span class="sim-label">Time:</span> 91 + <span class="sim-value" id="simTime">0.0s</span> 92 + </div> 93 + <div class="sim-stat"> 94 + <span class="sim-label">Balls in Hopper:</span> 95 + <span class="sim-value" id="simBalls">0</span> 96 + </div> 97 + <div class="sim-stat"> 98 + <span class="sim-label">Total Scored:</span> 99 + <span class="sim-value" id="simScored">0</span> 100 + </div> 101 + <div class="sim-stat"> 102 + <span class="sim-label">Status:</span> 103 + <span class="sim-value" id="simStatus">Ready</span> 104 + </div> 105 + </div> 106 + 107 + <canvas id="matchCanvas" width="800" height="200"></canvas> 108 + 109 + <div class="timeline-container"> 110 + <div class="timeline-labels" id="timelineLabels"></div> 111 + <input type="range" id="timelineScrubber" min="0" max="168" value="0" step="0.1" class="timeline-scrubber"> 112 + </div> 113 + 114 + <div class="simulation-controls"> 115 + <button id="playPauseBtn" class="sim-button">Play</button> 116 + <button id="resetBtn" class="sim-button">Reset</button> 117 + <div class="speed-controls"> 118 + <label for="speedSelect">Speed:</label> 119 + <select id="speedSelect"> 120 + <option value="0.5">0.5x</option> 121 + <option value="1" selected>1x</option> 122 + <option value="2">2x</option> 123 + <option value="4">4x</option> 124 + <option value="8">8x</option> 125 + </select> 126 + </div> 127 + </div> 128 + </div> 129 + </div> 130 + 131 + <div class="calc-section results"> 132 + <h3>Results</h3> 133 + 134 + <div class="result-box"> 135 + <div class="result-label">Total Active Scoring Time:</div> 136 + <div class="result-value" id="totalTime">-</div> 137 + </div> 138 + 139 + <div class="result-box"> 140 + <div class="result-label">Effective Cycle Time:</div> 141 + <div class="result-value" id="cycleTime">-</div> 142 + </div> 143 + 144 + <div class="result-box highlight"> 145 + <div class="result-label">Alliance BPS for Energized RP:</div> 146 + <div class="result-value" id="energizedAllianceBPS">-</div> 147 + </div> 148 + 149 + <div class="result-box"> 150 + <div class="result-label">Per Robot BPS for Energized RP:</div> 151 + <div class="result-value" id="energizedRobotBPS">-</div> 152 + </div> 153 + 154 + <div class="result-box highlight"> 155 + <div class="result-label">Alliance BPS for Supercharged RP:</div> 156 + <div class="result-value" id="superchargedAllianceBPS">-</div> 157 + </div> 158 + 159 + <div class="result-box"> 160 + <div class="result-label">Per Robot BPS for Supercharged RP:</div> 161 + <div class="result-value" id="superchargedRobotBPS">-</div> 162 + </div> 163 + </div> 164 + </div> 165 + </div> 166 + 167 + <style> 168 + .frc-calculator { 169 + background-color: var(--accent); 170 + border-bottom: 5px solid var(--bg-light); 171 + border-radius: 7px 7px 10px 10px; 172 + padding: 0.75rem; 173 + margin: 2rem 0; 174 + } 175 + 176 + .frc-calculator-inner { 177 + background-color: var(--nightshade-violet); 178 + border-radius: 0.3rem; 179 + padding: 1rem; 180 + } 181 + 182 + .calc-section { 183 + margin-bottom: 1.5rem; 184 + } 185 + 186 + .calc-section:last-child { 187 + margin-bottom: 0; 188 + } 189 + 190 + .calc-section h3 { 191 + margin: 0 0 1rem 0; 192 + padding: 0.22em 0.4em 0.22em 0.4em; 193 + font-size: 1.25rem; 194 + background-color: var(--accent); 195 + border-bottom: 5px solid var(--bg-light); 196 + border-radius: 0.2em 0.2em 0.27em 0.27em; 197 + color: var(--accent-text); 198 + width: fit-content; 199 + } 200 + 201 + .input-group { 202 + display: flex; 203 + align-items: center; 204 + gap: 0.75rem; 205 + margin-bottom: 0.75rem; 206 + } 207 + 208 + .input-group label { 209 + flex: 1; 210 + font-weight: 500; 211 + color: var(--text); 212 + } 213 + 214 + .input-group input[type="number"] { 215 + width: 100px; 216 + padding: 0.5rem; 217 + border: 2px solid var(--ultra-violet); 218 + border-radius: var(--standard-border-radius); 219 + background-color: var(--bg); 220 + color: var(--text); 221 + font-size: 1rem; 222 + font-family: inherit; 223 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); 224 + transition: border-color 120ms ease, box-shadow 120ms ease; 225 + } 226 + 227 + .input-group input[type="number"]:focus { 228 + outline: none; 229 + border-color: var(--rose-quartz); 230 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); 231 + } 232 + 233 + .input-group .unit { 234 + color: var(--text-light); 235 + font-size: 0.875rem; 236 + min-width: 60px; 237 + } 238 + 239 + .checkbox-group { 240 + margin-bottom: 0.5rem; 241 + } 242 + 243 + .checkbox-group input[type="checkbox"] { 244 + vertical-align: middle; 245 + position: relative; 246 + width: 16px; 247 + height: 16px; 248 + cursor: pointer; 249 + margin: 0; 250 + margin-right: 0.5rem; 251 + border: 2px solid var(--ultra-violet); 252 + border-radius: var(--standard-border-radius); 253 + background-color: var(--bg); 254 + transition: all 120ms ease; 255 + } 256 + 257 + .checkbox-group input[type="checkbox"]:checked { 258 + background-color: var(--rose-quartz); 259 + border-color: var(--rose-quartz); 260 + } 261 + 262 + .checkbox-group input[type="checkbox"]:hover { 263 + border-color: var(--pink-puree); 264 + } 265 + 266 + .checkbox-group label { 267 + cursor: pointer; 268 + user-select: none; 269 + display: inline-block; 270 + } 271 + 272 + .indent-group { 273 + margin-left: 2rem; 274 + margin-bottom: 1rem; 275 + } 276 + 277 + .results { 278 + background-color: var(--purple-night); 279 + padding: 1rem; 280 + border-radius: 0.3rem; 281 + border: 2px solid var(--ultra-violet); 282 + } 283 + 284 + .result-box { 285 + display: flex; 286 + justify-content: space-between; 287 + align-items: center; 288 + padding: 0.75rem; 289 + margin-bottom: 0.5rem; 290 + background-color: var(--bg); 291 + border-radius: var(--standard-border-radius); 292 + } 293 + 294 + .result-box:last-child { 295 + margin-bottom: 0; 296 + } 297 + 298 + .result-box.highlight { 299 + background-color: var(--ultra-violet); 300 + border: 1px solid var(--rose-quartz); 301 + } 302 + 303 + .result-label { 304 + font-weight: 500; 305 + color: var(--text); 306 + } 307 + 308 + .result-value { 309 + font-weight: 700; 310 + font-size: 1.125rem; 311 + color: var(--pink-puree); 312 + font-family: var(--mono-font); 313 + } 314 + 315 + @media (max-width: 640px) { 316 + .input-group { 317 + flex-direction: column; 318 + align-items: flex-start; 319 + } 320 + 321 + .input-group label { 322 + margin-bottom: 0.25rem; 323 + } 324 + 325 + .input-group input[type="number"] { 326 + width: 100%; 327 + } 328 + 329 + .result-box { 330 + flex-direction: column; 331 + align-items: flex-start; 332 + gap: 0.5rem; 333 + } 334 + 335 + #matchCanvas { 336 + width: 100%; 337 + height: auto; 338 + } 339 + } 340 + 341 + .simulation-container { 342 + margin-top: 1rem; 343 + } 344 + 345 + .simulation-header { 346 + display: grid; 347 + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 348 + gap: 0.75rem; 349 + margin-bottom: 1rem; 350 + padding: 0.75rem; 351 + background-color: var(--bg); 352 + border-radius: var(--standard-border-radius); 353 + border: 2px solid var(--ultra-violet); 354 + } 355 + 356 + .sim-stat { 357 + display: flex; 358 + flex-direction: column; 359 + gap: 0.25rem; 360 + } 361 + 362 + .sim-label { 363 + font-size: 0.75rem; 364 + color: var(--text-light); 365 + font-weight: 600; 366 + } 367 + 368 + .sim-value { 369 + font-size: 1rem; 370 + color: var(--pink-puree); 371 + font-family: var(--mono-font); 372 + font-weight: 700; 373 + } 374 + 375 + #matchCanvas { 376 + width: 100%; 377 + height: 200px; 378 + background-color: var(--nightshade-violet); 379 + border-radius: var(--standard-border-radius); 380 + border: 2px solid var(--ultra-violet); 381 + display: block; 382 + } 383 + 384 + .timeline-container { 385 + margin-top: 1rem; 386 + position: relative; 387 + } 388 + 389 + .timeline-labels { 390 + display: flex; 391 + justify-content: space-between; 392 + margin-bottom: 0.5rem; 393 + font-size: 0.75rem; 394 + color: var(--text-light); 395 + padding: 0 0.5rem; 396 + } 397 + 398 + .timeline-scrubber { 399 + width: 100%; 400 + height: 8px; 401 + border-radius: 4px; 402 + background: var(--bg); 403 + border: 2px solid var(--ultra-violet); 404 + outline: none; 405 + cursor: pointer; 406 + -webkit-appearance: none; 407 + appearance: none; 408 + } 409 + 410 + .timeline-scrubber::-webkit-slider-thumb { 411 + -webkit-appearance: none; 412 + appearance: none; 413 + width: 16px; 414 + height: 16px; 415 + border-radius: 50%; 416 + background: var(--rose-quartz); 417 + cursor: pointer; 418 + border: 2px solid var(--pink-puree); 419 + } 420 + 421 + .timeline-scrubber::-moz-range-thumb { 422 + width: 16px; 423 + height: 16px; 424 + border-radius: 50%; 425 + background: var(--rose-quartz); 426 + cursor: pointer; 427 + border: 2px solid var(--pink-puree); 428 + } 429 + 430 + .simulation-controls { 431 + display: flex; 432 + gap: 0.75rem; 433 + align-items: center; 434 + margin-top: 1rem; 435 + flex-wrap: wrap; 436 + } 437 + 438 + .sim-button { 439 + padding: 0.5rem 1rem; 440 + background-color: var(--accent); 441 + color: var(--accent-text); 442 + border: 2px solid var(--ultra-violet); 443 + border-radius: var(--standard-border-radius); 444 + font-weight: 600; 445 + cursor: pointer; 446 + transition: all 120ms ease; 447 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); 448 + } 449 + 450 + .sim-button:hover { 451 + background-color: var(--rose-quartz); 452 + border-color: var(--pink-puree); 453 + } 454 + 455 + .sim-button:active { 456 + transform: translateY(1px); 457 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); 458 + } 459 + 460 + .speed-controls { 461 + display: flex; 462 + align-items: center; 463 + gap: 0.5rem; 464 + margin-left: auto; 465 + } 466 + 467 + .speed-controls label { 468 + font-size: 0.875rem; 469 + color: var(--text); 470 + font-weight: 600; 471 + } 472 + 473 + .speed-controls select { 474 + padding: 0.5rem; 475 + background-color: var(--bg); 476 + color: var(--text); 477 + border: 2px solid var(--ultra-violet); 478 + border-radius: var(--standard-border-radius); 479 + font-family: inherit; 480 + cursor: pointer; 481 + } 482 + </style> 483 + 484 + <script> 485 + (function() { 486 + // Wait for DOM to be ready 487 + if (document.readyState === 'loading') { 488 + document.addEventListener('DOMContentLoaded', initCalculator); 489 + } else { 490 + initCalculator(); 491 + } 492 + 493 + function initCalculator() { 494 + const calculator = document.getElementById("frc-calculator"); 495 + if (!calculator) return; 496 + 497 + // Get all input elements 498 + const inputs = { 499 + ballCapacity: document.getElementById("ballCapacity"), 500 + reloadTime: document.getElementById("reloadTime"), 501 + shooterBPS: document.getElementById("shooterBPS"), 502 + numRobots: document.getElementById("numRobots"), 503 + graceTime: document.getElementById("graceTime"), 504 + includeAuto: document.getElementById("includeAuto"), 505 + autoShootTime: document.getElementById("autoShootTime"), 506 + includeEndgame: document.getElementById("includeEndgame"), 507 + endgameShootTime: document.getElementById("endgameShootTime"), 508 + wonAuto: document.getElementById("wonAuto"), 509 + energizedThreshold: document.getElementById("energizedThreshold"), 510 + superchargedThreshold: document.getElementById("superchargedThreshold"), 511 + }; 512 + 513 + // Get all result elements 514 + const results = { 515 + totalTime: document.getElementById("totalTime"), 516 + cycleTime: document.getElementById("cycleTime"), 517 + energizedAllianceBPS: document.getElementById("energizedAllianceBPS"), 518 + energizedRobotBPS: document.getElementById("energizedRobotBPS"), 519 + superchargedAllianceBPS: document.getElementById("superchargedAllianceBPS"), 520 + superchargedRobotBPS: document.getElementById("superchargedRobotBPS"), 521 + }; 522 + 523 + // Toggle visibility of conditional inputs 524 + const autoTimeGroup = document.getElementById("autoTimeGroup"); 525 + const endgameTimeGroup = document.getElementById("endgameTimeGroup"); 526 + 527 + inputs.includeAuto.addEventListener("change", () => { 528 + autoTimeGroup.style.display = inputs.includeAuto.checked ? "flex" : "none"; 529 + calculate(); 530 + }); 531 + 532 + inputs.includeEndgame.addEventListener("change", () => { 533 + endgameTimeGroup.style.display = inputs.includeEndgame.checked ? "flex" : "none"; 534 + calculate(); 535 + }); 536 + 537 + // Add event listeners to all inputs 538 + Object.values(inputs).forEach((input) => { 539 + input.addEventListener("input", calculate); 540 + input.addEventListener("change", calculate); 541 + }); 542 + 543 + function calculate() { 544 + // Get input values 545 + const ballCapacity = parseFloat(inputs.ballCapacity.value) || 0; 546 + const reloadTime = parseFloat(inputs.reloadTime.value) || 0; 547 + const shooterBPS = parseFloat(inputs.shooterBPS.value) || 4; 548 + const numRobots = parseFloat(inputs.numRobots.value) || 1; 549 + const includeAuto = inputs.includeAuto.checked; 550 + const autoShootTime = parseFloat(inputs.autoShootTime.value) || 0; 551 + const includeEndgame = inputs.includeEndgame.checked; 552 + const endgameShootTime = parseFloat(inputs.endgameShootTime.value) || 0; 553 + const wonAuto = inputs.wonAuto.checked; 554 + const energizedThreshold = parseFloat(inputs.energizedThreshold.value) || 0; 555 + const superchargedThreshold = parseFloat(inputs.superchargedThreshold.value) || 0; 556 + 557 + // Calculate total active scoring time 558 + let totalTime = 0; 559 + 560 + // Auto period (if included) 561 + if (includeAuto) { 562 + totalTime += autoShootTime; 563 + } 564 + 565 + // Transition period (always active, now 10s) 566 + totalTime += 10; 567 + 568 + // Shifts - 2 active shifts of 25s each 569 + // If won auto: Shifts 2 & 4 are active 570 + // If lost auto: Shifts 1 & 3 are active 571 + totalTime += 50; // 2 shifts × 25s 572 + 573 + // Endgame period (if included, now 30s) 574 + if (includeEndgame) { 575 + totalTime += endgameShootTime; 576 + } 577 + 578 + // Calculate free reloads during inactive hub time 579 + // There are 58 seconds of inactive time (8s break + 2 shifts × 25s) 580 + // This is the ONLY time we can reload without losing active scoring time 581 + const inactiveTime = 58; 582 + const freeReloads = reloadTime > 0 ? Math.floor(inactiveTime / reloadTime) : 0; 583 + 584 + // BPS calculation accounting for reload time during auto/endgame 585 + function calculateBPSForThreshold(threshold) { 586 + if (totalTime <= 0 || threshold <= 0 || ballCapacity <= 0) return 0; 587 + 588 + // Calculate cycles needed to reach threshold 589 + const cyclesNeeded = Math.ceil(threshold / ballCapacity); 590 + const reloadsNeeded = Math.max(0, cyclesNeeded - 1); 591 + 592 + // Reloads during inactive time are free 593 + // Remaining reloads consume active time 594 + const paidReloads = Math.max(0, reloadsNeeded - freeReloads); 595 + 596 + // Available shooting time = total active time - paid reload time 597 + const shootingTime = totalTime - (paidReloads * reloadTime); 598 + 599 + // BPS = threshold / available shooting time 600 + return shootingTime > 0 ? threshold / shootingTime : 0; 601 + } 602 + 603 + // Calculate max balls achievable by simulating full match 604 + function calculateMaxBalls() { 605 + if (shooterBPS <= 0 || ballCapacity <= 0) return 0; 606 + 607 + // Run simulation to completion (t=168s) with current settings 608 + let totalScored = 0; 609 + let cyclePosition = 0; // 0 = ready to shoot, 1 = reloading 610 + let reloadProgress = 0; 611 + let ballsInHopper = Math.min(ballCapacity, 8); // Preload 612 + 613 + const includeAuto = inputs.includeAuto.checked; 614 + const autoShootTime = parseFloat(inputs.autoShootTime.value) || 0; 615 + const includeEndgame = inputs.includeEndgame.checked; 616 + const endgameShootTime = parseFloat(inputs.endgameShootTime.value) || 0; 617 + 618 + for (let periodIndex = 0; periodIndex < periods.length; periodIndex++) { 619 + const period = periods[periodIndex]; 620 + 621 + // Skip BREAK period 622 + if (period.name === "BREAK") continue; 623 + 624 + // Check if hub is active for this period 625 + let periodActive = period.active; 626 + if (period.name === "S1" || period.name === "S3") { 627 + periodActive = !wonAuto; 628 + } else if (period.name === "S2" || period.name === "S4") { 629 + periodActive = wonAuto; 630 + } 631 + 632 + // Determine effective time and if shooting is allowed 633 + const timeInPeriod = period.end - period.start; 634 + let effectiveTimeInPeriod = timeInPeriod; 635 + let canShootInPeriod = true; 636 + 637 + if (period.name === "AUTO") { 638 + if (!includeAuto) { 639 + canShootInPeriod = false; 640 + } else { 641 + effectiveTimeInPeriod = Math.min(timeInPeriod, autoShootTime); 642 + } 643 + } else if (period.name === "END") { 644 + if (!includeEndgame) { 645 + canShootInPeriod = false; 646 + } else { 647 + effectiveTimeInPeriod = Math.min(timeInPeriod, endgameShootTime); 648 + } 649 + } 650 + 651 + let timeRemaining = effectiveTimeInPeriod; 652 + 653 + // Check grace period eligibility 654 + let allowGraceShoot = false; 655 + if (!periodActive && periodIndex > 0 && period.name !== "BREAK") { 656 + const prevPeriod = periods[periodIndex - 1]; 657 + let prevActive = prevPeriod.active; 658 + if (prevPeriod.name === "S1" || prevPeriod.name === "S3") { 659 + prevActive = !wonAuto; 660 + } else if (prevPeriod.name === "S2" || prevPeriod.name === "S4") { 661 + prevActive = wonAuto; 662 + } 663 + if (prevActive) { 664 + allowGraceShoot = true; 665 + } 666 + } 667 + 668 + // Simulate this period 669 + let timeIntoPeriod = 0; 670 + while (timeRemaining > 0) { 671 + const canShootWithGrace = periodActive || (allowGraceShoot && timeIntoPeriod < graceTime); 672 + 673 + if (cyclePosition === 0) { 674 + // Ready to shoot 675 + if (canShootWithGrace && canShootInPeriod) { 676 + const ballsToShoot = ballsInHopper; 677 + const timeToShootAll = ballsToShoot / shooterBPS; 678 + const timeToShoot = Math.min(timeToShootAll, timeRemaining); 679 + 680 + const ballsActuallyShot = Math.floor(timeToShoot * shooterBPS); 681 + totalScored += ballsActuallyShot * numRobots; 682 + ballsInHopper -= ballsActuallyShot; 683 + timeRemaining -= timeToShoot; 684 + timeIntoPeriod += timeToShoot; 685 + 686 + if (ballsInHopper <= 0) { 687 + ballsInHopper = 0; 688 + cyclePosition = 1; 689 + reloadProgress = 0; 690 + } else { 691 + break; // Still have balls but out of time 692 + } 693 + } else { 694 + break; // Can't shoot, wait 695 + } 696 + } else if (cyclePosition === 1) { 697 + // Reloading 698 + const timeToReload = Math.min(reloadTime - reloadProgress, timeRemaining); 699 + timeRemaining -= timeToReload; 700 + timeIntoPeriod += timeToReload; 701 + reloadProgress += timeToReload; 702 + 703 + if (reloadProgress >= reloadTime) { 704 + ballsInHopper = ballCapacity; 705 + cyclePosition = 0; 706 + reloadProgress = 0; 707 + } else { 708 + break; // Still reloading 709 + } 710 + } 711 + } 712 + } 713 + 714 + return totalScored; 715 + } 716 + 717 + const energizedAllianceBPS = calculateBPSForThreshold(energizedThreshold); 718 + const superchargedAllianceBPS = calculateBPSForThreshold(superchargedThreshold); 719 + const maxBalls = calculateMaxBalls(); 720 + 721 + // Calculate per robot BPS 722 + const energizedRobotBPS = numRobots > 0 ? energizedAllianceBPS / numRobots : 0; 723 + const superchargedRobotBPS = numRobots > 0 ? superchargedAllianceBPS / numRobots : 0; 724 + 725 + // Cycle time is the time to shoot all balls in the hopper plus reload time 726 + // Based on physical shooter speed (not target BPS) 727 + const shootingTimePerCycle = ballCapacity > 0 && shooterBPS > 0 ? ballCapacity / shooterBPS : 0; 728 + const avgCycleTime = shootingTimePerCycle + reloadTime; 729 + 730 + // Update results 731 + results.totalTime.textContent = `${totalTime.toFixed(1)}s`; 732 + results.cycleTime.textContent = avgCycleTime > 0 ? `${avgCycleTime.toFixed(2)}s` : "N/A"; 733 + 734 + // Show BPS or "N/A (max X balls)" if unreachable 735 + if (energizedAllianceBPS > 0 && maxBalls >= energizedThreshold) { 736 + results.energizedAllianceBPS.textContent = `${energizedAllianceBPS.toFixed(2)} balls/s`; 737 + results.energizedRobotBPS.textContent = `${energizedRobotBPS.toFixed(2)} balls/s`; 738 + } else { 739 + results.energizedAllianceBPS.textContent = `N/A (max ${maxBalls} balls)`; 740 + results.energizedRobotBPS.textContent = `N/A (max ${maxBalls} balls)`; 741 + } 742 + 743 + if (superchargedAllianceBPS > 0 && maxBalls >= superchargedThreshold) { 744 + results.superchargedAllianceBPS.textContent = `${superchargedAllianceBPS.toFixed(2)} balls/s`; 745 + results.superchargedRobotBPS.textContent = `${superchargedRobotBPS.toFixed(2)} balls/s`; 746 + } else { 747 + results.superchargedAllianceBPS.textContent = `N/A (max ${maxBalls} balls)`; 748 + results.superchargedRobotBPS.textContent = `N/A (max ${maxBalls} balls)`; 749 + } 750 + } 751 + 752 + // Match Simulation 753 + const canvas = document.getElementById("matchCanvas"); 754 + const ctx = canvas ? canvas.getContext("2d") : null; 755 + const timelineScrubber = document.getElementById("timelineScrubber"); 756 + const playPauseBtn = document.getElementById("playPauseBtn"); 757 + const resetBtn = document.getElementById("resetBtn"); 758 + const speedSelect = document.getElementById("speedSelect"); 759 + const simTime = document.getElementById("simTime"); 760 + const simBalls = document.getElementById("simBalls"); 761 + const simScored = document.getElementById("simScored"); 762 + const simStatus = document.getElementById("simStatus"); 763 + const timelineLabels = document.getElementById("timelineLabels"); 764 + 765 + let isPlaying = false; 766 + let currentTime = 0; 767 + let animationFrame = null; 768 + let lastTimestamp = 0; 769 + 770 + // Match timeline periods 771 + const periods = [ 772 + { name: "AUTO", start: 0, end: 20, active: true }, 773 + { name: "BREAK", start: 20, end: 28, active: false }, 774 + { name: "TRANS", start: 28, end: 38, active: true }, 775 + { name: "S1", start: 38, end: 63, active: false }, 776 + { name: "S2", start: 63, end: 88, active: true }, 777 + { name: "S3", start: 88, end: 113, active: false }, 778 + { name: "S4", start: 113, end: 138, active: true }, 779 + { name: "END", start: 138, end: 168, active: true } 780 + ]; 781 + 782 + function setupTimeline() { 783 + if (!timelineLabels) return; 784 + 785 + timelineLabels.innerHTML = periods.map(p => `<span>${p.name}</span>`).join(''); 786 + } 787 + 788 + function drawCanvas() { 789 + if (!ctx || !canvas) return; 790 + 791 + const width = canvas.width; 792 + const height = canvas.height; 793 + const ballCapacity = parseFloat(inputs.ballCapacity.value) || 3; 794 + const reloadTime = parseFloat(inputs.reloadTime.value) || 5; 795 + const shooterBPS = parseFloat(inputs.shooterBPS.value) || 4; 796 + 797 + // Clear canvas 798 + ctx.fillStyle = '#1e1e2e'; // nightshade-violet 799 + ctx.fillRect(0, 0, width, height); 800 + 801 + // Determine current period 802 + const wonAuto = inputs.wonAuto.checked; 803 + let currentPeriod = periods.find(p => currentTime >= p.start && currentTime < p.end); 804 + if (!currentPeriod) currentPeriod = periods[periods.length - 1]; 805 + 806 + // Draw timeline periods 807 + periods.forEach(period => { 808 + const x = (period.start / 168) * width; 809 + const w = ((period.end - period.start) / 168) * width; 810 + 811 + // Determine if hub is active based on wonAuto 812 + let isActive = period.active; 813 + 814 + if (period.name === "S1" || period.name === "S3") { 815 + isActive = !wonAuto; 816 + } else if (period.name === "S2" || period.name === "S4") { 817 + isActive = wonAuto; 818 + } 819 + 820 + // Check if this is the current period 821 + const isCurrent = period === currentPeriod; 822 + 823 + // Background color - brighter if current period 824 + if (isCurrent) { 825 + ctx.fillStyle = isActive ? 'rgba(166, 218, 149, 0.4)' : 'rgba(237, 135, 150, 0.4)'; 826 + } else { 827 + ctx.fillStyle = isActive ? 'rgba(166, 218, 149, 0.15)' : 'rgba(237, 135, 150, 0.15)'; 828 + } 829 + ctx.fillRect(x, 0, w, height); 830 + 831 + // Draw period border - thicker if current 832 + ctx.strokeStyle = isCurrent ? '#8aadf4' : 'rgba(138, 173, 244, 0.3)'; 833 + ctx.lineWidth = isCurrent ? 2 : 1; 834 + ctx.strokeRect(x, 0, w, height); 835 + 836 + // Draw period label - bold if current 837 + ctx.fillStyle = '#cad3f5'; 838 + ctx.font = isCurrent ? 'bold 14px monospace' : '12px monospace'; 839 + ctx.textAlign = 'center'; 840 + ctx.fillText(period.name, x + w / 2, isCurrent ? 22 : 20); 841 + }); 842 + 843 + // Simulate robot state at current time 844 + let ballsInHopper = 0; 845 + let totalScored = 0; 846 + let status = "Idle"; 847 + let timeInCycle = 0; 848 + const numRobots = Math.min(3, parseFloat(inputs.numRobots.value) || 1); 849 + const graceTime = parseFloat(inputs.graceTime.value) || 0; 850 + 851 + // Helper function to check if we're in grace period after hub goes inactive 852 + function isInGracePeriod(time) { 853 + // Find which period we're in 854 + const currentPeriodIndex = periods.findIndex(p => time >= p.start && time < p.end); 855 + if (currentPeriodIndex === -1) return false; 856 + 857 + const currentPer = periods[currentPeriodIndex]; 858 + 859 + // Check if previous period was active and current is inactive 860 + if (currentPeriodIndex > 0) { 861 + const prevPeriod = periods[currentPeriodIndex - 1]; 862 + 863 + let prevActive = prevPeriod.active; 864 + let currActive = currentPer.active; 865 + 866 + if (prevPeriod.name === "S1" || prevPeriod.name === "S3") { 867 + prevActive = !wonAuto; 868 + } else if (prevPeriod.name === "S2" || prevPeriod.name === "S4") { 869 + prevActive = wonAuto; 870 + } 871 + 872 + if (currentPer.name === "S1" || currentPer.name === "S3") { 873 + currActive = !wonAuto; 874 + } else if (currentPer.name === "S2" || currentPer.name === "S4") { 875 + currActive = wonAuto; 876 + } 877 + 878 + // If we went from active to inactive 879 + if (prevActive && !currActive) { 880 + const timeSinceTransition = time - currentPer.start; 881 + return timeSinceTransition <= graceTime; 882 + } 883 + } 884 + return false; 885 + } 886 + 887 + // Check if hub is active (including grace period) 888 + let isHubActive = currentPeriod.active; 889 + if (currentPeriod.name === "S1" || currentPeriod.name === "S3") { 890 + isHubActive = !wonAuto; 891 + } else if (currentPeriod.name === "S2" || currentPeriod.name === "S4") { 892 + isHubActive = wonAuto; 893 + } 894 + 895 + // Check grace period 896 + if (!isHubActive && isInGracePeriod(currentTime)) { 897 + isHubActive = true; 898 + } 899 + 900 + if (shooterBPS > 0) { 901 + const shootingTimePerCycle = ballCapacity / shooterBPS; 902 + const includeAuto = inputs.includeAuto.checked; 903 + const autoShootTime = parseFloat(inputs.autoShootTime.value) || 0; 904 + const includeEndgame = inputs.includeEndgame.checked; 905 + const endgameShootTime = parseFloat(inputs.endgameShootTime.value) || 0; 906 + 907 + // Simulate through all periods up to current time 908 + let timeAccumulator = 0; 909 + let cyclePosition = 0; // 0 = ready to shoot, 1 = reloading 910 + let reloadProgress = 0; // Track partial reload progress (0 to reloadTime) 911 + 912 + // Start with preloaded balls (max 8) 913 + ballsInHopper = Math.min(ballCapacity, 8); 914 + 915 + for (let periodIndex = 0; periodIndex < periods.length; periodIndex++) { 916 + const period = periods[periodIndex]; 917 + if (currentTime < period.start) break; 918 + 919 + const periodEnd = Math.min(period.end, currentTime); 920 + const timeInPeriod = periodEnd - period.start; 921 + 922 + // Check if hub is active for this period 923 + let periodActive = period.active; 924 + if (period.name === "S1" || period.name === "S3") { 925 + periodActive = !wonAuto; 926 + } else if (period.name === "S2" || period.name === "S4") { 927 + periodActive = wonAuto; 928 + } 929 + 930 + // Limit time in period based on user settings for auto/endgame 931 + let effectiveTimeInPeriod = timeInPeriod; 932 + let canShootInPeriod = true; 933 + 934 + if (period.name === "AUTO") { 935 + if (!includeAuto) { 936 + canShootInPeriod = false; 937 + } else { 938 + // Limit to autoShootTime 939 + effectiveTimeInPeriod = Math.min(timeInPeriod, autoShootTime); 940 + } 941 + } else if (period.name === "END") { 942 + if (!includeEndgame) { 943 + canShootInPeriod = false; 944 + } else { 945 + // Limit to endgameShootTime 946 + effectiveTimeInPeriod = Math.min(timeInPeriod, endgameShootTime); 947 + } 948 + } else if (period.name === "BREAK") { 949 + // No activity during break - skip this period entirely 950 + if (currentTime >= period.start && currentTime <= periodEnd) { 951 + status = "Break"; 952 + } 953 + continue; // Skip to next period 954 + } 955 + 956 + let timeRemaining = effectiveTimeInPeriod; 957 + 958 + // Check if we should allow shooting in grace period 959 + let allowGraceShoot = false; 960 + if (!periodActive && periodIndex > 0 && period.name !== "BREAK") { 961 + const prevPeriod = periods[periodIndex - 1]; 962 + let prevActive = prevPeriod.active; 963 + if (prevPeriod.name === "S1" || prevPeriod.name === "S3") { 964 + prevActive = !wonAuto; 965 + } else if (prevPeriod.name === "S2" || prevPeriod.name === "S4") { 966 + prevActive = wonAuto; 967 + } 968 + 969 + // Previous period was active, current is inactive - allow grace period 970 + if (prevActive) { 971 + allowGraceShoot = true; 972 + } 973 + } 974 + 975 + while (timeRemaining > 0) { 976 + // Check if we're still in grace period 977 + const timeIntoPeriod = effectiveTimeInPeriod - timeRemaining; 978 + const canShootWithGrace = periodActive || (allowGraceShoot && timeIntoPeriod < graceTime); 979 + 980 + if (cyclePosition === 0) { 981 + // Ready to shoot (have balls loaded) 982 + if (canShootWithGrace && canShootInPeriod) { 983 + // Calculate how many balls we actually have to shoot 984 + const ballsToShoot = ballsInHopper; 985 + const timeToShootAll = ballsToShoot / shooterBPS; 986 + const timeToShoot = Math.min(timeToShootAll, timeRemaining); 987 + timeRemaining -= timeToShoot; 988 + 989 + const ballsActuallyShot = Math.floor(timeToShoot * shooterBPS); 990 + totalScored += ballsActuallyShot * numRobots; 991 + ballsInHopper -= ballsActuallyShot; 992 + 993 + if (ballsInHopper <= 0) { 994 + // Finished shooting all balls - start reload 995 + ballsInHopper = 0; 996 + cyclePosition = 1; 997 + reloadProgress = 0; 998 + 999 + if (currentTime >= period.start && currentTime <= periodEnd && timeRemaining === 0) { 1000 + status = "Reloading"; 1001 + } 1002 + } else { 1003 + // Still shooting 1004 + if (currentTime >= period.start && currentTime <= periodEnd && timeRemaining === 0) { 1005 + status = "Shooting"; 1006 + } 1007 + break; 1008 + } 1009 + } else { 1010 + // Hub inactive, can't shoot - maintain current balls 1011 + if (currentTime >= period.start && currentTime <= periodEnd) { 1012 + status = allowGraceShoot && timeIntoPeriod < graceTime ? "Grace Period" : "Idle"; 1013 + } 1014 + break; 1015 + } 1016 + } else if (cyclePosition === 1) { 1017 + // Reloading 1018 + const timeToReload = Math.min(reloadTime - reloadProgress, timeRemaining); 1019 + timeRemaining -= timeToReload; 1020 + reloadProgress += timeToReload; 1021 + 1022 + if (reloadProgress >= reloadTime) { 1023 + // Finished reloading 1024 + ballsInHopper = ballCapacity; 1025 + cyclePosition = 0; 1026 + reloadProgress = 0; 1027 + 1028 + if (currentTime >= period.start && currentTime <= periodEnd && timeRemaining === 0) { 1029 + status = canShootWithGrace ? "Ready" : "Idle"; 1030 + } 1031 + } else { 1032 + // Still reloading - show partial progress 1033 + if (currentTime >= period.start && currentTime <= periodEnd && timeRemaining === 0) { 1034 + const reloadPercent = reloadProgress / reloadTime; 1035 + ballsInHopper = Math.floor(ballCapacity * reloadPercent); 1036 + status = "Reloading"; 1037 + } 1038 + break; 1039 + } 1040 + } 1041 + } 1042 + 1043 + // Check if we're past the shooting time in auto/endgame 1044 + if (currentTime >= period.start && currentTime <= periodEnd) { 1045 + if (period.name === "AUTO" && includeAuto) { 1046 + const timeIntoPeriod = currentTime - period.start; 1047 + if (timeIntoPeriod >= autoShootTime) { 1048 + status = "Idle"; 1049 + // Keep current balls loaded 1050 + } 1051 + } else if (period.name === "END" && includeEndgame) { 1052 + const timeIntoPeriod = currentTime - period.start; 1053 + if (timeIntoPeriod >= endgameShootTime) { 1054 + status = "Idle"; 1055 + // Keep current balls loaded 1056 + } 1057 + } 1058 + } 1059 + } 1060 + } 1061 + 1062 + // Draw progress bar 1063 + const progressX = (currentTime / 168) * width; 1064 + ctx.strokeStyle = '#f5bde6'; 1065 + ctx.lineWidth = 3; 1066 + ctx.beginPath(); 1067 + ctx.moveTo(progressX, 0); 1068 + ctx.lineTo(progressX, height); 1069 + ctx.stroke(); 1070 + 1071 + // Draw robot visualizations (one per robot, max 3) 1072 + const robotWidth = 50; 1073 + const robotHeight = 40; 1074 + const robotSpacing = 80; 1075 + const startX = 40; 1076 + const robotY = height - 60; 1077 + 1078 + for (let robotIndex = 0; robotIndex < numRobots; robotIndex++) { 1079 + const robotX = startX + (robotIndex * robotSpacing); 1080 + 1081 + // Robot body - color based on status 1082 + let robotColor; 1083 + if (status === "Shooting") { 1084 + robotColor = '#a6da95'; // Green - shooting 1085 + } else if (status === "Reloading") { 1086 + robotColor = '#eed49f'; // Yellow - reloading 1087 + } else { 1088 + robotColor = '#ed8796'; // Red - idle/waiting 1089 + } 1090 + 1091 + ctx.fillStyle = robotColor; 1092 + ctx.fillRect(robotX, robotY, robotWidth, robotHeight); 1093 + ctx.strokeStyle = '#8aadf4'; 1094 + ctx.lineWidth = 2; 1095 + ctx.strokeRect(robotX, robotY, robotWidth, robotHeight); 1096 + 1097 + // Draw balls in hopper 1098 + if (status === "Reloading") { 1099 + // During reload, show balls filling up from bottom to top 1100 + // ballsInHopper already contains the partial reload count 1101 + if (ballCapacity <= 16) { 1102 + const ballRadius = 4; 1103 + const ballsPerRow = 4; 1104 + for (let i = 0; i < ballsInHopper; i++) { 1105 + const row = Math.floor(i / ballsPerRow); 1106 + const col = i % ballsPerRow; 1107 + const ballX = robotX + 6 + col * 10; 1108 + const ballY = robotY + robotHeight - 6 - row * 10; 1109 + 1110 + ctx.fillStyle = '#f5a97f'; 1111 + ctx.beginPath(); 1112 + ctx.arc(ballX, ballY, ballRadius, 0, Math.PI * 2); 1113 + ctx.fill(); 1114 + } 1115 + } else { 1116 + // For large capacities, show count and progress 1117 + const reloadProgress = ballsInHopper / ballCapacity; 1118 + ctx.fillStyle = '#f5a97f'; 1119 + ctx.font = 'bold 20px monospace'; 1120 + ctx.textAlign = 'center'; 1121 + ctx.fillText(ballsInHopper, robotX + 25, robotY + 25); 1122 + 1123 + // Progress bar 1124 + ctx.fillStyle = 'rgba(245, 169, 127, 0.3)'; 1125 + ctx.fillRect(robotX + 5, robotY + robotHeight - 8, robotWidth - 10, 4); 1126 + ctx.fillStyle = '#f5a97f'; 1127 + ctx.fillRect(robotX + 5, robotY + robotHeight - 8, (robotWidth - 10) * reloadProgress, 4); 1128 + } 1129 + } else if (ballCapacity <= 16) { 1130 + // Draw individual balls for small capacities (up to 16) 1131 + const ballRadius = 4; 1132 + const ballsPerRow = 4; 1133 + for (let i = 0; i < ballsInHopper; i++) { 1134 + const row = Math.floor(i / ballsPerRow); 1135 + const col = i % ballsPerRow; 1136 + const ballX = robotX + 6 + col * 10; 1137 + const ballY = robotY + 6 + row * 10; 1138 + 1139 + ctx.fillStyle = '#f5a97f'; 1140 + ctx.beginPath(); 1141 + ctx.arc(ballX, ballY, ballRadius, 0, Math.PI * 2); 1142 + ctx.fill(); 1143 + } 1144 + } else { 1145 + // For large capacities, just show the count 1146 + ctx.fillStyle = '#f5a97f'; 1147 + ctx.font = 'bold 24px monospace'; 1148 + ctx.textAlign = 'center'; 1149 + ctx.fillText(ballsInHopper, robotX + 25, robotY + 28); 1150 + } 1151 + 1152 + // Robot number label 1153 + ctx.fillStyle = '#8aadf4'; 1154 + ctx.font = 'bold 10px monospace'; 1155 + ctx.textAlign = 'center'; 1156 + ctx.fillText(`R${robotIndex + 1}`, robotX + 25, robotY - 4); 1157 + } 1158 + 1159 + // Status text (shared for all robots) 1160 + const statusX = startX + (numRobots * robotSpacing); 1161 + ctx.fillStyle = '#cad3f5'; 1162 + ctx.font = 'bold 14px monospace'; 1163 + ctx.textAlign = 'left'; 1164 + ctx.fillText(status, statusX, robotY + 20); 1165 + ctx.fillText(`Balls: ${ballsInHopper}/${ballCapacity}`, statusX, robotY + 40); 1166 + 1167 + // Update stats 1168 + // Timer display logic: countdown in AUTO, pause at 0 during BREAK, count up in teleop 1169 + // Match time is 160s (game time) but simulation runs 168s (includes 8s break) 1170 + let displayTime; 1171 + let matchTime; // Actual match time shown to drivers 1172 + 1173 + if (currentTime <= 20) { 1174 + // AUTO period: countdown from 15 to 0 1175 + matchTime = 20 - currentTime; 1176 + displayTime = matchTime.toFixed(1); 1177 + } else if (currentTime <= 28) { 1178 + // BREAK period: stays at 0 (doesn't count toward match time) 1179 + matchTime = 0; 1180 + displayTime = "0.0"; 1181 + } else { 1182 + // Teleop: count up from 0 to 140 (subtract the 8s break) 1183 + matchTime = currentTime - 8; // Remove the break time 1184 + displayTime = (matchTime - 20).toFixed(1); // Show time since auto ended 1185 + } 1186 + 1187 + if (simTime) simTime.textContent = `${displayTime}s`; 1188 + if (simBalls) simBalls.textContent = ballsInHopper; 1189 + if (simScored) simScored.textContent = totalScored; 1190 + if (simStatus) simStatus.textContent = status; 1191 + } 1192 + 1193 + function animate(timestamp) { 1194 + if (!isPlaying) return; 1195 + 1196 + if (lastTimestamp === 0) lastTimestamp = timestamp; 1197 + const deltaTime = (timestamp - lastTimestamp) / 1000; // Convert to seconds 1198 + lastTimestamp = timestamp; 1199 + 1200 + const speed = parseFloat(speedSelect.value) || 1; 1201 + currentTime += deltaTime * speed; 1202 + 1203 + if (currentTime >= 168) { 1204 + currentTime = 168; 1205 + pause(); 1206 + } 1207 + 1208 + if (timelineScrubber) timelineScrubber.value = currentTime; 1209 + drawCanvas(); 1210 + 1211 + if (isPlaying) { 1212 + animationFrame = requestAnimationFrame(animate); 1213 + } 1214 + } 1215 + 1216 + function play() { 1217 + if (currentTime >= 160) currentTime = 0; 1218 + isPlaying = true; 1219 + lastTimestamp = 0; 1220 + if (playPauseBtn) playPauseBtn.textContent = "Pause"; 1221 + animationFrame = requestAnimationFrame(animate); 1222 + } 1223 + 1224 + function pause() { 1225 + isPlaying = false; 1226 + if (playPauseBtn) playPauseBtn.textContent = "Play"; 1227 + if (animationFrame) cancelAnimationFrame(animationFrame); 1228 + } 1229 + 1230 + function reset() { 1231 + pause(); 1232 + currentTime = 0; 1233 + if (timelineScrubber) timelineScrubber.value = 0; 1234 + drawCanvas(); 1235 + } 1236 + 1237 + // Event listeners 1238 + if (playPauseBtn) { 1239 + playPauseBtn.addEventListener("click", () => { 1240 + if (isPlaying) pause(); 1241 + else play(); 1242 + }); 1243 + } 1244 + 1245 + if (resetBtn) { 1246 + resetBtn.addEventListener("click", reset); 1247 + } 1248 + 1249 + if (timelineScrubber) { 1250 + timelineScrubber.addEventListener("input", (e) => { 1251 + currentTime = parseFloat(e.target.value); 1252 + drawCanvas(); 1253 + }); 1254 + 1255 + timelineScrubber.addEventListener("mousedown", () => { 1256 + if (isPlaying) pause(); 1257 + }); 1258 + } 1259 + 1260 + // Redraw on parameter changes 1261 + Object.values(inputs).forEach((input) => { 1262 + input.addEventListener("input", () => { 1263 + if (!isPlaying) drawCanvas(); 1264 + }); 1265 + }); 1266 + 1267 + // Initialize 1268 + setupTimeline(); 1269 + drawCanvas(); 1270 + 1271 + // Initial calculation 1272 + calculate(); 1273 + } 1274 + })(); 1275 + </script>