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.basemaptakes a free preset (positron,liberty,dark-matter,osm, …), a style URL, or"none", and it switches live. 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,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() (it needs an explicit domain=), colorRamp(), clamp(), lerp(), formatDate(), and Math.*. 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.
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
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.
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, 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.
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.
To wire your own logic, read the DOM events on <om-map>:
om-view-changedfires when the camera settles.detailcarries the camera and anoriginof"user"or"programmatic". Useoriginto suppress echoes during state sync.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 or convert canvas pixels yourself — the map does not expose those.om-tileset-loadgives aTile3DLayer’s live deckTileset3D.detail = {layerId, tileset}. Use it for a tool such as region export.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.
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.
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).$type == 'a' ? '#c1' : $type == 'b' ? '#c2' : '#fallback'→ a category palette with an “other” row.- A
COGLayerwithcolormapandmin/max→ the colormap ramp over the rescale window.
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.
Validate, Then Trust
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.