FIMViz.js

4 · Temporal & multi-dimensional data

A NetCDF, GRIB2 or Zarr file holds many timesteps in one payload. FIMViz models that as a selection axis on an ordinary Dataset: one entry per step, each an in-file selector rather than a separate download. select() resolves one, selectRange() narrows the series, reduce() collapses it — and because reduce is sugar over select + combine, temporal aggregation needed no new grid maths at all.

The decoder (SciWrid Toolkit) carries a ~193 KB wasm and is deliberately not bundled, so this page's import map points sciwrid-toolkit at its own browser build. Only these four formats need that entry. First NetCDF4 decode also pulls h5wasm from a CDN.

1One call opens all four

There is no separate "open a NetCDF" entry point — addDataset routes on the extension, and then the file's own header decides which format it is. That is why ds.format below says netcdf4 rather than "nc": .nc cannot distinguish NetCDF3 from NetCDF4, so the extension only routes and the concrete name comes from the scan.

What comes back is a lazy Dataset with an axis and nothing decoded — a 120-step file costs one header read. Try the four samples; each is a different format of the same storm.

// The same call a GeoTIFF takes.
const ds = await fim.addDataset(file);

ds.format;              // 'netcdf4' — from the header, not the filename
ds.axis.entries.length; // 120 timesteps
ds.meta.grid;           // { width, height, bbox, bounds } — the file's NATIVE grid
ds.meta.variable;       // the variable this Dataset is; one variable per Dataset
ds.meta.axisSource;     // where the axis came from: scan | header | caller | index
ds.isMaterialized;      // false

// A .zarr.zip needs the format said out loud: the extension names the CONTAINER,
// and '.zip' already means "a zipped shapefile" to the detector.
await fim.addDataset(zipped, { format: "zarr" });

// GRIB2 and NetCDF3 report no extent through scan(), so GRIB2 needs one supplied.
await fim.addDataset(grib, { grid: { bbox: [-87.98, 24.02, -75.01, 36.99] } });

Output

loading the NetCDF4 sample…

2select() — one step out of the file

An axis entry's ref says how to get that entry, and it has three forms: a URL (one file per step — the flood-scenario shape), named URL variants, or { select: {…} } — an in-file selector. The last is what a multi-dimensional file produces: the child Dataset is rooted on its parent's own bytes, so selecting a timestep costs no second fetch and the selection simply reaches the decoder.

Coordinates are epoch milliseconds, not ISO strings, because select()'s nearest match is numeric — the readable timestamp lives on entry.meta.time. Selecting peels one axis: on a (time × member) series you would be left with a member series, and a second select() would finish the job.

const t = ds.select(Date.parse("2023-08-28T06:00:00Z"));   // nearest match by default

t.selector;   // { variable: 'APCP', t: 6 } — what reaches the decoder
t.axes;       // null — a child is ONE payload, so it forces like any Dataset
await t.grid();   // decodes that slice, off the parent's bytes

ds.selectAxisEntry(coord);   // the entry itself, without resolving it
await fim.addLayer(t);       // already EPSG:4326 — the reader resamples onto a geographic bbox

// The series itself is NOT forceable — ds.grid() on it throws, naming select()/reduce().

Output

3Scrubbing it — createAxisSlider

Scrubbing is layer.setSources([ds.select(coord)]) and nothing more, which is why the widget is not called a time slider: it drives stage, level, band, ensemble member or variable just as readily. It is a library export rather than fifteen lines in this page for two reasons that are easy to get wrong and invisible when you do.

A stale frame must never win. Dragging fires far faster than a grid decodes, so several swaps are in flight and they do not resolve in order — without a guard the last frame to resolve wins rather than the last one asked for, and the map ends up showing a step nobody selected. Playback is paced by the decode, not by a timer: each frame is queued only once the previous one lands, or the playhead runs away from the map.

import { createAxisSlider, axisOf, axisEntryLabel } from "fimviz/ui";

const scrub = createAxisSlider("#scrub", {
  layer, dataset: ds,
  axis: 0,                        // index or name — a Dataset may carry several
  interval: 400,                  // playback pacing floor, in ms
  label: (entry) => entry.meta.time,
  onChange: (entry, i, child) => readFrame(child),   // only for the CURRENT frame
});

scrub.play(); scrub.pause(); scrub.goto(12); scrub.next();
axisOf(ds, 0);   // pure — the axis, or null if there isn't one

Output

4selectRange() — series in, series out

A window over the axis returns another series, not a payload — so everything that works on a full axis works on the window unchanged. "The peak of these six hours" is selectRange(a, b).reduce('max'), needing nothing new on either side.

Bounds are inclusive and compared with plain >=/<=, which is type-agnostic: numbers compare numerically and ISO strings lexicographically, i.e. chronologically. Reversed bounds swap. There is deliberately no nearest match — a window narrower than the sampling interval returns null rather than silently widening to something bigger than you asked for.

const storm = ds.selectRange(t0, t1);        // → a narrower series, still lazy

storm.axis.entries.length;                // fewer entries
await storm.reduce("max").grid();          // the peak within the window
storm.select(coord);                       // …or one step out of it

Output

5reduce() — collapse the whole axis

Every entry is resolved and reduced per pixel. It is sugar over select() + combine() — the same N-ary reducer band maths uses in notebook 2 — so the temporal case brought no new grid code with it. That also means the LHS-conform rule applies: the result lands on the first entry's grid.

This is the expensive one: 120 steps is 120 decodes, a few seconds. max must exceed mean, which is the cheapest proof the axis was really traversed.

await ds.reduce("mean").grid();   // 'sum' | 'mean' | 'min' | 'max'
await ds.reduce("max").grid();

// Reducing a WINDOW is the same call on a narrower series:
await ds.selectRange(t0, t1).reduce("max").grid();

Output

6What actually differs between the four formats

Not the API — the same call opens all four. What differs is how much the reader can tell us about a file, and it is worth knowing which gap you are looking at, because they send you to completely different places.

FormatExtentSeries axisSo you get
NetCDF4from scan(), via 1-D coordinatesCF timestampsthe full temporal path
Zarr v2from scan(), when the store has CF coordinatesCF timestampsthe full temporal path
GRIB2never — supply grid.bboxCF timestampsscrub + reduce, once an extent is given
NetCDF3the file's own headerthe file's own headerthe full temporal path, no options

GRIB2 and NetCDF3 get no extent from scan() whatever their grid looks like — that is a property of the reader's WASM path, not of your file, so a rectilinear GRIB2 is as extent-less as a polar-stereographic one. NetCDF3 escapes it because FIMViz reads that one header itself: io/netcdf3.js is the single container format the library parses directly, it reads the header only, and it declines any bytes it does not fully recognise. Watch meta.axisSource change between the samples.

A missing extent throws rather than being guessed, because a wrong extent puts every pixel confidently in the wrong place. The message distinguishes the three causes: the format never reports one, no bbox could be derived (curvilinear coordinates, a CF-less Zarr store), or one was derived and rejected as non-geographic — a projected file reports metres, which is range-checked as degrees and refused.

// The recoverable failure, and its fix:
await fim.addDataset(grib);
// ✗ Error: … has no usable geographic extent …

await fim.addDataset(grib, { grid: { bbox: [minLon, minLat, maxLon, maxLat] } });
// ✓ `grid` is a PARTIAL override — width/height still come from the variable's own shape.

Output

7Everything the reader assumes is an option you can override

A file that does not match CF's overwhelming majority should be a configuration problem, not an unsupported one. Each default below has an override sitting beside it.

await fim.addDataset(file, {
  variable: "APCP",        // which variable — one per Dataset; defaults to the first supported
  grid: { bbox, width, height },   // PARTIAL: anything omitted stays native
  series: { coords: (i) => Date.UTC(2001, i, 16) },   // label an axis the format cannot
  series: false,             // …or collapse to a single grid
  dims: { order: "yx" },      // which trailing pair of the shape is (lat, lon). 'yx' is CF.
  lon: "-180..180",          // re-express the extent; a global grid is genuinely rolled
  allowExtraDims: true,     // accept a level/member dimension the reader will collapse
  header: false,             // diagnostic: switch off the NetCDF3 header supplement
  workers: 0,                // decoder worker count (0 under Node, where the pool never resolves)
});

Two of those deserve a note. lon is applied by us, not by the reader — asking the decoder for a [-180…180] window on a 0..360 file returns the file's own pixels with the requested bbox echoed back, which would draw the Pacific where the Atlantic belongs; so it is implemented as a column roll on the decoded grid, and a regional window that would cross the antimeridian throws instead. And a fourth dimension throws: T(time, level, lat, lon) is ordinary output, the decoder exposes no level selector, and silently handing you whichever slice it chose is the worst available outcome — allowExtraDims: true is the caller acknowledging it, recorded on meta.extraDims.

What is deliberately not an option: which dimension is the series axis. The scan reports a variable's shape as bare numbers with no dimension names, and the decoder exposes exactly one index knob — so an option to pick a different dimension would be a promise the library could not keep. You can say how long the axis is, what its coordinates mean and what to call it.

Output