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)
+ }
}
},