// Timeline playback — rAF-driven frame advance plus scrubber wiring. const FRAMES_PER_SEC = 12; function startPlayback() { isPlaying = true; $("play").textContent = "❚❚"; lastTickTs = null; if (rafHandle) cancelAnimationFrame(rafHandle); rafHandle = requestAnimationFrame(tick); } function stopPlayback() { isPlaying = false; $("play").textContent = "▶"; if (rafHandle) { cancelAnimationFrame(rafHandle); rafHandle = null; } lastTickTs = null; } function tick(now) { if (!isPlaying || !filmstrip) return; if (lastTickTs === null) lastTickTs = now; const dt = (now - lastTickTs) / 1000; lastTickTs = now; currentFrame += FRAMES_PER_SEC * playSpeed * dt; const n = filmstrip.snapshots.length; if (currentFrame >= n - 1) { currentFrame = n - 1; draw(); updateScrubInfo(); $("scrub").value = Math.floor(currentFrame); stopPlayback(); return; } $("scrub").value = Math.floor(currentFrame); draw(); updateScrubInfo(); rafHandle = requestAnimationFrame(tick); } function updateScrubInfo() { if (!filmstrip || !filmstrip.snapshots.length) { $("scrub-frame").textContent = "-"; $("scrub-time").textContent = "-"; return; } const fi = Math.max(0, Math.min(filmstrip.snapshots.length - 1, Math.floor(currentFrame))); const snap = filmstrip.snapshots[fi]; const n = filmstrip.snapshots.length; $("scrub-frame").textContent = `frame ${fi + 1} / ${n}`; const date = new Date(snap.ts * 1000); $("scrub-time").textContent = date.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); $("stat-tiles").textContent = totalTilesAtFrame(currentFrame); } function totalTilesAtFrame(idx) { const fi = Math.max(0, Math.min((filmstrip?.snapshots.length ?? 0) - 1, Math.floor(idx))); if (!filmstrip || !filmstrip.snapshots[fi]) return 0; return filmstrip.snapshots[fi].frames.reduce((s, f) => s + f.tiles.length, 0); } // ---- playback section listeners ---- $("play").addEventListener("click", () => { if (!filmstrip) return; if (isPlaying) stopPlayback(); else { if (currentFrame >= filmstrip.snapshots.length - 1) currentFrame = 0; startPlayback(); } }); $("scrub").addEventListener("input", () => { if (isPlaying) stopPlayback(); currentFrame = parseInt($("scrub").value, 10); draw(); updateScrubInfo(); }); $("speed-sel").addEventListener("change", () => { playSpeed = parseFloat($("speed-sel").value); }); $("snap-count").addEventListener("input", () => { $("snap-val").textContent = $("snap-count").value; }); // window-sel just stages the value — the user has to press Run to apply it. // While the staged value differs from what's actually simulated, lock out // play/scrub with an explanatory tooltip so it's obvious why nothing // happens until Run is clicked. setState() re-enables them on the next sim. $("window-sel").addEventListener("change", () => { if (appState !== "ready") return; if (isPlaying) stopPlayback(); const msg = "Time window changed — click Run simulation to apply."; const play = $("play"); const scrub = $("scrub"); play.disabled = true; play.title = msg; scrub.disabled = true; scrub.title = msg; }); // Spacebar = play/pause when a filmstrip exists. Ignored if focus is in a // form field so it doesn't fight with text inputs / sliders. window.addEventListener("keydown", (e) => { if (e.code !== "Space") return; const t = e.target; if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return; if (!filmstrip || appState !== "ready") return; e.preventDefault(); $("play").click(); });