FIMViz.js

1 · Quickstart — from a file to a map

Four calls get a flood raster on screen: mount, addDataset, addLayer, fit. Everything after that — recolouring, the legend, statistics, a hover readout, a second vector layer — is built on the same three objects: a Dataset (the parsed source), a Layer (one rendering of it) and a ColorScale (value → colour).

Run npm run build, serve the repo root (npx serve .) and open /examples/01-quickstart.html. This page runs on Leaflet and needs no credentials; add ?provider=google to the URL to run the identical code on Google Maps (it asks once for a key and keeps it in localStorage).

1Boot a map

mount() takes a container and a config, and resolves to a FimMap — the per-map handle everything else hangs off. provider is required and has no default: the two built-in backends differ in credentials and in capability, so the engine refuses to pick one for you. Leaflet needs nothing; Google needs an apiKey.

// The engine ships no UI. This mounts a real, working, empty map — nothing else.
import { FimViz, ColorScale, Stats } from "fimviz";

// Omit `provider` and this throws `config-invalid` naming both backends.
// Omit the `apiKey` that 'google' requires and it throws the same error.
const fim = await FimViz.mount("#map", { provider: "leaflet" });

fim.map;      // the real L.Map (or google.maps.Map) — advisory, read-mostly
fim.config;   // this instance's config; the engine never reads an ambient one

Output

2Parse a file into a Dataset

addDataset() takes a File, Blob, ArrayBuffer or URL string, detects the format from the name, and returns a lazy Dataset. It reads the header and stops there: isMaterialized is false and no pixel has been decoded. Note also that parsing never reprojectsds.crs is whatever the file itself declares. This sample happens to be EPSG:4326, so it can be drawn as-is; a raster in any other CRS has to be warped first (notebook 2).

// A user upload would arrive as a File from an <input> or a drop; a fetched sample
// is the same thing, so this page wraps the bytes in a File to keep the shape honest.
const res  = await fetch("../assets/SampleFiles/4326.tif");
const file = new File([await res.blob()], "4326.tif");

const ds = await fim.addDataset(file);   // → Dataset, pushed onto fim.datasets

ds.toJSON();          // the metadata view — no heavy `data` payload
ds.isMaterialized;   // false — the header was read, nothing was decoded

Output

3Render it, and fit the map to it

addLayer() constructs a layer and draws it. Passing the Dataset alone is enough — the registry infers 'raster' or 'vector' from ds.kind. This is the call that actually forces the chain: the file is decoded here, not in step 2.

Nothing said which colours to use, yet it draws. When a RasterLayer has no ColorScale attached it resolves one and keeps it, so layer.colorScale, getLegend() and getStats() always describe what is actually on screen. The order is: an explicit scale you attached → a GDAL legend embedded in the file → a continuous default ramp over the grid's own min/max. This file has no embedded legend, so it lands on the default.

const layer = await fim.addLayer(ds);   // kind-inferred; decodes, colorizes, draws
layer.fit();                            // move the camera to this layer's bounds

layer.colorScale.toJSON();   // the auto-resolved default, ranged to the data
fim.layers;                    // [layer] — the per-map registry, bottom-up

Output

4Recolour it live

layer.set() is the one knob writer. Scale keys (palette, continuous, min, max, unit) route to the attached ColorScale; layer keys (opacity, noData, hover) apply to the layer. A batch is one write and one repaint, not one per key — and an unknown palette name throws with the list of real ones rather than silently falling back.

ColorScale.palettes();   // → every ramp available: built-ins + anything registered

// One call, one repaint. `set` is partial/best-effort: if one key fails the
// others still apply, and a single aggregate error names which failed.
await layer.set({ palette: "viridis", continuous: true, min: 0, max: 10, opacity: 0.85 });

// Registering your own ramp is two arguments — the library owns the mechanism,
// the host owns the specifics.
ColorScale.registerPalette("flood", ["#f7fbff", "#4292c6", "#08306b"]);

Output

5The two read-models: Legend and Stats

Both are derived, not stored. getLegend() is a view of the colour scale — data-complete (toJSON()) with toHtml() as a convenience renderer. getStats() is a single pass over the decoded pixels: min/max/mean/median/stddev, a histogram, real ground area, and byClass counts bucketed by the same scale the map is drawn with. Both re-derive after a recolour, which is why the panel below changes when you touch the controls above.

const legend = layer.getLegend();     // Legend — { unit, kind, source, stops }
legend.toHtml();                       // a ready-made swatch list / gradient bar

const stats = await layer.getStats();   // Stats — pure, terminal, serializable
stats.describe();                     // a one-line human summary
stats.percentile(95);                 // p95 depth
stats.toCSV();                        // …or hand it straight to a download

Output

6Read a pixel under the cursor

valueAt(lat, lng) is the lookup; enableHover() wires it to the provider's mousemove and emits a ready-to-display hover event. The engine names no element of yours — it reports what happened and the page decides what that looks like, which is why the readout below is three lines of page code.

Hit-testing is pixel-level, not bounding-box: hitTest() is true only over a real non-noData pixel, so a click on a transparent part of the footprint falls through to whatever is underneath. Move the pointer over the raster to see it.

layer.enableHover({
  // A domain rule, not a noData rule: zero depth means "not flooded here".
  isEmpty: (v) => v === 0,
  formatValue: (v) => `${v.toFixed(2)} m`,
});

layer.on("hover", ({ lat, lng, value, text }) => {
  readout.textContent = `${text}  @ ${lat.toFixed(4)}, ${lng.toFixed(4)}`;
});
move the pointer over the raster…

7A vector layer on the same map

Same two calls, different kind. Vector styling uses a neutral vocabularyfillColor, fillOpacity, strokeColor, strokeWidth, pointRadius — that each provider translates into its own SDK names, so one style object works on Google and Leaflet alike. Any key the vocabulary doesn't name passes through untouched to the provider.

This sample is 99 Iowa counties, and the raster is in Mississippi, so the two layers do not overlap — which makes fit() worth having. Click a county to resolve the feature under the pointer with featureAt().

const counties = await fim.addDataset(countyFile);   // GeoJSON → a vector Dataset

const vec = await fim.addLayer("vector", {
  source: counties,
  style: { fillColor: "#58a6ff", fillOpacity: 0.15,
           strokeColor: "#58a6ff", strokeWidth: 1 },
});

// Layer dispatch has to be turned on: hover/click are routed to layers by
// hit-test and z-order, and a layer nobody listens to shouldn't pay for it.
fim.enableMapEvents(["click", "hover"]);
fim.on("map:click", ({ lat, lng }) => vec.featureAt(lat, lng));

Output

8Teardown

layer.remove() tears the overlay down, fires removed synchronously and drops the layer from fim.layers. The Dataset survives: the registry records what was parsed, the reference count records what is rendered, and only the latter is affected. Re-adding a layer for the same Dataset re-forces it from bytes it already holds.

fim.on("layers:changed", ({ reason, layer }) => refreshMyList());

layer.remove();                       // teardown + 'removed' + drop from fim.layers
layer.remove({ purgeSource: true });   // …and unregister sources nothing else holds

fim.removeDataset(ds);               // throws while a layer still renders it
fim.removeDataset(ds, { force: true });

fim.destroy();                       // detach the map, release it from its app

Output