FIMViz.js

Dataset operations — usage reference

Dataset (barrel-exported) is a lazy, immutable node in an op-chain: a ROOT wraps a source (inline bytes/GeoJSON, or a URL); a DERIVED node is a parent + one op. Nothing decodes/warps/transforms until a terminal forces it. Every op returns a new Dataset — the original is never mutated, so one Dataset safely backs many Layers. kind is 'raster' or 'vector' — most ops are gated to one kind and throw immediately (not at force time) if called on the wrong kind.

Contents

Construction · Kind-agnostic · Terminals · Raster — unary · Raster — binary / N-ary · Vector · Notes · Standalone grid functions · Standalone warp() · Vendored primitives · GDAL escape hatch (callGdal)

Construction

new Dataset({ id?, name?, kind?, format?, crs?, bounds?, meta?, data?, url?, axis?, axes? })
// id: auto ("ds_...") · name: defaults to id · kind: 'raster'|'vector'|null
// format: 'geotiff'|'geojson'|'kml'|'kmz'|'shp'|'hazus'|null · crs: native CRS string|null
// bounds: {north,south,east,west}|null · data: ArrayBuffer|Object (inline payload) · url: a URI root
// axis: one DatasetAxis (sugar for axes:[axis]) · axes: DatasetAxis[] (selection-axis series)

Dataset.fromURL(url, { format?, name?, kind?, crs?, bounds?, meta? })
// A lazy URL root — fetches + decodes only on force. format/kind inferred from the URL extension
// when omitted; crs defaults to EPSG:4326 for vector formats, null (unknown) for raster.

Dataset.fromRecord(record)   // rehydrate a toRecord() snapshot (recipe or materialized)

Dataset.fromGrid(value, { name?, format?, meta? })
// A Dataset around an ALREADY-DECODED RasterGrid/VectorFeatures — one you got from ds.grid(),
// computed with the standalone grid functions, or built by hand. Pre-materialized: no fetch, no
// decode, no materializer needed, and load()/grid() return the value you passed in. `kind`/`crs`/
// `bounds` come from the value; `format` stays null, since there are no encoded bytes to claim one.
// This is the way BACK into the op chain, so `ds.grid()` isn't a one-way door.

Normally you don't construct directly — fim.addDataset(file) / FimViz.parseFile(source) parse a raw File/Blob/ArrayBuffer/URL into a Dataset for you.

Kind-agnostic

Method Params Returns Notes
select(coord, opts?) `coord: number string; opts.axis=0(index/name),opts.nearest=true, opts.variant, opts.base` `Dataset
selectRange(from, to, opts?) from/to: inclusive bounds (same type as the coords); opts.axis=0 `Dataset null`
selectAxisEntry(coord, opts?) same opts (no variant/base) `DatasetAxisEntry null`
reduce(op?, opts?) `op: 'sum' 'mean' 'min'
toRecord(opts?) opts.storeMaterialized=false Object Structured-cloneable snapshot for Storage.put(). Default = source + op recipe (small); storeMaterialized:true also embeds the decoded grid/features (call await ds.load() first, or it throws).
toJSON() {id,name,kind,format,crs,bounds,meta,axes} Metadata view, no heavy data payload.
download() void Saves the original bytes to disk. Inline roots only (a URL root has no local bytes yet).

Axis entries: one file per entry, or one file many entries

An axis entry's ref says how to GET that entry's payload, and takes three forms. select() and reduce() behave identically across all three — the axis model doesn't care which you built:

// 1. A URL — one file per entry (the FIM Scenario shape: each stage/timestep its own raster).
{ coord: 19.5, ref: 'stage_19p5.tif' }                       // → a URL-rooted child

// 2. Named URL variants — pick one with select(coord, { variant }); ds.variantsAt(coord) lists them.
{ coord: 19.5, ref: { raster: 'a.tif', vector: 'a.kmz' } }    // → select(19.5, { variant: 'raster' })

// 3. An IN-FILE selector — the entry is a slice of the SAME source, not a separate download.
{ coord: 6, ref: { select: { variable: 'TMP', t: 1 } } }      // → a child sharing this file's bytes

Form 2 is not an axis, on purpose. A variant switches the Dataset's kind.tif gives a raster in an unknown CRS, .kmz a vector in EPSG:4326 — whereas every axis preserves kind, CRS and bounds. It is a choice of encoding of the same datum, not a coordinate in the data. Use ds.variantsAt(coord) to enumerate what an entry offers (['raster', 'vector'], or null) rather than catching the error select() throws when you omit a required one.

Form 3 is what lets one multi-dimensional file (NetCDF/GRIB2/Zarr, every timestep inside it) back a whole temporal axis. The child shares the parent's bytes or URL — no second fetch — carries the same format, and the selection reaches the decoder as root.select:

const child = ds.select(6);      // ds.format 'netcdf4', ds.data the file's bytes
child.selector;                   // → { variable: 'TMP', t: 1 }
child.axes;                       // → null — a child is ONE payload, so it forces like any Dataset
await child.grid();               // materializer receives { kind:'inline', data, select: {…} }
await ds.reduce('mean').grid();   // every entry resolved + reduced — no extra machinery

A series is not forceableload()/grid() on one throws, naming select()/reduce() as the way forward. That holds whether or not it carries the file's bytes, which an in-file series does:

const storm = ds.selectRange(t0, t1);   // → a NARROWER series, still lazy, still not forceable
await storm.reduce('max').grid();        // the peak within that window
storm.select(coord);                      // …or one step out of it

select's base applies to URL refs only. A selector ref may also carry name/crs/bounds to override what the child would otherwise inherit from its parent. Selecting on a Dataset with no source of its own (no data, no url) throws — there is nothing to select from. Selector children round-trip through toRecord()/fromRecord() like any root.

Writing a materializer that understands selectors is one if: root.select is simply absent for ordinary sources, so existing decoders are unaffected. See APP_STARTUP_ADVANCED.md → Materialize / decode extension seam.

Reading NetCDF / GRIB2 / Zarr

The concrete producer of selector axes — multi-dimensional scientific formats. They open through the ordinary fim.addDataset(file) / FimViz.parseFile(file), on extension (.nc, .nc4, .cdf, .grib, .grib2, .grb2, .zarr); the file's own header decides which of the four it is.

Under it is SciWrid Toolkit, reached through a dynamic import and left external to the bundle, so its ~193 KB wasm is downloaded only by a page that actually opens one of these files, and is never bundled into dist/fimviz.js. The consequence for a consumer is that sciwrid-toolkit must be resolvable — an npm dependency under a bundler, an import-map entry in a raw browser page (examples/04-temporal.html shows one). Only these four formats depend on it, and the thrown error says so.

One call covers every format — the differences live inside the adapter (GRIB2 reports nx/ny instead of a shape; Zarr reports shape as an array rather than a string). What differs for a caller is what scan() can tell us about a file, which is not uniform:

Format Extent Series axis So you get
NetCDF4 scan(), from 1-D coords ✅ CF timestamps the full temporal path
Zarr v2 scan(), when the store has CF coords ✅ CF timestamps the full temporal path
GRIB2 ❌ never — supply grid.bbox ✅ CF timestamps scrub + reduce(), with an extent
NetCDF3 the file's own header the file's own header the full temporal path

NetCDF3 gets its extent and its timestamps from a different place than the other three, and that is worth knowing about. scan() supplies neither — SciWrid populates a bbox only on its netcdf4/zarr/parquet paths, and its NetCDF3 time-units accessor is still outstanding upstream. But the files themselves carry lon/lat/time coordinate variables with CF units right there in the header, so io/netcdf3.js reads them. It is the one container format FIMViz parses itself, it reads the header only (never pixels — point it at a data variable and it refuses), and it declines any bytes that are not NetCDF-3 classic, so it cannot affect the other formats. meta.axisSource records where the axis came from: 'scan', 'header', 'caller', or 'index'. Pass header: false to switch the supplement off.

One consequence worth stating: this works from inline bytes only. A NetCDF3 opened from a URL is never fetched just to read its header — that would undo the laziness the URL path exists for — so it still needs grid.bbox.

Calendars that are not Gregorian keep the file's own numbers. A 360_day or noleap calendar has no real instants to convert to, so rather than fabricating dates or falling back to bare positions, the axis carries the raw offsets with the file's units as axis.unit — e.g. 'days since 2001-1-1' with coords 15, 45, 75…. Ordered, exact, and selectable; just not calendar dates.

Dimensions beyond (lat, lon) + time

T(time, level, lat, lon) is ordinary — ERA5, GFS, CMIP. The decoder exposes no way to pick a level, member or band, so a 4-D variable throws rather than silently handing you whichever slice the reader chose:

await fim.addDataset(file, { allowExtraDims: true });   // accept it; recorded on meta.extraDims

This is arithmetic on the declared shape, not on what the reader admits to — which is why a NetCDF3 file trips it even though scan() reports no time axis: its time dimension is invisible but still there, and was being collapsed unannounced.

const ds = await fim.addDataset(file);            // header only — nothing decoded
ds.format;                                         // 'netcdf4' — from the file, not the extension
ds.axis.entries.length;                            // 120 timesteps
ds.meta.grid;                                      // { width, height, bbox, bounds } — the file's NATIVE grid
ds.meta.unit;                                      // 'kg m-2'

const t = ds.select(Date.parse('2023-08-28T06:00:00Z'));   // → one grid, lazily, off the same bytes
await fim.addLayer(t);                                       // renders like any raster (already EPSG:4326)

await ds.reduce('mean').grid();    // temporal mean over the whole axis
await ds.reduce('max').grid();     // …or the storm peak
Option Notes
variable which variable; defaults to the first supported one. One variable per Dataset — call it again for another.
grid Partial override of the native grid — anything omitted comes from the variable's own shape and scan().bbox. Pass width/height alone to decode coarser than native; pass bbox alone when the file's extent can't be derived (below).
series The series (time) axis: { coords, length, name, unit }. coords may be an array or a generator (i, n) => …; ISO strings and Dates become epoch ms. Resolved in priority order: this option → scan()'s CF times → the file's own header (NetCDF3) → integer indices. series: false forces a single grid.
header false disables the NetCDF3 header supplement, so extent and times come from scan() alone. Diagnostic; there is no reason to set it in normal use.
dims { order: 'yx' | 'xy' } — which trailing pair of the declared shape is (lat, lon). 'yx' is CF order and the default; 'xy' is for a variable declared (…, lon, lat). Decides native height/width only.
lon 'native' (default), '-180..180', or '0..360' — re-express the extent in a longitude convention.
allowExtraDims accept a dimension beyond (lat, lon) + the series axis, collapsed by the reader.
workers extractGrid's worker count. Defaults to SciWrid's own in a browser, and to 0 under Node, where the worker pool never resolves.
name / resolveUrl as elsewhere.

Which axis is the series axis (and why you cannot choose)

The series axis is always the file's outermost non-spatial dimension. That is not a simplification we chose — scan() reports a variable's shape as bare numbers ('24x170x180') with no dimension names, and the decoder exposes exactly one index knob. You can say how long that axis is, what its coordinates mean, and what to call it; there is nothing that could act on "use dimension 2 instead". A file whose layout genuinely differs needs dims.order (for the spatial pair) or a transposed copy.

Longitude conventions are 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 — same data, new label, Pacific drawn where the Atlantic belongs. So lon is implemented as a column roll on the decoded grid:

await fim.addDataset(file, { grid: { bbox: [0, -80, 360, 90] }, lon: '-180..180' });
// bounds → { west: -180, east: 180, … }, and every decoded timestep is rolled to match

lon fixes where the grid claims to be. How it is drawn is a separate concern handled by geo/mercator.js: raster overlays are images placed in a lat/lng box, which Leaflet and Google both stretch linearly in Web Mercator, so the rows are resampled onto Mercator spacing before colorizing. That is automatic — a global field renders correctly with no options — and latitude beyond ±85.0511° is clipped away (it is not representable in Mercator) with the overlay box shrinking to match. Only the image is reprojected; hover, Stats and the filters keep reading the source grid.

The knobs, on addLayer(ds, { raster }) or layer.render({ raster }):

Key Default Notes
strategy 'auto' 'image' or 'tiles' to force. auto picks by output size.
maxPixels 16e6 Above this the plan asks for tiles.
maxDimension 8192 Per-side limit; the largest safe canvas edge everywhere.
tileSize 256 Carried on the plan for the tile backend.
mercator true false for a provider that already draws plate carrée.
resample 'nearest' 'linear' interpolates; nearest keeps classified values intact.

Tiles are not implemented yet. When the plan says tiles, the image is still drawn — correctly placed and aspect-preserved, but downsampled to fit the budget — with a console.warn and a layer:raster-oversized host event. A baked image is also fixed in resolution, so zooming far past what it was sized for magnifies pixels. See DECISIONS → raster overlays.

Axis coordinates are epoch milliseconds, not ISO strings — select()'s nearest-match is numeric-only, and that is what a time slider needs. The ISO timestamp is on each entry's meta.time, so ds.selectAxisEntry(Date.parse(iso)).meta.time reads it back.

No GDAL is involved: extractGrid resamples onto a geographic bbox, so these arrive as EPSG:4326 and render directly. Missing values arrive as NaN, which colorize and Stats already treat as absent. Rehydrating a stored selector Dataset skips the parser, so call registerSciwridFormats() once at boot before Dataset.fromRecord.

"has no usable geographic extent"

A common failure on real-world files, and a recoverable one. It happens for three different reasons, and the message says which:

Not caught: a rotated-pole grid (CORDEX/COSMO, rlat/rlon) reports plausible small degree-like numbers that are not geographic. Detecting it needs the grid_mapping attribute, which scan() does not expose — so such a file will render in the wrong place without complaint. See PACKAGE_ROADMAP.md §8.1.

The pixels are perfectly readable; only the extent is unknown, so nothing can place them on a map. FIMViz will not guess one — a wrong extent silently puts every pixel in the wrong place, which is the same failure the CRS precondition exists to prevent. Supply it instead:

await fim.addDataset(file, { grid: { bbox: [-180, -90, 180, 90] } });   // width/height stay native

The thrown error names the variable, its shape, and the variables present, so you can tell which case you are in. examples/04-temporal.html has an "extent override" box that does exactly this.

Terminals (force the chain — nothing exists until one of these runs)

Method Returns Notes
await ds.load() `RasterGrid VectorFeatures`
await ds.grid() RasterGrid load() + assert raster; throws if this Dataset is vector.
await ds.features() VectorFeatures load() + assert vector; throws if this Dataset is raster. Iterable — see below.
ds.release() void Evicts the memoized result (not async).
ds.isMaterialized boolean (getter) Has this exact node been forced yet.
ds.warnings string[] (getter) Collected at force time (e.g. "reprojected X→Y", "resampled onto...") — empty until forced.

Reading the features out of VectorFeatures

A decoder may hand back a FeatureCollection, a lone Feature, or a bare array, so .features is whatever the format produced. Iterate the wrapper instead and the shape stops mattering:

const vf = await ds.features();

for (const feature of vf) console.log(feature.properties);   // iterable
vf.toArray();      // Feature[] in document order — normalized from all three payload shapes
vf.count;          // how many features
vf.features;       // the raw payload, if you specifically want the GeoJSON object as decoded
vf.bounds; vf.crs; // the frame it was decoded in

Raster — unary

All throw "<op>: raster-only op" if called on a vector Dataset.

Method Params Notes
reproject(toCrs) toCrs: string (required, "EPSG:<code>" — validated + case-normalized) Lazy CRS warp. Same-CRS request (case-insensitive) → no-op, returns this. The actual GDAL warp runs on force, JIT-loaded automatically — see COLOR_SCALE.md-adjacent reprojector note, or just call it; no setup needed. Works on a plain source, a reproject-only chain (reuses the original file's bytes — cheap), and on a node derived via combine/clip/mask/reclassify/resample/rasterize (the actually-computed grid is encoded to a fresh GeoTIFF and warped directly — see geo/gdal.js's warpGrid/encodeGridAsGeoTiff — rather than silently re-warping the untouched original file).
clip(bbox) bbox: {north,south,east,west} (required) Crop to overlap, snapped to pixel edges.
mask(polygon, opts?) polygon: SpatialFilter or a ring/multi-ring of {lat,lng} [lat,lng]; opts.invert=false
reclassify(rules, opts?) rules: [{min?,max?,value?}] (non-empty) or (value, index) => number|null|undefined (required either way); `opts.unmatched='nodata' 'keep'`
resampleTo(target, opts?) target: {width,height,bw,bs,be,bn} or grid-shaped {width,height,bounds:{north,south,east,west}} (e.g. another Dataset's .grid()); opts.method='nearest', opts.noData Resample onto an explicit target grid. crs unchanged (resamples, doesn't reproject). GDAL-only methods (cubic/lanczos/...) need Dataset.registerResampler or force throws.
slope(opts?) opts.zFactor, opts.cellsizeX, opts.cellsizeY, `opts.unit='degrees' 'percent'`
aspect() Downslope compass bearing, Horn's method.
hillshade(opts?) opts.altitude, opts.azimuth, opts.zFactor, opts.cellsizeX, opts.cellsizeY Shaded-relief illumination.
zonalStats(zones, opts?)terminal, async zones: [{id?,polygon?,filter?}]; opts.noData Returns data (an array), not a Dataset. Forces the grid.
groupBy(by, opts?)terminal, async by: a raster Dataset whose values define the groups; opts.bins (a count → equal-width bands, or explicit edges), opts.method, opts.noData, opts.byNoData Reduce this raster grouped by another raster's values — "mean depth per land-use class", "rainfall binned by elevation". Returns a table. by is conformed onto this grid (the same LHS rule combine uses); a pixel counts only where both rasters have a value.

Three kinds of reduction — which verb collapses what

Easy to conflate, so stated once. All three shrink something; they differ in what and by what:

verb collapses grouped by returns
reduce(op) a selection axis (time, level, member) a Dataset (one grid)
zonalStats(zones) space geometry (polygons) a table
groupBy(by) space another raster's values a table

groupBy is what "one variable as a series against another" means concretely. It is a distinct verb rather than an overload of the other two because its grouping key comes from data — not from the axis model, and not from geometry.

await depth.groupBy(landuse);                        // one row per distinct class
await rain.groupBy(dem, { bins: 10 });                // ten equal-width elevation bands
await rain.groupBy(dem, { bins: [0, 100, 500, 2000] });  // explicit edges
// -> [{ class, range?, count, sum, min, max, mean, area }, ...]

Note what is not here: producing a new grid from two variables is combine/difference (band math). groupBy is the table-producing half of the same question.

Raster — binary / N-ary

Method Params Notes
combine(others, opts?) `others: Dataset Dataset[](required, ≥1);opts.op='difference'
difference(other) other: Dataset Sugar for combine([other], {op:'difference'}).
ds128.combine(ds177, { op: 'difference' });        // ds128 − ds177
ds128.combine([ds177, ds3], { op: 'mean' });        // N-ary
ds128.difference(ds177);                            // same as combine(..., {op:'difference'})

Vector — the one kind-changing op

Method Params Notes
rasterize(opts) opts.width, opts.height (required); opts.bounds (defaults to the Dataset's own footprint — throws if neither is available); opts.field (burn a feature property; omit for a constant); opts.burnValue=1 Vector → raster. Throws "vector-only op" on a raster Dataset.

Notes

Standalone grid functions (no Dataset needed)

Every raster Dataset op above is a thin lazy wrapper over a pure, directly-callable function on an already-decoded RasterGrid — barrel-exported, so if you already have a grid (e.g. from await ds.grid(), or built by hand), you can call these without a Dataset at all. Same signatures as the Dataset methods, minus the laziness — each takes a RasterGrid first and returns a new one:

Finished with a grid and want the op chain back? Dataset.fromGrid(grid) wraps it (above).

maskGrid(grid, polygon, opts?)          // opts: { invert? }
clipGrid(grid, bbox)
reclassifyGrid(grid, rules, opts?)      // opts: { unmatched? }
combineGrids(grids, opts?)               // grids: RasterGrid[]; opts: { op?, method? }
zonalStats(grid, zones, opts?)          // → data, not a RasterGrid
slopeGrid(grid, opts?)
aspectGrid(grid)
hillshadeGrid(grid, opts?)
rasterizeFeatures(featureCollection, bounds, opts)   // opts: { width, height, field?, burnValue? }

Resample/align (geo/resample.js, also barrel-exported):

GRID_POLICY                 // { LOW, HIGH, AVERAGE } — target-resolution policy
RESAMPLE_METHODS             // ['nearest','bilinear','average', 'cubic','cubicspline','lanczos','mode','min','max','med','q1','q3']
resolveTargetGrid(metas, policy)          // metas: [{width,height,bw,bs,be,bn}, ...] → the common target grid
resampleGrid(pixels, srcMeta, dstMeta, opts?)   // opts: { method?, noData? }
alignRasters(rasters, opts?)              // rasters: [{pixels,meta}, ...] → { grid, rasters: [{pixels,meta}, ...] }
Dataset.registerResampler(fn)   // the escape hatch for cubic/lanczos/etc. — nothing registered by default

Colorize (package/rasterImage.js, also barrel-exported — see COLOR_SCALE.md for the ColorScale side of this):

colorizeGrid(grid, opts?)     // opts: { colorScale?, alpha?, skipZero?, noData? } → Uint8ClampedArray RGBA
gridToDataURL(grid, opts?)    // colorizeGrid + canvas encode → a PNG data URL

Standalone warp() (the EAGER twin of Dataset.reproject())

import { warp } from 'fimviz';   // geo/warp.js — NOT the same as ds.reproject(toCrs)

const warped = await warp(ds, toCrs);   // async — warps immediately, returns a NEW Dataset

Dataset.reproject(toCrs) (documented above) is lazy — it builds an op node, and the warp only runs when something forces the chain. This standalone warp(ds, toCrs) is the eager equivalent — it warps immediately and returns a new, already-reprojected Dataset (still rasters only; same-CRS request returns ds unchanged, no copy). Both dispatch to the same GDAL warp underneath; pick the lazy method for a chain you're building up before rendering, or this function when you want the result right away.

Vendored primitives

Re-exported so the engine stays the single owner of these third-party dependencies — a host never imports geotiff/@tmcw/togeojson/shpjs/@googlemaps/js-api-loader directly:

fromArrayBuffer   // geotiff's buffer → GeoTIFF image accessor (pixel-level access beyond parseFile)
kml               // @tmcw/togeojson's KML XML DOM → GeoJSON
shp               // shpjs's zipped shapefile buffer → GeoJSON
Loader            // @googlemaps/js-api-loader's script loader class

GDAL escape hatch (callGdal)

Dataset.reproject/resampleTo (above) cover the one GDAL utility (gdalwarp) most callers need. For anything else gdal3.js offers — gdal_translate, gdal_rasterize, ogr2ogr, gdalinfo, ogrinfo, gdaltransform, or gdalwarp options those two don't expose — callGdal(method, ...params) dispatches to any method on the gdal3.js instance by name, passing your params through verbatim. No fimviz-specific wrapper exists (or is needed) per GDAL utility:

import { callGdal } from 'fimviz';

const file = new File([arrayBuffer], 'in.tif', { type: 'image/tiff' });
const { datasets } = await callGdal('open', file);
const outPath = await callGdal('gdal_translate', datasets[0],
  ['-of', 'GTiff', '-outsize', '50%', '50%'], 'out.tif');
await callGdal('close', datasets[0]);
const bytes = await callGdal('getFileBytes', outPath);   // Uint8Array

gdal3.js's methods fall into a few parameter shapes (see its own index.d.ts for exact signatures): lifecycleopen(fileOrFiles, options?, VFSHandlers?){datasets, errors}, close(dataset), getFileBytes(path), getOutputFiles(), getInfo(dataset); dataset-based utilities, output written to gdal3.js's virtual FS — gdalwarp/gdal_translate/ gdal_rasterize/ogr2ogr(dataset, options?, outputName?); info-only, no output file — gdalinfo/ogrinfo(dataset, options?); no dataset at allgdaltransform(coords, options). An unknown method name throws immediately, listing every real method available.

callGdal dynamically imports geo/gdal.js (→ gdal3.js, ~38 MB wasm) on first call — never just from importing the fimviz barrel, so a consumer who never calls it pays nothing for GDAL, same as the reproject seam. Browser-only (gdal3.js's Emscripten loader fails in Node), so — like reproject and the warp path generally — this isn't covered by npm test; verify it by exercising the site or an example page.

callGdal is deliberately raw — it dispatches by method name but leaves the whole open/run/close/ read-bytes lifecycle to the caller, spelled out in full above. For gdalwarp specifically, warpTo and warpToGrid already hide that ceremony.