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:
parent
fe7d998f67
commit
b6fb7d2a13
1 changed files with 97 additions and 54 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue