FIMViz.js

5 · The UI toolkit — fimviz/ui

A separate, opt-in module the engine core never imports. Every widget here touches the DOM only inside a function call, and none of them is required for the engine to work: the engine emits and the UI subscribes, never the reverse. Each section below mounts the real widget next to the code that mounts it — so this page is also the manual check for the things a headless test cannot judge.

It has exactly one home — import … from "fimviz/ui", its own bundle (~56 KB, no geotiff, no Maps loader, no GDAL). The engine barrel deliberately does not re-export it, so an app can never end up running two copies with two sets of state.

1Toast, busy, and the engine→host bus

The engine's only outbound channel is its event bus. connectToast subscribes a toast to notify, and also surfaces two warnings that otherwise reach only console.warn — an oversized raster drawn downsampled, and a layer whose CRS the provider cannot draw. Both describe something visibly wrong with what was just drawn, which is exactly the thing nobody finds in a console.

createBusyIndicator ref-counts by source. The busy event carries one precisely because work overlaps, and a single boolean would let the first job to finish hide an indicator two others still need. Parsing emits source: 'parse' and a forced warp emits 'reproject', both paired in a finally so a failure clears the spinner rather than leaving it running on work that is not happening.

Two bus events are deliberately left unbound: storage:changed and upload:complete each mean "a list you own is stale" or "dismiss the affordance you showed" — and neither the list nor the affordance belongs to this library.

import { createToast, connectToast, createBusyIndicator } from "fimviz/ui";

const toast = connectToast(fim);            // creates one if you don't pass it
toast.show("Saved.", { level: "success", timeout: 4000 });

connectToast(fim, toast, { warnings: false });   // `notify` only

const busy = createBusyIndicator(fim, {
  root: "#busy",
  label: (sources) => `working: ${sources.join(", ")}`,   // a string, or a function of the sources
});
busy.active; busy.sources; busy.off();

Output

2createDropzone — a DOM contract, not a loader

fim.addDataset(file) already takes a File, so this widget adds nothing to the loading. It exists because the drag half is where the mistakes are, and every one of them fails silently: dragover must be cancelled or the browser navigates away to display the file and drop never fires at all; dragenter/ dragleave fire for every descendant crossed, so an uncounted pair flickers; dataTransfer.items also carries dragged text and links, whose getAsFile() is null.

Loading is sequential and per-file: one bad file reports through onError and the rest still land, and the stacking order matches the order dropped rather than whichever decoded first. Drop a GeoTIFF or a GeoJSON below — or click it, since it is not drag-only.

import { createDropzone, DROP_EXTENSIONS } from "fimviz/ui";

const zone = createDropzone("#drop", {
  fim,
  add: "layer",          // 'layer' → addDataset + addLayer · 'dataset' → stop at the Dataset
                          // 'none'  → just report the File (no `fim` needed)
  accept: DROP_EXTENSIONS, // or your own array, or a (file) => boolean; [] accepts everything
  onLoad: (layer, file, i) => layer.fit(),
  onError: (err, file) => console.warn(file.name, err),
  onDone: (results) => console.log(`${results.length} loaded`),
});
zone.open();   // the file picker, programmatically

Output

3createLayerPanel and click-to-select

A view of fim.layers with show/hide, reorder, fit and remove. Two things worth knowing: rows are top-first, the reverse of fim.layers, which is bottom-up — that matches every layer list a user has met, but it means "up" in the panel is toward the end of the array. And hidden layers stay listed, struck through: a layer you cannot see is exactly the one you need a list to find.

Reordering moves the array and calls fim.applyLayerOrder(). Both halves matter — the array is what hit-testing walks, and applyLayerOrder is what makes the map draw in the same order. Before that method existed the two could disagree, and the layer receiving a click was not necessarily the one on top.

createLayerSelect resolves a map click to the stack of layers under the pointer, and clicking the same spot again advances through it — the only way to reach a layer buried under another. It rides the ordinary click bus rather than modal capture, so hover and feature clicks keep working while it is on.

import { createLayerPanel, createLayerSelect, layerLabel } from "fimviz/ui";

const panel = createLayerPanel("#layers", {
  fim, pretty: true,
  onSelect: (layer) => showSettingsFor(layer),
  onRemove: (layer) => console.log("removed", layerLabel(layer)),
});

createLayerSelect(fim, { fit: false, onSelect: (l) => panel.select(l) });
layerLabel(layer);   // filename → dataset name → type → id

Output

4createToolsPanel — a view over layer.settings

Every control writes layer.settings.set({ [key]: value }) and nothing else. The preset is picked from the layer's shape: rasterControls offers every key RasterSettings accepts — palette, continuous, min, max, unit, plus row editors for the two mode-switching ones (stops for discrete bands, colorStops for gradient control points) — because a knob the settings layer honours but no preset exposes is a knob nobody can reach. vectorControls offers opacity plus either a flat colour or, when the layer grades features, the same palette controls.

The panel does not police the three mutually exclusive colouring modes — it inherits the exclusion from the engine, where each setter clears the others. So adding a band leaves palette mode, and picking a palette is how you get back. Neither editor has a "clear", because emptying stops does not restore palette mode: it leaves an explicit scale with no bands, and nothing painted.

reactive: true re-reads the layer when something else changes it — opt-in, because this panel is made of live inputs and a restyle arrives on every keystroke-driven commit. The rule it follows is: if focus is inside the panel, defer the redraw to focusout. The user's own edits are exactly the ones that need no repaint.

import { createToolsPanel, rasterControls, vectorControls } from "fimviz/ui";

const tools = createToolsPanel("#tools", {
  layer, pretty: true, reactive: true,
  // controls: rasterControls(layer),        ← the preset, if you want to inspect it
  // controls: (l) => [...rasterControls(l), { type:'range', key:'myKnob', label:'Mine' }],
});
tools.update();   // re-read on demand

rasterControls(layer);   // PURE — the control spec, node-testable

Output

5bindLegend / bindStats — read-models that keep up

The same renderers as notebook 3, mounted and kept current, so a host never has to remember to re-read after a repaint. They subscribe to three of the layer's own events and only these three: restyle (the colours changed), recomputed (the pixels changed) and rendered (a draw completed — which is when a legend derived from the grid first exists at all). settings is excluded on purpose: it also fires for knobs that change neither.

Four things they handle that hand-rolled re-reading usually does not. Coalescing: one logical change can emit two of those events, so reads batch to one per microtask. Ordering: getStats() is async and a fast sequence of edits can resolve out of order — a stale result is dropped rather than painted over a newer one, because a lagging panel is recoverable and a wrong one is not. removed: the panel clears rather than showing a legend for a layer that is gone. And filter may be a getter, because the usual filter is a drawn region that changes independently of the layer.

import { bindLegend, bindStats } from "fimviz/ui";

const legend = bindLegend(layer, { root: "#legend", html: true, empty: "no colour scale" });
const stats  = bindStats(layer,  { root: "#stats", html: true,
                              filter: () => lastRegion });   // a GETTER — see the note above

await layer.set({ palette: "plasma" });   // both repaint themselves
stats.update();                          // only needed when the FILTER changed

6Hover tooltip and feature info window

Both are thin views over the layer-dispatch tier, so fim.enableMapEvents() has to be on — a layer nobody listens to should not pay for a mousemove listener. bindHoverValue reads layer.settings.get('hover') on every move, so a host can suppress the readout with a settings write instead of unbinding. bindFeatureInfo opens on a feature click and closes on a miss; propsTable is the default renderer and is exported separately.

import { createTooltip, bindHoverValue,
         createInfoWindow, bindFeatureInfo, propsTable } from "fimviz/ui";

fim.enableMapEvents(["click", "hover"]);

bindHoverValue(rasterLayer, { format: (v) => `${v.toFixed(2)} m` });

bindFeatureInfo(vectorLayer, {
  render: (feature) => `<b>${feature.properties.CountyName}</b>` + propsTable(feature.properties),
});

await rasterLayer.set({ hover: false });   // suppress without unbinding

Output

hover the raster, or click a county…

7Selection — four tools, one geometry

createRegionDraw is modal: while it is active fim.captureInteraction routes every map event to the tool and layer hover/click dispatch is suppressed. Four modes produce one output — rings of {lat,lng} wrapped in a SpatialFilter, which is exactly what dataset.mask() and layer.getStats({ filter }) already take, so nothing downstream can tell which tool drew the shape. The brush exploits that a second time: a stamp per stroke sample is just a multi-polygon, which SpatialFilter already unions.

Three things a host has to get right, all of them learned in a browser rather than in a test. Enable the events the drag modes need (mousedown/mouseup, or freehand and brush fall back to click-to-start). Wait for the camera — a click resolved mid-animation is projected against the pre-animation view, so await fim.whenIdle() after a fit(). And start() is what arms the tool: a UI where picking "brush" does not call it will pan the map under the user's stroke, because for the drag modes start() is also what takes pan-by-drag away.

The tool is headless — it names no map SDK, which is what lets it run on Google, Leaflet and a test's fake map alike. createRegionOverlay is the other half, and it draws three feature kinds because a selection is visible long before it is a polygon: a vertex marker from the first click, an open edge at two points, a closed ring at three. It draws through fim.addScratchVector — deliberately not a Layer, so the shape is never hit-tested, reordered, listed or saved.

import { createRegionDraw, createRegionOverlay, regionGeoJSON, REGION_MODES } from "fimviz/ui";

fim.enableMapEvents(["click", "hover", "mousedown", "mouseup"]);

const overlay = createRegionOverlay(fim);
const draw = createRegionDraw(fim, {
  mode: "polygon",        // polygon | rectangle | freehand | brush
  brushRadius: "2vw",     // metres as a number, or a SCREEN size re-resolved per stamp
  onPreview:  (rings, points) => overlay.show(rings, points),
  onComplete: (filter, points, rings) => {
    overlay.show(rings, points);
    if (filter) layer.getStats({ filter }).then(report);   // null if the shape has no area
  },
  onCancel: () => overlay.clear(),
});

draw.setMode("brush");   // mid-draw: discards the shape, and does NOT fire onCancel
draw.start();            // ← arms it. Esc cancels, Enter finishes, Backspace undoes.

Output

pick a tool — it arms itself — then draw on the map

8createOperationsPanel — the ops as a form

Every Dataset operation from notebook 2, driven onto the live layer through deriveSources so memoized ancestors are reused. It is a table, not a form per op: the only thing that genuinely differs between operations is which controls they need and the one line that calls the Dataset, which buys three properties. An op's controls cannot disagree with what it passes. Ops are filtered by kind, so a vector layer is offered rasterize and nothing else — an op that could only ever throw is not shown at all. And an op whose requirement is missing is disabled with the reason printed rather than failing when pressed.

The two table-returning terminals — zonal statistics and group-by — report through onResult instead of onApply, because they return data and provably leave layer.sources alone: telling the host "the layer changed" when it had not would be a lie the panel can easily avoid. Draw a region in the previous section first and Mask and Zonal stats light up.

import { createOperationsPanel } from "fimviz/ui";

const ops = createOperationsPanel("#ops", {
  layer, fim, pretty: true,
  region: () => lastRegion,      // feeds Mask and Zonal stats
  layers: () => fim.layers,      // the operand pool for Combine and Group by
  open: ["Extent", "Values"],     // which groups start expanded
  onApply:  (layer, err) => err ? report(err) : report(layer),
  onResult: (id, rows) => console.table(rows),   // a TERMINAL's table, not a layer change
});
ops.ops;   // the op ids actually offered for this layer's kind

Output

9bindRasterMetadata — the last of the bus

Renders the raster:metadata / raster:metadata-hidden pair. The engine computes the rows and emits them precisely so it never has to name a panel — this is the panel. Hiding does not clear the payload, because the engine's event means "no longer current", not "forget it". renderRasterMetadata is the pure default renderer, for a host that wants its own chrome. In this bundle only app-tier layers emit the event, so the button below emits one by hand.

import { bindRasterMetadata, renderRasterMetadata } from "fimviz/ui";

const meta = bindRasterMetadata(fim, { root: "#meta" });
meta.shown; meta.payload; meta.off();

renderRasterMetadata(payload);   // PURE — the same rows, as HTML

Output

10The one widget that lives elsewhere

createAxisSlider — plus axisOf and axisEntryLabel — belongs to the selection-axis model, so it is demonstrated where that model is: notebook 4. It is not a time slider; scrubbing is layer.setSources([ds.select(coord)]), which says nothing about time.