prop/assets/js/weather_map_hook.ts
Graham McIntire e632002255
fix(weather): paint overlay on mount instead of waiting for click
renderLayer was called during mounted() before selectedTime was seeded
from the dataset, so buildWeatherTileUrl returned null and no tile
layer was added. The overlay only appeared when the user clicked a
forecast hour and update_weather_overlay fired. Seed selectedTime
(and timelineData) from data attributes before the first renderLayer.
2026-04-29 12:50:28 -05:00

932 lines
36 KiB
TypeScript

// assets/js/weather_map_hook.ts
import { updateGridOverlay } from "./maidenhead_grid"
// --- Type definitions ---
interface RGB {
r: number
g: number
b: number
}
interface Breakpoint extends RGB {
value: number
}
interface ContinuousColorScale {
breakpoints: Breakpoint[]
trueColor?: never
format: (v: number | null) => string
label: string
unit: string
}
interface BooleanColorScale {
breakpoints: null
trueColor: RGB
format: (v: boolean | null) => string
label: string
unit: string
}
type ColorScale = ContinuousColorScale | BooleanColorScale
type LayerId = keyof typeof COLOR_SCALES
interface WeatherPoint {
lat: number
lon: number
valid_time: string | null
temperature: number | null
dewpoint_depression: number | null
surface_rh: number | null
surface_pressure_mb: number | null
surface_refractivity: number | null
refractivity_gradient: number | null
bl_height: number | null
pwat: number | null
temp_850mb: number | null
dewpoint_850mb: number | null
temp_700mb: number | null
dewpoint_700mb: number | null
lapse_rate: number | null
mid_lapse_rate: number | null
inversion_strength: number | null
inversion_base_m: number | null
ducting: boolean | null
duct_base_m: number | null
duct_strength: number | null
[key: string]: unknown
}
interface DetailRow {
label?: string
value?: string
color?: string
separator?: boolean
}
interface WeatherMapHook extends ViewHook {
map: L.Map
weatherOverlay: L.TileLayer | null
weatherTileUrl: string
selectedLayer: LayerId
detailPanel: HTMLElement | null
escHint: HTMLDivElement
clickMarker: L.LayerGroup
legend: L.Control
legendContent: HTMLElement
gridLayer: L.LayerGroup
gridVisible: boolean
radarLayer: L.TileLayer.WMS | null
radarRefreshTimer: ReturnType<typeof setInterval> | null
timelineEl: HTMLElement | null
timelineData: string[]
selectedTime: string | null
// Whether the currently rendered timeline DOM matches `timelineData`.
// Selection-only updates can patch button state without rebuilding;
// a data change forces a full rebuild.
timelineRenderedKey: string
playbackTimer: ReturnType<typeof setInterval> | null
_escHandler: (e: KeyboardEvent) => void
_layerObserver: MutationObserver
visibilityHandler: (() => void) | null
mounted(this: WeatherMapHook): void
destroyed(this: WeatherMapHook): void
reconnected(this: WeatherMapHook): void
positionDetailPanel(this: WeatherMapHook): void
renderLayer(this: WeatherMapHook): void
overlayUrl(this: WeatherMapHook): string | null
refreshLegend(this: WeatherMapHook): void
buildLegendContent(this: WeatherMapHook): string
renderTimeline(this: WeatherMapHook): void
applyTimelineSelection(this: WeatherMapHook): void
selectTimelineTime(this: WeatherMapHook, time: string): void
startPlayback(this: WeatherMapHook): void
stopPlayback(this: WeatherMapHook): void
}
// --- Helpers ---
function isMobile(): boolean {
return window.innerWidth < 768
}
const COLOR_SCALES = {
refractivity_gradient: {
breakpoints: [
{ value: -500, r: 0, g: 255, b: 163 },
{ value: -300, r: 125, g: 255, b: 212 },
{ value: -200, r: 255, g: 229, b: 102 },
{ value: -100, r: 255, g: 144, b: 68 },
{ value: 0, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(0)} N/km` : "N/A",
label: "Refractivity Gradient",
unit: "N/km"
},
bl_height: {
breakpoints: [
{ value: 0, r: 0, g: 255, b: 163 },
{ value: 500, r: 125, g: 255, b: 212 },
{ value: 1000, r: 255, g: 229, b: 102 },
{ value: 2000, r: 255, g: 144, b: 68 },
{ value: 3000, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(0)} m` : "N/A",
label: "Boundary Layer Height",
unit: "m"
},
dewpoint_depression: {
breakpoints: [
{ value: 0, r: 0, g: 255, b: 163 },
{ value: 5, r: 125, g: 255, b: 212 },
{ value: 10, r: 255, g: 229, b: 102 },
{ value: 20, r: 255, g: 144, b: 68 },
{ value: 30, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} \u00b0C` : "N/A",
label: "Dewpoint Depression (T - Td)",
unit: "\u00b0C"
},
pwat: {
breakpoints: [
{ value: 0, r: 139, g: 90, b: 43 },
{ value: 15, r: 194, g: 165, b: 116 },
{ value: 30, r: 173, g: 216, b: 230 },
{ value: 45, r: 65, g: 105, b: 225 },
{ value: 60, r: 0, g: 0, b: 180 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} mm` : "N/A",
label: "Precipitable Water",
unit: "mm"
},
temperature: {
breakpoints: [
{ value: -20, r: 0, g: 0, b: 200 },
{ value: 0, r: 100, g: 149, b: 237 },
{ value: 15, r: 255, g: 255, b: 150 },
{ value: 30, r: 255, g: 140, b: 0 },
{ value: 45, r: 200, g: 0, b: 0 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} \u00b0C (${(v * 9/5 + 32).toFixed(0)} \u00b0F)` : "N/A",
label: "Surface Temperature",
unit: "\u00b0C"
},
ducting: {
breakpoints: null,
trueColor: { r: 0, g: 255, b: 163 },
format: (v: boolean | null): string => v ? "Yes" : "No",
label: "Ducting Detected",
unit: ""
},
surface_rh: {
breakpoints: [
{ value: 0, r: 255, g: 144, b: 68 },
{ value: 30, r: 255, g: 229, b: 102 },
{ value: 60, r: 125, g: 255, b: 212 },
{ value: 80, r: 0, g: 255, b: 163 },
{ value: 100, r: 0, g: 200, b: 130 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(0)}%` : "N/A",
label: "Relative Humidity",
unit: "%"
},
surface_refractivity: {
breakpoints: [
{ value: 250, r: 255, g: 79, b: 79 },
{ value: 300, r: 255, g: 144, b: 68 },
{ value: 330, r: 255, g: 229, b: 102 },
{ value: 360, r: 125, g: 255, b: 212 },
{ value: 400, r: 0, g: 255, b: 163 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} N` : "N/A",
label: "Surface Refractivity",
unit: "N"
},
temp_850mb: {
breakpoints: [
{ value: -20, r: 0, g: 0, b: 200 },
{ value: -5, r: 100, g: 149, b: 237 },
{ value: 5, r: 255, g: 255, b: 150 },
{ value: 15, r: 255, g: 140, b: 0 },
{ value: 30, r: 200, g: 0, b: 0 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
label: "Temperature at 850mb",
unit: "°C"
},
dewpoint_850mb: {
breakpoints: [
{ value: -20, r: 139, g: 90, b: 43 },
{ value: -5, r: 194, g: 165, b: 116 },
{ value: 5, r: 173, g: 216, b: 230 },
{ value: 15, r: 65, g: 105, b: 225 },
{ value: 25, r: 0, g: 0, b: 180 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
label: "Dewpoint at 850mb",
unit: "°C"
},
// 700 mb T is the canonical cap indicator over the southern Plains.
// Cool greens (no cap) → yellow at ~8°C → orange at the cap edge
// (10°C) → red for strong caps (≥12°C). Warm caps stand out clearly
// against the cooler "no cap" base map.
temp_700mb: {
breakpoints: [
{ value: -10, r: 0, g: 100, b: 200 },
{ value: 0, r: 100, g: 180, b: 230 },
{ value: 5, r: 200, g: 230, b: 200 },
{ value: 8, r: 255, g: 229, b: 102 },
{ value: 10, r: 255, g: 144, b: 68 },
{ value: 14, r: 200, g: 0, b: 0 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
label: "Temperature at 700mb",
unit: "°C"
},
dewpoint_700mb: {
breakpoints: [
{ value: -30, r: 139, g: 90, b: 43 },
{ value: -15, r: 194, g: 165, b: 116 },
{ value: -5, r: 173, g: 216, b: 230 },
{ value: 5, r: 65, g: 105, b: 225 },
{ value: 15, r: 0, g: 0, b: 180 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
label: "Dewpoint at 700mb",
unit: "°C"
},
lapse_rate: {
breakpoints: [
{ value: 0, r: 0, g: 255, b: 163 },
{ value: 4, r: 125, g: 255, b: 212 },
{ value: 6.5, r: 255, g: 229, b: 102 },
{ value: 8, r: 255, g: 144, b: 68 },
{ value: 10, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C/km` : "N/A",
label: "Lapse Rate",
unit: "°C/km"
},
// EML signature: a steep lapse rate across the 850→700 layer above
// a moist surface = elevated mixed layer = textbook cap. Red end of
// the scale (≥7 °C/km) is the cap-mechanism trigger.
mid_lapse_rate: {
breakpoints: [
{ value: 0, r: 0, g: 100, b: 200 },
{ value: 4, r: 100, g: 200, b: 200 },
{ value: 6, r: 255, g: 229, b: 102 },
{ value: 7, r: 255, g: 144, b: 68 },
{ value: 9, r: 200, g: 0, b: 0 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C/km` : "N/A",
label: "Lapse 850→700",
unit: "°C/km"
},
inversion_strength: {
breakpoints: [
{ value: 0, r: 80, g: 80, b: 80 },
{ value: 1, r: 255, g: 229, b: 102 },
{ value: 3, r: 125, g: 255, b: 212 },
{ value: 5, r: 0, g: 255, b: 163 },
{ value: 10, r: 0, g: 200, b: 130 }
],
format: (v: number | null): string => v != null ? (v > 0 ? `+${v.toFixed(1)} °C` : "None") : "N/A",
label: "Inversion Strength",
unit: "°C"
},
inversion_base_m: {
breakpoints: [
{ value: 0, r: 0, g: 255, b: 163 },
{ value: 500, r: 125, g: 255, b: 212 },
{ value: 1000, r: 255, g: 229, b: 102 },
{ value: 2000, r: 255, g: 144, b: 68 },
{ value: 3000, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(0)} m AGL` : "None",
label: "Inversion Base Height",
unit: "m"
},
duct_base_m: {
breakpoints: [
{ value: 0, r: 0, g: 255, b: 163 },
{ value: 200, r: 125, g: 255, b: 212 },
{ value: 500, r: 255, g: 229, b: 102 },
{ value: 1000, r: 255, g: 144, b: 68 },
{ value: 2000, r: 255, g: 79, b: 79 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(0)} m AGL` : "None",
label: "Duct Base Height",
unit: "m"
},
duct_strength: {
breakpoints: [
{ value: 0, r: 80, g: 80, b: 80 },
{ value: 5, r: 255, g: 229, b: 102 },
{ value: 10, r: 125, g: 255, b: 212 },
{ value: 20, r: 0, g: 255, b: 163 },
{ value: 40, r: 0, g: 200, b: 130 }
],
format: (v: number | null): string => v != null ? (v > 0 ? `${v.toFixed(1)} M-units` : "None") : "N/A",
label: "Duct Strength",
unit: "M"
}
} as const satisfies Record<string, ColorScale>
function buildWeatherTileUrl(baseUrl: string, layerId: LayerId, selectedTime: string | null): string | null {
if (!selectedTime) return null
const params = new URLSearchParams({
layer: layerId,
time: selectedTime
})
return `${baseUrl}/{z}/{x}/{y}?${params.toString()}`
}
function buildDetailHTML(point: WeatherPoint): string {
if (!point) return ""
const rows: DetailRow[] = [
{ label: "Temperature", value: COLOR_SCALES.temperature.format(point.temperature), color: "#ff9044" },
{ label: "Td Depression", value: COLOR_SCALES.dewpoint_depression.format(point.dewpoint_depression), color: "#7dffd4" },
{ label: "Rel. Humidity", value: COLOR_SCALES.surface_rh.format(point.surface_rh), color: "#00ffa3" },
{ label: "Sfc Pressure", value: point.surface_pressure_mb != null ? `${point.surface_pressure_mb.toFixed(1)} mb` : "N/A", color: "#aaa" },
{ label: "Refractivity", value: COLOR_SCALES.surface_refractivity.format(point.surface_refractivity), color: "#00ffa3" },
{ label: "N-Gradient", value: COLOR_SCALES.refractivity_gradient.format(point.refractivity_gradient), color: "#00ffa3" },
{ label: "BL Height", value: COLOR_SCALES.bl_height.format(point.bl_height), color: "#ffe566" },
{ label: "PWAT", value: COLOR_SCALES.pwat.format(point.pwat), color: "#4169e1" },
{ separator: true },
{ label: "T @ 850mb", value: COLOR_SCALES.temp_850mb.format(point.temp_850mb), color: "#ff9044" },
{ label: "Td @ 850mb", value: COLOR_SCALES.dewpoint_850mb.format(point.dewpoint_850mb), color: "#4169e1" },
{ label: "T @ 700mb", value: COLOR_SCALES.temp_700mb.format(point.temp_700mb), color: "#ff9044" },
{ label: "Td @ 700mb", value: COLOR_SCALES.dewpoint_700mb.format(point.dewpoint_700mb), color: "#4169e1" },
{ label: "Lapse Rate", value: COLOR_SCALES.lapse_rate.format(point.lapse_rate), color: "#ffe566" },
{ label: "Lapse 850→700", value: COLOR_SCALES.mid_lapse_rate.format(point.mid_lapse_rate), color: "#ffe566" },
{ label: "Inversion", value: COLOR_SCALES.inversion_strength.format(point.inversion_strength), color: point.inversion_strength != null && point.inversion_strength > 0 ? "#00ffa3" : "#666" },
{ label: "Inv. Base", value: COLOR_SCALES.inversion_base_m.format(point.inversion_base_m), color: point.inversion_base_m != null ? "#7dffd4" : "#666" },
{ separator: true },
{ label: "Ducting", value: COLOR_SCALES.ducting.format(point.ducting), color: point.ducting ? "#00ffa3" : "#ff4f4f" },
{ label: "Duct Base", value: COLOR_SCALES.duct_base_m.format(point.duct_base_m), color: point.duct_base_m != null ? "#00ffa3" : "#666" },
{ label: "Duct Strength", value: COLOR_SCALES.duct_strength.format(point.duct_strength), color: point.duct_strength != null ? "#00ffa3" : "#666" }
]
const mobile = isMobile()
const closeBtn = mobile
? `<button onclick="document.getElementById('weather-detail-panel').style.display='none'" style="position:absolute;top:8px;right:10px;background:rgba(255,255,255,0.15);border:none;color:#fff;font-size:18px;line-height:1;width:28px;height:28px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;">&times;</button>`
: ""
const handle = mobile
? `<div style="display:flex;justify-content:center;padding:6px 0 2px;"><div style="width:32px;height:4px;border-radius:2px;background:rgba(255,255,255,0.3);"></div></div>`
: ""
const tableRows = rows.map(r => {
if (r.separator) {
return `<tr><td colspan="2" style="padding:2px 0;"><hr style="border:none;border-top:1px solid rgba(255,255,255,0.1);"></td></tr>`
}
return `<tr>
<td style="padding:3px 8px 3px 0;font-size:12px;white-space:nowrap;"><span style="color:${r.color};">\u25cf</span> ${r.label}</td>
<td style="padding:3px 0;font-size:12px;text-align:right;font-weight:600;">${r.value}</td>
</tr>`
}).join("")
const time = point.valid_time
? new Date(point.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC"
: ""
return `<div style="min-width:${mobile ? 'auto' : '240px'};position:relative;">
${handle}${closeBtn}
<div style="background:rgba(255,255,255,0.1);padding:6px 10px;">
<strong style="font-size:14px;">Weather Details</strong>
</div>
<div style="padding:6px 10px;">
<div style="font-size:11px;opacity:0.6;margin-bottom:6px;">
${point.lat.toFixed(3)}\u00b0N, ${Math.abs(point.lon).toFixed(3)}\u00b0W &mdash; ${time}
</div>
<table style="width:100%;border-collapse:collapse;">${tableRows}</table>
</div>
</div>`
}
// --- Hook ---
export const WeatherMap: WeatherMapHook = {
mounted(this: WeatherMapHook) {
this.map = L.map(this.el, {
center: [32.897, -97.038],
zoom: 7,
minZoom: 4,
maxZoom: 10
})
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; OpenStreetMap contributors",
maxZoom: 19
}).addTo(this.map)
this.weatherOverlay = null
this.weatherTileUrl = this.el.dataset.weatherTileUrl || "/weather/tiles"
this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId
const storedLayer = localStorage.getItem("weatherMap.selectedLayer")
if (storedLayer && storedLayer in COLOR_SCALES && storedLayer !== this.selectedLayer) {
this.selectedLayer = storedLayer as LayerId
this.pushEvent("select_layer", { layer: storedLayer })
}
// Seed selectedTime/timelineData from the dataset before the first
// renderLayer call. Without this, buildWeatherTileUrl returns null
// and no overlay paints until the user clicks a forecast button.
this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]")
this.selectedTime = this.el.dataset.selectedTime || null
this.renderLayer()
this.handleEvent("update_weather_overlay", ({ selected }: { selected: string | null }) => {
this.selectedTime = selected
this.renderLayer()
})
this.detailPanel = document.getElementById("weather-detail-panel")
if (this.detailPanel) {
L.DomEvent.disableClickPropagation(this.detailPanel)
L.DomEvent.disableScrollPropagation(this.detailPanel)
}
// ESC hint
this.escHint = document.createElement("div")
this.escHint.className = "hidden md:block"
this.escHint.style.cssText = "position:absolute;top:12px;right:12px;z-index:1100;background:rgba(0,0,0,0.6);color:#fff;padding:4px 10px;border-radius:6px;font-size:12px;opacity:0;transition:opacity 0.2s;pointer-events:none;"
this.escHint.textContent = "Press ESC to close"
this.el.appendChild(this.escHint)
this.handleEvent("point_detail", (detail: WeatherPoint) => {
if (detail && detail.lat && this.detailPanel) {
this.detailPanel.innerHTML = buildDetailHTML(detail)
this.detailPanel.style.display = "block"
this.escHint.style.opacity = "1"
this.positionDetailPanel()
}
})
this.clickMarker = L.layerGroup().addTo(this.map)
this.map.on("click", (e: L.LeafletMouseEvent) => {
this.clickMarker.clearLayers()
L.circleMarker([e.latlng.lat, e.latlng.lng], {
radius: 6, color: "#fff", weight: 2,
fillColor: "#888", fillOpacity: 0.8, interactive: false
}).addTo(this.clickMarker)
this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
})
this._escHandler = (e: KeyboardEvent) => {
if (e.key === "Escape" && this.detailPanel) {
this.detailPanel.style.display = "none"
this.detailPanel.innerHTML = ""
this.clickMarker.clearLayers()
this.escHint.style.opacity = "0"
}
}
document.addEventListener("keydown", this._escHandler)
const controls = document.getElementById("weather-controls")
if (controls) {
L.DomEvent.disableClickPropagation(controls)
L.DomEvent.disableScrollPropagation(controls)
}
// Maidenhead grid overlay
this.gridLayer = L.layerGroup()
this.gridVisible = false
this.handleEvent("toggle_grid", ({ visible }: { visible: boolean }) => {
this.gridVisible = visible
localStorage.setItem("weatherMap.gridVisible", String(visible))
if (visible) {
this.gridLayer.addTo(this.map)
updateGridOverlay(this.map, this.gridLayer)
} else {
this.gridLayer.remove()
}
})
if (localStorage.getItem("weatherMap.gridVisible") === "true") {
this.pushEvent("toggle_grid", {})
}
// Weather radar overlay (ECCC GeoMet WMS — CONUS + Canada composite dBZ).
// Lazy-created on first toggle so we don't hit the service until asked.
this.radarLayer = null
this.radarRefreshTimer = null
this.handleEvent("toggle_radar", ({ visible }: { visible: boolean }) => {
localStorage.setItem("weatherMap.radarVisible", String(visible))
if (visible) {
if (!this.radarLayer) {
this.radarLayer = L.tileLayer.wms("https://geo.weather.gc.ca/geomet", {
layers: "Radar_1km_dBZ-Extrapolation",
format: "image/png",
transparent: true,
opacity: 0.55,
version: "1.3.0",
attribution: "Radar &copy; Environment and Climate Change Canada"
})
}
this.radarLayer.addTo(this.map)
// ECCC radar updates every ~6 minutes; force a tile refresh on that
// cadence so the overlay stays current without a page reload.
this.radarRefreshTimer = setInterval(() => {
if (this.radarLayer) {
// @ts-expect-error setParams exists on WMS TileLayer but isn't in @types/leaflet yet
this.radarLayer.setParams({ _: Date.now() })
}
}, 6 * 60 * 1000)
} else {
if (this.radarLayer) this.radarLayer.remove()
if (this.radarRefreshTimer) {
clearInterval(this.radarRefreshTimer)
this.radarRefreshTimer = null
}
}
})
if (localStorage.getItem("weatherMap.radarVisible") === "true") {
this.pushEvent("toggle_radar", {})
}
this.map.on("moveend", () => {
if (this.gridVisible) {
updateGridOverlay(this.map, this.gridLayer)
}
})
// When the tab becomes visible again, refresh the current overlay tiles
// for the now-current viewport and map size.
this.visibilityHandler = () => {
if (document.visibilityState === "visible") {
this.map.invalidateSize()
this.renderLayer()
}
}
document.addEventListener("visibilitychange", this.visibilityHandler)
this._layerObserver = new MutationObserver(() => {
const newLayer = this.el.dataset.selectedLayer as LayerId | undefined
if (newLayer && newLayer !== this.selectedLayer) {
this.selectedLayer = newLayer
localStorage.setItem("weatherMap.selectedLayer", newLayer)
this.renderLayer()
}
})
this._layerObserver.observe(this.el, { attributes: true, attributeFilter: ["data-selected-layer"] })
// Mount the legend control once and update its content in place.
// Removing + re-adding the control on every renderLayer was a
// visible source of jank during layer switches and time scrubs.
this.legend = L.control({ position: "topright" })
this.legend.onAdd = () => {
this.legendContent = L.DomUtil.create("div")
this.legendContent.innerHTML = this.buildLegendContent()
return this.legendContent
}
this.legend.addTo(this.map)
// --- Forecast timeline ---
// timelineData and selectedTime are seeded earlier (before the
// initial renderLayer); the timeline DOM hookup happens here.
this.timelineEl = document.getElementById("weather-forecast-timeline")
this.playbackTimer = null
this.timelineRenderedKey = ""
if (this.timelineData.length > 1) {
this.renderTimeline()
}
this.handleEvent("update_timeline", ({ times, selected }: { times: string[]; selected: string | null }) => {
// Force a full rebuild when the time set changes — invalidate
// the rendered key so renderTimeline doesn't take the
// selection-only patch path against stale buttons.
const dataChanged = times.join("|") !== this.timelineRenderedKey
this.timelineData = times
this.selectedTime = selected
if (dataChanged) this.timelineRenderedKey = ""
this.renderLayer()
this.renderTimeline()
})
// Prevent timeline from eating map clicks
if (this.timelineEl) {
L.DomEvent.disableClickPropagation(this.timelineEl)
L.DomEvent.disableScrollPropagation(this.timelineEl)
}
},
destroyed(this: WeatherMapHook) {
if (this._layerObserver) this._layerObserver.disconnect()
if (this._escHandler) document.removeEventListener("keydown", this._escHandler)
if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer)
if (this.playbackTimer !== null) clearInterval(this.playbackTimer)
if (this.visibilityHandler) {
document.removeEventListener("visibilitychange", this.visibilityHandler)
this.visibilityHandler = null
}
},
reconnected(this: WeatherMapHook) {
if (this.map) {
this.map.invalidateSize()
this.renderLayer()
}
},
positionDetailPanel(this: WeatherMapHook) {
if (!this.detailPanel || isMobile()) return
const controls = document.getElementById("weather-controls")
if (controls) {
const rect = controls.getBoundingClientRect()
this.detailPanel.style.left = rect.left + "px"
this.detailPanel.style.top = (rect.bottom + 8) + "px"
}
},
overlayUrl(this: WeatherMapHook) {
return buildWeatherTileUrl(this.weatherTileUrl, this.selectedLayer, this.selectedTime)
},
renderLayer(this: WeatherMapHook) {
const layerId = this.selectedLayer
const scale = COLOR_SCALES[layerId]
if (!scale) return
const tileUrl = this.overlayUrl()
if (!tileUrl) {
if (this.weatherOverlay) {
this.map.removeLayer(this.weatherOverlay)
this.weatherOverlay = null
}
} else if (this.weatherOverlay) {
this.weatherOverlay.setUrl(tileUrl, false)
this.weatherOverlay.redraw()
} else {
this.weatherOverlay = L.tileLayer(tileUrl, {
tileSize: 256,
opacity: 1.0,
updateWhenIdle: true,
keepBuffer: 1,
bounds: [[-85.0511, -180], [85.0511, 180]]
})
this.weatherOverlay.addTo(this.map)
}
this.refreshLegend()
},
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 `<div style="${wrapStyle}"><strong style="color:#333;">${scale.label}</strong><br>` +
`<span style="background:rgb(${c.r},${c.g},${c.b});width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span>Ducting<br>` +
`<span style="background:transparent;width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span>None</div>`
}
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 `<div style="${wrapStyle}">` + bp.map(b =>
`<span style="background:rgb(${b.r},${b.g},${b.b});width:10px;height:10px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:3px;border:1px solid rgba(0,0,0,0.15);"></span>${b.value}`
).join("<br>") + "</div>"
}
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 `<div style="${wrapStyle}"><strong style="color:#333;">${scale.label}</strong><br>` +
bp.map(b =>
`<span style="background:rgb(${b.r},${b.g},${b.b});width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span><span style="color:#333;">${b.value} ${scale.unit}</span>`
).join("<br>") + "</div>"
},
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 = `
<div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:6px 14px;display:flex;gap:8px;align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);font-size:11px;color:rgba(255,255,255,0.6);">
<span>Data: ${dateLabel} ${utcLabel}</span>
</div>`
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 `<button data-time="${t.time}" style="
padding:${btnPad};font-size:${btnFont};font-weight:${weight};
background:${bg};color:${fg};border:${border};border-radius:6px;
cursor:pointer;white-space:nowrap;min-width:${btnMinW};
transition:background 0.15s;
">${t.label}<br><span style="font-size:${subFont};opacity:0.7;">${utcLabel}</span></button>`
}).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 = `
<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);">
<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>`
this.timelineEl.querySelectorAll<HTMLButtonElement>("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<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())
},
// 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<HTMLButtonElement>("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<HTMLButtonElement>("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