feat(weather): main map renders HRRR + HRDPS in parallel

/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.
This commit is contained in:
Graham McIntire 2026-04-30 12:47:15 -05:00
parent fe7d998f67
commit b6fb7d2a13
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -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 `<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
@ -79,13 +84,16 @@ interface CellPack {
interface WeatherMapHook extends ViewHook {
map: L.Map
weatherOverlay: WeatherCanvasOverlay | null
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
pack: CellPack | null
packs: Map<SourceName, CellPack>
packKey: string | null
inflightCellsAbort: AbortController | null
inflightAborts: Map<SourceName, AbortController>
moveDebounce: ReturnType<typeof setTimeout> | null
viewportDebounce: ReturnType<typeof setTimeout> | 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<void>
loadLayers(this: WeatherMapHook, layers: LayerId[], cacheKey: string): 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
@ -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<string, Float32Array>()
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<string, Float32Array>()
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
// 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)
}
return
continue
}
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)
if (!overlay) {
const newOverlay = new WeatherCanvasOverlayCtor(pack, this.selectedLayer, halfStepForSource(src))
newOverlay.addTo(this.map)
this.weatherOverlays.set(src, newOverlay)
} else {
this.weatherOverlay.pack = this.pack
this.weatherOverlay.setLayer(this.selectedLayer)
overlay.pack = pack
overlay.setLayer(this.selectedLayer)
}
}
},