From 1a9df521023aa89a0ca95ee37bd02e761628fa3a Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 15 Apr 2026 09:50:06 -0500 Subject: [PATCH] Add play/stop controls to the propagation forecast timeline New play and stop buttons live directly under the "Propagation Forecast" label in the bottom timeline bar. Play starts iterating one forecast hour per second from "Now" forward through every loadable future hour, then loops back to "Now" and repeats. Stop clears the interval and returns the map to "Now". A manual click on any time button cancels playback so the user's selection isn't overwritten by the next tick. The existing click handler body moved into a shared selectTimelineTime() so playback and manual clicks go through the same cache-or-roundtrip path. A server-driven update_timeline event (band change or new forecast hour) also tears the timer down before overwriting timelineData underneath it. --- assets/js/propagation_map_hook.ts | 146 ++++++++++++++++++++++++++---- 1 file changed, 129 insertions(+), 17 deletions(-) diff --git a/assets/js/propagation_map_hook.ts b/assets/js/propagation_map_hook.ts index 6382d747..a8faba56 100644 --- a/assets/js/propagation_map_hook.ts +++ b/assets/js/propagation_map_hook.ts @@ -144,6 +144,7 @@ interface PropagationMapHook extends ViewHook { timelineData: TimelineEntry[] selectedTime: string | null forecastCache: Map + playbackTimer: ReturnType | null showDetailPanel(this: PropagationMapHook): void hideDetailPanel(this: PropagationMapHook): void @@ -155,6 +156,9 @@ interface PropagationMapHook extends ViewHook { lookupPoint(this: PropagationMapHook, lat: number, lon: number): (ScorePoint & BandInfo) | null drawScatterMarkers(this: PropagationMapHook, scatter: RainScatter): void renderTimeline(this: PropagationMapHook): void + selectTimelineTime(this: PropagationMapHook, time: string): void + startPlayback(this: PropagationMapHook): void + stopPlayback(this: PropagationMapHook): void sendBounds(this: PropagationMapHook): void colorCache: string[] } @@ -653,6 +657,9 @@ export const PropagationMap: Record & { lookupPoint(this: PropagationMapHook, lat: number, lon: number): (ScorePoint & BandInfo) | null drawScatterMarkers(this: PropagationMapHook, scatter: RainScatter): void renderTimeline(this: PropagationMapHook): void + selectTimelineTime(this: PropagationMapHook, time: string): void + startPlayback(this: PropagationMapHook): void + stopPlayback(this: PropagationMapHook): void sendBounds(this: PropagationMapHook): void destroyed(this: PropagationMapHook): void } = { @@ -681,6 +688,7 @@ export const PropagationMap: Record & { this.scoreOverlay = null this.scores = [] this.forecastCache = new Map() + this.playbackTimer = null this.colorScale = [ { min: 80, r: 0, g: 255, b: 163 }, @@ -937,6 +945,13 @@ export const PropagationMap: Record & { } this.handleEvent("update_timeline", ({ times, selected }: { times: TimelineEntry[]; selected: string }) => { + // Band change or new forecast hour arrived — whatever playback + // was iterating through no longer matches the new set, so drop + // the timer before we rewrite timelineData under it. + if (this.playbackTimer !== null) { + clearInterval(this.playbackTimer) + this.playbackTimer = null + } this.timelineData = times this.selectedTime = selected this.renderTimeline() @@ -1305,11 +1320,30 @@ export const PropagationMap: Record & { }).join("") const labelText = mobile ? "Forecast" : "Propagation Forecast" + const isPlaying = this.playbackTimer !== null + const playBg = isPlaying ? "#00ffa3" : "rgba(255,255,255,0.15)" + const playFg = isPlaying ? "#000" : "#fff" + const ctrlFont = mobile ? "10px" : "11px" + const ctrlPad = mobile ? "1px 5px" : "2px 7px" this.timelineEl.style.display = "block" this.timelineEl.innerHTML = `
- ${labelText} +
+ ${labelText} +
+ + +
+
${buttons}
` @@ -1317,24 +1351,98 @@ export const PropagationMap: Record & { this.timelineEl.querySelectorAll("button[data-time]").forEach(btn => { btn.addEventListener("click", () => { const time = btn.dataset.time! - this.selectedTime = time - this.renderTimeline() - - const cached = this.forecastCache.get(time) - if (cached) { - // Fast path — render instantly from preloaded cache, just inform - // the server of the new selected time for state tracking. - this.renderScores(cached) - this.redrawReachPolygon() - this.pushEvent("set_selected_time", { time }) - } else { - // Cache miss — fall back to server roundtrip via the existing - // update_scores path (which also redraws the polygon). - topbar.show() - this.pushEvent("select_time", { time }) - } + // A manual click interrupts playback — the user has taken over. + this.stopPlayback() + this.selectTimelineTime(time) }) }) + + const playBtn = this.timelineEl.querySelector("button[data-timeline-play]") + if (playBtn) { + playBtn.addEventListener("click", () => this.startPlayback()) + } + + const stopBtn = this.timelineEl.querySelector("button[data-timeline-stop]") + if (stopBtn) { + stopBtn.addEventListener("click", () => this.stopPlayback()) + } + }, + + selectTimelineTime(this: PropagationMapHook, time: string) { + this.selectedTime = time + this.renderTimeline() + + const cached = this.forecastCache.get(time) + if (cached) { + // Fast path — render instantly from preloaded cache, just inform + // the server of the new selected time for state tracking. + this.renderScores(cached) + this.redrawReachPolygon() + this.pushEvent("set_selected_time", { time }) + } else { + // Cache miss — fall back to server roundtrip via the existing + // update_scores path (which also redraws the polygon). + topbar.show() + this.pushEvent("select_time", { time }) + } + }, + + startPlayback(this: PropagationMapHook) { + // Restarting re-seeds at "now" so repeated clicks don't resume + // mid-loop — the user's mental model is "play from the start". + if (this.playbackTimer !== null) { + clearInterval(this.playbackTimer) + this.playbackTimer = null + } + + if (this.timelineData.length < 2) return + + const now = Date.now() + const nowIdx = this.timelineData.reduce((best, t, i) => { + const d = Math.abs(new Date(t.time).getTime() - now) + const bestD = Math.abs(new Date(this.timelineData[best].time).getTime() - now) + return d < bestD ? i : best + }, 0) + + // Iterate through "now" + every later forecast hour on disk, then + // loop back. Past forecast hours (items before nowIdx) are excluded + // so the animation only steps forward in time. + const playable = this.timelineData.slice(nowIdx) + if (playable.length < 2) return + + let cursor = 0 + const step = () => { + this.selectTimelineTime(playable[cursor].time) + cursor = (cursor + 1) % playable.length + } + + // Assign the interval handle *before* the first step so the + // renderTimeline() call inside selectTimelineTime sees + // playbackTimer !== null and paints the Play button as active. + this.playbackTimer = setInterval(step, 1000) + step() + }, + + stopPlayback(this: PropagationMapHook) { + if (this.playbackTimer !== null) { + clearInterval(this.playbackTimer) + this.playbackTimer = null + } + + if (this.timelineData.length === 0) { + this.renderTimeline() + return + } + + // "Stop" returns the map to "now" — the timeline entry closest to + // the current wall clock. + const now = Date.now() + const nowIdx = this.timelineData.reduce((best, t, i) => { + const d = Math.abs(new Date(t.time).getTime() - now) + const bestD = Math.abs(new Date(this.timelineData[best].time).getTime() - now) + return d < bestD ? i : best + }, 0) + this.selectTimelineTime(this.timelineData[nowIdx].time) }, sendBounds(this: PropagationMapHook) { @@ -1353,6 +1461,10 @@ export const PropagationMap: Record & { }, destroyed(this: PropagationMapHook) { + if (this.playbackTimer !== null) { + clearInterval(this.playbackTimer) + this.playbackTimer = null + } if (this.map) this.map.remove() } }