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