A static single-page app that animates the next two days of NOAA HRRR forecasts — near-surface smoke and precipitation rate — on a map centered on your location. All data is read directly in the browser from dynamical.org's icechunk stores; there is no backend.
- icechunk-js + zarrita
open two icechunk repositories straight from S3: the
18 hour
and 48 hour
HRRR forecasts. Chunks are virtual: byte ranges into NOAA's original
GRIB2 files on
noaa-hrrr-bdp-pds(all buckets are public + CORS-enabled). Both are map-optimized — one whole grid per (init, lead), which is what painting a frame wants; the point readout reads a time-optimized store instead (see Data & attribution). - The two runs are spliced on valid time (
src/lib/splice.ts). HRRR runs the 18-hour forecast every hour and the 48-hour one only at 00/06/12/18 UTC, so neither alone is what you want: the hourly run is fresher (and more skilful) but stops at +18 h, while the six-hourly run reaches +48 h with a nose up to five hours stale. Every forecast hour is served by whichever run has the newest init that still reaches it, and hour 0 of the timeline is the newest init in play. One consequence is that the timeline is not always 48 hours long: with the six-hourly run five hours behind, +48 h of that run is +43 h of the timeline, and that is where the scrubber ends. Losing a store is survivable — the map falls back to whatever the other one covers. - A pure-TypeScript GRIB2 decoder (
src/lib/grib/decoder.ts, registered as the store'sgribberishzarr codec) decodes complex-packed messages (DRS templates 5.0/5.2/5.3) — no WASM, no COOP/COEP headers needed. Unit tests cross-validate it element-wise against the native gribberish library. - A Web Worker owns every store, finds each one's latest complete forecast init (1-byte manifest probes), and streams frames progressively (every 6 h first — playable after ~5 MB — then 3 h, then hourly; ~35 MB total). Fields are quantized to log-scale bytes (block-max downsampled 2× on phones).
- Frames render on the GPU via a MapLibre custom layer: a fragment
shader inverts each screen pixel through the Lambert conformal projection,
crossfades two quantized frame textures, and applies the palette LUT — so
animation costs the CPU almost nothing. Where WebGL2 (or the shader) won't
initialize, a fallback renderer paints frames in the worker through a
precomputed Lambert-conformal → web-mercator index map and blits them
into MapLibre canvas sources (
?gpu=0/?gpu=1force a renderer).
npm install
npm run dev # live app against the real store
npm run test # unit tests (decoder vs gribberish oracle, projection, colormaps, ...)
npm run test:e2e # Playwright: behavior + visual snapshots, offline via recorded fixtures
npm run lint && npm run typecheckUseful dev scripts:
node --experimental-strip-types scripts/spike-read.ts # end-to-end store read + network stats
node --experimental-strip-types scripts/inspect-store.ts # dump store hierarchy/metadata
node scripts/screenshot.mjs # screenshot the running dev server
npm run fetch-grib-fixtures # refresh unit-test GRIB messages (pinned date)
npm run record-fixtures # top up e2e HTTP fixtures (keeps recorded init time)
npm run record-fixtures -- --prune # also drop bodies nothing requested any more
npm run record-fixtures -- --fresh # re-record from scratch (new init: refresh snapshots)- Unit tests treat native gribberish as an oracle: real HRRR PRATE/MASSDEN messages are decoded by both implementations and compared element-wise; projection math is checked against gribberish's computed lat/lon grid.
- Playwright replays recorded store traffic (
tests/fixtures/http/) and a local Natural Earth basemap style, with pinned clock, timezone, and geolocation — fully offline and deterministic. Visual snapshots run on chromium (desktop) and webkit (iPhone 14 viewport). After changing rendering intentionally, refresh withnpm run test:e2e:update. - CI runs in the
mcr.microsoft.com/playwrightimage matching the pinned@playwright/testversion so snapshots render identically. - Fixture recording is incremental: bodies already in the manifest are
replayed from disk, so adding coverage never moves the recorded init time
(and never invalidates the committed visual snapshots).
--freshre-records against the latest init and does requirenpm run test:e2e:update. tests/e2e/readout.spec.tsasserts the temperature/dew-point values the pinned fixtures hold at the manifest'spointlocation; if you re-record with--fresh, update those expected values too. It also asserts the loading priority — that the first progressive frame pass is fully requested before the point store is touched.- Recording freezes both map stores, and with them the splice. The manifest
carries the results out to the specs —
maxHours(where the timeline ends),stores(each run's init and how many hours it serves) andcoarseLeads— so nothing hard-codes a 48-hour forecast that a given recording may not have.
The build is fully static — any static host works.
npm run build
npx wrangler pages deploy distOr connect the repo to Cloudflare Pages with build command npm run build
and output directory dist. public/_headers sets immutable caching for
hashed assets. No environment variables or server functions are required.
- Forecast data: NOAA HRRR, processed
and served by dynamical.org
(CC BY 4.0); store versions
pinned in
src/config.ts. - Basemap: OpenFreeMap / © OpenMapTiles data from © OpenStreetMap contributors.
- UI typeface: IBM Plex Mono
(OFL 1.1), self-hosted latin subset in
public/fonts/. - Smoke is HRRR's near-surface smoke tracer (
mass_density_8m, µg/m³, ~8 m AGL); rain is instantaneous precipitation rate (precipitation_rate_surface, mm/hr). HRRR publishes an 18-hour forecast every hour and a 48-hour forecast at 00/06/12/18 UTC; the app splices the most recent complete run of each, so the hours it can show from the hourly run are at most an hour old and the rest fall back to the six-hourly one. - With a location fix, the bottom bar reads out the forecast for that point
beside the valid time: 2 m temperature (
temperature_2m) and dew point (dew_point_temperature_2m), read from dynamical.org's time-optimizednoaa-hrrr-forecast-48-hourstore rather than the map's. Same 48-hour forecast, rechunked: all 49 lead times sit in one chunk (sharded over y/x), so a cell's whole 48-hour series is a single ~3 MB read per variable — 6 requests / 6.4 MB at full hourly resolution, where pulling the same series from the map-optimized store would be ~109 requests / ~120 MB of GRIB. It also needs no GRIB decode (float32 + blosc/zstd, already in °C). dynamical.org publishes no time-optimized sibling for the hourly 18-hour product, so the readout stays on the six-hourly run even where the map's frames come from a fresher one; the worker shifts the series by the gap between the two inits, so the numbers always describe the hour on screen. The map's frames always load first — the readout waits for the first progressive pass, since the overlay is what you're looking at and both share the worker and the connection. Until the values land it shows its labels over dithered placeholders occupying the value's exact box, so the row reserves its space and nothing moves on arrival. Each metric is a column with its label centred over its value; the readout is anchored to the bar's right edge and value boxes have a fixed size, so digits hold still as the hour label widens ("+9h" -> "+10h") and as readings cross between one and three characters. - The °C/°F button (bottom right, above the attribution) switches the whole UI
between metric and imperial: temperature/dew point in °C or °F and the rain
legend in mm/hr or in/hr. Smoke stays µg/m³ — that concentration has no
imperial counterpart in common use (US AQI reporting uses it too). Only
display strings convert; the store, decoder and colormap thresholds are
metric throughout (
src/lib/units.ts). The choice persists inlocalStorage, defaulting to metric.

