From b6fb7d2a13551f0028d1ba03004b9647c8c22da6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 30 Apr 2026 12:47:15 -0500 Subject: [PATCH] feat(weather): main map renders HRRR + HRDPS in parallel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /weather now fetches both grids and paints each as its own canvas overlay at its native cell size (HRRR 0.125°, HRDPS 0.5°). Server-side chunk filtering means a US-only viewport gets an empty HRDPS pack and vice versa — no wasted payload. /weather-ca's HRDPS-only mode is unchanged. Hook state moves from single (pack/overlay/abort) to per-source maps keyed by "hrrr"|"hrdps". Render order is fixed (hrdps first, hrrr second) so HRRR's higher-res canvas stacks on top along the border where both sources cover the viewport. --- assets/js/weather_map_hook.ts | 151 ++++++++++++++++++++++------------ 1 file changed, 97 insertions(+), 54 deletions(-) diff --git a/assets/js/weather_map_hook.ts b/assets/js/weather_map_hook.ts index ad238b08..dc05f2bc 100644 --- a/assets/js/weather_map_hook.ts +++ b/assets/js/weather_map_hook.ts @@ -34,6 +34,11 @@ type ColorScale = ContinuousColorScale | BooleanColorScale 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 @@ -79,13 +84,16 @@ interface CellPack { interface WeatherMapHook extends ViewHook { map: L.Map - weatherOverlay: WeatherCanvasOverlay | null + 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 - pack: CellPack | null + packs: Map packKey: string | null - inflightCellsAbort: AbortController | null + inflightAborts: Map moveDebounce: ReturnType | null viewportDebounce: ReturnType | null detailPanel: HTMLElement | null @@ -115,7 +123,7 @@ interface WeatherMapHook extends ViewHook { positionDetailPanel(this: WeatherMapHook): void renderLayer(this: WeatherMapHook): void fetchAndPaintCells(this: WeatherMapHook, opts?: { force?: boolean }): Promise - loadLayers(this: WeatherMapHook, layers: LayerId[], cacheKey: string): Promise + loadLayers(this: WeatherMapHook, source: SourceName, layers: LayerId[], cacheKey: string): Promise paintOverlay(this: WeatherMapHook): void refreshLegend(this: WeatherMapHook): void buildLegendContent(this: WeatherMapHook): string @@ -369,6 +377,18 @@ 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) @@ -679,13 +699,18 @@ export const WeatherMap: WeatherMapHook = { maxZoom: 19 }).addTo(this.map) - this.weatherOverlay = null + 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.pack = null + this.packs = new Map() this.packKey = null - this.inflightCellsAbort = null + this.inflightAborts = new Map() this.moveDebounce = null this.viewportDebounce = null @@ -903,7 +928,8 @@ export const WeatherMap: WeatherMapHook = { if (this.playbackTimer !== null) clearInterval(this.playbackTimer) if (this.moveDebounce) clearTimeout(this.moveDebounce) if (this.viewportDebounce) clearTimeout(this.viewportDebounce) - if (this.inflightCellsAbort) this.inflightCellsAbort.abort() + for (const ab of this.inflightAborts.values()) ab.abort() + this.inflightAborts.clear() if (this.visibilityHandler) { document.removeEventListener("visibilitychange", this.visibilityHandler) this.visibilityHandler = null @@ -952,7 +978,7 @@ export const WeatherMap: WeatherMapHook = { async fetchAndPaintCells(this: WeatherMapHook, opts: { force?: boolean } = {}) { if (!this.selectedTime) { - this.pack = null + this.packs.clear() this.packKey = null this.paintOverlay() return @@ -961,48 +987,52 @@ export const WeatherMap: WeatherMapHook = { const { south, north, west, east } = quantizeBounds(this.map.getBounds()) const cacheKey = `${this.selectedTime}|${south},${north},${west},${east}` - // New viewport or time → drop the existing pack. + // New viewport or time → cancel inflights and drop existing packs. if (cacheKey !== this.packKey || opts.force) { - if (this.inflightCellsAbort) this.inflightCellsAbort.abort() - this.pack = null + 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, then paint. - if (!this.pack || !this.pack.layers.has(this.selectedLayer)) { - await this.loadLayers([this.selectedLayer], 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: in the background, lazy-load every other layer so - // toggling between them later is instant. One request batches them - // all so the server's GridCache lookup runs once. - if (this.pack && this.packKey === cacheKey) { + // 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) => !this.pack!.layers.has(l) + (l) => !p.layers.has(l) ) if (remaining.length > 0) { - // Fire-and-forget; loadLayers updates this.pack and triggers - // no repaint (layer switches will pick up the new data). - void this.loadLayers(remaining, cacheKey) + void this.loadLayers(src, remaining, cacheKey) } } }, - async loadLayers(this: WeatherMapHook, layers: LayerId[], cacheKey: string) { + 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 sourceQuery = this.source ? `&source=${encodeURIComponent(this.source)}` : "" const url = `${this.weatherCellsUrl}?format=bin&time=${encodeURIComponent(this.selectedTime)}` + `&south=${south}&north=${north}&west=${west}&east=${east}` + `&layers=${layers.join(",")}` + - sourceQuery + queryParamForSource(source) const abort = new AbortController() - if (layers.length === 1 || !this.pack) this.inflightCellsAbort = abort + this.inflightAborts.set(source, abort) try { const resp = await fetch(url, { signal: abort.signal }) @@ -1011,57 +1041,70 @@ export const WeatherMap: WeatherMapHook = { 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 - if (!this.pack) { + const existing = this.packs.get(source) + if (!existing) { const layerMap = new Map() decoded.layerNames.forEach((name, i) => layerMap.set(name, decoded.values[i])) - this.pack = { + this.packs.set(source, { cellCount: decoded.cellCount, lats: decoded.lats, lons: decoded.lons, layers: layerMap - } - } else if (decoded.cellCount === this.pack.cellCount) { - decoded.layerNames.forEach((name, i) => this.pack!.layers.set(name, decoded.values[i])) + }) + } 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.pack = { + 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", err) + console.warn(`weather cells fetch failed (${source})`, err) } }, paintOverlay(this: WeatherMapHook) { - if (!this.pack || this.pack.cellCount === 0) { - if (this.weatherOverlay) { - this.map.removeLayer(this.weatherOverlay) - this.weatherOverlay = null - } - return - } + // 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"] - if (!this.weatherOverlay) { - // HRRR is on a 0.125° native grid → 0.0625 half-step. - // HRDPS in this app is downsampled to 0.5° → 0.25 half-step. - // Without this, HRDPS cells were painted 1/16th their actual area - // and looked like a sparse pinstripe instead of an overlay. - const halfStep = this.source === "hrdps" ? 0.25 : 0.0625 - this.weatherOverlay = new WeatherCanvasOverlayCtor(this.pack, this.selectedLayer, halfStep) - this.weatherOverlay.addTo(this.map) - } else { - this.weatherOverlay.pack = this.pack - this.weatherOverlay.setLayer(this.selectedLayer) + 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.addTo(this.map) + this.weatherOverlays.set(src, newOverlay) + } else { + overlay.pack = pack + overlay.setLayer(this.selectedLayer) + } } },