@nika-js/onlymap 0.6.2 → 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 (33) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +2 -2
  3. package/dist/{LercDecode.es-BOJJr6Gx.js → LercDecode.es-D5in29tf.js} +1 -1
  4. package/dist/{basemap-BofXgXxy.js → basemap-C0pFT3AO.js} +1 -1
  5. package/dist/data-layer.d.ts +26 -4
  6. package/dist/elements/om-map.d.ts +1 -0
  7. package/dist/geodesy.d.ts +3 -0
  8. package/dist/{geoparquet-DapHATA0.js → geoparquet-By98JVB0.js} +1 -1
  9. package/dist/{index-BmX6IId3.js → index-Bx9GFkrn.js} +1 -1
  10. package/dist/{index-SPJVn_n_.js → index-CqC4sW_k.js} +1 -1
  11. package/dist/{index-7B-6Cbzu.js → index-Cxo9mCw_.js} +12920 -12806
  12. package/dist/{index-BF9iO8Tq.js → index-olfncHIq.js} +1 -1
  13. package/dist/{index-xmJjZxQJ.js → index-tnlYDALL.js} +2 -2
  14. package/dist/index.d.ts +3 -1
  15. package/dist/{lerc-C6k7EzSN.js → lerc-gKDDtc69.js} +2 -2
  16. package/dist/license.d.ts +16 -2
  17. package/dist/measure-controller.d.ts +28 -4
  18. package/dist/onlymap.standalone.js +22003 -21889
  19. package/dist/onlymapjs.js +33 -32
  20. package/dist/programmatic.d.ts +18 -3
  21. package/dist/{raster-Cuzhibe2.js → raster-Cl5m3KsC.js} +2 -2
  22. package/dist/{raster-pipeline-DLrnJK8y.js → raster-pipeline-hJGxIwYx.js} +1 -1
  23. package/dist/version.d.ts +1 -1
  24. package/dist/{zarr-CUzEX8fB.js → zarr-hRDavRGV.js} +2 -2
  25. package/docs/3d-assets.md +1 -1
  26. package/docs/live-data.md +17 -0
  27. package/docs/react.md +9 -1
  28. package/llms.txt +2 -2
  29. package/package.json +1 -1
  30. package/skills/onlymapjs/SKILL.md +3 -3
  31. package/skills/onlymapjs/references/react.md +1 -1
  32. package/skills/onlymapjs/references/syntax.md +3 -3
  33. package/skills/onlymapjs/references/testing.md +6 -0
package/dist/onlymapjs.js CHANGED
@@ -1,4 +1,4 @@
1
- import { aA as e, aB as r, aC as t, aD as o, aE as b, aF as i, ap as l, aG as n, aH as S, aI as E, aJ as T, aK as L, aL as g, aM as c, aN as A, aO as p, aP as m, aQ as _, aR as d, aS as I, aT as y, aU as M, aV as u, aW as D, ao as O, aX as f, aw as N, aY as R, aZ as P, a_ as h, W as F, a$ as B, b0 as C, b1 as G, b2 as U, b3 as W, b4 as v, b5 as x, b6 as w, b7 as Y, b8 as H, b9 as X, ba as k, bb as J, bc as K, bd as V, be as z, bf as Q, bg as Z, bh as $, bi as j, bj as q, bk as aa, bl as sa, bm as ea, bn as ra, bo as ta, bp as oa, bq as ba, br as ia, bs as la, bt as na, bu as Sa, bv as Ea, bw as Ta, bx as La, by as ga, bz as ca, bA as Aa, bB as pa, bC as ma, bD as _a, bE as da, bF as Ia, bG as ya, bH as Ma, bI as ua, bJ as Da, bK as Oa, bL as fa, bM as Na, bN as Ra, bO as Pa, bP as ha, bQ as Fa, bR as Ba, bS as Ca, bT as Ga, bU as Ua, bV as Wa, bW as va, bX as xa, bY as wa, bZ as Ya, b_ as Ha, b$ as Xa, c0 as ka, c1 as Ja, c2 as Ka } from "./index-7B-6Cbzu.js";
1
+ import { aA as e, aB as r, aC as t, aD as o, aE as b, aF as i, ap as l, aG as n, aH as S, aI as E, aJ as T, aK as L, aL as c, aM as g, aN as A, aO as p, aP as m, aQ as _, aR as d, aS as I, aT as y, aU as D, aV as M, aW as u, ao as O, aX as f, aw as N, aY as R, aZ as P, a_ as h, W as F, a$ as B, b0 as C, b1 as G, b2 as U, b3 as W, b4 as v, b5 as w, b6 as x, b7 as Y, b8 as H, b9 as X, ba as k, bb as J, bc as K, bd as V, be as z, bf as Q, bg as Z, bh as $, bi as j, bj as q, bk as aa, bl as sa, bm as ea, bn as ra, bo as ta, bp as oa, bq as ba, br as ia, bs as la, bt as na, bu as Sa, bv as Ea, bw as Ta, bx as La, by as ca, bz as ga, bA as Aa, bB as pa, bC as ma, bD as _a, bE as da, bF as Ia, bG as ya, bH as Da, bI as Ma, bJ as ua, bK as Oa, bL as fa, bM as Na, bN as Ra, bO as Pa, bP as ha, bQ as Fa, bR as Ba, bS as Ca, bT as Ga, bU as Ua, bV as Wa, bW as va, bX as wa, bY as xa, bZ as Ya, b_ as Ha, b$ as Xa, c0 as ka, c1 as Ja, c2 as Ka, c3 as Va } from "./index-Cxo9mCw_.js";
2
2
  export {
3
3
  e as ALL_POSITION_VALUES,
4
4
  r as AUDIT_EXEMPTIONS,
@@ -12,8 +12,8 @@ export {
12
12
  E as DRONE_SENSOR_DATABASE,
13
13
  T as EDGE_TOLERANCE_PX,
14
14
  L as FOLD_HYSTERESIS_PX,
15
- g as FOLD_SIDES,
16
- c as GeoJsonLayer,
15
+ c as FOLD_SIDES,
16
+ g as GeoJsonLayer,
17
17
  A as IconLayer,
18
18
  p as LEGACY_POSITION_ALIASES,
19
19
  m as LIGHTING_PRESET_NAMES,
@@ -21,9 +21,9 @@ export {
21
21
  d as LayerExtension,
22
22
  I as MANAGED_SLOTS,
23
23
  y as MapController,
24
- M as OmMap,
25
- u as ScatterplotLayer,
26
- D as ScenegraphLayer,
24
+ D as OmMap,
25
+ M as ScatterplotLayer,
26
+ u as ScenegraphLayer,
27
27
  O as SimpleMeshLayer,
28
28
  f as Tile3DLayer,
29
29
  N as TileLayer,
@@ -37,8 +37,8 @@ export {
37
37
  U as compileExpression,
38
38
  W as compileFullJsAccessorBlockInSandbox,
39
39
  v as computeTimeline,
40
- x as configureBasemap,
41
- w as configureData,
40
+ w as configureBasemap,
41
+ x as configureData,
42
42
  Y as configureIfc,
43
43
  H as configureLicense,
44
44
  X as configureTelemetry,
@@ -66,8 +66,8 @@ export {
66
66
  Ea as midpoint,
67
67
  Ta as mountForTest,
68
68
  La as niceNumber,
69
- ga as normalizeData,
70
- ca as parseDurationMs,
69
+ ca as normalizeData,
70
+ ga as parseDurationMs,
71
71
  Aa as parseTerrainAttrs,
72
72
  pa as parseUnitSystem,
73
73
  ma as parseWidgetStyle,
@@ -75,28 +75,29 @@ export {
75
75
  da as registerAction,
76
76
  Ia as registerBasemap,
77
77
  ya as registerFormat,
78
- Ma as registerLayer,
79
- ua as registerSource,
80
- Da as registerTerrain,
78
+ Da as registerLayer,
79
+ Ma as registerSource,
80
+ ua as registerTerrain,
81
81
  Oa as registerWidget,
82
- fa as resolveFoldBreakpointPx,
83
- Na as resolveImageOverlay,
84
- Ra as resolveLighting,
85
- Pa as resolveSlot,
86
- ha as ringAreaMeters2,
87
- Fa as ringEnclosesPole,
88
- Ba as ringPerimeterMeters,
89
- Ca as rowAt,
90
- Ga as sandboxHtml,
91
- Ua as scaleBarStep,
92
- Wa as setMeasureRadiusMeters,
93
- va as settleLayout,
82
+ fa as releaseDataOwner,
83
+ Na as resolveFoldBreakpointPx,
84
+ Ra as resolveImageOverlay,
85
+ Pa as resolveLighting,
86
+ ha as resolveSlot,
87
+ Fa as ringAreaMeters2,
88
+ Ba as ringEnclosesPole,
89
+ Ca as ringPerimeterMeters,
90
+ Ga as rowAt,
91
+ Ua as sandboxHtml,
92
+ Wa as scaleBarStep,
93
+ va as setMeasureRadiusMeters,
94
+ wa as settleLayout,
94
95
  xa as shouldFoldAtWidth,
95
- wa as slotContainerStyle,
96
- Ya as slotLayout,
97
- Ha as snapshotDescriptorIR,
98
- Xa as snapshotIR,
99
- ka as solarAzElDegrees,
100
- Ja as validateManifest,
101
- Ka as validateManifestString
96
+ Ya as slotContainerStyle,
97
+ Ha as slotLayout,
98
+ Xa as snapshotDescriptorIR,
99
+ ka as snapshotIR,
100
+ Ja as solarAzElDegrees,
101
+ Ka as validateManifest,
102
+ Va as validateManifestString
102
103
  };
@@ -22,6 +22,7 @@ import { type TokenStore, type ViewOrigin, type ViewportSnapshot, type DataVersi
22
22
  import type { LayerIR } from "./ir";
23
23
  import { type RuntimeContext } from "./ctx";
24
24
  import type { LayerMetaSnapshot } from "./ctx";
25
+ import { type DataTransportOwner } from "./data-layer";
25
26
  import type { Selection } from "./selection";
26
27
  import type { ValidationEntry } from "./validation";
27
28
  import type { MapViewport } from "./basemap";
@@ -128,8 +129,18 @@ export interface MapControllerOptions {
128
129
  /** @internal Stable React-owned hosts that place required chrome in managed slots. */
129
130
  chromeHosts?: MandatedChromeHosts;
130
131
  }
131
- /** Descriptor → layer IR: the programmatic counterpart of one parse-manifest.ts loop iteration. */
132
- export declare function descriptorToIR(desc: LayerDescriptor, onDataLoaded: () => void): LayerIR | null;
132
+ /**
133
+ * Descriptor layer IR: the programmatic counterpart of one parse-manifest.ts
134
+ * loop iteration.
135
+ *
136
+ * Pass `owner` (any stable object identity — a host controller, an element) to
137
+ * put a URL-backed descriptor's fetch/poll/socket under descriptor-owned
138
+ * lifetime; `releaseDataOwner(owner)` then stops everything that owner holds.
139
+ * Without one the transport is un-owned: it is still shared by identity and
140
+ * still notifies, but it holds no reference count, so a real owner's release
141
+ * can stop it. `MapController` owns its own transports and does not need this.
142
+ */
143
+ export declare function descriptorToIR(desc: LayerDescriptor, onDataLoaded: () => void, owner?: DataTransportOwner): LayerIR | null;
133
144
  /**
134
145
  * Resolve JSON-safe/programmatic descriptors to deterministic snapshots.
135
146
  * URL data is represented as an empty pending layer and is never fetched.
@@ -292,7 +303,11 @@ export declare class MapController {
292
303
  flyToBounds(bounds: [[number, number], [number, number]], padding?: number, opts?: CameraOptions): void;
293
304
  setView(partial: Partial<CameraState>, opts?: CameraOptions): void;
294
305
  getViewState(): CameraState;
295
- /** Pause descriptor-owned live transports while retaining canonical map state. */
306
+ /**
307
+ * Pause descriptor-owned live transports while retaining canonical map state.
308
+ * `retain` because this pause is meant to be reversed: `resume()` repaints the
309
+ * last rows immediately rather than showing an empty map while it reconnects.
310
+ */
296
311
  suspend(): void;
297
312
  /** Reacquire current descriptor transports and reconcile after foregrounding. */
298
313
  resume(): void;
@@ -1,4 +1,4 @@
1
- import { c as re, t as Ae, i as nt, a as Re, s as rt, C as ot, b as it, F as at, A as st, d as lt, R as he, e as ct, p as ut, m as dt, f as ht, g as pt, h as gt } from "./raster-pipeline-DLrnJK8y.js";
1
+ import { c as re, t as Ae, i as nt, a as Re, s as rt, C as ot, b as it, F as at, A as st, d as lt, R as he, e as ct, p as ut, m as dt, f as ht, g as pt, h as gt } from "./raster-pipeline-hJGxIwYx.js";
2
2
  import pe from "./index-CW1n5LdO.js";
3
3
  function mt(e, t) {
4
4
  const n = e.length / 3, r = new Uint8ClampedArray(n * 4), o = 0, i = n, a = n * 2;
@@ -1069,7 +1069,7 @@ A.set(m.Zstd, () => import("./zstd-jXobGRcq.js").then((e) => e.decode));
1069
1069
  A.set(m.Jpeg, () => Promise.resolve(oe));
1070
1070
  A.set(m.Jpeg6, () => Promise.resolve(oe));
1071
1071
  A.set(m.Webp, () => Promise.resolve(oe));
1072
- A.set(m.Lerc, () => import("./lerc-C6k7EzSN.js").then((e) => e.l).then((e) => e.decode));
1072
+ A.set(m.Lerc, () => import("./lerc-gKDDtc69.js").then((e) => e.l).then((e) => e.decode));
1073
1073
  async function ie(e, t, n) {
1074
1074
  const r = A.get(t);
1075
1075
  if (!r)
@@ -1,5 +1,5 @@
1
1
  import { w as ae } from "./mgrs-BY9bIvp4.js";
2
- import { am as ce, an as le, ao as ee, ap as te, aq as ue, b as he, ar as de, l as Z, as as fe, d as pe, at as me, au as ge, av as ve, aw as ne } from "./index-7B-6Cbzu.js";
2
+ import { am as ce, an as le, ao as ee, ap as te, aq as ue, b as he, ar as de, l as Z, as as fe, d as pe, at as me, au as ge, av as ve, aw as ne } from "./index-Cxo9mCw_.js";
3
3
  function Pe(o, e, t) {
4
4
  const { projectedCorners: n } = e, { topLeft: s, topRight: r, bottomRight: a, bottomLeft: i } = n, c = t(s[0], s[1]), u = t(r[0], r[1]), l = t(a[0], a[1]), d = t(i[0], i[1]), f = [
5
5
  c,
package/dist/version.d.ts CHANGED
@@ -5,4 +5,4 @@
5
5
  * the build rootDir, and a `define` would need repeating across vite/vitest/
6
6
  * vite-node configs.
7
7
  */
8
- export declare const LIBRARY_VERSION = "0.6.2";
8
+ export declare const LIBRARY_VERSION = "0.6.4";
@@ -1,5 +1,5 @@
1
- import { A as ur, d as lr, R as zt, e as fr, m as dr, p as St, f as hr, g as pr, h as mr } from "./raster-pipeline-DLrnJK8y.js";
2
- import { ap as gr } from "./index-7B-6Cbzu.js";
1
+ import { A as ur, d as lr, R as zt, e as fr, m as dr, p as St, f as hr, g as pr, h as mr } from "./raster-pipeline-hJGxIwYx.js";
2
+ import { ap as gr } from "./index-Cxo9mCw_.js";
3
3
  import $t from "./index-CW1n5LdO.js";
4
4
  var Et;
5
5
  function h(e, t, n) {
package/docs/3d-assets.md CHANGED
@@ -181,7 +181,7 @@ Face counts are long-tailed, so the building count you can fit is not predictabl
181
181
 
182
182
  ### Visible edges in surfaces mode
183
183
 
184
- Surfaces mode is flat-shaded, so adjacent faces at similar heights (e.g. a LoD2.2 hip roof's planes) can be hard to tell apart by color alone. Each row carries an `outline` field — the same face's outer ring, flattened and closed — for exactly this: give a `PathLayer` `get-path="$outline"` and it traces real per-face edges. Always pair one with a surfaces-mode `SolidPolygonLayer`; deck's `wireframe` prop on `SolidPolygonLayer` only builds wireframe geometry when `extruded: true`, so it does nothing in this unextruded mode. Point the `PathLayer` at the same `data` URL (the cache is keyed by URL, so this doesn't cost a second fetch) and mirror the fill layer's `filter-field`/`filter-range` so filtered-out buildings' outlines disappear too.
184
+ Surfaces mode is flat-shaded, so adjacent faces at similar heights (e.g. a LoD2.2 hip roof's planes) can be hard to tell apart by color alone. Each row carries an `outline` field — the same face's outer ring, flattened and closed — for exactly this: give a `PathLayer` `get-path="$outline"` and it traces real per-face edges. Always pair one with a surfaces-mode `SolidPolygonLayer`; deck's `wireframe` prop on `SolidPolygonLayer` only builds wireframe geometry when `extruded: true`, so it does nothing in this unextruded mode. Point the `PathLayer` at the same `data` URL (the cache is keyed by URL plus live-source options, so two plain layers on one URL share a single fetch — give one of them a `refresh`/stream attribute the other lacks and they become separate requests) and mirror the fill layer's `filter-field`/`filter-range` so filtered-out buildings' outlines disappear too.
185
185
 
186
186
  The semantics still do work in both modes: they decide which surfaces count as roof, so these are real roof measurements rather than bounding-box numbers. Derived names win over a same-named source attribute, so manifests can rely on them.
187
187
 
package/docs/live-data.md CHANGED
@@ -86,6 +86,23 @@ handles while preserving descriptors and rendered state;
86
86
  This is the intended app-background/app-foreground integration for native
87
87
  hosts.
88
88
 
89
+ A transport released by an owner that means to come back leaves its **last rows
90
+ behind as a cold snapshot**, keyed by the same transport identity. Re-acquiring
91
+ it — `resume()`, or re-adding a layer you removed — repaints those rows
92
+ immediately instead of flashing an empty layer while the first fetch or stream
93
+ message arrives; on a 30-second feed that gap would otherwise be 30 seconds of
94
+ blank map after every foreground. A keyed stream also restores its upsert set,
95
+ so the next message merges rather than replacing.
96
+
97
+ Only reversible releases retain. `suspend()` and layer removal/re-pointing keep
98
+ their rows; `MapController.destroy()` and an `<om-map>` leaving the document
99
+ keep nothing, since neither can re-acquire. (Re-parenting a live `<om-map>`
100
+ isn't a teardown at all — the release is deferred a microtask, so the transport
101
+ never stops.) The snapshot is cold, not live: the first real response supersedes
102
+ it, and polling readiness (`om-map-ready`) still waits for that response.
103
+ Nothing is retained for a transport still held by another owner — it was never
104
+ stopped.
105
+
89
106
  ## Testing live layers
90
107
 
91
108
  In the [headless harness](testing.md), mock the transport: `vi.stubGlobal("fetch", ...)` for polling (readiness waits for your mock), or `vi.stubGlobal("WebSocket", ...)` for streams. The library's own test suites are the reference patterns — including asserting *movement* (fixed entity count, changing coordinates) rather than just presence.
package/docs/react.md CHANGED
@@ -60,7 +60,15 @@ Actions that mutate manifest attributes (`toggle-layer`, `show-overlay`, `fade`,
60
60
  | `onReady` | Renderer up + first commit + no data URL still loading. |
61
61
  | `onViewStateChange` | Every camera change, with the current `CameraState`. |
62
62
  | `onRuntimeError` | deck.gl-level failures in the structured validation shape. |
63
- | `ref` | The imperative handle — a `MapController`: `flyTo`, `setView`, `emit`, `getLayers`, `injectPick`, `ready`. |
63
+ | `ref` | The imperative handle — a `MapController`: `flyTo`, `setView`, `emit`, `getLayers`, `injectPick`, `ready`, `suspend`/`resume`. |
64
+
65
+ `suspend()` releases the map's live fetch/poll/socket handles without discarding
66
+ its layers or camera — the app-background hook for a WebView or native shell.
67
+ `resume()` reacquires them and repaints the last rows straight away, so a
68
+ foregrounded map is never blank while the first response is in flight. Unmounting
69
+ `<OmMap>` releases everything permanently; unmounting a single `<OmLayer>`
70
+ releases just that layer's handle, and the transport stops once its last owner
71
+ lets go. See [live-data.md](live-data.md).
64
72
 
65
73
  Give it a size (`style`/`className`) — it renders a `position: relative` div.
66
74
 
package/llms.txt CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  OnlyMapJS is NOT raw deck.gl and NOT generic HTML/JSX. The rules below are the delta from what you already assume — following them produces correct manifests on the first pass. Validate with `OmMap.validate(htmlString)` (structured errors AND warnings, each with a `fix` instruction — heed both; an "unknown attribute" warning means a prop is silently dropped) and inspect resolved output with `OmMap.snapshotIR(htmlString)` before finalizing.
6
6
 
7
- Programmatic/native bridge rule: `MapController.setLayers()` accepts normal function accessors and, only for schema-declared accessor props, restricted expression strings (`props: {getPosition: "[$lon, $lat]"}`) so a descriptor can cross JSON safely. Verify that lane with `OmMap.snapshotDescriptorIR(descriptors)`; URL data is represented as pending and is not fetched. Active descriptors own reference-counted data transports: removal/identity change/destroy releases them; `MapController.suspend()` releases live work and `resume()` reacquires it. For app-scoped packaged licenses, `configureLicense(key, {appId})` takes an exact platform identifier obtained by the native host — never copy an app id from page/bridge input.
7
+ Programmatic/native bridge rule: `MapController.setLayers()` accepts normal function accessors and, only for schema-declared accessor props, restricted expression strings (`props: {getPosition: "[$lon, $lat]"}`) so a descriptor can cross JSON safely. Verify that lane with `OmMap.snapshotDescriptorIR(descriptors)`; URL data is represented as pending and is not fetched. Active descriptors own reference-counted data transports: removal/identity change/destroy releases them; `MapController.suspend()` releases live work and `resume()` reacquires it, repainting the last rows so a foregrounded map is never blank while the first response is in flight. Live sockets and poll loops no longer outlive their layer — code that relied on that must keep the layer mounted. For app-scoped packaged licenses, `configureLicense(key, {appId})` takes an exact platform identifier obtained by the native host — never copy an app id from page/bridge input, and prefer keys scoped by both `domains` and `apps`, since only the domain claim is pinned by the browser.
8
8
 
9
9
  ## Loading the library
10
10
 
@@ -29,7 +29,7 @@ Programmatic/native bridge rule: `MapController.setLayers()` accepts normal func
29
29
  - To capture where the user CLICKS on the map (a measure tool, drop-a-pin, a custom rectangle/circle AOI, snap-to-feature), listen for the `om-map-point` event on `<om-map>`: `mapEl.addEventListener('om-map-point', e => { const { coordinate, kind } = e.detail; })` — `coordinate` is `[lng,lat]` (or `null` off-globe), `kind` is `"click"`|`"hover"`, and it fires on every click/hover including empty-map clicks. Do NOT reach for deck.gl internals (`mapEl.getMap()`, `.deckInstance`, `.deck.viewManager`) or unproject canvas pixels — those are not exposed on `<om-map>` and return nothing. The built-in `draw` widget handles polygon/line/point sketching; `om-map-point` is for tools it doesn't cover.
30
30
  - `<om-widget type="draw" modes="point line polygon" target="sketch" save="both" autosave="<key>">` is the sketch-capture toolbar — `target` binds the store a `data="draw:<target>"` layer reads. `export-3d` (bare = GLB, `="b3dm"` for Cesium/3D-Tiles pipelines) adds an "Export 3D" button (issue #34), separate from `save` (that's the drawn shape's own GeoJSON): outline a polygon over loaded `Tile3DLayer`/`BIMLayer` content, close it, and it clips every loaded tile's triangles to that footprint (a plain 2D clip, no elevation-picking involved), re-frames them to a local coordinate frame at the footprint's centroid, and downloads it, each triangle carrying its own source color (vertex colors) — no textures (BIM/IFC materials are flat colors, not textured meshes). Only currently-VISIBLE 3D Tiles/BIM layers are included — `visible="false"` (or `toggle-layer`) excludes a layer, with distinct console warnings for "nothing loaded" vs. "everything hidden." Validation warns on an unrecognized `export-3d` value.
31
31
  - Clip box (issue #34): `<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 — geometry outside it discarded, every layer clipped by default (`clip="off"` on an `<om-layer>` opts out), works on ANY layer type including georeferenced `Tile3DLayer`/`BIMLayer` content (not just flat `GeoJsonLayer` extrusions). `clip-box-invert` shows outside instead of inside; `clip-box-highlight` dims clipped-out geometry instead of discarding it (non-destructive preview). Attribute-backed (undoable, story-steppable) via `set-clip-box {min, max, invert?, highlight?}` (`{clear:true}` removes it) and `<om-widget type="clip-box">` (six number inputs + invert/highlight checkboxes + clear button). v1 is axis-aligned only — rotation is a documented follow-up.
32
- - `<om-widget type="measure" modes="distance area volume" units="metric|imperial|nautical">` is the geodesic ruler: click to place points, live labels + a totals panel, read the value programmatically via the `om-measure` event (`detail.mode`/`.totalMeters`/`.areaMeters2`/`.perimeterMeters`/`.cutMeters3`/`.fillMeters3`/`.netMeters3`/`.totalMeters3`/`.cutAdjustedMeters3`/`.fillAdjustedMeters3`/`.cutMassKg`/`.fillMassKg`/`.stale`/`.profileSeries`). `volume` outlines a footprint like `area` (close it with a double-click/Enter — it turns solid teal, "ready"), then a fixed-screen-pixel-size double-headed arrow gizmo appears at the centroid: drag up to fill, down to cut (unbounded distance), reading Cut/Fill/Net (signed, fill−cut)/Total (unsigned, cut+fill) — always RAW geometric volumes, never altered by `swell`/`shrink`. It REQUIRES `terrain` on `<om-map>` (validation warns a `volume` mode with none — cut/fill against flat ground with no elevation surface has nothing to measure against). The math is a REAL per-cell grid integration: 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, scanline point-in-polygon, bilinear seam-correct sampling, worker-offloaded) — mixed cut AND fill in one footprint on undulating ground, with `cellSizeM`/`gsdM`/`cutErrorM3`/`fillErrorM3` (± = per-cell cellArea × 1.5 × GSD, per side)/`nodataFraction` published on the readout; without terrain a flat-plane fallback runs with no error figures. `base-surface` picks the reference: `custom` (default — the gizmo's target plane) or boundary-derived stockpile strategies with no gizmo (`triangulated` boundary TIN, `plane`, `lowest`, `highest`, `average`). Five more volume-only attributes (no-ops, and validation warns, without `volume` in `modes`): `base-surface` (above); `profile` (elevation samples around the footprint's own perimeter, live while sketching, dispatched on `profileSeries` for a paired `dynamic-chart`); `deadband` (m³, 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 — Bank/Loose/Compacted convention, `cutAdjustedMeters3` = raw × swell (loose/haul, bigger), `fillAdjustedMeters3` = raw ÷ shrink (loose/borrow needed, also bigger), tonnage from the raw (mass-conserving) volume — shown only once one of the three is actually configured.
32
+ - `<om-widget type="measure" modes="distance area volume" units="metric|imperial|nautical">` is the geodesic ruler: click to place points, live labels + a totals panel, read the value programmatically via the `om-measure` event (`detail.mode`/`.totalMeters`/`.areaMeters2`/`.perimeterMeters`/`.cutMeters3`/`.fillMeters3`/`.netMeters3`/`.totalMeters3`/`.cutAdjustedMeters3`/`.fillAdjustedMeters3`/`.cutMassKg`/`.fillMassKg`/`.stale`/`.profileSeries` — profile points are `{x: metres from the first vertex, y: elevation}` plus `vertexIndex` on the samples that ARE drawn corners, so a chart can mark them; vertex 0 is the leftmost, and with `profile` on the map badges the first two vertices `1 · Start` and `2` in draw order so the ring's winding direction is readable at constant cost, while the chart marks every corner). `volume` outlines a footprint like `area` (close it with a double-click/Enter — it turns solid teal, "ready"), then a fixed-screen-pixel-size double-headed arrow gizmo appears at the centroid: drag up to fill, down to cut (unbounded distance), reading Cut/Fill/Net (signed, fill−cut)/Total (unsigned, cut+fill) — always RAW geometric volumes, never altered by `swell`/`shrink`. It REQUIRES `terrain` on `<om-map>` (validation warns a `volume` mode with none — cut/fill against flat ground with no elevation surface has nothing to measure against). The math is a REAL per-cell grid integration: 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, scanline point-in-polygon, bilinear seam-correct sampling, worker-offloaded) — mixed cut AND fill in one footprint on undulating ground, with `cellSizeM`/`gsdM`/`cutErrorM3`/`fillErrorM3` (± = per-cell cellArea × 1.5 × GSD, per side)/`nodataFraction` published on the readout; without terrain a flat-plane fallback runs with no error figures. `base-surface` picks the reference: `custom` (default — the gizmo's target plane) or boundary-derived stockpile strategies with no gizmo (`triangulated` boundary TIN, `plane`, `lowest`, `highest`, `average`). Five more volume-only attributes (no-ops, and validation warns, without `volume` in `modes`): `base-surface` (above); `profile` (elevation samples around the footprint's own perimeter, live while sketching, dispatched on `profileSeries` for a paired `dynamic-chart`); `deadband` (m³, 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 — Bank/Loose/Compacted convention, `cutAdjustedMeters3` = raw × swell (loose/haul, bigger), `fillAdjustedMeters3` = raw ÷ shrink (loose/borrow needed, also bigger), tonnage from the raw (mass-conserving) volume — shown only once one of the three is actually configured.
33
33
 
34
34
  ## Element vocabulary
35
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nika-js/onlymap",
3
- "version": "0.6.2",
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": {
@@ -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.2` (the bare package URL serves `dist/onlymap.standalone.js`) — plus `<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.6.2/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
 
@@ -37,7 +37,7 @@ The adapter inverts several HTML-manifest rules: props are camelCase deck.gl pro
37
37
 
38
38
  ## Native and JSON Bridges
39
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. 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.
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
41
 
42
42
  ## Required References
43
43
 
@@ -89,7 +89,7 @@ Load the smallest reference needed for the task:
89
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
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`.
91
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 — 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.
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
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
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.
95
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.)
@@ -48,7 +48,7 @@ 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`.
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
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.
@@ -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.2/dist/onlymapjs.css">
20
- <script type="module" src="https://unpkg.com/@nika-js/onlymap@0.6.2"></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).
@@ -356,7 +356,7 @@ Built-ins:
356
356
  - `filter`
357
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
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}` — 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.
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.
360
360
  - `vega-lite`
361
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.
362
362
  - `player`
@@ -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.