@nika-js/onlymap 0.5.3 → 0.5.7

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 (38) hide show
  1. package/README.md +20 -7
  2. package/THIRD-PARTY-LICENSES.md +120 -0
  3. package/dist/{Arrow.dom-BNjbQ9jb.js → Arrow.dom-BFGShwSA.js} +854 -1427
  4. package/dist/{LercDecode.es-S6KKhkuA.js → LercDecode.es-Bp2rG98n.js} +1 -1
  5. package/dist/{basemap-BSCq9hHZ.js → basemap-CfOA0HWY.js} +1 -1
  6. package/dist/builder-CqJZRFaq.js +580 -0
  7. package/dist/data-layer.d.ts +4 -0
  8. package/dist/date-format.d.ts +18 -0
  9. package/dist/flatgeobuf-Cx0IwxhV.js +6332 -0
  10. package/dist/full.esm-Cl_Dig1y.js +1467 -0
  11. package/dist/geoparquet-kW7KUrwB.js +35 -0
  12. package/dist/geoparquet.d.ts +33 -0
  13. package/dist/image-overlay.d.ts +83 -0
  14. package/dist/{index-BCgjJcZn.js → index-BLy1eTWe.js} +1 -1
  15. package/dist/index-BYbMtNVH.js +2301 -0
  16. package/dist/{index-BoAq5XsU.js → index-CUKgoX_E.js} +13291 -12728
  17. package/dist/index-CgPV7QlM.js +4019 -0
  18. package/dist/{index-D1swbykc.js → index-CnY_rdB8.js} +1 -1
  19. package/dist/{index-DfNGlG_k.js → index-MRS7UAHr.js} +1 -1
  20. package/dist/{index-BTcRCrfX.js → index-wZ6n6ggy.js} +2 -2
  21. package/dist/index.d.ts +5 -1
  22. package/dist/ir-snapshot.d.ts +3 -1
  23. package/dist/ir.d.ts +18 -0
  24. package/dist/layer-registry.d.ts +2 -0
  25. package/dist/{lerc-n0GaWEmL.js → lerc-DWbkmLYR.js} +2 -2
  26. package/dist/onlymap.standalone.js +59664 -44945
  27. package/dist/onlymapjs.js +76 -74
  28. package/dist/{raster-DO-Ex2Lb.js → raster-5jEwGISN.js} +2 -2
  29. package/dist/validation.d.ts +1 -1
  30. package/dist/version.d.ts +1 -1
  31. package/docs/3d-assets.md +1 -1
  32. package/docs/image-overlays.md +75 -0
  33. package/llms.txt +7 -4
  34. package/onlymapjs.html-data.json +147 -86
  35. package/package.json +5 -1
  36. package/skills/onlymapjs/SKILL.md +4 -1
  37. package/skills/onlymapjs/references/syntax.md +120 -19
  38. package/skills/onlymapjs/references/testing.md +11 -2
package/dist/onlymapjs.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ay as e, az as r, aA as t, aB as o, aC as b, aD as i, an as l, aE as n, aF as S, aG as L, aH as T, aI as E, aJ as g, aK as m, aL as A, aM as d, aN as p, aO as c, aP as _, aQ as y, aR as M, aS as u, aT as I, am as D, aU as O, au as f, aV as N, aW as P, aX as R, W as F, aY as h, aZ as C, a_ as B, a$ as G, b0 as U, b1 as W, b2 as x, b3 as v, b4 as w, b5 as Y, b6 as H, b7 as X, b8 as k, b9 as z, ba as J, bb as K, bc as V, bd as Q, be as j, bf as q, bg as Z, bh as $, bi as aa, bj as sa, bk as ea, bl as ra, bm as ta, bn as oa, bo as ba, bp as ia, bq as la, br as na, bs as Sa, bt as La, bu as Ta, bv as Ea, bw as ga, bx as ma, by as Aa, bz as da, bA as pa, bB as ca, bC as _a, bD as ya, bE as Ma, bF as ua, bG as Ia, bH as Da, bI as Oa, bJ as fa, bK as Na, bL as Pa, bM as Ra, bN as Fa, bO as ha, bP as Ca, bQ as Ba, bR as Ga, bS as Ua, bT as Wa, bU as xa, bV as va, bW as wa, bX as Ya } from "./index-BoAq5XsU.js";
1
+ import { aA as e, aB as r, aC as t, aD as o, aE as b, aF as i, ap as l, aG as n, aH as S, aI as E, aJ as T, aK as L, aL as g, aM as A, aN as m, aO as _, aP as p, aQ as d, aR as c, aS as y, aT as I, aU as M, aV as u, aW as D, ao as O, aX as N, aw as R, aY as f, aZ as P, a_ as F, W as h, a$ as B, b0 as C, b1 as G, b2 as U, b3 as W, b4 as v, b5 as x, b6 as w, b7 as Y, b8 as H, b9 as X, ba as k, bb as J, bc as K, bd as V, be as z, bf as Q, bg as Z, bh as $, bi as j, bj as q, bk as aa, bl as sa, bm as ea, bn as ra, bo as ta, bp as oa, bq as ba, br as ia, bs as la, bt as na, bu as Sa, bv as Ea, bw as Ta, bx as La, by as ga, bz as Aa, bA as ma, bB as _a, bC as pa, bD as da, bE as ca, bF as ya, bG as Ia, bH as Ma, bI as ua, bJ as Da, bK as Oa, bL as Na, bM as Ra, bN as fa, bO as Pa, bP as Fa, bQ as ha, bR as Ba, bS as Ca, bT as Ga, bU as Ua, bV as Wa, bW as va, bX as xa, bY as wa, bZ as Ya, b_ as Ha, b$ as Xa } from "./index-CUKgoX_E.js";
2
2
  export {
3
3
  e as ALL_POSITION_VALUES,
4
4
  r as AUDIT_EXEMPTIONS,
@@ -9,89 +9,91 @@ export {
9
9
  l as CompositeLayer,
10
10
  n as DEFAULT_AUDIT_WIDTHS,
11
11
  S as DEFAULT_FOLD_BREAKPOINT_PX,
12
- L as EDGE_TOLERANCE_PX,
13
- T as FOLD_HYSTERESIS_PX,
14
- E as FOLD_SIDES,
15
- g as GeoJsonLayer,
12
+ E as DRONE_SENSOR_DATABASE,
13
+ T as EDGE_TOLERANCE_PX,
14
+ L as FOLD_HYSTERESIS_PX,
15
+ g as FOLD_SIDES,
16
+ A as GeoJsonLayer,
16
17
  m as IconLayer,
17
- A as LEGACY_POSITION_ALIASES,
18
- d as LIGHTING_PRESET_NAMES,
19
- p as Layer,
18
+ _ as LEGACY_POSITION_ALIASES,
19
+ p as LIGHTING_PRESET_NAMES,
20
+ d as Layer,
20
21
  c as LayerExtension,
21
- _ as MANAGED_SLOTS,
22
- y as MapController,
22
+ y as MANAGED_SLOTS,
23
+ I as MapController,
23
24
  M as OmMap,
24
25
  u as ScatterplotLayer,
25
- I as ScenegraphLayer,
26
- D as SimpleMeshLayer,
27
- O as Tile3DLayer,
28
- f as TileLayer,
29
- N as UNIT_SYSTEMS,
26
+ D as ScenegraphLayer,
27
+ O as SimpleMeshLayer,
28
+ N as Tile3DLayer,
29
+ R as TileLayer,
30
+ f as UNIT_SYSTEMS,
30
31
  P as WGS84_MEAN_RADIUS_M,
31
- R as WIDGET_STYLE_KEY_NAMES,
32
- F as WebMercatorViewport,
33
- h as arrowTableToColumnar,
32
+ F as WIDGET_STYLE_KEY_NAMES,
33
+ h as WebMercatorViewport,
34
+ B as arrowTableToColumnar,
34
35
  C as asRows,
35
- B as auditLayout,
36
- G as compileExpression,
37
- U as compileFullJsAccessorBlockInSandbox,
38
- W as computeTimeline,
36
+ G as auditLayout,
37
+ U as compileExpression,
38
+ W as compileFullJsAccessorBlockInSandbox,
39
+ v as computeTimeline,
39
40
  x as configureBasemap,
40
- v as configureData,
41
- w as configureLicense,
42
- Y as configureTelemetry,
43
- H as descriptorToIR,
44
- X as destroySandbox,
45
- k as distanceMeters,
46
- z as evaluateInSandbox,
47
- J as foldDrawerStyle,
48
- K as foldPanelStyle,
49
- V as foldSideForSlot,
50
- Q as foldToggleSlot,
51
- j as foldToggleStyle,
52
- q as formatArea,
53
- Z as formatLength,
54
- $ as getBasemap,
55
- aa as getEntitlements,
56
- sa as getLayerSchema,
57
- ea as getMeasureRadiusMeters,
58
- ra as getTerrain,
59
- ta as isBrowser,
60
- oa as isColumnar,
61
- ba as isStoreToken,
62
- ia as loadRasterModule,
63
- la as midpoint,
64
- na as mountForTest,
65
- Sa as niceNumber,
66
- La as normalizeData,
67
- Ta as parseDurationMs,
68
- Ea as parseTerrainAttrs,
69
- ga as parseUnitSystem,
41
+ w as configureData,
42
+ Y as configureLicense,
43
+ H as configureTelemetry,
44
+ X as descriptorToIR,
45
+ k as destroySandbox,
46
+ J as distanceMeters,
47
+ K as evaluateInSandbox,
48
+ V as foldDrawerStyle,
49
+ z as foldPanelStyle,
50
+ Q as foldSideForSlot,
51
+ Z as foldToggleSlot,
52
+ $ as foldToggleStyle,
53
+ j as formatArea,
54
+ q as formatLength,
55
+ aa as getBasemap,
56
+ sa as getEntitlements,
57
+ ea as getLayerSchema,
58
+ ra as getMeasureRadiusMeters,
59
+ ta as getTerrain,
60
+ oa as isBrowser,
61
+ ba as isColumnar,
62
+ ia as isStoreToken,
63
+ la as loadRasterModule,
64
+ na as midpoint,
65
+ Sa as mountForTest,
66
+ Ea as niceNumber,
67
+ Ta as normalizeData,
68
+ La as parseDurationMs,
69
+ ga as parseTerrainAttrs,
70
+ Aa as parseUnitSystem,
70
71
  ma as parseWidgetStyle,
71
- Aa as pathLengthMeters,
72
- da as registerAction,
73
- pa as registerBasemap,
72
+ _a as pathLengthMeters,
73
+ pa as registerAction,
74
+ da as registerBasemap,
74
75
  ca as registerFormat,
75
- _a as registerLayer,
76
- ya as registerSource,
76
+ ya as registerLayer,
77
+ Ia as registerSource,
77
78
  Ma as registerTerrain,
78
79
  ua as registerWidget,
79
- Ia as resolveFoldBreakpointPx,
80
- Da as resolveLighting,
81
- Oa as resolveSlot,
80
+ Da as resolveFoldBreakpointPx,
81
+ Oa as resolveImageOverlay,
82
+ Na as resolveLighting,
83
+ Ra as resolveSlot,
82
84
  fa as ringAreaMeters2,
83
- Na as ringEnclosesPole,
84
- Pa as ringPerimeterMeters,
85
- Ra as rowAt,
86
- Fa as sandboxHtml,
87
- ha as scaleBarStep,
88
- Ca as setMeasureRadiusMeters,
89
- Ba as settleLayout,
90
- Ga as shouldFoldAtWidth,
91
- Ua as slotContainerStyle,
92
- Wa as slotLayout,
93
- xa as snapshotIR,
94
- va as solarAzElDegrees,
95
- wa as validateManifest,
96
- Ya as validateManifestString
85
+ Pa as ringEnclosesPole,
86
+ Fa as ringPerimeterMeters,
87
+ ha as rowAt,
88
+ Ba as sandboxHtml,
89
+ Ca as scaleBarStep,
90
+ Ga as setMeasureRadiusMeters,
91
+ Ua as settleLayout,
92
+ Wa as shouldFoldAtWidth,
93
+ va as slotContainerStyle,
94
+ xa as slotLayout,
95
+ wa as snapshotIR,
96
+ Ya as solarAzElDegrees,
97
+ Ha as validateManifest,
98
+ Xa as validateManifestString
97
99
  };
@@ -1,5 +1,5 @@
1
1
  import Ae from "./index-CW1n5LdO.js";
2
- import { ak as Mt, al as Rt, am as tt, an as nt, ao as zt, b as Ft, ap as Dt, l as Ee, aq as Nt, d as Ut, ar as Bt, as as Gt, at as Kt, au as rt } from "./index-BoAq5XsU.js";
2
+ import { am as Mt, an as Rt, ao as tt, ap as nt, aq as zt, b as Ft, ar as Dt, l as Ee, as as Nt, d as Ut, at as Bt, au as Gt, av as Kt, aw as rt } from "./index-CUKgoX_E.js";
3
3
  import { w as _t } from "./mgrs-BY9bIvp4.js";
4
4
  function Xt(e, t, n) {
5
5
  const { projectedCorners: r } = t, { topLeft: o, topRight: i, bottomRight: s, bottomLeft: a } = r, l = n(o[0], o[1]), c = n(i[0], i[1]), u = n(s[0], s[1]), h = n(a[0], a[1]), p = [
@@ -2378,7 +2378,7 @@ D.set(P.Zstd, () => import("./zstd-jXobGRcq.js").then((e) => e.decode));
2378
2378
  D.set(P.Jpeg, () => Promise.resolve(Ce));
2379
2379
  D.set(P.Jpeg6, () => Promise.resolve(Ce));
2380
2380
  D.set(P.Webp, () => Promise.resolve(Ce));
2381
- D.set(P.Lerc, () => import("./lerc-n0GaWEmL.js").then((e) => e.l).then((e) => e.decode));
2381
+ D.set(P.Lerc, () => import("./lerc-DWbkmLYR.js").then((e) => e.l).then((e) => e.decode));
2382
2382
  async function je(e, t, n) {
2383
2383
  const r = D.get(t);
2384
2384
  if (!r)
@@ -15,7 +15,7 @@ export interface ValidationResult {
15
15
  /**
16
16
  * A scalar/shorthand attribute value that is actually a data-driven accessor
17
17
  * expression — a `$field` reference or a `scale()`/`clamp()`/`lerp()`/
18
- * `colorRamp()` call. Scalar props take a CONSTANT; an expression here doesn't
18
+ * `colorRamp()`/`formatDate()` call. Scalar props take a CONSTANT; an expression here doesn't
19
19
  * coerce (deck.gl gets a string/NaN and silently falls back to defaults — the
20
20
  * "black squares" / invisible-layer class of bug), and the value belongs in a
21
21
  * `get-*` accessor instead. Word-boundary on the function names so a literal
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.5.3";
8
+ export declare const LIBRARY_VERSION = "0.5.7";
package/docs/3d-assets.md CHANGED
@@ -127,7 +127,7 @@ This convention (a `defaultExpr` on the `get-fill-color` `PropDescriptor`, resol
127
127
 
128
128
  ### Row budget (surfaces mode)
129
129
 
130
- Surfaces mode multiplies the row count by however many faces a building has: the 3DBAG tile in `dev/examples/cityjson.html` decodes to 120 rows as footprints and **3,940 rows** as surfaces — roughly 33×. That lands against the free tier's 25,000-row cap (see [Monetization Gates](../agent-map-library-architecture.md)) at around 750 buildings, where the footprint mode would still be nowhere near it. Past the cap the layer renders its first 25,000 rows — an arbitrary subset in source order, with a dismissible on-map notice — rather than going blank, so a slightly-too-big scene still draws. Because faces are emitted per building, the cut lands mid-building. To show a whole scene rather than part of one: pin a lower LoD with `?om-lod=` (1.3 is ~15× instead of ~33×), tile the source, or extrude footprints instead.
130
+ Surfaces mode multiplies the row count by however many faces a building has: the 3DBAG tile in `dev/examples/cityjson.html` decodes to 120 rows as footprints and **3,940 rows** as surfaces — roughly 33×. That lands against the [free tier's](../README.md#free-tier--licensing) 25,000-row cap at around 750 buildings, where the footprint mode would still be nowhere near it. Past the cap the layer renders its first 25,000 rows — an arbitrary subset in source order, with a dismissible on-map notice — rather than going blank, so a slightly-too-big scene still draws. Because faces are emitted per building, the cut lands mid-building. To show a whole scene rather than part of one: pin a lower LoD with `?om-lod=` (1.3 is ~15× instead of ~33×), tile the source, or extrude footprints instead.
131
131
 
132
132
  Face counts are long-tailed, so the building count you can fit is not predictable from the average: in that same tile the median building is 17 faces but the largest is 963 — one building, 24% of the tile's rows.
133
133
 
@@ -0,0 +1,75 @@
1
+ # Georeferenced image overlays
2
+
3
+ `ImageOverlay` places a geotagged drone JPEG on the map. It is the OnlyMapJS-owned preprocessing path over deck.gl's `BitmapLayer`: the browser reads EXIF/XMP, computes an axis-aligned WGS84 footprint, bakes the camera yaw and upside-down roll correction into the pixels, then renders the processed image at those bounds.
4
+
5
+ ## Direct manifest use
6
+
7
+ ```html
8
+ <om-map center="[103.85, 1.29]" zoom="18" pitch="45">
9
+ <om-layer id="survey-photo"
10
+ type="ImageOverlay"
11
+ src="./DJI_0123.jpg"
12
+ georeference="exif"
13
+ opacity="0.8"></om-layer>
14
+ </om-map>
15
+ ```
16
+
17
+ The JPEG must contain finite GPS latitude/longitude, positive `RelativeAltitude`, image dimensions, `GimbalYawDegree`, and `GimbalPitchDegree`. `GimbalRollDegree` defaults to zero. Focal length comes from EXIF; it is never guessed.
18
+
19
+ Known DJI/Parrot cameras use the bundled sensor database. The release path has been verified in Chromium against original DJI FC300S and M30T photos, including M30T files carrying the 180° roll correction. For another camera, supply the physical sensor dimensions and, if EXIF lacks it, focal length:
20
+
21
+ ```html
22
+ <om-layer id="survey-photo"
23
+ type="ImageOverlay"
24
+ src="./survey.jpg"
25
+ georeference="exif"
26
+ sensor-width-mm="13.2"
27
+ sensor-height-mm="8.8"
28
+ focal-length-mm="8.8"></om-layer>
29
+ ```
30
+
31
+ The source follows `OmMap.configureData({ headers, credentials, fetch })`, so authenticated image endpoints use the same request policy as data URLs. `map.ready` waits for EXIF resolution. A failed image is omitted and logged with an actionable error rather than constructing an invalid `BitmapLayer`.
32
+
33
+ ## Persist once, reconstruct cheaply
34
+
35
+ For collaborative maps or saved manifests, preprocess once and upload the returned image. Store its bounds and metadata beside the new URL:
36
+
37
+ ```js
38
+ const resolved = await OmMap.resolveImageOverlay(file);
39
+ const imageUrl = await upload(resolved.image);
40
+
41
+ const savedLayer = {
42
+ src: imageUrl,
43
+ bounds: resolved.bounds,
44
+ metadata: resolved.metadata
45
+ };
46
+ ```
47
+
48
+ Reconstruct with explicit bounds:
49
+
50
+ ```html
51
+ <om-layer id="survey-photo"
52
+ type="ImageOverlay"
53
+ src="./processed/0123.png"
54
+ bounds="[103.841,1.286,103.845,1.290]"
55
+ metadata='{"cameraAssetId":"0123"}'></om-layer>
56
+ ```
57
+
58
+ Explicit bounds bypass fetching and EXIF parsing. The source may therefore be the processed PNG returned when rotation was required, or an unchanged JPEG when it was not. Do not also set `georeference="exif"`; validation warns because bounds win.
59
+
60
+ The React/programmatic twin uses the same camel-case props:
61
+
62
+ ```tsx
63
+ <OmLayer
64
+ id="survey-photo"
65
+ type="ImageOverlay"
66
+ src="./survey.jpg"
67
+ georeference="exif"
68
+ sensorWidthMm={13.2}
69
+ sensorHeightMm={8.8}
70
+ />
71
+ ```
72
+
73
+ ## Accuracy boundary
74
+
75
+ This patch intentionally matches the proven PlanetGPT stage-1 approach: a flat-ground pinhole-camera estimate, a pitch-adjusted center approximation, baked yaw/roll, and an axis-aligned bounding box. It is suitable for visualization, not surveying or measurement. Terrain relief, lens distortion, camera calibration, antimeridian-crossing footprints, and a perspective-correct four-corner projective footprint are not modeled. Pre-orthorectified imagery should use explicit bounds, while large orthomosaics belong in a Cloud-Optimized GeoTIFF through `COGLayer`.
package/llms.txt CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Agent-first map library: a declarative HTML manifest drives deck.gl. You write `<om-map>` / `<om-layer>` / `<om-widget>` / `<om-overlay>` / `<om-behavior>` elements; the library owns reconciliation, accessor compilation, basemap/view sync, overlays, and validation. No build step, no imperative deck.gl code.
4
4
 
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, each with a `fix` instruction) and inspect resolved output with `OmMap.snapshotIR(htmlString)` before finalizing.
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
7
  ## Loading the library
8
8
 
@@ -19,6 +19,7 @@ OnlyMapJS is NOT raw deck.gl and NOT generic HTML/JSX. The rules below are the d
19
19
  - A literal color inside a `get-*` accessor is a string IN the expression — quote it: `get-line-color="'#ffffff'"` or an RGBA array `get-line-color="[255,255,255,200]"`. Bare `get-line-color="#ffffff"` is an expression parse error (plain attributes like `color="#dc2626"` take bare hex, accessors do not).
20
20
  - Inline event handlers (`onclick="..."`) are rejected. Use `data-emit` attributes (`<span data-emit="hide-overlay" data-target="popup1">`) or `addEventListener` inside a `<script type="om/widget">` block where `ctx` is in scope.
21
21
  - `scale()` requires an explicit `domain=`: `get-fill-color="scale($depth, sequential, ['#ffffcc','#800026'], domain=[0,700])"`. A missing domain is a validation error.
22
+ - Readable date/time labels use the safe built-in `formatDate($time, 'datetime', 'UTC')`. Input may be epoch milliseconds (NEVER seconds; multiply seconds by 1000 first) or an ISO string. Styles: `date|datetime|time|iso`; default style `datetime`, default zone `UTC`; zone may be `local` or an IANA name such as `Asia/Singapore`. Do NOT use `new Date()`, `Intl`, or instance methods in restricted expressions.
22
23
  - `scale()`/`clamp()`/`lerp()`/`$field` expressions go ONLY in `get-*` accessors — NEVER in a scalar attribute (`radius-min-pixels`, `point-radius-min-pixels`, `opacity`, `line-width-min-pixels`, `color`, …). A data-driven expression in a scalar doesn't coerce, so the layer silently renders defaults (invisible / black points); validation errors on it. Data-driven size/width/opacity/color → the matching `get-*` accessor (`get-radius`, `get-fill-color`, …); scalars take a constant floor/value.
23
24
  - `id` is required on every `<om-layer>`. `label` and `color` feed the legend; an explicit `get-fill-color` overrides `color` for rendering (both together is valid and common). The legend widget also reads `get-fill-color` itself: a `sequential`/`diverging` scale renders as a gradient ramp with domain labels, a `threshold` scale as discrete class ranges, and an equality ternary chain (`$f == 'a' ? '#c1' : '#c2'`) as a category palette — write those canonical shapes and the legend describes the symbology automatically.
24
25
  - 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.
@@ -27,15 +28,17 @@ OnlyMapJS is NOT raw deck.gl and NOT generic HTML/JSX. The rules below are the d
27
28
  ## Element vocabulary
28
29
 
29
30
  - `<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 `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. 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: 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.
30
- - `<om-layer id="..." type="ScatterplotLayer" data="./points.json">` — any deck.gl layer class by `type` (all 33 bundled, plus the native `COGLayer` raster type), 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, 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. 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).
31
+ - `<om-layer id="..." type="ScatterplotLayer" data="./points.json">` — any deck.gl layer class by `type` (all 33 bundled, plus the native `COGLayer` raster type), 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. 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).
32
+ - 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.
31
33
  - `<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 both), `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 (`detail = {mode, units, totalMeters, segments, areaMeters2, perimeterMeters, poleWarning}`); it reuses the draw capture stack (measure/draw mutually exclusive) and its geometry is ephemeral (never saved, not an undo step). `scale-bar` now takes the same `units`. 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.
34
+ - 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.
32
35
  - `<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). `{{field}}` interpolates the picked feature HTML-escaped; `{{{field}}}` is raw (avoid). For labels on many features use `PopupLayer`, not overlays.
33
36
  - `<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`. One payload contract everywhere: `{ layer, target, feature, featureId, coordinate }`.
34
37
  - 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")`.
35
38
  - `<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.
36
39
  - 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`.
37
40
  - `<om-story id="tour" autoplay loop interrupt="pause|ignore">` — a storyboard of `<om-step>` children. Each step: `action="..."` + payload attributes (same kebab-case rule as behaviors) + `duration`/`delay`/`parallel` timing. Steps REFERENCE layers/overlays by id (`layer=`/`target=`) — a step must NEVER contain elements (validation error). Control: `<om-widget type="player" story="tour">`, the story-play/story-pause/story-seek actions, or `storyEl.play()/pause()/seek(ms)`. Seeking restores initial state then applies steps before T; use declarative payloads (e.g. `action="toggle-layer" visible="true"`, not bare toggles) so scrubbing is deterministic. Scene actions are story-steppable AND scrub-capturable: `set-basemap`, `set-lighting` (a sunset story: steps walking sun-elevation down; a bare preset step is a clean reset), and `set-terrain` all rewind on seek — the story captures the map's scene attributes before first play. Effect verbs as bare step attributes: `<om-step fade layer="regions" duration="1s">` (opacity reveal — start the layer at `opacity="0"`), `pulse` (attention flash), `trace` (progressive draw — whole-layer needs a TripsLayer; add `feature-id="..."` to make ONE polygon/line draw itself on inside any layer, or use it from a click behavior for click-to-trace), `populate` (rows drop in one by one — ordered by the authored filter-field, a payload `field`, or data order).
38
- - Filtering: `filter-field="magnitude" filter-range="[4, 10]"` on a layer (GPU-side, live-updatable via the `filter-layer` action); pair with `<om-widget type="filter" layer="..." field="...">`.
41
+ - Filtering: `filter-field="magnitude" filter-range="[4, 10]"` on a layer (GPU-side, live-updatable via the `filter-layer` action); pair with `<om-widget type="filter" layer="..." field="...">`. For an epoch-millisecond field, make the slider labels readable with `<om-widget type="filter" layer="quakes" field="time" format="date" date-style="datetime" time-zone="UTC"></om-widget>`.
39
42
 
40
43
  ## Decision rule for annotations
41
44
 
@@ -54,6 +57,6 @@ In a React codebase, do NOT render om-* elements from JSX (React and the library
54
57
  ## Verification loop
55
58
 
56
59
  1. Write the manifest (or edit the live DOM — changes reconcile automatically).
57
- 2. `OmMap.validate(html)` → `{ valid, errors: [{ severity, element, attribute, message, fix }] }`. Apply each `fix` verbatim; it is an instruction, not a diagnosis.
60
+ 2. `OmMap.validate(html)` → `{ valid, errors, warnings }`, each entry `{ severity, element, attribute, message, fix }`. Apply every `fix` verbatim — from BOTH lists; it is an instruction, not a diagnosis. `valid` reflects ERRORS ONLY, so never stop at `valid: true` — clear the warnings too. A warning is not cosmetic: an `Unknown attribute "…" — likely a typo` warning means a prop you wrote is being silently dropped and the layer renders wrong-but-valid (classic: `get-radius`/`radius-units` on a `GeoJsonLayer`, whose point size is `get-point-radius`/`point-radius-units` — `get-radius` is a `ScatterplotLayer` prop). Consult `onlymapjs.html-data.json` for the layer's real attribute set rather than guessing.
58
61
  3. `OmMap.snapshotIR(html)` → resolved layer descriptors (JSON-safe; accessors appear as behavioural fingerprints) — diff against intent, assert in tests (works headless in jsdom/happy-dom, no WebGL needed).
59
62
  4. For interaction tests: `mountForTest(html)` mounts the page headlessly (no WebGL) — `await h.pick({ layer, featureId })` then assert overlay/widget DOM via `el.shadowRoot`. Full guide: [docs/testing.md](docs/testing.md).