prop/assets/js/weather_map_hook.ts
Graham McIntire f4a177c9d0
perf(weather): single-fetch viewport cells, client-side recolor on layer switch
Previously every layer toggle triggered 21 fresh tile requests with the
new layer in the URL — ~21 server-side SVG renders, plus the round-trip
overhead. Even with the GridCache fix the server still spent real CPU
generating thousands of <rect>s per tile, and the user felt it.

Add /weather/cells: a single JSON endpoint returning every layer field
for every cell in the requested viewport. The hook fetches once on
mount, time change, or pan/zoom, caches the cells in memory, and paints
them via L.svgOverlay. Layer switches now re-color the same cached
cells locally — zero network, zero server CPU.

The single SVG uses lat/lon coords with preserveAspectRatio="none";
Leaflet stretches it linearly to the bbox. There is mild equirect→
mercator distortion at low zoom over CONUS but it's imperceptible at
typical use (z6+) and the layer-toggle UX win is large.

Tile endpoint kept for back-compat. svgOverlay opacity matches the
prior tile renderer's 0.55 fill-opacity.
2026-04-29 13:31:17 -05:00

1075 lines
41 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 ViewportCell {
lat: number
lon: number
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
}
interface WeatherMapHook extends ViewHook {
map: L.Map
weatherOverlay: L.SVGOverlay | null
weatherCellsUrl: string
selectedLayer: LayerId
// Cells most recently fetched. Layer switches re-render from this
// without going to the server.
currentCells: ViewportCell[]
cellsRequestSeq: number
inflightCellsAbort: AbortController | null
moveDebounce: ReturnType<typeof setTimeout> | null
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
fetchAndPaintCells(this: WeatherMapHook, opts?: { force?: boolean }): Promise<void>
paintOverlay(this: WeatherMapHook): void
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>
// Round a lat/lon viewport edge out to the cell-grid resolution so two
// nearby pans hit the same fetch URL (and the browser cache).
function quantizeBounds(b: L.LatLngBounds, pad = 0.5): { south: number; north: number; west: number; east: number } {
const south = Math.floor((b.getSouth() - pad) * 8) / 8
const north = Math.ceil((b.getNorth() + pad) * 8) / 8
const west = Math.floor((b.getWest() - pad) * 8) / 8
const east = Math.ceil((b.getEast() + pad) * 8) / 8
return { south, north, west, east }
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
function interpolateRgb(value: number, bps: Breakpoint[]): RGB {
if (value <= bps[0].value) return { r: bps[0].r, g: bps[0].g, b: bps[0].b }
const last = bps[bps.length - 1]
if (value >= last.value) return { r: last.r, g: last.g, b: last.b }
for (let i = 0; i < bps.length - 1; i++) {
const a = bps[i]
const b = bps[i + 1]
if (value >= a.value && value <= b.value) {
const t = (value - a.value) / (b.value - a.value)
return {
r: Math.round(lerp(a.r, b.r, t)),
g: Math.round(lerp(a.g, b.g, t)),
b: Math.round(lerp(a.b, b.b, t))
}
}
}
return { r: last.r, g: last.g, b: last.b }
}
function colorForCell(cell: ViewportCell, layerId: LayerId): string | null {
const scale = COLOR_SCALES[layerId]
if (!scale) return null
const value = cell[layerId as keyof ViewportCell]
if (scale.breakpoints === null) {
if (value !== true) return null
const c = scale.trueColor
return `rgb(${c.r},${c.g},${c.b})`
}
if (typeof value !== "number" || !Number.isFinite(value)) return null
const c = interpolateRgb(value, scale.breakpoints)
return `rgb(${c.r},${c.g},${c.b})`
}
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.weatherCellsUrl = "/weather/cells"
this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId
this.currentCells = []
this.cellsRequestSeq = 0
this.inflightCellsAbort = null
this.moveDebounce = null
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 })
}
this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]")
this.selectedTime = this.el.dataset.selectedTime || null
// Initial paint — fetches cells for the current viewport + time and
// paints them via L.svgOverlay. Layer switches afterwards re-color
// these same cells client-side without going to the server.
this.fetchAndPaintCells()
this.handleEvent("update_weather_overlay", ({ selected }: { selected: string | null }) => {
this.selectedTime = selected
this.fetchAndPaintCells({ force: true })
})
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)
}
// Debounce so a rapid pan-zoom only fires one fetch.
if (this.moveDebounce) clearTimeout(this.moveDebounce)
this.moveDebounce = setTimeout(() => this.fetchAndPaintCells(), 250)
})
// When the tab becomes visible again, refresh the overlay for the
// now-current viewport.
this.visibilityHandler = () => {
if (document.visibilityState === "visible") {
this.map.invalidateSize()
this.fetchAndPaintCells()
}
}
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)
// Layer switch: re-color the cells we already have. No network.
this.paintOverlay()
this.refreshLegend()
}
})
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
const timeChanged = selected !== this.selectedTime
this.timelineData = times
this.selectedTime = selected
if (dataChanged) this.timelineRenderedKey = ""
if (timeChanged) this.fetchAndPaintCells({ force: true })
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.moveDebounce) clearTimeout(this.moveDebounce)
if (this.inflightCellsAbort) this.inflightCellsAbort.abort()
if (this.visibilityHandler) {
document.removeEventListener("visibilitychange", this.visibilityHandler)
this.visibilityHandler = null
}
},
reconnected(this: WeatherMapHook) {
if (this.map) {
this.map.invalidateSize()
this.fetchAndPaintCells()
}
},
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"
}
},
renderLayer(this: WeatherMapHook) {
// Kept for back-compat with code paths that still call it. The
// server fetch + paint pipeline lives in fetchAndPaintCells; layer
// switches go through paintOverlay directly.
this.paintOverlay()
this.refreshLegend()
},
async fetchAndPaintCells(this: WeatherMapHook, opts: { force?: boolean } = {}) {
if (!this.selectedTime) {
this.currentCells = []
this.paintOverlay()
return
}
const seq = ++this.cellsRequestSeq
if (this.inflightCellsAbort) this.inflightCellsAbort.abort()
this.inflightCellsAbort = new AbortController()
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
const url = `${this.weatherCellsUrl}?time=${encodeURIComponent(this.selectedTime)}&south=${south}&north=${north}&west=${west}&east=${east}`
try {
const resp = await fetch(url, {
signal: this.inflightCellsAbort.signal,
cache: opts.force ? "no-cache" : "default"
})
if (!resp.ok) return
const data = await resp.json() as { cells: ViewportCell[]; valid_time: string }
// A newer fetch already started — drop this stale response so we
// don't paint over the in-flight one.
if (seq !== this.cellsRequestSeq) return
this.currentCells = data.cells
this.paintOverlay()
} catch (e) {
const err = e as Error
if (err.name === "AbortError") return
console.warn("weather cells fetch failed", err)
}
},
paintOverlay(this: WeatherMapHook) {
const cells = this.currentCells
if (!cells || cells.length === 0) {
if (this.weatherOverlay) {
this.map.removeLayer(this.weatherOverlay)
this.weatherOverlay = null
}
return
}
const halfStep = 0.0625
const layerId = this.selectedLayer
let south = Infinity
let north = -Infinity
let west = Infinity
let east = -Infinity
for (const c of cells) {
if (c.lat < south) south = c.lat
if (c.lat > north) north = c.lat
if (c.lon < west) west = c.lon
if (c.lon > east) east = c.lon
}
south -= halfStep
north += halfStep
west -= halfStep
east += halfStep
const parts: string[] = []
for (const cell of cells) {
const color = colorForCell(cell, layerId)
if (!color) continue
const x = (cell.lon - halfStep).toFixed(4)
// SVG y grows downward, so flip latitude.
const y = (-cell.lat - halfStep).toFixed(4)
parts.push(`<rect x="${x}" y="${y}" width="0.125" height="0.125" fill="${color}" />`)
}
const svgEl = document.createElementNS("http://www.w3.org/2000/svg", "svg")
svgEl.setAttribute("viewBox", `${west} ${-north} ${east - west} ${north - south}`)
svgEl.setAttribute("preserveAspectRatio", "none")
svgEl.setAttribute("shape-rendering", "crispEdges")
svgEl.innerHTML = parts.join("")
const bounds: L.LatLngBoundsExpression = [[south, west], [north, east]]
if (this.weatherOverlay) this.map.removeLayer(this.weatherOverlay)
this.weatherOverlay = L.svgOverlay(svgEl, bounds, { opacity: 0.55, interactive: false })
this.weatherOverlay.addTo(this.map)
},
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