Skip to main content

The Manifest

A small set of elements follows one rule. An attribute is the kebab-case form of a deck.gl prop (radius-unitsradiusUnits). 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. basemap takes 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, 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, 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:
The built-in functions include 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

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.
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):
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:
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.
  • 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.
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:
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:
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:
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.
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: 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, 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-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 or convert canvas pixels yourself — the map does not expose those.
  • om-tileset-load gives a Tile3DLayer’s live deck Tileset3D. detail = {layerId, tileset}. Use it for a tool such as region export.
  • 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 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.

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 COGLayer with colormap and min/max → the colormap ramp over the rescale window.
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.

Validate, Then Trust

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.

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.