@nika-js/onlymap 0.6.1 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +106 -1
  2. package/README.md +28 -15
  3. package/dist/{LercDecode.es-CJnypw8j.js → LercDecode.es-D5in29tf.js} +1 -1
  4. package/dist/{basemap-DrQ0-eyR.js → basemap-C0pFT3AO.js} +13 -3
  5. package/dist/basemap.d.ts +10 -1
  6. package/dist/clip-box-controller.d.ts +94 -0
  7. package/dist/clip-box.d.ts +109 -0
  8. package/dist/data-layer.d.ts +34 -4
  9. package/dist/draw-controller.d.ts +75 -1
  10. package/dist/draw.d.ts +22 -4
  11. package/dist/elements/om-map.d.ts +20 -0
  12. package/dist/elements/om-overlay.d.ts +12 -0
  13. package/dist/geodesy.d.ts +32 -3
  14. package/dist/{geoparquet-Dix4lTNy.js → geoparquet-By98JVB0.js} +1 -1
  15. package/dist/html-data.d.ts +2 -2
  16. package/dist/{index-BQMjW5w0.js → index-Bx9GFkrn.js} +1 -1
  17. package/dist/{index-CXPaeisL.js → index-CqC4sW_k.js} +1 -1
  18. package/dist/{index-1UgNlfGR.js → index-Cxo9mCw_.js} +30947 -27785
  19. package/dist/{index-B_1PPJgC.js → index-olfncHIq.js} +1 -1
  20. package/dist/{index-CAuT5j9Y.js → index-tnlYDALL.js} +2 -2
  21. package/dist/index.d.ts +8 -3
  22. package/dist/ir-snapshot.d.ts +3 -1
  23. package/dist/layers/bim-layer.d.ts +30 -7
  24. package/dist/layers/feature-mesh-layer.d.ts +1 -1
  25. package/dist/layers/popup-layer.d.ts +13 -0
  26. package/dist/legend-spec.d.ts +1 -1
  27. package/dist/{lerc-CbTjQ7uI.js → lerc-gKDDtc69.js} +2 -2
  28. package/dist/license.d.ts +25 -4
  29. package/dist/measure-controller.d.ts +482 -4
  30. package/dist/onlymap.standalone.js +53976 -50804
  31. package/dist/onlymapjs.js +42 -40
  32. package/dist/parse-manifest.d.ts +3 -0
  33. package/dist/programmatic.d.ts +34 -5
  34. package/dist/{raster-0b0nSHUh.js → raster-Cl5m3KsC.js} +2 -2
  35. package/dist/{raster-pipeline-D8siq7-4.js → raster-pipeline-hJGxIwYx.js} +1 -1
  36. package/dist/react/om-layer.d.ts +6 -1
  37. package/dist/react.js +167 -160
  38. package/dist/region-export-controller.d.ts +37 -0
  39. package/dist/region-export.d.ts +56 -0
  40. package/dist/runtime-core.d.ts +150 -0
  41. package/dist/selection.d.ts +11 -1
  42. package/dist/site-placement.d.ts +22 -0
  43. package/dist/snapping.d.ts +88 -0
  44. package/dist/terrain-heightfield.d.ts +53 -0
  45. package/dist/terrain-sample.d.ts +30 -0
  46. package/dist/terrain.d.ts +6 -1
  47. package/dist/units.d.ts +27 -0
  48. package/dist/version.d.ts +1 -1
  49. package/dist/volumetrics-run.d.ts +13 -0
  50. package/dist/volumetrics-worker.d.ts +1 -0
  51. package/dist/volumetrics.d.ts +201 -0
  52. package/dist/{zarr-DCYro_Vs.js → zarr-hRDavRGV.js} +2 -2
  53. package/docs/3d-assets.md +49 -1
  54. package/docs/live-data.md +26 -1
  55. package/docs/react.md +9 -1
  56. package/docs/testing.md +2 -0
  57. package/llms.txt +10 -5
  58. package/onlymapjs.html-data.json +161 -2
  59. package/package.json +4 -2
  60. package/skills/onlymapjs/SKILL.md +11 -1
  61. package/skills/onlymapjs/references/react.md +2 -2
  62. package/skills/onlymapjs/references/syntax.md +20 -8
  63. package/skills/onlymapjs/references/testing.md +6 -0
@@ -157,6 +157,44 @@
157
157
  "name": "terrain-texture",
158
158
  "description": "Optional {z}/{x}/{y} imagery template draped over the surface (satellite, etc.)."
159
159
  },
160
+ {
161
+ "name": "clip-box-min",
162
+ "description": "3D clip box corner \"[lng, lat, elevation]\" — with clip-box-max, cuts every layer (opt out per-layer with clip=\"off\") down to what's inside the box. Axis-aligned."
163
+ },
164
+ {
165
+ "name": "clip-box-max",
166
+ "description": "3D clip box opposite corner \"[lng, lat, elevation]\" — see clip-box-min."
167
+ },
168
+ {
169
+ "name": "clip-box-invert",
170
+ "description": "Show what's OUTSIDE the clip box instead of inside."
171
+ },
172
+ {
173
+ "name": "clip-box-highlight",
174
+ "description": "Dim clipped-out geometry instead of discarding it (non-destructive preview) — nothing disappears, it just de-emphasizes."
175
+ },
176
+ {
177
+ "name": "snap",
178
+ "description": "XY snapping for draw/measure vertex capture: space-separated agents (vertex, edge, midpoint). Refines deck's own hover/click pick to the nearest vertex/edge/midpoint of the ALREADY-picked feature within snap-tolerance pixels — searches every pickable vector layer's geometry by default, opt a layer out with snap=\"off\". Hold Space to suppress momentarily.",
179
+ "values": [
180
+ {
181
+ "name": "vertex edge midpoint"
182
+ },
183
+ {
184
+ "name": "vertex"
185
+ },
186
+ {
187
+ "name": "edge"
188
+ },
189
+ {
190
+ "name": "midpoint"
191
+ }
192
+ ]
193
+ },
194
+ {
195
+ "name": "snap-tolerance",
196
+ "description": "Snap search radius in screen pixels (default 12)."
197
+ },
160
198
  {
161
199
  "name": "widget-style",
162
200
  "description": "Layout-token sugar: space-separated key:number pairs → --om-widget-* custom properties (e.g. \"gap:10 opacity:0.9\"). Keys: inset, gap (shorthands), inset-x/-y, gap-x/-y, opacity, radius, size. Numbers are px except opacity."
@@ -432,6 +470,24 @@
432
470
  }
433
471
  ]
434
472
  },
473
+ {
474
+ "name": "clip",
475
+ "description": "Opt out of the map's active clip box (<om-map clip-box-min/max>) with \"off\" — clipping applies to every layer by default once a box is configured.",
476
+ "values": [
477
+ {
478
+ "name": "off"
479
+ }
480
+ ]
481
+ },
482
+ {
483
+ "name": "snap",
484
+ "description": "Opt out of the map's active XY snapping (<om-map snap>) with \"off\" — snapping searches every layer's own geometry by default once enabled, including a BIMLayer's edge overlay.",
485
+ "values": [
486
+ {
487
+ "name": "off"
488
+ }
489
+ ]
490
+ },
435
491
  {
436
492
  "name": "key",
437
493
  "description": "Stream entity identity field — messages upsert by this key (wss data)."
@@ -1603,6 +1659,10 @@
1603
1659
  "name": "hover"
1604
1660
  }
1605
1661
  ]
1662
+ },
1663
+ {
1664
+ "name": "clip-to-map",
1665
+ "description": "Hide the overlay when its own BOX would spill past the map viewport, not just when its anchor leaves (the default). Opt-in: an overhanging absolutely-positioned box inflates the page's scrollable overflow, and the resulting scrollbar → map resize → reprojection loop shows as view jitter. Use it for small transient tips that track the cursor; an authored popup near an edge normally wants to keep showing its visible half."
1606
1666
  }
1607
1667
  ],
1608
1668
  "references": [
@@ -1731,6 +1791,9 @@
1731
1791
  {
1732
1792
  "name": "draw-save"
1733
1793
  },
1794
+ {
1795
+ "name": "export-region-3d"
1796
+ },
1734
1797
  {
1735
1798
  "name": "measure-mode"
1736
1799
  },
@@ -1749,6 +1812,12 @@
1749
1812
  {
1750
1813
  "name": "set-terrain"
1751
1814
  },
1815
+ {
1816
+ "name": "set-clip-box"
1817
+ },
1818
+ {
1819
+ "name": "clip-box-edit"
1820
+ },
1752
1821
  {
1753
1822
  "name": "set-widgets-visible"
1754
1823
  },
@@ -1799,6 +1868,9 @@
1799
1868
  {
1800
1869
  "name": "vega-lite"
1801
1870
  },
1871
+ {
1872
+ "name": "dynamic-chart"
1873
+ },
1802
1874
  {
1803
1875
  "name": "player"
1804
1876
  },
@@ -1814,6 +1886,9 @@
1814
1886
  {
1815
1887
  "name": "lighting"
1816
1888
  },
1889
+ {
1890
+ "name": "clip-box"
1891
+ },
1817
1892
  {
1818
1893
  "name": "undo-redo"
1819
1894
  },
@@ -1919,6 +1994,14 @@
1919
1994
  "name": "field",
1920
1995
  "description": "Data field (filter / vega-lite widgets)."
1921
1996
  },
1997
+ {
1998
+ "name": "on",
1999
+ "description": "dynamic-chart widget: the <om-map> event name to listen for (required), e.g. \"om-measure\"."
2000
+ },
2001
+ {
2002
+ "name": "series-field",
2003
+ "description": "dynamic-chart widget: the event.detail field holding the chart's data array (default \"series\")."
2004
+ },
1922
2005
  {
1923
2006
  "name": "format",
1924
2007
  "description": "Filter value-label format. Use date for epoch-millisecond fields; default number.",
@@ -1963,7 +2046,7 @@
1963
2046
  },
1964
2047
  {
1965
2048
  "name": "modes",
1966
- "description": "Draw widget: space-separated tools (point line polygon). Measure widget: space-separated modes (distance area).",
2049
+ "description": "Draw widget: space-separated tools (point line polygon). Measure widget: space-separated modes (distance area volume) — volume needs terrain on <om-map>.",
1967
2050
  "values": [
1968
2051
  {
1969
2052
  "name": "point line polygon"
@@ -1978,13 +2061,16 @@
1978
2061
  "name": "polygon"
1979
2062
  },
1980
2063
  {
1981
- "name": "distance area"
2064
+ "name": "distance area volume"
1982
2065
  },
1983
2066
  {
1984
2067
  "name": "distance"
1985
2068
  },
1986
2069
  {
1987
2070
  "name": "area"
2071
+ },
2072
+ {
2073
+ "name": "volume"
1988
2074
  }
1989
2075
  ]
1990
2076
  },
@@ -2007,6 +2093,26 @@
2007
2093
  "name": "autosave",
2008
2094
  "description": "Draw widget: localStorage key — mirrors the sketch and restores it on reload."
2009
2095
  },
2096
+ {
2097
+ "name": "export-3d",
2098
+ "description": "Draw widget: adds an \"Export 3D\" button — clips loaded Tile3DLayer/BIMLayer content to the drawn footprint and downloads it. Bare = GLB (default); \"b3dm\" wraps it for Cesium/3D-Tiles pipelines.",
2099
+ "values": [
2100
+ {
2101
+ "name": "glb"
2102
+ },
2103
+ {
2104
+ "name": "b3dm"
2105
+ }
2106
+ ]
2107
+ },
2108
+ {
2109
+ "name": "fill-color",
2110
+ "description": "Draw widget: shades the committed (closed) shape's interior — CSS color (hex/named/rgb()/rgba()) or a JSON [r,g,b,a?] array. Auto-creates a committed-geometry layer bound to the draw target; without this (or line-color), a closed shape has no built-in visual once the in-progress preview clears."
2111
+ },
2112
+ {
2113
+ "name": "line-color",
2114
+ "description": "Draw widget: the committed (closed) shape's boundary color — same formats as fill-color. Given alone, a translucent fill in the same hue is derived automatically so the shape still reads as an area, not just an outline."
2115
+ },
2010
2116
  {
2011
2117
  "name": "units",
2012
2118
  "description": "Measure / scale-bar widget: unit system for length and area.",
@@ -2022,6 +2128,50 @@
2022
2128
  }
2023
2129
  ]
2024
2130
  },
2131
+ {
2132
+ "name": "profile",
2133
+ "description": "Measure widget: volume-mode attribute — closing a footprint also samples elevation around its own perimeter, for a paired dynamic-chart widget reading the om-measure event's profileSeries field. No effect without \"volume\" in modes."
2134
+ },
2135
+ {
2136
+ "name": "density",
2137
+ "description": "Measure widget: volume-mode attribute — material density for a tonnage readout (t/m³ metric, lb/yd³ imperial). Omit to skip tonnage reporting."
2138
+ },
2139
+ {
2140
+ "name": "swell",
2141
+ "description": "Measure widget: volume-mode attribute — bulking multiplier applied to the reported Cut volume (excavated material occupies more space loose than in situ). Default 1 (no adjustment)."
2142
+ },
2143
+ {
2144
+ "name": "shrink",
2145
+ "description": "Measure widget: volume-mode attribute — compaction multiplier applied to the reported Fill volume. Default 1 (no adjustment)."
2146
+ },
2147
+ {
2148
+ "name": "deadband",
2149
+ "description": "Measure widget: volume-mode attribute — Cut/Fill below this many m³ reports as zero, filtering drag noise near zero height. Default 0 (no deadband)."
2150
+ },
2151
+ {
2152
+ "name": "base-surface",
2153
+ "description": "Measure widget: volume-mode base-surface strategy. \"custom\" (default) is the draggable gizmo's target plane; \"triangulated\" (boundary TIN), \"plane\" (least-squares fit), \"lowest\"/\"highest\"/\"average\" derive the base from the footprint's own boundary elevations — stockpile-style, no gizmo.",
2154
+ "values": [
2155
+ {
2156
+ "name": "custom"
2157
+ },
2158
+ {
2159
+ "name": "triangulated"
2160
+ },
2161
+ {
2162
+ "name": "plane"
2163
+ },
2164
+ {
2165
+ "name": "lowest"
2166
+ },
2167
+ {
2168
+ "name": "highest"
2169
+ },
2170
+ {
2171
+ "name": "average"
2172
+ }
2173
+ ]
2174
+ },
2025
2175
  {
2026
2176
  "name": "options",
2027
2177
  "description": "Basemap-switcher: space-separated preset names to offer (default: every keyless registered preset)."
@@ -2213,6 +2363,9 @@
2213
2363
  {
2214
2364
  "name": "draw-save"
2215
2365
  },
2366
+ {
2367
+ "name": "export-region-3d"
2368
+ },
2216
2369
  {
2217
2370
  "name": "measure-mode"
2218
2371
  },
@@ -2231,6 +2384,12 @@
2231
2384
  {
2232
2385
  "name": "set-terrain"
2233
2386
  },
2387
+ {
2388
+ "name": "set-clip-box"
2389
+ },
2390
+ {
2391
+ "name": "clip-box-edit"
2392
+ },
2234
2393
  {
2235
2394
  "name": "set-widgets-visible"
2236
2395
  },
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@nika-js/onlymap",
3
- "version": "0.6.1",
3
+ "version": "0.6.4",
4
4
  "description": "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.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
9
9
  "author": "NikaGeospatial",
10
- "homepage": "https://www.nikaplanet.com/onlymap",
10
+ "homepage": "https://onlymap.nikaplanet.com",
11
11
  "repository": {
12
12
  "type": "git",
13
13
  "url": "git+https://github.com/NikaGeospatial/onlymapjs.git"
@@ -120,6 +120,7 @@
120
120
  "gen:public": "npm run gen:html-data && vite-node dev/build-public.ts",
121
121
  "gen:site": "vite-node dev/build-site.ts",
122
122
  "test:public": "npm run gen:public -- --dry-run --strict",
123
+ "test:site": "npm run gen:public -- --out dist-public --strict && npm run gen:site -- --in dist-public --dry-run --strict --site-url https://onlymap.nikaplanet.com --cname onlymap.nikaplanet.com",
123
124
  "deploy:telemetry": "wrangler deploy --config cloud/workers/telemetry/wrangler.toml",
124
125
  "dev:telemetry": "wrangler dev --config cloud/workers/telemetry/wrangler.toml",
125
126
  "deploy:examples": "wrangler deploy --config cloud/workers/examples/wrangler.toml",
@@ -140,6 +141,7 @@
140
141
  "@loaders.gl/csv": "^4.4.3",
141
142
  "@loaders.gl/kml": "^4.4.3",
142
143
  "@loaders.gl/shapefile": "^4.4.3",
144
+ "@luma.gl/engine": "9.3.5",
143
145
  "@playwright/test": "^1.61.1",
144
146
  "@reduxjs/toolkit": "^2.12.0",
145
147
  "@types/d3-array": "^3.2.2",
@@ -23,7 +23,7 @@ Use OnlyMapJS as a declarative HTML map library. Write custom elements such as `
23
23
  </script>
24
24
  ```
25
25
 
26
- For no-build CDN pages, use the single-file standalone bundle from a raw-file CDN — `https://unpkg.com/@nika-js/onlymap@0.6.1` (the bare package URL serves `dist/onlymap.standalone.js`) — plus `<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.6.1/dist/onlymapjs.css">`. Never a rebundling CDN (esm.sh, skypack): re-bundling duplicates the deck.gl/luma.gl runtime and every layer fails shader compilation.
26
+ For no-build CDN pages, use the single-file standalone bundle from a raw-file CDN — `https://unpkg.com/@nika-js/onlymap@0.6.4` (the bare package URL serves `dist/onlymap.standalone.js`) — plus `<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.6.4/dist/onlymapjs.css">`. Never a rebundling CDN (esm.sh, skypack): re-bundling duplicates the deck.gl/luma.gl runtime and every layer fails shader compilation.
27
27
 
28
28
  ## React Projects
29
29
 
@@ -35,6 +35,10 @@ import { OmMap, OmLayer, OmWidget, OmOverlay, useOmMap } from "@nika-js/onlymap/
35
35
 
36
36
  The adapter inverts several HTML-manifest rules: props are camelCase deck.gl props, accessors are plain JS functions (`getFillColor={d => ...}` — no `$field` expression language, no `js` opt-in), and interactions are `onClick`/`onHover` handlers plus React state, not `<om-behavior>` or state-mutating actions. Load `references/react.md` before writing React map code.
37
37
 
38
+ ## Native and JSON Bridges
39
+
40
+ When a trusted native/cross-process host drives `MapController`, keep descriptors JSON-safe. A schema-declared accessor prop may use the same restricted expression grammar as HTML, for example `props: { getPosition: "[$lon, $lat]" }`; ordinary scalar string props remain strings. Use `snapshotDescriptorIR(descriptors)` for deterministic parity checks without fetching URL data. Call `controller.suspend()` on background and `resume()` on foreground; removing/changing a live descriptor or destroying the controller releases its reference-counted fetch/poll/socket handle. A release the owner means to reverse (`suspend()`, removing a layer) keeps its last rows, so `resume()` or re-adding repaints immediately rather than flashing empty; `destroy()` keeps nothing. React components should continue using function accessors. For app-scoped licenses, pass `configureLicense(key, { appId })` only with an identifier obtained from trusted platform build metadata, never from bridge/page input — and mint keys scoped by both `domains` and `apps` where possible, since the `apps` claim is asserted by the host rather than pinned by the browser.
41
+
38
42
  ## Required References
39
43
 
40
44
  Load the smallest reference needed for the task:
@@ -81,7 +85,13 @@ Load the smallest reference needed for the task:
81
85
  - Live entity updates -> `wss://` stream with `key` and optional `source` decoder.
82
86
  - REST snapshot that changes over time -> `refresh="5s"`.
83
87
  - User sketching -> `data="draw:sketch"` layer plus `<om-widget type="draw" target="sketch">`.
88
+ - Export part of a loaded 3D model (BIM/`Tile3DLayer`) as a portable file (issue #34) -> `<om-widget type="draw" modes="polygon" target="<name>" export-3d>` (or `export-3d="b3dm"` for Cesium/3D-Tiles pipelines). Outline a footprint over the loaded content, close it, "Export 3D" clips every loaded tile's triangles to that footprint (plain 2D clip — no elevation-picking needed) and downloads a GLB re-framed to a local coordinate frame at the footprint's own centroid, each triangle carrying its own source color (vertex colors). Separate capability from `save` (that's the drawn shape's GeoJSON, not the 3D content inside it). No textures — BIM/IFC materials are flat colors, not textured meshes. Only currently-visible 3D Tiles/BIM layers are included in the export — hiding a layer (`visible="false"`, or the `toggle-layer` action) excludes it, with distinct console warnings for "nothing loaded yet" vs. "everything hidden."
89
+ - Cut into / reveal the inside of a loaded 3D scene (BIM model, 3D Tiles, or any layer) with a box (issue #34) -> `<om-map clip-box-min="[lng,lat,elev]" clip-box-max="[lng,lat,elev]">` clips every layer to that axis-aligned box by default (`clip="off"` on an `<om-layer>` opts it out); `clip-box-invert` shows the outside instead, `clip-box-highlight` dims clipped-out geometry rather than discarding it (non-destructive preview). Works on any layer type including georeferenced `Tile3DLayer`/`BIMLayer` content — the point is cutting into a dense BIM scene, not just flat GeoJSON extrusions. Attribute-backed (undoable, story-steppable) via the `set-clip-box {min, max, invert?, highlight?}` action (`{clear: true}` removes it); `<om-widget type="clip-box">` is the native UI — six number inputs (min/max × lng/lat/elevation) + invert/highlight checkboxes + a clear button. v1 is axis-aligned only; rotated boxes are a documented follow-up, not this release.
90
+ - Snap a drawn/measured vertex to a nearby feature's own vertex/edge/midpoint (issue #34 Part A) -> `<om-map snap="vertex edge midpoint" snap-tolerance="12">` (px, default 12). NOT a spatial index — it refines whatever feature deck's own hover/click pick already found under the cursor (free, every frame) to that ONE feature's nearest vertex/edge/edge-midpoint, on the CPU, only while snapping is on. Applies to every layer by default (`snap="off"` on any `<om-layer>` opts it out, same shape as `clip="off"`) AND to a `BIMLayer`'s own edge/crease overlay (its real wall corners/edges, converted from the model's local mesh coordinates to real `[lng,lat]` automatically) — the raw triangle MESH itself is not a snap target (no comparable "nearest vertex" concept for an arbitrarily-picked point on a dense surface). Vertex beats midpoint beats edge on range conflicts; Space suppresses snapping momentarily (standard CAD/GIS convention). No action to wire — it's live-reactive like `terrain`/`clip-box-*`, and works automatically with any drawing/measuring tool that already routes through `om-map-point`.
84
91
  - Measure geodesic distance or area (a ruler / area tool) -> `<om-widget type="measure" modes="distance area" units="metric|imperial|nautical">`. Click the map to place points; it shows live per-segment + total labels and dispatches an `om-measure` event (`detail` = the readout). Reuses the draw capture stack, so measure and draw are mutually exclusive. Do NOT hand-roll distance math off canvas pixels; the `scale-bar` widget also takes `units` now.
92
+ - Measure cut/fill volume (earthworks/stockpiles — how much material a shape holds, or how much to add/remove to reach a target elevation) -> `modes="distance area volume"` on the same `measure` widget. Outline a footprint (closes like `area`); the math is a REAL per-cell grid integration against the map's terrain DEM (metric tangent-plane grid at the DEM's own GSD, scanline point-in-polygon, bilinear seam-correct sampling, worker-offloaded) — mixed cut AND fill within one footprint on undulating ground, with `cellSizeM`/`gsdM`/`cutErrorM3`/`fillErrorM3` (± = per-cell cellArea × 1.5 × GSD) and `nodataFraction` published on the readout. `base-surface` picks the reference: `custom` (default — a draggable gizmo target plane, re-summed live per drag frame) or boundary-derived stockpile strategies with NO gizmo (`triangulated` boundary TIN — recommend this for "measure this pile/mound", `plane`, `lowest`, `highest`, `average`). Reads out `cutMeters3`/`fillMeters3`/`netMeters3` (signed, fill−cut)/`totalMeters3` (unsigned, cut+fill) — always RAW geometric volumes, never altered by `swell`/`shrink`. REQUIRES `terrain` on `<om-map>` — a `volume` mode with none is a validation warning (falls back to a flat-plane approximation with no error figures). Volume-only attributes, all no-ops (validation warns) without `volume` in `modes`: `base-surface`; `profile` (elevation samples around the footprint's own perimeter, live while sketching — dispatched on `profileSeries` for a `dynamic-chart` widget to plot; each point is `{x, y}` plus `vertexIndex` on the samples that ARE the drawn corners, so a spec can mark them with `isValid(datum.vertexIndex)` — vertex 0 is the chart's leftmost point, and the map badges the FIRST TWO vertices `1 · Start` and `2` in draw order (1-based display; the field stays 0-based) so the ring's winding direction is readable rather than guessed — two badges fix a direction at constant cost, while the chart still marks every corner — that widget is generic: `<om-widget type="dynamic-chart" on="om-measure" series-field="profileSeries" width="320">` reads `event.detail[seriesField]` on every matching event and redraws, and "freezes" for free when a later event simply omits that field); `deadband` (zeroes a Cut/Fill figure below the threshold); `density`/`swell`/`shrink` populate a SEPARATE Material section (Bank/Loose/Compacted convention — `cutAdjustedMeters3` = raw × swell, `fillAdjustedMeters3` = raw ÷ shrink, `cutMassKg`/`fillMassKg` from the raw, mass-conserving volume), shown only once one of the three is configured — never baked into the primary Cut/Fill/Net/Total numbers. A `stale` readout field flags the window between a footprint committing and its integration resolving.
93
+ - Need a real ELEVATION on a click/hover (a z readout, a tooltip showing the height of the building face under the cursor, a coordinate that lands on 3D content rather than the ground behind it) -> `pickable="3d"` on the layer instead of a bare `pickable` (issue #34): it opts the layer into deck's depth-pick pass, and the resolved coordinate carries a third component. Read it as `{{z}}` in an `<om-overlay>` / `show-tooltip` template or `ctx.selection.coordinate` in a widget script. `terrain` sets this on itself. `{{z}}` is ABSENT (not `0`) when no layer in the scene ran the depth pass for that pick — do not treat a missing elevation as sea level.
94
+ - Small transient overlay that tracks the cursor near a map edge (a snap tip, a live readout badge) -> add `clip-to-map` to the `<om-overlay>`: it hides when the overlay's own BOX would spill past the map viewport, not just when its anchor leaves. Without it an overhanging absolutely-positioned box inflates the page's scrollable overflow and the scrollbar -> map resize -> reprojection loop shows as view jitter. Opt-in on purpose — an authored popup near an edge normally wants to keep showing its visible half.
85
95
  - Capture raw map clicks/hovers yourself (measure distance, drop a pin where the user clicks, a custom rectangle/circle AOI, snap-to-feature) -> listen for the **`om-map-point`** DOM event on `<om-map>`: `mapEl.addEventListener('om-map-point', e => { const { coordinate, kind } = e.detail; /* [lng,lat] or null; kind is "click"|"hover" */ })`. It fires on EVERY click/hover including empty-map clicks. NEVER read deck.gl internals (`getMap()`, `deckInstance`, `deck.viewManager`) or unproject canvas pixels by hand — those are not on `<om-map>` and will silently return nothing. The built-in `draw` widget covers polygon/line/point sketching; `om-map-point` is for tools it doesn't. (`MapController` twin: the `onMapPoint` option. See patterns.md.)
86
96
  - Page may travel as a file (shared, emailed, downloaded) or be embedded -> add an `<om-fallback>` child to `<om-map>`. Chat-app and email previews render HTML with JavaScript disabled (iOS QuickLook), so the map cannot boot there; the fallback is what recipients see instead. It is hidden automatically once the map boots. Good practice on every complete page — without one, the stylesheet shows a generic text-only banner.
87
97
 
@@ -48,8 +48,8 @@ function StatsPanel({ onToggle }) {
48
48
 
49
49
  ## Component surface
50
50
 
51
- - **`<OmMap>`** — `center`/`zoom`/`pitch`/`bearing` (initial; later changes move the camera, unchanged props never fight user panning), `basemap`, `headless`, `widgetStyle` (layout-token sugar, the `widget-style` attribute's twin: `"gap:10 opacity:0.9"` → `--om-widget-*` custom properties), `widgetsHidden` (hide-all: hides authored managed widget wrappers without removal, so widget state survives; provider attribution and the license badge stay visible in their slots), `widgetsFold` (default true; map-width responsive side drawers, set false to opt out), `onReady`, `onViewStateChange`, `onRuntimeError`. Give it a size via `style`/`className`. `ref` exposes the imperative `MapController` handle: `flyTo`, `setView`, `emit`, `getLayers`, `getSelection`, `injectPick`, `ready` (promise), `project`.
52
- - **`<OmLayer>`** — `id` + `type` (any registered deck.gl layer type) + deck props. `data`: stable inline reference or URL string (full Data Layer: CSV/Arrow/Shapefile/KML formats, `ws(s)://` streams via `source`/`streamKey`/`flush`, `refresh` polling). `label`/`color` feed `ctx.layers`; `filterField`/`filterRange` = GPU filter; `onClick`/`onHover` receive the flattened picked object (`onHover(null)` = pointer left).
51
+ - **`<OmMap>`** — `center`/`zoom`/`pitch`/`bearing` (initial; later changes move the camera, unchanged props never fight user panning), `basemap`, `headless`, `widgetStyle` (layout-token sugar, the `widget-style` attribute's twin: `"gap:10 opacity:0.9"` → `--om-widget-*` custom properties), `widgetsHidden` (hide-all: hides authored managed widget wrappers without removal, so widget state survives; provider attribution and the license badge stay visible in their slots), `widgetsFold` (default true; map-width responsive side drawers, set false to opt out), `onReady`, `onViewStateChange`, `onRuntimeError`. Give it a size via `style`/`className`. `ref` exposes the imperative `MapController` handle: `flyTo`, `setView`, `emit`, `getLayers`, `getSelection`, `injectPick`, `ready` (promise), `project`, `suspend`/`resume` (release live fetch/poll/socket handles on app background, reacquire on foreground — native/WebView hosts; the layer repaints its last rows on resume while the first response is in flight).
52
+ - **`<OmLayer>`** — `id` + `type` (any registered deck.gl layer type) + deck props. `data`: stable inline reference or URL string (full Data Layer: CSV/Arrow/Shapefile/KML formats, `ws(s)://` streams via `source`/`streamKey`/`flush`, `refresh` polling). `dash`/`dashJustified` mirror the HTML path-style attributes. `label`/`color` feed `ctx.layers`; `filterField`/`filterRange` = GPU filter; `onClick`/`onHover` receive the flattened picked object (`onHover(null)` = pointer left). Conditional unmount/removal releases that layer's live-transport ownership; the final owner closes it.
53
53
  - **`<OmWidget>`** — positioning shell: `position` takes one of 8 managed slots (logical, RTL-aware: `top-start|top-center|top-end|center-start|center-end|bottom-start|bottom-center|bottom-end`; legacy corners `top-left` etc. alias) + arbitrary JSX. Same-slot widgets stack with flush edges and a shared gap; at map widths ≤640px they auto-fold into per-side drawers (`fold="never"` exempts an essential control); provider attribution joins `bottom-end` and the license badge joins `bottom-start` as in-flow members; `order={1}` sets deterministic in-slot ordering. `position="manual"` renders a plain block at the JSX site — note it sits inside OmMap's overflow-hidden box, so for UI OUTSIDE the map render your own element next to `<OmMap>` and drive the map via `useOmMap()`/the ref instead. (Automatic button-clustering of adjacent compact widgets, and collision-dim of slots under an open overlay, are HTML-lane only — in React, compose your own control group in JSX and dim via your own state.)
54
54
  - **`<OmOverlay>`** — geo-anchored HTML with managed projection/tracking/culling. `anchor={[lng, lat]}` or `anchorFrom="selection"` (+ `layer` to scope which picks move it, + `selectionType="click"|"hover"` to scope which pick TYPE — a click-opened popup wants `selectionType="click"`, else hovering any pickable feature drags and re-renders it; a click on empty space still dismisses); children may be `(selection) => JSX`; `anchorOffset` (default `bottom-center`); `interactive={false}` for hover-following tooltips.
55
55
  - **`useOmMap(watch?)`** — the same `ctx` contract HTML widget scripts get, typed: `layers`, `viewport`, `selection`, `emit`, `data()`, `dataInViewport()`, `stats()`. Watch tokens: `"viewport"`, `"selection"`, `"layers"`, `"data:<layerId>"`. Rides `useSyncExternalStore` (tearing-safe; `ctx` identity stable until a watched token fires).
@@ -16,8 +16,8 @@ Vite/npm project:
16
16
  Static CDN page (raw-file CDNs only — unpkg/jsDelivr; never esm.sh or another rebundling CDN, which duplicates the WebGL runtime and breaks layer shaders):
17
17
 
18
18
  ```html
19
- <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.6.1/dist/onlymapjs.css">
20
- <script type="module" src="https://unpkg.com/@nika-js/onlymap@0.6.1"></script>
19
+ <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.6.4/dist/onlymapjs.css">
20
+ <script type="module" src="https://unpkg.com/@nika-js/onlymap@0.6.4"></script>
21
21
  ```
22
22
 
23
23
  Always include `onlymapjs.css` — it carries the MapLibre basemap styles and the no-JS fallback rules (`<om-fallback>` / default banner). For the fallback to work in script-disabled previews it must load without JavaScript: a real `<link rel="stylesheet">` or inlined `<style>` on no-build pages (a bundler-emitted stylesheet is fine in npm projects).
@@ -44,6 +44,8 @@ Common attributes:
44
44
  - `license-key="om_live_…"` — lifts the free-plan limits (5 layers, 25k rows per layer — a layer past the layer cap doesn't render, a layer past the row cap renders its first 25k rows plus a dismissible on-map notice; caps apply only on HOSTED http(s) pages — localhost/file:// and other dev contexts run uncapped, badge stays) and removes the corner badge; publishable origin-restricted token, safe in page source (or `OmMap.configureLicense(key)` once). Free-plan violations don't break the map: the offending layer isn't rendered and validation names the limit.
45
45
  - `terrain="terrarium|<preset>|<{z}/{x}/{y} DEM URL>|off"` — 3D elevation surface. `terrarium` is keyless (AWS); `mapterhorn` is also keyless and carries a CARTO Positron drape by default; `maptiler-terrain` needs `basemap-key`/`configureBasemap`; raw DEM URLs need `terrain-decoder` (`terrarium`, `mapbox-rgb`, or `{rScaler,gScaler,bScaler,offset}` JSON). `terrain-exaggeration` scales relief (1 = true); `terrain-max-zoom` = the provider's REAL tileset cap; `terrain-texture` drapes a `{z}/{x}/{y}` imagery template. Geographic layers drape automatically; per-layer `terrain="drape|offset|off"` overrides (3D-model layers default to `offset`). Terrain REPLACES an active basemap while on (restored when off) — validation warns. Register presets with `OmMap.registerTerrain(name, {...})`; `set-terrain` action + `terrain` watch token; attribute-backed (undoable). **BIM requires explicit terrain**: a model that resolves real elevation on a map with no `terrain` attribute raises an ERROR through the validation channel at load time — the library never writes `terrain` for you; author a preset (`mapterhorn` pairs a keyless DEM with a CARTO drape) or an explicit `terrain="off"` for flat-ground siting.
46
46
  - `lighting="daylight|studio|flat|custom"` — scene lighting for 3D content (extruded polygons, models); absent = deck defaults. Preset seeds values; `lighting-ambient`, `lighting-sun` (intensity; 0 removes the sun), `lighting-sun-azimuth` (° CW from north), `lighting-sun-elevation` (° above horizon), `lighting-camera` (model-inspection fill) override individual fields; `lighting-sun-date` (ISO 8601 or epoch ms) computes the sun from solar position at the map center and wins over azimuth/elevation. Attribute-backed: changes are undoable, and the `set-lighting {lighting, sunAzimuth, …}` action makes lighting story-steppable (`lighting="default"` removes the whole attribute set; a bare preset is a clean reset). `<om-widget type="lighting">` is the native UI. Widget scripts can `watch = ["lighting"]`.
47
+ - `clip-box-min="[lng, lat, elevation]"` + `clip-box-max="[lng, lat, elevation]"` (issue #34) — a real axis-aligned 3D box clipping the whole scene: geometry outside it is discarded (order of the two corners doesn't matter). Every layer is clipped once a box is configured; opt a specific layer out with `clip="off"` on its `<om-layer>`. `clip-box-invert` shows what's OUTSIDE the box instead; `clip-box-highlight` dims clipped-out geometry instead of discarding it (non-destructive preview — nothing disappears). Works on ANY layer type, including georeferenced `Tile3DLayer`/`BIMLayer` content, so a dense BIM scene can be cut open to see what's inside — not just flat `GeoJsonLayer` extrusions. Attribute-backed (undoable, story-steppable) via `set-clip-box {min, max, invert?, highlight?}` (`{clear: true}` removes the box) and `<om-widget type="clip-box">` (six number inputs + invert/highlight checkboxes + clear button). v1 is axis-aligned only — rotated boxes are a documented follow-up.
48
+ - `snap="vertex edge midpoint"` + `snap-tolerance="12"` (issue #34 Part A) — XY snapping for draw/measure vertex capture: refines whatever deck already picked under the cursor to the nearest vertex, edge, or edge midpoint of THAT feature's own geometry, within `snap-tolerance` pixels (default 12) — not a spatial index, only the already-picked feature is searched. Applies to every layer by default; `snap="off"` on any `<om-layer>` opts it out, mirroring `clip="off"`. Also snaps to a `BIMLayer`'s own edge/crease overlay (its real wall corners/edges, converted from local mesh coordinates automatically) — the raw triangle mesh itself is not a snap target. Vertex beats midpoint beats edge on range conflicts. Hold Space to place a point nearby without snapping. No action/attribute-backed toggle beyond the attribute itself; live-reactive like `terrain`/`clip-box-*`.
47
49
  - `widgets-dim="off"` disables collision-dim — by default a widget slot dims (`--om-widget-opacity-dimmed`, 0.35) while an open `<om-overlay>` popup covers it, rather than the popup dodging (attribution/toggle slots never dim).
48
50
  - `headless width="800" height="600"` for test harness use
49
51
 
@@ -71,7 +73,7 @@ Core attributes:
71
73
  - `type="ScatterplotLayer"` — any bundled layer type.
72
74
  - `data="./points.json"` — URL, stream, draw store, or omit for inline JSON.
73
75
  - `label="Earthquakes"` and `color="#b30000"` — legend metadata.
74
- - `pickable` — enable click/hover behaviors.
76
+ - `pickable` — enable click/hover behaviors. `pickable="3d"` (issue #34) additionally opts the layer into deck's DEPTH-pick pass, so a click/hover's resolved coordinate carries a real third (elevation) component instead of the ray∩z=0-plane guess a flat pick gives you — a click on a building face resolves ON the face, not on the ground behind it. `terrain` sets this on itself. The elevation flows through `ctx.selection.coordinate` and `{{z}}` in `<om-overlay>`/`show-tooltip` templates; `{{z}}` is ABSENT (not `0`) when no layer in the scene ran the depth pass for that pick, so "no elevation" is distinguishable from sea level.
75
77
  - `visible="false"` or `opacity="0"` — initial visibility/opacity.w
76
78
 
77
79
  Accessors:
@@ -164,7 +166,7 @@ Common choices:
164
166
  - `src` (required) — the GeoTIFF URL. NOT `data`: rasters stream tiles by HTTP Range request through the layer's own reader; they are never parsed rows (`$field`, `ctx.data()`, `ctx.stats()`, filters do not apply).
165
167
  - Sources must be Cloud-Optimized GeoTIFFs (`gdal_translate -of COG` otherwise).
166
168
  - `min`/`max` — the rescale window mapped onto the colormap. Defaults to 0–255, so ALWAYS set them for float or 16-bit data (DEMs, NDVI, temperature).
167
- - `colormap` — single-band ramps from the bundled sprite: `gray` (default), `viridis`, `plasma`, `inferno`, `magma`, `cividis`, `rdylgn`, `rdbu`, `spectral`, `terrain`, `jet`, `turbo`. Sources with 3+ bands composite as RGB and ignore it.
169
+ - `colormap` — single-band ramps from the bundled sprite: `gray` (default), `viridis`, `plasma`, `inferno`, `magma`, `cividis`, `rdylgn`, `rdbu`, `spectral`, `terrain`, `jet`, `turbo`, `ylorrd` (ColorBrewer yellow→orange→red — a gentler sequential ramp than turbo/jet). Sources with 3+ bands composite as RGB and ignore it.
168
170
  - `nodata` — overrides the source's nodata sentinel; nodata pixels render transparent.
169
171
  - Plain 8-bit RGB COGs (satellite truecolor) need no styling attributes at all.
170
172
  - Restretch/recolor (min/max/colormap edits) are GPU uniform updates — tiles are not refetched. The legend widget renders the colormap ramp automatically when `colormap` + `min`/`max` are authored.
@@ -352,9 +354,11 @@ Built-ins:
352
354
  - `scale-bar` — `units="metric|imperial|nautical"` (default metric) picks the length system; snaps to a nice round distance.
353
355
  - `attribution`
354
356
  - `filter`
355
- - `draw`
356
- - `measure` — geodesic ruler: `modes="distance area"` (space-separated; default both), `units="metric|imperial|nautical"`. Click the map to place points; live per-segment + total labels render on the map, and a totals panel + a `units` toggle sit in the widget. Distance is haversine on the WGS84 mean sphere (≤0.56% vs. the true geodesic); area is the spherical-excess integral. Nautical shows nmi for length and falls back to metric for area. Reuses the draw capture stack (measure and draw are mutually exclusive); the geometry is ephemeral (never saved, never an undo step). Consume the reading programmatically via the `om-measure` event on `<om-map>` (`detail = {mode, units, totalMeters, segments, areaMeters2, perimeterMeters, poleWarning}`).
357
+ - `draw` — sketch-capture toolbar: `modes="point line polygon"` (default all three), `target="<name>"` (default `sketch`, bound via `data="draw:<target>"`), `save="both|download|file-system"`, `autosave="<localStorage key>"`. `export-3d` (bare = GLB default, `="b3dm"` wraps it for Cesium/3D-Tiles pipelines) adds an "Export 3D" button (spec: issue #34 — region export) — deliberately separate from `save` (that's the drawn shape's own GeoJSON; `export-3d` exports the 3D `Tile3DLayer`/`BIMLayer` content found INSIDE the drawn footprint). Outline a polygon over loaded 3D content, close it, click "Export 3D": clips every loaded tile's triangles to the footprint (a plain 2D clip — no elevation-picking involved), re-frames them to a local coordinate frame at the footprint's own centroid (portable — opens correctly in Blender/three.js/etc. without ECEF-scale support), and downloads it, each triangle carrying its own source color (baked as vertex colors). No textures — BIM/IFC materials are flat colors, not textured meshes. The export only pulls in currently-VISIBLE 3D Tiles/BIM layers — one hidden via `visible="false"` (or the `toggle-layer` action) is excluded, with a distinct console warning distinguishing "nothing has loaded yet" from "everything loaded is hidden." Validation warns on an unrecognized `export-3d` value.
358
+ - `clip-box` — native UI over the map's `clip-box-*` scene-state attributes (see the `<om-map>` section above): six number inputs (min/max × lng/lat/elevation), invert/highlight checkboxes, and a clear button, all wired through `set-clip-box`. Manifest is the source of truth the panel re-syncs from the attributes on every render, so undo/redo and story-scrub move the inputs too.
359
+ - `measure` — geodesic ruler: `modes="distance area volume"` (space-separated; default `distance area`), `units="metric|imperial|nautical"`. Click the map to place points; live per-segment + total labels render on the map, and a totals panel + a `units` toggle sit in the widget. Distance is haversine on the WGS84 mean sphere (≤0.56% vs. the true geodesic); area is the spherical-excess integral. Nautical shows nmi for length and falls back to metric for area. Reuses the draw capture stack (measure and draw are mutually exclusive); the geometry is ephemeral (never saved, never an undo step). Consume the reading programmatically via the `om-measure` event on `<om-map>` (`detail = {mode, units, totalMeters, segments, areaMeters2, perimeterMeters, poleWarning, cutMeters3, fillMeters3, netMeters3, totalMeters3, cutAdjustedMeters3, fillAdjustedMeters3, swell, shrink, cutMassKg, fillMassKg, cellSizeM, gsdM, cutErrorM3, fillErrorM3, nodataFraction, baseSurface, stale, profileSeries}` — `profileSeries` points are `{x: metres from the first vertex, y: elevation m}`, with `vertexIndex` present only on samples that ARE a drawn corner (filter a chart on `isValid(datum.vertexIndex)`; vertex 0 is the leftmost point, and with `profile` on the map badges the first two vertices `1 · Start` and `2` in draw order — 1-based display, field stays 0-based — so clockwise vs counter-clockwise is stated, not inferred; two is the minimum that fixes a direction and stays constant however many corners there are, while the chart marks them all) — everything from `cutMeters3` on is volume-mode-only, populating once a footprint closes). `volume` mode outlines a polygon footprint the same way `area` does — double-click (or Enter) closes it, and it turns solid teal to signal it's ready — then a double-headed arrow gizmo (fixed screen-pixel size, unbounded drag distance) appears at the centroid: drag it up to fill, down to cut, panel reads Cut/Fill/Net (signed, fill−cut)/Total (unsigned, cut+fill) volume + Area/Perimeter live — always RAW geometric figures, never altered by `swell`/`shrink`. REQUIRES `terrain` on `<om-map>` (validation warns a `volume` mode with none): the math is a REAL per-cell grid integration (issue #35) — closing a footprint bulk-loads its covering DEM tiles and integrates terrain-vs-base per cell on a metric tangent-plane grid (cell size = the DEM's GSD at the ring's latitude, scanline point-in-polygon, bilinear tile-seam-correct sampling, worker-offloaded with a synchronous fallback), reporting mixed cut AND fill within one footprint on undulating ground plus `cellSizeM`/`gsdM`/`cutErrorM3`/`fillErrorM3` (± = per-cell cellArea × 1.5 × GSD, summed per side) and `nodataFraction` on the readout. `base-surface` picks the reference surface: `custom` (default — the gizmo's draggable target plane, re-summed live from the cached grid during a drag) or boundary-derived stockpile strategies with NO gizmo (`triangulated` boundary TIN — the drone-survey default, `plane` least-squares, `lowest`/`highest`/`average`). No terrain (or a failed tile fetch) falls back to the flat single-elevation approximation with no error figures rather than erroring. Five more volume-only attributes, all no-ops without `volume` in `modes` (validation warns): `base-surface` (above); `profile` — closing a footprint also samples elevation around its own perimeter, dispatched on `profileSeries` for a paired `dynamic-chart` widget to plot, updating live from the first vertex (debounced on hover, immediate on each new vertex) while sketching, not just on close; `deadband` (m³, default 0) — zeroes a Cut/Fill figure below the threshold; `density` (t/m³ metric, lb/yd³ imperial) and `swell`/`shrink` (multipliers, default 1×) populate a SEPARATE Material section instead of touching Cut/Fill/Net/Total — standard Bank/Loose/Compacted convention: `cutAdjustedMeters3` = raw cut × swell (loose/haul volume, bigger — excavating adds air voids), `fillAdjustedMeters3` = raw fill ÷ shrink (loose/borrow volume needed, also bigger — raw fill is already a compacted target void), `cutMassKg`/`fillMassKg` from the RAW volume (mass-conserving — swell/shrink change volume via air voids, not the mass of material). The widget only renders the Material section once at least one of `density`/`swell`/`shrink` is configured — no separate toggle. `stale` flags the brief window between a footprint committing and its elevation sample resolving.
357
360
  - `vega-lite`
361
+ - `dynamic-chart` — same Vega-Lite rendering as `vega-lite`, but data-driven by a live DOM event instead of a layer/`ctx.data`: `on="<event-name>"` (required — the event to listen for on `<om-map>`), `series-field="<name>"` (default `series`) reads `event.detail[seriesField]` as the chart's `data.values` and re-embeds on every event where that field is a present array; `width` (fixed, default 280) and `title` work the same as `vega-lite`. The child `<script type="application/json">` spec is the same Vega-Lite mark/encoding shape, minus `data` (supplied live). A feature "freezes" the chart for free by simply not including the field on a later event (e.g. switching modes) — the widget has no separate pause API, it just does nothing when the field is absent. Built for a feature that computes its own series as the user interacts (a drawn line's elevation profile updating vertex-by-vertex) and has no layer of its own to bind to.
358
362
  - `player`
359
363
  - `basemap-switcher` — radio list of presets; `options="positron dark-matter osm"` (default: every keyless registered preset)
360
364
  - `lighting` — scene-lighting controller: preset radios (Off/daylight/studio/flat/custom) + ambient/sun/azimuth/elevation/camera sliders, all over the lighting* attributes via `set-lighting` (undoable; re-syncs when anything else writes them). A bare preset click is a clean RESET (stale lighting-* overrides removed); a slider edit flips to `custom` and sets only the touched key.
@@ -500,10 +504,15 @@ Selection scoping (both only apply with `anchor-from="selection"`):
500
504
  - `layer="quakes"` — only that layer's picks move/re-template the overlay.
501
505
  - `selection-type="click"` (or `"hover"`) — only that pick type does. A click-opened popup NEEDS `selection-type="click"`: without it, merely hovering any pickable feature drags the popup there and re-interpolates its template against the hovered object (wrong-layer ghost popup). With it, hover is inert and a click on empty space still dismisses. `"hover"` is the mirror for hover-driven overlays.
502
506
 
507
+ Viewport clipping:
508
+
509
+ - `clip-to-map` — hide the overlay when its own BOX would spill past the map viewport, not just when its anchor leaves (the default). Opt-in, because an overhanging absolutely-positioned box inflates the page's scrollable overflow, and the resulting scrollbar → map resize → reprojection loop shows as view jitter. Use it for small transient tips that track the cursor; an authored popup near an edge normally wants to keep showing its visible half.
510
+
503
511
  Templates:
504
512
 
505
513
  - `{{field}}` HTML-escaped interpolation.
506
514
  - `{{{field}}}` raw HTML; avoid unless trusted.
515
+ - `{{z}}` — the pick's elevation in meters, present only when a `pickable="3d"` layer ran the depth pass for it (see `<om-layer>`'s `pickable` above). Absent, not `0`, otherwise.
507
516
 
508
517
  Example:
509
518
 
@@ -559,6 +568,9 @@ Common built-in actions:
559
568
  - story actions: `story-play`, `story-pause`, `story-seek`
560
569
  - effect actions: `fade`, `pulse`, `trace`, `populate`
561
570
  - draw actions: `draw-mode`, `draw-commit`, `draw-cancel`, `draw-delete`, `draw-clear`, `draw-config`, `draw-save`
571
+ - measure actions: `measure-mode` (`{mode: "distance"|"area"|"volume"|null}`), `measure-units` (`{units: "metric"|"imperial"}`), `measure-clear`, `measure-config` (`{profile?, baseSurface?, density?, swell?, shrink?, deadband?}`), `measure-flat-target-plane` (`{flat}` — volume mode's target-surface switch)
572
+ - clip box: `set-clip-box` (`{min, max, invert?, highlight?}`, or `{clear: true}` to remove it), `clip-box-edit` (`{editing}` — shows/hides the draggable face gizmos)
573
+ - region export: `export-region-3d` (`{target?: "sketch", format?: "glb"|"b3dm"}`) — clips the drawn footprint's 3D content and downloads it; this is what the `draw` widget's `export-3d` button emits
562
574
 
563
575
  Payload attributes are kebab-case and become camelCase payload keys.
564
576
 
@@ -806,7 +818,7 @@ builds internally:
806
818
  ```
807
819
 
808
820
  Reach for `BIMLayer` when the model is fixed and known ahead of time
809
- (`dev/examples/bim-sample.html`); reach for the `ifc-loader` widget (BIM
821
+ (`dev/examples/features/terrain-3d/inspect-a-bim-model.html`); reach for the `ifc-loader` widget (BIM
810
822
  workbench) when a visitor picks the file, or when several models need to
811
823
  federate into one coordinated scene. **Known gap:** the outline overlay does
812
824
  not yet follow isolate/hide/ghost the way the mesh does — `ifc-browser`'s
@@ -822,7 +834,7 @@ declarative isolate/hide/ghost attributes work on it unchanged.
822
834
 
823
835
  Most pages should not call it directly — `<om-widget type="ifc-loader">` owns
824
836
  the drop zone, the call, the layer elements, the camera and the blob-URL
825
- lifetime. `examples/bim-workbench.html` is the whole workflow — load, browse, coordinate — in five widget tags.
837
+ lifetime. `examples/features/terrain-3d/inspect-a-bim-model.html` is the whole workflow — a model on load, a drop zone for more, browse and coordinate — in five widget tags.
826
838
 
827
839
  ```js
828
840
  import { loadIfc, configureIfc } from "@nika-js/onlymap";
@@ -61,6 +61,12 @@ expect(OmMap.snapshotIR(html)).toMatchSnapshot();
61
61
 
62
62
  The snapshot contains resolved layer descriptors. Accessors appear as behavioral fingerprints, so expression changes show up in diffs without serializing functions.
63
63
 
64
+ For programmatic or native JSON descriptors (the `MapController.setLayers()` lane, not a manifest string), use `OmMap.snapshotDescriptorIR(descriptors)`. Same schema/accessor/filter resolution, same fingerprints, and it never fetches URL-backed data — so a native/cross-process parity check is deterministic and offline.
65
+
66
+ ```ts
67
+ expect(OmMap.snapshotDescriptorIR(descriptors)).toMatchSnapshot();
68
+ ```
69
+
64
70
  ## Headless Behavior Harness
65
71
 
66
72
  Use `mountForTest` for most interaction tests.