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.
This commit is contained in:
Graham McIntire 2026-04-15 09:50:06 -05:00
parent 4c1ef56537
commit 1a9df52102
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -144,6 +144,7 @@ interface PropagationMapHook extends ViewHook {
timelineData: TimelineEntry[]
selectedTime: string | null
forecastCache: Map<string, ScorePoint[]>
playbackTimer: ReturnType<typeof setInterval> | 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<string, unknown> & {
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<string, unknown> & {
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<string, unknown> & {
}
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<string, unknown> & {
}).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 = `
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:${mobile ? "4px 6px" : "6px 10px"};display:flex;gap:${mobile ? "2px" : "3px"};align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);overflow-x:auto;max-width:calc(100vw - 1rem);">
<span style="font-size:${mobile ? "9px" : "10px"};color:rgba(255,255,255,0.5);margin-right:4px;white-space:nowrap;">${labelText}</span>
<div style="display:flex;flex-direction:column;align-items:center;gap:3px;margin-right:6px;flex-shrink:0;">
<span style="font-size:${mobile ? "9px" : "10px"};color:rgba(255,255,255,0.5);white-space:nowrap;">${labelText}</span>
<div style="display:flex;gap:3px;">
<button data-timeline-play style="
padding:${ctrlPad};font-size:${ctrlFont};line-height:1;
background:${playBg};color:${playFg};
border:1px solid rgba(255,255,255,0.2);border-radius:4px;
cursor:pointer;" title="Play forecast animation"></button>
<button data-timeline-stop style="
padding:${ctrlPad};font-size:${ctrlFont};line-height:1;
background:rgba(255,255,255,0.15);color:#fff;
border:1px solid rgba(255,255,255,0.2);border-radius:4px;
cursor:pointer;" title="Stop and return to now"></button>
</div>
</div>
${buttons}
</div>`
@ -1317,24 +1351,98 @@ export const PropagationMap: Record<string, unknown> & {
this.timelineEl.querySelectorAll<HTMLButtonElement>("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<HTMLButtonElement>("button[data-timeline-play]")
if (playBtn) {
playBtn.addEventListener("click", () => this.startPlayback())
}
const stopBtn = this.timelineEl.querySelector<HTMLButtonElement>("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<string, unknown> & {
},
destroyed(this: PropagationMapHook) {
if (this.playbackTimer !== null) {
clearInterval(this.playbackTimer)
this.playbackTimer = null
}
if (this.map) this.map.remove()
}
}