FIMViz.js

2 · Datasets & operations

A Dataset is not a decoded raster — it is a lazy, immutable node in an op chain. A root wraps a source; every operation returns a new node; nothing is fetched, decoded or warped until a terminal forces it. That one design decision is why an op is cheap, why an original is never damaged, and why the same parsed bytes can feed a dozen different derivations.

Two maps on the right: source never changes, result shows whatever the last operation produced. A page may only mount one map per app, so the second one passes { isolated: true } to get its own. Serve the repo root and run npm run build first.

1Nothing happens until you force it

Chaining builds a description. isMaterialized stays false the whole way down, and the four terminals — load(), grid(), features(), zonalStats() — are the only things that make work happen. A Layer rendering the Dataset is a terminal too, which is why notebook 1 decoded at addLayer and not at addDataset.

The result is memoized per node, so forcing the same node twice is free, and two chains that share a parent share that parent's decode. release() evicts it.

const ds      = await fim.addDataset(file);        // header only
const clipped = ds.clip(bbox);                    // a new node. Still nothing.
const shallow = clipped.reclassify(rules);      // another. Still nothing.

ds.isMaterialized;        // false
shallow.isMaterialized;   // false

const grid = await shallow.grid();          // ← the whole chain runs here, once
await shallow.grid();                         // memoized — free

shallow.warnings;   // collected at force time, e.g. "reclassify: 812 pixels matched no rule"
shallow.release();  // drop the memoized decode; the recipe survives

Output

2clip — cut the footprint down

Takes a bounds object in the Dataset's own CRS and snaps to pixel edges. The grid shrinks; values are untouched. Edit the numbers and re-run — the result map redraws from a fresh chain each time, while the source Dataset is never modified.

const cut = ds.clip({ north: 34.39, south: 34.35, west: -89.96, east: -89.87 });

await resultLayer.setSources([cut]);   // immutable swap: point the layer at a new node

Output

3mask — keep what is inside a polygon

Pixels outside the polygon become NaN, which colorize draws as transparent and Stats excludes — the two agree on what "absent" means, so a masked raster and its statistics never disagree. The footprint is unchanged: masking hides values, clipping removes ground.

The polygon can be a ring of {lat, lng}, a multi-ring array, or a SpatialFilter — the same object the drawing tools in notebook 5 hand you, which is why a drawn shape needs no conversion to become an analysis mask.

const ring = [{ lat: 34.392, lng: -89.95 }, { lat: 34.392, lng: -89.87 },
              { lat: 34.345, lng: -89.87 }, { lat: 34.345, lng: -89.95 }];

ds.mask(ring);                  // keep inside
ds.mask(ring, { invert: true });  // …or punch that shape out

// A SpatialFilter is interchangeable here, and unions multiple rings:
ds.mask(new SpatialFilter([ringA, ringB]));

Output

4reclassify — remap values

Two forms. Rules are first-match-wins ranges, and survive toRecord() — a range with no value means "keep what is there", so a plain threshold is one unbounded rule. A callback is called once per valid pixel with its value and flat index: cheaper past a couple of ranges, and not restricted to contiguous bands, but it cannot be serialized.

Existing noData is never passed to either. Anything that matches nothing becomes noData under the default unmatched: 'nodata' — and if that actually punched holes, the count arrives on ds.warnings, so incomplete coverage is reported rather than silently drawn.

// Rules: three depth bands, everything above 6 m kept as-is.
ds.reclassify([
  { min: 0, max: 2, value: 1 },
  { min: 2, max: 4, value: 2 },
  { min: 4, max: 6, value: 3 },
  { min: 6, max: Infinity },          // no `value` → keep the pixel's own
]);

// Callback: anything per-pixel. `null` means unmatched.
ds.reclassify((v, i) => (v > 2 ? Math.round(v) : null));

// A threshold is just an unbounded rule — "wet or nothing":
ds.reclassify([{ min: 0.3, max: Infinity, value: 1 }]);

Output

5Terrain — slope, aspect, hillshade

Horn's 1981 3×3 gradient — the same algorithm gdaldem uses, run in plain JavaScript on the decoded grid. No GDAL, no wasm. Cell size defaults to the grid's own pixel size in the bounds' units, which for EPSG:4326 is degrees — pass metres for a true-scale result.

Honest caveat about this page: no DEM ships with the repo, so these run over a flood-depth grid. The arithmetic is exactly what it would be on elevation; only the interpretation is nonsense.

ds.slope({ unit: "degrees" });      // or 'percent'
ds.aspect();                          // downslope compass bearing, 0–360
ds.hillshade({ azimuth: 315, altitude: 45 });

// Metres per cell, if you want a slope that means something:
ds.slope({ cellsizeX: 10, cellsizeY: 10 });

Output

6Band math — combine and difference

Two flood scenarios over the same town, differenced pixel by pixel. N-ary ops are LHS-conforming: every other input is resampled onto this Dataset's grid, and if a CRS had to be converted the op records a warning rather than throwing. That rule is what makes "which grid does the answer live on" have an obvious answer.

difference/ratio are strictly binary; sum, mean, min and max take any number of inputs.

const a = await fim.addDataset(scenarioA);   // 1312×664
const b = await fim.addDataset(scenarioB);   // same grid here, but it need not be

a.difference(b);                          // sugar for combine([b], { op:'difference' })
a.combine([b], { op: "ratio" });
a.combine([b, c], { op: "mean" });          // N-ary

(await diff.grid()) && diff.warnings;      // e.g. "resampled onto this grid"

Output

7Three kinds of reduction

Easy to conflate, so worth naming: reduce() collapses a selection axis (time, level, member — notebook 4). zonalStats() collapses space, grouped by geometry. groupBy() collapses space, grouped by another raster's values. The last two return a table, not a Dataset — so they arrive on a different channel from anything that changes a layer.

// zonalStats — per-polygon summary. `zones` take a polygon or a SpatialFilter.
await ds.zonalStats([
  { id: "west", polygon: westRing },
  { id: "east", polygon: eastRing },
]);
// → [{ id, count, sum, min, max, mean, area }, …]

// groupBy — "mean depth per class of another raster". Discrete, or binned:
await depth.groupBy(other);                  // one row per distinct value
await depth.groupBy(other, { bins: 5 });       // five equal-width bands
await depth.groupBy(other, { bins: [0, 1, 3, 10] });   // explicit edges

Output

8The same ops, straight off a Layer

A layer exposes the unary ops so you can transform what is on screen without touching Datasets by hand. Three timings are worth knowing: the sources are rewritten immediately (so getStats() and fit() tell the truth mid-chain, and a bad argument throws at the call that made it), the map redraws once at render(), and the data still computes lazily inside that render. layer.dirty reports an applied but undrawn op; reset() goes back to the sources the layer was built from.

Not available on a layer: combine/difference — "which source is the left operand" has no answer there — and rasterize, which changes the Dataset's kind and so cannot return the same layer.

await layer.clip(bbox).mask(ring).reclassify(rules).render();
layer.dirty;              // false again — the chain was drawn
await layer.reset();       // back to the original sources, re-rendered

Output

9rasterize — vector into raster

The one kind-changing op that ships: it point-tests every pixel centre against each polygon and burns a property value or a constant, so counties become a grid you can run raster maths over. Later features win on overlap; holes are ignored, and only Polygon/MultiPolygon are handled.

const counties = await fim.addDataset(countyGeoJson);

const grid = counties.rasterize({
  width: 600, height: 400,
  field: "SHAPE_Area",   // burn a property … or omit it and burn `burnValue`
});

// It is a raster Dataset now — every raster op applies:
await grid.grid();
counties.rasterize({ width: 600, height: 400, burnValue: 1 });   // a coverage mask

Output

10Reprojection, and the precondition that forces it

Both built-in providers accept only the WGS84 family, and the engine never warps behind your back: rendering a raster in another CRS throws an actionable error instead of drawing a plausible-looking map in the wrong place. The Brazos sample below is EPSG:26914 (UTM 14N), so it fails on purpose.

ds.reproject(crs) is the lazy op; warp(ds, crs) is the eager free function. Both dispatch to the same GDAL warp, which is browser-only and lazily pulls about 38 MB of wasm from a CDN on first use — so the button below is opt-in, and the first press is slow.

const utm = await fim.addDataset(brazosFile);
utm.crs;   // 'EPSG:26914' — parsing never reprojects

await fim.addLayer(utm);
// ✗ throws: the 'leaflet' provider cannot render CRS 'EPSG:26914'. Reproject first…

await fim.addLayer(utm.reproject("EPSG:4326"));   // ✓ warps when the render forces it

// No setup call is needed: the barrel registers a JIT loader, so the first
// forced reproject is what imports GDAL. registerGdalReprojector() only pre-warms it.

Output

11The same maths without a Dataset

Every raster op above is a thin lazy wrapper over a pure function on a decoded RasterGrid, and those functions are exported. If you already hold a grid — from ds.grid(), from your own decoder, or built by hand — call them directly. Dataset.fromGrid() is the way back in, so ds.grid() is not a one-way door.

import { maskGrid, clipGrid, reclassifyGrid, combineGrids, zonalStats,
         slopeGrid, aspectGrid, hillshadeGrid, rasterizeFeatures,
         colorizeGrid, gridToDataURL, Dataset } from "fimviz";

const grid  = await ds.grid();
const small = clipGrid(grid, bbox);
const shown = gridToDataURL(small, { colorScale });   // a PNG data URL

const back  = Dataset.fromGrid(small);   // pre-materialized: no decode, ops chain again
await back.slope().grid();

Output