prop/assets/js/weather_map_hook.ts
Graham McIntire bff90da60e
fix(weather): log failed cell-tile fetches instead of swallowing them
loadLayers did `if (!resp.ok) return`, so a 413 or 400 from
/weather/cells left the map blank with no console error and no UI
feedback — indistinguishable from "no data for this forecast hour".

This is reachable in normal use: the hook sets minZoom: 4, and a z=4
viewport on a wide window clamps to ~4500 sq deg, over GridBounds'
4000 sq deg cap, so zooming all the way out silently drops the
overlay. Zooming back in restores it, which makes the failure look
intermittent and untraceable.

Log the status, requested layers and URL so the next report is
diagnosable from the browser console.
2026-08-05 17:40:58 -05:00

1496 lines
58 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
}
// Discrete band-step scale: each step is (upper-bound GHz, RGB). The
// first step whose `upTo` >= value wins. Used by the duct-cutoff
// layer so cells snap to amateur-band bins instead of interpolating.
interface BandStepsColorScale {
breakpoints: "band_steps"
steps: Array<{ upTo: number } & RGB>
format: (v: number | null) => string
label: string
unit: string
}
type ColorScale = ContinuousColorScale | BooleanColorScale | BandStepsColorScale
type LayerId = keyof typeof COLOR_SCALES
// "hrrr" is the default (CONUS); "hrdps" is the Canadian sibling read
// from `<dir>.hrdps/` chunks. /weather-ca pins to hrdps; /weather (no
// data-source attr) shows both.
type SourceName = "hrrr" | "hrdps"
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
duct_cutoff_ghz: number | null
[key: string]: unknown
}
interface DetailRow {
label?: string
value?: string
color?: string
separator?: boolean
}
// Columnar viewport pack: lats/lons shared, one Float32Array per loaded
// layer. NaN = null. Layer switches re-paint from the same pack, no
// server round-trip.
interface CellPack {
cellCount: number
lats: Float32Array
lons: Float32Array
layers: Map<string, Float32Array>
}
interface WeatherMapHook extends ViewHook {
map: L.Map
weatherOverlays: Map<SourceName, WeatherCanvasOverlay>
weatherCellsUrl: string
source: string | null
// Active sources for this map. Pinned to ["hrdps"] on /weather-ca,
// otherwise ["hrrr", "hrdps"] so the main map paints both grids.
sources: SourceName[]
selectedLayer: LayerId
bandFilter: number | null
packs: Map<SourceName, CellPack>
packKey: string | null
inflightAborts: Map<SourceName, AbortController>
moveDebounce: ReturnType<typeof setTimeout> | null
viewportDebounce: 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>
loadLayers(this: WeatherMapHook, source: SourceName, layers: LayerId[], cacheKey: string): 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
pushViewport(this: WeatherMapHook): void
}
// --- Helpers ---
function isMobile(): boolean {
return window.innerWidth < 768
}
function parseBandFilter(raw: string | undefined): number | null {
if (!raw) return null
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) && n > 0 ? n : null
}
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"
},
// Lowest band the duct supports. A low cutoff means even the
// popular lower bands work; a high cutoff means the duct is so thin
// only millimeter-wave gets trapped. Greener = broader operational
// utility.
duct_cutoff_ghz: {
breakpoints: "band_steps",
steps: [
{ upTo: 3.4, r: 0, g: 200, b: 130 },
{ upTo: 5.7, r: 0, g: 255, b: 163 },
{ upTo: 10, r: 125, g: 255, b: 212 },
{ upTo: 24, r: 255, g: 229, b: 102 },
{ upTo: 47, r: 255, g: 180, b: 80 },
{ upTo: 76, r: 255, g: 144, b: 68 },
{ upTo: 122, r: 255, g: 100, b: 80 },
{ upTo: 241, r: 255, g: 79, b: 79 },
{ upTo: Infinity, r: 200, g: 50, b: 120 }
],
format: (v: number | null): string => v != null ? `${v.toFixed(1)} GHz` : "No duct",
label: "Duct Cutoff Band",
unit: "GHz"
}
} 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
}
// HRRR is on the native 0.125° grid (0.0625 half-step); HRDPS is
// downsampled server-side to 0.5° (0.25 half-step). Each source paints
// its cells at its own native size — mixing them in one pack would
// require either a per-cell size or an artificial common grid.
function halfStepForSource(source: SourceName): number {
return source === "hrdps" ? 0.25 : 0.0625
}
function queryParamForSource(source: SourceName): string {
return source === "hrdps" ? "&source=hrdps" : ""
}
// Decode the WCEL binary cell pack (see weather_tile_controller.ex).
function decodeCellPack(buf: ArrayBuffer): { cellCount: number; layerNames: string[]; lats: Float32Array; lons: Float32Array; values: Float32Array[] } {
const view = new DataView(buf)
const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3))
if (magic !== "WCEL") throw new Error(`bad magic: ${magic}`)
const version = view.getUint8(4)
if (version !== 1) throw new Error(`unsupported version: ${version}`)
const cellCount = view.getUint32(5, true)
const layerCount = view.getUint8(9)
let off = 10
const layerNames: string[] = []
const decoder = new TextDecoder()
for (let i = 0; i < layerCount; i++) {
const len = view.getUint8(off)
off += 1
layerNames.push(decoder.decode(new Uint8Array(buf, off, len)))
off += len
}
// Pad to 4-byte boundary to align the f32 arrays.
off = Math.ceil(off / 4) * 4
const lats = new Float32Array(buf, off, cellCount)
off += cellCount * 4
const lons = new Float32Array(buf, off, cellCount)
off += cellCount * 4
const values: Float32Array[] = []
for (let i = 0; i < layerCount; i++) {
values.push(new Float32Array(buf, off, cellCount))
off += cellCount * 4
}
return { cellCount, layerNames, lats, lons, values }
}
interface RgbAndAlpha {
r: number
g: number
b: number
}
function interpolateRgb(value: number, bps: Breakpoint[]): RgbAndAlpha {
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 }
}
// Build a 256-bucket lookup table over a continuous breakpoint scale.
// Each bucket stores RGB packed as a CSS color string. Lets the canvas
// paint avoid re-interpolating colors per cell.
function buildContinuousLut(bps: Breakpoint[]): { lo: number; hi: number; palette: string[] } {
const lo = bps[0].value
const hi = bps[bps.length - 1].value
const palette: string[] = new Array(256)
for (let i = 0; i < 256; i++) {
const v = lo + (hi - lo) * (i / 255)
const c = interpolateRgb(v, bps)
palette[i] = `rgb(${c.r},${c.g},${c.b})`
}
return { lo, hi, palette }
}
interface PaintScale {
kind: "continuous"
lo: number
hi: number
palette: string[]
}
interface BoolPaintScale {
kind: "bool"
trueColor: string
}
// Discrete steps with an optional ≤ threshold filter ("band picker
// mode"). When `mask` is set, only paint cells whose value is ≤ mask;
// when null, paint all cells using their step color.
interface BandStepsPaintScale {
kind: "band_steps"
steps: Array<{ upTo: number; color: string }>
mask: number | null
}
type AnyPaintScale = PaintScale | BoolPaintScale | BandStepsPaintScale
function buildPaintScale(layerId: LayerId, bandFilter: number | null): AnyPaintScale | null {
const scale = COLOR_SCALES[layerId]
if (!scale) return null
if (scale.breakpoints === null) {
const c = scale.trueColor
return { kind: "bool", trueColor: `rgb(${c.r},${c.g},${c.b})` }
}
if (scale.breakpoints === "band_steps") {
return {
kind: "band_steps",
steps: scale.steps.map(s => ({ upTo: s.upTo, color: `rgb(${s.r},${s.g},${s.b})` })),
mask: bandFilter
}
}
return { kind: "continuous", ...buildContinuousLut(scale.breakpoints) }
}
// --- Canvas overlay layer ---
//
// A custom Leaflet layer that paints viewport cells onto a single
// HTML canvas. The canvas tracks the map's container; pan repositions
// it, zoom triggers a full repaint. Replaces the old per-tile <rect>
// SVG overlay — at low zoom (whole-CONUS, ~80k cells) SVG paint cost
// dominated; canvas is one fillRect per cell with a precomputed LUT.
const WeatherCanvasOverlayProto: L.LayerOptions & {
initialize: (this: WeatherCanvasOverlay, pack: CellPack | null, layerId: LayerId, halfStep: number) => void
onAdd: (this: WeatherCanvasOverlay, map: L.Map) => WeatherCanvasOverlay
onRemove: (this: WeatherCanvasOverlay, map: L.Map) => WeatherCanvasOverlay
setPack: (this: WeatherCanvasOverlay, pack: CellPack | null) => void
setLayer: (this: WeatherCanvasOverlay, layerId: LayerId) => void
setBandFilter: (this: WeatherCanvasOverlay, band: number | null) => void
_reset: (this: WeatherCanvasOverlay) => void
_draw: (this: WeatherCanvasOverlay) => void
} = {
initialize(pack: CellPack | null, layerId: LayerId, halfStep: number): void {
this.pack = pack
this.layerId = layerId
this.halfStep = halfStep
this.bandFilter = null
},
onAdd(map: L.Map): WeatherCanvasOverlay {
this._map = map
const canvas = L.DomUtil.create("canvas", "weather-canvas-overlay leaflet-zoom-hide") as HTMLCanvasElement
canvas.style.pointerEvents = "none"
canvas.style.opacity = "0.55"
this._canvas = canvas
map.getPanes().overlayPane.appendChild(canvas)
map.on("moveend", this._reset, this)
map.on("zoomend", this._reset, this)
this._reset()
return this
},
onRemove(map: L.Map): WeatherCanvasOverlay {
map.off("moveend", this._reset, this)
map.off("zoomend", this._reset, this)
L.DomUtil.remove(this._canvas)
return this
},
setPack(pack: CellPack | null): void {
this.pack = pack
this._reset()
},
setLayer(layerId: LayerId): void {
this.layerId = layerId
this._reset()
},
setBandFilter(band: number | null): void {
this.bandFilter = band
this._reset()
},
_reset(): void {
if (!this._map) return
const map = this._map
const size = map.getSize()
const topLeft = map.containerPointToLayerPoint([0, 0])
L.DomUtil.setPosition(this._canvas, topLeft)
this._canvas.width = size.x
this._canvas.height = size.y
this._draw()
},
_draw(): void {
const ctx = this._canvas.getContext("2d")
if (!ctx) return
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height)
if (!this.pack) return
const values = this.pack.layers.get(this.layerId)
if (!values) return
const paintScale = buildPaintScale(this.layerId, this.bandFilter)
if (!paintScale) return
const map = this._map
const halfStep = this.halfStep
const lats = this.pack.lats
const lons = this.pack.lons
const n = this.pack.cellCount
// Cache mapping of (lat, lon, halfStep) → container points across
// a single draw. Two adjacent cells share an edge in container
// space, so we project each cell-edge once.
let prevColor = ""
for (let i = 0; i < n; i++) {
const v = values[i]
if (!Number.isFinite(v)) continue
let color: string
if (paintScale.kind === "bool") {
if (v < 0.5) continue
color = paintScale.trueColor
} else if (paintScale.kind === "band_steps") {
// Band-picker mode: only paint cells whose cutoff is at or
// below the selected band — that's the operational "this band
// works here" mask. Overview mode (mask null) paints every
// cell by its step color.
if (paintScale.mask != null && v > paintScale.mask) continue
const step = paintScale.steps.find(s => v <= s.upTo)
if (!step) continue
color = step.color
} else {
const t = (v - paintScale.lo) / (paintScale.hi - paintScale.lo)
const idx = t <= 0 ? 0 : t >= 1 ? 255 : Math.round(t * 255)
color = paintScale.palette[idx]
}
const lat = lats[i]
const lon = lons[i]
const ne = map.latLngToContainerPoint([lat + halfStep, lon + halfStep])
const sw = map.latLngToContainerPoint([lat - halfStep, lon - halfStep])
if (color !== prevColor) {
ctx.fillStyle = color
prevColor = color
}
ctx.fillRect(sw.x, ne.y, ne.x - sw.x, sw.y - ne.y)
}
}
}
interface WeatherCanvasOverlay extends L.Layer {
pack: CellPack | null
layerId: LayerId
halfStep: number
bandFilter: number | null
_map: L.Map
_canvas: HTMLCanvasElement
setPack(pack: CellPack | null): void
setLayer(layerId: LayerId): void
setBandFilter(band: number | null): void
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WeatherCanvasOverlayCtor: new (pack: CellPack | null, layerId: LayerId, halfStep: number) => WeatherCanvasOverlay = (L.Layer as any).extend(WeatherCanvasOverlayProto)
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" },
{ label: "Duct Cutoff", value: COLOR_SCALES.duct_cutoff_ghz.format(point.duct_cutoff_ghz), color: point.duct_cutoff_ghz != null ? "#00ffa3" : "#666" }
]
const mobile = isMobile()
const closeBtn = `<button class="detail-close-btn" title="Close" 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:24px;height:24px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:1;">&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) {
// Initial center/zoom comes from `?lat=&lon=&zoom=` URL params via
// the LiveView, falling back to DFW@z7 when missing. Reload-friendly:
// the hook below pushes `viewport_changed` on every moveend/zoomend
// so the URL stays in sync.
const dataLat = parseFloat(this.el.dataset.initialLat || "")
const dataLon = parseFloat(this.el.dataset.initialLon || "")
const dataZoom = parseInt(this.el.dataset.initialZoom || "", 10)
const initialLat = Number.isFinite(dataLat) ? dataLat : 32.897
const initialLon = Number.isFinite(dataLon) ? dataLon : -97.038
const initialZoom = Number.isFinite(dataZoom) ? dataZoom : 7
this.map = L.map(this.el, {
center: [initialLat, initialLon],
zoom: initialZoom,
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.weatherOverlays = new Map()
this.weatherCellsUrl = "/weather/cells"
this.source = this.el.dataset.source || null
// /weather-ca pins to HRDPS-only via data-source. The default main
// map fetches both — server-side bounds filtering at the chunk
// level already prunes payloads outside each grid's domain, so a
// CONUS-only viewport gets an empty HRDPS pack and vice versa.
this.sources = this.source === "hrdps" ? ["hrdps"] : ["hrrr", "hrdps"]
this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId
this.bandFilter = parseBandFilter(this.el.dataset.bandFilter)
this.packs = new Map()
this.packKey = null
this.inflightAborts = new Map()
this.moveDebounce = null
this.viewportDebounce = 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.handleEvent("set_band_filter", ({ band }: { band: number | null }) => {
this.bandFilter = (typeof band === "number" && Number.isFinite(band)) ? band : null
for (const overlay of this.weatherOverlays.values()) {
overlay.setBandFilter(this.bandFilter)
}
})
this.detailPanel = document.getElementById("weather-detail-panel")
if (this.detailPanel) {
L.DomEvent.disableClickPropagation(this.detailPanel)
L.DomEvent.disableScrollPropagation(this.detailPanel)
this.detailPanel.addEventListener("click", (e: MouseEvent) => {
if ((e.target as HTMLElement).closest(".detail-close-btn") && this.detailPanel) {
this.detailPanel.style.display = "none"
this.detailPanel.innerHTML = ""
this.clickMarker?.clearLayers()
if (this.escHint) this.escHint.style.opacity = "0"
}
})
}
// 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)
// Sync the URL with the current viewport so a reload lands the
// user at the same place. Separate debounce from the cells fetch
// — the URL push is cheap (server-side push_patch with replace:
// true) but we still don't want one per pixel of pan.
if (this.viewportDebounce) clearTimeout(this.viewportDebounce)
this.viewportDebounce = setTimeout(() => this.pushViewport(), 400)
})
// 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(() => {
let changed = false
const newLayer = this.el.dataset.selectedLayer as LayerId | undefined
if (newLayer && newLayer !== this.selectedLayer) {
this.selectedLayer = newLayer
localStorage.setItem("weatherMap.selectedLayer", newLayer)
changed = true
}
const newBand = parseBandFilter(this.el.dataset.bandFilter)
if (newBand !== this.bandFilter) {
this.bandFilter = newBand
for (const overlay of this.weatherOverlays.values()) {
overlay.bandFilter = newBand
}
changed = true
}
if (changed) {
// Layer or band 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", "data-band-filter"]
})
// 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.viewportDebounce) clearTimeout(this.viewportDebounce)
for (const ab of this.inflightAborts.values()) ab.abort()
this.inflightAborts.clear()
if (this.visibilityHandler) {
document.removeEventListener("visibilitychange", this.visibilityHandler)
this.visibilityHandler = null
}
},
// Push the current viewport (lat, lon, zoom) to the LiveView so the
// URL can be patched with `?lat=&lon=&zoom=`. Called from a debounced
// moveend handler so a rapid pan doesn't spam the channel. The
// server-side push_patch uses replace: true, so the browser history
// doesn't grow an entry per pan tick.
pushViewport(this: WeatherMapHook) {
if (!this.map) return
const center = this.map.getCenter()
const zoom = this.map.getZoom()
if (!Number.isFinite(center.lat) || !Number.isFinite(center.lng)) return
this.pushEvent("viewport_changed", {
lat: center.lat,
lon: center.lng,
zoom: zoom
})
},
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.
this.paintOverlay()
this.refreshLegend()
},
async fetchAndPaintCells(this: WeatherMapHook, opts: { force?: boolean } = {}) {
if (!this.selectedTime) {
this.packs.clear()
this.packKey = null
this.paintOverlay()
return
}
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
const cacheKey = `${this.selectedTime}|${south},${north},${west},${east}`
// New viewport or time → cancel inflights and drop existing packs.
if (cacheKey !== this.packKey || opts.force) {
for (const ab of this.inflightAborts.values()) ab.abort()
this.inflightAborts.clear()
this.packs.clear()
this.packKey = cacheKey
}
// Step 1: ensure the currently selected layer is loaded for every
// active source, then paint. Sources fetch in parallel.
await Promise.all(this.sources.map((src) => {
const existing = this.packs.get(src)
if (existing && existing.layers.has(this.selectedLayer)) return Promise.resolve()
return this.loadLayers(src, [this.selectedLayer], cacheKey)
}))
this.paintOverlay()
// Step 2: lazy-load every other layer per source so toggling
// between layers later is instant. One request per source batches
// its remaining layers so the server's GridCache lookup runs once.
if (this.packKey !== cacheKey) return
for (const src of this.sources) {
const p = this.packs.get(src)
if (!p) continue
const remaining = (Object.keys(COLOR_SCALES) as LayerId[]).filter(
(l) => !p.layers.has(l)
)
if (remaining.length > 0) {
void this.loadLayers(src, remaining, cacheKey)
}
}
},
async loadLayers(this: WeatherMapHook, source: SourceName, layers: LayerId[], cacheKey: string) {
if (!this.selectedTime || layers.length === 0) return
if (cacheKey !== this.packKey) return
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
const url =
`${this.weatherCellsUrl}?format=bin&time=${encodeURIComponent(this.selectedTime)}` +
`&south=${south}&north=${north}&west=${west}&east=${east}` +
`&layers=${layers.join(",")}` +
queryParamForSource(source)
const abort = new AbortController()
this.inflightAborts.set(source, abort)
try {
const resp = await fetch(url, { signal: abort.signal })
if (!resp.ok) {
// Never swallow a failed tile fetch. A 413 (viewport too large —
// reachable by zooming to minZoom on a wide window) or a 400
// leaves the map blank with no other signal, which is
// indistinguishable from "no data for this hour" when someone
// reports a missing overlay.
console.warn(
`weather cells fetch failed (${source}): HTTP ${resp.status}`,
{ status: resp.status, layers, url }
)
return
}
const buf = await resp.arrayBuffer()
if (cacheKey !== this.packKey) return
const decoded = decodeCellPack(buf)
// Skip empty responses — leaving the source out of `packs`
// suppresses both its overlay and any further lazy fetches for
// this viewport (paintOverlay's empty-pack branch removes a
// stale overlay if one exists).
if (decoded.cellCount === 0) return
const existing = this.packs.get(source)
if (!existing) {
const layerMap = new Map<string, Float32Array>()
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
})
} else if (decoded.cellCount === existing.cellCount) {
decoded.layerNames.forEach((name, i) => existing.layers.set(name, decoded.values[i]))
} else {
// Cell count drifted (rare — viewport quantization mismatch).
// Replace the pack rather than mix incompatible arrays.
const layerMap = new Map<string, Float32Array>()
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 `<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>`
}
if (scale.breakpoints === "band_steps") {
const steps = (scale as BandStepsColorScale).steps
const heading = this.bandFilter != null
? `${scale.label} &middot; ≤ ${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)
? `&gt; ${prev} GHz`
: prev === 0
? `${s.upTo} GHz`
: `${s.upTo} GHz`
const dim = this.bandFilter != null && prev > this.bandFilter ? "opacity:0.3;" : ""
return `<span style="${dim}"><span style="background:rgb(${s.r},${s.g},${s.b});width:${mobile ? '10px' : '14px'};height:${mobile ? '10px' : '14px'};display:inline-block;border-radius:50%;vertical-align:middle;margin-right:${mobile ? '3px' : '6px'};border:1px solid rgba(0,0,0,0.15);"></span><span style="color:#333;">${label}</span></span>`
}
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 ? "" : `<strong style="color:#333;">${heading}</strong><br>`
return `<div style="${wrapStyle}">${header}${steps.map(rowFor).join("<br>")}</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