@nika-js/onlymap 0.6.26 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +2 -2
  3. package/dist/{LercDecode.es-B4KknLMs.js → LercDecode.es-CYOgDeVu.js} +1 -1
  4. package/dist/{basemap-C4bmWq6M.js → basemap-0rGUZkQT.js} +1 -1
  5. package/dist/elements/om-map.d.ts +9 -1
  6. package/dist/{geoparquet-BPMda753.js → geoparquet-Cau8fzH7.js} +1 -1
  7. package/dist/{index-DMT_7aV3.js → index-BF3xZe9d.js} +1 -1
  8. package/dist/{index-DK5w4OxN.js → index-Bt3U7sbb.js} +1 -1
  9. package/dist/{index-B5hsHnPl.js → index-CpSUZ-J3.js} +1 -1
  10. package/dist/{index-DtU_XhTX.js → index-CriPOt1s.js} +2 -2
  11. package/dist/{index-COuIQk2Z.js → index-CxOS31XG.js} +11193 -10962
  12. package/dist/index.d.ts +1 -1
  13. package/dist/layer-registry.d.ts +10 -0
  14. package/dist/{lerc-BNW5A6zN.js → lerc-aeqKo4fK.js} +2 -2
  15. package/dist/onlymap.standalone.js +29316 -28616
  16. package/dist/onlymapjs.js +91 -89
  17. package/dist/{raster-eMqzBMQf.js → raster-D70FrkLt.js} +1049 -790
  18. package/dist/raster-identify.d.ts +19 -0
  19. package/dist/{raster-pipeline-Dg-GSDP3.js → raster-pipeline-A-zvBnKU.js} +660 -450
  20. package/dist/raster-pipeline.d.ts +109 -14
  21. package/dist/raster.d.ts +91 -1
  22. package/dist/react.js +253 -234
  23. package/dist/runtime-core.d.ts +23 -0
  24. package/dist/version.d.ts +1 -1
  25. package/dist/widget-layout.d.ts +18 -0
  26. package/dist/{zarr-Dv7OiUcg.js → zarr-DmrbmSNx.js} +10 -3
  27. package/docs/design/cog-v2.md +223 -0
  28. package/llms.txt +1 -1
  29. package/onlymapjs.attributes.json +9 -0
  30. package/onlymapjs.html-data.json +24 -0
  31. package/package.json +1 -1
  32. package/skills/onlymapjs/SKILL.md +1 -1
  33. package/skills/onlymapjs/references/syntax.md +12 -7
@@ -224,6 +224,18 @@ export interface RuntimeCoreCallbacks {
224
224
  hasFullMapConversion: boolean;
225
225
  approximatePlacement: boolean;
226
226
  }) => void;
227
+ /** A `carriesRasterWindow` layer (COGLayer) resolved an automatic rescale window (COG v2 phase 3) — legend derivation for auto-stretched sources. `bands` are 1-based; `source` is "stats" (deterministic header tags) or "overview" (data-derived sample). */
228
+ onAutoWindow?: (layerId: string, info: {
229
+ min: number | number[];
230
+ max: number | number[];
231
+ bands: number[];
232
+ source: string;
233
+ }) => void;
234
+ /** A paletted raster derived legend class rows from its embedded color table (COG v2 phase 5; only when the used-entry count is small). */
235
+ onPaletteLegend?: (layerId: string, entries: {
236
+ color: string;
237
+ label: string;
238
+ }[]) => void;
227
239
  /**
228
240
  * Runtime error boundary (spec: "Runtime error boundary") — deck.gl-level
229
241
  * failures (a crashing accessor, an incompatible prop) formatted into the
@@ -410,6 +422,8 @@ export declare class RuntimeCore {
410
422
  private unsubscribeLicense?;
411
423
  private readonly chromeHosts;
412
424
  constructor(parent: HTMLElement, initialView: InitialView, callbacks?: RuntimeCoreCallbacks, basemapAttr?: string, headless?: HeadlessOptions, basemapOptions?: BasemapRuntimeOptions, chromeHosts?: MandatedChromeHosts);
425
+ /** Zoom ±1 about the cursor (shift inverts), animated, tagged "user" — a real gesture, so one burst latch before the transition's programmatic frames. */
426
+ private readonly standaloneDblclick;
413
427
  /** `basemap` attribute → concrete style, logging resolution problems (unknown preset / missing key) — the demo-style fallback still renders. */
414
428
  private resolveBasemap;
415
429
  /**
@@ -442,6 +456,15 @@ export declare class RuntimeCore {
442
456
  private installHoverGate;
443
457
  /** Writes the affordance cursor onto whichever canvas owns pointer events in the current mode; "" restores the renderer's own cursor management. */
444
458
  private applyHoverCursor;
459
+ /**
460
+ * COG v2 phase 4: a click/hover that hits no vector feature but lands on
461
+ * an identify-capable raster synthesizes a selection whose `object`
462
+ * carries the pixel's per-band values ({ value, values, bands, nodata },
463
+ * plus band_1..band_n) — so overlays ({{value}}), tooltips, and behaviors
464
+ * consume raster picks through the machinery they already have. Topmost
465
+ * layer wins; identify itself is a CPU sample of the retained decode.
466
+ */
467
+ private rasterSelectionAt;
445
468
  private pickCallbacks;
446
469
  /** The current lighting as a deck `effects` array — [] restores deck's default lights. */
447
470
  private buildEffects;
package/dist/version.d.ts CHANGED
@@ -5,4 +5,4 @@
5
5
  * the build rootDir, and a `define` would need repeating across vite/vitest/
6
6
  * vite-node configs.
7
7
  */
8
- export declare const LIBRARY_VERSION = "0.6.26";
8
+ export declare const LIBRARY_VERSION = "0.7.0";
@@ -29,6 +29,24 @@ export declare const FOLD_SIDES: readonly FoldSide[];
29
29
  export declare function foldSideForSlot(slot: ManagedSlot): FoldSide;
30
30
  /** The managed slot that owns each drawer toggle, keeping it in normal flow. */
31
31
  export declare function foldToggleSlot(side: FoldSide): ManagedSlot;
32
+ /** Extra bottom offset that keeps the bottom-end row clear of the folded bottom drawer's toggle. */
33
+ export declare const FOLD_BOTTOM_CLEARANCE_PX = 28;
34
+ /**
35
+ * Bottom-corner separation (spec: "Widget Layout Manager / Overlap
36
+ * avoidance", structural half): how far above the inset the bottom-end row
37
+ * must sit to clear the bottom-start row. The two bottom corners are
38
+ * independent absolute containers, so nothing in normal flow stops a wide
39
+ * expanded provider attribution from crossing into the license badge, or a
40
+ * badge that wraps taller than the fixed folded clearance from rising into
41
+ * the attribution. Rects come from REAL geometry (callers no-op headless).
42
+ *
43
+ * Lifting only moves bottom-end vertically — neither row's width changes —
44
+ * so the horizontal-intersection test is a stable fixpoint: the same rects
45
+ * produce the same lift on every pass, and widening the map back out drops
46
+ * it to the floor again. Vertical positions are deliberately NOT consulted;
47
+ * they'd make the result depend on the previous lift.
48
+ */
49
+ export declare function bottomEndLiftPx(bottomStart: DOMRect | null, bottomEnd: DOMRect | null, floorPx: number, gapYPx: number): number;
32
50
  /** Shared drawer plumbing styles for the HTML and React front-ends. */
33
51
  export declare function foldDrawerStyle(side?: FoldSide): Record<string, string | number>;
34
52
  export declare function foldPanelStyle(side: FoldSide): Record<string, string | number>;
@@ -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-Dg-GSDP3.js";
2
- import { ap as gr } from "./index-COuIQk2Z.js";
1
+ import { A as ur, d as lr, R as zt, e as fr, m as dr, p as St, f as hr, j as pr, l as mr } from "./raster-pipeline-A-zvBnKU.js";
2
+ import { ap as gr } from "./index-CxOS31XG.js";
3
3
  import $t from "./index-CW1n5LdO.js";
4
4
  var Et;
5
5
  function h(e, t, n) {
@@ -5419,7 +5419,11 @@ const ku = async (e, t) => {
5419
5419
  width: o,
5420
5420
  height: s,
5421
5421
  byteLength: u.byteLength,
5422
- texture: l,
5422
+ device: n,
5423
+ // Zarr pre-uploads its single band and retains no decode (its `select`
5424
+ // slicing plays the role COG's `bands` plays; identify is COG-only in v2).
5425
+ array: null,
5426
+ bandTextures: /* @__PURE__ */ new Map([[0, l]]),
5423
5427
  colormapTexture: await mr(n),
5424
5428
  bandCount: 1,
5425
5429
  nodata: null
@@ -5495,6 +5499,9 @@ class Zu extends gr {
5495
5499
  rescaleMin: this.props.rescaleMin,
5496
5500
  rescaleMax: this.props.rescaleMax,
5497
5501
  colormap: this.props.colormap,
5502
+ reverse: this.props.reverse,
5503
+ stretch: this.props.stretch,
5504
+ gamma: this.props.gamma,
5498
5505
  nodataOverride: this.props.nodataOverride
5499
5506
  };
5500
5507
  return new zu(
@@ -0,0 +1,223 @@
1
+ # COGLayer v2 — design (issue #13, targeting 0.7.0)
2
+
3
+ _Status: IMPLEMENTED (2026-09-02, ships in 0.7.0) — all six phases landed as
4
+ designed; the one deviation is recorded inline (the auto-window overview
5
+ fallback samples the COARSEST overview, `overviews[overviews.length - 1]`,
6
+ because upstream's list is finest-first). Grounded against the 0.6.26 tree and
7
+ the actually-bundled
8
+ dep versions (`@developmentseed/deck.gl-geotiff` 0.7.0, `deck.gl-raster`
9
+ 0.7.0, `@developmentseed/geotiff`), not the ticket's 0.3.x-era assumptions.
10
+ Written 2026-09-02._
11
+
12
+ ## What the deep-read changed about the ticket
13
+
14
+ The ticket's implementation plan predates three upstream facts that make the
15
+ work smaller and the design better:
16
+
17
+ 1. **`fetchTile` already decodes EVERY band.** `RasterArray` carries all
18
+ samples (band-separate or interleaved); today we throw away everything but
19
+ band 0 / the first three. Band selection is therefore a re-slice of data
20
+ we already hold — not a refetch.
21
+ 2. **`CompositeBands` exists upstream** (deck.gl-raster 0.7.0): a GPU module
22
+ with four texture slots and an `ivec4 channelMap` uniform, plus
23
+ `buildCompositeBandsProps`. Band → channel routing is a *uniform*.
24
+ 3. **Paletted rasters already render** in the unstyled path:
25
+ `inferRenderPipeline` reads `colorMap`/`photometric` from `cachedTags` and
26
+ routes through `photometricInterpretationToRGB` (`parseColormap` in
27
+ `@developmentseed/geotiff`). Item 5 shrinks to "don't break it + legend".
28
+
29
+ Also confirmed: `geotiff.gdalMetadata` is parsed per band at open
30
+ (`STATISTICS_MINIMUM/MAXIMUM/MEAN/STDDEV/VALID_PERCENT` — gdal-metadata.js),
31
+ and `cachedTags` exposes `bitsPerSample`/`sampleFormat`/`samplesPerPixel` —
32
+ so `rescale="auto"` and the bit-depth auto-route are header reads, no pixel
33
+ pass. No stretch/gamma module exists upstream; we write one (the module
34
+ contract is ~40 lines — `LinearRescale` is the template: `fs` uniform block,
35
+ `inject fs:DECKGL_FILTER_COLOR`, `uniformTypes`, `getUniforms`).
36
+
37
+ ## The load-bearing design decision: retained decode + lazy band textures
38
+
39
+ One decision serves three ticket items at once (bands, identify, auto-route):
40
+ **`OmTileData` retains the decoded `RasterArray` CPU-side for the tile's
41
+ cache lifetime**, and band textures are created LAZILY from it.
42
+
43
+ ```
44
+ getTileData (module-level, stable identity — NEVER changes per ticket item 1)
45
+ └─ fetchTile → RasterArray (all bands, already decoded)
46
+ OmTileData {
47
+ array: RasterArray ← retained (identify + band switches)
48
+ bandTextures: Map<int, Texture> ← lazy, per selected band
49
+ width/height/byteLength/nodata/colormapTexture
50
+ }
51
+
52
+ renderTile(data, props) ← re-runs on every new layer instance (restretch,
53
+ └─ ensureBandTextures(data, bands) band switch, stretch/gamma edit)
54
+ └─ pipeline: CompositeBands(channelMap ← bands)
55
+ → FilterNoDataVal (raw values, unchanged ordering)
56
+ → LinearRescale
57
+ → StretchGamma (NEW module; identity when linear/γ=1)
58
+ → Colormap | (reversed) — single-band only
59
+ ```
60
+
61
+ Consequences, in order of importance:
62
+
63
+ - **A `bands` change never refetches or re-decodes.** The ticket's central
64
+ constraint ("bands must mint a distinct getTileData identity, deliberately
65
+ invalidating the tile cache") is dissolved, not implemented: the new
66
+ instance's `renderTile` uploads any missing band texture from the retained
67
+ array and updates `channelMap`. The tile cache, the HTTP cache, and the
68
+ decode work all survive. Acceptance criterion 1 gets *stronger*: nothing
69
+ short of a `src` change refetches.
70
+ - **Identify is a free rider.** The pick samples the retained array —
71
+ no second fetch, no GPU readback (open Q1 resolved below).
72
+ - **VRAM discipline:** textures for bands no longer referenced by the current
73
+ `bands` are destroyed at `ensureBandTextures` time (keep = selected set
74
+ only); `onTileUnload` destroys every entry plus drops the array reference.
75
+ Worst-case steady-state VRAM equals today's (≤4 r32float textures ≈ one
76
+ rgba32float); the new CPU cost is the retained arrays, bounded by deck's
77
+ existing tile cache and released on eviction exactly like textures today.
78
+
79
+ `extractSingleBand`/`packFirstThreeAsRgba` are subsumed by
80
+ `extractBand(arr, i)` (both layouts, Float32 widening preserved). The
81
+ single-band path uploads one `r32float` and routes `channelMap = [0,0,0,-1]`
82
+ (gray replicate) into Colormap — the current visual result, one pipeline
83
+ instead of two texture formats.
84
+
85
+ ## Attribute grammar (per the ticket, with resolved semantics)
86
+
87
+ | Attribute | Values | Semantics |
88
+ |---|---|---|
89
+ | `bands` | `"4"` or `"[4,3,2]"` | 1-based (GDAL convention). Single = colormap-eligible; triple = RGB composite. Absent = today's defaults (band 1 / first three). |
90
+ | `rescale` | `"auto"` | Window(s) from per-band GDAL stats tags — a composite gets TRUE per-band windows (decision above). Tags absent → sample the coarsest overview (one small read), dev-notice that the window is data-derived. Explicit `min`/`max` always win. |
91
+ | `min` / `max` | number or triple | A number broadcasts to every selected band; a triple (`min="[0,0,0]"`) gives per-band windows, positionally matching the `bands` triple. Validation: triple lengths must match a `bands` triple. |
92
+ | `stretch` | `linear` (default) \| `log` \| `sqrt` | StretchGamma module, applied to the rescaled [0,1] value. `log` uses `log(1+9x)/log(10)` (bounded, monotone, no −∞ at 0). |
93
+ | `gamma` | number > 0 | `pow(x, 1/gamma)` in the same module. Validation error at ≤ 0. |
94
+ | `reverse` | bare | Colormap uv-flip prop (the sprite has no reversed rows — flip `u` in the Colormap lookup; small fork of the module if upstream's lacks the prop). |
95
+ | `identify` | `"off"` | Opt out of CPU array retention (memory-tight embeds). Default on. |
96
+
97
+ Bit-depth auto-route (the "my COG is blank" fix): at class init read
98
+ `bitsPerSample`/`sampleFormat`; if not uint8 and NO styling attrs are
99
+ authored, enter the styled pipeline anyway with `rescale="auto"` semantics
100
+ and emit the dev-panel notice verbatim from the ticket. uint8 keeps the
101
+ untouched unstyled fast path — including paletted sources
102
+ (`colorMap` present → never auto-route; upstream renders the palette).
103
+
104
+ ## Identify (ticket item 4)
105
+
106
+ `OmCOGLayer.identify(lngLat)` walks the currently-cached tiles for the
107
+ best-zoom tile containing the coordinate (tile bounds are known to the
108
+ TileLayer), converts to fractional uv, samples ALL bands from the retained
109
+ array, and returns `{ values: number[], bands: number[], nodata: boolean }`.
110
+ Runtime wiring follows the established schema-flag pattern
111
+ (`carriesTileset` precedent): a `carriesRasterIdentify` flag makes
112
+ runtime-core enrich the pick path — when a click/hover resolves over a
113
+ COG/Zarr layer with no vector object, `selection.raster` carries the
114
+ identify result and `{{raster.value}}` / `{{raster.values}}` interpolate in
115
+ overlays/tooltips. Float64 sources: values surface as the Float32 the
116
+ pipeline holds; the readout notes original dtype (ticket's documented cost).
117
+
118
+ ## Structured errors (ticket item 6)
119
+
120
+ The #39 work built the exact channel this needs: parse-time entries via
121
+ `ParseManifestOptions.report` → `reportRuntimeErrorInternal` →
122
+ `om-validation-error`/validate panel. Raster failures are RUNTIME (fetch/
123
+ header time, inside the lazy chunk), so raster.ts gets an error classifier
124
+ around GeoTIFF open + tile fetch, reported through a runtime-core-injected
125
+ callback (the `onTilesetLoad` compose-not-clobber pattern):
126
+
127
+ - CORS: `TypeError` from fetch with zero response → the ticket's CORS
128
+ message verbatim (fix string included).
129
+ - Non-COG: header lacks tile structure/overviews → warning + `rio cogeo`
130
+ fix string. Layer keeps rendering (slow full reads) — warning, not error.
131
+ - Band out of range: `bands` vs `samplesPerPixel` at init → error naming the
132
+ source's band count; layer falls back to defaults rather than blanking.
133
+
134
+ Static validation (validation.ts): `bands` shape, `colormap`+triple warning,
135
+ `min ≥ max`, `gamma ≤ 0` — messages verbatim from the ticket.
136
+
137
+ ## Decisions (maintainer-settled 2026-09-02)
138
+
139
+ - **Auto-route: APPROVED.** Behavior change accepted — confined to unstyled
140
+ non-8-bit sources that render broken today. Known trade acknowledged: the
141
+ stale-stats-tags case moves from obviously-broken to subtly-wrong-with-a-
142
+ notice; the dev-panel notice names the derived window so it stays
143
+ inspectable, and authored `min`/`max` is the standing remedy.
144
+ - **Entitlements: EXEMPT.** COG Range traffic joins every other tile stream
145
+ outside `maxFetchBytes`; the gate's rule ("row-data loads, not tile
146
+ streams") gets written into the architecture doc's license section.
147
+ - **Identify: retain by default, `identify="off"` opt-out — SAME DEFAULT ON
148
+ MOBILE.** No native-side default flip (web/native parity is the product
149
+ stance); instead the onlymap-native docs/llms guidance RECOMMENDS
150
+ `identify="off"` on raster layers for memory-tight WebViews.
151
+ - **`reverse`: fork the Colormap module** (tiny, pinned dep, no upstream
152
+ contact) rather than the sprite-doubling alternative.
153
+ - **deck/luma pin: DOES NOT MOVE in 0.7.0.** Decided consciously at the one
154
+ version boundary where it could.
155
+ - **Pipeline: UNIFY.** Single-band sources route through CompositeBands too
156
+ (slot-0 gray replicate) — one pipeline, every feature written once. The
157
+ parachute is pre-authorized: phase 1 acceptance pixel-compares the
158
+ existing single-band fixtures against 0.6.26 on Metal-ANGLE, and a
159
+ failure there retreats to the dedicated single-band path without redesign.
160
+ - **Per-band rescale windows: PINNED, BUT SHIP IN 0.7.0.** Not a follow-up
161
+ ticket — the same release. `min`/`max` accept a number (broadcast to all
162
+ bands) OR a triple (`min="[0,0,0]" max="[3000,8000,3000]"`, positionally
163
+ matching a `bands` triple); `rescale="auto"` on a composite resolves TRUE
164
+ per-band windows from per-band stats (the min-of-mins merge hack is
165
+ DELETED from the design, not deferred). Mechanism: a PerBandRescale
166
+ module variant (vec4 min/max uniforms) replacing LinearRescale when bands
167
+ is a triple — scalar sources keep the upstream module.
168
+
169
+ ## Open questions — recommendations
170
+
171
+ 1. **Identify cost:** retain by default. The array is already allocated by
172
+ decode; retention converts a GC into a cache-lifetime hold, bounded by the
173
+ existing tile cache and freed on the existing eviction hook. Re-fetch-per-
174
+ pick buys idle memory back at the cost of latency + a second Range-request
175
+ path to maintain. `identify="off"` is the escape hatch (one boolean, drops
176
+ the reference after texture upload).
177
+ 2. **Entitlements:** exempt, explicitly. Tile streams are self-limiting and
178
+ already exempt for TileLayer/MVTLayer/Tile3DLayer — the fetch gate's rule
179
+ becomes "row-data loads, not tile streams", stated in the license section
180
+ of the architecture doc. Counting COG bytes would gate panning, which no
181
+ tier intends. (Telemetry MAY count bytes later for observability; not
182
+ entitlements.)
183
+ 3. **Index mode:** out of scope, as the ticket leans — but note the retained-
184
+ decode design lands ALL its plumbing (multi-band textures + CompositeBands
185
+ are exactly what `index="ndvi"` needs; the follow-up becomes one shader
186
+ module + a preset table).
187
+
188
+ ## Phases (each independently green; ~1–1.5 weeks total)
189
+
190
+ 1. **Retained decode + lazy band textures + `bands`.** Rework OmTileData,
191
+ `extractBand`, CompositeBands pipeline, VRAM lifecycle; schema row;
192
+ ir-diff needs NO band special-case (props flow through updateTriggers as
193
+ uniforms). Unit: extraction across both layouts + dtype matrix
194
+ (uint8/uint16/int16/float32 fixtures); harness: snapshotIR mapping.
195
+ 2. **StretchGamma + PerBandRescale modules + `reverse` + validation
196
+ rules.** Two small forked/authored GPU modules; min/max triple grammar;
197
+ pipeline ordering tests (nodata → per-band rescale → stretch → colormap).
198
+ 3. **`rescale="auto"` + bit-depth auto-route + dev notice.** Header reads,
199
+ legend feed (`deriveRasterLegendSpec` gets the resolved window),
200
+ coarsest-overview fallback sampler.
201
+ 4. **Identify + pick wiring.** Layer method, `carriesRasterIdentify`
202
+ enrichment, `selection.raster`, tooltip interpolation, `identify="off"`.
203
+ 5. **Palette legend + guard.** Paletted sources never auto-route; legend
204
+ class rows when used-entry count ≤ 12 (matches classify legend cap), else
205
+ single swatch.
206
+ 6. **Errors + doc-sync + e2e.** Classifier + report channel; README, skill
207
+ syntax.md, llms.txt, html-data + attributes.json regen, arch doc,
208
+ `dev/examples` raster page extension + build-public allowlist,
209
+ CHANGELOG at the 0.7.0 bump. e2e: band composite non-blank & differs
210
+ from default; CORS URL → structured error with live canvas; identify
211
+ click returns a plausible DEM value.
212
+
213
+ Zarr note: phases 1–3 land in `raster-pipeline.ts`/shared shapes where
214
+ possible; ZarrLayer inherits stretch/gamma/reverse for free (same
215
+ RasterStyle), while `bands`/identify stay COG-only in v2 (Zarr's variable
216
+ model differs — its `select` already plays the role).
217
+
218
+ ## Non-goals (unchanged from the ticket)
219
+
220
+ Server-side tiling, band math/spectral indices (follow-up), NetCDF/HDF5,
221
+ raster analysis. No deck.gl/luma bump — 0.7.0 is the version where the pin
222
+ COULD move per the deps contract, but nothing here needs it; don't spend the
223
+ coordination budget.
package/llms.txt CHANGED
@@ -34,7 +34,7 @@ Programmatic/native bridge rule: `MapController.setLayers()` accepts normal func
34
34
  ## Element vocabulary
35
35
 
36
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), `om-tiles-warmed` (`detail = {tilesets, viewports, ms, timedOut}` — a `warm-tiles` pass finished; see the story bullet), `om-route-resolved` (`detail = {layerId, route}` — a Route layer resolved, direct geometry or provider round-trip; `route` carries the normalized geometry/distanceMeters/durationSec/legs/bounds — how a page reads a provider-computed route without re-fetching, re-fired on every re-resolve). `MapController` mirrors these as `onViewChange`/`onMapPoint`/`onTilesetLoad`/`onRouteResolved` 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` (39 layer types total: 32 bundled deck.gl core/geo/aggregation/mesh layers, plus the native `COGLayer`/`ZarrLayer` raster types, `ImageOverlay` for georeferenced drone JPEGs, `BIMLayer` for BIM source files, and `Route`/`Tracking` for routing/live-tracking — see the dedicated bullet below), 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).
37
+ - `<om-layer id="..." type="ScatterplotLayer" data="./points.json">` — any deck.gl layer class by `type` (39 layer types total: 32 bundled deck.gl core/geo/aggregation/mesh layers, plus the native `COGLayer`/`ZarrLayer` raster types, `ImageOverlay` for georeferenced drone JPEGs, `BIMLayer` for BIM source files, and `Route`/`Tracking` for routing/live-tracking — see the dedicated bullet below), 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). COG v2 surface (0.7.0): `bands` selects 1-based bands — one (`bands="4"`, colormap-eligible) or an `[r,g,b]` triple (`bands="[8,4,3]"`, false-color composite); band switches NEVER refetch (the decode is retained; textures re-upload from memory). `min`/`max` set the rescale window — a number broadcasts, a triple pairs per-band with a `bands` triple; `rescale="auto"` reads the window from GDAL statistics tags (coarsest-overview sample fallback, with a warning that the window is data-derived); non-8-bit sources with NO styling attrs auto-stretch automatically now (the "my COG is blank" fix — a dev notice reports the derived window; author min/max for stable styling). `colormap` picks a bundled ramp for single-band sources (gray, viridis, plasma, inferno, magma, cividis, rdylgn, rdbu, spectral, terrain, jet, turbo, ylorrd), `reverse` flips it, `stretch="log|sqrt"` and `gamma="1.8"` shape the display curve, `nodata` overrides the source sentinel (renders transparent). Plain 8-bit RGB COGs need no styling attributes; paletted GeoTIFFs render via their embedded color table and emit a classes legend when few palette entries are used. Every styling change is a GPU uniform (no refetch); the legend derives automatically (authored or auto window). PIXEL IDENTIFY: clicks/hovers over a COGLayer with no vector feature carry the pixel's per-band values through the ordinary selection — overlays/tooltips interpolate `{{value}}`, `{{values}}`, `{{band_1}}`…; `identify="off"` opts out (drops the CPU-retained decode — recommended for memory-tight WebViews). Source failures (CORS-refused Range requests, non-COG plain GeoTIFFs, out-of-range bands) surface as structured validation errors with fix strings instead of a silent blank layer. Sources should be Cloud-Optimized (`gdal_translate -of COG`; plain GeoTIFFs render with a warning, re-reading the full image per view).
38
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.
39
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`).
40
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.
@@ -179,14 +179,20 @@
179
179
  "wrap-longitude"
180
180
  ],
181
181
  "COGLayer": [
182
+ "bands",
182
183
  "clip",
183
184
  "colormap",
185
+ "gamma",
186
+ "identify",
184
187
  "max",
185
188
  "min",
186
189
  "nodata",
187
190
  "opacity",
191
+ "rescale",
192
+ "reverse",
188
193
  "snap",
189
194
  "src",
195
+ "stretch",
190
196
  "visible"
191
197
  ],
192
198
  "ColumnLayer": [
@@ -1567,14 +1573,17 @@
1567
1573
  "clip",
1568
1574
  "colormap",
1569
1575
  "crs",
1576
+ "gamma",
1570
1577
  "max",
1571
1578
  "min",
1572
1579
  "nodata",
1573
1580
  "opacity",
1581
+ "reverse",
1574
1582
  "select",
1575
1583
  "snap",
1576
1584
  "spatial-dims",
1577
1585
  "src",
1586
+ "stretch",
1578
1587
  "variable",
1579
1588
  "visible"
1580
1589
  ]
@@ -1202,6 +1202,18 @@
1202
1202
  "name": "texture-parameters",
1203
1203
  "description": "deck.gl textureParameters. Valid on: BitmapLayer, IconLayer, ImageOverlay, SimpleMeshLayer."
1204
1204
  },
1205
+ {
1206
+ "name": "bands",
1207
+ "description": "deck.gl bands. Valid on: COGLayer."
1208
+ },
1209
+ {
1210
+ "name": "rescale",
1211
+ "description": "deck.gl rescale. Valid on: COGLayer."
1212
+ },
1213
+ {
1214
+ "name": "identify",
1215
+ "description": "deck.gl identify. Valid on: COGLayer."
1216
+ },
1205
1217
  {
1206
1218
  "name": "min",
1207
1219
  "description": "deck.gl rescaleMin. Valid on: COGLayer, ZarrLayer."
@@ -1214,6 +1226,18 @@
1214
1226
  "name": "colormap",
1215
1227
  "description": "deck.gl colormap. Valid on: COGLayer, ZarrLayer."
1216
1228
  },
1229
+ {
1230
+ "name": "reverse",
1231
+ "description": "deck.gl reverse. Valid on: COGLayer, ZarrLayer."
1232
+ },
1233
+ {
1234
+ "name": "stretch",
1235
+ "description": "deck.gl stretch. Valid on: COGLayer, ZarrLayer."
1236
+ },
1237
+ {
1238
+ "name": "gamma",
1239
+ "description": "deck.gl gamma. Valid on: COGLayer, ZarrLayer."
1240
+ },
1217
1241
  {
1218
1242
  "name": "nodata",
1219
1243
  "description": "deck.gl nodataOverride. Valid on: COGLayer, ZarrLayer."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nika-js/onlymap",
3
- "version": "0.6.26",
3
+ "version": "0.7.0",
4
4
  "description": "Declarative deck.gl maps for HTML and React — interactive WebGL mapping with GeoJSON/CSV/Arrow data, MapLibre basemaps, widgets, popups, and live streams from a custom-element manifest or typed React components. TypeScript, no build step.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "publishConfig": {
@@ -23,7 +23,7 @@ Use OnlyMapJS as a declarative HTML map library. Write custom elements such as `
23
23
  </script>
24
24
  ```
25
25
 
26
- For no-build CDN pages, use the single-file standalone bundle from a raw-file CDN — `https://unpkg.com/@nika-js/onlymap@0.6.26` (the bare package URL serves `dist/onlymap.standalone.js`) — plus `<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.6.26/dist/onlymapjs.css">`. Never a rebundling CDN (esm.sh, skypack): re-bundling duplicates the deck.gl/luma.gl runtime and every layer fails shader compilation.
26
+ For no-build CDN pages, use the single-file standalone bundle from a raw-file CDN — `https://unpkg.com/@nika-js/onlymap@0.7.0` (the bare package URL serves `dist/onlymap.standalone.js`) — plus `<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.7.0/dist/onlymapjs.css">`. Never a rebundling CDN (esm.sh, skypack): re-bundling duplicates the deck.gl/luma.gl runtime and every layer fails shader compilation.
27
27
 
28
28
  ## React Projects
29
29
 
@@ -16,8 +16,8 @@ Vite/npm project:
16
16
  Static CDN page (raw-file CDNs only — unpkg/jsDelivr; never esm.sh or another rebundling CDN, which duplicates the WebGL runtime and breaks layer shaders):
17
17
 
18
18
  ```html
19
- <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.6.26/dist/onlymapjs.css">
20
- <script type="module" src="https://unpkg.com/@nika-js/onlymap@0.6.26"></script>
19
+ <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.7.0/dist/onlymapjs.css">
20
+ <script type="module" src="https://unpkg.com/@nika-js/onlymap@0.7.0"></script>
21
21
  ```
22
22
 
23
23
  Always include `onlymapjs.css` — it carries the MapLibre basemap styles and the no-JS fallback rules (`<om-fallback>` / default banner). For the fallback to work in script-disabled previews it must load without JavaScript: a real `<link rel="stylesheet">` or inlined `<style>` on no-build pages (a bundler-emitted stylesheet is fine in npm projects).
@@ -193,12 +193,17 @@ Common choices:
193
193
  ```
194
194
 
195
195
  - `src` (required) — the GeoTIFF URL. NOT `data`: rasters stream tiles by HTTP Range request through the layer's own reader; they are never parsed rows (`$field`, `ctx.data()`, `ctx.stats()`, filters do not apply).
196
- - Sources must be Cloud-Optimized GeoTIFFs (`gdal_translate -of COG` otherwise).
197
- - `min`/`max` — the rescale window mapped onto the colormap. Defaults to 0–255, so ALWAYS set them for float or 16-bit data (DEMs, NDVI, temperature).
198
- - `colormap` — single-band ramps from the bundled sprite: `gray` (default), `viridis`, `plasma`, `inferno`, `magma`, `cividis`, `rdylgn`, `rdbu`, `spectral`, `terrain`, `jet`, `turbo`, `ylorrd` (ColorBrewer yellow→orange→red a gentler sequential ramp than turbo/jet). Sources with 3+ bands composite as RGB and ignore it.
196
+ - Sources should be Cloud-Optimized GeoTIFFs (`gdal_translate -of COG`); a plain GeoTIFF renders with a structured warning (every view re-reads the full image).
197
+ - `bands` — 1-based band selection (GDAL convention): one band (`bands="4"`, colormap-eligible) or an `[r,g,b]` triple (`bands="[8,4,3]"`, false-color composite). Band switches NEVER refetch tiles every band is decoded once and retained; textures re-upload from memory.
198
+ - `min`/`max` — the rescale window. A number broadcasts to every selected band; with a `bands` triple, each may be an `[r,g,b]` triple for per-band windows (`min="[0,0,0]" max="[3000,8000,3000]"`). Defaults to 0–255 set them for float/16-bit data, or use `rescale="auto"`.
199
+ - `rescale="auto"` — window(s) from the file's GDAL statistics tags (deterministic per file; per-band for composites); falls back to sampling the coarsest overview with a warning that the window is data-derived. Explicit `min`/`max` always win. Non-8-bit sources with NO styling attributes auto-stretch automatically (a dev notice reports the derived window).
200
+ - `colormap` — single-band ramps from the bundled sprite: `gray` (default), `viridis`, `plasma`, `inferno`, `magma`, `cividis`, `rdylgn`, `rdbu`, `spectral`, `terrain`, `jet`, `turbo`, `ylorrd`. Composites ignore it (validation warns). `reverse` flips the ramp.
201
+ - `stretch="linear|log|sqrt"` + `gamma` (> 0) — display-curve shaping between rescale and colormap; both are uniforms.
199
202
  - `nodata` — overrides the source's nodata sentinel; nodata pixels render transparent.
200
- - Plain 8-bit RGB COGs (satellite truecolor) need no styling attributes at all.
201
- - Restretch/recolor (min/max/colormap edits) are GPU uniform updates tiles are not refetched. The legend widget renders the colormap ramp automatically when `colormap` + `min`/`max` are authored.
203
+ - Plain 8-bit RGB COGs (satellite truecolor) need no styling attributes at all; paletted GeoTIFFs render via their embedded color table and emit a classes legend when few entries are used.
204
+ - PIXEL IDENTIFY: a click/hover over the raster (no vector feature hit) produces a selection whose object carries `{ value, values, bands, nodata, band_1..n }` overlays/tooltips interpolate `{{value}}` etc. with zero extra wiring. `identify="off"` disables it and releases the CPU-retained decode (recommended on memory-tight WebViews).
205
+ - Every styling change (bands/min/max/colormap/reverse/stretch/gamma/nodata) is a GPU-side update — tiles are never refetched. The legend renders automatically from the authored OR auto-resolved window.
206
+ - Failure modes are structured (`validate` panel / `om-validation-error`): CORS-refused Range requests, non-COG sources, and out-of-range `bands` each carry an actionable fix string; the map stays alive.
202
207
 
203
208
  ### ZarrLayer (Zarr / GeoZarr rasters)
204
209