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() } }