@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
package/CHANGELOG.md CHANGED
@@ -8,9 +8,114 @@ Note: npm collapsed a few closely-spaced releases — the GPX/FlatGeobuf (0.5.4)
8
8
  and GeoParquet (0.5.5) work shipped to npm together as **0.5.6**, so npm's
9
9
  version list jumps 0.5.3 → 0.5.6. Each logical version is listed here regardless.
10
10
 
11
- ## 0.6.1unreleased
11
+ ## 0.6.42026-08-12
12
+
13
+ React Native core prerequisites. 0.6.3 was already tagged for release when this
14
+ work landed, so it carries its own version rather than redefining that one.
15
+
16
+ ### Added
17
+ - **Descriptor-owned data transports**: fetches, polling loops, and WebSockets
18
+ are shared by transport identity and reference-counted across active map
19
+ owners. Layer removal or option changes release stale handles; map disposal
20
+ releases all handles; `MapController.suspend()` and `resume()` provide an
21
+ app-background lifecycle without discarding canonical descriptors. A release
22
+ the owner means to reverse — `suspend()`, removing or re-pointing a layer —
23
+ leaves its last rows as a cold snapshot, so resuming or re-adding repaints
24
+ immediately instead of flashing empty; a permanent teardown (`destroy()`, an
25
+ `<om-map>` leaving the document) keeps nothing.
26
+ - `releaseDataOwner(owner, {retain})` plus an optional `owner` argument on
27
+ `descriptorToIR` — descriptor-owned transport lifetime for hosts that drive
28
+ the IR directly instead of through `MapController`.
29
+ - **JSON-safe programmatic descriptors**: schema-declared accessor props may
30
+ use restricted OnlyMap expression strings, and
31
+ `snapshotDescriptorIR(descriptors)` produces deterministic, fetch-free IR
32
+ snapshots for native/cross-process parity tests. The React layer adapter now
33
+ exposes a pure descriptor conversion path and mirrors dashed-line options.
34
+ - **App-scoped packaged licenses**: signed license tokens may now declare exact
35
+ native application identifiers in an `apps` claim, independently or together
36
+ with web domains. Native hosts pass platform-derived identity to
37
+ `configureLicense(key, {appId})`; page or bridge input must never supply it.
38
+ Only the `domains` claim is pinned by the browser — `apps` is asserted by the
39
+ host, so scope a native key by both where you can.
40
+
41
+ ### Changed
42
+ - **Live transports no longer live for the page.** A `ws(s)://` socket, a
43
+ `refresh` poll loop, and an in-flight `data` fetch previously outlived the
44
+ layer that opened them; they are now stopped when their last descriptor owner
45
+ releases them — layer removal, a change to `data`/`source`/`key`/`flush`/
46
+ `refresh`, `MapController.destroy()`/`suspend()`, or an `<om-map>` leaving the
47
+ document. Pages that relied on a connection surviving layer removal must keep
48
+ the layer mounted (`visible="false"` does not release) or re-add it, which now
49
+ repaints from the retained cold snapshot. Re-parenting a live `<om-map>` in
50
+ the DOM does **not** drop its transports: the release is deferred a microtask,
51
+ so a synchronous disconnect→reconnect keeps the socket open.
52
+ - The data cache is keyed by **transport identity** (URL plus live-source
53
+ options) rather than URL alone. Two layers on the same URL still share one
54
+ transport when their stream/poll options match, and now correctly get separate
55
+ ones when they do not.
56
+
57
+ ## 0.6.3 — 2026-08-12
58
+
59
+ ### Fixed
60
+ - **Measure labels no longer render painted onto the terrain** (reported readability bug). The distance/area badges were `terrain="drape"`, and draping renders a layer INTO the terrain's own texture — so a badge came out flat on the ground, stretching and skewing with the slope and going edge-on to a pitched camera. They now use `terrain="offset"`, which keeps the badge a screen-facing billboard and only lifts its anchor to the surface, plus a 14px screen-space lift so a pill never reads as half-buried in a rise.
61
+ - **`PopupLayer`'s default terrain mode is now `offset` rather than `drape`**, for the same reason and following the rule the library already applied to 3D models: billboarded content anchors ON the surface instead of being painted onto it. This affects any `PopupLayer` under an active `terrain` that did not set the attribute explicitly; `terrain="drape"` still does the old thing for anyone who genuinely wants text painted onto the ground like a road marking.
62
+
63
+ ### Added
64
+ - **The elevation profile marks the footprint's own corners.** `profileSeries` samples that ARE a drawn vertex now carry `vertexIndex` (0-based, in draw order; the closing sample back at the start carries the last index), and interpolated samples omit the field entirely so a Vega-Lite spec separates them with `isValid(datum.vertexIndex)`. The measure widget's built-in profile chart uses this: a clean line, a distinct point on each real corner with a `Vertex / Distance / Elevation` tooltip, and vertex 0 labelled **"1 · Start"** (the exact badge text used on the map) — replacing the old `point: true`, which dotted all ~50 interpolated samples equally and so said nothing about which points were corners. New `ProfilePoint` type, exported.
65
+ - **Direction badges on the map: `1 · Start` and `2`.** Two problems, one marker. The profile runs around a closed loop, so its chart has a leftmost point but the map had no cue for WHICH corner that was; and marking only the start is still ambiguous, because from that corner the ring could run either way and clockwise vs counter-clockwise produce mirror-image profiles. The first two vertices are badged in draw order — the minimum that fixes a direction, and a constant cost: one badge per vertex would grow the clutter precisely as a footprint got complicated enough to need the cue. Draw order IS the order the profile walks, so badge N and the chart's Vertex N are the same point by construction. The chart still marks every corner. Displayed 1-based; `ProfilePoint.vertexIndex` stays 0-based, being an array index. Shown only with `profile` on, and cleared with the footprint.
66
+ - **`resamplePathWithVertices(points, count)`** in `geodesy.ts` — `resamplePath`'s evenly-spaced curve with the path's own vertices merged in by distance and tagged, coinciding samples replaced rather than duplicated. This is what makes a profile addressable back to the geometry that produced it.
67
+
68
+ ## 0.6.2 — 2026-08-12
69
+
70
+ ### Added
71
+ - **Region 3D export** (issue #34 — ported from a sibling project's proven implementation): the `draw` widget's `export-3d` attribute adds an "Export 3D" button that clips loaded `Tile3DLayer`/`BIMLayer` triangle geometry to the drawn footprint (a plain 2D ring — no elevation picking involved) and downloads the result as a portable GLB (positions re-framed to a local coordinate frame at the footprint's own centroid, so it opens correctly in Blender/three.js/etc. without needing ECEF-scale support), or `export-3d="b3dm"` for the same mesh wrapped for Cesium/3D-Tiles pipelines (with a `_BATCHID` attribute + a feature table, namespaced per source tileset so two different tilesets' local batch ids don't collide). Reuses the existing `om-tileset-load` consumer event — its own doc comment already anticipated "tools (e.g. region export) that need the real tileset" — to resolve the live `Tileset3D` registry; no new picking/loader plumbing needed. Each triangle exports with its own source color (standard glTF `pbrMetallicRoughness.baseColorFactor`, baked as per-vertex `COLOR_0`) — no textures (BIM/IFC materials are flat colors, not textured meshes — dropped rather than carried for a case this library doesn't have). Region export also only pulls in currently-**visible** 3D Tiles/BIM layers — a layer hidden via `visible="false"` (or the `toggle-layer` action) is skipped, with distinct console warnings for "nothing loaded yet" vs. "everything loaded but hidden."
72
+ - **Clip box** (issue #34 — the geometric complement to the export above): a new `<om-widget type="clip-box">` widget plus `<om-map clip-box-min="[lng,lat,elev]" clip-box-max="[lng,lat,elev]" clip-box-invert clip-box-highlight>` scene-state attributes cut a real axis-aligned 3D box through the whole scene — every layer is clipped by default, opt a layer out with `clip="off"`. `clip-box-invert` shows what's outside the box instead of inside; `clip-box-highlight` dims clipped-out geometry instead of discarding it (a non-destructive preview — nothing disappears). Works on any layer type, including georeferenced `Tile3DLayer`/`BIMLayer` content (3D Tiles, IFC/BIM models) — cutting into a dense BIM scene, not just flat `GeoJsonLayer` extrusions, was the whole point. New `set-clip-box {min, max, invert?, highlight?}` action (`{clear: true}` removes the box); attribute-backed, so changes are undoable and story-steppable. v1 is axis-aligned only — rotated/oriented boxes are a documented follow-up. The clip box itself renders as a visible, draggable solid cuboid with a double-headed-arrow gizmo on each of its 6 faces (pointing along that face's own normal, pixel-locked size so it stays grabbable at any zoom) — drag a face to resize the box directly, gated behind an explicit "Show face gizmos" toggle so the handles don't sit on top of ordinary map panning when not in use.
73
+ - **`BIMLayer` now lands on real terrain, not just near it** (issue #34 Part B groundwork): a georeferenced IFC file's `IfcMapConversion.OrthogonalHeight` (its real absolute elevation) was computed but never applied to the rendered model — correct on a flat basemap (a local Z≈0 model sits right on a Z=0 ground plane), wrong once real `terrain` is active (the model stayed at Z≈0 while the real ground elevation at any inhabited location is essentially never 0, burying it inside the terrain mesh). `BIMLayer` now applies its own `OrthogonalHeight` automatically whenever terrain is active — no attribute to author — reusing the same `tilesetPlacementMatrix` ENU re-anchoring the BIM workbench's `ifc-loader` widget already used manually for models it loads via drag-and-drop.
74
+ - **Depth-aware picking (`pickable="3d"`) + a real z on hover/click**: any `pickable` layer can opt into deck's depth-pick render pass with `pickable="3d"` instead of a bare `pickable` — terrain now sets this on itself — so a click/hover's resolved coordinate carries a real 3rd (elevation) component instead of the ray∩z=0-plane guess a flat pick gives you. `ctx.selection.coordinate` (and `<om-overlay>`/`show-tooltip` template interpolation, now `{{z}}`) carries it through end to end; absent (not `0`) when no layer in the scene ran the depth pass for that pick.
75
+ - **New example: `dev/examples/features/widgets/3d-snapping-cutting-tools.html`** (replaces the earlier `bim-snap-export-clip.html`) — region export, clip box, XY snapping, and z-aware hover picking together against the buildingSMART Medical-Dental Clinic (IFC 2x3, CC-BY 4.0), the same public sample the BIM workbench demo uses. A real multi-storey building is what these tools need: dozens of walls give the snap agents genuine corners and edges to lock onto, and a dense interior is the point of cutting a box through it. The clinic georeferences itself through `IfcSite` and declares no `OrthogonalHeight`, so the page authors `terrain="off"` — a model with no vertical datum placed over a real DEM renders buried inside the terrain mesh.
76
+ - **XY snapping** (issue #34 Part A): `<om-map snap="vertex edge midpoint" snap-tolerance="12">` refines a click/hover to the nearest vertex, edge, or edge midpoint of whichever feature deck already picked under the cursor — a two-stage design (deck's own pick narrows to one feature per frame for free; only that feature's own geometry is searched, on the CPU, when snapping is on), not a spatial index. Applies to every vector layer by default (`snap="off"` opts a layer out, mirroring clip box's own default) and to a `BIMLayer`'s own edge/crease overlay — its real wall corners/edges, converted from the model's local mesh coordinates back to real `[lng, lat]` automatically (`site-placement.ts`'s new `localOffsetToLngLat`, fed the model's own reported georeference). Vertex beats midpoint beats edge in range-conflicts. Space suppresses snapping momentarily; a "Snapped: …" tip shows which agent fired. New `src/snapping.ts` (pure resolver, 18 unit tests). XY only for now — a snapped vertex takes the matched feature's horizontal position; threading its exact elevation through the shared draw/measure position type is a documented follow-up.
77
+ - **"Flat target plane" toggle for volume measurement** (`base-surface="custom"` only) — now a real semantic switch, not a rendering preference. Default (toggle off): the target surface is the terrain itself offset by the dragged distance — a slope-parallel prism, volume = |offset| × area, pure cut OR pure fill, and exactly **zero at rest** (previously the readout reported "grade everything to the centroid's elevation" numbers before the gizmo was ever touched). Toggled on: the target is one genuinely level plane, per-cell integrated — mixed cut AND fill on sloped ground. Toggling re-integrates the cached grid, so the rendered prism and the reported numbers are always the same shape. The drawn footprint ring itself always hugs real per-vertex terrain in both modes — only the target face changes. Also fixes the flat prism's broken rendering (missing side walls, mis-triangulated caps): its wall quads are vertical polygons, which the default XY-plane earcut triangulates to zero triangles — the explicit prism now renders through its own dedicated `SolidPolygonLayer` with `full3d` (the exact CityJSON-surfaces recipe), instead of mode-flipping the live extruded layer. The prism's ground edge is now DENSIFIED (~192 perimeter samples, each with its own terrain elevation from the same commit-time batch) instead of using only the drawn corners — corner-only geometry drew straight bottom edges across every groove between vertices — and the terrain-draped footprint stays visible through fills as the prism's conforming interior ground face.
78
+ - **Real per-cell volumetric integration** (issue #35 — replaces the v1 flat single-elevation approximation): closing a volume footprint now bulk-loads the DEM tiles covering its bbox (`loadHeightfieldForBounds`, budget-capped with honest zoom coarsening), lays a metric grid over the polygon in a local tangent-plane frame at the ring centroid (cell size = the DEM's ground-sample distance at that latitude — never silently finer than the data; Web Mercator's sec²(φ) area distortion avoided by construction), fills it by scanline point-in-polygon, and integrates terrain-vs-base per cell with bilinear, tile-seam-correct sampling — all in a Web Worker (inline-blob, CDN-safe, same pattern as the COG decoder; synchronous fallback when workers are unavailable). One footprint on undulating ground now reports mixed Cut AND Fill simultaneously, each with a published ±error (per-cell `cellArea × 1.5 × GSD`, summed per side — quoted against the SOURCE's GSD, never the grid spacing), plus cell size, GSD, and a no-data fraction, all on the `om-measure` readout (`cellSizeM`/`gsdM`/`cutErrorM3`/`fillErrorM3`/`nodataFraction`) and in the widget panel (± on the Cut/Fill rows, Base surface/Cell size rows, an under-reporting warning when data is missing). Without terrain, the flat fallback still runs — with no error figures, since there'd be nothing honest to quote.
79
+ - **`base-surface` attribute on the measure widget** (issue #35 — "Multiple Base Surfaces"): `custom` (default) keeps the draggable gizmo's target plane — drag frames re-sum the cached per-cell grid synchronously (`reintegrateCustomBase`), so the numbers stay real integration all through the gesture; `triangulated` (Delaunay TIN over densely-resampled boundary elevations — the drone-survey stockpile default, following a pile's toe all the way around), `plane` (least-squares fit), and `lowest`/`highest`/`average` derive the base from the footprint's own boundary — stockpile-style: no gizmo, the volume reports immediately on close. Switching strategies re-integrates against cached tiles (no re-fetch). Validation warns on unknown strategies and on `base-surface` without `"volume"` in `modes`.
80
+ - **`terrain-heightfield.ts` is now bilinear and seam-correct**: `elevationAt` interpolates over the four surrounding DEM pixels — across tile boundaries, with the neighbor tiles a tile-edge point needs auto-prefetched — upgrading the elevation profile and vertex sampling for free (previously nearest-pixel). Missing corners renormalize rather than failing the whole sample.
81
+ - **Cut/fill volume measurement**: the `measure` widget gains a third mode, `modes="distance area volume"`. Outline a footprint (closes like `area` — double-click or Enter); while flat (no drag yet) it drapes onto the terrain surface exactly like the sketch preview that drew it, then a real 3D double-headed arrow gizmo (an orange cylinder shaft with a cone at each end, one inverted) appears at its centroid — drag it up to fill or down to cut, and it switches to a solid extruded prism at that instant. Reads out `cutMeters3`/`fillMeters3`/`netMeters3`/`totalMeters3` (Net signed fill−cut, Total unsigned cut+fill, each labelled with its own convention) alongside area/perimeter via the `om-measure` event. The gizmo is a FIXED SCREEN-PIXEL size (derived from the current zoom/latitude, not footprint- or drag-relative), so it stays a constant, grabbable size regardless of zoom or how far it's been dragged; the drag distance itself is unbounded — v1 has no real bulk elevation surface to derive a legitimate "how deep can this go" limit from, so there's nothing to clamp against. Requires `terrain` on `<om-map>` — validation warns when `volume` is authored with none. Cut/fill are computed by the real per-cell grid integrator (see "Real per-cell volumetric integration" below) — mixed cut AND fill within one footprint on undulating ground, not a flat single-elevation approximation.
82
+ - **Elevation profile, as a volume-mode `profile` attribute** (not a mode of its own): `<om-widget type="measure" modes="distance area volume" profile>` — closing a volume footprint also samples elevation around that same footprint's own perimeter (no separate line to draw) and dispatches it on the `om-measure` event's `profileSeries` field for a `dynamic-chart` widget to plot. Updates LIVE while still sketching, from the very first vertex — resampling immediately on each new vertex and, debounced (250ms), while just hovering the cursor around — not only once the footprint is closed. Validation warns if `profile` is authored without `"volume"` in `modes` (nothing for it to sample a perimeter around).
83
+ - **Cut/fill reporting factors** (`density`, `swell`, `shrink`, `deadband` — all volume-mode `measure` widget attributes). `cutMeters3`/`fillMeters3`/`netMeters3`/`totalMeters3` stay RAW geometric volumes — `deadband` (m³) zeroes out a figure below the threshold (drag noise near zero height), but `swell`/`shrink` never touch them, so these four keep answering "does this reach target elevation" regardless of what material's configured. `swell`/`shrink` (default 1×) instead populate two SEPARATE fields, `cutAdjustedMeters3`/`fillAdjustedMeters3`, on the standard Bank/Loose/Compacted earthworks convention: Adjusted Cut = raw × swell (loose/haul volume — excavating adds air voids, so it's bigger than the bank volume removed); Adjusted Fill = raw ÷ shrink (raw fill is already a compacted target-void volume, so the loose/borrow material actually needed is likewise bigger). `density` (t/m³ metric, lb/yd³ imperial) adds `cutMassKg`/`fillMassKg` — computed from the RAW volume, not the adjusted one, since swell/shrink change volume via air voids, not the actual mass of material; a second "adjusted tonnage" would misrepresent the same dirt as weighing differently depending on how it's packed. The widget's readout gets a separate "Material" section for the adjusted/tonnage figures, showing only when at least one of `density`/`swell`/`shrink` is actually configured — no separate on/off toggle, the attribute's presence is the opt-in. A new `stale` boolean on the readout flags the brief window between a footprint committing and its elevation sample resolving, so a consumer never mistakes a prior footprint's leftover numbers for current ones. Validation warns on a non-positive `density`/`swell`/`shrink`, a negative `deadband`, or any of the four authored without `"volume"` in `modes`.
84
+ - **`dynamic-chart` widget**: the same Vega-Lite rendering as the existing `vega-lite` widget, but data-driven by a live DOM event instead of a layer — `on="<event-name>"` + `series-field="<name>"` reads `event.detail[seriesField]` as the chart's data on every matching event and redraws at a fixed `width`. A feature "freezes" the chart for free by simply omitting that field on a later event (no separate pause API) — built for the elevation-profile attribute above (a feature that computes its own series live as the user draws and has no layer of its own to bind to), but intentionally generic (no elevation/measurement-specific code) so it's reusable for anything that needs a chart fed programmatically.
85
+ - **`geodesy.ts` gains `intermediate(a, b, fraction)` and `resamplePath(points, count)`**: `intermediate` is great-circle interpolation (spherical slerp) between two points; `resamplePath` walks a multi-segment polyline's cumulative length to place `count` evenly-spaced points along it — the elevation profile's sampling backbone. `midpoint()` is now defined as `intermediate(a, b, 0.5)` (same antimeridian-safe behavior, verified unchanged).
86
+ - **`terrain-heightfield.ts`: bulk terrain elevation sampling**, now wired to both volume mode's per-vertex/centroid sample and the elevation profile above. The existing `sampleTerrainElevation` is one network fetch per point — fine for volume mode's handful of footprint vertices, wrong for a profile's tens-to-hundreds of samples along a line. `loadHeightfield(points, terrain)` resolves every DEM tile the points touch, fetches and decodes each ONCE (deduplicated, in parallel, with an 8s per-tile fetch timeout), and returns a synchronous in-memory `elevationAt()` lookup — no further network access per sample. Tested entirely against synthetic, known-by-construction DEM tiles (no real ground-truth file needed or used), specifically covering the genuinely new risk vs. the single-point sampler: correctness across a tile SEAM.
87
+
88
+ ### Fixed
89
+ - **The spacebar is no longer swallowed page-wide.** The snap-suppression key handler (`<om-map>`'s window-level `keydown`) called `preventDefault()` on the spacebar unconditionally — on every page containing a map, whether or not `snap` was configured, and regardless of what had focus. That broke space-to-scroll (the browser default, and how keyboard-only users page down) and space-to-activate on any focused `<button>`, including this library's own widget buttons. It is now gated on a snap resolver actually being configured, and skips space-activated controls (`button`, `summary`, `a[href]`, `[role=button]`, focusable elements) alongside the existing text-input guard. Snapping's own "hold Space to place a point without snapping" behavior is unchanged where snapping is on.
90
+ - **`clip="off"` (the clip box's documented per-layer opt-out) never worked.** It was published in the docs and offered by editor IntelliSense, but was not registered as a layer attribute — so it warned as an unknown-attribute typo, never reached the IR, and the clip box kept clipping the layer the author had explicitly opted out. `snap="off"` had a narrower version of the same problem: registered on only four vector layer types, so it was rejected on a `BIMLayer` — precisely the layer whose edge overlay snapping reads. Both are now registered on **every** layer type, matching their scene-wide defaults, and stripped before deck layer construction like the other OnlyMapJS-only props.
91
+ - **Snapping and clip-box attributes are now validated.** `snap`/`snap-tolerance` on `<om-map>` (an unrecognized agent, e.g. the plural `snap="vertices"`, silently left snapping off) and the per-layer `snap`/`clip` opt-outs (any value other than `"off"` is a silent no-op) now produce warnings naming the fix, closing the gap `snapping.ts`'s own doc comment already promised.
92
+ - **An antimeridian-crossing volume footprint silently reported 0 m³.** `loadHeightfieldForBounds` computed its tile span as `east - west`, which goes negative across ±180°; the span then passed the tile budget at max zoom and the cover loop produced no tiles at all, which the integrator read as "every cell is no-data." The span now wraps around the world and the cover collects tiles on both sides of the seam.
93
+ - **Region export could ship a deleted layer's geometry.** The visible-layer filter excluded a layer only when its `<om-layer>` existed *and* was `visible="false"`; a layer removed from the DOM fell through to *included*, and the tileset registry was append-only so it never aged out. Removal is now detected (and the registry entry dropped) while programmatically-built layers, which legitimately have no element, stay exportable.
94
+ - **Snapping no longer invents edges across a `MultiPoint`.** Its members were held in one shared ring, so the `edge`/`midpoint` agents snapped to segments between unrelated points — empty space where no geometry is drawn. Each member now gets its own ring; vertex snapping is unchanged.
95
+ - **`base-surface="triangulated"` no longer scales as cells × triangles.** The TIN evaluator tested every triangle's bounding box for every grid cell — ~1.4×10⁸ tests per integration at the 750k-cell budget. Triangles are now bucketed into horizontal bands (indexed by overlap, so a triangle spanning several bands is found from all of them) and a cell only tests its own band's. Results are unchanged.
96
+ - **The snap re-pick no longer rebuilds its layer-id list on every pointer move.** It is memoized against deck's layer-array identity, so the filter runs per layer-set change rather than per mousemove.
97
+ - **Region export yields a frame before its (synchronous, main-thread) clip and pack**, so the button's own click feedback paints instead of the tab appearing to ignore it. The work itself still blocks — it consumes live `Tileset3D` objects, which can't be handed to a worker the way the volumetrics integrator's decoded tile buffers can — and that limit is now stated in `docs/3d-assets.md`.
98
+ - **The "Snapped" tip anchors at the snapped point, at its real height**: the tip's overlay anchored at `[lng, lat]` with an implied z=0, so for an elevated vertex (a wall corner 400 m up) it rendered far from the visible point, drifting with camera pitch. `SnapResult` now carries the matched point's elevation and the tip anchors with it (the overlay's existing 3-component-anchor contract). The tip also hides (`clip-to-map`, opt-in per overlay) when its own box would spill past the map viewport — an overhanging absolutely-positioned box inflates the page's scrollable overflow, and the resulting scrollbar → map resize → reprojection loop was visible as view jitter.
99
+ - **`<om-map>` is now its own positioning context** (`position:relative` in the injected defaults layer): overlays and widget chrome are `position:absolute` children positioned in map pixels, and without it they resolved against whatever ancestor happened to be positioned — correct only on pages where that box coincided with the map's own. Author CSS still wins (same `@layer onlymap-defaults`).
100
+ - **XY snapping actually finds vertices now** — three independent faults fixed. (1) The resolver only understood GeoJSON-shaped picks, so flat-row layers (CSV/JSON + `get-position`-style accessors — most data) silently never snapped: the layer's own resolved accessors now synthesize the geometry (`get-polygon` → Polygon, `get-path` → LineString, `get-source/target-position` → LineString, `get-position` → Point). (2) Elevated vertices (a BIM model's wall corners) were projected to screen as if at ground level — hundreds of pixels from where they're actually seen, so never within tolerance: vertex elevations now carry through the screen-space tolerance test. (3) `BIMLayer`'s edge/crease overlay rendered at z≈0 regardless of terrain — buried the model's whole elevation below the mesh (and picked as phantoms where nothing visibly was): the outline now rides the same `OrthogonalHeight` placement as the mesh, via `coordinateOrigin`'s elevation component (the matrix is rotation+scale only and silently drops a z).
101
+ - **Clicks no longer "pass through" tall content onto the ground behind it**: with snapping on, a click/hover that picks nothing snappable but DOES hit a real (non-terrain) feature now resolves at that feature's own coordinate — depth-aware (`unproject3D`) when the 3D pick pass ran — instead of falling back to the original pick's ray∩ground point far behind a building.
102
+ - **Activating the clip box crashed every placed BIM/3D-Tiles sublayer** ("invalid latitude" on each mesh primitive, model gone until reload): `ClipBoxExtension` projected the box's world-anchored lng/lat corners through `Layer#projectPosition`, which applies the layer's own `modelMatrix` — a placed model's meters-scale ENU matrix — to the *input* before interpreting it as lng/lat. The matrix is now suppressed for the corner projection, and a degenerate frame falls back to drawing that layer unclipped (with a one-time console warning) instead of throwing — a draw-time throw makes deck drop the layer entirely.
103
+ - **The `draw`/`measure` widgets' own geometry was invisible under an active `terrain`**: every runtime-internal layer was unconditionally forced to `terrain="off"`, so a sketch/measurement's plain `[lng, lat]` vertices rendered at literal sea level — hidden below or behind a raised terrain mesh — instead of draping onto its surface. An internal layer's own explicit `terrain` attribute now wins over that default (other internal layers that never set one, like trace-temps, are unaffected); the shared draw-preview and measure's committed-geometry/label layers opt into `terrain="drape"`.
104
+ - **Double-click-to-close on a `draw`/`measure` polygon could silently fail to finalize, or reopen a stray one-vertex shape right after closing**: the native `dblclick` event (which triggers the close) can fire before deck.gl's own click-gesture recognition has settled, so the double-click's own two taps were unreliable as vertices and could arrive late as an independent, unwanted click. Closing now uses the already-reliable hover cursor position as the final vertex instead of depending on the taps, and a stray click landing within a small pixel radius of the just-used closing point is swallowed rather than starting a new shape (a real double-click's two physical taps rarely land at the bit-identical pixel, so this is a proximity match, not exact equality).
105
+ - **The volume gizmo could render with its shaft/bottom cone invisible or buried underground**: deck's default lit material shaded faces pointed away from the scene light near-black (fixed via unlit rendering), and the gizmo's rest position extended symmetrically below the surface, occluded by the terrain/fill (fixed by keeping its floor pinned above ground). It's now also sized relative to the footprint instead of a fixed constant, and the extruded prism renders with `wireframe` (vertical struts + top/bottom rings) for both fill and cut.
106
+ - **A cut's extruded prism was invisible, including its own starting footprint**: the whole thing sits entirely below the original ground, so the opaque terrain mesh fully occludes it from a normal view — the `wireframe` struts existed but were behind the terrain, and even the prism's own top face (at ground level) was unreliable to see, being nearly coplanar with the terrain mesh. The original footprint now stays visibly shaded (the same green as a fresh/at-rest footprint) throughout a cut; a dedicated depth-test-disabled line layer draws one vertical strut per footprint vertex from ground down to the cut depth plus the closed target-ring boundary at the bottom (unshaded, just an outline) — visible through the terrain, so a cut's shape and depth read visually instead of only through the numeric Cut readout.
107
+ - **The extruded volume's base could visibly float above or sink below the terrain on sides away from the footprint's center**: v1 samples one flat elevation at the centroid for the cut/fill math (unchanged, still documented as a v1 simplification — see issue #35), but the rendered base ring now additionally samples each vertex's own real ground elevation, so the footprint's corners hug the actual terrain instead of a single flat plane.
108
+ - **The volume readout could claim "sampling elevation…" while the footprint was still an open, unclosed preview**: that state is now distinguished from an actually-closed footprint awaiting its terrain sample; a 3+-vertex preview shows live area/perimeter (matching `area` mode) with no cut/fill numbers until the shape is genuinely closed.
109
+ - **Dragging the volume gizmo far enough could make the whole extrusion (and its target-ring boundary) vanish, and cutting kept the gizmo pinned at the original ground instead of tracking the excavation**: root cause was the gizmo's own shaft length scaling with `|heightOffsetM|` with no upper bound, pushing its Z far enough from the scene to plausibly lose float precision in deck's projection. Fixed at the source — the gizmo is now a FIXED SCREEN-PIXEL size regardless of drag distance or zoom (re-derived from the current zoom/latitude on every viewport change, not just on drag/commit) and tracks the current working face symmetrically on both sides (just above the fill top while filling, just below the cut floor while cutting, rather than staying pinned at the original ground). The drag distance itself is no longer clamped at all (an earlier footprint-scaled clamp, and later a flat numerical backstop, were both removed in turn — neither reflected real ground-truth depth, and the float-precision failure mode they guarded against is now fixed at its actual sources: the fixed-pixel gizmo above, plus the `farZMultiplier` fix below).
110
+ - **The gizmo became invisible while cutting, immediately after the fix above; then became noticeably smaller and self-occluded oddly when made visible again; then its elevation tooltip disappeared too**: tracking the cut floor means the gizmo (and its tooltip, anchored at the gizmo's own vertical midpoint) sits below ground the same way the cut prism/guide lines do, and was occluded by the opaque terrain mesh the same way they were before their own fix. Disabling depth-testing unconditionally (the first attempt) fixed the occlusion but broke the gizmo's OWN internal self-occlusion — with 3 separate mesh instances (shaft + 2 cones) and no depth test, whichever drew last simply painted over the others regardless of true depth, most visible at closer zoom. The depth-test bypass is now conditional (only while actually cutting, re-evaluated every frame) and shared by the tooltip; the gizmo's fixed size was restored to its original scale (an intermediate fix had shrunk it below what it always was, not just capped its growth at extreme drag distances), then later halved again on request now that its size is a deliberate, tunable constant, not a bug; and `PopupLayer` (the tooltip's underlying layer) gained the same `parameters` passthrough as its earlier `pixelOffset` addition, since depth-test toggling wasn't previously plumbed through the composite layer at all. The tooltip text is now also prefixed `"Elevation: "` so the number isn't ambiguous out of context.
111
+ - Added a simple always-on tooltip beside the volume gizmo showing the current drag magnitude as an absolute value (m/ft depending on units) — no more needing to check the widget panel to see how far you've dragged. Cleared by a real screen-pixel offset (a new `PopupLayer` `pixelOffset` override), not a world-space one — a fixed-meters gap shrinks to fewer screen pixels at low zoom than the badge needs, which caused a reported overlap with the gizmo mesh.
112
+ - **The gizmo, its target-ring boundary, and its tooltip could all disappear together at high zoom on a deep cut** — a different bug class than the earlier occlusion fixes above: deck's far clipping plane is computed in units that stay roughly constant across zoom levels, while a fixed real-world depth maps to a progressively larger clip-space distance the closer you zoom in, eventually falling outside the camera's view frustum entirely (culled, not merely hidden behind something). The standalone-mode view (the mode `terrain`, and therefore the volume tool, always runs in) now sets `farZMultiplier: 10` — the effective ceiling for this setting, since deck's own horizon-distance cap clamps any higher value to the same result.
113
+ - **`DrawController.deleteLast()` never notified observers**, unlike its sibling `clear()` — a controller watching a target (e.g. `measure` in volume mode) kept showing numbers for a shape that had just been deleted, with no later mutation guaranteed to correct them. Now mirrors `clear()`'s own `notify()` call.
114
+
115
+ ## 0.6.1 — 2026-08-06
12
116
 
13
117
  ### Added
118
+ - **`ylorrd` joins the curated raster colormaps**: the ColorBrewer yellow→orange→red sequential ramp now renders a proper gradient in the legend (any sprite colormap always rendered on the map; only curated names get legend ramps) and is listed in the docs as the gentler alternative to `turbo`/`jet` for heat-like fields.
14
119
  - **`selection-type` on `<om-overlay>`** (React: `selectionType` on `<OmOverlay>`): scope a selection-anchored overlay to one pick type — `"click"` or `"hover"`. Fixes the reported ghost-popup bug: a popup wired to open on click re-anchored *and re-interpolated its template* on every hover pick (deck fires hover on all pointer movement), so it followed the pointer onto other features and rendered their objects into the wrong template with blank fields — with no author-side workaround. With `selection-type="click"`, hover picks are inert; a click on empty space still dismisses (empty picks now carry the pointer-event type internally), while hovering empty space no longer does. Absent attribute keeps the old behavior. Validation warns on a typoed value and on `selection-type` without `anchor-from="selection"`; the test harness's `clearSelection()` gains a kind (`"hover"` default, `"click"` for empty-space clicks).
15
120
  - **BIM / IFC support**: `BIMLayer` loads `.ifc` files entirely in the browser (web-ifc WASM, CDN-fetched + integrity-pinned on first use, never bundled) and renders them as 3D Tiles; per-element **feature picking** on `Tile3DLayer` (`pick-features`, EXT_structural_metadata property tables, texture-backed IDs included); declarative **isolate / hide / ghost** (`feature-filter-field` + `isolate-features`/`hide-features`/`ghost-features`) and **style-by-property** (`feature-color-by`/`feature-color-scale`/`feature-palette`); **multi-model federation** (`ifc-loader federate`) and an AABB **clash-detection overlay**; widgets `ifc-loader`, `ifc-browser`, `feature-inspector` (alias `ifc-inspector`), `ifc-clash`; IFC georeferencing via `IfcMapConversion` (UTM + non-UTM projected CRS, grid-convergence heading correction) with a structured warning when a model's placement can't be trusted.
16
121
 
package/README.md CHANGED
@@ -8,8 +8,11 @@
8
8
  [![npm downloads](https://img.shields.io/npm/dm/%40nika-js%2Fonlymap?color=8956ff)](https://www.npmjs.com/package/@nika-js/onlymap)
9
9
  [![license](https://img.shields.io/badge/license-free%20for%20non--commercial-2f8fa6)](LICENSE.md)
10
10
  [![docs](https://img.shields.io/badge/docs-nikaplanet-003646)](https://docs.nikaplanet.com/onlymap/overview)
11
+ [![examples](https://img.shields.io/badge/examples-live%20gallery-8956ff)](https://onlymap.nikaplanet.com/)
11
12
 
12
- **Interactive WebGL maps from plain HTML.** OnlyMapJS is a declarative mapping library built on [deck.gl](https://deck.gl), written in TypeScript, with first-class HTML and React APIs. Write a manifest layers, widgets, popups, behaviors as custom elements — and it drives deck.gl underneath: rendering, GeoJSON/CSV/Arrow data loading, live updates, GPU picking, MapLibre basemaps, and UI, with no build step and no imperative glue code. If you've wanted a declarative deck.gl wrapper — for a geospatial dashboard, a React mapping component, or a single-file HTML map — that's the entire premise.
13
+ **[▶ Browse over 50 runnable examples](https://onlymap.nikaplanet.com/)**heatmaps, choropleths, 3D terrain, live streams and map stories, each with copyable source.
14
+
15
+ OnlyMapJS is a map rendering and visualization library for web, desktop, and mobile apps. With support for popular frameworks like React, it integrates seamlessly into your existing components via a robust two-way interface for custom map behavior; overlays, charts, and finely controlled story-map animations are all first-class supported alongside 30+ data layer types. Built-in GPU acceleration also means it scales very well for both 2D and 3D use-cases. It is both human and AI agent-readable by design, and troubleshooting is effortless with a compile-time validator and runtime error debugging tools.
13
16
 
14
17
  ```html
15
18
  <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap/dist/onlymapjs.css">
@@ -36,7 +39,7 @@ That's a complete app: a no-token MapLibre basemap, data-driven colors and sizes
36
39
 
37
40
  It's also designed to be written **by AI agents**: HTML is a reliable generation target, [`llms.txt`](llms.txt) teaches the format, and `OmMap.validate()` returns structured errors **and warnings** with actionable fixes — a real feedback loop instead of a blank canvas (heed both: an "unknown attribute" warning means a prop is being silently dropped).
38
41
 
39
- > ⚠️ **Status: v0.2.** Proprietary — free for non-commercial use with attribution; commercial licensing terms are in [LICENSE.md](LICENSE.md). APIs may still move before 1.0.
42
+ > ⚠️ **Status: beta.** Proprietary — free for non-commercial use with attribution; commercial licensing terms are in [LICENSE.md](LICENSE.md). APIs may still move before 1.0.
40
43
 
41
44
  ## Why not deck.gl directly?
42
45
 
@@ -69,7 +72,7 @@ Or with no build step at all, straight from a CDN:
69
72
 
70
73
  The bare package URL serves `dist/onlymap.standalone.js`, a single-file bundle built for exactly this (jsDelivr too). Use a CDN that serves the package's raw files — **not** a rebundling CDN like esm.sh, which re-splits the bundle into duplicate copies of the deck.gl/luma.gl runtime and breaks every layer's shader compilation.
71
74
 
72
- Then `npx @nika-js/onlymap init` wires up VS Code IntelliSense and `!`-prefixed manifest snippets for your project. The library ships with 664 unit/behavioral tests and 58 Playwright GPU tests.
75
+ Then `npx @nika-js/onlymap init` wires up VS Code IntelliSense and `!`-prefixed manifest snippets for your project. The library ships with 1,083 unit/behavioral tests and 62 Playwright GPU tests.
73
76
 
74
77
  The [examples](https://github.com/NikaGeospatial/onlymapjs/tree/main/examples) are the best tour: widgets, behaviors & overlays, basemaps, columnar/Arrow data, manual drawing, 3D models, scene lighting (with the native lighting widget), DEM terrain, a live WebSocket ship feed, and a polled driver fleet.
75
78
 
@@ -81,8 +84,8 @@ A handful of elements, one rule: **attributes are kebab-case versions of deck.gl
81
84
  |---|---|
82
85
  | `<om-map>` | The map. `center`, `zoom`, `pitch`, `bearing`; `basemap` takes a free preset (`positron`, `liberty`, `dark-matter`, `osm`, …), a style URL, or `"none"` (standalone canvas) — and switches **live**; `validate` for a live on-page error panel. Give it a height: a custom element is `display:inline` by default, so the library injects a `display:block` default (fills a sized parent, else a 400px floor) to keep a bare map visible, but set an explicit height (`om-map { height: 100vh }`) for real layout — any height you set wins outright, including one below the floor. A map that still collapses warns in the console; `hidden` and `display:none` maps stay hidden and stay quiet. |
83
86
  | `<om-layer>` | Any of **37 layer types** by name — all of deck.gl's core, geo, aggregation, and mesh layers (Scatterplot, GeoJson, Arc, Path, Heatmap, Hexagon, Trips, Tile, Tile3D, Scenegraph, …) plus the built-in `PopupLayer` for WebGL badges/labels at scale, the native `COGLayer` for GeoTIFF rasters, `ZarrLayer` for chunked Zarr/GeoZarr rasters, `ImageOverlay` for georeferenced drone JPEGs, and `BIMLayer` for BIM source files (`.ifc` today) loaded straight in the browser, no pre-conversion step. `id` required; `label`/`color` feed the legend. |
84
- | `<om-widget>` | UI panels. Built-ins: `legend` (symbology-aware: color scales render as gradient ramps or class ranges, categorical ternaries as discrete palettes), `layer-switcher`, `basemap-switcher`, `lighting`, `zoom-controls`, `undo-redo`, `scale-bar` (metric/imperial/nautical `units`), `attribution`, `filter`, `draw`, `measure` (geodesic distance + area — live labels, `units` toggle, an `om-measure` readout event), `vega-lite` (live charts), and the BIM set `ifc-browser` / `feature-inspector` / `ifc-loader` / `ifc-clash`. Or write your own inline with HTML + a `<script type="om/widget">`. Adjacent compact button widgets (`zoom-controls`, `undo-redo`, `widgets-toggle`) **auto-cluster** into one control group (opt out per widget with `cluster="false"`), and `<om-map widgets-hidden>` / the `set-widgets-visible` action / `<om-widget type="widgets-toggle">` hide all authored chrome without destroying it — provider attribution and the license badge never hide. **Placement is managed**: `position` takes one of 8 logical, RTL-aware slots (`top-start`, `top-center`, `top-end`, `center-start`, `center-end`, `bottom-start`, `bottom-center`, `bottom-end`; legacy corner names alias) — same-slot widgets stack with flush edges and a shared gap, `order` sets in-slot ordering, and `position="manual"` opts out entirely (a plain block you style yourself, even outside the map). At map widths ≤640px, managed widgets automatically move into accessible top/end/bottom/start drawers; `fold="never"` keeps an essential control out, `widgets-fold="off"` disables folding, and `--om-widget-fold-breakpoint` changes the map-width threshold. Provider attribution is an in-flow member of `bottom-end` and the license badge of `bottom-start`, so neither covers a widget. A slot dims automatically while an open popup covers it (`widgets-dim="off"` to disable), except slots containing required chrome. Themeable from plain page CSS via custom properties: `om-map { --om-widget-bg: #111827; --om-widget-fg: #f9fafb; }` (also `-muted`, `-border`, `-hover-bg`, `-accent`), plus layout tokens (`--om-widget-inset-x/-y`, `--om-widget-gap-x/-y`, `--om-widget-opacity`, `--om-widget-radius`) or the no-CSS sugar `<om-map widget-style="gap:10 opacity:0.9">`. |
85
- | `<om-overlay>` | Rich HTML anchored to a map location — a static `anchor="[lng, lat]"`, the current selection, or a feature's own geometry via `anchor-layer`/`anchor-feature-id`. Selection-anchored overlays scope with `layer` (one layer's picks) and `selection-type="click"`/`"hover"` (one pick type — give a click-opened popup `selection-type="click"` so hovering elsewhere doesn't drag it along). `{{field}}` interpolates the picked feature, HTML-escaped by default. |
87
+ | `<om-widget>` | UI panels. Built-ins: `legend` (symbology-aware: color scales render as gradient ramps or class ranges, categorical ternaries as discrete palettes), `layer-switcher`, `basemap-switcher`, `lighting`, `clip-box`, `zoom-controls`, `undo-redo`, `scale-bar` (metric/imperial/nautical `units`), `attribution`, `filter`, `draw` (point/line/polygon sketch capture, GeoJSON save/autosave; `export-3d` adds an "Export 3D" button that clips loaded `Tile3DLayer`/`BIMLayer` content to the drawn footprint and downloads it as a portable GLB — re-framed to a local coordinate frame at the footprint's own centroid, each triangle carrying its own source color as vertex colors, no textures — or, with `export-3d="b3dm"`, the same mesh wrapped for Cesium/3D-Tiles pipelines; only currently-visible 3D Tiles/BIM layers are included — a layer hidden via `visible="false"` or the `toggle-layer` action is skipped, with a distinct console warning for "nothing loaded" vs. "everything hidden"), `measure` (geodesic distance + area + cut/fill volume — live labels, `units` toggle, an `om-measure` readout event; `modes="distance area volume"` adds a footprint-then-extrude tool: outline a polygon, double-click to close it — it turns solid teal, ready — then drag the double-headed arrow gizmo that appears at its centroid up to fill or down to cut, reading out Cut/Fill/Net (signed, fill−cut)/Total (unsigned, cut+fill) volume plus Area/Perimeter, each labelled with its own sign convention. The math is a REAL per-cell grid integration: closing a footprint bulk-loads its covering DEM tiles, lays a metric grid in a local tangent frame at the ring centroid (cell size = the DEM's ground-sample distance; scanline point-in-polygon; bilinear, tile-seam-correct sampling; worker-offloaded), and integrates terrain-vs-base per cell — mixed cut AND fill within one footprint on undulating ground, with a published ±error (per-cell cellArea × 1.5 × GSD, summed per side), the cell size used, and a no-data warning all carried on the `om-measure` readout (`cellSizeM`/`gsdM`/`cutErrorM3`/`fillErrorM3`/`nodataFraction`). A `base-surface` attribute picks the reference surface: `custom` (default — the gizmo's draggable target plane), or boundary-derived stockpile strategies with no gizmo (`triangulated` boundary TIN, `plane` least-squares fit, `lowest`/`highest`/`average`). On `custom`, the extruded prism's own boundary hugs each corner's real ground elevation by default (so it doesn't visibly float above or sink below sloped terrain) — a "Flat target plane" toggle button appears above the readout once a footprint closes, switching that rendering to a single level plane instead (a rendering choice only; the underlying cut/fill numbers were already computed against one flat target elevation either way). Requires `terrain` on `<om-map>` — validation warns if `volume` is in `modes` with none (without terrain a flat-plane fallback runs, with no error figures — nothing honest to quote). A `profile` attribute alongside `modes` — not a mode of its own — samples elevation around a volume footprint's own perimeter as it's drawn and closed, live, and dispatches it on `om-measure`'s `profileSeries` field for a `dynamic-chart` widget to plot. Each sample is `{x: distance-from-start-m, y: elevation-m}`, and samples that ARE one of the drawn footprint's own corners additionally carry `vertexIndex` (0-based, in draw order) — so a chart can mark the real corners instead of every interpolated sample, and the widget's built-in profile chart marks each corner, labelling the first `1 · Start`. The map badges the first two vertices in draw order (`1 · Start`, `2`), which states the ring's direction outright — a start marker alone leaves clockwise vs counter-clockwise ambiguous, and the two wind to mirror-image profiles. Two badges is the minimum that fixes a direction and a constant cost regardless of how many corners the footprint has. Also volume-only: `deadband` (m³) zeroes out a Cut/Fill figure below the threshold, filtering drag noise near zero height (Cut/Fill/Net/Total are always RAW geometric volumes — deliberately unaffected by `swell`/`shrink`, so they keep answering "does this reach target elevation" regardless of what material's configured); `density` (t/m³ metric, lb/yd³ imperial) and `swell`/`shrink` (multipliers, default 1×) instead populate a separate Material section, standard Bank/Loose/Compacted earthworks convention — Adjusted Cut = raw × swell (loose/haul volume, bigger — excavating adds air voids), Adjusted Fill = raw ÷ shrink (loose/borrow volume needed, also bigger — the raw fill is already the compacted target void), plus Cut/Fill tonnage computed from the raw (not adjusted) volume, since swell/shrink change volume, not mass; the section only appears once at least one of `density`/`swell`/`shrink` is actually configured, no separate toggle. A `stale` field on the `om-measure` readout flags the brief window between a footprint committing and its elevation sample resolving, so a consumer doesn't read numbers left over from a prior footprint as current), `vega-lite` (live charts bound to a layer's data), `dynamic-chart` (the same Vega-Lite rendering, but data-driven by a live DOM event instead of a layer — `on="<event-name>"` + `series-field="<name>"` reads `event.detail[seriesField]` as the chart's `values` on every matching event, redrawing at a fixed `width`; a feature "freezes" it for free by simply omitting that field the next time it fires, no separate pause API needed), and the BIM set `ifc-browser` / `feature-inspector` / `ifc-loader` / `ifc-clash`. Or write your own inline with HTML + a `<script type="om/widget">`. Adjacent compact button widgets (`zoom-controls`, `undo-redo`, `widgets-toggle`) **auto-cluster** into one control group (opt out per widget with `cluster="false"`), and `<om-map widgets-hidden>` / the `set-widgets-visible` action / `<om-widget type="widgets-toggle">` hide all authored chrome without destroying it — provider attribution and the license badge never hide. **Placement is managed**: `position` takes one of 8 logical, RTL-aware slots (`top-start`, `top-center`, `top-end`, `center-start`, `center-end`, `bottom-start`, `bottom-center`, `bottom-end`; legacy corner names alias) — same-slot widgets stack with flush edges and a shared gap, `order` sets in-slot ordering, and `position="manual"` opts out entirely (a plain block you style yourself, even outside the map). At map widths ≤640px, managed widgets automatically move into accessible top/end/bottom/start drawers; `fold="never"` keeps an essential control out, `widgets-fold="off"` disables folding, and `--om-widget-fold-breakpoint` changes the map-width threshold. Provider attribution is an in-flow member of `bottom-end` and the license badge of `bottom-start`, so neither covers a widget. A slot dims automatically while an open popup covers it (`widgets-dim="off"` to disable), except slots containing required chrome. Themeable from plain page CSS via custom properties: `om-map { --om-widget-bg: #111827; --om-widget-fg: #f9fafb; }` (also `-muted`, `-border`, `-hover-bg`, `-accent`), plus layout tokens (`--om-widget-inset-x/-y`, `--om-widget-gap-x/-y`, `--om-widget-opacity`, `--om-widget-radius`) or the no-CSS sugar `<om-map widget-style="gap:10 opacity:0.9">`. |
88
+ | `<om-overlay>` | Rich HTML anchored to a map location — a static `anchor="[lng, lat]"`, the current selection, or a feature's own geometry via `anchor-layer`/`anchor-feature-id`. Selection-anchored overlays scope with `layer` (one layer's picks) and `selection-type="click"`/`"hover"` (one pick type — give a click-opened popup `selection-type="click"` so hovering elsewhere doesn't drag it along). `{{field}}` interpolates the picked feature, HTML-escaped by default; `{{z}}` is a real depth-picked elevation when some layer under the cursor set `pickable="3d"` (terrain does this itself, so hovering the ground always has one) — empty otherwise, never a misleading 0. `clip-to-map` (opt-in) hides the overlay when its own box would spill past the map viewport, not just when its anchor leaves — for small transient tips that track the cursor, where an overhanging box would otherwise inflate the page's scroll overflow and cause visible view jitter. |
86
89
  | `<om-behavior>` | Declarative interactions: `on="click|hover|drag|load|data-loaded"` → a named action. |
87
90
  | `<om-story>` | A storyboard: `<om-step>` children fire actions on a timeline. Controlled by the `player` widget, behaviors, or `storyEl.play()/pause()/seek()`. |
88
91
  | `<om-fallback>` | What shows where scripts never run — chat-app/email file previews (iOS QuickLook renders HTML attachments with JS off), file managers, sandboxed webviews. A direct child of `<om-map>`, hidden automatically the moment the map boots; pages without one get a text-only default banner from the stylesheet. Good practice on any page that may travel as a file. The gate is pure CSS (`om-map:not(:defined)`), so `onlymapjs.css` must load without JS — a real `<link>`, a bundler-emitted sheet, or an inlined `<style>`. |
@@ -127,8 +130,8 @@ Numeric timestamps are always epoch **milliseconds**, never guessed as seconds.
127
130
  | **GeoTIFF / COG raster** | `<om-layer type="COGLayer" src="./dem.tif" min="0" max="1900" colormap="viridis">` | Cloud-Optimized GeoTIFFs stream tiles by Range request (lazy chunk); min/max restretch + colormap swaps are GPU uniforms, nodata → transparent, legend ramp derives automatically; plain 8-bit RGB COGs need no attributes at all |
128
131
  | **Zarr / GeoZarr raster** | `<om-layer type="ZarrLayer" src="./x.zarr" variable="temp" select="time=0" colormap="viridis" min="…" max="…">` | Chunked N-dimensional arrays (climate/weather grids, datacubes) rendered on the GPU (lazy chunk; built on `@developmentseed/deck.gl-zarr` + zarrita). Pin non-spatial dims with `select`; GeoZarr stores georeference themselves, a plain Zarr takes manual `bounds`/`crs`/`spatial-dims`; `min`/`max`/`colormap` reuse the COG pipeline. `src` can be any absolute URL — an external/remote store needs no server setup, but must be CORS-enabled (fetched directly in the browser) and public (authenticated stores are a follow-up) |
129
132
  | **Drone JPEG image overlay** | `<om-layer type="ImageOverlay" src="./survey.jpg" georeference="exif">` | Reads GPS, relative altitude, camera, focal length, and DJI gimbal XMP; computes a WGS84 footprint, bakes yaw/roll into the pixels, and renders through `BitmapLayer`. Persist the processed image plus returned bounds for reloads — see [docs/image-overlays.md](docs/image-overlays.md) |
130
- | **WebSocket stream** | `data="wss://feed" key="id" flush="250ms" source="myFormat"` | upsert-by-key, burst coalescing, auto-reconnect; decode any format via `OmMap.registerSource` |
131
- | **Polled REST snapshot** | `data="/api/fleet.json" refresh="5s"` | full-snapshot replace per poll; outages keep the last good data |
133
+ | **WebSocket stream** | `data="wss://feed" key="id" flush="250ms" source="myFormat"` | upsert-by-key, burst coalescing, auto-reconnect; shared while active and closed after the final descriptor owner releases it |
134
+ | **Polled REST snapshot** | `data="/api/fleet.json" refresh="5s"` | full-snapshot replace per poll; outages keep the last good data; removal/disposal stops polling |
132
135
  | **Tiled layer (XYZ / vector)** | `<om-layer type="TileLayer" data="…/{z}/{x}/{y}.png">` or `type="MVTLayer"` | a `{z}/{x}/{y}` `data` template is deck's tile URL — passed through, never fetched as rows; raster `TileLayer` gets a built-in `BitmapLayer` renderer, `MVTLayer` self-renders vector tiles (`get-*` accessors apply to tile features) |
133
136
  | **Draw store** | `data="draw:sketch"` | live in-memory GeoJSON feature store written by `<om-widget type="draw" target="sketch">` |
134
137
 
@@ -268,9 +271,13 @@ Plus `OmMap.snapshotIR(html)` to lock down what a manifest *means* in a snapshot
268
271
 
269
272
  ## 3D
270
273
 
271
- `ScenegraphLayer` instances glTF/GLB models at coordinates (no three.js, no loader wiring), `Tile3DLayer` consumes OGC 3D Tiles, `GeoJsonLayer` extrudes polygons (`extruded get-elevation="$height"`), and the camera tilts via `pitch`/`bearing` attributes. Terrain is one attribute: `<om-map terrain="terrarium">` raises a real DEM surface (keyless AWS tiles; `mapterhorn` keyless too, paired with a CARTO Positron drape by default; `maptiler-terrain` keyed; or a bring-your-own `{z}/{x}/{y}` DEM URL + `terrain-decoder`), geographic layers drape onto it automatically (per-layer `terrain="drape|offset|off"` overrides; 3D models sit ON the surface), `terrain-exaggeration` scales the relief, and `terrain-texture` drapes imagery. Terrain replaces an active basemap while on (flat canvas vs raised surface) and restores it when off. **BIM models require explicit terrain**: a georeferenced model's absolute elevation only means anything against a DEM surface, and the library never writes attributes for you — a model that resolves real elevation on a map without a `terrain` attribute raises an error through the validation channel at load time (any authored value satisfies it, including an explicit `terrain="off"` for flat-ground siting). Scene lighting is declarative: `<om-map lighting="daylight">` (presets `daylight`/`studio`/`flat`/`custom`, tuned via `lighting-ambient`, `lighting-sun`, `lighting-sun-azimuth`/`-elevation`, or `lighting-sun-date` for a solar-position sun) — attribute-backed, so lighting changes are undoable and story-steppable via the `set-lighting` action. `<om-widget type="lighting">` gives users native preset radios + tuning sliders over the same attributes. Converting IFC/CAD upstream: [docs/3d-assets.md](docs/3d-assets.md).
274
+ `ScenegraphLayer` instances glTF/GLB models at coordinates (no three.js, no loader wiring), `Tile3DLayer` consumes OGC 3D Tiles, `GeoJsonLayer` extrudes polygons (`extruded get-elevation="$height"`), and the camera tilts via `pitch`/`bearing` attributes. Terrain is one attribute: `<om-map terrain="terrarium">` raises a real DEM surface (keyless AWS tiles; `mapterhorn` keyless too, paired with a CARTO Positron drape by default; `maptiler-terrain` keyed; or a bring-your-own `{z}/{x}/{y}` DEM URL + `terrain-decoder`), geographic layers drape onto it automatically (per-layer `terrain="drape|offset|off"` overrides; 3D models sit ON the surface), `terrain-exaggeration` scales the relief, and `terrain-texture` drapes imagery. Terrain replaces an active basemap while on (flat canvas vs raised surface) and restores it when off. **BIM models require explicit terrain**: a georeferenced model's absolute elevation only means anything against a DEM surface, and the library never writes attributes for you — a model that resolves real elevation on a map without a `terrain` attribute raises an error through the validation channel at load time (any authored value satisfies it, including an explicit `terrain="off"` for flat-ground siting). When terrain IS active, `BIMLayer` automatically re-plants itself at its file's own `IfcMapConversion.OrthogonalHeight` (its real absolute elevation) instead of the local Z≈0 a flat basemap expects — no attribute to author, it just lands on the surface. Scene lighting is declarative: `<om-map lighting="daylight">` (presets `daylight`/`studio`/`flat`/`custom`, tuned via `lighting-ambient`, `lighting-sun`, `lighting-sun-azimuth`/`-elevation`, or `lighting-sun-date` for a solar-position sun) — attribute-backed, so lighting changes are undoable and story-steppable via the `set-lighting` action. `<om-widget type="lighting">` gives users native preset radios + tuning sliders over the same attributes. Converting IFC/CAD upstream: [docs/3d-assets.md](docs/3d-assets.md).
275
+
276
+ **Clip box.** `<om-map clip-box-min="[lng,lat,elev]" clip-box-max="[lng,lat,elev]">` cuts a real axis-aligned 3D box through the whole scene — every layer is clipped by default (`clip="off"` opts a layer out), `clip-box-invert` shows outside the box instead of inside, and `clip-box-highlight` dims clipped-out geometry instead of discarding it (a non-destructive preview). Works on any layer type, including georeferenced `Tile3DLayer`/`BIMLayer` content, so a dense BIM scene can be cut open to see what's inside. Attribute-backed (undoable, story-steppable) via the `set-clip-box` action; `<om-widget type="clip-box">` gives users the same six numeric fields plus invert/highlight toggles. v1 is axis-aligned only — rotated boxes are a documented follow-up.
272
277
 
273
- **Per-element picking on 3D Tiles (BIM/photogrammetry).** A `pickable` `Tile3DLayer` picks a whole tile; adding `pick-features` picks an individual ELEMENT a wall, a window, one IFC product — and the `selection` carries that element's `featureId`, `properties` and `class`, resolved from the tile's own `EXT_mesh_features` + `EXT_structural_metadata` (`feature-id-property` selects the ID set, default `_FEATURE_ID_0`). Isolate/hide/ghost are declarative and mirror the vector filter vocabulary — `feature-filter-field="component"` names the metadata field, then `isolate-features`/`hide-features`/`ghost-features` take JSON value lists (`ghost-opacity` tunes the fade); hiding is a shader discard, so a hidden element also stops being pickable and you can select what's behind it, and `isolate-features` is exclusive (anything unlisted is hidden). Because they're attributes they're undoable and story-steppable. Underneath, `feature-styles` recolours, fades or highlights elements by feature ID — an array indexed by id of `{color, strength, opacity}` uploaded as a small lookup texture, so restyling costs no refetch and no re-tesselation. IDs stored per texel (how photogrammetry classification ships) are sampled per FRAGMENT, so element boundaries are pixel-sharp rather than snapped to triangle edges. Multi-material models fan out correctly (glTF allows one material per primitive, so a five-material house is five primitives and a real IFC export is often dozens); only genuinely instanced i3dm content falls back to tile-granularity picking. Two limits, stated plainly: texture-backed IDs need `load-options` with `gltf.loadImages`/`loadBuffers` **and** `image: {"type": "data"}` (without it loaders.gl reads the whole ID texture back through a canvas once per vertex and the tileset takes minutes to appear); and `opacity` below 1 currently blanks the model, so use `strength` to tint for now. See `dev/examples/ferry-building-features.html`.
278
+ **XY snapping**, for draw/measure vertex capture. `<om-map snap="vertex edge midpoint" snap-tolerance="12">` (pixels, default 12) refines whatever deck already picked under the cursor to the nearest vertex, edge, or edge midpoint of that feature's own geometry a two-stage design, not a spatial index: deck's own hover/click pick narrows to one feature every frame for free, and only THAT feature's geometry gets searched, on the CPU, when snapping is on. Applies to every vector layer by default (`snap="off"` opts a layer out, mirroring clip box's own default) and to a BIMLayer's own edge/crease overlay its real wall corners and edges, converted from the model's local mesh coordinates back to `[lng, lat]` automatically, so a drawn footprint can snap onto an actual building rather than free-clicking near it. Vertex beats midpoint beats edge when more than one is in range. Hold Space to place a point nearby without snapping (standard CAD/GIS convention); a small "Snapped: " tip shows which agent fired. XY only a snapped vertex takes the tolerance-matched feature's horizontal position, not its exact elevation (drawn geometry still drapes onto terrain the normal way). See `dev/examples/features/widgets/3d-snapping-cutting-tools.html`.
279
+
280
+ **Per-element picking on 3D Tiles (BIM/photogrammetry).** `pickable="3d"` (instead of a bare `pickable`) runs deck's depth-pick pass, so a click/hover's coordinate carries a real 3rd (elevation) component instead of the ray∩z=0-plane guess a flat pick gives you — needed for any tool that reads *where in 3D* you picked, not just *what*; terrain sets this on itself automatically. A `pickable` `Tile3DLayer` picks a whole tile; adding `pick-features` picks an individual ELEMENT — a wall, a window, one IFC product — and the `selection` carries that element's `featureId`, `properties` and `class`, resolved from the tile's own `EXT_mesh_features` + `EXT_structural_metadata` (`feature-id-property` selects the ID set, default `_FEATURE_ID_0`). Isolate/hide/ghost are declarative and mirror the vector filter vocabulary — `feature-filter-field="component"` names the metadata field, then `isolate-features`/`hide-features`/`ghost-features` take JSON value lists (`ghost-opacity` tunes the fade); hiding is a shader discard, so a hidden element also stops being pickable and you can select what's behind it, and `isolate-features` is exclusive (anything unlisted is hidden). Because they're attributes they're undoable and story-steppable. Underneath, `feature-styles` recolours, fades or highlights elements by feature ID — an array indexed by id of `{color, strength, opacity}` — uploaded as a small lookup texture, so restyling costs no refetch and no re-tesselation. IDs stored per texel (how photogrammetry classification ships) are sampled per FRAGMENT, so element boundaries are pixel-sharp rather than snapped to triangle edges. Multi-material models fan out correctly (glTF allows one material per primitive, so a five-material house is five primitives and a real IFC export is often dozens); only genuinely instanced i3dm content falls back to tile-granularity picking. Two limits, stated plainly: texture-backed IDs need `load-options` with `gltf.loadImages`/`loadBuffers` **and** `image: {"type": "data"}` (without it loaders.gl reads the whole ID texture back through a canvas once per vertex and the tileset takes minutes to appear); and `opacity` below 1 currently blanks the model, so use `strength` to tint for now. See `dev/examples/features/terrain-3d/inspect-a-bim-model.html` (per-element picking, isolate/hide/ghost, the model tree) and `dev/examples/features/widgets/3d-snapping-cutting-tools.html` (`pickable="3d"` with `{{z}}`, the clip box, region export and snapping).
274
281
 
275
282
 
276
283
  **The BIM widgets.** Three built-ins turn a `pick-features` tileset into a usable model browser without page JS. `ifc-browser` (renamed from `ifc-legend`, which still works — it browses a model, it doesn't explain symbology) groups the model by any property-table field (`fields="ifcClass material container spatialPath"`), counts each value, and gives every row I/H/G buttons that write `isolate-features`/`hide-features`/`ghost-features` — so the panel is a UI over the attributes, and everything it does is undoable and story-steppable; it keeps the companion outline layer's `filter-categories` in step too, since hidden elements would otherwise leave their edges behind. It also offers whichever **model trees** the file supports — spatial (IfcSite → IfcBuilding → IfcStorey), type, system, and classification — each expandable, counts aggregated upward, the same I/H/G on every node so isolating a storey, a duct system or a CCS code reaches everything beneath it. Spatial isn't privileged: on a real Danish project the classification tree covered 3,415 elements to spatial's 660. A tree isn't a second mode or a second widget — it's a group-by on a metadata column, which is why it reuses the visibility attributes untouched (and why there's one browser per layer: those attributes are single-valued). `loadIfc` extracts every hierarchy a model has and emits a column only when the file populates it, so a tree that would render empty is never offered. `feature-inspector` (renamed from `ifc-inspector`, which still works — its body is generic property-row rendering with no IFC dependency, so it works unchanged on a non-IFC `pick-features` layer) shows the picked element's properties (`fields="ifcClass material container netVolume"`). `ifc-loader` is a drop zone that parses an `.ifc` in the browser and builds both layers for it; add `federate` and one drop zone accepts SEVERAL models into a co-registered scene, each with its own visibility toggle — the shape coordination tools use, because a real project has architecture, structure, mechanical, electrical and plumbing rather than two. `ifc-clash` compares any two loaded models. Colour is a question, not decoration: `feature-color-by` colours categorically by a field, `feature-color-scale` graduates over a numeric one, `feature-palette` and `feature-color-strength` tune them — and setting *neither* is the default, leaving the model in its own IFC surface colours. Widget scripts reach the same data through `ctx.features(layerId)` (the decoded property table) plus the `features` watch token, which fires when the table decodes off the first tile.
@@ -279,15 +286,15 @@ Plus `OmMap.snapshotIR(html)` to lock down what a manifest *means* in a snapshot
279
286
 
280
287
  It is deliberately **not** Navisworks. An axis-aligned box test over-reports anything diagonal, and it cannot tell a resolved penetration from a collision: on that same pair, 676 of the results are air diffusers sitting inside ceilings, which is how buildings work. Treat the number as *"here are the places two disciplines occupy the same box — go look"*, not as a defect list. Persisting and sharing results is what BCF exists for and is not attempted here.
281
288
 
282
- **In-browser IFC.** `loadIfc(bytes)` parses an `.ifc` with web-ifc (WASM) and returns a 3D Tiles model held entirely in memory — nothing uploaded, nothing written to disk. Because the output *is* a tileset, per-element picking, styling, `site-*` and isolate/hide/ghost work on it unchanged. It returns `tilesetUrl`/`edgesUrl` blob URLs, the property table, the georeferencing (`lonLat`, `heading`, `scale`, and a `headingSource` that distinguishes a real reading from an assumption), per-phase `timings`, and a `revoke()` you must call when swapping models. web-ifc is MPL-2.0 and is *not* a package dependency — it's dynamic-imported from unpkg on first use, so it never reaches the bundle and pages that never open an IFC pay nothing (measured: the IFC chunk is 8.5 KB gzipped and contains only the URL). **For offline, air-gapped or strict-CSP deployments**, `npm run vendor:web-ifc public/vendor/web-ifc` copies the four files you serve yourself, then `wasm-path="/vendor/web-ifc/"` on the loader — 1.37 MB gzipped of *static assets*, with the JS bundle unchanged. Note that web-ifc is only one network dependency: a georeferenced model also switches a basemap on, so an offline page wants `basemap="none"` and `telemetry="off"` too. See `dev/examples/bim-workbench.html`.
283
- **Georeferencing is declarative too.** `site-origin="[lng, lat]"` (optionally `[lng, lat, elevation]`), `site-heading` (a bearing, degrees clockwise from true north) and `site-scale` place a model from the markup instead of baking a position into the tileset — `site-origin` overrides whatever the root transform carries, and rotation/scale pivot on the model's own anchor so a heading change spins the building about itself. They work on `Tile3DLayer` and on `PathLayer` (whose paths are then local east/north/up metres), which is what lets an IFC mesh and its outline overlay move together. This matters more than it sounds: authoring tools ship a default project location and a default is indistinguishable from a survey — the buildingSMART Medical-Dental Clinic sample carries Revit's Boston default, the Duplex a Chicago city-centre point — while `IfcMapConversion` is absent from most IFC2x3 exports and `TrueNorth` is routinely unset. Being attributes, corrections are undoable and story-steppable rather than a reconversion. Whenever a model resolves anything short of a real `IfcMapConversion` — `IfcSite` coordinates or no georeference at all — `BIMLayer` and `ifc-loader` raise a structured `"warning"` through the same validation channel other `om-layer` errors use (the `validate` on-page panel, `om-validation-error`'s `detail.warnings`), rather than only a status-line sentence a human has to notice; it never fails the map (`valid` stays `true`), it just names the layer and points at `site-origin`/`site-heading` as the fix. See `dev/examples/bim-sample.html`.
289
+ **In-browser IFC.** `loadIfc(bytes)` parses an `.ifc` with web-ifc (WASM) and returns a 3D Tiles model held entirely in memory — nothing uploaded, nothing written to disk. Because the output *is* a tileset, per-element picking, styling, `site-*` and isolate/hide/ghost work on it unchanged. It returns `tilesetUrl`/`edgesUrl` blob URLs, the property table, the georeferencing (`lonLat`, `heading`, `scale`, and a `headingSource` that distinguishes a real reading from an assumption), per-phase `timings`, and a `revoke()` you must call when swapping models. web-ifc is MPL-2.0 and is *not* a package dependency — it's dynamic-imported from unpkg on first use, so it never reaches the bundle and pages that never open an IFC pay nothing (measured: the IFC chunk is 8.5 KB gzipped and contains only the URL). **For offline, air-gapped or strict-CSP deployments**, `npm run vendor:web-ifc public/vendor/web-ifc` copies the four files you serve yourself, then `wasm-path="/vendor/web-ifc/"` on the loader — 1.37 MB gzipped of *static assets*, with the JS bundle unchanged. Note that web-ifc is only one network dependency: a georeferenced model also switches a basemap on, so an offline page wants `basemap="none"` and `telemetry="off"` too. See `dev/examples/features/terrain-3d/inspect-a-bim-model.html`.
290
+ **Georeferencing is declarative too.** `site-origin="[lng, lat]"` (optionally `[lng, lat, elevation]`), `site-heading` (a bearing, degrees clockwise from true north) and `site-scale` place a model from the markup instead of baking a position into the tileset — `site-origin` overrides whatever the root transform carries, and rotation/scale pivot on the model's own anchor so a heading change spins the building about itself. They work on `Tile3DLayer` and on `PathLayer` (whose paths are then local east/north/up metres), which is what lets an IFC mesh and its outline overlay move together. This matters more than it sounds: authoring tools ship a default project location and a default is indistinguishable from a survey — the buildingSMART Medical-Dental Clinic sample carries Revit's Boston default, the Duplex a Chicago city-centre point — while `IfcMapConversion` is absent from most IFC2x3 exports and `TrueNorth` is routinely unset. Being attributes, corrections are undoable and story-steppable rather than a reconversion. Whenever a model resolves anything short of a real `IfcMapConversion` — `IfcSite` coordinates or no georeference at all — `BIMLayer` and `ifc-loader` raise a structured `"warning"` through the same validation channel other `om-layer` errors use (the `validate` on-page panel, `om-validation-error`'s `detail.warnings`), rather than only a status-line sentence a human has to notice; it never fails the map (`valid` stays `true`), it just names the layer and points at `site-origin`/`site-heading` as the fix. See `dev/examples/features/terrain-3d/inspect-a-bim-model.html`.
284
291
 
285
292
  ## Programmatic surface
286
293
 
287
- - **`OmMap.*`** — `validate`, `snapshotIR`, `resolveImageOverlay`, `registerLayer`, `registerWidget`, `registerAction`, `registerSource`, `registerFormat`, `registerBasemap`, `configureBasemap`, `configureData`, `configureTelemetry`, `configureLicense`, `getLayerSchema`
294
+ - **`OmMap.*`** — `validate`, `snapshotIR`, `snapshotDescriptorIR`, `resolveImageOverlay`, `registerLayer`, `registerWidget`, `registerAction`, `registerSource`, `registerFormat`, `registerBasemap`, `configureBasemap`, `configureData`, `configureTelemetry`, `configureLicense`, `getLayerSchema`
288
295
  - **`@nika-js/onlymap/deck`** — the bundled deck.gl classes (`CompositeLayer`, `TileLayer`, …) for building custom layer types: shims must extend the same class hierarchy the core renders with, not a second installed deck.gl copy. Recipe: [docs/custom-layers.md](docs/custom-layers.md)
289
296
  - **On a `<om-map>` element** — `ready` (promise), `flyTo(coords, zoom?)`, `setLayerVisible(id, bool)`, `getLayers()`, `emit(action, payload)`, `snapshot(opts?)` (canvas-only PNG of basemap + layers at device pixels — DOM widgets/overlays and provider attribution are NOT captured, so exports must render credits themselves; `{as: "blob"}` for files, default dataURL); the `om-view-changed` event fires once the camera settles (debounced; `detail` = `{longitude, latitude, zoom, pitch, bearing, origin}`, where `origin` is `"user"` for gesture-driven bursts vs `"programmatic"` for API/story moves — the echo-suppression signal for state sync) — the camera-persistence hook; the `om-map-point` event (`detail = {coordinate: [lng,lat]|null, kind: "click"|"hover"}`) fires on every click/hover with the map coordinate, including empty-map clicks picks discard — the hook for custom capture tools the built-in draw widget doesn't cover; the `om-tileset-load` event (`detail = {layerId, tileset}`) surfaces a `Tile3DLayer`'s live deck `Tileset3D` for tools that need the real tileset (e.g. region export), not the IR; `document.querySelector("om-map")` is fully typed
290
- - **`MapController`** — the framework-grade programmatic front-end (typed `LayerDescriptor`s → the same reconcile core, no DOM manifest): `setLayers`, `watch`, `emit`, camera methods, `injectPick`, `ready`, `snapshot`, an `onViewChange(view, origin)` option (the `om-view-changed` twin), plus `onMapPoint` / `onTilesetLoad` options (the `om-map-point` / `om-tileset-load` twins). The React adapter rides it; usable directly from vanilla TS or other frameworks
297
+ - **`MapController`** — the framework-grade programmatic front-end (typed `LayerDescriptor`s → the same reconcile core, no DOM manifest): `setLayers`, `watch`, `emit`, camera methods, `injectPick`, `ready`, `snapshot`, `suspend`/`resume`, an `onViewChange(view, origin)` option (the `om-view-changed` twin), plus `onMapPoint` / `onTilesetLoad` options (the `om-map-point` / `om-tileset-load` twins). Active descriptors own reference-counted fetch/poll/socket handles: layer removal, transport-option change, suspend, and destroy release them. Accessor props normally take functions; schema-declared accessors also accept restricted expression strings such as `getPosition: "[$lon, $lat]"`, which makes the descriptor JSON-safe for native/cross-process bridges. `snapshotDescriptorIR(descriptors)` resolves that lane without fetching URL data. The React adapter rides the function form; vanilla/native hosts may use either
291
298
  - **`getStore(token)`** — the external-store contract: per-token `{subscribe, getSnapshot}` stores (`viewport`/`selection`/`layers`/`data:<id>`) with cached immutable plain-data snapshots and `origin` tagging — directly consumable by `useSyncExternalStore` (the React adapter's own hooks ride it), MobX autoruns, Redux listeners, Zustand mirrors. ~20-line integration-tested recipes for Redux Toolkit, MobX/mobx-keystone, Zustand, and Jotai: [docs/external-stores.md](docs/external-stores.md)
292
299
  - **Testing** — `mountForTest`, and imports are SSR-safe (importing in Node/jsdom never touches browser globals)
293
300
 
@@ -304,7 +311,13 @@ A license key lifts all limits and removes the badge:
304
311
  OmMap.configureLicense("om_live_…"); // or once, in code
305
312
  ```
306
313
 
307
- Keys are self-verifying signed tokens (no network round-trip, works offline and in CI) bound to your domains. Licensing: https://www.nikaplanet.com/onlymap.
314
+ Packaged native hosts can verify an app-scoped key by passing platform-derived identity:
315
+
316
+ ```ts
317
+ await OmMap.configureLicense("om_live_…", { appId: "com.example.riders" });
318
+ ```
319
+
320
+ The `appId` must come from trusted native build metadata, never page/bridge input — the library cannot verify where it came from. Unlike `domains`, which the browser pins, the `apps` claim is asserted by the caller, so prefer keys minted with **both** claims (include the packaged host's own origins) and treat app scoping as a deterrent rather than enforcement. Keys are self-verifying signed tokens (no network round-trip, works offline and in CI) bound to declared domains and/or exact app identifiers. Licensing: https://www.nikaplanet.com/onlymap.
308
321
 
309
322
  ## Telemetry
310
323
 
@@ -312,7 +325,7 @@ The library reports one **deployment-scoped** usage snapshot per map per page lo
312
325
 
313
326
  ## Not implemented yet (honestly)
314
327
 
315
- Mapbox GL basemaps, depth-interleaved 3D compositing, globe projection, SSE transport, multi-field filters, `dblclick` behaviors, the `transform` data pipeline, the typed fluent builder, and stories/draw as React components (both work via the HTML manifest). On `pick-features`: instanced (i3dm) tiles keep tile-granularity picking, and `feature-styles`' `opacity` below 1 is still being validated. On BIM: clash detection is bounding-box only (no mesh-level test, and it cannot tell a resolved penetration from a collision), there is no BCF export, no equivalent of Navisworks' Auto Reveal, and prepared tilesets converted before the `bbox*` columns existed cannot take part in a clash pass — `strength` tinting and `opacity: 0` (hide, a shader discard) work.
328
+ Mapbox GL basemaps, depth-interleaved 3D compositing, globe projection, SSE transport, multi-field filters, `dblclick` behaviors, the `transform` data pipeline, the typed fluent builder, and stories/draw as React components (both work via the HTML manifest). On `pick-features`: instanced (i3dm) tiles keep tile-granularity picking, and `feature-styles`' `opacity` below 1 is still being validated. On BIM: clash detection is bounding-box only (no mesh-level test, and it cannot tell a resolved penetration from a collision), there is no BCF export, no equivalent of Navisworks' Auto Reveal, and prepared tilesets converted before the `bbox*` columns existed cannot take part in a clash pass — `strength` tinting and `opacity: 0` (hide, a shader discard) work. On the measure widget's `volume` mode: the per-cell grid integration measures against the map's active terrain DEM only — measuring against an imported design surface or a previous survey (period-over-period reconciliation) and a cut/fill heat-map overlay are not implemented yet, and a snapped footprint vertex takes the matched feature's horizontal position only — its exact elevation is not threaded through (see **XY snapping** above).
316
329
 
317
330
  ## Going deeper
318
331
 
@@ -19,7 +19,7 @@ async function v(N = {}) {
19
19
  if (n) {
20
20
  const { createRequire: I } = await import(
21
21
  /*webpackIgnore:true*/
22
- "./lerc-CbTjQ7uI.js"
22
+ "./lerc-gKDDtc69.js"
23
23
  ).then((g) => g._);
24
24
  var O = I(import.meta.url);
25
25
  }
@@ -1,4 +1,4 @@
1
- import { C as ny, L as sy, M as oy, m as ay, c as ka, z as oc, a as Sd, G as sm, V as ly, W as cy, b as uy, g as hy, d as Vu, f as Ed, e as dy, l as py, u as fy, D as Qf, h as my } from "./index-1UgNlfGR.js";
1
+ import { C as ny, L as sy, M as oy, m as ay, c as ka, z as oc, a as Sd, G as sm, V as ly, W as cy, b as uy, g as hy, d as Vu, f as Ed, e as dy, l as py, u as fy, D as Qf, h as my } from "./index-Cxo9mCw_.js";
2
2
  const Cd = Math.PI / 180, gy = 180 / Math.PI;
3
3
  function em(Pe, Q = 0) {
4
4
  const me = Math.min(180, Pe) * Cd;
@@ -25200,6 +25200,10 @@ class Ry {
25200
25200
  setEffects(Q) {
25201
25201
  this.overlay.setProps({ effects: Q }), this.map.triggerRepaint();
25202
25202
  }
25203
+ /** XY snapping (spec: issue #34 Part A) — deck's own pick tolerance, 0 by default; MapboxOverlay forwards the same DeckProps a standalone Deck instance takes. */
25204
+ setPickingRadius(Q) {
25205
+ this.overlay.setProps({ pickingRadius: Q });
25206
+ }
25203
25207
  /**
25204
25208
  * Live basemap switch (spec: "Basemap presets & switching"). Deck layers
25205
25209
  * survive BY CONSTRUCTION: the MapboxOverlay is a map control, not style
@@ -25222,9 +25226,15 @@ class Ry {
25222
25226
  me && (me.setAttribute("data-om-mandated-chrome-item", "attribution"), me.style.margin = "0", this.attributionHost.appendChild(me), this.attributionElement = me);
25223
25227
  }
25224
25228
  }
25225
- /** Toggle map interactions (spec: "Manual Drawing", D4) — used to suspend double-click-zoom while a draw tool is active. */
25229
+ /**
25230
+ * Toggle map interactions (spec: "Manual Drawing", D4 — doubleClickZoom;
25231
+ * issue #34's clip-box gizmo — dragPan) — used to suspend a MapLibre
25232
+ * gesture while a tool needs sole ownership of it (double-click-zoom
25233
+ * while a draw tool is active; drag-pan while a clip-box face handle is
25234
+ * grabbed, or it fights the map's own camera drag for every mousemove).
25235
+ */
25226
25236
  setInteractive(Q) {
25227
- Q.doubleClickZoom === !1 ? this.map.doubleClickZoom.disable() : Q.doubleClickZoom === !0 && this.map.doubleClickZoom.enable();
25237
+ Q.doubleClickZoom === !1 ? this.map.doubleClickZoom.disable() : Q.doubleClickZoom === !0 && this.map.doubleClickZoom.enable(), Q.dragPan === !1 ? this.map.dragPan.disable() : Q.dragPan === !0 && this.map.dragPan.enable();
25228
25238
  }
25229
25239
  isLoaded() {
25230
25240
  return this.initialized;
package/dist/basemap.d.ts CHANGED
@@ -83,6 +83,8 @@ export declare class MapLibreBasemapAdapter {
83
83
  * map's render pass.
84
84
  */
85
85
  setEffects(effects: DeckProps["effects"]): void;
86
+ /** XY snapping (spec: issue #34 Part A) — deck's own pick tolerance, 0 by default; MapboxOverlay forwards the same DeckProps a standalone Deck instance takes. */
87
+ setPickingRadius(pickingRadius: number): void;
86
88
  /**
87
89
  * Live basemap switch (spec: "Basemap presets & switching"). Deck layers
88
90
  * survive BY CONSTRUCTION: the MapboxOverlay is a map control, not style
@@ -98,9 +100,16 @@ export declare class MapLibreBasemapAdapter {
98
100
  * remount, since `customAttribution` is fixed at construction.
99
101
  */
100
102
  private mountAttribution;
101
- /** Toggle map interactions (spec: "Manual Drawing", D4) — used to suspend double-click-zoom while a draw tool is active. */
103
+ /**
104
+ * Toggle map interactions (spec: "Manual Drawing", D4 — doubleClickZoom;
105
+ * issue #34's clip-box gizmo — dragPan) — used to suspend a MapLibre
106
+ * gesture while a tool needs sole ownership of it (double-click-zoom
107
+ * while a draw tool is active; drag-pan while a clip-box face handle is
108
+ * grabbed, or it fights the map's own camera drag for every mousemove).
109
+ */
102
110
  setInteractive(opts: {
103
111
  doubleClickZoom?: boolean;
112
+ dragPan?: boolean;
104
113
  }): void;
105
114
  isLoaded(): boolean;
106
115
  getViewport(): ViewportLike;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Clip box controller (spec: issue #34 §"Cutting / Clipping" — visible,
3
+ * draggable box). `clip-box.ts` owns the pure shader/patch math; this file
4
+ * owns the DOM-touching half: a wireframe box + 6 face-handle gizmos so the
5
+ * box is something you can SEE and GRAB, not just type numbers into. Same
6
+ * architecture as `measure-controller.ts`'s volume-mode height gizmo (real
7
+ * `<om-layer>` elements driven by `core.patchAnimatedProps` on the per-frame
8
+ * channel) — generalized from that gizmo's one hardcoded vertical axis to 6
9
+ * axis-constrained handles (one per box face), since a face can move along
10
+ * lng, lat, OR elevation depending on which one it is.
11
+ *
12
+ * Owned per `<om-map>` (the draw/measure precedent) and kept alive for as
13
+ * long as a clip box is configured, independent of whether the `clip-box`
14
+ * widget is even mounted — set clip-box-min/max programmatically and the
15
+ * wireframe still shows up, the same way the box itself still clips content
16
+ * with no widget present.
17
+ */
18
+ import type { PickingInfo } from "@deck.gl/core";
19
+ import type { RuntimeCore, GizmoDragEvent } from "./runtime-core";
20
+ export declare class ClipBoxController {
21
+ private readonly mapEl;
22
+ private readonly core;
23
+ private grab;
24
+ /**
25
+ * Whether the visual box + drag handles are showing. OFF by default and
26
+ * OFF whenever a drag/hover isn't in progress — the box's CLIPPING effect
27
+ * is entirely independent of this (that's driven straight off the
28
+ * clip-box-min/max attributes, always active once set). This is purely
29
+ * about the interactive gizmo: reported that its handles — always
30
+ * pickable once rendered, several of them sitting right around screen
31
+ * center where a normal pan gesture naturally starts — were hijacking
32
+ * ordinary map panning. Gating them behind an explicit "Edit box" toggle
33
+ * (see the clip-box-edit action) means panning is never at risk, and the
34
+ * gizmo only exists on screen when someone actually asked to adjust it.
35
+ */
36
+ private editing;
37
+ constructor(mapEl: Element, core: RuntimeCore);
38
+ setEditing(active: boolean): void;
39
+ isEditing(): boolean;
40
+ /** Re-reads the current box from `core` and repaints the wireframe + handles — call after ANY change, drag or attribute. */
41
+ syncVisuals(): void;
42
+ /**
43
+ * Repaints the wireframe + handles for a GIVEN box — factored out of
44
+ * `syncVisuals()` so `handleGizmoDrag` can drive this every mousemove
45
+ * frame from its own LOCAL `grab.liveBox` without going anywhere near
46
+ * `core.setClipBox`/DOM attributes (see that method's own doc comment).
47
+ * `patchAnimatedProps` alone is cheap and frame-rate-safe; this is the
48
+ * same reason `measure-controller.ts`'s volume gizmo redraws through
49
+ * `patchAnimatedProps` on every drag frame instead of writing anything
50
+ * to the DOM until the gesture actually ends.
51
+ */
52
+ private renderBox;
53
+ /**
54
+ * Grab a face handle. Computes a LOCAL screen-space drag basis for this
55
+ * handle's axis by projecting a known small step (`AXIS_EPSILON`) along
56
+ * it — the same linearization trick `measure-controller.ts`'s height
57
+ * gizmo uses for its one hardcoded vertical axis, generalized to a full
58
+ * 2D screen vector so it's correct for lng/lat faces too (which project
59
+ * to some diagonal screen direction depending on the camera's bearing/
60
+ * pitch, not a pure vertical like elevation).
61
+ */
62
+ handleGizmoDragStart(info: PickingInfo, event: GizmoDragEvent): void;
63
+ /**
64
+ * Every mousemove during a drag updates ONLY `grab.liveBox` and repaints
65
+ * the gizmo via `renderBox` (the cheap `patchAnimatedProps` channel) —
66
+ * it does NOT write clip-box-min/max attributes or call
67
+ * `core.setClipBox` per frame. Those go through `RuntimeCore.applyLayers`,
68
+ * which rebuilds the ENTIRE layer list (every layer, not just this box) —
69
+ * fine for an occasional attribute change, but firing it on every one of
70
+ * a mousemove's ~60 frames/sec froze the whole map (reported: "dragging
71
+ * the gizmos ... the whole map just hangs"). The real content stays
72
+ * clipped to the box's PRE-drag position for the gesture's duration and
73
+ * catches up in ONE `applyLayers` pass at `handleGizmoDragEnd` — the same
74
+ * "cheap live preview, expensive commit once at the end" split the proven
75
+ * volume-measurement gizmo already uses (it never touches the DOM mid-
76
+ * drag either).
77
+ */
78
+ handleGizmoDrag(info: PickingInfo): void;
79
+ /**
80
+ * The ONE expensive commit for the whole gesture: write the final
81
+ * min/max to the authored attributes — same channel the widget's own
82
+ * number inputs use, so the drag is undoable (history.ts's 500ms
83
+ * coalesce window merges it with any other same-attribute writes into a
84
+ * single step) and reflects live in the widget. The map's own
85
+ * MutationObserver picks this up and calls core.setClipBox, which
86
+ * re-applies the ACTUAL clip to real layers and (via that same observer
87
+ * hook) resyncs this controller's own gizmo from the now-authoritative
88
+ * state.
89
+ */
90
+ handleGizmoDragEnd(): void;
91
+ private ensureLayers;
92
+ }
93
+ export declare function getClipBoxController(mapEl: Element, core: RuntimeCore): ClipBoxController;
94
+ export declare function peekClipBoxController(mapEl: Element): ClipBoxController | undefined;