> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nikaplanet.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Authoring Model

> The OnlyMap manifest: elements, expression accessors, data sources, widgets, interactions, and the validation loop.

## The Manifest

A small set of elements follows one rule. An attribute is the kebab-case form of a deck.gl prop (`radius-units` → `radiusUnits`). A `get-*` attribute is a data-driven accessor.

* **`<om-map>`** — the root element. Give it a height. A custom element is inline by default and collapses to zero size, so a bare map shows nothing. Set `om-map { display: block; height: 100vh }` (with `html, body { height: 100% }`), or put the map in a sized container. Without a height the library fills a sized parent or falls back to a 400px default, and it warns in the console if the map still has no size. It takes `center`, `zoom`, `pitch`, and `bearing`; `max-pitch` and `min-zoom` <sup>0.8.1 / 0.8.3</sup> cap the tilt and floor the zoom-out for a kiosk or embedded map, and are never applied automatically. `crs="EPSG:5070"` <sup>0.10.0</sup> draws the whole map in a real projection — see [Cartographic Maps](#cartographic-maps). `basemap` takes a free preset (`positron`, `liberty`, `dark-matter`, `osm`, `satellite` <sup>0.11.10</sup>, …), a style URL, or `"none"`, and it switches live. `satellite` is keyless imagery of the whole world — EOX's Sentinel-2 cloudless mosaic (2016), commercial use allowed with the credit it shows — detailed to about zoom 13–14; for street-level or recent imagery use the keyed `maptiler-satellite`. The 3D scene is also attributes: `terrain="terrarium"` (or a DEM tile URL) adds elevation, and `lighting="daylight|studio|flat|custom"` with `lighting-*` attributes lights it. Add `validate` for an error panel on the page while you author.
* **`<om-layer>`** — any registered deck.gl layer type, by name. `id` is required. `label` and `color` feed the legend. Under active terrain, a geographic layer drapes onto the surface by default (set `terrain="drape|offset|off"` per layer).
* **`<om-widget>`** — a UI panel: `legend`, `layer-switcher`, `basemap-switcher`, `lighting`, `zoom-controls`, `undo-redo`, `scale-bar`, `attribution`, `filter`, `time-slider`, `vega-lite`, `draw`, `player`, `widgets-toggle` — or your own HTML with a `<script type="om/widget">` block. You can theme every built-in widget with `--om-widget-*` CSS custom properties from plain page CSS. The library manages placement (see below).
* **`<om-overlay>`** — HTML anchored to a coordinate, the current selection, or a feature's geometry. `{{field}}` inserts a value from the picked feature, HTML-escaped by default.
* **`<om-behavior>`** — a declarative interaction: `on="click|hover|drag|load|data-loaded"` runs a named action.
* **`<om-story>` / `<om-step>`** — a storyboard. Each step fires actions on a timeline. The `player` widget plays, pauses, and scrubs it.
* **`<om-fallback>`** — content that shows where scripts do not run (chat-app and email file previews). The library hides it after the map starts.

Always write explicit closing tags (`<om-layer …></om-layer>`). Do not self-close a custom element. Give every layer a stable `id`.

## Widget Placement & Adaptive Layout

`position` puts a widget in one of 8 logical slots: `top-start`, `top-center`, `top-end`, `center-start`, `center-end`, `bottom-start`, `bottom-center`, `bottom-end`. The slots flip left-to-right for right-to-left languages. Old corner names such as `top-right` still map to these slots. The library sets the pixel position for you. You do not position the widgets and map controls by hand.

* **Widgets in the same slot stack together** with a shared gap. The `order` attribute sets their sequence in the slot.
* **Small controls next to each other join into one group** (`zoom-controls`, `undo-redo`, `widgets-toggle`). Set `cluster="false"` to keep a control separate.
* **The map attribution goes in `bottom-end`** and the license badge in `bottom-start`. They are part of the slot flow, so they do not cover a widget. The map always shows one attribution, because the license needs it.
* **Folding on narrow maps**: when the map is 640px wide or less, the widgets move into a drawer on each side. Set `fold="never"` to keep a control always visible. Set `widgets-fold="off"` to stop the folding. Change the width limit with `--om-widget-fold-breakpoint`.
* **Dimming under popups**: a slot becomes dim when an open popup covers it. Set `widgets-dim="off"` to stop this. Slots with the attribution or the badge do not dim.
* **Hide all widgets**: `<om-map widgets-hidden>`, the `set-widgets-visible` action, or a `widgets-toggle` widget hide your widgets but keep them in the page. The attribution and the badge stay visible.
* **Turn off management**: set `position="manual"`. The widget becomes a plain block that you style yourself. You can put it outside the map.

You can also set colors (`--om-widget-bg`, `-fg`, `-muted`, `-border`, `-hover-bg`, `-accent`) and spacing (`--om-widget-inset-x/-y`, `--om-widget-gap-x/-y`, `--om-widget-opacity`, `--om-widget-radius`) with CSS custom properties. For a quick option without CSS, use `<om-map widget-style="gap:10 opacity:0.9">`.

## Accessors Without JavaScript

A `get-*` attribute takes a small, safe expression language. `$field` reads a field from a datum, for any data shape — flat JSON, GeoJSON properties, or Arrow columns:

```html theme={null}
get-position="[$lon, $lat]"
get-radius="$population * 0.001"
get-fill-color="$value > 100 ? [255,0,0] : [0,128,255]"
get-fill-color="scale($depth, sequential, ['#ffffcc','#800026'], domain=[0,700])"
get-text="formatDate($time, 'datetime', 'UTC')"
```

The built-in functions include `scale()`, `colorRamp()`, `clamp()`, `lerp()`, `formatDate()`, and `Math.*`. `scale()` needs an explicit `domain=`, and its kind must match its range: `sequential`/`diverging` interpolate color stops, `linear`/`sqrt`/`log`/`pow` interpolate numbers (sizes, radii, widths), and `threshold` maps either — a mismatch is a compile error rather than an invisible layer. `pow` takes a trailing `exponent=` (`scale($v, pow, [0, 30], domain=[0, 100], exponent=2)`); for a straight line use `linear`. The compiler is an AST whitelist, not an eval, so untrusted, agent-written expressions are safe by design. To use arbitrary JavaScript, you must add an explicit `js` opt-in on the layer.

When the class boundaries should come from the data itself rather than a hand-written `domain=`, use classification attributes instead of an expression: `classify-by="magnitude"` on a layer computes class breaks from the loaded rows and colors the fill. `classify-scale` picks the method — `quantile` (the default, equal counts per class), `equal-interval` (equal spans), or `jenks` (natural breaks) — `classify-classes` sets 2–12 classes (default 5), and `classify-ramp` names one of 14 built-in color ramps (`viridis`, `plasma`, `turbo`, `blues`, `reds`, `ylorrd`, `rdbu`, `spectral`, …). The legend shows the computed class rows automatically. A hand-authored fill color always wins over classification, and `OmMap.validate` names the conflict. A URL-backed layer classifies when its data arrives.

```html theme={null}
<om-layer id="quakes" type="ScatterplotLayer" data="./quakes.csv"
          get-position="[$longitude,$latitude]" radius="6" radius-units="pixels"
          classify-by="mag" classify-scale="jenks" classify-classes="5"
          classify-ramp="viridis"></om-layer>
```

Dashed lines are a single attribute — `dash="[6, 3]"` (or SVG-style `dash="6 3"`, plus optional `dash-justified`) on a line layer (`PathLayer`, `GeoJsonLayer`, `PolygonLayer`, `TripsLayer`). The values are `[dashLength, gapLength]` in the same units as the line width; you do not wire deck.gl's `PathStyleExtension` yourself.

`formatDate(value, style?, timeZone?)` formats epoch-millisecond numbers or ISO strings without exposing JavaScript's `Date` or `Intl` objects. Its styles are `date`, `datetime` (the default), `time`, and `iso`. The default time zone is `UTC`; use `local` for the viewer's zone or an IANA name such as `Asia/Singapore`. Numeric timestamps always mean milliseconds—multiply Unix seconds by 1000 first. Invalid values produce an empty label rather than stopping layer rendering.

## Data Sources

| Source | Manifest |
| - | - |
| JSON / GeoJSON | `data="./points.json"` |
| Inline rows | `<script type="application/json">` child |
| CSV / TSV | `data="./quakes.csv"` — parsed to typed columns |
| Apache Arrow / GeoArrow | `data="./big.arrow"` — points stay columnar; zstd IPC supported |
| Shapefile | `data="./countries.shp"` — `.dbf` attributes joined automatically |
| KML | `data="./tour.kml"` |
| GPX | `data="./hike.gpx"` — waypoints/tracks/routes → features tagged `_gpxKind`; `#waypoints`/`#tracks`/`#routes` selects one part |
| FlatGeobuf | `data="./cities.fgb"` — cloud-native binary vector, whole-file decode |
| GeoParquet | `data="./data.parquet"` (or `.geoparquet`) — cloud-native columnar vector; all-Point files stay columnar, lines/polygons → features; needs `geo` metadata + CRS84/EPSG:4326 (projected CRS errors — reproject first) |
| CityJSON / CityJSONSeq | `data="./tile.city.json"` (or `.city.jsonl`, streamed) — 3D city models; see below |
| WebSocket stream | `data="wss://feed" key="id" flush="250ms"` — upsert-by-key live entities |
| Polled REST snapshot | `data="/api/fleet.json" refresh="5s"` |
| Drawing store | `data="draw:sketch"` + `<om-widget type="draw" target="sketch">` |
| GeoTIFF / COG raster | `<om-layer type="COGLayer" src="./dem.tif" min="0" max="1900" colormap="viridis">` |
| Zarr / GeoZarr raster | `<om-layer type="ZarrLayer" src="./x.zarr" variable="temp" select="time=0" colormap="viridis" min="…" max="…">` — chunked N-D arrays; see below |
| Drone JPEG image | `<om-layer type="ImageOverlay" src="./DJI_0123.jpg" georeference="exif">` — georeferenced photo overlay; see below |

`data` is always an address, never the rows themselves: `data='[{…}]'` draws nothing, and the validator reports it as an error <sup>0.11.9</sup>. Put inline rows in a `<script type="application/json">` child instead. A `data` address that fails to load (a typo, a missing file, a blocked request) is reported in the map's validation panel and diagnostics until it loads <sup>0.11.9</sup>.

You can add custom formats and stream decoders with `OmMap.registerFormat` and `OmMap.registerSource`. For an authenticated endpoint, use `OmMap.configureData({ headers, credentials, fetch })`. Credentials do not belong in the markup.

A raster is the one exception to the `data` attribute. The layer's own reader streams a GeoTIFF as tiles, with HTTP Range requests against a Cloud-Optimized GeoTIFF. It does not parse the raster into rows, so you use `src`. Set `min` and `max` for float or 16-bit data; the default stretch is 0–255. A single-band source takes a bundled `colormap` (`viridis`, `terrain`, `rdbu`, …). A source with 3 or more bands composites as RGB, with no attributes. A restretch or a recolor is a GPU uniform update, so the tiles are not fetched again. The legend shows the colormap ramp automatically.

## Clustering Overlapping Points <sup>new in 0.11.0</sup>

Thousands of points that pile up when zoomed out become count bubbles that split apart as the reader zooms in. It is one attribute on the layer, not a different layer type — the layer keeps its id, its data, its own colours and sizes for the points that stand alone, its popups and its actions:

```html theme={null}
<om-layer id="plants" type="ScatterplotLayer" data="./plants.csv"
          get-position="[$lon, $lat]" color="#2563eb" radius="4" radius-units="pixels"
          cluster="true" cluster-properties="sum:capacity_mw,mode:fuel"
          get-cluster-fill-color="$mode_fuel == 'Hydro' ? '#0ea5e9' : '#64748b'"
          pickable></om-layer>
```

`cluster="true"` works on `ScatterplotLayer`, `GeoJsonLayer` (Point features only — lines and polygons refuse with a structured error) and `IconLayer`. `cluster-radius` (screen pixels, 50), `cluster-max-zoom` (16) and `cluster-min-points` (2) use the names MapLibre users already know.

**Bubbles can say more than a count.** `cluster-properties="op:field,…"` with `sum`, `min`, `max`, `mean`, `mode` (the commonest value) or `first` <sup>0.11.1</sup> (the earliest member's value in data order — any type, a URL or a name) puts `$sum_capacity_mw`, `$mode_fuel`, `$first_photo`, … on every bubble beside `$count`. Style bubbles with `get-cluster-radius`, `get-cluster-fill-color`, `get-cluster-line-color` and `get-cluster-label` — the same expression language as the rest of the map.

**A bubble can carry an image** <sup>new in 0.11.1</sup>. `get-cluster-icon="$first_photo"` draws the image as a circular thumbnail cropped to the bubble and ringed in the bubble's colour — the photo of the first place in the cluster. A name instead of a URL draws a glyph from `cluster-icon-atlas` / `cluster-icon-mapping` (or an `IconLayer`'s own atlas). Images must be same-origin or served with CORS headers, as any image a map draws must; a bubble whose image is still loading, or could not load, stays a plain circle.

**The count as a badge** <sup>new in 0.11.2</sup>. `cluster-label-position="top-right"` (or any edge or corner) moves the number onto the bubble's rim, off a photo; `cluster-label-background="#111827"` draws a pill behind it; `cluster-label-color` and `cluster-label-size` set its colour and size. `cluster-label="false"` hides it. The default is unchanged — centred, white, with a thin dark halo.

**Sizes that survive zoom.** Every bubble also carries `$count_max` and `$<aggregate>_max`, the largest value among all clusters at the *current* zoom. Divide by them and a ramp stays meaningful whether the reader is looking at a continent or a county; the default radius, `scale($count / $count_max, sqrt, [12, 36], domain=[0, 1])`, keeps the biggest bubble at 36 px at every zoom. (`scale()` domains are literals, so this is the way to get a zoom-adaptive ramp.)

**Picks.** A click on a bubble flies, eased, to the zoom where it splits (`cluster-click="none"` opts out; the `expand-cluster` action does it by hand). A bubble pick carries `cluster: true`, `cluster_id`, `count` and the aggregates, so `{{count}}` works in overlays. Give points and bubbles their own cards with `pick="point"` / `pick="cluster"` on `<om-behavior>` — a template cannot branch, and one shared card would open half-empty for bubbles:

```html theme={null}
<om-behavior on="click" layer="plants" pick="point"   action="show-overlay" target="detail"></om-behavior>
<om-behavior on="hover" layer="plants" pick="cluster" action="show-tooltip" template="#bubble-tip"></om-behavior>
```

`map.clusterLeaves(layerId, clusterId)` returns the rows inside a bubble; the legend adds a bubble row under a clustered layer. A widget button can switch clustering on and off at runtime with `set-layer-attribute` (`{ layer, name: "cluster", value: "true" | "false" }`); `ctx.layers[i].cluster` reports the state.

**Bubbles far out, icons close in**: two layers over the same file with complementary `visible-zoom-range`s — the clustered layer on `[0, 12]` with `cluster-max-zoom="11"`, an `IconLayer` on `[12, 24]`. Clustering is not applied on a projected (`crs=`) sheet; the layer renders unclustered with a warning.

## CityJSON 3D City Models

Point `data` at a CityJSON `.city.json` file (or a `.city.jsonl` sequence, which fills the map as it downloads). CityJSON is the format of national building datasets such as 3DBAG, PLATEAU, and swisstopo. There is no CityJSON layer type — the `data` URL decodes to one of two shapes:

* **Extruded footprints** (default): one row per building. Use `type="GeoJsonLayer"` with `extruded` and `get-elevation="$roof_height"`.
* **Per-face surfaces** (`?om-surfaces=1`): one row per face, each at its own real height, so a pitched roof looks pitched. Use `type="SolidPolygonLayer"` with `get-polygon="$polygon"` and `full3d`. Each row carries a `surface_type` (RoofSurface / WallSurface / GroundSurface) and a default `fill_color`, which `get-fill-color` reads automatically — so the buildings render in sensible colors with no styling.

Both modes add derived properties: `roof_height`, `eaves_height`, `ridge_height`, `ground_height`, `roof_area`, `surface_count`, `lod`, and the CityObject ids. Surfaces mode also adds `polygon` and `outline` — bind a companion `type="PathLayer"` with `get-path="$outline"` for visible face edges, because surfaces mode is flat-shaded.

National grids (NL, CH, DE, JP, AT, SG) reproject automatically, axis order included; any other EPSG code fails with an error that names it. The highest LoD is used — pin one with `?om-lod=1.2` (combine as `?om-lod=1.2&om-surfaces=1`). Surfaces mode costs about 30× the rows, so it reaches the free-tier 25,000-row cap at a few hundred buildings; past that the layer renders its first 25,000 rows with an on-map notice.

## Georeferenced Image Overlays

`type="ImageOverlay"` drops a geotagged drone photo onto the map. It is the OnlyMap-owned preprocessing path over deck.gl's `BitmapLayer`: the browser reads the JPEG's EXIF + DJI XMP, computes an axis-aligned WGS84 footprint, bakes the camera yaw and any upside-down roll correction into the pixels, and renders the processed image at those bounds.

```html theme={null}
<om-layer id="survey-photo" type="ImageOverlay"
          src="./DJI_0123.jpg" georeference="exif" opacity="0.8"></om-layer>
```

The JPEG must carry GPS latitude/longitude, a positive `RelativeAltitude`, image dimensions, and gimbal yaw/pitch. Known DJI and Parrot cameras use a bundled sensor database; focal length comes from EXIF and is never guessed. For an unlisted camera, supply the physical sensor size (and focal length if EXIF omits it):

```html theme={null}
<om-layer id="survey-photo" type="ImageOverlay"
          src="./survey.jpg" georeference="exif"
          sensor-width-mm="13.2" sensor-height-mm="8.8" focal-length-mm="8.8"></om-layer>
```

The source follows `OmMap.configureData` for authenticated endpoints, and `map.ready` waits for the image to resolve; a failed image is logged and skipped rather than rendering wrong.

**Persist once, reconstruct cheaply.** For saved or collaborative maps, preprocess a file once with `OmMap.resolveImageOverlay(file)` → `{ image, bounds, metadata }`, upload the returned image, and store its bounds. Reconstructing with explicit `bounds` skips all fetching and EXIF work:

```html theme={null}
<om-layer id="survey-photo" type="ImageOverlay"
          src="./processed/0123.png" bounds="[103.841,1.286,103.845,1.290]"></om-layer>
```

This is a visualization-grade flat-ground estimate (pinhole camera, pitch-adjusted center, axis-aligned bbox), not orthorectification. Terrain relief, lens distortion, and a perspective-correct four-corner footprint are not modeled; large orthomosaics belong in a Cloud-Optimized GeoTIFF via `COGLayer`.

## Zarr / GeoZarr Rasters

`type="ZarrLayer"` renders chunked, N-dimensional array data (climate and weather grids, datacubes) on the GPU. It is built on `@developmentseed/deck.gl-zarr` and zarrita, and loads on first use. Styling — `min`, `max`, `colormap`, `nodata` — and the legend work exactly like `COGLayer`.

```html theme={null}
<om-layer id="temp" type="ZarrLayer"
          src="https://example.com/store.zarr"
          variable="temperature" select="time=0"
          bounds="[-30, 25, 40, 72]" crs="EPSG:4326" spatial-dims="latitude longitude"
          colormap="turbo" min="-35" max="40"></om-layer>
```

* `src` (required) — the `.zarr` store URL. Like `COGLayer`, chunks stream through the layer's own reader; they are never parsed rows.
* `variable` — the array within the store to render.
* `select` — pin every non-spatial dimension: `"time=0"`, `"init_time=0, lead_time=0, ensemble_member=0"`. The two spatial dimensions are handled for you; a 2-D array needs no `select`. Changing `select` re-slices without reopening the store, so you can animate a time axis by rewriting the attribute.
* A **GeoZarr**-compliant store georeferences itself. A **plain Zarr** needs manual `bounds="[west,south,east,north]"`, `crs="EPSG:4326"`, and `spatial-dims="<yName> <xName>"` (the two spatial dimension names).

**External / remote stores.** `src` can be any absolute URL, and a remote store needs no server setup — a static host (S3, GCS, a CDN, any object store) serves Zarr's chunk files natively. Two current constraints: the store is fetched directly from the browser, so it must be **CORS-enabled** (`Access-Control-Allow-Origin`); and it must be **public** — authenticated stores that need a header or token are not yet supported (the layer does not route through `OmMap.configureData`). Also mind the store's chunking: a dataset chunked coarsely over its non-spatial dimensions (for example, every forecast step in one chunk) decodes far more than the single pinned frame needs.

## BIM / IFC Models

`type="BIMLayer"` loads an `.ifc` file entirely in the browser — no pre-conversion, no server. The web-ifc WASM parser is fetched from a CDN on first use (integrity-pinned, never bundled), and the model renders as 3D Tiles with a companion edge-outline layer.

```html theme={null}
<om-layer id="clinic" type="BIMLayer" src="./Clinic_Architectural.ifc"
          pick-features pickable
          site-origin="[-71.059776, 42.358429]" site-heading="32"></om-layer>
```

For interactive loading, `<om-widget type="ifc-loader">` is a drop zone that parses the file and builds the layer pair for you; add `federate` and one drop zone accepts several discipline models into a co-registered scene (the first model fixes the shared origin), each with its own visibility toggle and remove button.

**Per-element picking and styling** works on any 3D Tiles layer, not just IFC. Add `pick-features` to a `Tile3DLayer` (or `BIMLayer`) and picks resolve individual elements — a wall, a window, one IFC product — with their `EXT_structural_metadata` properties. On top of that, everything is attributes:

* **Isolate / hide / ghost**: `feature-filter-field="component"` plus `isolate-features='["Wall"]'` / `hide-features` / `ghost-features` (JSON arrays of values). Isolate is exclusive scope; hiding is a shader discard, so a hidden element also stops blocking picks.
* **Color by property**: `feature-color-by="material"` (categorical, colour-blind-safe palette; override with `feature-palette`) or `feature-color-scale="netVolume"` (graduated). `feature-color-strength` below 1 tints instead of replacing. Setting neither is the default and renders the model's own IFC surface colours.
* Being attributes, all of this is undoable and story-steppable.

**Widgets**: `ifc-browser` groups the model by any property field with per-value isolate/hide/ghost buttons; `feature-inspector` shows the picked element's properties; `ifc-clash` flags interpenetrating element pairs across two co-registered models and flies to each clash.

**Placement**: `site-origin="[lng, lat]"`, `site-heading` (degrees clockwise from north), and `site-scale` position the model — they override what the file declares. A model with real georeferencing (`IfcMapConversion`) places itself, but do not trust a declared position without looking: most IFC2x3 exports carry an authoring-tool default location that is indistinguishable from a survey.

**Terrain is yours to author.** The library never writes scene attributes as a side effect: a model that resolves real-world elevation loading on a map with no `terrain` attribute raises a validation **error** (any value satisfies it, including an explicit `terrain="off"`), and a georeferenced model landing on a map with no basemap and no terrain raises a "no spatial context" warning — instead of the map switching either on for you.

## Cutting, Exporting, and Picking a 3D Scene

These tools operate on whatever 3D content is already loaded — 3D Tiles, `BIMLayer` models, extruded vector geometry alike — and they compose: cut a box open, outline what is inside, export it.

**Clip box.** An axis-aligned 3D box cuts the whole scene. It is map-level scene state, so it is undoable and story-steppable like `terrain` and `lighting`:

```html theme={null}
<om-map terrain="mapterhorn"
        clip-box-min="[6.145, 46.201, 380]"
        clip-box-max="[6.149, 46.204, 440]">
  <om-widget type="clip-box"></om-widget>
</om-map>
```

Every layer is clipped by default; `clip="off"` on an `<om-layer>` opts one out (a basemap, usually). `clip-box-invert` shows what is *outside* the box instead of inside. `clip-box-highlight` dims the clipped-away geometry rather than discarding it, which makes it a non-destructive preview. The `clip-box` widget renders the box as a draggable cuboid, and its "Show face gizmos" toggle adds a handle on each of the six faces so you can resize by dragging. The `set-clip-box` action takes `{min, max, invert?, highlight?}`, or `{clear: true}` to remove it. v1 is axis-aligned only.

**Region export.** Outline a footprint with the draw widget, then export the 3D content inside it:

```html theme={null}
<om-widget type="draw" export-3d></om-widget>          <!-- GLB (default) -->
<om-widget type="draw" export-3d="b3dm"></om-widget>   <!-- b3dm, for Cesium and 3D Tiles pipelines -->
```

Every triangle of every loaded, *visible* 3D Tiles or BIM layer inside the ring is clipped exactly and downloaded. Positions are re-framed to a local coordinate frame at the footprint's own centroid, so the file opens correctly in Blender or three.js without ECEF-scale support. Each triangle carries its source colour as vertex colours; there are no textures, because BIM and IFC materials are flat colours. A layer hidden with `visible="false"` is skipped. The clip and pack run synchronously, which is fine at single-model scale — keep footprints to the region you actually want.

**Depth picking.** An ordinary `pickable` layer resolves a click against the ground plane, so clicking a building face reports the point *behind* it. `pickable="3d"` opts the layer into the depth-pick pass instead:

```html theme={null}
<om-layer id="city" type="Tile3DLayer" tileset="…/tileset.json" pickable="3d"></om-layer>
```

The resolved coordinate then carries a real elevation, available as `{{z}}` in an `<om-overlay>` or `show-tooltip` template and as `ctx.selection.coordinate` in a widget script. `terrain` sets this on itself. `{{z}}` is **absent, not `0`**, when no layer ran the depth pass for that pick, so a missing elevation never reads as sea level.

**Snapping.** `<om-map snap="vertex edge midpoint" snap-tolerance="12">` refines a click or hover to the nearest vertex, edge, or edge midpoint of whichever feature is already under the cursor — including a `BIMLayer`'s real wall corners, converted from the model's local mesh coordinates automatically. Vertex beats midpoint beats edge when more than one is in range, and holding Space places a point without snapping. `snap="off"` opts a layer out. Snapping is horizontal: a snapped vertex takes the matched feature's position, and drawn geometry still drapes onto terrain normally.

## Measuring, Including Cut/Fill Volume

Distance and area work on any map. `volume` mode reads cut and fill against the map's elevation surface, so it needs `terrain` on `<om-map>` — validation warns when you author it without one.

```html theme={null}
<om-map terrain="mapterhorn">
  <om-widget type="measure" modes="distance area volume"
             units="metric" base-surface="custom"
             density="1.8" swell="1.25" shrink="0.9" profile></om-widget>
  <om-widget type="dynamic-chart" on="om-measure" series-field="profileSeries" width="320"></om-widget>
</om-map>
```

Click to place points; distance and area read out live. In `volume` mode you outline a footprint, close it, and drag the elevation gizmo. The result is a **real per-cell integration** of the terrain DEM against a base surface — not a flat approximation — so one footprint on sloping ground reports cut *and* fill simultaneously, each with a published ± error quoted against the DEM's own ground-sample distance rather than the grid spacing. The readout also carries the cell size and the fraction of cells with no DEM data, so you can tell when to distrust a number.

`base-surface` picks what the terrain is measured against:

| Value | Base surface |
| - | - |
| `custom` (default) | The draggable gizmo's target plane. By default it grades *parallel* to the terrain, so volume is depth × area and reads exactly zero at rest; the widget's "flat target plane" toggle switches it to one level plane, which produces mixed cut and fill on a slope. |
| `triangulated` | A TIN over the footprint's own boundary elevations — the usual choice for a stockpile, since it follows the pile's toe all the way round. |
| `plane` | A least-squares plane fit through the boundary elevations. |
| `lowest` / `highest` / `average` | A level plane at the boundary's minimum, maximum, or mean elevation. |

Switching strategies re-integrates against the already-fetched tiles, with no new network requests.

Reporting factors are separate attributes, and they never change the raw geometric volumes — those always answer "does this reach target elevation":

* `density` (t/m³ metric, lb/yd³ imperial) adds tonnage, computed from the raw volume, because swell and shrink change volume through air voids rather than mass.
* `swell` and `shrink` populate *separate* adjusted figures on the standard Bank/Loose/Compacted convention: adjusted cut = raw × swell, adjusted fill = raw ÷ shrink.
* `deadband` (m³) zeroes a figure below the threshold, filtering drag noise near zero.
* `profile` samples an elevation profile around the footprint's own perimeter and puts it on the `om-measure` event for a chart to plot — there is no second line to draw.

Everything is dispatched on the `om-measure` DOM event, so you can render your own readout instead of the widget's. The `dynamic-chart` widget above is deliberately generic: it reads `event.detail[seriesField]` on every matching event and redraws, and a feature "freezes" the chart for free by simply omitting that field on a later event.

## Interactions and State

Every built-in action uses one payload contract, across every trigger: behaviors, widget `data-emit` buttons, `ctx.emit`, and story steps. The actions include `show-overlay`, `hide-overlay`, `show-tooltip`, `hide-tooltip`, `toggle-layer`, `set-layer-attribute` <sup>0.11.0</sup> (write any layer attribute at runtime — the switch behind a widget button), `expand-cluster` <sup>0.11.0</sup>, `highlight-feature`, `zoom-to-feature`, `filter-layer`, `fly-to`, `set-basemap`, `set-lighting`, `set-terrain`, `set-clip-box`, `export-region-3d`, the `measure-*` family (`measure-mode`, `measure-units`, `measure-clear`, `measure-config`, `measure-flat-target-plane`), `undo`, and `redo`. Add your own with `OmMap.registerAction`.

A card that carries an image or a link — `<img src="{{photo}}">` — goes inside a `<template>` child of the `<om-overlay>` <sup>0.11.1</sup>: a template is inert when the page loads, so the browser never requests the literal `{{photo}}`; tokens stay blank until a pick fills them, and tooltips behave the same way.

A tooltip opened by a click closes when the selection clears — a click on empty map, or a pick that moves off every feature <sup>0.10.2</sup>. A popup carrying its own `data-emit` controls keeps pointer events and stays until it is dismissed. Nothing needs a hand-wired `hide-tooltip`. A popup that a click behavior opens with `show-overlay` stays open while the pointer moves on <sup>0.11.8</sup>: hovering another feature neither moves nor closes it, and the next click moves it to that feature or, on empty map, closes it. Before 0.11.8 this needed `selection-type="click"` on the `<om-overlay>`; that attribute still works and still wins.

GPU filtering is declarative (`filter-field="magnitude" filter-range="[4,10]"`). It updates live from the `filter` widget, and the widget statistics match what the map renders. For an epoch-millisecond field, the built-in widget can show readable labels:

```html theme={null}
<om-layer id="quakes" type="GeoJsonLayer" data="./quakes.geojson"
          filter-field="time" filter-range="[1782889284760,1785480717910]"></om-layer>
<om-widget type="filter" layer="quakes" field="time"
           format="date" date-style="datetime" time-zone="UTC"></om-widget>
```

`format` is `number` (the default) or `date`; `date-style` and `time-zone` use the same contract as `formatDate()`. The manifest is the state, so all of this is undoable. Scene state (basemap, lighting, terrain) is attributes on `<om-map>`, so a story step can change it, and a scrub restores it exactly.

A layer can filter on several fields at once. `filter-fields` takes up to 4 numeric `{field, range}` dimensions as a JSON array, and `filter-category-fields` takes up to 4 categorical `{field, categories}` keep-lists — a row must pass every active dimension of both kinds. Place one `filter` widget per field: the widget renders a range slider for a numeric field and a checkbox list (with per-value counts) for a categorical one, and each widget moves only its own dimension. `ctx.stats` and the built-in statistics respect whichever filters are active.

For temporal playback, the `time-slider` widget animates a filter over a time field instead of making you scrub it by hand:

```html theme={null}
<om-widget type="time-slider" layer="quakes" field="time" duration="20s" loop
           format="date" date-style="datetime" time-zone="UTC"></om-widget>
```

Play sweeps the layer's filter from the field's minimum to its maximum over `duration` of wall time — cumulative by default, or a sliding interval with `window="86400000"` (in field units, here one day of milliseconds). It drives the ordinary `filter-layer` action, so `ctx.stats`, the statistics readouts, and undo stay coherent with what the map renders, and any external filter edit (a story step, another widget, a script) moves the thumb rather than fighting it. `format`/`date-style`/`time-zone` label the current value with the same contract as the `filter` widget.

## Routes and Live Tracking

`<om-layer type="Route">` draws a styled A-to-B route — casing, colored line, and origin/destination markers — and `<om-layer type="Tracking">` renders one moving entity that glides between position updates. Both expand into ordinary deck.gl layers, so `pickable`, `opacity`, and the other layer attributes work unchanged.

```html theme={null}
<!-- You already have the path: resolves instantly, no network -->
<om-layer id="trip" type="Route" follow="fit-route"
          geometry='{"type":"LineString","coordinates":[[-122.42,37.77],[-122.41,37.79]]}'></om-layer>

<!-- A routing engine computes the path -->
<om-layer id="trip" type="Route" follow="fit-route" provider="osrm"
          origin="[-122.4194,37.7749]" destination="[-122.4130,37.7805]"></om-layer>

<!-- A moving marker over a live feed -->
<om-layer id="rider" type="Tracking" get-position="[$lng,$lat]"
          follow="follow" interpolate-ms="1200"
          data="wss://feed.example/rider" source="my-decoder" key="id"></om-layer>
```

A `RoutingProvider` is a small adapter you register once — `OmMap.registerRoutingProvider(name, { computeRoute })` — and reference by name in the `provider` attribute. A \~15-line adapter covers OSRM's keyless public demo server (it returns GeoJSON natively), and the same shape works for Mapbox Directions, OpenRouteService, GraphHopper, or a self-hosted engine. Re-routing is attribute writes: change `origin` or `destination` (from a click handler, a story step, an undo) and the layer re-resolves, aborting any in-flight request.

The Tracking layer's position feed is ordinary layer `data` — a WebSocket stream with a decoder, a polled REST endpoint, or inline JSON your script replaces. There is no separate tracking subscription API. `bearing-field` names the data field that rotates the marker, `interpolate-ms` sets how long the marker glides between two fixes instead of jumping, and `follow="follow"` eases the camera along with it. `icon="arrow|car|motorcycle"` picks the marker's shape (default arrow) — every shape points nose-up and is baked in the layer's `color`, so bearing rotation and per-entity tinting work identically; `size` scales it. One entity per layer: for a fleet, run one `Tracking` layer per vehicle (each glides independently), and beyond a handful drop down to a plain `IconLayer` over a keyed stream.

Link a Route to a Tracking layer with `progress-from="<tracking-layer-id>"` and the route splits at the marker's live position — the split advances per frame with the glide, projected onto the route line, since real GPS fixes sit slightly off it. `tail` picks how the traveled portion renders: `tail="none"` shows only current position → destination (the client view — a customer waiting on a delivery), `tail="dim"` darkens the traveled portion (the operator view; `tail-color` overrides the automatic darkening), and `tail="full"` (the default) keeps today's single-color line.

Whenever a Route resolves — direct `geometry` or a provider round-trip alike — the map dispatches `om-route-resolved`: `detail = { layerId, route }`, where `route` carries the normalized `geometry`, `distanceMeters`, `durationSec`, `legs`, and the fitted `bounds`. That is how a page reads a provider-computed route (an ETA readout, a turn list) without re-fetching it.

## Travel-Map Animations and On-Map Annotations

The classic animated-travel-map moves are native story verbs. A `trace` step draws a line progressively; add `follow` to lock the camera to the drawing tip, and `easing="linear|ease-in|ease-out|ease-in-out"` to shape the clock. Per-feature tracing (`trace` with `feature-id`) draws a polygon's outline at uniform speed — every part of a MultiPolygon, largest first — then reveals its fill. For labels on the shapes themselves (no popup card), put `anchor="surface"` on a `TextLayer` over polygon data: each row's text anchors inside its own polygon, with concave shapes handled; pair with `font-settings='{"sdf":true}'` and `outline-width`/`outline-color` for a legible halo.

## Driving Stories From External Video Renderers

Frameworks that own the clock (Remotion-class) render frame-by-frame: mark the map `data-om-recording` — the determinism switch (camera moves land instantly, effects snap to their end state, transitions and gesture interruption turn off, `snapshot()` returns byte-stable captures) — then per frame call `story.seek(t, { interpolateCamera: true })` (mid-fly-to seeks land on the real flight arc), `await mapEl.whenSettled()` (resolves when the map has genuinely finished drawing; also available on `MapController`), and capture. The same frame renders byte-identically across processes and orderings, which is what licenses parallel rendering. `npx onlymapjs record` remains the built-in one-command path.

## Sharp Flybys Over 3D Tiles, and Recording Them

Story fly-bys over 3D tiles (photogrammetry, BIM, Google Photorealistic) normally show tiles popping in or refining from blurry to sharp as the camera moves — tiles only start downloading when the camera needs them. Because a story's camera route is known before it plays, OnlyMapJS can fix this three ways, in increasing strength:

* **`<om-story warm-tiles>`** pre-loads every 3D tileset's tiles along the story's fly-to route in the background as soon as the page loads. Manual form: the `warm-tiles` action; the `om-tiles-warmed` event on `<om-map>` announces completion. For long routes, keep more tiles cached with `load-options='{"tileset":{"maximumMemoryUsage":512}}'` on the tiles layer.
* **`<om-story paced>`** goes further: playback holds each frame until the tiles under the camera are fully sharp before advancing. The flight takes longer, but no frame ever shows blurry tiles. `paced-max-hold` caps how long one frame may wait (default 10s; `"none"` waits unconditionally); each frame reports its wait through the `om-paced-tick` event. Combine with `warm-tiles` to shorten the holds.
* **`npx onlymapjs record map.html --out flyby.mp4`** plays the story load-paced in headless Chromium and writes a video in which every frame is sharp — widgets, overlays, and attribution included. Uses ffmpeg when installed (otherwise you get PNG frames plus the exact command); `--gpu` is \~3× faster on heavy scenes; `--fps`, `--width/--height/--scale`, `--story`, `--max-hold` tune the take. Custom pipelines hook the same seam with `storyEl.setPacedCapture(...)` — the story waits for your capture, so frames can never tear.

The full guide, including per-engine adapter notes, is [docs/routing.md](https://github.com/NikaGeospatial/onlymapjs/blob/main/docs/routing.md) in the package.

To wire your own logic, read the DOM events on `<om-map>`:

* `om-view-changed` fires when the camera settles. `detail` carries the camera and an `origin` of `"user"` or `"programmatic"`. Use `origin` to suppress echoes during state sync.
* `om-map-point` fires on each click or hover. `detail = {coordinate, kind}` — `coordinate` is `[lng, lat]` (or `null` off the map), and `kind` is `"click"` or `"hover"`. It fires on every click and hover, including empty-map clicks a pick discards. This is how you build a tool the `draw` widget does not cover: a distance measurer, a drop-a-pin, a custom rectangle or circle area. Read the coordinate from this event. Do not read deck.gl internals — the map does not expose those. To turn a screen point into a coordinate yourself, call `mapEl.unproject([x, y])` <sup>0.11.5</sup>.
* `om-tileset-load` gives a `Tile3DLayer`'s live deck `Tileset3D`. `detail = {layerId, tileset}`. Use it for a tool such as region export.
* `om-route-resolved` fires when a `Route` layer's path is ready. `detail = {layerId, route}` with the normalized geometry, distance, duration, legs, and bounds — see Routes and Live Tracking above.
* `om-measure` carries the measure widget's whole readout. `detail` has the mode and units, `totalMeters`/`areaMeters2`/`perimeterMeters`, and in volume mode `cutMeters3`/`fillMeters3`/`netMeters3`/`totalMeters3` plus `cellSizeM`, `gsdM`, `cutErrorM3`/`fillErrorM3` and `nodataFraction` (the error model the numbers were computed under), the material-adjusted `cutAdjustedMeters3`/`fillAdjustedMeters3` and `cutMassKg`/`fillMassKg`, and `profileSeries`. A `stale` flag marks the brief window after a footprint commits but before its terrain sample resolves, so you never mistake a previous shape's numbers for the current ones.

To ask the map what is under a point instead of waiting for a click, use the picking methods <sup>0.11.5</sup>. `mapEl.pickAt([x, y])` returns the feature at a screen point, searching 4 pixels around it, or `null`. `mapEl.pickRect({x, y, width, height})` returns every feature inside a box, front to back, up to 100. Both take a `layers` list to narrow the search. They are queries: they do not change the selection, fire `om-map-point`, or run your behaviors. Only `pickable` layers answer. For a tool that must reach every layer, such as a picker that tells an assistant which feature the user means, set `mapEl.pickMode = "all"` while the tool is open and back to `"authored"` when it closes. It is a property, not an attribute, so nothing is written into the map. While it is on, ordinary hover and click see every layer too.

A tool can read the rest of the map without changing it either <sup>0.11.7</sup>. Set `mapEl.diagnostics = true` and the map reports its problems, each with a suggested fix, as `om-validation-error` events after every change. No error panel appears and nothing is written into the page; `mapEl.getDiagnostics()` returns the latest report. Use this rather than adding `validate` from a tool, which edits the author's map. Legend and layer-switcher rows carry `data-layer="<layer id>"`, so a click on a row can be traced to its layer. An overlay's `templateSource` returns its template as written, with the `{{fields}}` it shows.

To mirror state instead of reading events, use `getStore(token)`. It gives a `{subscribe, getSnapshot}` store for each token: `viewport`, `selection`, `layers`, and `data:<id>`. The store works directly with `useSyncExternalStore`, MobX, Redux, or Zustand.

## Cartographic Maps

A print-like map — an atlas plate, a poster, a regional reference sheet, anything a
cartograph frame will show — wants different defaults from a slippy map. Everything
below is a layer or an attribute on the map itself, so a sheet's frame inherits all of
it.

### A real projection <sup>new in 0.10.0</sup>

```html theme={null}
<om-map crs="EPSG:5070" basemap="none" reference-scale="1:6500000"
        center="[-96, 38]" zoom="3.2">
```

`crs` draws the whole map in that projection's plane: parallels curve, an equal-area map
is genuinely equal-area, and a national-grid sheet is drawn in its own grid rather than
stretched out of Web Mercator. Albers (`EPSG:5070`), the UTM zones, British National
Grid, Swiss LV95, Lambert 93, RD New and `EPSG:4326` are built in; anything else takes a
`+proj=` string via `OmMap.registerCrs()`. The authored camera **stays geographic** —
`center` is lng/lat and `zoom` is unchanged — so stories and `seek()` keep working.

A projected map cannot carry these, and each is a validation error with a fix rather
than a silent no-op:

| Limit | Why | Instead |
| - | - | - |
| A MapLibre basemap | a second renderer with its own Mercator camera | `basemap="none"` |
| `terrain` | the 3D surface and its tiles are Mercator throughout | drop it, or stay Mercator |
| `pitch` / `bearing` | the projected view is planimetric | rotate the *frame* in the sheet |
| H3 / S2 / geohash / quadkey layers | deck builds their geometry from a token, inside the layer | convert the cells to polygons |
| Changing `crs` after boot | re-ingesting is a remount | author two maps |

**Relief works** on a projected map, by both routes below. `sun-datum="sheet"` keeps the
apparent light constant across the plate, the way printed relief atlases do — true north
rotates by 34° across a map of the United States, and light that turned with it would
read as a printing fault. `sun-datum="geographic"` follows true north instead.

`reference-scale="1:6500000"` declares the scale the sheet is drawn at, so millimetre
sizes elsewhere on the map — transform tolerances, buffer widths — resolve to ground
units. Without it, any `mm` value is a validation error naming this attribute.

### Shaded relief <sup>new in 0.9.1</sup>

```html theme={null}
<om-layer type="HillshadeLayer" src="terrarium" sun-azimuth="315 45" z-factor="2"></om-layer>
```

Relief is a **layer**, not `terrain=`: it draws shaded terrain on a flat map at any
angle, sits under your data in document order, is captured by `snapshot()`, and is
treated by a print finish. `src="terrarium"` uses keyless global elevation tiles (the
attribute is required even so). Or shade the elevation raster you already have —
`shading="hillshade"` on a `COGLayer` or `ZarrLayer` turns your own DEM into relief that
shows *through* its `colormap`, so a hypsometric map with shaded terrain is one layer.

The sun is a list: `sun-azimuth="315 45 135"` blends several light directions — the
difference between flat single-light web-map shading and the Swiss-style relief of a
printed atlas. `sun-weight`, `sun-elevation` and `z-factor` tune it, and all are instant.
Tile edges are seamless, relief keeps its true shape at every latitude, and a
high-resolution export gets genuinely sharper relief rather than an enlarged screen.

Two things the validator will tell you: a terrain source in a lossy image format
invents plausible-but-wrong heights and is flagged once; and `identify="off"` does not
free a raster's memory while `shading="hillshade"` is set, because seamless relief needs
neighbouring pixels to hand.

<Note>
  On a sheet, put the relief **under** a translucent land wash — `get-fill-color="[226,206,166,88]"`,
  alpha around 80–90 — not an opaque one. Relief under alpha 255 is still computed, still
  costs the render, and none of it reaches the reader. It is the single most common way a
  plate comes out flat.
</Note>

### Geometry your data doesn't contain <sup>new in 0.10.0</sup>

`<om-transform>` inside a layer rewrites its geometry at ingest:

```html theme={null}
<om-layer id="coast" type="GeoJsonLayer" data="./land.geojson" get-fill-color="[0,0,0,0]"
          get-line-color="[72,56,40,120]">
  <om-transform type="buffer" distance="0.9mm" repeat="5" fade></om-transform>
</om-layer>
```

* `simplify tolerance="0.15mm"` thins a dense coastline to what the scale can print.
* `smooth iterations="1"` — one Chaikin pass removes the chain-of-straight-segments look
  that marks a map as a web map.
* `dot-density` scatters points for a population figure.
* `buffer` draws bands around a shape; with `repeat` and `fade` it is the engraved
  shoreline vignette of old charts. Five bands cost what one costs and cannot tangle. It is
  an **ornament**, not a measurement — for a setback someone will act on, use PostGIS.
* `contour src="terrarium" bounds="[w,s,e,n]" interval="100m"` marches real isolines off
  a DEM (not deck's `ContourLayer`, which contours point density). Its extent is
  *declared*, because a transform runs once at ingest, not per camera. Each line carries
  `elevation` and `index` (true every fifth line — draw those heavier).

Millimetre knobs need `reference-scale` on the map; ground units (`50m`) never do.
Polygons only for `buffer`; lines and polygons for `simplify`/`smooth`; points pass
through untouched and the validator says so.

### Labels that follow the line they name <sup>new in 0.10.0</sup>

```html theme={null}
<om-layer type="TextOnPathLayer" data="./rivers.geojson"
          get-text="$name" get-path="d.geometry.coordinates"
          letter-spacing="1.5" font-family="serif"></om-layer>
```

The oldest label convention there is, and one no web-map library ships: a river's name
runs along the river. Both accessors are required. **On GeoJSON, `get-path` is
`d.geometry.coordinates`** — a `$field` compiles to `properties.<field>`, so
`get-path="$geometry"` hands the layer an object and every label silently vanishes
(a validation error since 0.10.3). `$path` is right only for flat rows that carry the
coordinate list themselves.

The name reads the right way round whichever direction the line was drawn, and one too
long for its line is left off rather than squeezed. Since 0.10.5 a label slides to the
straightest stretch it fits on (`placement="auto"`, the default; `start`/`middle`/`end`
pin it), `max-angle` sets how sharply a name may bend between neighbouring letters
(45°, MapLibre's figure), and `orientation="chord"` lays the whole name straight along
the stretch it covers for data too jagged to follow. `letter-spacing` tracks a name out —
the convention for rivers and ranges rather than a flourish. Add `pickable` to make labels
clickable <sup>0.11.6</sup>: a click reports the feature the label names, so a `{{name}}` popup
shows that river's fields.

### Editable vector export <sup>new in 0.10.0</sup>

```js theme={null}
const svg = await map.snapshot({ format: "svg" });          // SVG text
const file = await map.snapshot({ format: "svg", as: "blob" });
```

Polygons and paths become `<path>`, labels become real `<text>` after a deterministic
decluttering pass, and a curved label becomes one `<textPath>` — artwork you can open in
Illustrator. Relief, imagery and 3D come out as placed `<image>` bands; consecutive
raster layers merge into one band, a vector layer between them keeps them apart. A map
carrying an `<om-effect>` print finish exports as a single raster band at `effectDpi`
(default 600), because the finish treats the whole frame — tell the user that before
they build a sheet around both. In a cartograph, `frame.freeze({ format: "svg" })` does
the same to a frame in place.

## Print Finishes <sup>new in 0.9.0</sup>

```html theme={null}
<om-map basemap="none">
  <om-effect preset="vintage"></om-effect>
  …
</om-map>
```

`<om-effect>` treats the rendered frame — warm paper and ink, a pressed edge, paper
tooth, a soft vignette. Seven looks ship: `vintage`, `engraved`, `night`, `blueprint`,
`riso`, `newsprint` and `muted` (which only calms a busy sheet, with no texture). Presets
assume the map beneath is styled to suit them.

Every look is built from named operations you can write yourself, composed in document
order — `tint`, `levels`, `saturation`, `posterize`, `vignette`, `grain`, `edges`, `blur`,
`sharpen`, `halftone`:

```html theme={null}
<om-effect op="grain" amount="0.07" size="0.12mm"></om-effect>
```

Sizes are **millimetres**, so the grain and ink on a 600 dpi export are the same
physical texture you tuned on screen rather than four times finer. To start from a preset
and change one thing, override a single knob on it. An `<om-effect>` carrying an inline
`<script type="application/json">` shader module is handed to deck.gl untouched, so
anything deck's own `PostProcessEffect` can do is available from the document.

`set-effect` switches the finish live from a behavior or a widget button, and because it
writes the document, undo/redo see it. `<om-widget type="effects">` <sup>0.9.1</sup>
lists every preset plus Off and holds no state of its own, so a hand edit or an undo
moves the radio too.

<Note>
  An effect treats deck's own frame. With a MapLibre basemap, which is composited
  underneath, the data layers are treated and the basemap is not — the validator says so,
  with the fix: `basemap="none"`. Transparent areas stay transparent (since 0.9.1), and
  the `newsprint` screen is sized to read on screen — take `halftone-cell` back down when
  exporting at 300 dpi.
</Note>

## Legends That Read the Styling

The legend widget builds each entry from the layer's real symbology. Write the standard expression shapes, and the legend describes itself:

* `scale($f, sequential|diverging, […], domain=[a,b])` → a gradient ramp with the domain ends labeled.
* `scale($f, threshold, […], domain=[b1, b2])` → discrete class rows (`< b1`, `b1 – b2`, `≥ b2`).
* `classify-by="mag" classify-scale="jenks"` → the same discrete class rows, with the boundaries the classification computed from the data.
* `$type == 'a' ? '#c1' : $type == 'b' ? '#c2' : '#fallback'` → a category palette with an "other" row.
* A `COGLayer` with `colormap` and `min`/`max` → the colormap ramp over the rescale window.

A flat colour — `get-fill-color="[226,206,166,92]"`, a hex literal, `rgb()` — shows as that colour <sup>0.10.5</sup>, read in the order the eye sees it: a transparent fill defers to the stroke. For anything else, the legend shows the layer's single `color` swatch. The legend updates live when the styling changes. When an agent rewrites a color expression, the legend and the map refresh in the same frame.

## Exports

`map.snapshot()` (also on the React controller) returns a PNG data URL. Pass `{ type: "blob" }` to get a `Blob` instead. It captures the basemap and the WebGL canvas together, at device pixels. It does not capture DOM widgets or overlays. Your export pipeline must render the provider attribution itself. `{ format: "svg" }` returns editable vector instead — see [Editable vector export](#editable-vector-export).

`map.hasDrawableContent()` <sup>0.10.6</sup> answers whether the map drew anything at all — a basemap, rows on any layer, a tile template — which is how a host tells "finished loading" from "finished loading and there is nothing there". `whenSettled()` resolves either way, by design.

## Validate, Then Trust

Three authoring mistakes that used to pass validation and draw nothing are now caught <sup>0.10.3</sup>: `get-path="$geometry"` on GeoJSON (an error when the data is inline, a warning when it is a URL), `line-width-units="millimeters"` (only `meters`, `pixels`, `common` exist), and a widget `type` that does not exist — the error lists the real ones, and points out that a north arrow, scale bar or graticule on a print sheet is a cartograph element, not a widget.

Use this authoring loop:

1. Write the manifest, or edit the live DOM. Changes reconcile automatically.
2. `OmMap.validate(html)` returns structured errors, each with a `fix` string.
3. `OmMap.snapshotIR(html)` returns resolved layer descriptors for snapshot tests.
4. `mountForTest(html)` runs headless behavioral tests. A synthetic pick uses the same selection path as a real GPU pick.
5. `npx @nika-js/onlymap check-layout <manifest.html>` checks the real widget layout in headless Chromium at several viewport widths. It confirms no overlaps, and that each widget is hit-testable and in its slot. It exits 0 or 1. You can also import `auditLayout(page)` into your own Playwright suite.

```ts theme={null}
const h = await mountForTest(pageHtml);
await h.pick({ layer: "quakes", featureId: "q-101" });
expect(h.map.querySelector("#detail")!.shadowRoot!.textContent).toContain("M 6.1");
```

## React Projects

In React, do not render `om-*` elements from JSX. Use the first-party adapter: `import { OmMap, OmLayer, OmWidget, OmOverlay, useOmMap } from "@nika-js/onlymap/react"`. Props are camelCase deck.gl props. Accessors are plain functions. Interactions are `onClick` and `onHover` handlers. React keeps the state, and the adapter drives the same rendering core with no DOM conflict.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.