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:
parent
4c1ef56537
commit
1a9df52102
1 changed files with 129 additions and 17 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue