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. Setom-map { display: block; height: 100vh }(withhtml, 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 takescenter,zoom,pitch, andbearing;max-pitchandmin-zoom0.8.1 / 0.8.3 cap the tilt and floor the zoom-out for a kiosk or embedded map, and are never applied automatically.crs="EPSG:5070"0.10.0 draws the whole map in a real projection — see Cartographic Maps.basemaptakes a free preset (positron,liberty,dark-matter,osm,satellite0.11.10, …), a style URL, or"none", and it switches live.satelliteis 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 keyedmaptiler-satellite. The 3D scene is also attributes:terrain="terrarium"(or a DEM tile URL) adds elevation, andlighting="daylight|studio|flat|custom"withlighting-*attributes lights it. Addvalidatefor an error panel on the page while you author.<om-layer>— any registered deck.gl layer type, by name.idis required.labelandcolorfeed the legend. Under active terrain, a geographic layer drapes onto the surface by default (setterrain="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. Theplayerwidget 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.
<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
orderattribute sets their sequence in the slot. - Small controls next to each other join into one group (
zoom-controls,undo-redo,widgets-toggle). Setcluster="false"to keep a control separate. - The map attribution goes in
bottom-endand the license badge inbottom-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. Setwidgets-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>, theset-widgets-visibleaction, or awidgets-togglewidget 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.
--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
Aget-* 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:
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.
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
data is always an address, never the rows themselves: data='[{…}]' draws nothing, and the validator reports it as an error 0.11.9. 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 0.11.9.
A file in a national grid or other local coordinate system lands where it belongs 0.12.0 — see Data in a local grid.
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 new in 0.11.0
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: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 0.11.1 (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 new in 0.11.1. 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 new in 0.11.2. 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:
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-ranges — 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
Pointdata 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"withextrudedandget-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. Usetype="SolidPolygonLayer"withget-polygon="$polygon"andfull3d. Each row carries asurface_type(RoofSurface / WallSurface / GroundSurface) and a defaultfill_color, whichget-fill-colorreads automatically — so the buildings render in sensible colors with no styling.
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.
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):
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:
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.
src(required) — the.zarrstore URL. LikeCOGLayer, 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 noselect. Changingselectre-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", andspatial-dims="<yName> <xName>"(the two spatial dimension names).
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.
<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"plusisolate-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 withfeature-palette) orfeature-color-scale="netVolume"(graduated).feature-color-strengthbelow 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.
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:
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:
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:
{{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.
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:
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.swellandshrinkpopulate 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.profilesamples an elevation profile around the footprint’s own perimeter and puts it on theom-measureevent for a chart to plot — there is no second line to draw.
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, widgetdata-emit buttons, ctx.emit, and story steps. The actions include show-overlay, hide-overlay, show-tooltip, hide-tooltip, toggle-layer, set-layer-attribute 0.11.0 (write any layer attribute at runtime — the switch behind a widget button), expand-cluster 0.11.0, 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> 0.11.1: 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 0.10.2. 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 0.11.8: 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:
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:
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.
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. Atrace 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 mapdata-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: thewarm-tilesaction; theom-tiles-warmedevent on<om-map>announces completion. For long routes, keep more tiles cached withload-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-holdcaps how long one frame may wait (default 10s;"none"waits unconditionally); each frame reports its wait through theom-paced-tickevent. Combine withwarm-tilesto shorten the holds.npx onlymapjs record map.html --out flyby.mp4plays 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);--gpuis ~3× faster on heavy scenes;--fps,--width/--height/--scale,--story,--max-holdtune the take. Custom pipelines hook the same seam withstoryEl.setPacedCapture(...)— the story waits for your capture, so frames can never tear.
<om-map>:
om-view-changedfires when the camera settles.detailcarries the camera and anoriginof"user"or"programmatic". Useoriginto suppress echoes during state sync. A mouse-wheel zoom on a map with a basemap reports"user"0.12.0; before, it said"programmatic".om-map-pointfires on each click or hover.detail = {coordinate, kind}—coordinateis[lng, lat](ornulloff the map), andkindis"click"or"hover". It fires on every click and hover, including empty-map clicks a pick discards. This is how you build a tool thedrawwidget 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, callmapEl.unproject([x, y])0.11.5.om-tileset-loadgives aTile3DLayer’s live deckTileset3D.detail = {layerId, tileset}. Use it for a tool such as region export.om-route-resolvedfires when aRoutelayer’s path is ready.detail = {layerId, route}with the normalized geometry, distance, duration, legs, and bounds — see Routes and Live Tracking above.om-measurecarries the measure widget’s whole readout.detailhas the mode and units,totalMeters/areaMeters2/perimeterMeters, and in volume modecutMeters3/fillMeters3/netMeters3/totalMeters3pluscellSizeM,gsdM,cutErrorM3/fillErrorM3andnodataFraction(the error model the numbers were computed under), the material-adjustedcutAdjustedMeters3/fillAdjustedMeters3andcutMassKg/fillMassKg, andprofileSeries. Astaleflag 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.
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 0.11.7. Set mapEl.diagnostics = true and the map reports its problems — including those on <om-map> itself, such as a basemap or projection the map cannot draw 0.12.0 — 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 new in 0.10.0
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:
Imagery works on a projected map new in 0.12.0. An image
TileLayer (…/{z}/{x}/{y}.png, .jpg, .webp) is warped tile by tile into the projection and lands within a pixel of where it belongs, so a projected map can have a satellite or OpenStreetMap backdrop:
basemap="satellite" on a projected map is still an error — the map never swaps a basemap for you — and its fix is that imagery written as this layer. Tiles you name in a layer are yours to credit; the map credits only the basemaps it picks.
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.
Data in a local grid new in 0.12.0
Data whose coordinates are eastings and northings — British National Grid, SVY21, State Plane, a UTM zone — is converted to longitude/latitude when it loads, on any map. A file that names its own coordinate system needs nothing: a shapefile’s.prj, a FlatGeobuf or
GeoParquet header, or a legacy GeoJSON crs member is read and applied. Otherwise declare
it on the layer, which also overrides the file:
OmMap.configureCrs({ online: false }) turns that off. Coordinates too
large to be degrees, with no system named anywhere, are reported in the map’s diagnostics
rather than drawn in the ocean. Datum shifts are the standard seven-parameter ones, good to
a few metres.
A globe new in 0.12.0 · preview
center/zoom are written exactly as for a flat map. It needs a
basemap, because MapLibre draws the sphere. It switches live — set or remove the attribute,
or use the set-projection action — and a basemap switch keeps it. An <om-overlay> on the
far side of the planet hides, and a click in the space around the globe reports no
coordinate.
A globe is drawn flat-on and north-up: there is no pitch or bearing. Text, icon and
popup-badge layers, arcs, heatmap-style aggregation layers and terrain cannot draw on a
globe yet, and each is a validation error with a fix — labels go in <om-overlay>
elements, routes in a PathLayer through points along the route. A globe cannot sit in a
cartograph frame, and cannot be combined with crs.
Shaded relief new in 0.9.1
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.
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.Geometry your data doesn’t contain new in 0.10.0
<om-transform> inside a layer rewrites its geometry at ingest:
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-densityscatters points for a population figure.bufferdraws bands around a shape; withrepeatandfadeit 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’sContourLayer, which contours point density). Its extent is declared, because a transform runs once at ingest, not per camera. Each line carrieselevationandindex(true every fifth line — draw those heavier).
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 new in 0.10.0
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 0.11.6: a click reports the feature the label names, so a {{name}} popup
shows that river’s fields.
Editable vector export new in 0.10.0
<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 new in 0.9.0
<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:
<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"> 0.9.1
lists every preset plus Off and holds no state of its own, so a hand edit or an undo
moves the radio too.
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.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
COGLayerwithcolormapandmin/max→ the colormap ramp over the rescale window.
get-fill-color="[226,206,166,92]", a hex literal, rgb() — shows as that colour 0.10.5, 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.
map.hasDrawableContent() 0.10.6 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 0.10.3: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:
- Write the manifest, or edit the live DOM. Changes reconcile automatically.
OmMap.validate(html)returns structured errors, each with afixstring.OmMap.snapshotIR(html)returns resolved layer descriptors for snapshot tests.mountForTest(html)runs headless behavioral tests. A synthetic pick uses the same selection path as a real GPU pick.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 importauditLayout(page)into your own Playwright suite.
React Projects
In React, do not renderom-* 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.