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
|
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 {
|
interface WeatherPoint {
|
||||||
lat: number
|
lat: number
|
||||||
lon: number
|
lon: number
|
||||||
|
|
@ -79,13 +84,16 @@ interface CellPack {
|
||||||
|
|
||||||
interface WeatherMapHook extends ViewHook {
|
interface WeatherMapHook extends ViewHook {
|
||||||
map: L.Map
|
map: L.Map
|
||||||
weatherOverlay: WeatherCanvasOverlay | null
|
weatherOverlays: Map<SourceName, WeatherCanvasOverlay>
|
||||||
weatherCellsUrl: string
|
weatherCellsUrl: string
|
||||||
source: string | null
|
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
|
selectedLayer: LayerId
|
||||||
pack: CellPack | null
|
packs: Map<SourceName, CellPack>
|
||||||
packKey: string | null
|
packKey: string | null
|
||||||
inflightCellsAbort: AbortController | null
|
inflightAborts: Map<SourceName, AbortController>
|
||||||
moveDebounce: ReturnType<typeof setTimeout> | null
|
moveDebounce: ReturnType<typeof setTimeout> | null
|
||||||
viewportDebounce: ReturnType<typeof setTimeout> | null
|
viewportDebounce: ReturnType<typeof setTimeout> | null
|
||||||
detailPanel: HTMLElement | null
|
detailPanel: HTMLElement | null
|
||||||
|
|
@ -115,7 +123,7 @@ interface WeatherMapHook extends ViewHook {
|
||||||
positionDetailPanel(this: WeatherMapHook): void
|
positionDetailPanel(this: WeatherMapHook): void
|
||||||
renderLayer(this: WeatherMapHook): void
|
renderLayer(this: WeatherMapHook): void
|
||||||
fetchAndPaintCells(this: WeatherMapHook, opts?: { force?: boolean }): Promise<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
|
paintOverlay(this: WeatherMapHook): void
|
||||||
refreshLegend(this: WeatherMapHook): void
|
refreshLegend(this: WeatherMapHook): void
|
||||||
buildLegendContent(this: WeatherMapHook): string
|
buildLegendContent(this: WeatherMapHook): string
|
||||||
|
|
@ -369,6 +377,18 @@ function lerp(a: number, b: number, t: number): number {
|
||||||
return a + (b - a) * t
|
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).
|
// 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[] } {
|
function decodeCellPack(buf: ArrayBuffer): { cellCount: number; layerNames: string[]; lats: Float32Array; lons: Float32Array; values: Float32Array[] } {
|
||||||
const view = new DataView(buf)
|
const view = new DataView(buf)
|
||||||
|
|
@ -679,13 +699,18 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
maxZoom: 19
|
maxZoom: 19
|
||||||
}).addTo(this.map)
|
}).addTo(this.map)
|
||||||
|
|
||||||
this.weatherOverlay = null
|
this.weatherOverlays = new Map()
|
||||||
this.weatherCellsUrl = "/weather/cells"
|
this.weatherCellsUrl = "/weather/cells"
|
||||||
this.source = this.el.dataset.source || null
|
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.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId
|
||||||
this.pack = null
|
this.packs = new Map()
|
||||||
this.packKey = null
|
this.packKey = null
|
||||||
this.inflightCellsAbort = null
|
this.inflightAborts = new Map()
|
||||||
this.moveDebounce = null
|
this.moveDebounce = null
|
||||||
this.viewportDebounce = null
|
this.viewportDebounce = null
|
||||||
|
|
||||||
|
|
@ -903,7 +928,8 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
if (this.playbackTimer !== null) clearInterval(this.playbackTimer)
|
if (this.playbackTimer !== null) clearInterval(this.playbackTimer)
|
||||||
if (this.moveDebounce) clearTimeout(this.moveDebounce)
|
if (this.moveDebounce) clearTimeout(this.moveDebounce)
|
||||||
if (this.viewportDebounce) clearTimeout(this.viewportDebounce)
|
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) {
|
if (this.visibilityHandler) {
|
||||||
document.removeEventListener("visibilitychange", this.visibilityHandler)
|
document.removeEventListener("visibilitychange", this.visibilityHandler)
|
||||||
this.visibilityHandler = null
|
this.visibilityHandler = null
|
||||||
|
|
@ -952,7 +978,7 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
|
|
||||||
async fetchAndPaintCells(this: WeatherMapHook, opts: { force?: boolean } = {}) {
|
async fetchAndPaintCells(this: WeatherMapHook, opts: { force?: boolean } = {}) {
|
||||||
if (!this.selectedTime) {
|
if (!this.selectedTime) {
|
||||||
this.pack = null
|
this.packs.clear()
|
||||||
this.packKey = null
|
this.packKey = null
|
||||||
this.paintOverlay()
|
this.paintOverlay()
|
||||||
return
|
return
|
||||||
|
|
@ -961,48 +987,52 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
|
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
|
||||||
const cacheKey = `${this.selectedTime}|${south},${north},${west},${east}`
|
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 (cacheKey !== this.packKey || opts.force) {
|
||||||
if (this.inflightCellsAbort) this.inflightCellsAbort.abort()
|
for (const ab of this.inflightAborts.values()) ab.abort()
|
||||||
this.pack = null
|
this.inflightAborts.clear()
|
||||||
|
this.packs.clear()
|
||||||
this.packKey = cacheKey
|
this.packKey = cacheKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: ensure the currently selected layer is loaded, then paint.
|
// Step 1: ensure the currently selected layer is loaded for every
|
||||||
if (!this.pack || !this.pack.layers.has(this.selectedLayer)) {
|
// active source, then paint. Sources fetch in parallel.
|
||||||
await this.loadLayers([this.selectedLayer], cacheKey)
|
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()
|
this.paintOverlay()
|
||||||
|
|
||||||
// Step 2: in the background, lazy-load every other layer so
|
// Step 2: lazy-load every other layer per source so toggling
|
||||||
// toggling between them later is instant. One request batches them
|
// between layers later is instant. One request per source batches
|
||||||
// all so the server's GridCache lookup runs once.
|
// its remaining layers so the server's GridCache lookup runs once.
|
||||||
if (this.pack && this.packKey === cacheKey) {
|
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(
|
const remaining = (Object.keys(COLOR_SCALES) as LayerId[]).filter(
|
||||||
(l) => !this.pack!.layers.has(l)
|
(l) => !p.layers.has(l)
|
||||||
)
|
)
|
||||||
if (remaining.length > 0) {
|
if (remaining.length > 0) {
|
||||||
// Fire-and-forget; loadLayers updates this.pack and triggers
|
void this.loadLayers(src, remaining, cacheKey)
|
||||||
// no repaint (layer switches will pick up the new data).
|
|
||||||
void this.loadLayers(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 (!this.selectedTime || layers.length === 0) return
|
||||||
if (cacheKey !== this.packKey) return
|
if (cacheKey !== this.packKey) return
|
||||||
|
|
||||||
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
|
const { south, north, west, east } = quantizeBounds(this.map.getBounds())
|
||||||
const sourceQuery = this.source ? `&source=${encodeURIComponent(this.source)}` : ""
|
|
||||||
const url =
|
const url =
|
||||||
`${this.weatherCellsUrl}?format=bin&time=${encodeURIComponent(this.selectedTime)}` +
|
`${this.weatherCellsUrl}?format=bin&time=${encodeURIComponent(this.selectedTime)}` +
|
||||||
`&south=${south}&north=${north}&west=${west}&east=${east}` +
|
`&south=${south}&north=${north}&west=${west}&east=${east}` +
|
||||||
`&layers=${layers.join(",")}` +
|
`&layers=${layers.join(",")}` +
|
||||||
sourceQuery
|
queryParamForSource(source)
|
||||||
|
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
if (layers.length === 1 || !this.pack) this.inflightCellsAbort = abort
|
this.inflightAborts.set(source, abort)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, { signal: abort.signal })
|
const resp = await fetch(url, { signal: abort.signal })
|
||||||
|
|
@ -1011,57 +1041,70 @@ export const WeatherMap: WeatherMapHook = {
|
||||||
if (cacheKey !== this.packKey) return
|
if (cacheKey !== this.packKey) return
|
||||||
|
|
||||||
const decoded = decodeCellPack(buf)
|
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>()
|
const layerMap = new Map<string, Float32Array>()
|
||||||
decoded.layerNames.forEach((name, i) => layerMap.set(name, decoded.values[i]))
|
decoded.layerNames.forEach((name, i) => layerMap.set(name, decoded.values[i]))
|
||||||
this.pack = {
|
this.packs.set(source, {
|
||||||
cellCount: decoded.cellCount,
|
cellCount: decoded.cellCount,
|
||||||
lats: decoded.lats,
|
lats: decoded.lats,
|
||||||
lons: decoded.lons,
|
lons: decoded.lons,
|
||||||
layers: layerMap
|
layers: layerMap
|
||||||
}
|
})
|
||||||
} else if (decoded.cellCount === this.pack.cellCount) {
|
} else if (decoded.cellCount === existing.cellCount) {
|
||||||
decoded.layerNames.forEach((name, i) => this.pack!.layers.set(name, decoded.values[i]))
|
decoded.layerNames.forEach((name, i) => existing.layers.set(name, decoded.values[i]))
|
||||||
} else {
|
} else {
|
||||||
// Cell count drifted (rare — viewport quantization mismatch).
|
// Cell count drifted (rare — viewport quantization mismatch).
|
||||||
// Replace the pack rather than mix incompatible arrays.
|
// Replace the pack rather than mix incompatible arrays.
|
||||||
const layerMap = new Map<string, Float32Array>()
|
const layerMap = new Map<string, Float32Array>()
|
||||||
decoded.layerNames.forEach((name, i) => layerMap.set(name, decoded.values[i]))
|
decoded.layerNames.forEach((name, i) => layerMap.set(name, decoded.values[i]))
|
||||||
this.pack = {
|
this.packs.set(source, {
|
||||||
cellCount: decoded.cellCount,
|
cellCount: decoded.cellCount,
|
||||||
lats: decoded.lats,
|
lats: decoded.lats,
|
||||||
lons: decoded.lons,
|
lons: decoded.lons,
|
||||||
layers: layerMap
|
layers: layerMap
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as Error
|
const err = e as Error
|
||||||
if (err.name === "AbortError") return
|
if (err.name === "AbortError") return
|
||||||
console.warn("weather cells fetch failed", err)
|
console.warn(`weather cells fetch failed (${source})`, err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
paintOverlay(this: WeatherMapHook) {
|
paintOverlay(this: WeatherMapHook) {
|
||||||
if (!this.pack || this.pack.cellCount === 0) {
|
// Order matters: HRDPS goes first so HRRR's higher-resolution
|
||||||
if (this.weatherOverlay) {
|
// canvas paints on top along the US/Canada border where both have
|
||||||
this.map.removeLayer(this.weatherOverlay)
|
// coverage. Leaflet stacks overlayPane children in DOM (add) order.
|
||||||
this.weatherOverlay = null
|
const renderOrder: SourceName[] = ["hrdps", "hrrr"]
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.weatherOverlay) {
|
for (const src of renderOrder) {
|
||||||
// HRRR is on a 0.125° native grid → 0.0625 half-step.
|
const overlay = this.weatherOverlays.get(src)
|
||||||
// HRDPS in this app is downsampled to 0.5° → 0.25 half-step.
|
const active = this.sources.includes(src)
|
||||||
// Without this, HRDPS cells were painted 1/16th their actual area
|
const pack = active ? this.packs.get(src) : undefined
|
||||||
// and looked like a sparse pinstripe instead of an overlay.
|
|
||||||
const halfStep = this.source === "hrdps" ? 0.25 : 0.0625
|
if (!pack || pack.cellCount === 0) {
|
||||||
this.weatherOverlay = new WeatherCanvasOverlayCtor(this.pack, this.selectedLayer, halfStep)
|
if (overlay) {
|
||||||
this.weatherOverlay.addTo(this.map)
|
this.map.removeLayer(overlay)
|
||||||
} else {
|
this.weatherOverlays.delete(src)
|
||||||
this.weatherOverlay.pack = this.pack
|
}
|
||||||
this.weatherOverlay.setLayer(this.selectedLayer)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue