()
decoded.layerNames.forEach((name, i) => layerMap.set(name, decoded.values[i]))
this.packs.set(source, {
cellCount: decoded.cellCount,
lats: decoded.lats,
lons: decoded.lons,
layers: layerMap
})
}
} catch (e) {
const err = e as Error
if (err.name === "AbortError") return
console.warn(`weather cells fetch failed (${source})`, err)
}
},
paintOverlay(this: WeatherMapHook) {
// Order matters: HRDPS goes first so HRRR's higher-resolution
// canvas paints on top along the US/Canada border where both have
// coverage. Leaflet stacks overlayPane children in DOM (add) order.
const renderOrder: SourceName[] = ["hrdps", "hrrr"]
for (const src of renderOrder) {
const overlay = this.weatherOverlays.get(src)
const active = this.sources.includes(src)
const pack = active ? this.packs.get(src) : undefined
if (!pack || pack.cellCount === 0) {
if (overlay) {
this.map.removeLayer(overlay)
this.weatherOverlays.delete(src)
}
continue
}
if (!overlay) {
const newOverlay = new WeatherCanvasOverlayCtor(pack, this.selectedLayer, halfStepForSource(src))
newOverlay.bandFilter = this.bandFilter
newOverlay.addTo(this.map)
this.weatherOverlays.set(src, newOverlay)
} else {
overlay.pack = pack
overlay.bandFilter = this.bandFilter
overlay.setLayer(this.selectedLayer)
}
}
},
refreshLegend(this: WeatherMapHook) {
if (this.legendContent) {
this.legendContent.innerHTML = this.buildLegendContent()
}
},
buildLegendContent(this: WeatherMapHook): string {
const layerId = this.selectedLayer
const scale = COLOR_SCALES[layerId]
if (!scale) return ""
const mobile = isMobile()
if (layerId === "ducting") {
const c = (scale as BooleanColorScale).trueColor
const wrapStyle = `background:#fff;color:#333;padding:${mobile ? '4px 6px' : '10px 14px'};border-radius:${mobile ? '6px' : '8px'};box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:${mobile ? '10px' : '13px'};line-height:2;`
return `${scale.label}
` +
`Ducting
` +
`None
`
}
if (scale.breakpoints === "band_steps") {
const steps = (scale as BandStepsColorScale).steps
const heading = this.bandFilter != null
? `${scale.label} · ≤ ${this.bandFilter} GHz`
: scale.label
const rowFor = (s: typeof steps[number], i: number) => {
const prev = i > 0 ? steps[i - 1].upTo : 0
const label = !Number.isFinite(s.upTo)
? `> ${prev} GHz`
: prev === 0
? `≤ ${s.upTo} GHz`
: `≤ ${s.upTo} GHz`
const dim = this.bandFilter != null && prev > this.bandFilter ? "opacity:0.3;" : ""
return `${label}`
}
const wrapStyle = mobile
? "background:#fff;color:#333;padding:4px 6px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.3);font-size:10px;line-height:1.6;"
: "background:#fff;color:#333;padding:10px 14px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:13px;line-height:2;"
const header = mobile ? "" : `${heading}
`
return `${header}${steps.map(rowFor).join("
")}
`
}
const bp = (scale as ContinuousColorScale).breakpoints
if (mobile) {
const wrapStyle = "background:#fff;color:#333;padding:4px 6px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.3);font-size:10px;line-height:1.6;"
return `` + bp.map(b =>
`${b.value}`
).join("
") + "
"
}
const wrapStyle = "background:#fff;color:#333;padding:10px 14px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:13px;line-height:2;"
return `${scale.label}
` +
bp.map(b =>
`${b.value} ${scale.unit}`
).join("
") + "
"
},
renderTimeline(this: WeatherMapHook) {
if (!this.timelineEl || this.timelineData.length === 0) {
if (this.timelineEl) this.timelineEl.style.display = "none"
this.timelineRenderedKey = ""
return
}
// Selection-only change: skip the full innerHTML rebuild, just
// restyle the active vs inactive buttons. The rendered button set
// is keyed by the timeline data — if the data is unchanged, the
// existing buttons + listeners stay attached.
const dataKey = this.timelineData.join("|")
if (this.timelineRenderedKey === dataKey && this.timelineData.length > 1) {
this.applyTimelineSelection()
return
}
this.timelineRenderedKey = dataKey
if (this.timelineData.length === 1) {
const dt = new Date(this.timelineData[0])
const utcLabel = `${dt.getUTCHours().toString().padStart(2, "0")}:00 UTC`
const dateLabel = `${dt.getUTCFullYear()}-${(dt.getUTCMonth() + 1).toString().padStart(2, "0")}-${dt.getUTCDate().toString().padStart(2, "0")}`
this.timelineEl.style.display = "block"
this.timelineEl.innerHTML = `
Data: ${dateLabel} ${utcLabel}
`
return
}
const now = new Date()
type TItem = { time: string; dt: Date; isSelected: boolean; isPast: boolean; offsetH: number; label: string }
const items: TItem[] = this.timelineData.map((time) => {
const dt = new Date(time)
const isSelected = time === this.selectedTime
const isPast = dt.getTime() < now.getTime() - 1800000
return { time, dt, isSelected, isPast, offsetH: 0, label: "" }
})
const closestIdx = items.reduce((best, item, i) =>
Math.abs(item.dt.getTime() - now.getTime()) < Math.abs(items[best].dt.getTime() - now.getTime()) ? i : best, 0)
// Label offsets relative to the "Now" slot, not wall-clock. If we
// round from wall-clock, the hour after "Now" rounds to "0h" when
// the clock is past the half-hour, which reads as a second "now".
const anchorMs = items[closestIdx].dt.getTime()
items.forEach((t, i) => {
const diff = Math.round((t.dt.getTime() - anchorMs) / 3600000)
t.offsetH = diff
if (i === closestIdx) {
t.label = "Now"
} else {
t.label = diff > 0 ? `+${diff}h` : `${diff}h`
}
})
const mobile = isMobile()
const btnPad = mobile ? "3px 5px" : "4px 8px"
const btnMinW = mobile ? "32px" : "38px"
const btnFont = mobile ? "10px" : "11px"
const subFont = mobile ? "8px" : "9px"
const buttons = items.map(t => {
const bg = t.isSelected ? "#7dffd4" : (t.isPast ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.15)")
const fg = t.isSelected ? "#000" : (t.isPast ? "rgba(255,255,255,0.4)" : "#fff")
const border = t.isSelected ? "2px solid #7dffd4" : "1px solid rgba(255,255,255,0.2)"
const weight = t.isSelected ? "700" : "400"
const utcLabel = `${t.dt.getUTCHours().toString().padStart(2, "0")}:00`
return ``
}).join("")
const labelText = mobile ? "Forecast" : "Weather Forecast"
const isPlaying = this.playbackTimer !== null
const playBg = isPlaying ? "#7dffd4" : "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 = `
`
this.timelineEl.querySelectorAll("button[data-time]").forEach(btn => {
btn.addEventListener("click", () => {
const time = btn.dataset.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())
},
// Update the active/inactive styling on existing timeline buttons
// without re-running `innerHTML`. Called when only `selectedTime`
// changes; `renderTimeline` falls through to a full rebuild whenever
// `timelineData` itself changes.
applyTimelineSelection(this: WeatherMapHook) {
if (!this.timelineEl) return
const now = Date.now()
this.timelineEl.querySelectorAll("button[data-time]").forEach(btn => {
const time = btn.dataset.time!
const dt = new Date(time)
const isSelected = time === this.selectedTime
const isPast = dt.getTime() < now - 1800000
btn.style.background = isSelected ? "#7dffd4" : (isPast ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.15)")
btn.style.color = isSelected ? "#000" : (isPast ? "rgba(255,255,255,0.4)" : "#fff")
btn.style.border = isSelected ? "2px solid #7dffd4" : "1px solid rgba(255,255,255,0.2)"
btn.style.fontWeight = isSelected ? "700" : "400"
})
const playBtn = this.timelineEl.querySelector("button[data-timeline-play]")
if (playBtn) {
const isPlaying = this.playbackTimer !== null
playBtn.style.background = isPlaying ? "#7dffd4" : "rgba(255,255,255,0.15)"
playBtn.style.color = isPlaying ? "#000" : "#fff"
}
},
selectTimelineTime(this: WeatherMapHook, time: string) {
this.selectedTime = time
this.renderLayer()
this.renderTimeline()
this.pushEvent("select_time", { time })
},
startPlayback(this: WeatherMapHook) {
// Restarting re-seeds at "now" so repeated clicks don't resume
// mid-loop — users expect "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, time, i) => {
const d = Math.abs(new Date(time).getTime() - now)
const bestD = Math.abs(new Date(this.timelineData[best]).getTime() - now)
return d < bestD ? i : best
}, 0)
// Iterate "now" forward through the end of the forecast then loop.
const playable = this.timelineData.slice(nowIdx)
if (playable.length < 2) return
let cursor = 0
const step = () => {
this.selectTimelineTime(playable[cursor])
cursor = (cursor + 1) % playable.length
}
// Assign the handle before the first step so renderTimeline sees
// playbackTimer !== null and paints Play as active.
this.playbackTimer = setInterval(step, 1000)
step()
},
stopPlayback(this: WeatherMapHook) {
if (this.playbackTimer !== null) {
clearInterval(this.playbackTimer)
this.playbackTimer = null
}
if (this.timelineData.length === 0) {
this.renderTimeline()
return
}
const now = Date.now()
const nowIdx = this.timelineData.reduce((best, time, i) => {
const d = Math.abs(new Date(time).getTime() - now)
const bestD = Math.abs(new Date(this.timelineData[best]).getTime() - now)
return d < bestD ? i : best
}, 0)
this.selectTimelineTime(this.timelineData[nowIdx])
}
} as unknown as WeatherMapHook