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.