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.
| Format | Extent | Series axis | So you get |
|---|---|---|---|
| NetCDF4 | from scan(), via 1-D coordinates | CF timestamps | the full temporal path |
| Zarr v2 | from scan(), when the store has CF coordinates | CF timestamps | the full temporal path |
| GRIB2 | never — supply grid.bbox | CF timestamps | scrub + reduce, once an extent is given |
| NetCDF3 | the file's own header | the file's own header | the 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
…