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
…