@nika-js/onlymap 0.6.16 → 0.6.18

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 (40) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.md +1 -1
  3. package/bin/onlymapjs.mjs +287 -24
  4. package/dist/{LercDecode.es-Dk9wels3.js → LercDecode.es-CFkuAghv.js} +1 -1
  5. package/dist/actions.d.ts +0 -9
  6. package/dist/{basemap-BA1ZJlLK.js → basemap-BYsWqrqp.js} +9 -1
  7. package/dist/basemap.d.ts +6 -0
  8. package/dist/declarative-filter.d.ts +1 -0
  9. package/dist/effects.d.ts +14 -2
  10. package/dist/elements/om-map.d.ts +22 -0
  11. package/dist/elements/om-story.d.ts +32 -1
  12. package/dist/{geoparquet-8OqhWIi0.js → geoparquet-C5ezOpDN.js} +1 -1
  13. package/dist/html-data.d.ts +1 -1
  14. package/dist/{index-CHMnADf_.js → index-B0qtzfOX.js} +1 -1
  15. package/dist/{index-Bfr8mB8a.js → index-C7pRTBjN.js} +2 -2
  16. package/dist/{index-DbfeUg8P.js → index-PHDt5jos.js} +1 -1
  17. package/dist/{index-BVwjHomd.js → index-T1jT84TY.js} +1 -1
  18. package/dist/{index-CfSUY_BJ.js → index-h5A4LBtR.js} +48812 -48302
  19. package/dist/layers/feature-mesh-layer.d.ts +7 -7
  20. package/dist/layers/warm-tile3d-layer.d.ts +24 -0
  21. package/dist/{lerc-YYE0CLud.js → lerc-B8gvl7FV.js} +2 -2
  22. package/dist/onlymap.standalone.js +63372 -62854
  23. package/dist/onlymapjs.js +1 -1
  24. package/dist/paced-flyby.d.ts +69 -0
  25. package/dist/programmatic.d.ts +9 -0
  26. package/dist/{raster-BRQWdAhD.js → raster-B8bDaA3c.js} +2 -2
  27. package/dist/{raster-pipeline-zQdEkSvr.js → raster-pipeline-BLKrUZxU.js} +1 -1
  28. package/dist/runtime-core.d.ts +21 -0
  29. package/dist/surface-anchor.d.ts +17 -0
  30. package/dist/tile-warm.d.ts +144 -0
  31. package/dist/version.d.ts +1 -1
  32. package/dist/{zarr-BV0RmDUH.js → zarr-Y8OewWSs.js} +2 -2
  33. package/docs/design/paced-flyby.md +42 -0
  34. package/docs/design/trace-link-failure.md +20 -0
  35. package/docs/stories.md +22 -0
  36. package/llms.txt +6 -2
  37. package/onlymapjs.html-data.json +49 -0
  38. package/package.json +1 -1
  39. package/skills/onlymapjs/SKILL.md +1 -1
  40. package/skills/onlymapjs/references/syntax.md +14 -2
package/dist/onlymapjs.js CHANGED
@@ -1,4 +1,4 @@
1
- import { aA as e, aB as r, aC as t, aD as o, aE as b, aF as i, ap as l, aG as n, aH as S, aI as g, aJ as c, aK as E, aL as L, aM as T, aN as A, aO as d, aP as p, aQ as m, aR as _, aS as I, aT as u, aU as M, aV as y, aW as D, ao as O, aX as R, aw as f, aY as N, aZ as P, a_ as h, W as F, a$ as B, b0 as C, b1 as v, b2 as G, b3 as U, b4 as W, b5 as w, b6 as x, b7 as Y, b8 as H, b9 as X, ba as k, bb as J, bc as K, bd as V, be as z, bf as Q, bg as Z, bh as $, bi as j, bj as q, bk as aa, bl as sa, bm as ea, bn as ra, bo as ta, bp as oa, bq as ba, br as ia, bs as la, bt as na, bu as Sa, bv as ga, bw as ca, bx as Ea, by as La, bz as Ta, bA as Aa, bB as da, bC as pa, bD as ma, bE as _a, bF as Ia, bG as ua, bH as Ma, bI as ya, bJ as Da, bK as Oa, bL as Ra, bM as fa, bN as Na, bO as Pa, bP as ha, bQ as Fa, bR as Ba, bS as Ca, bT as va, bU as Ga, bV as Ua, bW as Wa, bX as wa, bY as xa, bZ as Ya, b_ as Ha, b$ as Xa, c0 as ka, c1 as Ja, c2 as Ka, c3 as Va, c4 as za, c5 as Qa, c6 as Za } from "./index-CfSUY_BJ.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 g, aJ as c, aK as E, aL as L, aM as T, aN as A, aO as d, aP as p, aQ as m, aR as _, aS as I, aT as u, aU as M, aV as y, aW as D, ao as O, aX as R, aw as f, aY as N, aZ as P, a_ as h, W as F, a$ as B, b0 as C, b1 as v, b2 as G, b3 as U, b4 as W, b5 as w, b6 as x, b7 as Y, b8 as H, b9 as X, ba as k, bb as J, bc as K, bd as V, be as z, bf as Q, bg as Z, bh as $, bi as j, bj as q, bk as aa, bl as sa, bm as ea, bn as ra, bo as ta, bp as oa, bq as ba, br as ia, bs as la, bt as na, bu as Sa, bv as ga, bw as ca, bx as Ea, by as La, bz as Ta, bA as Aa, bB as da, bC as pa, bD as ma, bE as _a, bF as Ia, bG as ua, bH as Ma, bI as ya, bJ as Da, bK as Oa, bL as Ra, bM as fa, bN as Na, bO as Pa, bP as ha, bQ as Fa, bR as Ba, bS as Ca, bT as va, bU as Ga, bV as Ua, bW as Wa, bX as wa, bY as xa, bZ as Ya, b_ as Ha, b$ as Xa, c0 as ka, c1 as Ja, c2 as Ka, c3 as Va, c4 as za, c5 as Qa, c6 as Za } from "./index-h5A4LBtR.js";
2
2
  export {
3
3
  e as ALL_POSITION_VALUES,
4
4
  r as AUDIT_EXEMPTIONS,
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Load-paced ("clean") flyby driver (docs/design/paced-flyby.md; public
3
+ * issue #16's flyby pop-in, second front).
4
+ *
5
+ * The warm-tiles A/B monitor's verdict on real photogrammetry (Google
6
+ * P3DT): a two-leg flight needs ~970 fine tiles and route warming covers
7
+ * ~3% of them — no pre-load fits a real flight in cache, so the cure for a
8
+ * take with ZERO unrefined frames is pacing, not pre-warming. Wall-clock
9
+ * stretches; output is clean by construction. (Warming still composes:
10
+ * warm first and each paced frame waits far less.)
11
+ *
12
+ * Why a driver must own the camera: a story fly-to step dispatches ONE
13
+ * camera action whose animation then runs inside deck/maplibre, independent
14
+ * of story elapsed time — pausing the story clock does not hold the camera.
15
+ * So this driver replaces wall-clock play entirely: it steps story time
16
+ * manually, writes the camera itself each frame (cameraAtTime over the same
17
+ * fly-to arc the warm sampler uses, via the instant harness setView path),
18
+ * and gates every advance on all live tilesets reporting isLoaded().
19
+ *
20
+ * Pure module: every effect arrives through `deps`, so tests drive it with
21
+ * fake tilesets and clocks exactly (warmTileset's now/sleep precedent).
22
+ */
23
+ /** Per-frame tick — `om-paced-tick`'s detail; the story recorder's hook. `waitedMs` > 0 means this frame paused for tiles. */
24
+ export interface PacedTick {
25
+ t: number;
26
+ waitedMs: number;
27
+ }
28
+ export interface PacedFlybyDeps {
29
+ /** Advance story time by dt, firing due non-camera steps; returns the new clamped story time and whether the story ended. */
30
+ advance(dt: number): {
31
+ t: number;
32
+ done: boolean;
33
+ };
34
+ /** Instant camera write for story-time t — a set, never a transition. */
35
+ setCamera(t: number): void;
36
+ /** Every live tileset has refined for the current selection. */
37
+ tilesetsLoaded(): boolean;
38
+ /** True once the run must stop mid-frame (paused, superseded, disconnected). Ended is NOT aborted — the final frame still gates. */
39
+ aborted(): boolean;
40
+ /** Per-frame notification once the frame is fully refined. A returned promise HOLDS the next advance until it resolves — the recorder's frame-capture seam (screenshot/snapshot race-free by construction). */
41
+ onTick(tick: PacedTick): void | Promise<void>;
42
+ /** Timing hooks — injectable for tests (warmTileset's now/sleep precedent); production callers omit them for the browser defaults. */
43
+ raf?(): Promise<void>;
44
+ sleep?(ms: number): Promise<void>;
45
+ now?(): number;
46
+ /** Story-time step per output frame (default 1000/30). */
47
+ frameIntervalMs?: number;
48
+ /** Cap on one frame's tile wait so a dead tile server degrades to ordinary pop-in instead of freezing playback (default 10s; Infinity = absolute gate). */
49
+ maxWaitPerFrameMs?: number;
50
+ }
51
+ export declare function runPacedFlyby(deps: PacedFlybyDeps): Promise<void>;
52
+ /**
53
+ * One settle pass (issue #37's whenSettled contract, shared with the paced
54
+ * driver's per-frame gate): resolve once `loaded()` reports true AND one
55
+ * further animation frame has been drawn — loaded tile data still needs a
56
+ * draw to reach pixels. Times out to {settled:false} rather than rejecting
57
+ * (the paced-max-hold degradation posture: a late frame is ordinary pop-in,
58
+ * not an error).
59
+ */
60
+ export declare function settleOnce(deps: {
61
+ loaded(): boolean;
62
+ timeoutMs: number;
63
+ aborted?(): boolean;
64
+ raf?(): Promise<void>;
65
+ now?(): number;
66
+ }): Promise<{
67
+ settled: boolean;
68
+ waitedMs: number;
69
+ }>;
@@ -171,6 +171,7 @@ export type PickListener = (selection: Selection | null) => void;
171
171
  * DOM-manifest concern. One controller per map container.
172
172
  */
173
173
  export declare class MapController {
174
+ private firstSettleDrawDone;
174
175
  private core;
175
176
  private mount;
176
177
  private layerIRs;
@@ -321,5 +322,13 @@ export declare class MapController {
321
322
  resume(): void;
322
323
  /** Projects a lng/lat through the current viewport — undefined before the viewport resolves. */
323
324
  project(lngLat: [number, number]): [number, number] | undefined;
325
+ /** Issue #37 — the controller-lane twin of om-map.whenSettled(): resolves once every live 3D tileset has refined for the current view plus one painted frame; {settled:false} + warning on timeout. */
326
+ whenSettled(opts?: {
327
+ timeout?: number;
328
+ }): Promise<{
329
+ settled: boolean;
330
+ }>;
331
+ /** Issue #37 — the determinism switch: while on, camera moves land instantly, effect verbs snap to end state, and GPU transitions are disabled, so a captured frame is a pure function of story time. */
332
+ setDeterministic(on: boolean): void;
324
333
  destroy(): void;
325
334
  }
@@ -1,4 +1,4 @@
1
- import { c as re, t as Ae, i as nt, a as Re, s as rt, C as ot, b as it, F as at, A as st, d as lt, R as he, e as ct, p as ut, m as dt, f as ht, g as pt, h as gt } from "./raster-pipeline-zQdEkSvr.js";
1
+ import { c as re, t as Ae, i as nt, a as Re, s as rt, C as ot, b as it, F as at, A as st, d as lt, R as he, e as ct, p as ut, m as dt, f as ht, g as pt, h as gt } from "./raster-pipeline-BLKrUZxU.js";
2
2
  import pe from "./index-CW1n5LdO.js";
3
3
  function mt(e, t) {
4
4
  const n = e.length / 3, r = new Uint8ClampedArray(n * 4), o = 0, i = n, a = n * 2;
@@ -1069,7 +1069,7 @@ A.set(m.Zstd, () => import("./zstd-jXobGRcq.js").then((e) => e.decode));
1069
1069
  A.set(m.Jpeg, () => Promise.resolve(oe));
1070
1070
  A.set(m.Jpeg6, () => Promise.resolve(oe));
1071
1071
  A.set(m.Webp, () => Promise.resolve(oe));
1072
- A.set(m.Lerc, () => import("./lerc-YYE0CLud.js").then((e) => e.l).then((e) => e.decode));
1072
+ A.set(m.Lerc, () => import("./lerc-B8gvl7FV.js").then((e) => e.l).then((e) => e.decode));
1073
1073
  async function ie(e, t, n) {
1074
1074
  const r = A.get(t);
1075
1075
  if (!r)
@@ -1,5 +1,5 @@
1
1
  import { w as ae } from "./mgrs-BY9bIvp4.js";
2
- import { am as ce, an as le, ao as ee, ap as te, aq as ue, b as he, ar as de, l as Z, as as fe, d as pe, at as me, au as ge, av as ve, aw as ne } from "./index-CfSUY_BJ.js";
2
+ import { am as ce, an as le, ao as ee, ap as te, aq as ue, b as he, ar as de, l as Z, as as fe, d as pe, at as me, au as ge, av as ve, aw as ne } from "./index-h5A4LBtR.js";
3
3
  function Pe(o, e, t) {
4
4
  const { projectedCorners: n } = e, { topLeft: s, topRight: r, bottomRight: a, bottomLeft: i } = n, c = t(s[0], s[1]), u = t(r[0], r[1]), l = t(a[0], a[1]), d = t(i[0], i[1]), f = [
5
5
  c,
@@ -277,6 +277,19 @@ export declare class RuntimeCore {
277
277
  private headless?;
278
278
  /** Animated-props flush coalescing (see patchAnimatedProps): patches since the last rebuild / a microtask flush already queued. */
279
279
  private animatedDirty;
280
+ /**
281
+ * The determinism switch (issue #37; set from om-map's data-om-recording
282
+ * attribute or MapController.setDeterministic): while on, NOTHING animates
283
+ * on the wall clock — camera moves land instantly, effect verbs snap to
284
+ * their end state, GPU transitions are zeroed at parse — so a frame
285
+ * captured after whenSettled() is a pure function of story time, and
286
+ * frame N renders byte-identically across processes and orderings.
287
+ */
288
+ private deterministic;
289
+ /** layerId → live loaders.gl Tileset3D (see tilesetHook in buildLayers). */
290
+ private liveTilesets;
291
+ /** The lastIRs reference getLiveTilesets last pruned against (see its comment). */
292
+ private liveTilesetsPrunedFor;
280
293
  private animatedFlushQueued;
281
294
  private destroyed;
282
295
  private viewState;
@@ -505,6 +518,10 @@ export declare class RuntimeCore {
505
518
  * consumers rendering exports must add provider credits themselves.
506
519
  * Headless rejects (no renderer); pre-ready rejects (await map.ready).
507
520
  */
521
+ /** deck's own per-layer readiness (async layer/pipeline init) — whenSettled gates on this too; a capture before it is stably missing content (issue #37's flaky first frame). Headless/basemap-pending count as ready. */
522
+ layersReady(): boolean;
523
+ /** Force one renderer draw without capturing (issue #37 — whenSettled's completed-render guarantee; a fresh page's first capture otherwise misses content that has never drawn). */
524
+ forceDraw(): void;
508
525
  snapshot(): Promise<HTMLCanvasElement>;
509
526
  /**
510
527
  * Live basemap change (spec: "Basemap presets & switching"), both paths:
@@ -542,6 +559,8 @@ export declare class RuntimeCore {
542
559
  patchAnimatedProps(layerId: string, props: Record<string, unknown> | null): void;
543
560
  clearAnimatedProps(): void;
544
561
  getAnimatedProps(layerId: string): Record<string, unknown> | undefined;
562
+ /** Live Tileset3D per currently-present tiles layer — the warm-tiles surface and the paced gate. Pruned only when the IR set actually changed: the paced driver polls this every rAF of a tile hold, and re-proving an unchanged layer set per poll is pure waste. */
563
+ getLiveTilesets(): Map<string, unknown>;
545
564
  /**
546
565
  * The current resolved viewport (for `ctx.viewport`'s bounds/project) —
547
566
  * always fresh, never cached. Standalone: `Deck#getViewports()` asserts
@@ -610,6 +629,8 @@ export declare class RuntimeCore {
610
629
  * center/zoom delegate to the basemap's camera (which owns the view) and
611
630
  * pitch/bearing are a documented not-yet gap there.
612
631
  */
632
+ setDeterministic(on: boolean): void;
633
+ isDeterministic(): boolean;
613
634
  setViewState(partial: Partial<ViewState>, opts?: CameraOptions): void;
614
635
  /**
615
636
  * Recenters and rezooms to fit a `[[minLng,minLat],[maxLng,maxLat]]` box —
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `anchor="surface"` (universal layer attribute, the classify/filter
3
+ * pattern): installs a `getPosition` that anchors each row INSIDE its own
4
+ * polygon — on-map annotations (TextLayer labels on shapes) without a
5
+ * card/popup and without authoring per-row coordinates. Resolved where the
6
+ * rows are in hand (parse-manifest / descriptorToIR); an authored
7
+ * `get-position` wins. Placement: centroid of the largest ring, verified
8
+ * inside; a concave polygon whose centroid falls outside gets the midpoint
9
+ * of the widest interior run of a horizontal line through the centroid
10
+ * latitude (polylabel-lite — cheap and always inside).
11
+ */
12
+ import type { LayerData } from "./ir";
13
+ import type { Shape } from "./expr";
14
+ /** A point guaranteed inside the largest ring of the geometry (see module doc). */
15
+ export declare function pointOnSurface(coords: unknown): [number, number] | null;
16
+ /** Installs the surface-anchored getPosition (no-ops: no anchor, authored position, no rows yet). */
17
+ export declare function applySurfaceAnchor(props: Record<string, unknown>, anchor: string | undefined, data: LayerData, shape: Shape, warnLabel: string): void;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Fixed-route 3D-tile pre-loading (spec: "Routing & Tracking" adjacency;
3
+ * public issue #16's flyby pop-in / "blurry then clear" LOD refinement).
4
+ *
5
+ * A story's camera route is fully known before it plays, so the tiles every
6
+ * future viewport needs can be fetched AND parsed ahead of time. The whole
7
+ * mechanism rides public loaders.gl surface: `Tileset3D.selectTiles()`
8
+ * accepts arbitrary viewports (traversal queues real tile loads into the
9
+ * tileset cache), `isLoaded()` signals the queue draining, and the flight
10
+ * arc is sampled with the SAME interpolator deck's fly-to uses
11
+ * (`flyToViewport`), so the zoom-out-then-in phase — where refinement blur
12
+ * is worst — is sampled on the true path, not a straight line.
13
+ *
14
+ * What warming buys: network + parse eliminated on arrival. What it can't:
15
+ * permanent GPU residency — deck uploads buffers for SELECTED tiles only,
16
+ * so far tiles' VRAM is freed when selection returns to the parked camera;
17
+ * the arrival cost after warming is a sub-frame re-upload. The tileset's
18
+ * cache budget (default 32 MB) bounds how much parsed content survives to
19
+ * playback — `budgetMB` raises it for the take (raise-and-keep: restoring
20
+ * it mid-playback would evict exactly the tiles being warmed for), and
21
+ * `load-options='{"tileset":{"maximumMemoryUsage":512}}'` is the authored,
22
+ * persistent form of the same knob.
23
+ */
24
+ import { WebMercatorViewport } from "@deck.gl/core";
25
+ export interface CameraKeyframe {
26
+ longitude: number;
27
+ latitude: number;
28
+ zoom: number;
29
+ pitch?: number;
30
+ bearing?: number;
31
+ }
32
+ /** Warm-pass viewports are minted with this id prefix, and warm cleanup evicts traversal state by it — one constant so the two sides can't drift (and real viewport ids must simply never start with it). */
33
+ export declare const WARM_VIEWPORT_PREFIX = "warm-";
34
+ /** `undefined`/`null`/`""` → undefined; otherwise `Number()` with a finite guard. The one "what counts as a number" rule shared by action payloads and camera-field reading. */
35
+ export declare function coerceNumber(value: unknown): number | undefined;
36
+ /**
37
+ * One point on the fly-to arc between two keyframes, at fraction `t` ∈ [0,1]
38
+ * — deck's own flight math (lng/lat/zoom via flyToViewport; pitch/bearing
39
+ * lerp linearly, matching FlyToInterpolator's treatment of them as plain
40
+ * transition props). The shared core of route sampling (by sample index)
41
+ * and the paced-flyby driver's cameraAtTime (by story time).
42
+ */
43
+ export declare function interpolateFlight(a: CameraKeyframe, b: CameraKeyframe, t: number, width: number, height: number): CameraKeyframe;
44
+ /**
45
+ * Sample the flight path through `keyframes` as renderer viewports —
46
+ * `samplesPerLeg` points per consecutive pair via `interpolateFlight`.
47
+ * Layout contract: index 0 is the starting keyframe and every
48
+ * `samplesPerLeg`-th viewport thereafter lands exactly on an authored
49
+ * keyframe — `destinationViewports` depends on it.
50
+ */
51
+ export declare function sampleFlightViewports(keyframes: readonly CameraKeyframe[], width: number, height: number, samplesPerLeg?: number): WebMercatorViewport[];
52
+ /** The sampled viewports that sit ON authored keyframes (the fly-to destinations, where the camera arrives and lingers) — rides sampleFlightViewports' layout contract above. */
53
+ export declare function destinationViewports<T>(viewports: readonly T[], samplesPerLeg: number): T[];
54
+ /** The slice of loaders.gl's Tileset3D the wait/raise driver touches — structural so tests fake it (and a pinned-version bump that changes the shape fails loudly at the type level); selection itself is owned by WarmableTile3DLayer (in-band). */
55
+ export interface WarmableTileset {
56
+ isLoaded(): boolean;
57
+ _cacheBytes?: number;
58
+ _cacheOverflowBytes?: number;
59
+ options?: {
60
+ maximumScreenSpaceError?: number;
61
+ };
62
+ memoryAdjustedScreenSpaceError?: number;
63
+ /** Per-viewport-id traversal state loaders.gl retains forever — warm ids are evicted after the pass. */
64
+ frameStateData?: Record<string, unknown>;
65
+ roots?: Record<string, unknown>;
66
+ }
67
+ export interface WarmOptions {
68
+ /** Raise the tileset cache budget to this many MB (raise-and-keep). */
69
+ budgetMB?: number;
70
+ /** Warm at a coarser LOD target: maximumScreenSpaceError × this for the pass (default 2 ≈ one level coarser), restored after. Raises BOTH the option and memoryAdjustedScreenSpaceError — the field traversal actually refines against. */
71
+ sseFactor?: number;
72
+ /** Deadline; resolves {timedOut:true} with a partial warm rather than hanging (default 90s — background work). */
73
+ timeoutMs?: number;
74
+ now?: () => number;
75
+ sleep?: (ms: number) => Promise<void>;
76
+ }
77
+ /**
78
+ * Raise budgets, wait for the LAYER-driven warm to drain, restore SSE, evict
79
+ * warm traversal state. Selection is NOT driven here — WarmableTile3DLayer
80
+ * merges the warm viewports into its own update (loaders.gl's multi-viewport
81
+ * API), so there is exactly one selection caller and nothing to race.
82
+ */
83
+ export declare function warmTileset(tileset: WarmableTileset, opts?: WarmOptions): Promise<{
84
+ timedOut: boolean;
85
+ }>;
86
+ /** The camera fields a fly-to step can author — attribute strings or payload values; `center` is the documented `"[lng, lat]"` form, bare longitude/latitude accepted too. */
87
+ export interface CameraStepFields {
88
+ center?: unknown;
89
+ longitude?: unknown;
90
+ latitude?: unknown;
91
+ zoom?: unknown;
92
+ pitch?: unknown;
93
+ bearing?: unknown;
94
+ }
95
+ /**
96
+ * The camera fields a fly-to authors, coerced — per-field undefined when
97
+ * unauthored, null when NO camera field is authored at all. The ONE reading
98
+ * of "what does this fly-to mean" shared by the fly-to action (which merges
99
+ * only authored fields into the view) and `flyToTarget` (which inherits the
100
+ * rest from the previous keyframe). `center` accepts the documented
101
+ * `"[lng, lat]"` JSON form or an array; bare longitude/latitude ride as the
102
+ * fallback.
103
+ */
104
+ export declare function readCameraFields(fields: CameraStepFields): {
105
+ longitude?: number;
106
+ latitude?: number;
107
+ zoom?: number;
108
+ pitch?: number;
109
+ bearing?: number;
110
+ } | null;
111
+ /**
112
+ * The destination a fly-to step describes, with unset fields inherited from
113
+ * `prev`. Returns null when the step authors no camera field at all
114
+ * (nothing to move).
115
+ */
116
+ export declare function flyToTarget(fields: CameraStepFields, prev: CameraKeyframe): CameraKeyframe | null;
117
+ /** A story's camera keyframes from its DOM (route warming's input) — the same fold as buildCameraLegs, keeping the two step→camera walks one. */
118
+ export declare function extractStoryKeyframes(storyEl: Element, current: CameraKeyframe): CameraKeyframe[];
119
+ /** One camera-moving fly-to step as an arc segment over its own timeline interval. */
120
+ export interface CameraLeg {
121
+ from: CameraKeyframe;
122
+ to: CameraKeyframe;
123
+ start: number;
124
+ end: number;
125
+ }
126
+ /**
127
+ * Camera legs from a story's built steps + timeline intervals (index-
128
+ * aligned arrays): each camera-moving fly-to becomes an arc leg over its own
129
+ * interval; every other step leaves the camera parked at the previous
130
+ * destination. Pure — the paced driver's route model.
131
+ */
132
+ export declare function buildCameraLegs(steps: readonly ({
133
+ action: string;
134
+ } & CameraStepFields)[], intervals: readonly {
135
+ start: number;
136
+ end: number;
137
+ }[], initial: CameraKeyframe): CameraLeg[];
138
+ /**
139
+ * The camera the paced driver owns at story-time `t` — parked at `initial`
140
+ * before the first leg, on the true fly-to arc mid-leg, at the destination
141
+ * between/after legs. Leg starts are non-decreasing in authoring order
142
+ * (timeline math guarantees it), so the last leg with start ≤ t is active.
143
+ */
144
+ export declare function cameraAtTime(legs: readonly CameraLeg[], initial: CameraKeyframe, t: number, width: number, height: number): CameraKeyframe;
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.16";
8
+ export declare const LIBRARY_VERSION = "0.6.18";
@@ -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-zQdEkSvr.js";
2
- import { ap as gr } from "./index-CfSUY_BJ.js";
1
+ import { A as ur, d as lr, R as zt, e as fr, m as dr, p as St, f as hr, g as pr, h as mr } from "./raster-pipeline-BLKrUZxU.js";
2
+ import { ap as gr } from "./index-h5A4LBtR.js";
3
3
  import $t from "./index-CW1n5LdO.js";
4
4
  var Et;
5
5
  function h(e, t, n) {
@@ -0,0 +1,42 @@
1
+ # Load-paced ("clean") flyby — design, informed by the warm-tiles A/B data
2
+
3
+ > **Status: IMPLEMENTED** — `<om-story paced>`; `src/paced-flyby.ts` (pure
4
+ > driver) + `cameraAtTime`/`buildCameraLegs` in `src/tile-warm.ts`; see the
5
+ > architecture doc's dated entry 110.
6
+
7
+ Goal: a flyby where no frame ever shows unrefined tiles. Wall-clock runs
8
+ longer; output is clean by construction. (Monitor data: Google P3DT flight
9
+ needs ~970 fine tiles; pre-warming covered ~3% — pacing is the real cure.)
10
+
11
+ ## Key architectural fact (why this is NOT just "pause the story clock")
12
+ Story fly-to steps dispatch ONE camera action with a duration; the camera
13
+ then animates inside deck/maplibre independent of story elapsed time.
14
+ Pausing the story clock does not hold the camera. The pace driver must own
15
+ the camera per frame.
16
+
17
+ ## Design
18
+ `<om-story paced>` (or recorder option): a driver that replaces wall-clock
19
+ play with manual stepping:
20
+ 1. Build the same camera keyframes tile-warm.ts extracts; interpolate with
21
+ flyToViewport per step's own duration -> cameraAt(t) (pure; reuse
22
+ sampleFlightViewports math, parameterized by t not sample index).
23
+ 2. Loop: t += frameInterval (e.g. 1000/30). setViewInternal(cameraAt(t))
24
+ (instant camera set — the harness setView path, not a transition), then
25
+ story.seek(t) for non-camera actions (manualClock mode), then WAIT:
26
+ every live tileset (core.getLiveTilesets) must report isLoaded() before
27
+ advancing. rAF-align each advance.
28
+ 3. om-paced-tick {t, waitedMs} event per frame -> the recorder (#16) hooks
29
+ here; a progress UI can show "paused for tiles".
30
+ 4. End: story "ended" state as normal; camera restore via existing capture.
31
+
32
+ ## Gotchas recorded for the implementer
33
+ - Suppress interrupt="pause" gesture listeners during a paced run (GL tour
34
+ precedent, already noted in #16).
35
+ - isLoaded() needs the CURRENT viewport selection to have been issued —
36
+ after setViewInternal, wait one deck update (the om-tiles-warmed probe
37
+ showed selection lags a frame; sleep(0)+rAF is enough).
38
+ - tile-warm's destination warming composes: warm first, then paced play
39
+ waits far less.
40
+ - Tests: manualClock + fake tileset isLoaded toggling; e2e via the existing
41
+ demo page + monitor (flight loads > 0 but zero frames advanced while
42
+ !isLoaded — assert via om-paced-tick waitedMs sum > 0).
@@ -0,0 +1,20 @@
1
+ # RESOLVED — per-feature trace link failure was schema-default array aliasing
2
+
3
+ Root cause (found by birth-tagging arrays + in-browser constructor
4
+ instrumentation): schema descriptors carry deck's own defaultProps values,
5
+ and the defaults pass assigned container defaults (`extensions: []`) BY
6
+ REFERENCE — one shared array per layer type, mutated by every layer's
7
+ extension push. A trace temp that verifiably skipped filter wiring still
8
+ built with a DataFilterExtension from the polluted shared array; with the
9
+ extra attributes the temp TripsLayer's pipeline exceeded the vertex-
10
+ attribute budget on Metal-ANGLE and failed to link ("Too many attributes
11
+ (instancePickingColors)") — traces swept their clock invisibly. Parse
12
+ order decided the pollution, hence the non-determinism, and hence
13
+ "the story-map example traces fine" (no categorical filters there).
14
+
15
+ Fix: `cloneDefault` — schema defaults assigned by value in BOTH front-ends
16
+ (attribute-resolution + programmatic). Regression tests pin independence
17
+ of per-layer extensions arrays in both lanes. The trace-temp filter-skip
18
+ and rounded-cap variant from the investigation are kept as hardening.
19
+ Verified: zero link errors across full probe runs; outlines draw visibly
20
+ (dev/.probe-atlas-draw1.png).
package/docs/stories.md CHANGED
@@ -124,6 +124,28 @@ The layer's rows appear one by one until it's fully populated — each frame onl
124
124
 
125
125
  Works from every dispatch surface, e.g. populate on load: `<om-behavior on="load" action="populate" layer="bikes" duration="3s">`. GeoJSON layers need an authored `filter-field` to populate (a live composite can't take a runtime filter accessor); without one it warns and falls back to `fade`.
126
126
 
127
+ ## Sharp flybys over 3D tiles
128
+
129
+ A flyby over a `Tile3DLayer` (photogrammetry, Google Photorealistic 3D Tiles) normally refines from blurry to sharp as tiles stream in. Two attributes fix this, and they compose:
130
+
131
+ - **`<om-story warm-tiles>`** pre-fetches and parses every tileset's tiles along the story's fly-to route in the background as soon as the page loads — a light route flies sharp with no other change. Completion fires `om-tiles-warmed` on `<om-map>`.
132
+ - **`<om-story paced>`** makes playback *load-paced*: the story steps its own clock frame by frame (`paced="60"` sets the story-fps, default 30), drives the camera itself along the fly-to route, and never advances a frame while any tileset is still refining. **No frame ever shows unrefined tiles — wall-clock stretches instead.** Playback is not real-time, so use it where the output matters more than the wait: recorded takes (screen capture the paced run) or very heavy tilesets, where no amount of pre-warming fits the whole flight in cache. Warm first (`warm-tiles paced`) and each paced frame waits far less.
133
+
134
+ Each paced frame emits `om-paced-tick` on the story (`detail = {t, waitedMs}`); `waitedMs > 0` means that frame paused for tiles — the hook for a "waiting for tiles" progress UI or a recorder. Two paced-run rules: only `fly-to` steps steer the camera (data-dependent camera steps like `zoom-to-feature` are skipped, with a console warning), and user gestures don't pause playback — pause via the player widget or the `story-pause` action.
135
+
136
+ ### Recording a story to video
137
+
138
+ ```bash
139
+ npm install --save-dev playwright && npx playwright install chromium
140
+ npx onlymapjs record map.html --out flyby.mp4
141
+ ```
142
+
143
+ `record` plays the story load-paced in an isolated headless Chromium and writes a video in which **every frame's 3D tiles are fully refined** — one screenshot per story frame (widgets, overlays, and provider attribution all included), assembled with ffmpeg when installed (H.264 `.mp4` or VP9 `.webm`; without ffmpeg you get the PNG frame directory plus the exact ffmpeg command to run). Options: `--story <id>`, `--fps <n>` (story-fps, default the story's `paced` value or 30), `--width`/`--height`/`--scale` (e.g. `--scale 2` for retina frames), `--gpu` (hardware rendering — headless Chromium defaults to software GL, and the real GPU measured ~3× shorter tile holds on Google Photorealistic 3D Tiles; recommended for heavy scenes), `--keep-frames`, `--timeout <s>`, `--max-hold <s>`. A `loop` story records a single pass; a `warm-tiles` story waits for the pre-load first so per-frame holds are short. Frames captured before a deadline hit are kept in `<out>.frames/` for salvage (the printed ffmpeg command assembles the partial take).
144
+
145
+ On very heavy tilesets, watch the per-frame hold in the progress line: each paced frame waits at most `paced-max-hold` (an `<om-story>` attribute, default 10s; the CLI's `--max-hold <s>` sets it) before advancing anyway, so frames that keep hitting the cap may carry residual blur. Raise the cap — or pass `--max-hold none` (`paced-max-hold="none"`) to remove it entirely — for a guaranteed-sharp take; the recording just takes longer (with `none`, only the overall `--timeout` bounds the run). Like `check-layout`, it executes the page's scripts — only run it on manifests you trust, and point it at a browser-runnable manifest (one that imports `@nika-js/onlymap`, not raw `.ts`).
146
+
147
+ Building your own recorder instead? `storyEl.setPacedCapture(async (tick) => { … })` is the seam the CLI uses: called once per paced frame after the frame is fully refined, and the story **does not advance until your promise resolves** — so `await mapEl.snapshot()` or an out-of-band screenshot is race-free by construction. The frame's `om-paced-tick` fires after your capture completes; the tick that arrives in the `"ended"` state means every frame was captured. (`snapshot()` is canvas-only — remember to render provider attribution into exported frames yourself.)
148
+
127
149
  ## Animation primitives (usable without stories)
128
150
 
129
151
  - **Camera:** `map.flyTo(coords, zoom, { duration, curve })`, or the `fly-to` action (`center`/`zoom`/`pitch`/`bearing`/`duration`/`curve`) from any behavior or button; `zoom-to-feature` accepts `duration` too. `prefers-reduced-motion` is honored in both renderer modes — moves become instant, final state identical.
package/llms.txt CHANGED
@@ -33,17 +33,21 @@ Programmatic/native bridge rule: `MapController.setLayers()` accepts normal func
33
33
 
34
34
  ## Element vocabulary
35
35
 
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-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.
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
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).
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.
41
41
  - Custom-widget event emission (the #1 custom-widget bug — a widget that renders but does nothing): a widget DRIVES the map ONLY by EMITTING a registered action; it never mutates the map or dispatches its own `CustomEvent`. Two ways: (1) declarative `data-emit="<action>"` + `data-*` payload keys on an element (fires on click, or change for form controls whose `.value` is auto-added; `data-*` values are STRINGS — use `ctx.emit` for numeric/array payloads like a slider's range); (2) `ctx.emit(action, payload)` for typed payloads, wired INSIDE `render` (so `ctx` is in scope) by assigning `.oninput`/`.onclick` — e.g. a day slider: `this.render = (ctx) => { this.$("#day").oninput = e => ctx.emit("filter-layer", { layer: "quakes", field: "day", range: [+e.target.value, +e.target.value] }); }`. Actions + payloads: `filter-layer {layer, field?, range:[min,max]}`, `toggle-layer {layer, visible?}`, `fly-to {center:[lng,lat], zoom?, duration?}`, `zoom-to-feature {layer, featureId}`, `set-basemap {basemap}`, `highlight-feature {layer, featureId}`, `show-overlay`/`hide-overlay {target}`, `story-play`/`story-pause`/`story-seek {story, t?}`, `undo`/`redo`, `zoom-in`/`zoom-out`, `set-widgets-visible {visible}`; register more with `OmMap.registerAction(name, handler)`. NEVER inline `onclick=`/`oninput=` — `ctx` isn't a global and CSP blocks them, so it silently fires nothing (validation errors on it). For a plain value/time slider prefer the built-in `<om-widget type="filter" layer=… field=…>` — it wires `filter-layer` for you; hand-author only for bespoke UI.
42
- - `<om-overlay id="..." anchor-from="selection">` — rich geo-anchored HTML (≤ ~20 per map). Anchors: `anchor="[lng, lat]"` (static), `anchor-from="selection"` (follows picks), or `anchor-layer="regions" anchor-feature-id="mission"` (anchored to a feature's own geometry — bbox center — no coordinates in markup; `{{field}}` interpolates that feature's attributes). Selection-anchored overlays scope with `layer="…"` (one layer's picks only) and `selection-type="click"|"hover"` (one pick type only) — a click-opened popup should ALWAYS set `selection-type="click"`, else merely hovering any pickable feature drags it there and re-templates it against the hovered object; with it, hover is inert and a click on empty space still dismisses. `{{field}}` interpolates the picked feature HTML-escaped; `{{{field}}}` is raw (avoid); `{{z}}` is the pick's ELEVATION in meters, present only when a `pickable="3d"` layer ran deck's depth pass for that pick (absent — not `0` — otherwise, so "no elevation" is distinguishable from sea level). `clip-to-map` (opt-in) hides the overlay when its own BOX would spill past the map viewport rather than only when its anchor leaves — for small transient tips that track the cursor; an overhanging absolutely-positioned box inflates the page's scrollable overflow and the scrollbar -> map resize -> reprojection loop shows as view jitter. For labels on many features use `PopupLayer`, not overlays.
42
+ - `<om-overlay id="..." anchor-from="selection">` — rich geo-anchored HTML (≤ ~20 per map). Anchors: `anchor="[lng, lat]"` (static), `anchor-from="selection"` (follows picks), or `anchor-layer="regions" anchor-feature-id="mission"` (anchored to a feature's own geometry — bbox center — no coordinates in markup; `{{field}}` interpolates that feature's attributes). Selection-anchored overlays scope with `layer="…"` (one layer's picks only) and `selection-type="click"|"hover"` (one pick type only) — a click-opened popup should ALWAYS set `selection-type="click"`, else merely hovering any pickable feature drags it there and re-templates it against the hovered object; with it, hover is inert and a click on empty space still dismisses. `{{field}}` interpolates the picked feature HTML-escaped; `{{{field}}}` is raw (avoid); `{{z}}` is the pick's ELEVATION in meters, present only when a `pickable="3d"` layer ran deck's depth pass for that pick (absent — not `0` — otherwise, so "no elevation" is distinguishable from sea level). `clip-to-map` (opt-in) hides the overlay when its own BOX would spill past the map viewport rather than only when its anchor leaves — for small transient tips that track the cursor; an overhanging absolutely-positioned box inflates the page's scrollable overflow and the scrollbar -> map resize -> reprojection loop shows as view jitter. For labels on many features use `PopupLayer`, not overlays. STYLING: overlay content renders inside a shadow root (style isolation, like widgets) — page stylesheets/classes do NOT reach it; use inline `style="…"` on the content or a `<style>` element INSIDE the overlay (children move into the shadow root wholesale, so it applies there); inheritable props + CSS custom properties pierce.
43
43
  - `<om-behavior on="click|hover|drag|load|data-loaded" layer="..." action="...">` — declarative interaction. Built-in actions: `show-overlay`, `hide-overlay`, `show-tooltip`, `hide-tooltip`, `toggle-layer`, `filter-layer`, `highlight-feature`, `zoom-to-feature`, `set-basemap`, `undo`, `redo`; scene/tool actions `set-lighting`, `set-terrain`, `set-clip-box` (`{min,max,invert?,highlight?}` / `{clear:true}`), `clip-box-edit` (`{editing}`), `export-region-3d` (`{target?, format?:"glb"|"b3dm"}` — what the draw widget's `export-3d` button emits), and the measure actions `measure-mode` (`{mode:"distance"|"area"|"volume"|null}`), `measure-units`, `measure-clear`, `measure-config` (`{profile?, baseSurface?, density?, swell?, shrink?, deadband?}`), `measure-flat-target-plane` (`{flat}`). One payload contract everywhere: `{ layer, target, feature, featureId, coordinate }`.
44
44
  - Undo/redo is built in: user-facing manifest changes (layer toggles, filter changes, basemap switches, element add/remove, drawn sketches) are recorded automatically — the manifest is the state. `<om-widget type="undo-redo">` renders the buttons; Cmd/Ctrl-Z, Shift-Cmd/Ctrl-Z, and Ctrl-Y work on any map (text inputs keep their native undo). Camera moves, hover effects, and story playback are deliberately NOT undo steps. Widget scripts: `ctx.history.canUndo/canRedo` with watch token `history`; `ctx.emit("undo")`/`ctx.emit("redo")`.
45
45
  - `<om-fallback>` — static no-JS fallback, direct child of `<om-map>` (one per map, no attributes, plain HTML content — links allowed). Shown ONLY where scripts never run (chat-app/email file previews — iOS QuickLook renders HTML attachments with JS off — file managers, sandboxed webviews); hidden automatically once the map boots. GOOD PRACTICE: include one on every complete page, especially pages that may be shared as a file ("This interactive map requires JavaScript — open this file in a web browser", plus a hosted-version link when one exists). Without one, the stylesheet shows a generic text-only banner. The gate is pure CSS (`om-map:not(:defined)` in onlymapjs.css), so the CSS must load without JS — a real `<link rel="stylesheet">` or inlined `<style>` on no-build pages; a bundler-emitted stylesheet is fine in npm projects.
46
46
  - Animation: `transition="get-fill-color 800ms, get-radius 400ms"` on a layer GPU-animates prop changes (also smooths streaming updates via `get-position`). Camera: the `fly-to` action takes `center`/`zoom`/`pitch`/`bearing`/`duration` (e.g. `duration="2s"`) — use it in behaviors or `data-emit` buttons; `zoom-to-feature` also accepts `duration`.
47
+ - Pull-model frame rendering (external video frameworks such as Remotion): mark the map `data-om-recording` (the determinism switch — instant camera, effects snap to end state, no transitions/gesture interrupts, byte-stable `snapshot()`), then per frame `story.seek(t, {interpolateCamera: true})` + `await mapEl.whenSettled()` + capture; the same frame is byte-identical across processes and orderings. `onlymapjs record` remains the built-in push-model path.
48
+ - Travel-map animation: a `trace` story step takes `follow` (camera rides the drawing tip along the path) and `easing="linear|ease-in|ease-out|ease-in-out"`; combine with a TripsLayer route + pins toggled by later steps, and export with `npx onlymapjs record`.
49
+ - Fixed-route 3D-tile pre-loading: `<om-story warm-tiles>` pre-fetches AND parses every 3D tileset's tiles along the story's fly-to route in the background at load (deck's own flight arc, sampled), so a flyby plays sharp instead of "blurry then clear" — or dispatch the `warm-tiles` action (`{story?, samples?, budget?}`; default budget raises each tileset cache to 256 MB, raise-only) manually before a take. Completion: `om-tiles-warmed` on `<om-map>`. Persistent per-layer knob: `load-options='{"tileset":{"maximumMemoryUsage":512}}'`.
50
+ - Load-paced ("clean") flyby: `<om-story paced>` steps its own clock frame by frame (optionally `paced="60"` story-fps, default 30), drives the camera itself along the fly-to route, and never advances while any 3D tileset is still refining — NO frame ever shows unrefined tiles, at the cost of wall-clock time (playback is not real-time; use for recorded takes or heavy tilesets — Google Photorealistic 3D Tiles — where no pre-warm fits the flight in cache). Per-frame `om-paced-tick` on the story (`detail = {t, waitedMs}`; `waitedMs > 0` = that frame paused for tiles). Composes with `warm-tiles` (warm first → shorter waits). Paced runs: only `fly-to` steps steer the camera (`zoom-to-feature` etc. are skipped with a warning), and user gestures do NOT pause playback — use the player widget or `story-pause`. VIDEO OUTPUT: `npx onlymapjs record map.html --out flyby.mp4` (needs dev-installed playwright; ffmpeg for assembly, else PNG frames + the command to run) plays the story paced in headless Chromium and writes a video where every frame is fully refined — widgets/overlays/attribution included; options `--story/--fps/--width/--height/--scale/--gpu/--keep-frames/--timeout/--max-hold` (`--gpu` = hardware rendering instead of headless software GL — ~3× shorter tile holds on heavy 3D scenes, recommended); frames survive a deadline hit in `<out>.frames/` for salvage. Per-frame tile waits are capped by `paced-max-hold` on `<om-story>` (duration grammar, default 10s; `--max-hold` sets it) — frames that keep hitting the cap may stay slightly blurry; raise it, or set `"none"` (`--max-hold none`) for an absolute gate: guaranteed-sharp takes, only the overall timeout bounds the run. Custom recorders: `storyEl.setPacedCapture(async (tick) => {...})` — awaited per frame BEFORE that frame's om-paced-tick, so capture is race-free and the ended-state tick means all frames captured.
47
51
  - `<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).
48
52
  - 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>`. Up to 4 numeric dimensions at once via `filter-fields='[{"field":"magnitude","range":[4,10]},{"field":"time","range":[…]}]'` (JSON array, additive to filter-field/filter-range — wins if both are authored) — one `<om-widget type="filter">` per field, each moves its own dimension independently (filter-layer merges the range onto the matching field rather than replacing the whole filter); a row must pass every active dimension (AND). A dimension can't be added live — the full set is declared up front in filter-fields. Categorical filtering is a SEPARATE mechanism (deck.gl's own discrete keep-list test, not a range) with its own attributes: `filter-category="fuel" filter-categories='["Coal","Gas"]'` (single) or `filter-category-fields='[{"field":"fuel","categories":[...]},...]'` (up to 4); the SAME `<om-widget type="filter">` auto-renders checkboxes instead of a slider when its `field` is declared categorically (mode is inferred from the layer's own filter, never a separate widget attribute) — one checkbox per distinct value present in the data, with its row count. A category dimension with no keep-list is dropped from the active filter (there is no "matches everything" category the way a numeric range has [-Infinity, Infinity]). Numeric and categorical filters on the same layer combine — a row must pass both. `ctx.stats`/`ctx.dataInViewport` respect whichever kind(s) are active by default (`{filtered:false}` opts out). CLASSIFIED SYMBOLOGY: `classify-by="<numeric field>"` (+ `classify-scale="quantile|equal-interval|jenks"`, `classify-classes="2-12"` default 5, `classify-ramp="viridis|plasma|inferno|magma|cividis|turbo|blues|greens|oranges|purples|reds|ylorrd|rdbu|spectral"`) computes class breaks FROM THE DATA at reconcile time, installs the fill-color accessor and the auto classes legend — use it when the user asks for graduated/choropleth styling WITHOUT hand-authoring domains; an authored get-fill-color/color always wins (validation warns on the conflict); URL-backed layers classify when their data arrives. TEMPORAL PLAYBACK: `<om-widget type="time-slider" layer="…" field="<numeric/epoch-ms field>" duration="20s" window="<span in field units>" loop format="date" date-style time-zone>` — play/pause/scrub emitting the ordinary filter-layer action (cumulative from the domain start, or a sliding window with `window`); manifest stays the source of truth so undo/story/external filter edits re-sync the thumb — prefer it over hand-rolling a playback loop.
49
53
  - Routing & tracking are two library-owned layer types (not `PathLayer`/`IconLayer` hand-wired) that expand into ordinary `PathLayer`/`IconLayer` instances internally, same pattern as `BIMLayer`→`Tile3DLayer`. `<om-layer type="Route" geometry='{"type":"LineString","coordinates":[[lng,lat],...]}'>` draws a styled route (casing + line + origin/destination pins) from geometry you already have — resolves SYNCHRONOUSLY, no network. `<om-layer type="Route" origin="[lng,lat]" destination="[lng,lat]" provider="nika" profile="driving">` (+ optional `waypoints`) resolves one ASYNCHRONOUSLY via a `RoutingProvider` named by `provider` — `"nika"` is registered by default but its endpoint is an UNVERIFIED PLACEHOLDER until NIKA's real routing service ships (register a working one with `OmMap.registerRoutingProvider(name, provider)` — a ~15-line adapter over OSRM's keyless public demo server (`router.project-osrm.org/route/v1/{profile}/{lng},{lat};{lng},{lat}?geometries=geojson&overview=full`, map `distance`/`duration`/`legs` onto `distanceMeters`/`durationSec`/`legs[]`) is the verified keyless real-data recipe; the skill's syntax.md carries it in full). `geometry` wins outright if both are authored (validation warns). `color`/`casing-color` style the line; `follow="fit-route"` auto-fits the camera once resolved. `<om-layer type="Tracking" get-position="[$lng,$lat]">` renders ONE moving entity (v1 — a fleet is one `Tracking` layer per vehicle) with bearing-derived icon rotation; position data arrives through the ORDINARY `data`/`source` mechanism, no separate tracking-subscription API. `bearing-field` (default `"bearing"`) names the plain field to rotate by (checks `properties.<field>` on GeoJSON rows, `<field>` directly on flat rows); `interpolate-ms` (default `1000`) glides the marker between two fixes via the per-frame channel instead of jumping; `follow="follow"` eases the camera along with it, same timing. `color`/`size` style the marker; `icon="arrow|car|motorcycle"` picks the shape (default arrow — all nose-up, baked in `color`, unknown names fall back with a validation warning). Tail modes: on the ROUTE layer, `progress-from="<tracking-layer-id>"` + `tail="none"` (client view — only current position → destination renders, origin pin dropped) or `tail="dim"` (operator view — traveled portion darkened; `tail-color` overrides) split the route at the marker's interpolated position per frame; default full ignores the split; validation warns on partial wiring.