1Three mutually exclusive modes
A scale is in exactly one of three states, and setting one clears the others. That exclusivity lives in the engine, not in the UI — which is why the tools panel in notebook 5 has no "mode" control and no "clear" button: picking a palette is how you get back to palette mode, and emptying the stops array would leave an explicit scale with nothing in it.
// 1 · PALETTE — a named ramp over a [min, max] domain, banded or smooth. const cs = new ColorScale({ palette: "viridis", min: 0, max: 10, continuous: true, unit: "m" }); // 2 · CLASSED STOPS — flat colours per range. No interpolation. The shape a GDAL // legend embedded in a GeoTIFF arrives in. cs.setStops([ { range: [0, 2], color: "#08306b", label: "shallow" }, { range: [2, 5], color: "#4292c6", label: "moderate" }, { range: [5, Infinity], color: "#deebf7", label: "deep" }, ]); // 3 · CONTINUOUS CONTROL POINTS — arbitrary breakpoints, interpolated between the // bracketing pair. For data whose interesting range is not evenly spread. cs.setColorStops([0, 0.5, 2, 10], ["#ffffcc", "#a1dab4", "#2c7fb8", "#253494"]); cs.kind; // 'continuous' | 'classed' cs.isExplicit; // true once you leave plain palette mode cs.getValues(); // the breakpoints — the PUBLIC way to read the domain back
Output
…
2missingColor — the value that isn't one
The three modes above all answer "what colour is this number?". missingColor
answers the one they cannot: what colour is null, NaN,
'', or a property that is not numeric at all.
It is applied before any arithmetic, and that is the whole point. Numeric coercion turns
null and '' into 0, so without this an absent value would be
painted the colour of the domain minimum — "no data" rendering as "the lowest reading", which is
the one confusion that must never happen on a flood map. null (the default) means
the consumer decides: a vector feature keeps its base style, a raster pixel stays
transparent. And 0 is a real value — it still colours normally.
cs.set({ missingColor: "#cccccc" }); cs.getColor(null); // '#cccccc' — NOT the colour of 0 cs.getColor(NaN); // '#cccccc' cs.getColor(""); // '#cccccc' cs.getColor(0); // the ramp's first colour — zero IS a reading // It sits OUTSIDE the three modes, so switching palette/stops/colorStops keeps it.
Output
…
3Escape hatch and change hook
colorFor is a plain property checked before every mode — return a colour to override,
null to fall through. The gotcha is that assigning it is a raw property write, so it
fires no onChange and triggers no repaint on its own.
Everything else notifies, and a set() batch notifies once for the whole patch
rather than once per key. That is what makes a five-key edit one repaint instead of five.
cs.onChange((scale) => redraw(scale)); cs.set({ palette: "plasma", min: 0, max: 5, continuous: true }); // → ONE notification cs.colorFor = (v) => (v < 0 ? "#000000" : null); // no onChange — repaint yourself
Output
…
4Legend — the display read-model
Derived from a scale, data-complete on its own ({unit, kind, source, stops}), with
toHtml() as a convenience renderer. source matters for labelling: a
gdal or custom legend shows the labels it was given, while
palette and default legends build "lo–hi unit" themselves,
because a bare ramp has no author-supplied text to preserve. formatLabel wins over
both.
A GeoTIFF can carry a legend in its GDAL_METADATA tag, and
ColorScale.fromGdalLegend() turns one into a classed scale.
Worth knowing: the parser for that tag is registered by an app-tier module, not by the
engine bundle — so with plain fimviz, step 2 of a RasterLayer's
auto-resolution never fires and an embedded legend has to be built explicitly.
const legend = Legend.fromColorScale(cs); legend.toJSON(); // { unit, kind, source, stops } — render it however you like legend.toHtml(); // a gradient bar (continuous) or a swatch list (classed) legend.formatLabel = (stop) => `${stop.label} — ${stop.count ?? 0} cells`; // A GDAL-embedded legend, built explicitly: ColorScale.fromGdalLegend([{ range: [0, 2], color: "#08306b", label: "0 – 2 m" }], "m");
Output
…
5Stats over a raster
One pass over the pixels, and then it is terminal: a Stats has already reduced
the raster away, so there is deliberately no stats.applyFilter(). "Statistics, then
filter" is always a fresh computation with the filter supplied up front — which is why
filter is an argument here and not a method.
Pass the same ColorScale the map is drawn with as classify and the
byClass buckets line up exactly with the legend beside them.
const grid = await ds.grid(); // Stats.raster takes the flat meta shape: bounds as bw/bs/be/bn, plus size. const stats = Stats.raster(grid.pixels, { bw: grid.bounds.west, bs: grid.bounds.south, be: grid.bounds.east, bn: grid.bounds.north, width: grid.width, height: grid.height, noData: grid.noData, unit: "m", }, { classify: cs, skipZero: false, bins: 64 }); stats.describe(); // one human line stats.percentile(95); // from the histogram stats.diff(otherStats); // { deltaMin, deltaMax, deltaMean, … } stats.toCSV(); // or stats.download('depth.csv')
Output
…
6Stats over vectors — the same read-model
Features instead of pixels, so the fields differ (featureCount,
byType, area, length, bbox) — but classification
is identical: give it a scale and the property to read, and you get the same
byClass shape a raster produces. Nothing here needs a map or a render; it computes
straight from GeoJSON.
const vf = await counties.features(); // VectorFeatures — iterable const areaScale = new ColorScale({ palette: "viridis", min: 0, max: 4e9, continuous: false }); const vs = Stats.vector(vf, { classify: areaScale, classifyBy: "SHAPE_Area" }); vs.featureCount; // 99 vs.byType; // { polygon, line, point } vs.byClass; // counts per band of the scale above // A VectorLayer wires both automatically from its own colorScale + colorBy: await vectorLayer.getStats();
Output
…
7Filters — scoping a computation
Two shapes, one interface. SpatialFilter is pure polygon geometry and works on both
kinds (a raster pixel and a vector feature both have a position). PredicateFilter is
an arbitrary test — (value, x, y, at) for rasters, (feature) for vectors.
Filter.all([…]) conjoins them, and Filter.from() accepts a function, a
filter or a raw polygon.
Geometry is deliberately kept apart from policy: the filter says where, and each consumer
decides what that means — statistics exclude, a raster render dims, a marker layer
hides. Filter.from is duck-typed on test() rather than
instanceof, so a filter built inside fimviz/ui — a different bundle with
its own copy of the class — is still accepted here.
const region = new SpatialFilter([ring]); // one ring, or many (a union) region.contains(lat, lng); // point-in-polygon, the public test region.pixelBbox(meta); // fast-reject window in pixel space const deep = new PredicateFilter((v) => v > 2); // raster: by value const both = Filter.all([region, deep]); // AND Stats.raster(pixels, meta, { filter: both }); await layer.getStats({ filter: region }); // the same argument, from a layer
Output
…
8Rendering them — fimviz/ui's thin wrappers
renderLegend and renderStats are pure: data by default, HTML on request.
They exist so a host does not hand-roll the same two renderers, and they are the read-only half of
the UI module — the live, self-updating versions (bindLegend,
bindStats) are in notebook 5.
import { renderLegend, renderStats } from "fimviz/ui"; renderLegend(legend); // → legend.toJSON() renderLegend(legend, { html: true }); // → an HTML string renderStats(stats, { html: true }); // → a table of the scalar fields
Output
…