@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.
- package/CHANGELOG.md +106 -1
- package/README.md +28 -15
- package/dist/{LercDecode.es-CJnypw8j.js → LercDecode.es-D5in29tf.js} +1 -1
- package/dist/{basemap-DrQ0-eyR.js → basemap-C0pFT3AO.js} +13 -3
- package/dist/basemap.d.ts +10 -1
- package/dist/clip-box-controller.d.ts +94 -0
- package/dist/clip-box.d.ts +109 -0
- package/dist/data-layer.d.ts +34 -4
- package/dist/draw-controller.d.ts +75 -1
- package/dist/draw.d.ts +22 -4
- package/dist/elements/om-map.d.ts +20 -0
- package/dist/elements/om-overlay.d.ts +12 -0
- package/dist/geodesy.d.ts +32 -3
- package/dist/{geoparquet-Dix4lTNy.js → geoparquet-By98JVB0.js} +1 -1
- package/dist/html-data.d.ts +2 -2
- package/dist/{index-BQMjW5w0.js → index-Bx9GFkrn.js} +1 -1
- package/dist/{index-CXPaeisL.js → index-CqC4sW_k.js} +1 -1
- package/dist/{index-1UgNlfGR.js → index-Cxo9mCw_.js} +30947 -27785
- package/dist/{index-B_1PPJgC.js → index-olfncHIq.js} +1 -1
- package/dist/{index-CAuT5j9Y.js → index-tnlYDALL.js} +2 -2
- package/dist/index.d.ts +8 -3
- package/dist/ir-snapshot.d.ts +3 -1
- package/dist/layers/bim-layer.d.ts +30 -7
- package/dist/layers/feature-mesh-layer.d.ts +1 -1
- package/dist/layers/popup-layer.d.ts +13 -0
- package/dist/legend-spec.d.ts +1 -1
- package/dist/{lerc-CbTjQ7uI.js → lerc-gKDDtc69.js} +2 -2
- package/dist/license.d.ts +25 -4
- package/dist/measure-controller.d.ts +482 -4
- package/dist/onlymap.standalone.js +53976 -50804
- package/dist/onlymapjs.js +42 -40
- package/dist/parse-manifest.d.ts +3 -0
- package/dist/programmatic.d.ts +34 -5
- package/dist/{raster-0b0nSHUh.js → raster-Cl5m3KsC.js} +2 -2
- package/dist/{raster-pipeline-D8siq7-4.js → raster-pipeline-hJGxIwYx.js} +1 -1
- package/dist/react/om-layer.d.ts +6 -1
- package/dist/react.js +167 -160
- package/dist/region-export-controller.d.ts +37 -0
- package/dist/region-export.d.ts +56 -0
- package/dist/runtime-core.d.ts +150 -0
- package/dist/selection.d.ts +11 -1
- package/dist/site-placement.d.ts +22 -0
- package/dist/snapping.d.ts +88 -0
- package/dist/terrain-heightfield.d.ts +53 -0
- package/dist/terrain-sample.d.ts +30 -0
- package/dist/terrain.d.ts +6 -1
- package/dist/units.d.ts +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/volumetrics-run.d.ts +13 -0
- package/dist/volumetrics-worker.d.ts +1 -0
- package/dist/volumetrics.d.ts +201 -0
- package/dist/{zarr-DCYro_Vs.js → zarr-hRDavRGV.js} +2 -2
- package/docs/3d-assets.md +49 -1
- package/docs/live-data.md +26 -1
- package/docs/react.md +9 -1
- package/docs/testing.md +2 -0
- package/llms.txt +10 -5
- package/onlymapjs.html-data.json +161 -2
- package/package.json +4 -2
- package/skills/onlymapjs/SKILL.md +11 -1
- package/skills/onlymapjs/references/react.md +2 -2
- package/skills/onlymapjs/references/syntax.md +20 -8
- package/skills/onlymapjs/references/testing.md +6 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Volumetric integration core (spec: issue #35 "Volumetric measurement —
|
|
3
|
+
* cut/fill against a base surface, with a published error model"; grid
|
|
4
|
+
* design per §C of the acceptance spec: C1 tangent-plane grid, C2 bulk
|
|
5
|
+
* heightfield sampling, C3 scanline point-in-polygon).
|
|
6
|
+
*
|
|
7
|
+
* PURE by design — no DOM, no fetch, no globals. This module runs both on
|
|
8
|
+
* the main thread and inside `volumetrics-worker.ts` (the issue's "all of it
|
|
9
|
+
* in a worker"), so the heightfield arrives as already-decoded pixel buffers
|
|
10
|
+
* (`HeightfieldTiles`, produced by terrain-heightfield.ts's DOM half) and
|
|
11
|
+
* everything here is arithmetic over them.
|
|
12
|
+
*
|
|
13
|
+
* The three load-bearing choices, per the spec:
|
|
14
|
+
*
|
|
15
|
+
* C1 — the grid lives in a LOCAL TANGENT-PLANE metric frame at the ring
|
|
16
|
+
* centroid (x = R·cos(φ₀)·Δλ, y = R·Δφ, on the same measure sphere as
|
|
17
|
+
* geodesy.ts), NOT in lng/lat ("cell size 0.25m" is meaningless in degrees)
|
|
18
|
+
* and NOT in uncorrected Web Mercator (linear scale sec(φ) → area/volume
|
|
19
|
+
* error sec²(φ), +100% at 45° latitude). Equirectangular error over a
|
|
20
|
+
* stockpile-sized extent (≤ a few km) is ~10⁻⁵ — far below the DEM's own
|
|
21
|
+
* z noise.
|
|
22
|
+
*
|
|
23
|
+
* C2 — the heightfield is bulk-decoded ONCE upstream and bilinear-sampled
|
|
24
|
+
* in memory here; never an async per-cell fetch (a fine grid is 10⁵–10⁶
|
|
25
|
+
* cells — one network call per cell is dead on arrival).
|
|
26
|
+
*
|
|
27
|
+
* C3 — point-in-polygon by SCANLINE, not per-cell ray-cast: per grid row,
|
|
28
|
+
* intersect ring edges with the row's y, sort crossings, fill spans —
|
|
29
|
+
* O(rows × edges + inside cells) instead of O(cells × edges), and it emits
|
|
30
|
+
* exactly the inside-cell runs the integrator iterates. Fractional
|
|
31
|
+
* boundary-cell coverage is deliberately skipped (documented; the market
|
|
32
|
+
* leader doesn't bother either).
|
|
33
|
+
*/
|
|
34
|
+
import type { LngLat } from "./geodesy";
|
|
35
|
+
export interface ElevationDecoderSpec {
|
|
36
|
+
rScaler: number;
|
|
37
|
+
gScaler: number;
|
|
38
|
+
bScaler: number;
|
|
39
|
+
offset: number;
|
|
40
|
+
}
|
|
41
|
+
/** One decoded DEM tile — plain buffers so the whole set can postMessage to the worker (Uint8ClampedArray is structured-cloneable, and its ArrayBuffer transferable). */
|
|
42
|
+
export interface HeightfieldTile {
|
|
43
|
+
x: number;
|
|
44
|
+
y: number;
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
data: Uint8ClampedArray;
|
|
48
|
+
}
|
|
49
|
+
/** The serializable heightfield: every decoded tile at one zoom + the decoder. Produced by terrain-heightfield.ts, consumed here (main thread or worker). */
|
|
50
|
+
export interface HeightfieldTiles {
|
|
51
|
+
zoom: number;
|
|
52
|
+
tiles: HeightfieldTile[];
|
|
53
|
+
decoder: ElevationDecoderSpec;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Bilinear heightfield sample at `lngLat`, correct across TILE SEAMS.
|
|
57
|
+
*
|
|
58
|
+
* Works in global fractional pixel space at the heightfield's zoom, in each
|
|
59
|
+
* tile's NATIVE resolution scaled to a common 256-per-tile grid: the four
|
|
60
|
+
* pixels around the sample point may live in up to four different tiles, and
|
|
61
|
+
* each is looked up in whichever tile owns it. Corners whose tile is missing
|
|
62
|
+
* or failed drop out and the remaining weights renormalize — a point right
|
|
63
|
+
* at the edge of available data degrades gracefully toward nearest-available
|
|
64
|
+
* rather than snapping to `null`; only a point with NO available corner
|
|
65
|
+
* returns `null` ("no data", the caller's skip-and-count contract).
|
|
66
|
+
*
|
|
67
|
+
* Native-resolution note: tiles can ship at 512px. Sampling positions are
|
|
68
|
+
* computed in 256-grid units (matching `tilePixel`'s convention) and each
|
|
69
|
+
* corner reads the native pixel COVERING that 256-grid cell — bilinear over
|
|
70
|
+
* the 256 grid is slightly coarser than the 512 tile could support, but is
|
|
71
|
+
* consistent across mixed-resolution tile sets and matches the GSD the error
|
|
72
|
+
* model reports.
|
|
73
|
+
*/
|
|
74
|
+
export declare function sampleBilinear(hf: HeightfieldTiles, lngLat: LngLat, tileIndex?: Map<string, HeightfieldTile>): number | null;
|
|
75
|
+
/** Key the tile list for O(1) lookup — build once per batch of samples, pass into `sampleBilinear` (it rebuilds per call otherwise). */
|
|
76
|
+
export declare function buildTileIndex(hf: HeightfieldTiles): Map<string, HeightfieldTile>;
|
|
77
|
+
/**
|
|
78
|
+
* Base-surface strategies (spec: "Multiple Base Surfaces"):
|
|
79
|
+
* - `custom`: a constant elevation — the interactive gizmo's target plane
|
|
80
|
+
* (drag sets `customZ`), and the direct successor of the v1 flat math.
|
|
81
|
+
* - `plane`: least-squares plane fit to the ring's own boundary elevations —
|
|
82
|
+
* the right base for a stockpile on visibly sloped ground.
|
|
83
|
+
* - `lowest` / `highest` / `average`: constant at the boundary's min/max/mean
|
|
84
|
+
* elevation.
|
|
85
|
+
* - `triangulated`: boundary TIN (Delaunay over densely-resampled boundary
|
|
86
|
+
* points, interpolated per cell) — the industry default for stockpiles,
|
|
87
|
+
* since it follows the toe of the pile all the way around.
|
|
88
|
+
*/
|
|
89
|
+
export type BaseSurfaceKind = "custom" | "plane" | "lowest" | "highest" | "average" | "triangulated";
|
|
90
|
+
export declare const BASE_SURFACE_KINDS: readonly BaseSurfaceKind[];
|
|
91
|
+
export declare function parseBaseSurface(raw: string | null | undefined): BaseSurfaceKind | null;
|
|
92
|
+
export interface BaseSurfaceSpec {
|
|
93
|
+
kind: BaseSurfaceKind;
|
|
94
|
+
/** Required for `kind: "custom"` — the target plane's elevation in meters. */
|
|
95
|
+
customZ?: number;
|
|
96
|
+
}
|
|
97
|
+
export interface VolumetricsRequest {
|
|
98
|
+
/** Footprint ring, lng/lat, open or closed (normalized internally). */
|
|
99
|
+
ring: LngLat[];
|
|
100
|
+
heightfield: HeightfieldTiles;
|
|
101
|
+
base: BaseSurfaceSpec;
|
|
102
|
+
/** The measure sphere radius (pass `getMeasureRadiusMeters()` — a parameter so this module stays free of mutable global state, worker included). */
|
|
103
|
+
radiusM: number;
|
|
104
|
+
/**
|
|
105
|
+
* Boundary elevation samples for the non-custom base surfaces — lng/lat
|
|
106
|
+
* plus the elevation the caller sampled there (they ride the same
|
|
107
|
+
* heightfield; the caller already has them for the profile). Converted to
|
|
108
|
+
* the internal tangent frame here, so the caller never has to reproduce
|
|
109
|
+
* this module's frame math. Required for `plane`/`lowest`/`highest`/
|
|
110
|
+
* `average`/`triangulated`; ignored for `custom`.
|
|
111
|
+
*/
|
|
112
|
+
boundary?: Array<{
|
|
113
|
+
position: LngLat;
|
|
114
|
+
z: number;
|
|
115
|
+
}>;
|
|
116
|
+
/** Cell-count budget — cell size grows from the DEM's GSD until the grid fits. Default 1.5M. */
|
|
117
|
+
maxCells?: number;
|
|
118
|
+
/** Return the per-cell grids (`zTerrain`, `inside`) for cheap re-integration on gizmo drag + the heat map. */
|
|
119
|
+
includeGrid?: boolean;
|
|
120
|
+
}
|
|
121
|
+
export interface VolumetricsGrid {
|
|
122
|
+
/** Tangent-frame origin (the ring centroid), lng/lat — for projecting the grid back to the map (heat map bounds). */
|
|
123
|
+
originLngLat: LngLat;
|
|
124
|
+
/** Grid extent: cell (i, j) center is at x = x0 + (i + 0.5)·cellSize, y = y0 + (j + 0.5)·cellSize in tangent meters. */
|
|
125
|
+
x0: number;
|
|
126
|
+
y0: number;
|
|
127
|
+
nx: number;
|
|
128
|
+
ny: number;
|
|
129
|
+
cellSizeM: number;
|
|
130
|
+
/** Terrain elevation per cell, row-major (j·nx + i). NaN = no data. */
|
|
131
|
+
zTerrain: Float32Array;
|
|
132
|
+
/** Base elevation per cell (same layout). NaN outside the ring. */
|
|
133
|
+
zBase: Float32Array;
|
|
134
|
+
/** 1 = inside the ring, 0 = outside. */
|
|
135
|
+
inside: Uint8Array;
|
|
136
|
+
}
|
|
137
|
+
export interface VolumetricsResult {
|
|
138
|
+
cutM3: number;
|
|
139
|
+
fillM3: number;
|
|
140
|
+
netM3: number;
|
|
141
|
+
totalM3: number;
|
|
142
|
+
/** The actual cell size used (≥ the DEM's GSD when the budget forced coarsening). */
|
|
143
|
+
cellSizeM: number;
|
|
144
|
+
/** The elevation source's native ground-sample distance at the ring's latitude — what the error model is quoted against. */
|
|
145
|
+
gsdM: number;
|
|
146
|
+
insideCells: number;
|
|
147
|
+
/** Inside cells skipped for lack of DEM data (missing/failed tiles). */
|
|
148
|
+
nodataCells: number;
|
|
149
|
+
/** ± one-sided error bounds (spec: per-cell `cellArea × 1.5 × GSD_data`, summed separately per side). */
|
|
150
|
+
cutErrorM3: number;
|
|
151
|
+
fillErrorM3: number;
|
|
152
|
+
/** How the base surface resolved (constant z, plane params) — provenance for the readout. */
|
|
153
|
+
base: {
|
|
154
|
+
kind: BaseSurfaceKind;
|
|
155
|
+
z?: number;
|
|
156
|
+
plane?: {
|
|
157
|
+
a: number;
|
|
158
|
+
b: number;
|
|
159
|
+
c: number;
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
grid?: VolumetricsGrid;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The grid integrator. Everything in the tangent frame at the ring centroid;
|
|
166
|
+
* per inside cell: z_terrain from the bilinear heightfield, z_base from the
|
|
167
|
+
* strategy, signed delta accumulated into cut (terrain ABOVE base — material
|
|
168
|
+
* to remove) or fill (terrain BELOW base — material to add). Mixed cut+fill
|
|
169
|
+
* within one polygon is the entire point of the per-cell design — the
|
|
170
|
+
* v1 flat math this replaces could only ever be one or the other.
|
|
171
|
+
*/
|
|
172
|
+
export declare function computeVolumetrics(req: VolumetricsRequest): VolumetricsResult;
|
|
173
|
+
/**
|
|
174
|
+
* Re-integration for the DEFAULT (non-flat-target) `custom` semantics: the
|
|
175
|
+
* target surface is the terrain itself offset by one constant depth/height —
|
|
176
|
+
* "grade this footprint down/up by N meters from wherever the ground is."
|
|
177
|
+
* Every valid cell moves by exactly `offsetM`, so the result is pure cut OR
|
|
178
|
+
* pure fill (a parallel offset can never produce both), each cell
|
|
179
|
+
* contributing |offset| × cell area — closed-form over the SAME per-cell
|
|
180
|
+
* counts and GSD error model `reintegrateCustomBase` accumulates, so the two
|
|
181
|
+
* stay directly comparable when the flat-target toggle switches between
|
|
182
|
+
* them. `base.z` is deliberately absent: no single plane elevation exists.
|
|
183
|
+
*/
|
|
184
|
+
export declare function reintegrateParallelOffset(grid: VolumetricsGrid, offsetM: number, gsdM: number): Omit<VolumetricsResult, "grid" | "base"> & {
|
|
185
|
+
base: {
|
|
186
|
+
kind: "custom";
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Cheap re-integration for the interactive flat-target case: the gizmo drag
|
|
191
|
+
* changes ONLY the `custom` base plane's z, so a cached grid re-sums in one
|
|
192
|
+
* arithmetic pass over the terrain samples — no re-sampling, no scanline, no
|
|
193
|
+
* worker round-trip needed per drag frame. Everything except the base offset
|
|
194
|
+
* (cell size, GSD, error basis) is inherited from the original result.
|
|
195
|
+
*/
|
|
196
|
+
export declare function reintegrateCustomBase(grid: VolumetricsGrid, customZ: number, gsdM: number): Omit<VolumetricsResult, "grid" | "base"> & {
|
|
197
|
+
base: {
|
|
198
|
+
kind: "custom";
|
|
199
|
+
z: number;
|
|
200
|
+
};
|
|
201
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as ur, d as lr, R as zt, e as fr, m as dr, p as St, f as hr, g as pr, h as mr } from "./raster-pipeline-
|
|
2
|
-
import { ap as gr } from "./index-
|
|
1
|
+
import { A as ur, d as lr, R as zt, e as fr, m as dr, p as St, f as hr, g as pr, h as mr } from "./raster-pipeline-hJGxIwYx.js";
|
|
2
|
+
import { ap as gr } from "./index-Cxo9mCw_.js";
|
|
3
3
|
import $t from "./index-CW1n5LdO.js";
|
|
4
4
|
var Et;
|
|
5
5
|
function h(e, t, n) {
|
package/docs/3d-assets.md
CHANGED
|
@@ -69,6 +69,54 @@ For LOD experiments, common loaders.gl tileset options have first-class attribut
|
|
|
69
69
|
|
|
70
70
|
Upstream converters exist for IFC/CityGML → 3D Tiles (e.g. Cesium ion, `py3dtiles`, FME). Same rule: convert upstream, ingest the standard.
|
|
71
71
|
|
|
72
|
+
## Working inside a 3D scene: cutting, exporting, picking
|
|
73
|
+
|
|
74
|
+
Three tools operate on whatever 3D content is already loaded — `Tile3DLayer` tilesets, `BIMLayer` models, extruded vector geometry alike. They compose: cut a box open, outline what's inside, export it.
|
|
75
|
+
|
|
76
|
+
### Clip box — cut the scene open
|
|
77
|
+
|
|
78
|
+
```html
|
|
79
|
+
<om-map terrain="mapterhorn"
|
|
80
|
+
clip-box-min="[6.145, 46.201, 380]"
|
|
81
|
+
clip-box-max="[6.149, 46.204, 440]">
|
|
82
|
+
<om-widget type="clip-box"></om-widget>
|
|
83
|
+
</om-map>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Geometry outside the box is discarded. Every layer is clipped **by default** — `clip="off"` on an `<om-layer>` opts one out (a basemap, usually). Two modifiers change what "outside" means rather than how much is cut:
|
|
87
|
+
|
|
88
|
+
- `clip-box-invert` — show what's *outside* the box instead of inside.
|
|
89
|
+
- `clip-box-highlight` — dim clipped-out geometry instead of discarding it, so nothing disappears (a non-destructive preview).
|
|
90
|
+
|
|
91
|
+
The `clip-box` widget renders the box as a draggable cuboid; its "Show face gizmos" toggle adds a double-headed arrow on each of the 6 faces so you can resize by dragging, and keeps those handles off ordinary map panning when you're not using them. The box is attribute-backed like `terrain`/`lighting`, so changes are undoable and story-steppable, and the `set-clip-box` action (`{min, max, invert?, highlight?}`, or `{clear: true}`) drives it programmatically.
|
|
92
|
+
|
|
93
|
+
v1 is **axis-aligned only** — rotated/oriented boxes are a documented follow-up.
|
|
94
|
+
|
|
95
|
+
### Region export — download the 3D content inside a footprint
|
|
96
|
+
|
|
97
|
+
```html
|
|
98
|
+
<om-widget type="draw" export-3d></om-widget> <!-- GLB (default) -->
|
|
99
|
+
<om-widget type="draw" export-3d="b3dm"></om-widget> <!-- b3dm, for Cesium/3D-Tiles pipelines -->
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Outline a footprint with the draw widget, then hit **Export 3D**: every triangle of every loaded, *visible* 3D Tiles/BIM layer inside that ring is clipped exactly and downloaded. Positions are re-framed to a local coordinate frame at the footprint's own centroid, so the file opens correctly in Blender/three.js without ECEF-scale support. Each triangle carries its own source color as per-vertex `COLOR_0`; there are no textures (BIM/IFC materials are flat colors). `b3dm` adds a `_BATCHID` attribute and a feature table, namespaced per source tileset.
|
|
103
|
+
|
|
104
|
+
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."
|
|
105
|
+
|
|
106
|
+
The clip and pack run **synchronously on the main thread** — fine at single-model/BIM scale, but a city-scale tileset under a large footprint will visibly block the tab. Keep footprints to the region you actually want.
|
|
107
|
+
|
|
108
|
+
### `pickable="3d"` — a real elevation on hover and click
|
|
109
|
+
|
|
110
|
+
An ordinary `pickable` layer resolves a click against the z=0 ground plane, so clicking a building face reports the point on the ground *behind* it. `pickable="3d"` opts the layer into deck's depth-pick pass instead:
|
|
111
|
+
|
|
112
|
+
```html
|
|
113
|
+
<om-layer id="city" type="Tile3DLayer" tileset="…/tileset.json" pickable="3d"></om-layer>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The resolved coordinate then carries a real third (elevation) component, which flows through `ctx.selection.coordinate` and `{{z}}` in `<om-overlay>` / `show-tooltip` templates. `terrain` sets this on itself automatically. `{{z}}` is **absent, not `0`**, when no layer in the scene ran the depth pass for that pick — so a missing elevation is distinguishable from sea level.
|
|
117
|
+
|
|
118
|
+
Snapping (`<om-map snap="vertex edge midpoint">`, see the README) also reads a `BIMLayer`'s edge/crease overlay, converting real wall corners back to `[lng, lat]` — so a drawn footprint can land on an actual building corner rather than near it. `snap="off"` opts a layer out.
|
|
119
|
+
|
|
72
120
|
## Semantic city models: CityJSON
|
|
73
121
|
|
|
74
122
|
3D Tiles is the right target for *visual* city models. It is the wrong one for **CityJSON**, because converting flattens the per-building semantics — and semantics are the whole reason municipal programmes publish it: the Netherlands' [3DBAG](https://3dbag.nl) (~10M buildings), Japan's [PLATEAU](https://www.mlit.go.jp/plateau/) (250+ cities), swisstopo, several German states. Rooftop-solar, shadow, zoning and noise studies all read those attributes.
|
|
@@ -133,7 +181,7 @@ Face counts are long-tailed, so the building count you can fit is not predictabl
|
|
|
133
181
|
|
|
134
182
|
### Visible edges in surfaces mode
|
|
135
183
|
|
|
136
|
-
Surfaces mode is flat-shaded, so adjacent faces at similar heights (e.g. a LoD2.2 hip roof's planes) can be hard to tell apart by color alone. Each row carries an `outline` field — the same face's outer ring, flattened and closed — for exactly this: give a `PathLayer` `get-path="$outline"` and it traces real per-face edges. Always pair one with a surfaces-mode `SolidPolygonLayer`; deck's `wireframe` prop on `SolidPolygonLayer` only builds wireframe geometry when `extruded: true`, so it does nothing in this unextruded mode. Point the `PathLayer` at the same `data` URL (the cache is keyed by URL, so
|
|
184
|
+
Surfaces mode is flat-shaded, so adjacent faces at similar heights (e.g. a LoD2.2 hip roof's planes) can be hard to tell apart by color alone. Each row carries an `outline` field — the same face's outer ring, flattened and closed — for exactly this: give a `PathLayer` `get-path="$outline"` and it traces real per-face edges. Always pair one with a surfaces-mode `SolidPolygonLayer`; deck's `wireframe` prop on `SolidPolygonLayer` only builds wireframe geometry when `extruded: true`, so it does nothing in this unextruded mode. Point the `PathLayer` at the same `data` URL (the cache is keyed by URL plus live-source options, so two plain layers on one URL share a single fetch — give one of them a `refresh`/stream attribute the other lacks and they become separate requests) and mirror the fill layer's `filter-field`/`filter-range` so filtered-out buildings' outlines disappear too.
|
|
137
185
|
|
|
138
186
|
The semantics still do work in both modes: they decide which surfaces count as roof, so these are real roof measurements rather than bounding-box numbers. Derived names win over a same-named source attribute, so manifests can rely on them.
|
|
139
187
|
|
package/docs/live-data.md
CHANGED
|
@@ -76,7 +76,32 @@ This applies to **every** Data Layer request: initial loads, polling refreshes,
|
|
|
76
76
|
|
|
77
77
|
Every flush/poll takes the same path a resolved fetch takes: a **new data reference** enters the layer IR → the reconciler hands it to deck.gl, which re-uploads geometry without recompiling any accessor → `data:<layerId>` watch tokens fire, so widgets (stats panels, charts) re-render once per update. `om-map-ready` resolves after the *first* load for polling; for streams it resolves immediately (a stream never "finishes" — don't wait for a first message to consider the map ready).
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
Live transports follow the active descriptor document. Removing a layer,
|
|
80
|
+
changing its URL or stream options, destroying its `MapController`, or
|
|
81
|
+
disconnecting its `<om-map>` releases that owner's handle. Identical
|
|
82
|
+
URL/options across maps share one transport, and the final release aborts the
|
|
83
|
+
fetch/poller or closes the socket. `MapController.suspend()` releases its live
|
|
84
|
+
handles while preserving descriptors and rendered state;
|
|
85
|
+
`MapController.resume()` reacquires them from the current descriptor document.
|
|
86
|
+
This is the intended app-background/app-foreground integration for native
|
|
87
|
+
hosts.
|
|
88
|
+
|
|
89
|
+
A transport released by an owner that means to come back leaves its **last rows
|
|
90
|
+
behind as a cold snapshot**, keyed by the same transport identity. Re-acquiring
|
|
91
|
+
it — `resume()`, or re-adding a layer you removed — repaints those rows
|
|
92
|
+
immediately instead of flashing an empty layer while the first fetch or stream
|
|
93
|
+
message arrives; on a 30-second feed that gap would otherwise be 30 seconds of
|
|
94
|
+
blank map after every foreground. A keyed stream also restores its upsert set,
|
|
95
|
+
so the next message merges rather than replacing.
|
|
96
|
+
|
|
97
|
+
Only reversible releases retain. `suspend()` and layer removal/re-pointing keep
|
|
98
|
+
their rows; `MapController.destroy()` and an `<om-map>` leaving the document
|
|
99
|
+
keep nothing, since neither can re-acquire. (Re-parenting a live `<om-map>`
|
|
100
|
+
isn't a teardown at all — the release is deferred a microtask, so the transport
|
|
101
|
+
never stops.) The snapshot is cold, not live: the first real response supersedes
|
|
102
|
+
it, and polling readiness (`om-map-ready`) still waits for that response.
|
|
103
|
+
Nothing is retained for a transport still held by another owner — it was never
|
|
104
|
+
stopped.
|
|
80
105
|
|
|
81
106
|
## Testing live layers
|
|
82
107
|
|
package/docs/react.md
CHANGED
|
@@ -60,7 +60,15 @@ Actions that mutate manifest attributes (`toggle-layer`, `show-overlay`, `fade`,
|
|
|
60
60
|
| `onReady` | Renderer up + first commit + no data URL still loading. |
|
|
61
61
|
| `onViewStateChange` | Every camera change, with the current `CameraState`. |
|
|
62
62
|
| `onRuntimeError` | deck.gl-level failures in the structured validation shape. |
|
|
63
|
-
| `ref` | The imperative handle — a `MapController`: `flyTo`, `setView`, `emit`, `getLayers`, `injectPick`, `ready`. |
|
|
63
|
+
| `ref` | The imperative handle — a `MapController`: `flyTo`, `setView`, `emit`, `getLayers`, `injectPick`, `ready`, `suspend`/`resume`. |
|
|
64
|
+
|
|
65
|
+
`suspend()` releases the map's live fetch/poll/socket handles without discarding
|
|
66
|
+
its layers or camera — the app-background hook for a WebView or native shell.
|
|
67
|
+
`resume()` reacquires them and repaints the last rows straight away, so a
|
|
68
|
+
foregrounded map is never blank while the first response is in flight. Unmounting
|
|
69
|
+
`<OmMap>` releases everything permanently; unmounting a single `<OmLayer>`
|
|
70
|
+
releases just that layer's handle, and the transport stops once its last owner
|
|
71
|
+
lets go. See [live-data.md](live-data.md).
|
|
64
72
|
|
|
65
73
|
Give it a size (`style`/`className`) — it renders a `position: relative` div.
|
|
66
74
|
|
package/docs/testing.md
CHANGED
|
@@ -76,6 +76,8 @@ it("manifest meaning is locked", () => {
|
|
|
76
76
|
|
|
77
77
|
`snapshotIR` resolves the manifest through the real pipeline (schema, attribute resolution, accessor compilation) into JSON-safe descriptors where **accessors appear as behavioral fingerprints**. Any edit that changes what the map *means* — an expression, a filter range, layer order — shows up as a snapshot diff in code review. Refactors that don't change meaning produce no diff.
|
|
78
78
|
|
|
79
|
+
For programmatic or native JSON descriptors, use `snapshotDescriptorIR(descriptors)`. It runs the same schema/accessor/filter resolution as `MapController.setLayers()`, represents expression accessors by the same fingerprints, and never fetches URL-backed data.
|
|
80
|
+
|
|
79
81
|
## Tier 2b — behavioral: `mountForTest`
|
|
80
82
|
|
|
81
83
|
This is where most of your tests should live. The harness mounts your real page **headlessly**: no deck.gl instance, no canvas — but everything else runs for real, including the projection math (deck.gl's `WebMercatorViewport` is pure math, no WebGL).
|
package/llms.txt
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
OnlyMapJS is NOT raw deck.gl and NOT generic HTML/JSX. The rules below are the delta from what you already assume — following them produces correct manifests on the first pass. Validate with `OmMap.validate(htmlString)` (structured errors AND warnings, each with a `fix` instruction — heed both; an "unknown attribute" warning means a prop is silently dropped) and inspect resolved output with `OmMap.snapshotIR(htmlString)` before finalizing.
|
|
6
6
|
|
|
7
|
+
Programmatic/native bridge rule: `MapController.setLayers()` accepts normal function accessors and, only for schema-declared accessor props, restricted expression strings (`props: {getPosition: "[$lon, $lat]"}`) so a descriptor can cross JSON safely. Verify that lane with `OmMap.snapshotDescriptorIR(descriptors)`; URL data is represented as pending and is not fetched. Active descriptors own reference-counted data transports: removal/identity change/destroy releases them; `MapController.suspend()` releases live work and `resume()` reacquires it, repainting the last rows so a foregrounded map is never blank while the first response is in flight. Live sockets and poll loops no longer outlive their layer — code that relied on that must keep the layer mounted. For app-scoped packaged licenses, `configureLicense(key, {appId})` takes an exact platform identifier obtained by the native host — never copy an app id from page/bridge input, and prefer keys scoped by both `domains` and `apps`, since only the domain claim is pinned by the browser.
|
|
8
|
+
|
|
7
9
|
## Loading the library
|
|
8
10
|
|
|
9
11
|
- npm projects: `import "@nika-js/onlymap"` + `import "@nika-js/onlymap/onlymapjs.css"`.
|
|
@@ -25,17 +27,20 @@ OnlyMapJS is NOT raw deck.gl and NOT generic HTML/JSX. The rules below are the d
|
|
|
25
27
|
- Full JavaScript in accessor blocks needs the `js` attribute on the layer (`<om-layer js>` + `<script type="om/accessors">`). Without it, blocks are restricted to `export const name = d => <expression>` — no statements, no loops, no nested functions.
|
|
26
28
|
- Dashed lines are a single attribute: `dash="[6, 3]"` (or SVG-style `dash="6 3"`, plus optional `dash-justified`) on a path-stroking layer (`PathLayer`, `GeoJsonLayer`, `PolygonLayer`, `TripsLayer`). Do NOT hand-wire deck's `PathStyleExtension`/`getDashArray` — the attribute mounts it for you. Values are `[dashLength, gapLength]` in the SAME units as the line width; `dash` on a non-path layer (ScatterplotLayer, etc.) is ignored with a warning.
|
|
27
29
|
- To capture where the user CLICKS on the map (a measure tool, drop-a-pin, a custom rectangle/circle AOI, snap-to-feature), listen for the `om-map-point` event on `<om-map>`: `mapEl.addEventListener('om-map-point', e => { const { coordinate, kind } = e.detail; })` — `coordinate` is `[lng,lat]` (or `null` off-globe), `kind` is `"click"`|`"hover"`, and it fires on every click/hover including empty-map clicks. Do NOT reach for deck.gl internals (`mapEl.getMap()`, `.deckInstance`, `.deck.viewManager`) or unproject canvas pixels — those are not exposed on `<om-map>` and return nothing. The built-in `draw` widget handles polygon/line/point sketching; `om-map-point` is for tools it doesn't cover.
|
|
30
|
+
- `<om-widget type="draw" modes="point line polygon" target="sketch" save="both" autosave="<key>">` is the sketch-capture toolbar — `target` binds the store a `data="draw:<target>"` layer reads. `export-3d` (bare = GLB, `="b3dm"` for Cesium/3D-Tiles pipelines) adds an "Export 3D" button (issue #34), separate from `save` (that's the drawn shape's own GeoJSON): outline a polygon over loaded `Tile3DLayer`/`BIMLayer` content, close it, and it clips every loaded tile's triangles to that footprint (a plain 2D clip, no elevation-picking involved), re-frames them to a local coordinate frame at the footprint's centroid, and downloads it, each triangle carrying its own source color (vertex colors) — no textures (BIM/IFC materials are flat colors, not textured meshes). Only currently-VISIBLE 3D Tiles/BIM layers are included — `visible="false"` (or `toggle-layer`) excludes a layer, with distinct console warnings for "nothing loaded" vs. "everything hidden." Validation warns on an unrecognized `export-3d` value.
|
|
31
|
+
- Clip box (issue #34): `<om-map clip-box-min="[lng,lat,elev]" clip-box-max="[lng,lat,elev]">` cuts a real axis-aligned 3D box through the whole scene — geometry outside it discarded, every layer clipped by default (`clip="off"` on an `<om-layer>` opts out), works on ANY layer type including georeferenced `Tile3DLayer`/`BIMLayer` content (not just flat `GeoJsonLayer` extrusions). `clip-box-invert` shows outside instead of inside; `clip-box-highlight` dims clipped-out geometry instead of discarding it (non-destructive preview). Attribute-backed (undoable, story-steppable) via `set-clip-box {min, max, invert?, highlight?}` (`{clear:true}` removes it) and `<om-widget type="clip-box">` (six number inputs + invert/highlight checkboxes + clear button). v1 is axis-aligned only — rotation is a documented follow-up.
|
|
32
|
+
- `<om-widget type="measure" modes="distance area volume" units="metric|imperial|nautical">` is the geodesic ruler: click to place points, live labels + a totals panel, read the value programmatically via the `om-measure` event (`detail.mode`/`.totalMeters`/`.areaMeters2`/`.perimeterMeters`/`.cutMeters3`/`.fillMeters3`/`.netMeters3`/`.totalMeters3`/`.cutAdjustedMeters3`/`.fillAdjustedMeters3`/`.cutMassKg`/`.fillMassKg`/`.stale`/`.profileSeries` — profile points are `{x: metres from the first vertex, y: elevation}` plus `vertexIndex` on the samples that ARE drawn corners, so a chart can mark them; vertex 0 is the leftmost, and with `profile` on the map badges the first two vertices `1 · Start` and `2` in draw order so the ring's winding direction is readable at constant cost, while the chart marks every corner). `volume` outlines a footprint like `area` (close it with a double-click/Enter — it turns solid teal, "ready"), then a fixed-screen-pixel-size double-headed arrow gizmo appears at the centroid: drag up to fill, down to cut (unbounded distance), reading Cut/Fill/Net (signed, fill−cut)/Total (unsigned, cut+fill) — always RAW geometric volumes, never altered by `swell`/`shrink`. It REQUIRES `terrain` on `<om-map>` (validation warns a `volume` mode with none — cut/fill against flat ground with no elevation surface has nothing to measure against). The math is a REAL per-cell grid integration: closing a footprint bulk-loads its covering DEM tiles and integrates terrain-vs-base per cell on a metric tangent-plane grid (cell size = the DEM's GSD, scanline point-in-polygon, bilinear seam-correct sampling, worker-offloaded) — mixed cut AND fill in one footprint on undulating ground, with `cellSizeM`/`gsdM`/`cutErrorM3`/`fillErrorM3` (± = per-cell cellArea × 1.5 × GSD, per side)/`nodataFraction` published on the readout; without terrain a flat-plane fallback runs with no error figures. `base-surface` picks the reference: `custom` (default — the gizmo's target plane) or boundary-derived stockpile strategies with no gizmo (`triangulated` boundary TIN, `plane`, `lowest`, `highest`, `average`). Five more volume-only attributes (no-ops, and validation warns, without `volume` in `modes`): `base-surface` (above); `profile` (elevation samples around the footprint's own perimeter, live while sketching, dispatched on `profileSeries` for a paired `dynamic-chart`); `deadband` (m³, zeroes a Cut/Fill figure below the threshold); `density` (t/m³ metric, lb/yd³ imperial) and `swell`/`shrink` (multipliers, default 1×) populate a separate Material section instead — Bank/Loose/Compacted convention, `cutAdjustedMeters3` = raw × swell (loose/haul, bigger), `fillAdjustedMeters3` = raw ÷ shrink (loose/borrow needed, also bigger), tonnage from the raw (mass-conserving) volume — shown only once one of the three is actually configured.
|
|
28
33
|
|
|
29
34
|
## Element vocabulary
|
|
30
35
|
|
|
31
|
-
- `<om-map center="[lng, lat]" zoom="11" pitch="55" bearing="20" basemap="positron">` — the root. Give it a height (`om-map { display:block; height:100vh }` with `html,body{height:100%}`, or a sized container) — a custom element is display:inline by default and collapses to zero size; with none set, the library falls back to display:block + a 400px floor so a bare map still shows (any height you DO set wins over the floor, including one below 400px), and a collapsed map logs a console warning. A map hidden on purpose (`hidden`, or inside a `display:none` panel) stays hidden and does not warn. `basemap` accepts a free preset (`liberty`, `bright`, `positron`, `dark-matter`, `voyager`, `osm` — no keys; or `maptiler-streets`/`maptiler-dataviz`/`maptiler-satellite` with `basemap-key="…"` or `OmMap.configureBasemap({ maptilerKey })`), `maplibre` (bare demo style), a style URL (e.g. a MapTiler-customized style; any scheme fetch supports, including desktop asset protocols like Tauri's, query strings fine), or `none` (standalone canvas). The attribute is LIVE — writing it switches the basemap in place (camera + layers survive); the `set-basemap {basemap}` action and `<om-widget type="basemap-switcher" options="positron dark-matter osm">` do the same. Register more via `OmMap.registerBasemap(name, { style })`. Required provider attribution renders automatically (`attribution="false"` opts out). `pitch`/`bearing` tilt the initial camera (use for 3D content). Terrain: `terrain="terrarium"` (keyless AWS DEM; also `mapterhorn` — keyless, CARTO Positron drape by default — `maptiler-terrain` with a key, a raw `{z}/{x}/{y}` DEM URL + `terrain-decoder="terrarium|mapbox-rgb|<JSON>"`, or `off`) raises a 3D surface — geographic layers DRAPE onto it automatically (per-layer `terrain="drape|offset|off"` overrides; 3D-model layers sit ON it), `terrain-exaggeration` scales relief, `terrain-texture` drapes imagery; terrain REPLACES an active basemap while on (restored when off); `set-terrain` action, `terrain` watch token, `OmMap.registerTerrain(name, {...})` for more sources. a georeferenced BIM model REQUIRES the map to author `terrain` explicitly — any value including an explicit `terrain="off"` (flat-ground siting); a model that resolves real elevation (IfcMapConversion + OrthogonalHeight) on a map with no `terrain` attribute raises an ERROR through the validation channel at load time (the library never writes attributes for you — no auto-terrain). Scene lighting for 3D content: `lighting="daylight|studio|flat|custom"` (absent = deck defaults) with `lighting-ambient`/`lighting-sun`/`lighting-sun-azimuth`/`lighting-sun-elevation`/`lighting-camera` overrides and `lighting-sun-date` (ISO/epoch — solar-position sun computed at the map center, wins over azimuth/elevation); attribute-backed (undoable, live-editable), story-steppable via the `set-lighting {lighting, sunAzimuth, ...}` action (`lighting="default"` removes the attribute set; a bare preset is a clean reset — stale overrides clear), native UI via `<om-widget type="lighting">` (preset radios + tuning sliders), widget watch token `lighting`. GeoJsonLayer extrudes polygons declaratively: `extruded get-elevation="$height"` (+ `wireframe`). `widgets-hidden` attribute (or the `set-widgets-visible {visible}` action / `<om-widget type="widgets-toggle">` button) hides every widget WITHOUT destroying state — attribution never hides (license); transient (not an undo step) but story-steppable, so a step can clear chrome for a cinematic take. Slots auto-dim while an open `<om-overlay>` popup covers them (position stability over the popup dodging; `widgets-dim="off"` disables). `validate` attribute enables live validation + on-page error panel. Include a `map-id="<random UUID>"` on complete pages (identifies the map artifact for usage telemetry — not a visitor id; the page author deletes it to opt out); `telemetry="off"` disables usage telemetry + library-error reporting for the map (global: `OmMap.configureTelemetry({ disabled: true })`; schema: docs/telemetry.md). Free plan limits (HOSTED http(s) pages only — a dev context (localhost, file://, any non-web scheme) lifts every cap while the attribution badge stays; the exemption is technical convenience, not a license grant — commercial deployment incl. packaged apps still requires a key): 5 layers, 25k rows per layer — layers past a limit don't render and validation says why; a `license-key="om_live_…"` attribute (publishable, origin-restricted) or `OmMap.configureLicense(key)` lifts limits and removes the corner badge. When a user's map exceeds free limits, say so and point at the validation message rather than silently restructuring their data. Events on the element: `om-map-ready` (boot complete; `await mapEl.ready` is the promise twin), `om-validation-error`, `om-view-changed` (camera settled after a move, debounced; `detail = {longitude, latitude, zoom, pitch, bearing, origin}` where `origin` is `"user"` for gesture bursts vs `"programmatic"` for API/story moves — the camera-persistence hook, and the echo-suppression signal when syncing camera state to an app store), `om-map-point` (`detail = {coordinate: [lng,lat]|null, kind}` — every click/hover map coordinate incl. empty-map clicks, for custom capture tools beyond the draw widget), `om-tileset-load` (`detail = {layerId, tileset}` — a Tile3DLayer's live deck Tileset3D, for tools needing the real tileset like region export). `MapController` mirrors these as `onViewChange`/`onMapPoint`/`onTilesetLoad` options. `await mapEl.snapshot()` returns a canvas-only PNG dataURL of the scene (basemap + layers at device pixels; `{as:"blob"}` for files) — DOM widgets/overlays/attribution are NOT captured, so exports must render provider credits themselves.
|
|
32
|
-
- `<om-layer id="..." type="ScatterplotLayer" data="./points.json">` — any deck.gl layer class by `type` (37 layer types total: 32 bundled deck.gl core/geo/aggregation/mesh layers, plus the native `COGLayer`/`ZarrLayer` raster types, `ImageOverlay` for georeferenced drone JPEGs, and `BIMLayer` for BIM source files), plus `PopupLayer` (WebGL badges/labels at scale: `layout="badge|pin-label|card"`, `min-zoom`/`max-zoom`). Every type's full attribute list ships in the package's `onlymapjs.html-data.json` — consult it instead of guessing attribute names or reading the minified dist. TextLayer's default font atlas covers ASCII only: set `character-set` when label text carries other glyphs (`—`, `·`, accents) or deck warns and renders them blank. External layer classes register via `OmMap.registerLayer({type, deckClass, props})` — build them on `@nika-js/onlymap/deck` (the bundled `CompositeLayer`/`TileLayer`/… re-exports), never a separately-installed deck.gl (different class hierarchy, breaks in the renderer); function-valued props ride the subclass's `static defaultProps`; register at module top level BEFORE the manifest mounts (see docs/custom-layers.md). Data: `data` URL (JSON, GeoJSON, CSV/TSV `.csv` — parsed to typed columns — Shapefile `.shp` (+`.dbf` attributes) and KML `.kml` as GeoJSON features, GPX `.gpx` (waypoints/tracks/routes → features tagged `_gpxKind`; a `#waypoints`/`#tracks`/`#routes` URL fragment selects one part), FlatGeobuf `.fgb` (cloud-native binary vector, whole-file decode), GeoParquet `.parquet`/`.geoparquet` (cloud-native columnar vector — all-Point files stay columnar like Arrow, lines/polygons become GeoJSON features; requires the file's `geo` metadata with WKB geometry, and CRS84/EPSG:4326 — a projected CRS is a loud error telling you to reproject, not a silent misplacement; snappy/gzip/zstd row-group compression handled), CityJSON `.city.json` / CityJSONSeq `.city.jsonl` (semantic 3D city models — 3DBAG, PLATEAU — decoded to one of two shapes by the `data` URL, no CityJSON layer type: default → extruded footprints, `type="GeoJsonLayer" extruded get-elevation="$roof_height"`; `?om-surfaces=1` → one row PER FACE at its own real per-vertex height so a pitched LoD2.2 roof actually looks pitched, `type="SolidPolygonLayer" get-polygon="$polygon" full3d` (`extruded` stays at its ordinary `false` default) (flat-shaded — deck.gl only lights the `extruded` shader path — each row also carrying `surface_type`: RoofSurface/WallSurface/GroundSurface, and `fill_color`: a ninja-viewer-style default color per surface_type/cityobject_type, verified against cityjson-threejs-loader's own default palette — `get-fill-color` on `SolidPolygonLayer` reads it automatically when left unauthored, no color attribute required, and an authored `get-fill-color` still overrides it); derived properties (both modes) `roof_height` (area-weighted mean roof height above ground), `eaves_height`, `ridge_height`, `ground_height`, `roof_area`, `surface_count`, `lod`, `cityobject_id`, `cityobject_type`, `parent_id` win over same-named source attributes, plus surfaces-mode-only `polygon`/`outline`/`surface_type`/`fill_color` (`outline` is the face's outer ring flattened and closed — bind a companion `type="PathLayer" get-path="$outline"` layer to it for visible face edges, since surfaces mode is flat-shaded and `SolidPolygonLayer`'s own `wireframe` prop is a no-op when unextruded — always pair one, matching `filter-field`/`filter-range` to the fill layer), and a parent Building's attributes are inherited by its BuildingPart rows; national grids NL/CH/DE/JP/AT/SG reproject automatically including axis order, other EPSG codes fail with an error naming the code; highest LoD wins, pin one with `?om-lod=1.2` (combine as `?om-lod=1.2&om-surfaces=1`, cached independently); `.city.jsonl` fills in as it downloads in either mode — see docs/3d-assets.md), or Arrow IPC `.arrow`/`.feather` — large point datasets stay columnar, GeoArrow line/polygon geometry becomes GeoJSON features, zstd-compressed IPC is handled; other formats plug in via `OmMap.registerFormat({match, parse})`; data URLs accept any scheme the runtime's fetch supports — desktop webviews (Tauri, Electron) pass asset-protocol URLs straight in), inline `<script type="application/json">` (row arrays or column-oriented `{"columns": {"lon": [...], "lat": [...]}}`; must be a DIRECT child of the `<om-layer>`, and when present it wins — omit the `data` attribute), or `wss://` streaming (`key="mmsi"` upserts entities in place, `flush="250ms"` coalesces bursts, `source="name"` selects a `OmMap.registerSource` decoder plugin), or a polled REST snapshot (`refresh="5s"` re-fetches and replaces — for live endpoints that return the full current state). TILED layers: a `{z}/{x}/{y}` `data` template is deck's tile URL for `TileLayer`/`MVTLayer` (NOT rows) — passed through to deck verbatim, never fetched/parsed, so `<om-layer type="TileLayer" data="…/{z}/{x}/{y}.png">` works (raster gets a built-in BitmapLayer sublayer) and `type="MVTLayer" data="…/{z}/{x}/{y}.pbf"` self-renders vector tiles with `get-*` accessors applying to each decoded feature's properties; a tiled layer has no local rows so `ctx.data`/`ctx.stats`/`filter-*` don't apply. Authenticated endpoints: call `OmMap.configureData({ headers: {...} })` in a script — never put tokens in attributes. `$field` accessors work identically on all of them — never write column-index code yourself. One columnar restriction: the `js` full-JS opt-in is not allowed on Arrow/columnar layers (validation will tell you; use `$field` accessors instead). For 3D models use `type="ScenegraphLayer"` with `scenegraph="./model.glb"` (required) and `get-orientation="[0, $heading, 90]"` — the roll of 90 stands Y-up glTF models upright; see docs/3d-assets.md. 3D Tiles use `type="Tile3DLayer"` with `tileset="…/tileset.json"` (NOT `data`). For BIM/photogrammetry, `pickable` alone picks a whole TILE — add `pick-features` to pick an individual ELEMENT (a wall, a window, one IFC product), and the `selection` then carries that element's `featureId`/`properties`/`class` from the tile's own `EXT_mesh_features` + `EXT_structural_metadata` (`feature-id-property` selects the ID set, default `_FEATURE_ID_0`). `feature-styles` recolours/fades/highlights by feature ID — an array indexed BY id of `{color: [r,g,b], strength: 0-1, opacity: 0-1}`, live-settable, uploaded as a small lookup texture (no refetch). Use `strength` below 1 to tint rather than replace, or the model's own texture is hidden. Isolate/hide/ghost are DECLARATIVE and mirror the vector `filter-field`/`filter-categories` pair — `feature-filter-field="component"` names the metadata field, then `isolate-features='["Clock"]'` / `hide-features='["Windows","Skylight"]'` / `ghost-features='["Wall"]'` take JSON value lists matched against the tile's property table (`ghost-opacity` tunes the fade, default 0.18). `isolate-features` is EXCLUSIVE (anything unlisted is hidden); hiding is a shader discard so a hidden element also stops being pickable and whatever is behind it becomes selectable. They compose ONTO `feature-styles` (style table supplies colour, these supply visibility), and being attributes they are undoable and story-steppable — prefer them over computing a style table in page JS. Multi-material/multi-primitive models fan out correctly (glTF allows one material per primitive, so real IFC exports are usually many primitives); only genuinely instanced i3dm tiles stay tile-granularity. Limits to state rather than discover: texture-backed IDs — how photogrammetry classification ships — require `load-options='{"gltf":{"loadBuffers":true,"loadImages":true},"image":{"type":"data"}}'`, and omitting `image.type` makes the tileset take MINUTES to appear (loaders.gl otherwise reads the whole ID texture back through a canvas once per vertex); and `opacity` below 1 currently blanks the model. BIM WIDGETS (all need a `pick-features` layer): `<om-widget type="ifc-browser" layer="clinic" fields="ifcClass material container spatialPath" scale-fields="netVolume" rows="7">` groups the model by a property-table field, counts each value, and gives every row I/H/G buttons that WRITE `isolate-features`/`hide-features`/`ghost-features` (so it is a UI over the attributes — undoable and story-steppable; I is MULTI-SELECT, isolating the union of every pressed row or tree node, since `isolate-features` is a list and the layer matches it as a set) while keeping the companion outline layer's `filter-categories` in step; that same select also offers whichever TREES the file supports — Spatial (`spatialPath`), Type (`typePath`), System (`systemPath`) and Classification (`classificationPath`, built by walking `ReferencedSource`) — each expandable with counts aggregated upward and the same I/H/G on every node, so isolating a storey or a system or a CCS code reaches every element under it. Spatial is NOT privileged (on a real Danish project the classification tree covered 3,415 elements to spatial's 660). A tree is not a separate mode, just a group-by on a hierarchy column — LIST it in `fields` to offer it, or set `field="spatialPath"` to open on it — and `loadIfc` emits a hierarchy column ONLY when the file populates it, so a tree that would render empty is never offered; trees appear automatically when available (no need to list them in `fields`) and `field="spatialPath"` opens straight onto one. ONE browser per layer: `feature-filter-field` and the isolate/hide/ghost attributes are single-valued, so two instances on one layer clobber each other. The widget was renamed from `ifc-legend` (still registered as a deprecated alias) because it is a model browser, not a legend. non-physical classes (IfcSpace, IfcOpeningElement) are hidden unless `show-non-physical`, and `no-color` removes the colour select. `<om-widget type="feature-inspector" fields="ifcClass material container netVolume">` (renamed from `ifc-inspector`, kept as an alias) shows the picked element's properties. `<om-widget type="ifc-loader" layer="ifc" federate>` is a drop zone that parses `.ifc` files IN THE BROWSER and builds both layers itself; `federate` accepts SEVERAL models into one co-registered scene (one drop zone, one layer per model, each with a visibility toggle) rather than one widget per discipline, matching how coordination tools append models; a model that turns out to be georeferenced is AUTO-PLACED (its own coordinates/heading/scale written onto the layers the WIDGET created, camera flown there) — but the widget NEVER writes `<om-map>`'s own scene attributes (`basemap`, `terrain`): those are author-owned, and a georeferenced model on a map with neither raises a structured "no spatial context" warning instead of switching one on. COLOUR BY PROPERTY instead of hand-computing a `feature-styles` table: `feature-color-by` (categorical, `feature-palette` overrides the built-in cycle) or `feature-color-scale` (graduated over a numeric field), with `feature-color-strength` (default 0.85) controlling how hard the colour mixes over the model's own material. Setting NEITHER is the default and is meaningful — the model renders in its own IFC surface colours. A graduated ramp needs the field populated: Revit IFC2x3 exports often carry no `IfcElementQuantity`, so every `netVolume` is 0 and the ramp is flat. Widget scripts read the decoded property table with `ctx.features(layerId)` (undefined until the first tile carrying one lands) and re-render on the `features` watch token. `<om-widget type="ifc-clash" layers="arch mep" tolerance="0">` is the CLASH OVERLAY over two co-registered model layers: it flags element pairs whose bounding boxes interpenetrate, colours both sides via `feature-styles`, and flies to the centre of each overlap. v1 is an axis-aligned box test — fast and serverless, but it over-reports anything diagonal and says nothing about which clashes matter; zones/spaces/openings/proxies and same-class-same-name pairs are excluded as noise. The header carries an overlay on/off switch; an isolation mode select (None/Dim/Hide) sits above the results list and applies once a row is selected (with nothing selected, nothing is hidden), and clicking a row FOCUSES that clash (chosen pair at full strength, every other clashing element dropped to a faint tint, camera flown to the overlap centre) — without that, everything is highlighted and nothing is. Results are GROUPED by the side-A element with a count (one wall crossing four ducts is one row), and the two model selects appear only when more than two models are loaded. It CHECKS co-registration (matching `site-origin`) and says so when it fails, because two mis-registered models report zero clashes exactly like two clean ones. Persisting/sharing results is BCF's job and is out of scope. `<om-layer type="BIMLayer" src="./model.ifc">` is the declarative counterpart to `ifc-loader`/`loadIfc`: point it at a BIM source file (an .ifc today) and it runs the loader itself the moment src resolves — no pre-baked tileset, no site-origin/site-heading/site-scale (the file's own georeference is read and applied automatically; not wired up yet: an authored site-origin on a BIMLayer does not override it), and no separate PathLayer for the outline overlay (added automatically). `pick-features` defaults ON (unlike a plain Tile3DLayer); feature-filter-field/feature-styles/isolate-hide-ghost/feature-color-by/ghost-opacity all work unchanged, since BIMLayer forwards them to a real Tile3DLayer it builds internally. Known gap: the outline overlay does not yet follow isolate/hide/ghost the way the mesh does. Reach for BIMLayer when the model is fixed and known ahead of time; reach for ifc-loader when a visitor picks the file or several models need to federate. IN-BROWSER IFC: `loadIfc(bytes)` parses an `.ifc` with web-ifc (WASM, MPL-2.0, CDN-fetched on first use — NOT a package dependency; `configureIfc({wasmPath})` self-hosts) and returns `{tilesetUrl, edgesUrl, loadOptions, features (rows carry `ifcClass`/`name`/`material`/`container`/`netVolume`/`spatialPath`/`typePath`/`systemPath`/`classificationPath` — hierarchy columns joined by U+001F that the model trees navigate, each emitted only when the file populates it), lonLat, georeferenced, heading, scale, originSource, headingSource, stats, timings, bounds, revoke()}`; the output IS a tileset so picking/styling/`site-*`/isolate-hide-ghost work unchanged. CALL `revoke()` when swapping models — blob URLs are held by the document. FEDERATION: pass the first model's returned `origin` as `LoadIfcOptions.origin` for every later model of the same building, or each is centred on its own bounding box and they drift apart — a clash pass then finds nothing, which looks identical to a clean model. `ifc-loader` shares one origin AND one placement (`site-origin`/`site-heading`/`site-scale`) per map automatically — discipline files routinely declare IfcSite coordinates kilometres apart for the same building, so the first model loaded decides where it goes and the rest follow (`independent` opts out). Every element also carries a tile-local bounding box in the property table (`bboxMinE`/`bboxMinN`/`bboxMinU`/`bboxMaxE`/`bboxMaxN`/`bboxMaxU`). POSITION is read from the file preferring the trustworthy route: `IfcMapConversion` (a surveyed placement into a named projected CRS) WINS over `IfcSite.RefLatitude`/`RefLongitude`, which is very often an authoring default; `originSource` reports which was used, and anything other than `"map-conversion"` raises a structured `"warning"` through the same validation channel other `om-layer` errors use (the on-page panel with `validate` set, `om-validation-error`'s `detail.warnings`) for both `BIMLayer` and `ifc-loader`, once per layer — it never flips `valid` false, only flags the position may be off by tens of metres with no rotation correction applied; override with `site-origin`/`site-heading` or a proper `IfcMapConversion`. Un-projecting a map conversion is supported for WGS84 UTM zones (EPSG:326xx/327xx) and DECLINED with a warning for anything else — a guessed projection lands the model in another country while looking plausible. Eastings/northings are in the target CRS unit, frequently MILLIMETRES. Reading a file correctly and the model being somewhere sensible are SEPARATE problems: all three prepared samples declare placeholders (clinic on Revit's Boston default, which is a 1630 graveyard; duplex on a Chicago city-centre point; bridge — which does carry a real IfcMapConversion — into the mid-Pacific). `headingSource` distinguishes "map-conversion"/"true-north" (read) from "assumed" (file was silent) — report the assumption, never let it read as a measurement. `<om-map>` reads camera attributes ONCE at init, so `setAttribute("center", …)` after mount moves nothing: use `map.flyTo(lonLat, zoom)`. GEOREFERENCING is declarative on `Tile3DLayer` and `PathLayer`: `site-origin="[lng, lat]"` (or `[lng, lat, elevation]`) OVERRIDES the position baked into a tileset's root transform, `site-heading` is a bearing in degrees CLOCKWISE from true north (on its own it rotates the model where it stands), `site-scale` is a uniform multiplier; rotation and scale pivot on the model's own anchor, not the tileset origin. An IFC model is a PAIR of layers — the mesh tileset plus a `PathLayer` outline overlay whose paths are local east/north/up METRES — and both need the same three values or the building separates from its own edges. Never trust a model's declared position without looking at it: authoring tools ship a default project location that is indistinguishable from a survey (the buildingSMART Medical-Dental Clinic sample carries Revit's Boston default, the Duplex a Chicago city-centre point, so both land on occupied downtown blocks at an arbitrary rotation), `IfcMapConversion` is absent from most IFC2x3 exports, and `TrueNorth` is routinely unset. Editing `site-*` on a live Tile3DLayer reloads the tileset (deck.gl only reloads on a URL change); the PathLayer updates as a uniform. GeoTIFF/COG rasters use `type="COGLayer"` with `src="./dem.tif"` (NOT `data` — rasters stream tiles by Range request, they are not parsed rows): `min`/`max` set the rescale window (default 0–255; ALWAYS set them for float/16-bit data like DEMs), `colormap` picks a bundled ramp for single-band sources (gray, viridis, plasma, inferno, magma, cividis, rdylgn, rdbu, spectral, terrain, jet, turbo), `nodata` overrides the source sentinel (renders transparent); plain 8-bit RGB COGs need no styling attributes; restretch/recolor are GPU uniforms (no refetch) and the legend ramp derives from colormap+min/max automatically. Sources must be Cloud-Optimized (`gdal_translate -of COG` otherwise).
|
|
36
|
+
- `<om-map center="[lng, lat]" zoom="11" pitch="55" bearing="20" basemap="positron">` — the root. Give it a height (`om-map { display:block; height:100vh }` with `html,body{height:100%}`, or a sized container) — a custom element is display:inline by default and collapses to zero size; with none set, the library falls back to display:block + a 400px floor so a bare map still shows (any height you DO set wins over the floor, including one below 400px), and a collapsed map logs a console warning. A map hidden on purpose (`hidden`, or inside a `display:none` panel) stays hidden and does not warn. `basemap` accepts a free preset (`liberty`, `bright`, `positron`, `dark-matter`, `voyager`, `osm` — no keys; or `maptiler-streets`/`maptiler-dataviz`/`maptiler-satellite` with `basemap-key="…"` or `OmMap.configureBasemap({ maptilerKey })`), `maplibre` (bare demo style), a style URL (e.g. a MapTiler-customized style; any scheme fetch supports, including desktop asset protocols like Tauri's, query strings fine), or `none` (standalone canvas). The attribute is LIVE — writing it switches the basemap in place (camera + layers survive); the `set-basemap {basemap}` action and `<om-widget type="basemap-switcher" options="positron dark-matter osm">` do the same. Register more via `OmMap.registerBasemap(name, { style })`. Required provider attribution renders automatically (`attribution="false"` opts out). `pitch`/`bearing` tilt the initial camera (use for 3D content). Terrain: `terrain="terrarium"` (keyless AWS DEM; also `mapterhorn` — keyless, CARTO Positron drape by default — `maptiler-terrain` with a key, a raw `{z}/{x}/{y}` DEM URL + `terrain-decoder="terrarium|mapbox-rgb|<JSON>"`, or `off`) raises a 3D surface — geographic layers DRAPE onto it automatically (per-layer `terrain="drape|offset|off"` overrides; 3D-model layers sit ON it), `terrain-exaggeration` scales relief, `terrain-texture` drapes imagery; terrain REPLACES an active basemap while on (restored when off); `set-terrain` action, `terrain` watch token, `OmMap.registerTerrain(name, {...})` for more sources. a georeferenced BIM model REQUIRES the map to author `terrain` explicitly — any value including an explicit `terrain="off"` (flat-ground siting); a model that resolves real elevation (IfcMapConversion + OrthogonalHeight) on a map with no `terrain` attribute raises an ERROR through the validation channel at load time (the library never writes attributes for you — no auto-terrain). Scene lighting for 3D content: `lighting="daylight|studio|flat|custom"` (absent = deck defaults) with `lighting-ambient`/`lighting-sun`/`lighting-sun-azimuth`/`lighting-sun-elevation`/`lighting-camera` overrides and `lighting-sun-date` (ISO/epoch — solar-position sun computed at the map center, wins over azimuth/elevation); attribute-backed (undoable, live-editable), story-steppable via the `set-lighting {lighting, sunAzimuth, ...}` action (`lighting="default"` removes the attribute set; a bare preset is a clean reset — stale overrides clear), native UI via `<om-widget type="lighting">` (preset radios + tuning sliders), widget watch token `lighting`. Clip box (issue #34): `clip-box-min="[lng,lat,elev]"` + `clip-box-max="[lng,lat,elev]"` cut a real axis-aligned 3D box through the scene — every layer clipped by default (per-layer `clip="off"` opts out), `clip-box-invert` shows outside instead of inside, `clip-box-highlight` dims clipped-out geometry instead of discarding it; works on ANY layer including georeferenced Tile3DLayer/BIMLayer content; attribute-backed (undoable) via `set-clip-box {min, max, invert?, highlight?}` (`{clear:true}` removes it), native UI `<om-widget type="clip-box">`; v1 axis-aligned only. XY snapping (issue #34 Part A): `snap="vertex edge midpoint"` + `snap-tolerance="12"` (px, default 12) refines a click/hover to the nearest vertex/edge/edge-midpoint of whichever feature deck ALREADY picked under the cursor — not a spatial index, only that one feature's own geometry is searched, on the CPU, when snapping is on. Applies to every layer by default (`snap="off"` on any `<om-layer>` opts it out, mirroring `clip="off"`) and to a `BIMLayer`'s own edge/crease overlay (its real wall corners/edges, converted from the model's local mesh coordinates to real `[lng,lat]` automatically) — the raw triangle MESH itself is not yet a snap target. Vertex beats midpoint beats edge on range conflicts; hold Space to place a point nearby without snapping. GeoJsonLayer extrudes polygons declaratively: `extruded get-elevation="$height"` (+ `wireframe`). `widgets-hidden` attribute (or the `set-widgets-visible {visible}` action / `<om-widget type="widgets-toggle">` button) hides every widget WITHOUT destroying state — attribution never hides (license); transient (not an undo step) but story-steppable, so a step can clear chrome for a cinematic take. Slots auto-dim while an open `<om-overlay>` popup covers them (position stability over the popup dodging; `widgets-dim="off"` disables). `validate` attribute enables live validation + on-page error panel. Include a `map-id="<random UUID>"` on complete pages (identifies the map artifact for usage telemetry — not a visitor id; the page author deletes it to opt out); `telemetry="off"` disables usage telemetry + library-error reporting for the map (global: `OmMap.configureTelemetry({ disabled: true })`; schema: docs/telemetry.md). Free plan limits (HOSTED http(s) pages only — a dev context (localhost, file://, any non-web scheme) lifts every cap while the attribution badge stays; the exemption is technical convenience, not a license grant — commercial deployment incl. packaged apps still requires a key): 5 layers, 25k rows per layer — layers past a limit don't render and validation says why; a `license-key="om_live_…"` attribute (publishable, origin-restricted) or `OmMap.configureLicense(key)` lifts limits and removes the corner badge. When a user's map exceeds free limits, say so and point at the validation message rather than silently restructuring their data. Events on the element: `om-map-ready` (boot complete; `await mapEl.ready` is the promise twin), `om-validation-error`, `om-view-changed` (camera settled after a move, debounced; `detail = {longitude, latitude, zoom, pitch, bearing, origin}` where `origin` is `"user"` for gesture bursts vs `"programmatic"` for API/story moves — the camera-persistence hook, and the echo-suppression signal when syncing camera state to an app store), `om-map-point` (`detail = {coordinate: [lng,lat]|null, kind}` — every click/hover map coordinate incl. empty-map clicks, for custom capture tools beyond the draw widget), `om-tileset-load` (`detail = {layerId, tileset}` — a Tile3DLayer's live deck Tileset3D, for tools needing the real tileset like region export). `MapController` mirrors these as `onViewChange`/`onMapPoint`/`onTilesetLoad` options. `await mapEl.snapshot()` returns a canvas-only PNG dataURL of the scene (basemap + layers at device pixels; `{as:"blob"}` for files) — DOM widgets/overlays/attribution are NOT captured, so exports must render provider credits themselves.
|
|
37
|
+
- `<om-layer id="..." type="ScatterplotLayer" data="./points.json">` — any deck.gl layer class by `type` (37 layer types total: 32 bundled deck.gl core/geo/aggregation/mesh layers, plus the native `COGLayer`/`ZarrLayer` raster types, `ImageOverlay` for georeferenced drone JPEGs, and `BIMLayer` for BIM source files), plus `PopupLayer` (WebGL badges/labels at scale: `layout="badge|pin-label|card"`, `min-zoom`/`max-zoom`). Every type's full attribute list ships in the package's `onlymapjs.html-data.json` — consult it instead of guessing attribute names or reading the minified dist. TextLayer's default font atlas covers ASCII only: set `character-set` when label text carries other glyphs (`—`, `·`, accents) or deck warns and renders them blank. External layer classes register via `OmMap.registerLayer({type, deckClass, props})` — build them on `@nika-js/onlymap/deck` (the bundled `CompositeLayer`/`TileLayer`/… re-exports), never a separately-installed deck.gl (different class hierarchy, breaks in the renderer); function-valued props ride the subclass's `static defaultProps`; register at module top level BEFORE the manifest mounts (see docs/custom-layers.md). Data: `data` URL (JSON, GeoJSON, CSV/TSV `.csv` — parsed to typed columns — Shapefile `.shp` (+`.dbf` attributes) and KML `.kml` as GeoJSON features, GPX `.gpx` (waypoints/tracks/routes → features tagged `_gpxKind`; a `#waypoints`/`#tracks`/`#routes` URL fragment selects one part), FlatGeobuf `.fgb` (cloud-native binary vector, whole-file decode), GeoParquet `.parquet`/`.geoparquet` (cloud-native columnar vector — all-Point files stay columnar like Arrow, lines/polygons become GeoJSON features; requires the file's `geo` metadata with WKB geometry, and CRS84/EPSG:4326 — a projected CRS is a loud error telling you to reproject, not a silent misplacement; snappy/gzip/zstd row-group compression handled), CityJSON `.city.json` / CityJSONSeq `.city.jsonl` (semantic 3D city models — 3DBAG, PLATEAU — decoded to one of two shapes by the `data` URL, no CityJSON layer type: default → extruded footprints, `type="GeoJsonLayer" extruded get-elevation="$roof_height"`; `?om-surfaces=1` → one row PER FACE at its own real per-vertex height so a pitched LoD2.2 roof actually looks pitched, `type="SolidPolygonLayer" get-polygon="$polygon" full3d` (`extruded` stays at its ordinary `false` default) (flat-shaded — deck.gl only lights the `extruded` shader path — each row also carrying `surface_type`: RoofSurface/WallSurface/GroundSurface, and `fill_color`: a ninja-viewer-style default color per surface_type/cityobject_type, verified against cityjson-threejs-loader's own default palette — `get-fill-color` on `SolidPolygonLayer` reads it automatically when left unauthored, no color attribute required, and an authored `get-fill-color` still overrides it); derived properties (both modes) `roof_height` (area-weighted mean roof height above ground), `eaves_height`, `ridge_height`, `ground_height`, `roof_area`, `surface_count`, `lod`, `cityobject_id`, `cityobject_type`, `parent_id` win over same-named source attributes, plus surfaces-mode-only `polygon`/`outline`/`surface_type`/`fill_color` (`outline` is the face's outer ring flattened and closed — bind a companion `type="PathLayer" get-path="$outline"` layer to it for visible face edges, since surfaces mode is flat-shaded and `SolidPolygonLayer`'s own `wireframe` prop is a no-op when unextruded — always pair one, matching `filter-field`/`filter-range` to the fill layer), and a parent Building's attributes are inherited by its BuildingPart rows; national grids NL/CH/DE/JP/AT/SG reproject automatically including axis order, other EPSG codes fail with an error naming the code; highest LoD wins, pin one with `?om-lod=1.2` (combine as `?om-lod=1.2&om-surfaces=1`, cached independently); `.city.jsonl` fills in as it downloads in either mode — see docs/3d-assets.md), or Arrow IPC `.arrow`/`.feather` — large point datasets stay columnar, GeoArrow line/polygon geometry becomes GeoJSON features, zstd-compressed IPC is handled; other formats plug in via `OmMap.registerFormat({match, parse})`; data URLs accept any scheme the runtime's fetch supports — desktop webviews (Tauri, Electron) pass asset-protocol URLs straight in), inline `<script type="application/json">` (row arrays or column-oriented `{"columns": {"lon": [...], "lat": [...]}}`; must be a DIRECT child of the `<om-layer>`, and when present it wins — omit the `data` attribute), or `wss://` streaming (`key="mmsi"` upserts entities in place, `flush="250ms"` coalesces bursts, `source="name"` selects a `OmMap.registerSource` decoder plugin), or a polled REST snapshot (`refresh="5s"` re-fetches and replaces — for live endpoints that return the full current state). TILED layers: a `{z}/{x}/{y}` `data` template is deck's tile URL for `TileLayer`/`MVTLayer` (NOT rows) — passed through to deck verbatim, never fetched/parsed, so `<om-layer type="TileLayer" data="…/{z}/{x}/{y}.png">` works (raster gets a built-in BitmapLayer sublayer) and `type="MVTLayer" data="…/{z}/{x}/{y}.pbf"` self-renders vector tiles with `get-*` accessors applying to each decoded feature's properties; a tiled layer has no local rows so `ctx.data`/`ctx.stats`/`filter-*` don't apply. Authenticated endpoints: call `OmMap.configureData({ headers: {...} })` in a script — never put tokens in attributes. `$field` accessors work identically on all of them — never write column-index code yourself. One columnar restriction: the `js` full-JS opt-in is not allowed on Arrow/columnar layers (validation will tell you; use `$field` accessors instead). For 3D models use `type="ScenegraphLayer"` with `scenegraph="./model.glb"` (required) and `get-orientation="[0, $heading, 90]"` — the roll of 90 stands Y-up glTF models upright; see docs/3d-assets.md. 3D Tiles use `type="Tile3DLayer"` with `tileset="…/tileset.json"` (NOT `data`). Any pickable layer can opt into deck's DEPTH-pick pass with `pickable="3d"` instead of a bare `pickable` (issue #34) — the resolved click/hover coordinate then carries a real elevation (`{{z}}` in overlay/tooltip templates, `ctx.selection.coordinate`) instead of the ray∩ground-plane guess, so a click on a building face lands ON the face rather than on the ground behind it; `terrain` sets this on itself. For BIM/photogrammetry, `pickable` alone picks a whole TILE — add `pick-features` to pick an individual ELEMENT (a wall, a window, one IFC product), and the `selection` then carries that element's `featureId`/`properties`/`class` from the tile's own `EXT_mesh_features` + `EXT_structural_metadata` (`feature-id-property` selects the ID set, default `_FEATURE_ID_0`). `feature-styles` recolours/fades/highlights by feature ID — an array indexed BY id of `{color: [r,g,b], strength: 0-1, opacity: 0-1}`, live-settable, uploaded as a small lookup texture (no refetch). Use `strength` below 1 to tint rather than replace, or the model's own texture is hidden. Isolate/hide/ghost are DECLARATIVE and mirror the vector `filter-field`/`filter-categories` pair — `feature-filter-field="component"` names the metadata field, then `isolate-features='["Clock"]'` / `hide-features='["Windows","Skylight"]'` / `ghost-features='["Wall"]'` take JSON value lists matched against the tile's property table (`ghost-opacity` tunes the fade, default 0.18). `isolate-features` is EXCLUSIVE (anything unlisted is hidden); hiding is a shader discard so a hidden element also stops being pickable and whatever is behind it becomes selectable. They compose ONTO `feature-styles` (style table supplies colour, these supply visibility), and being attributes they are undoable and story-steppable — prefer them over computing a style table in page JS. Multi-material/multi-primitive models fan out correctly (glTF allows one material per primitive, so real IFC exports are usually many primitives); only genuinely instanced i3dm tiles stay tile-granularity. Limits to state rather than discover: texture-backed IDs — how photogrammetry classification ships — require `load-options='{"gltf":{"loadBuffers":true,"loadImages":true},"image":{"type":"data"}}'`, and omitting `image.type` makes the tileset take MINUTES to appear (loaders.gl otherwise reads the whole ID texture back through a canvas once per vertex); and `opacity` below 1 currently blanks the model. BIM WIDGETS (all need a `pick-features` layer): `<om-widget type="ifc-browser" layer="clinic" fields="ifcClass material container spatialPath" scale-fields="netVolume" rows="7">` groups the model by a property-table field, counts each value, and gives every row I/H/G buttons that WRITE `isolate-features`/`hide-features`/`ghost-features` (so it is a UI over the attributes — undoable and story-steppable; I is MULTI-SELECT, isolating the union of every pressed row or tree node, since `isolate-features` is a list and the layer matches it as a set) while keeping the companion outline layer's `filter-categories` in step; that same select also offers whichever TREES the file supports — Spatial (`spatialPath`), Type (`typePath`), System (`systemPath`) and Classification (`classificationPath`, built by walking `ReferencedSource`) — each expandable with counts aggregated upward and the same I/H/G on every node, so isolating a storey or a system or a CCS code reaches every element under it. Spatial is NOT privileged (on a real Danish project the classification tree covered 3,415 elements to spatial's 660). A tree is not a separate mode, just a group-by on a hierarchy column — LIST it in `fields` to offer it, or set `field="spatialPath"` to open on it — and `loadIfc` emits a hierarchy column ONLY when the file populates it, so a tree that would render empty is never offered; trees appear automatically when available (no need to list them in `fields`) and `field="spatialPath"` opens straight onto one. ONE browser per layer: `feature-filter-field` and the isolate/hide/ghost attributes are single-valued, so two instances on one layer clobber each other. The widget was renamed from `ifc-legend` (still registered as a deprecated alias) because it is a model browser, not a legend. non-physical classes (IfcSpace, IfcOpeningElement) are hidden unless `show-non-physical`, and `no-color` removes the colour select. `<om-widget type="feature-inspector" fields="ifcClass material container netVolume">` (renamed from `ifc-inspector`, kept as an alias) shows the picked element's properties. `<om-widget type="ifc-loader" layer="ifc" federate>` is a drop zone that parses `.ifc` files IN THE BROWSER and builds both layers itself; `federate` accepts SEVERAL models into one co-registered scene (one drop zone, one layer per model, each with a visibility toggle) rather than one widget per discipline, matching how coordination tools append models; a model that turns out to be georeferenced is AUTO-PLACED (its own coordinates/heading/scale written onto the layers the WIDGET created, camera flown there) — but the widget NEVER writes `<om-map>`'s own scene attributes (`basemap`, `terrain`): those are author-owned, and a georeferenced model on a map with neither raises a structured "no spatial context" warning instead of switching one on. COLOUR BY PROPERTY instead of hand-computing a `feature-styles` table: `feature-color-by` (categorical, `feature-palette` overrides the built-in cycle) or `feature-color-scale` (graduated over a numeric field), with `feature-color-strength` (default 0.85) controlling how hard the colour mixes over the model's own material. Setting NEITHER is the default and is meaningful — the model renders in its own IFC surface colours. A graduated ramp needs the field populated: Revit IFC2x3 exports often carry no `IfcElementQuantity`, so every `netVolume` is 0 and the ramp is flat. Widget scripts read the decoded property table with `ctx.features(layerId)` (undefined until the first tile carrying one lands) and re-render on the `features` watch token. `<om-widget type="ifc-clash" layers="arch mep" tolerance="0">` is the CLASH OVERLAY over two co-registered model layers: it flags element pairs whose bounding boxes interpenetrate, colours both sides via `feature-styles`, and flies to the centre of each overlap. v1 is an axis-aligned box test — fast and serverless, but it over-reports anything diagonal and says nothing about which clashes matter; zones/spaces/openings/proxies and same-class-same-name pairs are excluded as noise. The header carries an overlay on/off switch; an isolation mode select (None/Dim/Hide) sits above the results list and applies once a row is selected (with nothing selected, nothing is hidden), and clicking a row FOCUSES that clash (chosen pair at full strength, every other clashing element dropped to a faint tint, camera flown to the overlap centre) — without that, everything is highlighted and nothing is. Results are GROUPED by the side-A element with a count (one wall crossing four ducts is one row), and the two model selects appear only when more than two models are loaded. It CHECKS co-registration (matching `site-origin`) and says so when it fails, because two mis-registered models report zero clashes exactly like two clean ones. Persisting/sharing results is BCF's job and is out of scope. `<om-layer type="BIMLayer" src="./model.ifc">` is the declarative counterpart to `ifc-loader`/`loadIfc`: point it at a BIM source file (an .ifc today) and it runs the loader itself the moment src resolves — no pre-baked tileset, no site-origin/site-heading/site-scale (the file's own georeference is read and applied automatically; not wired up yet: an authored site-origin on a BIMLayer does not override it), and no separate PathLayer for the outline overlay (added automatically). `pick-features` defaults ON (unlike a plain Tile3DLayer); feature-filter-field/feature-styles/isolate-hide-ghost/feature-color-by/ghost-opacity all work unchanged, since BIMLayer forwards them to a real Tile3DLayer it builds internally. Known gap: the outline overlay does not yet follow isolate/hide/ghost the way the mesh does. Reach for BIMLayer when the model is fixed and known ahead of time; reach for ifc-loader when a visitor picks the file or several models need to federate. IN-BROWSER IFC: `loadIfc(bytes)` parses an `.ifc` with web-ifc (WASM, MPL-2.0, CDN-fetched on first use — NOT a package dependency; `configureIfc({wasmPath})` self-hosts) and returns `{tilesetUrl, edgesUrl, loadOptions, features (rows carry `ifcClass`/`name`/`material`/`container`/`netVolume`/`spatialPath`/`typePath`/`systemPath`/`classificationPath` — hierarchy columns joined by U+001F that the model trees navigate, each emitted only when the file populates it), lonLat, georeferenced, heading, scale, originSource, headingSource, stats, timings, bounds, revoke()}`; the output IS a tileset so picking/styling/`site-*`/isolate-hide-ghost work unchanged. CALL `revoke()` when swapping models — blob URLs are held by the document. FEDERATION: pass the first model's returned `origin` as `LoadIfcOptions.origin` for every later model of the same building, or each is centred on its own bounding box and they drift apart — a clash pass then finds nothing, which looks identical to a clean model. `ifc-loader` shares one origin AND one placement (`site-origin`/`site-heading`/`site-scale`) per map automatically — discipline files routinely declare IfcSite coordinates kilometres apart for the same building, so the first model loaded decides where it goes and the rest follow (`independent` opts out). Every element also carries a tile-local bounding box in the property table (`bboxMinE`/`bboxMinN`/`bboxMinU`/`bboxMaxE`/`bboxMaxN`/`bboxMaxU`). POSITION is read from the file preferring the trustworthy route: `IfcMapConversion` (a surveyed placement into a named projected CRS) WINS over `IfcSite.RefLatitude`/`RefLongitude`, which is very often an authoring default; `originSource` reports which was used, and anything other than `"map-conversion"` raises a structured `"warning"` through the same validation channel other `om-layer` errors use (the on-page panel with `validate` set, `om-validation-error`'s `detail.warnings`) for both `BIMLayer` and `ifc-loader`, once per layer — it never flips `valid` false, only flags the position may be off by tens of metres with no rotation correction applied; override with `site-origin`/`site-heading` or a proper `IfcMapConversion`. Un-projecting a map conversion is supported for WGS84 UTM zones (EPSG:326xx/327xx) and DECLINED with a warning for anything else — a guessed projection lands the model in another country while looking plausible. Eastings/northings are in the target CRS unit, frequently MILLIMETRES. Reading a file correctly and the model being somewhere sensible are SEPARATE problems: all three prepared samples declare placeholders (clinic on Revit's Boston default, which is a 1630 graveyard; duplex on a Chicago city-centre point; bridge — which does carry a real IfcMapConversion — into the mid-Pacific). `headingSource` distinguishes "map-conversion"/"true-north" (read) from "assumed" (file was silent) — report the assumption, never let it read as a measurement. `<om-map>` reads camera attributes ONCE at init, so `setAttribute("center", …)` after mount moves nothing: use `map.flyTo(lonLat, zoom)`. GEOREFERENCING is declarative on `Tile3DLayer` and `PathLayer`: `site-origin="[lng, lat]"` (or `[lng, lat, elevation]`) OVERRIDES the position baked into a tileset's root transform, `site-heading` is a bearing in degrees CLOCKWISE from true north (on its own it rotates the model where it stands), `site-scale` is a uniform multiplier; rotation and scale pivot on the model's own anchor, not the tileset origin. An IFC model is a PAIR of layers — the mesh tileset plus a `PathLayer` outline overlay whose paths are local east/north/up METRES — and both need the same three values or the building separates from its own edges. Never trust a model's declared position without looking at it: authoring tools ship a default project location that is indistinguishable from a survey (the buildingSMART Medical-Dental Clinic sample carries Revit's Boston default, the Duplex a Chicago city-centre point, so both land on occupied downtown blocks at an arbitrary rotation), `IfcMapConversion` is absent from most IFC2x3 exports, and `TrueNorth` is routinely unset. Editing `site-*` on a live Tile3DLayer reloads the tileset (deck.gl only reloads on a URL change); the PathLayer updates as a uniform. GeoTIFF/COG rasters use `type="COGLayer"` with `src="./dem.tif"` (NOT `data` — rasters stream tiles by Range request, they are not parsed rows): `min`/`max` set the rescale window (default 0–255; ALWAYS set them for float/16-bit data like DEMs), `colormap` picks a bundled ramp for single-band sources (gray, viridis, plasma, inferno, magma, cividis, rdylgn, rdbu, spectral, terrain, jet, turbo, ylorrd), `nodata` overrides the source sentinel (renders transparent); plain 8-bit RGB COGs need no styling attributes; restretch/recolor are GPU uniforms (no refetch) and the legend ramp derives from colormap+min/max automatically. Sources must be Cloud-Optimized (`gdal_translate -of COG` otherwise).
|
|
33
38
|
- Geotagged drone JPEGs are the library-owned `ImageOverlay` type, not row `data` and not a raw `BitmapLayer`: `<om-layer type="ImageOverlay" src="./photo.jpg" georeference="exif">`. It reads GPS/relative altitude/camera/focal length plus DJI gimbal metadata through the configured fetch policy, waits before `ready`, bakes yaw/roll, and computes visualization-grade flat-ground bounds. Unknown cameras need `sensor-width-mm` + `sensor-height-mm` (and `focal-length-mm` when EXIF lacks it). For collaborative/saved maps call `OmMap.resolveImageOverlay(fileOrUrl)`, upload its returned `image`, then reconstruct using `src` + the returned explicit `bounds` (no EXIF fetch). Use `COGLayer` for large orthomosaics; see docs/image-overlays.md.
|
|
34
39
|
- Chunked N-dimensional Zarr / GeoZarr rasters (climate/weather grids, datacubes) are the library-owned `ZarrLayer` type (built on `@developmentseed/deck.gl-zarr` + zarrita, a lazy chunk): `<om-layer type="ZarrLayer" src="./x.zarr" variable="temp" select="time=0" colormap="viridis" min="…" max="…">`. `src` not `data` (chunks stream through the layer's reader, never parsed rows). Pick the `variable` and pin EVERY non-spatial dimension in `select` ("init_time=0, lead_time=0, ensemble_member=0"); the two spatial dims are handled for you (a 2-D array needs no select). A GeoZarr-compliant store georeferences itself; a plain Zarr needs manual `bounds="[w,s,e,n]"` + `crs="EPSG:4326"` + `spatial-dims="<yName> <xName>"` (bounds without crs+spatial-dims is a validation error). `min`/`max`/`colormap`/`nodata` and the auto legend reuse the exact COGLayer raster pipeline. Beware store chunking: a dataset chunked coarsely over non-spatial dims (e.g. all forecast steps in one chunk) decodes far more than the pinned frame needs. `src` may be any absolute URL (`https://…/store.zarr`) — an external/remote store works with no server setup (a static host serves Zarr's extensionless chunk keys natively), but zarrita fetches it directly from the browser so the store MUST send CORS headers (`Access-Control-Allow-Origin`), and it must be PUBLIC — authenticated stores are not yet supported (ZarrLayer uses zarrita's own fetch, not `OmMap.configureData`).
|
|
35
|
-
- `<om-widget type="legend|layer-switcher|basemap-switcher|lighting|zoom-controls|undo-redo|scale-bar|attribution|filter|vega-lite|measure" position="bottom-end">` — static UI panels. `position` takes one of 8 managed slots (logical, RTL-aware: `top-start|top-center|top-end|center-start|center-end|bottom-start|bottom-center|bottom-end`; legacy corners `top-left` etc. alias) — same-slot widgets stack with flush edges and a shared gap (never overlap); provider attribution joins `bottom-end` and the license badge joins `bottom-start` as in-flow members, so required chrome never covers a widget; `order="1"` orders within a slot; adjacent compact button widgets (zoom-controls, undo-redo, widgets-toggle) auto-merge into ONE control group with dividers (`cluster="false"` opts a widget out); `position="manual"` renders a plain block you place with your own CSS (even outside the map). At map widths ≤640px managed widgets auto-fold into one accessible drawer per map side; `fold="never"` exempts an essential widget, `widgets-fold="off"` opts the map out, `--om-widget-fold-breakpoint` changes the threshold. Layout tokens on `<om-map>`: `widget-style="gap:10 opacity:0.9 inset:16"` (keys inset/gap/inset-x/-y/gap-x/-y/opacity/radius/size, px except opacity) or the `--om-widget-inset-x/-y/-gap-x/-gap-y/-opacity/-radius/-fold-breakpoint` custom properties. Built-ins are themeable from page CSS via custom properties (they inherit through the shadow root): `om-map { --om-widget-bg: #111827; --om-widget-fg: #f9fafb; }` — full set: `--om-widget-bg/-fg/-muted/-border/-hover-bg/-accent`; scope to a single widget with an `om-widget[type=legend]` selector instead. `measure` is a geodesic ruler: `modes="distance area"` (default
|
|
40
|
+
- `<om-widget type="legend|layer-switcher|basemap-switcher|lighting|clip-box|zoom-controls|undo-redo|scale-bar|attribution|filter|vega-lite|dynamic-chart|measure" position="bottom-end">` — static UI panels. `position` takes one of 8 managed slots (logical, RTL-aware: `top-start|top-center|top-end|center-start|center-end|bottom-start|bottom-center|bottom-end`; legacy corners `top-left` etc. alias) — same-slot widgets stack with flush edges and a shared gap (never overlap); provider attribution joins `bottom-end` and the license badge joins `bottom-start` as in-flow members, so required chrome never covers a widget; `order="1"` orders within a slot; adjacent compact button widgets (zoom-controls, undo-redo, widgets-toggle) auto-merge into ONE control group with dividers (`cluster="false"` opts a widget out); `position="manual"` renders a plain block you place with your own CSS (even outside the map). At map widths ≤640px managed widgets auto-fold into one accessible drawer per map side; `fold="never"` exempts an essential widget, `widgets-fold="off"` opts the map out, `--om-widget-fold-breakpoint` changes the threshold. Layout tokens on `<om-map>`: `widget-style="gap:10 opacity:0.9 inset:16"` (keys inset/gap/inset-x/-y/gap-x/-y/opacity/radius/size, px except opacity) or the `--om-widget-inset-x/-y/-gap-x/-gap-y/-opacity/-radius/-fold-breakpoint` custom properties. Built-ins are themeable from page CSS via custom properties (they inherit through the shadow root): `om-map { --om-widget-bg: #111827; --om-widget-fg: #f9fafb; }` — full set: `--om-widget-bg/-fg/-muted/-border/-hover-bg/-accent`; scope to a single widget with an `om-widget[type=legend]` selector instead. `measure` is a geodesic ruler: `modes="distance area volume"` (default `distance area`), `units="metric|imperial|nautical"` — click the map to place points, live per-segment + total labels render on the map, and it dispatches an `om-measure` event; it reuses the draw capture stack (measure/draw mutually exclusive) and its geometry is ephemeral (never saved, not an undo step) — see the dedicated `measure`/`volume` bullet above for the full event shape and the `profile`/`density`/`swell`/`shrink`/`deadband` volume-mode attributes. `scale-bar` now takes the same `units`. `dynamic-chart` is the same Vega-Lite rendering as `vega-lite` but fed by a live DOM event instead of a layer: `on="<event-name>"` (required) + `series-field="<name>"` (default `series`) reads `event.detail[seriesField]` as `data.values` and re-embeds on every event where it's a present array, at a fixed `width`; omitting the field on a later event freezes the chart with no separate pause API — built for a feature (a drawn line's elevation profile) that computes its own series live and has no layer to bind to. No `type` + HTML + `<script type="om/widget">` = custom widget with `ctx` (`ctx.layers`, `ctx.data(id)`, `ctx.dataInViewport(id)`, `ctx.stats(id, field)`, `ctx.viewport`, `ctx.selection`, `ctx.emit(action, payload)`), `this.watch = ['data:<layerId>', 'viewport', 'selection', 'layers', 'history', 'basemap']` (`layers` also fires on visibility/filter changes; `basemap` on basemap switches; `history` on undo/redo availability), `this.$(sel)`, `vegaEmbed`/`d3` as globals.
|
|
36
41
|
- Custom-widget event emission (the #1 custom-widget bug — a widget that renders but does nothing): a widget DRIVES the map ONLY by EMITTING a registered action; it never mutates the map or dispatches its own `CustomEvent`. Two ways: (1) declarative `data-emit="<action>"` + `data-*` payload keys on an element (fires on click, or change for form controls whose `.value` is auto-added; `data-*` values are STRINGS — use `ctx.emit` for numeric/array payloads like a slider's range); (2) `ctx.emit(action, payload)` for typed payloads, wired INSIDE `render` (so `ctx` is in scope) by assigning `.oninput`/`.onclick` — e.g. a day slider: `this.render = (ctx) => { this.$("#day").oninput = e => ctx.emit("filter-layer", { layer: "quakes", field: "day", range: [+e.target.value, +e.target.value] }); }`. Actions + payloads: `filter-layer {layer, field?, range:[min,max]}`, `toggle-layer {layer, visible?}`, `fly-to {center:[lng,lat], zoom?, duration?}`, `zoom-to-feature {layer, featureId}`, `set-basemap {basemap}`, `highlight-feature {layer, featureId}`, `show-overlay`/`hide-overlay {target}`, `story-play`/`story-pause`/`story-seek {story, t?}`, `undo`/`redo`, `zoom-in`/`zoom-out`, `set-widgets-visible {visible}`; register more with `OmMap.registerAction(name, handler)`. NEVER inline `onclick=`/`oninput=` — `ctx` isn't a global and CSP blocks them, so it silently fires nothing (validation errors on it). For a plain value/time slider prefer the built-in `<om-widget type="filter" layer=… field=…>` — it wires `filter-layer` for you; hand-author only for bespoke UI.
|
|
37
|
-
- `<om-overlay id="..." anchor-from="selection">` — rich geo-anchored HTML (≤ ~20 per map). Anchors: `anchor="[lng, lat]"` (static), `anchor-from="selection"` (follows picks), or `anchor-layer="regions" anchor-feature-id="mission"` (anchored to a feature's own geometry — bbox center — no coordinates in markup; `{{field}}` interpolates that feature's attributes). Selection-anchored overlays scope with `layer="…"` (one layer's picks only) and `selection-type="click"|"hover"` (one pick type only) — a click-opened popup should ALWAYS set `selection-type="click"`, else merely hovering any pickable feature drags it there and re-templates it against the hovered object; with it, hover is inert and a click on empty space still dismisses. `{{field}}` interpolates the picked feature HTML-escaped; `{{{field}}}` is raw (avoid). For labels on many features use `PopupLayer`, not overlays.
|
|
38
|
-
- `<om-behavior on="click|hover|drag|load|data-loaded" layer="..." action="...">` — declarative interaction. Built-in actions: `show-overlay`, `hide-overlay`, `show-tooltip`, `hide-tooltip`, `toggle-layer`, `filter-layer`, `highlight-feature`, `zoom-to-feature`, `set-basemap`, `undo`, `redo
|
|
42
|
+
- `<om-overlay id="..." anchor-from="selection">` — rich geo-anchored HTML (≤ ~20 per map). Anchors: `anchor="[lng, lat]"` (static), `anchor-from="selection"` (follows picks), or `anchor-layer="regions" anchor-feature-id="mission"` (anchored to a feature's own geometry — bbox center — no coordinates in markup; `{{field}}` interpolates that feature's attributes). Selection-anchored overlays scope with `layer="…"` (one layer's picks only) and `selection-type="click"|"hover"` (one pick type only) — a click-opened popup should ALWAYS set `selection-type="click"`, else merely hovering any pickable feature drags it there and re-templates it against the hovered object; with it, hover is inert and a click on empty space still dismisses. `{{field}}` interpolates the picked feature HTML-escaped; `{{{field}}}` is raw (avoid); `{{z}}` is the pick's ELEVATION in meters, present only when a `pickable="3d"` layer ran deck's depth pass for that pick (absent — not `0` — otherwise, so "no elevation" is distinguishable from sea level). `clip-to-map` (opt-in) hides the overlay when its own BOX would spill past the map viewport rather than only when its anchor leaves — for small transient tips that track the cursor; an overhanging absolutely-positioned box inflates the page's scrollable overflow and the scrollbar -> map resize -> reprojection loop shows as view jitter. For labels on many features use `PopupLayer`, not overlays.
|
|
43
|
+
- `<om-behavior on="click|hover|drag|load|data-loaded" layer="..." action="...">` — declarative interaction. Built-in actions: `show-overlay`, `hide-overlay`, `show-tooltip`, `hide-tooltip`, `toggle-layer`, `filter-layer`, `highlight-feature`, `zoom-to-feature`, `set-basemap`, `undo`, `redo`; scene/tool actions `set-lighting`, `set-terrain`, `set-clip-box` (`{min,max,invert?,highlight?}` / `{clear:true}`), `clip-box-edit` (`{editing}`), `export-region-3d` (`{target?, format?:"glb"|"b3dm"}` — what the draw widget's `export-3d` button emits), and the measure actions `measure-mode` (`{mode:"distance"|"area"|"volume"|null}`), `measure-units`, `measure-clear`, `measure-config` (`{profile?, baseSurface?, density?, swell?, shrink?, deadband?}`), `measure-flat-target-plane` (`{flat}`). One payload contract everywhere: `{ layer, target, feature, featureId, coordinate }`.
|
|
39
44
|
- Undo/redo is built in: user-facing manifest changes (layer toggles, filter changes, basemap switches, element add/remove, drawn sketches) are recorded automatically — the manifest is the state. `<om-widget type="undo-redo">` renders the buttons; Cmd/Ctrl-Z, Shift-Cmd/Ctrl-Z, and Ctrl-Y work on any map (text inputs keep their native undo). Camera moves, hover effects, and story playback are deliberately NOT undo steps. Widget scripts: `ctx.history.canUndo/canRedo` with watch token `history`; `ctx.emit("undo")`/`ctx.emit("redo")`.
|
|
40
45
|
- `<om-fallback>` — static no-JS fallback, direct child of `<om-map>` (one per map, no attributes, plain HTML content — links allowed). Shown ONLY where scripts never run (chat-app/email file previews — iOS QuickLook renders HTML attachments with JS off — file managers, sandboxed webviews); hidden automatically once the map boots. GOOD PRACTICE: include one on every complete page, especially pages that may be shared as a file ("This interactive map requires JavaScript — open this file in a web browser", plus a hosted-version link when one exists). Without one, the stylesheet shows a generic text-only banner. The gate is pure CSS (`om-map:not(:defined)` in onlymapjs.css), so the CSS must load without JS — a real `<link rel="stylesheet">` or inlined `<style>` on no-build pages; a bundler-emitted stylesheet is fine in npm projects.
|
|
41
46
|
- Animation: `transition="get-fill-color 800ms, get-radius 400ms"` on a layer GPU-animates prop changes (also smooths streaming updates via `get-position`). Camera: the `fly-to` action takes `center`/`zoom`/`pitch`/`bearing`/`duration` (e.g. `duration="2s"`) — use it in behaviors or `data-emit` buttons; `zoom-to-feature` also accepts `duration`.
|