npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nika-js/onlymap

v0.7.4

Published

Declarative deck.gl maps for HTML and React — interactive WebGL mapping with GeoJSON/CSV/Arrow data, MapLibre basemaps, widgets, popups, and live streams from a custom-element manifest or typed React components. TypeScript, no build step.

Readme

OnlyMapJS

npm version npm downloads license docs examples

▶ Browse over 50 runnable examples — heatmaps, choropleths, 3D terrain, live streams and map stories, each with copyable source.

OnlyMapJS is a map rendering and visualization library for web, desktop, and mobile apps. With support for popular frameworks like React, it integrates seamlessly into your existing components via a robust two-way interface for custom map behavior; overlays, charts, and finely controlled story-map animations are all first-class supported alongside 30+ data layer types. Built-in GPU acceleration also means it scales very well for both 2D and 3D use-cases. It is both human and AI agent-readable by design, and troubleshooting is effortless with a compile-time validator and runtime error debugging tools.

<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap/dist/onlymapjs.css">
<script type="module" src="https://unpkg.com/@nika-js/onlymap"></script>

<om-map center="[-122.42, 37.77]" zoom="11" basemap="maplibre">
  <om-layer id="quakes" type="ScatterplotLayer" data="./quakes.json"
              label="Earthquakes" color="#b30000"
              get-position="[$lon, $lat]"
              get-fill-color="scale($magnitude, sequential, ['#fee8c8','#b30000'], domain=[0,7])"
              get-radius="scale($magnitude, sqrt, [2, 18], domain=[0,7])"
              radius-units="pixels" pickable></om-layer>

  <om-widget type="legend" position="top-right"></om-widget>

  <om-overlay id="detail" anchor-from="selection" selection-type="click" visible="false">
    <div><b>{{place}}</b> — M {{magnitude}}</div>
  </om-overlay>
  <om-behavior on="click" layer="quakes" action="show-overlay" target="detail"></om-behavior>
</om-map>

That's a complete app: a no-token MapLibre basemap, data-driven colors and sizes, a legend, and a click-for-details popup. Edit any attribute on the live DOM and the map updates — the manifest is the state.

It's also designed to be written by AI agents: HTML is a reliable generation target, llms.txt teaches the format, and OmMap.validate() returns structured errors and warnings with actionable fixes — a real feedback loop instead of a blank canvas (heed both: an "unknown attribute" warning means a prop is being silently dropped).

⚠️ Status: beta. Proprietary — free for non-commercial use with attribution; commercial licensing terms are in LICENSE.md. APIs may still move before 1.0.

Why not deck.gl directly?

deck.gl is the best WebGL data-visualization engine there is — and OnlyMapJS is built on it, not against it. What it replaces is everything around deck.gl that every project rebuilds by hand:

| | Raw deck.gl | OnlyMapJS | |---|---|---| | Setup | new Deck({...}), canvas + basemap sync wiring | one <om-map> element (or <OmMap> in React) | | State | your reducers/stores drive setProps | the manifest is the state — edit an attribute, the map reconciles; undo/redo built in | | Data loading | fetch + parse + reload yourself | data="…" — GeoJSON, CSV, Arrow/GeoArrow, Shapefile, KML, GPX, FlatGeobuf, GeoParquet, CityJSON, WebSocket streams, polled REST, tiled XYZ/vector (TileLayer/MVTLayer); GeoTIFF/COG rasters via COGLayer (band selection, per-band windows, auto-stretch from file stats, stretch/gamma, pixel identify), chunked Zarr/GeoZarr via ZarrLayer; geotagged drone JPEGs via ImageOverlay | | Accessors | JS functions + updateTriggers bookkeeping | get-* expressions; update triggers derived automatically | | UI | build legends/popups/filters from scratch | built-in widgets, overlays, behaviors — declarative | | Testing | mock WebGL or ship untested | OmMap.validate, IR snapshots, headless behavioral harness | | Escape hatch | — | every deck.gl prop still passes through; custom layers register by name |

If you're comparing React mapping libraries: the React adapter (@nika-js/onlymap/react) gives you typed <OmLayer> components over the same core, so React owns state and deck.gl's updateTriggers gymnastics disappear. And when you need raw deck.gl behavior, every kebab-case attribute maps 1:1 to the underlying prop — it's a wrapper, not a wall.

Install

npm install @nika-js/onlymap

Or with no build step at all, straight from a CDN:

<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap/dist/onlymapjs.css">
<script type="module" src="https://unpkg.com/@nika-js/onlymap"></script>

The bare package URL serves dist/onlymap.standalone.js, a single-file bundle built for exactly this (jsDelivr too). Use a CDN that serves the package's raw files — not a rebundling CDN like esm.sh, which re-splits the bundle into duplicate copies of the deck.gl/luma.gl runtime and breaks every layer's shader compilation.

Then npx @nika-js/onlymap init wires up VS Code IntelliSense and !-prefixed manifest snippets for your project. The library ships with 1,380 unit/behavioral tests and 70 Playwright GPU tests.

The examples are the best tour: widgets, behaviors & overlays, basemaps, columnar/Arrow data, manual drawing, 3D models, scene lighting (with the native lighting widget), DEM terrain, a live WebSocket ship feed, and a polled driver fleet.

The manifest

A handful of elements, one rule: attributes are kebab-case versions of deck.gl props (radius-unitsradiusUnits), and get-* attributes are data-driven accessors.

| Element | Role | |---|---| | <om-map> | The map. center, zoom, pitch, bearing; basemap takes a free preset (positron, liberty, dark-matter, osm, …), a style URL, or "none" (standalone canvas) — and switches live; validate for a live on-page error panel. Give it a height: a custom element is display:inline by default, so the library injects a display:block default (fills a sized parent, else a 400px floor) to keep a bare map visible, but set an explicit height (om-map { height: 100vh }) for real layout — any height you set wins outright, including one below the floor. A map that still collapses warns in the console; hidden and display:none maps stay hidden and stay quiet. | | <om-layer> | Any of 39 layer types by name — all of deck.gl's core, geo, aggregation, and mesh layers (Scatterplot, GeoJson, Arc, Path, Heatmap, Hexagon, Trips, Tile, Tile3D, Scenegraph, …) plus the built-in PopupLayer for WebGL badges/labels at scale, the native COGLayer for GeoTIFF rasters, ZarrLayer for chunked Zarr/GeoZarr rasters, ImageOverlay for georeferenced drone JPEGs, BIMLayer for BIM source files (.ifc today) loaded straight in the browser with no pre-conversion step, and Route/Tracking for styled routes and live position tracking. id required; label/color feed the legend. visible-zoom-range="[8, 14]" gives any layer scale-dependent visibility (hidden outside min ≤ zoom < max, the standard minzoom/maxzoom convention) — the everyday GIS pattern for keeping a dense layer from swamping a zoomed-out view. | | <om-widget> | UI panels. Built-ins: legend (symbology-aware: color scales render as gradient ramps or class ranges, categorical ternaries as discrete palettes), layer-switcher, basemap-switcher, lighting, clip-box, zoom-controls, undo-redo, scale-bar (metric/imperial/nautical units), attribution, filter, draw (point/line/polygon sketch capture, GeoJSON save/autosave; lines/polygons complete via double-click, touch double-tap — detected by the library itself, since iOS WebViews never synthesize dblclick from taps — Enter, or the toolbar's Finish button; export-3d adds an "Export 3D" button that clips loaded Tile3DLayer/BIMLayer content to the drawn footprint and downloads it as a portable GLB — re-framed to a local coordinate frame at the footprint's own centroid, each triangle carrying its own source color as vertex colors, no textures — or, with export-3d="b3dm", the same mesh wrapped for Cesium/3D-Tiles pipelines; only currently-visible 3D Tiles/BIM layers are included — a layer hidden via visible="false" or the toggle-layer action is skipped, with a distinct console warning for "nothing loaded" vs. "everything hidden"), measure (geodesic distance + area + cut/fill volume — live labels, units toggle, an om-measure readout event; modes="distance area volume" adds a footprint-then-extrude tool: outline a polygon, double-click (double-tap on touch) to close it — it turns solid teal, ready — then drag the double-headed arrow gizmo that appears at its centroid up to fill or down to cut, reading out Cut/Fill/Net (signed, fill−cut)/Total (unsigned, cut+fill) volume plus Area/Perimeter, each labelled with its own sign convention. The math is a REAL per-cell grid integration: closing a footprint bulk-loads its covering DEM tiles, lays a metric grid in a local tangent frame at the ring centroid (cell size = the DEM's ground-sample distance; scanline point-in-polygon; bilinear, tile-seam-correct sampling; worker-offloaded), and integrates terrain-vs-base per cell — mixed cut AND fill within one footprint on undulating ground, with a published ±error (per-cell cellArea × 1.5 × GSD, summed per side), the cell size used, and a no-data warning all carried on the om-measure readout (cellSizeM/gsdM/cutErrorM3/fillErrorM3/nodataFraction). A base-surface attribute picks the reference surface: custom (default — the gizmo's draggable target plane), or boundary-derived stockpile strategies with no gizmo (triangulated boundary TIN, plane least-squares fit, lowest/highest/average). On custom, the extruded prism's own boundary hugs each corner's real ground elevation by default (so it doesn't visibly float above or sink below sloped terrain) — a "Flat target plane" toggle button appears above the readout once a footprint closes, switching that rendering to a single level plane instead (a rendering choice only; the underlying cut/fill numbers were already computed against one flat target elevation either way). Requires terrain on <om-map> — validation warns if volume is in modes with none (without terrain a flat-plane fallback runs, with no error figures — nothing honest to quote). A profile attribute alongside modes — not a mode of its own — samples elevation around a volume footprint's own perimeter as it's drawn and closed, live, and dispatches it on om-measure's profileSeries field for a dynamic-chart widget to plot. Each sample is {x: distance-from-start-m, y: elevation-m}, and samples that ARE one of the drawn footprint's own corners additionally carry vertexIndex (0-based, in draw order) — so a chart can mark the real corners instead of every interpolated sample, and the widget's built-in profile chart marks each corner, labelling the first 1 · Start. The map badges the first two vertices in draw order (1 · Start, 2), which states the ring's direction outright — a start marker alone leaves clockwise vs counter-clockwise ambiguous, and the two wind to mirror-image profiles. Two badges is the minimum that fixes a direction and a constant cost regardless of how many corners the footprint has. Also volume-only: deadband (m³) zeroes out a Cut/Fill figure below the threshold, filtering drag noise near zero height (Cut/Fill/Net/Total are always RAW geometric volumes — deliberately unaffected by swell/shrink, so they keep answering "does this reach target elevation" regardless of what material's configured); density (t/m³ metric, lb/yd³ imperial) and swell/shrink (multipliers, default 1×) instead populate a separate Material section, standard Bank/Loose/Compacted earthworks convention — Adjusted Cut = raw × swell (loose/haul volume, bigger — excavating adds air voids), Adjusted Fill = raw ÷ shrink (loose/borrow volume needed, also bigger — the raw fill is already the compacted target void), plus Cut/Fill tonnage computed from the raw (not adjusted) volume, since swell/shrink change volume, not mass; the section only appears once at least one of density/swell/shrink is actually configured, no separate toggle. A stale field on the om-measure readout flags the brief window between a footprint committing and its elevation sample resolving, so a consumer doesn't read numbers left over from a prior footprint as current), vega-lite (live charts bound to a layer's data), dynamic-chart (the same Vega-Lite rendering, but data-driven by a live DOM event instead of a layer — on="<event-name>" + series-field="<name>" reads event.detail[seriesField] as the chart's values on every matching event, redrawing at a fixed width; a feature "freezes" it for free by simply omitting that field the next time it fires, no separate pause API needed), and the BIM set ifc-browser / feature-inspector / ifc-loader / ifc-clash. Or write your own inline with HTML + a <script type="om/widget">. Adjacent compact button widgets (zoom-controls, undo-redo, widgets-toggle) auto-cluster into one control group (opt out per widget with cluster="false"), and <om-map widgets-hidden> / the set-widgets-visible action / <om-widget type="widgets-toggle"> hide all authored chrome without destroying it — provider attribution and the license badge never hide. Placement is managed: position takes one of 8 logical, RTL-aware slots (top-start, top-center, top-end, center-start, center-end, bottom-start, bottom-center, bottom-end; legacy corner names alias) — same-slot widgets stack with flush edges and a shared gap, order sets in-slot ordering, and position="manual" opts out entirely (a plain block you style yourself, even outside the map). At narrow map widths, managed widgets automatically move into accessible top/end/bottom/start drawers — at ≤640px when a row has widgets on more than one of its slots, at ≤416px when every occupied row is one-sided; fold="never" keeps an essential control out, widgets-fold="off" disables folding, and --om-widget-fold-breakpoint changes the map-width threshold. Provider attribution is an in-flow member of bottom-end and the license badge of bottom-start, so neither covers a widget. A slot dims automatically while an open popup covers it (widgets-dim="off" to disable), except slots containing required chrome. Themeable from plain page CSS via custom properties: om-map { --om-widget-bg: #111827; --om-widget-fg: #f9fafb; } (also -muted, -border, -hover-bg, -accent), plus layout tokens (--om-widget-inset-x/-y, --om-widget-gap-x/-y, --om-widget-opacity, --om-widget-radius) or the no-CSS sugar <om-map widget-style="gap:10 opacity:0.9">. | | <om-overlay> | Rich HTML anchored to a map location — a static anchor="[lng, lat]", the current selection, or a feature's own geometry via anchor-layer/anchor-feature-id. Selection-anchored overlays scope with layer (one layer's picks) and selection-type="click"/"hover" (one pick type — give a click-opened popup selection-type="click" so hovering elsewhere doesn't drag it along). {{field}} interpolates the picked feature, HTML-escaped by default; {{z}} is a real depth-picked elevation when some layer under the cursor set pickable="3d" (terrain does this itself, so hovering the ground always has one) — empty otherwise, never a misleading 0. clip-to-map (opt-in) hides the overlay when its own box would spill past the map viewport, not just when its anchor leaves — for small transient tips that track the cursor, where an overhanging box would otherwise inflate the page's scroll overflow and cause visible view jitter. | | <om-behavior> | Declarative interactions: on="click|hover|drag|load|data-loaded" → a named action. | | <om-story> | A storyboard: <om-step> children fire actions on a timeline. Controlled by the player widget, behaviors, or storyEl.play()/pause()/seek(). | | <om-fallback> | What shows where scripts never run — chat-app/email file previews (iOS QuickLook renders HTML attachments with JS off), file managers, sandboxed webviews. A direct child of <om-map>, hidden automatically the moment the map boots; pages without one get a text-only default banner from the stylesheet. Good practice on any page that may travel as a file. The gate is pure CSS (om-map:not(:defined)), so onlymapjs.css must load without JS — a real <link>, a bundler-emitted sheet, or an inlined <style>. |

Accessors without JavaScript

get-* attributes take a small, safe expression language — $field reads a datum field regardless of data shape (flat JSON, GeoJSON properties, or Arrow columns):

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')"

Built-ins: scale() (types: sequential, diverging, threshold, linear, sqrt, log, pow — explicit domain= required; color kinds take color-stop ranges, numeric kinds take number ranges, and pow takes a trailing exponent=2), colorRamp(), clamp(), lerp(), formatDate(), and Math.*. formatDate(value, style?, timeZone?) turns epoch-millisecond or ISO values into readable labels; styles are date, datetime (default), time, and iso, with UTC as the default zone (local or an IANA zone such as Asia/Singapore are explicit alternatives). Multi-line accessors go in a <script type="om/accessors"> block; genuinely arbitrary JS needs an explicit js opt-in on the layer. Untrusted (agent-written) expressions are safe by construction: the compiler is an AST whitelist, not an eval.

Date-valued filter sliders use the same contract without a custom widget:

<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>

Numeric timestamps are always epoch milliseconds, never guessed as seconds. Invalid date values render as an empty label rather than crashing an accessor or widget.

Data sources

| Source | Manifest | Notes | |---|---|---| | JSON / GeoJSON URL | data="./points.json" | flat arrays or FeatureCollections; $field works on both | | Inline JSON | <script type="application/json"> child | zero-request demos and tests | | Apache Arrow / GeoArrow | data="./big.arrow" | points stay columnar (zero row objects on the render path); GeoArrow line/polygon geometry becomes GeoJSON features — trace, anchored cards, picking all work; zstd-compressed IPC supported; parsers lazy-load on first use | | Columnar JSON | {"columns": {"lon": [...], ...}} | same columnar fast path without Arrow tooling | | CSV / TSV | data="./quakes.csv" | parsed to typed columns (lazy ~48 KB chunk); numbers auto-typed, quoted fields intact | | Shapefile | data="./countries.shp" | geometry + .dbf attributes joined into GeoJSON features (sidecars fetched with your auth config) | | KML | data="./tour.kml" | placemarks → GeoJSON features; other formats plug in via OmMap.registerFormat | | CityJSON / CityJSONSeq | data="./tile.city.json" + extruded get-elevation="$roof_height" | semantic 3D city models (3DBAG, PLATEAU) → extruded footprints with derived roof_height/eaves_height/ridge_height/roof_area; national grids reprojected automatically (NL/CH/DE/JP/AT/SG); .city.jsonl streams in as it downloads; lazy chunk — see docs/3d-assets.md | | GeoTIFF / COG raster | <om-layer type="COGLayer" src="./dem.tif" min="0" max="1900" colormap="viridis"> | Cloud-Optimized GeoTIFFs stream tiles by Range request (lazy chunk); min/max restretch + colormap swaps are GPU uniforms, nodata → transparent, legend ramp derives automatically; plain 8-bit RGB COGs need no attributes at all | | Zarr / GeoZarr raster | <om-layer type="ZarrLayer" src="./x.zarr" variable="temp" select="time=0" colormap="viridis" min="…" max="…"> | Chunked N-dimensional arrays (climate/weather grids, datacubes) rendered on the GPU (lazy chunk; built on @developmentseed/deck.gl-zarr + zarrita). Pin non-spatial dims with select; GeoZarr stores georeference themselves, a plain Zarr takes manual bounds/crs/spatial-dims; min/max/colormap reuse the COG pipeline. src can be any absolute URL — an external/remote store needs no server setup, but must be CORS-enabled (fetched directly in the browser) and public (authenticated stores are a follow-up) | | Drone JPEG image overlay | <om-layer type="ImageOverlay" src="./survey.jpg" georeference="exif"> | Reads GPS, relative altitude, camera, focal length, and DJI gimbal XMP; computes a WGS84 footprint, bakes yaw/roll into the pixels, and renders through BitmapLayer. Persist the processed image plus returned bounds for reloads — see docs/image-overlays.md | | WebSocket stream | data="wss://feed" key="id" flush="250ms" source="myFormat" | upsert-by-key, burst coalescing, auto-reconnect; shared while active and closed after the final descriptor owner releases it | | Polled REST snapshot | data="/api/fleet.json" refresh="5s" | full-snapshot replace per poll; outages keep the last good data; removal/disposal stops polling | | Tiled layer (XYZ / vector) | <om-layer type="TileLayer" data="…/{z}/{x}/{y}.png"> or type="MVTLayer" | a {z}/{x}/{y} data template is deck's tile URL — passed through, never fetched as rows; raster TileLayer gets a built-in BitmapLayer renderer, MVTLayer self-renders vector tiles (get-* accessors apply to tile features) | | Draw store | data="draw:sketch" | live in-memory GeoJSON feature store written by <om-widget type="draw" target="sketch"> |

Private endpoints: OmMap.configureData({ headers, credentials, fetch }) — applied to every fetch including refreshes. Credentials stay out of markup by design. Full guide: docs/live-data.md.

Interaction

Built-in actions wire to picks, widget buttons (data-emit), or script (ctx.emit) with one shared payload contract: show-overlay, hide-overlay, show-tooltip, hide-tooltip, toggle-layer, set-pickable (a runtime popups-on/off switch per layer), highlight-feature, zoom-to-feature, filter-layer, set-basemap, zoom-in, zoom-out, undo, redo — plus OmMap.registerAction for your own.

Hovering a pickable feature shows a pointer cursor automatically, so recipients can tell a map is interactive without clicking at random (pick-cursor on <om-map> customizes or disables it). Hover GPU picking is rest-debounced by default — one pick when the pointer stops instead of one per mouse move — which keeps hover popups smooth even on integrated graphics; hover-pick="continuous|off|<ms>" tunes it.

Undo/redo: user-facing manifest changes — layer toggles, filter changes, basemap switches, element edits, drawn sketches — are undoable out of the box; the manifest is the state, so history is recorded from the DOM itself. Add <om-widget type="undo-redo"> for buttons, or just press Cmd/Ctrl-Z (Shift-Cmd/Ctrl-Z or Ctrl-Y to redo). Camera moves, hover effects, and story playback are deliberately not undo steps.

Basemaps: free presets out of the box — OpenFreeMap liberty/bright/positron (no key, no limits), CARTO dark-matter/voyager, classic osm raster, and keyed maptiler-* presets (MapTiler's style editor is the visual way to customize basemap JSON; keys are publishable, via basemap-key or OmMap.configureBasemap). The basemap attribute switches live — camera and layers survive — from a hand edit, the set-basemap action, or <om-widget type="basemap-switcher">. Add your own with OmMap.registerBasemap(name, { style }); required attribution renders automatically. Guide: docs/basemaps.md.

Animation: camera moves accept a duration — map.flyTo(coords, zoom, { duration: 1200, curve: true }), or the fly-to action (center/zoom/pitch/bearing/duration) from any behavior or button; prefers-reduced-motion is honored (moves become instant, final state identical). Per-prop GPU transitions via the transition attribute: transition="get-fill-color 800ms" fades color changes; on a streaming layer, transition="get-position 300ms" makes entities glide between updates.

Map stories: a guided tour as markup — <om-story> holds <om-step> children that fire the same actions behaviors use, on a timeline (duration, delay, parallel); the built-in type="player" widget gives play/pause/scrub, seeking restores the scene's captured initial state, one story is active per map, and grabbing the map pauses playback. Effect verbs — fade, pulse, trace (progressive TripsLayer draw-on; with feature-id, a single polygon draws itself on inside its own layer), and populate (rows drop in one by one via a GPU filter sweep) — work as step shorthands or plain actions. The story is a sibling that references layers by id — delete it and the map is unchanged. For flybys over 3D tilesets, warm-tiles pre-loads the route's tiles in the background before playback, and paced goes further: the story steps its own clock and never advances a frame while a tileset is still refining, so no frame ever shows unrefined tiles — wall-clock stretches instead (built for recorded takes; emits om-paced-tick {t, waitedMs} per frame, and the two compose — warm first, wait less). npx @nika-js/onlymap record map.html --out flyby.mp4 turns that guarantee into an actual video: one screenshot per paced frame in headless Chromium (widgets and attribution included), assembled with ffmpeg — and effect verbs animate on the story clock, so recorded traces draw on frame by frame exactly as authored. Guide: docs/stories.md.

Classified symbology: classify-by="magnitude" classify-scale="jenks" classify-classes="5" classify-ramp="viridis" computes class breaks from the data (quantile, equal-interval, or Jenks natural breaks), styles the layer with a named ramp, and auto-generates the classes legend — no domains or colors to hand-author; an explicit get-fill-color/color always wins. Temporal playback: <om-widget type="time-slider" layer="…" field="time" duration="20s" window="86400000" format="date"> is a play/pause/scrub control over a layer's time field, driving the same filter-layer mechanics as the filter widget (stats stay coherent; loop restarts the sweep).

GPU filtering is declarative — filter-field="magnitude" filter-range="[4,10]" — updates live from the built-in filter widget (a slider), and widget statistics stay coherent with what the map shows (ctx.stats respects the active filter unless you opt out). Up to 4 numeric dimensions at once via filter-fields='[{"field":"magnitude","range":[4,10]},{"field":"time","range":[…]}]' — a widget per field, each moved independently; a row must pass every active dimension. Categorical filtering is the same shape, a separate mechanism — filter-category="fuel" filter-categories="[...]" (or filter-category-fields for up to 4) renders the same filter widget as a checkbox list instead of a slider, and combines with any active numeric filter (a row must pass both).

Dashed lines are one attribute — dash="[6, 3]" (or dash="6 3", plus optional dash-justified) on a PathLayer/GeoJsonLayer/PolygonLayer/TripsLayer — wired through deck's PathStyleExtension under the hood.

Routing & tracking: <om-layer type="Route" geometry='{"type":"LineString","coordinates":[...]}'> draws a styled route (casing + line + origin/destination markers) from geometry you already have; origin/destination (+ provider, default "nika") resolve one asynchronously instead via OmMap.registerRoutingProvider. follow="fit-route" auto-fits the camera once it resolves. <om-layer type="Tracking" get-position="[$lng,$lat]"> renders one moving entity with bearing-derived rotation (bearing-field, default "bearing"; icon="arrow|car|motorcycle" picks the marker shape, tinted by color) and glides smoothly between position updates (interpolate-ms, default 1000) rather than jumping — follow="follow" eases the camera along with it. Live position data arrives through the ordinary data/source mechanism, same as any layer — no separate tracking subscription API to learn. Link a route to its rider with progress-from and pick a tail mode: tail="none" (client view — only current position to destination renders) or tail="dim" (operator view — the traveled portion darkens), split at the marker's interpolated position per frame. Guide (incl. a keyless OSRM adapter recipe and per-engine notes): docs/routing.md.

Widgets get a real runtime API

Custom widget scripts receive ctx: layer metadata, viewport (bounds/zoom/project), the current selection, emit(), and data access — ctx.data(id), ctx.dataInViewport(id), ctx.stats(id, field) (count/min/max/mean/stddev/percentiles/histogram, viewport-scoped on request). Declare watch tokens (data:quakes viewport selection layers) and the runtime re-renders you only when relevant state changes. vegaEmbed and d3 are available as globals for charts.

React

The same engine, as real components — @nika-js/onlymap/react (React ≥ 18, optional peer). No om-* elements are rendered, so React and the library never fight over the DOM: components feed a typed programmatic front-end that produces the same layer IR the HTML manifest does. Accessors are plain functions (no expression language), interactions are event handlers, and useOmMap() is the widget ctx contract as a fully-typed hook:

import { OmMap, OmLayer, OmWidget, OmOverlay, useOmMap } from "@nika-js/onlymap/react";

<OmMap center={[-119, 36]} zoom={5} basemap="maplibre">
  <OmLayer id="quakes" type="ScatterplotLayer" data={features} pickable
           getPosition={d => [d.lon, d.lat]}
           getFillColor={d => (d.mag >= 6 ? [214, 40, 40] : [252, 191, 73])}
           onClick={sel => setSelected(sel.object)} />
  <OmWidget position="top-left"><StatsPanel /></OmWidget>
  <OmOverlay anchorFrom="selection">{sel => sel && <Card feature={sel.object} />}</OmOverlay>
</OmMap>

<OmOverlay> manages projection, per-frame tracking, and off-screen culling for you; <OmMap headless> makes the whole tree testable in jsdom/happy-dom. Full guide: docs/react.md.

Editor IntelliSense

onlymapjs.html-data.json (generated from the layer registry — npm run gen:html-data) gives autocomplete and hover docs for every om-* element and attribute in VS Code and any editor speaking the html-customData format:

npx @nika-js/onlymap init

That opt-in command updates .vscode/settings.json and copies the !-prefixed manifest snippets into .vscode/onlymap.code-snippets. It never runs automatically during install.

// .vscode/settings.json
{ "html.customData": ["./node_modules/@nika-js/onlymap/onlymapjs.html-data.json"] }

The package also ships !-prefixed manifest snippets (node_modules/@nika-js/onlymap/.vscode/onlymap.code-snippets) — type !starter, !map, !layer, !draw, etc. to scaffold a well-formed element.

React Native / mobile

The same runtime ships as an Expo React Native adapter, @nika-js/onlymap-native — the complete library hosted in a secure WebView and driven from JSX over a JSON bridge. The native surface keeps OnlyMap's vocabulary with two rule changes: accessors are expression strings (getPosition="[$lon, $lat]" — functions cannot cross the bridge) and everything on a descriptor is plain JSON. A 20-fixture parity gate keeps the native lane byte-identical to this repo's React adapter at the IR level. Guide: docs.nikaplanet.com → OnlyMap JS → React Native.

Quick-start guide

Use OnlyMapJS with Claude Code and Codex

The public repo includes a portable onlymapjs Skill that teaches coding agents the manifest syntax, React adapter, common map patterns, and validation/testing workflow. Install the library in the app you want to map:

npm install @nika-js/onlymap

Then, from that app's repository root, install the Skill for your agent:

# Claude Code
npx -y skills add NikaGeospatial/onlymapjs --skill onlymapjs --agent claude-code

# Codex
npx -y skills add NikaGeospatial/onlymapjs --skill onlymapjs --agent codex

Using both agents in the same repository? Install it for both in one command:

npx -y skills add NikaGeospatial/onlymapjs \
  --skill onlymapjs \
  --agent claude-code \
  --agent codex

Start or restart Claude Code or Codex in that repository, then describe the map you want. Mention the installed Skill explicitly when you want to guarantee it is used:

Use the installed OnlyMapJS skill to build a full-screen map in this project.
Inspect my data before choosing accessors. Add a legend and click details,
include a no-JavaScript fallback, validate the result, and run the relevant tests.

For an existing React app:

Use the installed OnlyMapJS skill to add this map to my React app. Use the
@nika-js/onlymap/react adapter, preserve the app's existing state patterns,
and verify the result with the library's headless testing tools.

The Skill steers HTML projects toward declarative om-* manifests and React projects toward the first-party adapter. It also tells the agent to use OmMap.validate(), OmMap.snapshotIR(), and the headless harness rather than guessing whether the generated map works.

To inspect the Skill before installing it:

npx -y skills add NikaGeospatial/onlymapjs --list

Validation — the feedback loop

const result = OmMap.validate(manifestHtml);
// { valid, errors: [{ severity, element, attribute, message, fix }], warnings: [...] }

Exhaustive (every problem in one pass) and prescriptive (each entry carries a fix instruction). The same pass runs live via the validate attribute, rendering an on-page error panel with click-to-locate. Runtime errors (crashing accessors, bad props) are formatted into the same shape. Attributes for not-yet-implemented features warn instead of failing silently.

Testing your map pages

Uniquely for a WebGL map library, pages built with OnlyMapJS are testable in plain vitest/jest — no browser, no GPU:

const h = await mountForTest(myPageHtml);            // headless: real projection math, no WebGL
await h.pick({ layer: "quakes", featureId: "q1" }); // same code path as a real click
expect(overlay.shadowRoot.textContent).toContain("M 6.5");

Plus OmMap.snapshotIR(html) to lock down what a manifest means in a snapshot test, and await mapEl.ready / the om-map-ready event to de-flake browser e2e tests.

Layout, checked like types. npx @nika-js/onlymap check-layout <manifest.html> audits a page's real rendered widget layout in headless Chromium — no screenshots, no eyeballing: it asserts no widgets overlap, each is hit-testable (not painted under the canvas), each sits in its slot, and same-slot edges are flush, across a viewport-width sweep, emitting structured {severity, element, message, fix} diagnostics and exiting 0/1. The same audit is importable as auditLayout(page) for your own Playwright suite. Full guide: docs/testing.md.

3D

ScenegraphLayer instances glTF/GLB models at coordinates (no three.js, no loader wiring), Tile3DLayer consumes OGC 3D Tiles, GeoJsonLayer extrudes polygons (extruded get-elevation="$height"), and the camera tilts via pitch/bearing attributes. Terrain is one attribute: <om-map terrain="terrarium"> raises a real DEM surface (keyless AWS tiles; mapterhorn keyless too, paired with a CARTO Positron drape by default; maptiler-terrain keyed; or a bring-your-own {z}/{x}/{y} DEM URL + terrain-decoder), geographic layers drape onto it automatically (per-layer terrain="drape|offset|off" overrides; 3D models sit ON the surface), terrain-exaggeration scales the relief, and terrain-texture drapes imagery. Terrain replaces an active basemap while on (flat canvas vs raised surface) and restores it when off. BIM models require explicit terrain: a georeferenced model's absolute elevation only means anything against a DEM surface, and the library never writes attributes for you — a model that resolves real elevation on a map without a terrain attribute raises an error through the validation channel at load time (any authored value satisfies it, including an explicit terrain="off" for flat-ground siting). When terrain IS active, BIMLayer automatically re-plants itself at its file's own IfcMapConversion.OrthogonalHeight (its real absolute elevation) instead of the local Z≈0 a flat basemap expects — no attribute to author, it just lands on the surface. Scene lighting is declarative: <om-map lighting="daylight"> (presets daylight/studio/flat/custom, tuned via lighting-ambient, lighting-sun, lighting-sun-azimuth/-elevation, or lighting-sun-date for a solar-position sun) — attribute-backed, so lighting changes are undoable and story-steppable via the set-lighting action. <om-widget type="lighting"> gives users native preset radios + tuning sliders over the same attributes. Converting IFC/CAD upstream: docs/3d-assets.md.

Clip box. <om-map clip-box-min="[lng,lat,elev]" clip-box-max="[lng,lat,elev]"> cuts a real axis-aligned 3D box through the whole scene — every layer is clipped by default (clip="off" opts a layer out), clip-box-invert shows outside the box instead of inside, and clip-box-highlight dims clipped-out geometry instead of discarding it (a non-destructive preview). Works on any layer type, including georeferenced Tile3DLayer/BIMLayer content, so a dense BIM scene can be cut open to see what's inside. Attribute-backed (undoable, story-steppable) via the set-clip-box action; <om-widget type="clip-box"> gives users the same six numeric fields plus invert/highlight toggles. v1 is axis-aligned only — rotated boxes are a documented follow-up.

XY snapping, for draw/measure vertex capture. <om-map snap="vertex edge midpoint" snap-tolerance="12"> (pixels, default 12) refines whatever deck already picked under the cursor to the nearest vertex, edge, or edge midpoint of that feature's own geometry — a two-stage design, not a spatial index: deck's own hover/click pick narrows to one feature every frame for free, and only THAT feature's geometry gets searched, on the CPU, when snapping is on. Applies to every vector layer by default (snap="off" opts a layer out, mirroring clip box's own default) and to a BIMLayer's own edge/crease overlay — its real wall corners and edges, converted from the model's local mesh coordinates back to [lng, lat] automatically, so a drawn footprint can snap onto an actual building rather than free-clicking near it. Vertex beats midpoint beats edge when more than one is in range. Hold Space to place a point nearby without snapping (standard CAD/GIS convention); a small "Snapped: …" tip shows which agent fired. XY only — a snapped vertex takes the tolerance-matched feature's horizontal position, not its exact elevation (drawn geometry still drapes onto terrain the normal way). See dev/examples/features/widgets/3d-snapping-cutting-tools.html.

Per-element picking on 3D Tiles (BIM/photogrammetry). pickable="3d" (instead of a bare pickable) runs deck's depth-pick pass, so a click/hover's coordinate carries a real 3rd (elevation) component instead of the ray∩z=0-plane guess a flat pick gives you — needed for any tool that reads where in 3D you picked, not just what; terrain sets this on itself automatically. A pickable Tile3DLayer picks a whole tile; adding pick-features picks an individual ELEMENT — a wall, a window, one IFC product — and the selection carries that element's featureId, properties and class, resolved from the tile's own EXT_mesh_features + EXT_structural_metadata (feature-id-property selects the ID set, default _FEATURE_ID_0). Isolate/hide/ghost are declarative and mirror the vector filter vocabulary — feature-filter-field="component" names the metadata field, then isolate-features/hide-features/ghost-features take JSON value lists (ghost-opacity tunes the fade); hiding is a shader discard, so a hidden element also stops being pickable and you can select what's behind it, and isolate-features is exclusive (anything unlisted is hidden). Because they're attributes they're undoable and story-steppable. Underneath, feature-styles recolours, fades or highlights elements by feature ID — an array indexed by id of {color, strength, opacity} — uploaded as a small lookup texture, so restyling costs no refetch and no re-tesselation. IDs stored per texel (how photogrammetry classification ships) are sampled per FRAGMENT, so element boundaries are pixel-sharp rather than snapped to triangle edges. Multi-material models fan out correctly (glTF allows one material per primitive, so a five-material house is five primitives and a real IFC export is often dozens); only genuinely instanced i3dm content falls back to tile-granularity picking. Two limits, stated plainly: texture-backed IDs need load-options with gltf.loadImages/loadBuffers and image: {"type": "data"} (without it loaders.gl reads the whole ID texture back through a canvas once per vertex and the tileset takes minutes to appear); and opacity below 1 currently blanks the model, so use strength to tint for now. See dev/examples/features/terrain-3d/inspect-a-bim-model.html (per-element picking, isolate/hide/ghost, the model tree) and dev/examples/features/widgets/3d-snapping-cutting-tools.html (pickable="3d" with {{z}}, the clip box, region export and snapping).

The BIM widgets. Three built-ins turn a pick-features tileset into a usable model browser without page JS. ifc-browser (renamed from ifc-legend, which still works — it browses a model, it doesn't explain symbology) groups the model by any property-table field (fields="ifcClass material container spatialPath"), counts each value, and gives every row I/H/G buttons that write isolate-features/hide-features/ghost-features — so the panel is a UI over the attributes, and everything it does is undoable and story-steppable; it keeps the companion outline layer's filter-categories in step too, since hidden elements would otherwise leave their edges behind. It also offers whichever model trees the file supports — spatial (IfcSite → IfcBuilding → IfcStorey), type, system, and classification — each expandable, counts aggregated upward, the same I/H/G on every node so isolating a storey, a duct system or a CCS code reaches everything beneath it. Spatial isn't privileged: on a real Danish project the classification tree covered 3,415 elements to spatial's 660. A tree isn't a second mode or a second widget — it's a group-by on a metadata column, which is why it reuses the visibility attributes untouched (and why there's one browser per layer: those attributes are single-valued). loadIfc extracts every hierarchy a model has and emits a column only when the file populates it, so a tree that would render empty is never offered. feature-inspector (renamed from ifc-inspector, which still works — its body is generic property-row rendering with no IFC dependency, so it works unchanged on a non-IFC pick-features layer) shows the picked element's properties (fields="ifcClass material container netVolume"). ifc-loader is a drop zone that parses an .ifc in the browser and builds both layers for it; add federate and one drop zone accepts SEVERAL models into a co-registered scene, each with its own visibility toggle — the shape coordination tools use, because a real project has architecture, structure, mechanical, electrical and plumbing rather than two. ifc-clash compares any two loaded models. Colour is a question, not decoration: feature-color-by colours categorically by a field, feature-color-scale graduates over a numeric one, feature-palette and feature-color-strength tune them — and setting neither is the default, leaving the model in its own IFC surface colours. Widget scripts reach the same data through ctx.features(layerId) (the decoded property table) plus the features watch token, which fires when the table decodes off the first tile.

Clash detection, coarse and honest. <om-widget type="ifc-clash"> flags element pairs from two co-registered models whose bounding boxes interpenetrate, colours both sides, and flies to the centre of each overlap. Results are grouped by element the way coordination tools report them — one wall crossing four ducts is one row, not four — with a highlight switch and an isolation mode (Dim Other / Hide Other, Navisworks' own terms) so the pair you selected is the only thing that reads. On a real architecture-vs-HVAC pair it finds 1,443 clashes across 9.6M candidate combinations in ~96 ms, entirely in the browser.

It is deliberately not Navisworks. An axis-aligned box test over-reports anything diagonal, and it cannot tell a resolved penetration from a collision: on that same pair, 676 of the results are air diffusers sitting inside ceilings, which is how buildings work. Treat the number as "here are the places two disciplines occupy the same box — go look", not as a defect list. Persisting and sharing results is what BCF exists for and is not attempted here.

In-browser IFC. loadIfc(bytes) parses an .ifc with web-ifc (WASM) and returns a 3D Tiles model held entirely in memory — nothing uploaded, nothing written to disk. Because the output is a tileset, per-element picking, styling, site-* and isolate/hide/ghost work on it unchanged. It returns tilesetUrl/edgesUrl blob URLs, the property table, the georeferencing (lonLat, heading, scale, and a headingSource that distinguishes a real reading from an assumption), per-phase timings, and a revoke() you must call when swapping models. web-ifc is MPL-2.0 and is not a package dependency — it's dynamic-imported from unpkg on first use, so it never reaches the bundle and pages that never open an IFC pay nothing (measured: the IFC chunk is 8.5 KB gzipped and contains only the URL). For offline, air-gapped or strict-CSP deployments, npm run vendor:web-ifc public/vendor/web-ifc copies the four files you serve yourself, then wasm-path="/vendor/web-ifc/" on the loader — 1.37 MB gzipped of static assets, with the JS bundle unchanged. Note that web-ifc is only one network dependency: a georeferenced model also switches a basemap on, so an offline page wants basemap="none" and telemetry="off" too. See dev/examples/features/terrain-3d/inspect-a-bim-model.html. Georeferencing is declarative too. site-origin="[lng, lat]" (optionally [lng, lat, elevation]), site-heading (a bearing, degrees clockwise from true north) and site-scale place a model from the markup instead of baking a position into the tileset — site-origin overrides whatever the root transform carries, and rotation/scale pivot on the model's own anchor so a heading change spins the building about itself. They work on Tile3DLayer and on PathLayer (whose paths are then local east/north/up metres), which is what lets an IFC mesh and its outline overlay move together. This matters more than it sounds: authoring tools ship a default project location and a default is indistinguishable from a survey — the buildingSMART Medical-Dental Clinic sample carries Revit's Boston default, the Duplex a Chicago city-centre point — while IfcMapConversion is absent from most IFC2x3 exports and TrueNorth is routinely unset. Being attributes, corrections are undoable and story-steppable rather than a reconversion. Whenever a model resolves anything short of a real IfcMapConversionIfcSite coordinates or no georeference at all — BIMLayer and ifc-loader raise a structured "warning" through the same validation channel other om-layer errors use (the validate on-page panel, om-validation-error's detail.warnings), rather than only a status-line sentence a human has to notice; it never fails the map (valid stays true), it just names the layer and points at site-origin/site-heading as the fix. See dev/examples/features/terrain-3d/inspect-a-bim-model.html.

Programmatic surface

  • OmMap.*validate, snapshotIR, snapshotDescriptorIR, resolveImageOverlay, registerLayer, registerWidget, registerAction, registerSource, registerFormat, registerBasemap, configureBasemap, configureData, configureTelemetry, configureLicense, getLayerSchema
  • @nika-js/onlymap/deck — the bundled deck.gl classes (CompositeLayer, TileLayer, …) for building custom layer types: shims must extend the same class hierarchy the core renders with, not a second installed deck.gl copy. Recipe: docs/custom-layers.md
  • On a <om-map> elementready (promise), flyTo(coords, zoom?), setLayerVisible(id, bool), getLayers(), emit(action, payload), snapshot(opts?) (canvas-only PNG of basemap + layers at device pixels — DOM widgets/overlays and provider attribution are NOT captured, so exports must render credits themselves; {as: "blob"} for files, default dataURL); the om-view-changed event fires once the camera settles (debounced; detail = {longitude, latitude, zoom, pitch, bearing, origin}, where origin is "user" for gesture-driven bursts vs "programmatic" for API/story moves — the echo-suppression signal for state sync) — the camera-persistence hook; the om-map-point event (detail = {coordinate: [lng,lat]|null, kind: "click"|"hover"}) fires on every click/hover with the map coordinate, including empty-map clicks picks discard — the hook for custom capture tools the built-in draw widget doesn't cover; the om-tileset-load event (detail = {layerId, tileset}) surfaces a Tile3DLayer's live deck Tileset3D for tools that need the real tileset (e.g. region export), not the IR; document.querySelector("om-map") is fully typed
  • MapController — the framework-grade programmatic front-end (typed LayerDescriptors → the same reconcile core, no DOM manifest): setLayers, watch, emit, camera methods, injectPick, ready, snapshot, suspend/resume, an onViewChange(view, origin) option (the om-view-changed twin), plus onMapPoint / onTilesetLoad options (the om-map-point / om-tileset-load twins). Active descriptors own reference-counted fetch/poll/socket handles: layer removal, transport-option change, suspend, and destroy release them. Accessor props normally take functions; schema-declared accessors also accept restricted expression strings such as getPosition: "[$lon, $lat]", which makes the descriptor JSON-safe for native/cross-process bridges. snapshotDescriptorIR(descriptors) resolves that lane without fetching URL data. The React adapter rides the function form; vanilla/native hosts may use either
  • getStore(token) — the external-store contract: per-token {subscribe, getSnapshot} stores (viewport/selection/layers/data:<id>) with cached immutable plain-data snapshots and origin tagging — directly consumable by useSyncExternalStore (the React adapter's own hooks ride it), MobX autoruns, Redux listeners, Zustand mirrors. ~20-line integration-tested recipes for Redux Toolkit, MobX/mobx-keystone, Zustand, and Jotai: docs/external-stores.md
  • TestingmountForTest, and imports are SSR-safe (importing in Node/jsdom never touches browser globals)

Free tier & licensing

Non-commercial use is free with attribution (LICENSE.md §3); commercial use requires a license. Without a license key, maps run on the free plan: up to 5 layers and 25,000 rows per layer (20 MB per data fetch), with a small "Built with OnlyMap by NIKA. Free for non-commercial use." badge in the corner. Exceeding a limit never breaks the map. Past the layer cap the extra layer simply doesn't render; past the row cap the layer renders its first 25,000 rows (an arbitrary subset, in source order) rather than nothing, with a dismissible on-map notice saying the data is partial — so a capped map is still useful to look at without ever pretending it's complete. Either way the validation stream/error panel tells you exactly which limit, why, and how to lift it. Limits apply only on hosted http(s) pages — in a dev context (localhost, file://, or any non-web scheme) every cap lifts so you can build without friction; the attribution badge stays in all contexts. The exemption is a technical convenience, not a license grant: commercial deployment — hosted or shipped inside a packaged app — requires a commercial key regardless of whether the technical gates fired (LICENSE.md §3).

A license key lifts all limits and removes the badge:

<om-map license-key="om_live_…">        <!-- keys are publishable & origin-restricted — safe in page source -->
OmMap.configureLicense("om_live_…");     // or once, in code

Packaged native hosts can verify an app-scoped key by passing platform-derived identity:

await OmMap.configureLicense("om_live_…", { appId: "com.example.riders" });

The appId must come from trusted native build metadata, never page/bridge input — the library cannot verify where it came from. Unlike domains, which the browser pins, the apps claim is asserted by the caller, so prefer keys minted with both claims (include the packaged host's own origins) and treat app scoping as a deterrent rather than enforcement. Keys are self-verifying signed tokens (no network round-trip, works offline and in CI) bound to declared domains and/or exact app identifiers. Licensing: https://www.nikaplanet.com/onlymap.

Telemetry

The library reports one deployment-scoped usage snapshot per map per page load — layer types and counts, widget types, renderer; hostname only (a packaged mobile app reports its declared app id instead, since an embedded WebView's hostname identifies nothing), no page URLs, no visitor identifiers, and headless (test) maps never report — plus errors caused by the library's own code (own-bundle stack filtering, scrubbed, rate-limited). Reports go to a first-party endpoint, never a third-party domain. Opt out globally with OmMap.configureTelemetry({ disabled: true }) or per map with telemetry="off". Full schema, rules, and the license disclosure: docs/telemetry.md, LICENSE.md §11.

Not implemented yet (honestly)

Mapbox GL basemaps, depth-interleaved 3D compositing, globe projection, SSE transport, dblclick behaviors, the transform data pipeline, the typed fluent builder, and stories/draw as React components (both work via the HTML manifest). On React Native (@nika-js/onlymap-native): stories, draw, widgets, and overlays as native components (build native UI beside the map instead), and bare (non-Expo) React Native until its 0.2.0. On pick-features: instanced (i3dm) tiles keep tile-granularity picking, and feature-styles' opacity below 1 is still being validated. On BIM: clash detection is bounding-box only (no mesh-level test, and it cannot tell a resolved penetration from a collision), there is no BCF export, no equivalent of Navisworks' Auto Reveal, and prepared tilesets converted before the bbox* columns existed cannot take part in a clash pass — strength tinting and opacity: 0 (hide, a shader discard) work. On the measure widget's volume mode: the per-cell grid integration measures against the map's active terrain DEM only — measuring against an imported design surface or a previous survey (period-over-period reconciliation) and a cut/fill heat-map overlay are not implemented yet, and a snapped footprint vertex takes the matched feature's horizontal position only — its exact elevation is not threaded through (see XY snapping above).

Going deeper

| | | |---|---| | docs/react.md · docs/basemaps.md · docs/testing.md · docs/live-data.md · docs/image-overlays.md · docs/3d-assets.md · docs/stories.md · docs/routing.md · docs/telemetry.md | Consumer guides | | CHANGELOG.md | Version-by-version release notes | | llms.txt | The agent-facing quick reference | | skills/onlymapjs | Installable LLM skill for OnlyMapJS authoring |