FIMViz.js

3 · Colour & the read-models

ColorScale answers one question — what colour is this number? — and Legend and Stats are derived views of the answer. All three are pure and headless: there is no map on this page. The picture on the right is gridToDataURL(), the same colorize pipeline a RasterLayer draws through.

Serve the repo root and run npm run build first. Everything here also works under a bundler with no DOM at all — except gridToDataURL, which needs a canvas.

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