@nika-js/onlymap 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,74 @@
1
+ # 3D assets on the map — the GLB pipeline
2
+
3
+ OnlyMapJS renders 3D models **in geographic context**: buildings, equipment, vehicles — anything you can place at a longitude/latitude. The architecture rule that makes this simple is the same one the library applies to data everywhere:
4
+
5
+ > **Domain 3D formats stay upstream; the manifest ingests web standards.**
6
+ > IFC, CAD, mesh formats → convert on your server to GLB. GLB (binary glTF) and 3D Tiles → the manifest renders directly. (2D vector formats — GeoJSON, CSV, Shapefile, KML, GeoArrow — load directly too; see the README's data-formats table.)
7
+
8
+ ## The manifest half (this library)
9
+
10
+ `ScenegraphLayer` instances a glTF/GLB model at each data row — the model loads and parses automatically (no loader wiring, no three.js, no scene setup):
11
+
12
+ ```html
13
+ <om-map center="[-122.399, 37.79]" zoom="15.5" pitch="55" bearing="20" basemap="maplibre">
14
+ <om-layer id="towers" type="ScenegraphLayer"
15
+ scenegraph="./building.glb"
16
+ data="./sites.json"
17
+ get-position="[$lon, $lat]"
18
+ get-orientation="[0, $heading, 90]"
19
+ get-scale="[$width, $height, $depth]"
20
+ lighting="pbr" pickable></om-layer>
21
+ <om-behavior on="hover" layer="towers" action="show-tooltip" template="#site-tooltip"></om-behavior>
22
+ </om-map>
23
+ ```
24
+
25
+ Everything else composes as usual: picking works on models (the hover tooltip above), so do filters, widgets, behaviors, and the [testing story](testing.md).
26
+
27
+ Things worth knowing:
28
+
29
+ - **glTF is Y-up; the map is Z-up.** The roll of `90` in `get-orientation="[pitch, yaw, roll]"` stands a Y-up model upright — the standard idiom. Yaw is your heading field.
30
+ - **`get-scale` is in model space** `[x, y, z]` — for an upright Y-up model that's `[width, height, depth]` in meters (if the model is unit-sized).
31
+ - **`lighting="pbr"`** shades models by their glTF materials; the default is flat.
32
+ - **One model, many instances.** `ScenegraphLayer` draws the *same* GLB at every row. Different models per row means one layer per model — or, at city scale, 3D Tiles (below).
33
+ - Runnable example: [`dev/examples/scenegraph.html`](../dev/examples/scenegraph.html) (its `box.glb` is a placeholder — a stand-in for the pipeline below).
34
+
35
+ ## The pipeline half (your server)
36
+
37
+ A proven Python stack for BIM/CAD sources — convert once, serve the GLB as a static asset:
38
+
39
+ ```python
40
+ # IFC (BIM) → GLB, e.g. behind a Flask endpoint or as a batch job
41
+ import ifcopenshell, ifcopenshell.geom # parse IFC, extract geometry
42
+ import trimesh # mesh cleanup: merge, decimate, reorient
43
+ # assemble + write GLB via trimesh.exports or pygltflib
44
+ ```
45
+
46
+ - **`ifcopenshell`** parses IFC and — importantly — can extract **geo-referencing** (`IfcMapConversion` / `IfcSite` coordinates), which is how a building lands at its real longitude/latitude instead of floating in local model space. Carry that lon/lat into your `data` rows.
47
+ - **`trimesh`** handles mesh processing: merging elements, decimation (web budgets: aim for well under ~1M triangles per model), re-centering the origin at the model's base so `get-position` anchors the ground point.
48
+ - **`pygltflib`** (or `trimesh`'s own exporter) writes the GLB.
49
+
50
+ Keep the conversion out of the browser: IFC parsing is heavy, and GLB is the interoperable boundary — the same asset works in three.js, Blender, or any glTF viewer, so the pipeline isn't coupled to this library.
51
+
52
+ ## At city scale: 3D Tiles
53
+
54
+ Hundreds of unique buildings or full city models shouldn't ship as one GLB. The OGC **3D Tiles** spec (streamed, LOD-managed) is the format to target — `Tile3DLayer` is bundled and consumes it directly:
55
+
56
+ ```html
57
+ <om-layer id="city" type="Tile3DLayer" tileset="https://example.com/tileset.json"></om-layer>
58
+ ```
59
+
60
+ For LOD experiments, common loaders.gl tileset options have first-class attributes:
61
+
62
+ ```html
63
+ <om-layer id="city" type="Tile3DLayer"
64
+ tileset="https://example.com/tileset.json"
65
+ maximum-screen-space-error="2"
66
+ maximum-memory-usage="256"
67
+ view-distance-scale="0.85"></om-layer>
68
+ ```
69
+
70
+ Upstream converters exist for IFC/CityGML → 3D Tiles (e.g. Cesium ion, `py3dtiles`, FME). Same rule: convert upstream, ingest the standard.
71
+
72
+ ## Scope boundary, stated honestly
73
+
74
+ OnlyMapJS is for assets **in geographic context** — models on a map, camera pitching down at the world. It is not a model *inspector*: orbiting freely around a single non-geo-referenced model (the classic three.js/CAD-viewer use case) is a different product with a different camera. If your models have no real-world coordinates and never will, a plain glTF viewer is the right tool; the moment they belong somewhere on Earth, this pipeline is.
@@ -0,0 +1,83 @@
1
+ # Live data — streams, polling, and authenticated endpoints
2
+
3
+ A live map is a layer whose `data` keeps changing. OnlyMapJS supports the two transports real fleet/telemetry backends actually expose, behind the same manifest surface — accessors, widgets, filters, and tooltips work identically on both, and **accessors never recompile when data updates** (only the `data` reference changes; the reconciler's fingerprints stay put).
4
+
5
+ ## Choosing a transport
6
+
7
+ | Your backend exposes… | Use | Semantics |
8
+ |---|---|---|
9
+ | A WebSocket pushing per-entity updates | `data="wss://…"` | **upsert by key** — entities move in place |
10
+ | A REST endpoint returning current state | `data="/api/…" refresh="5s"` | **snapshot replace** — each poll is the new truth |
11
+
12
+ Rule of thumb: delta/event feeds → WebSocket; materialized "where is everyone right now" endpoints → polling. (SSE is not supported yet — ask if you need it.)
13
+
14
+ ## WebSocket streams
15
+
16
+ ```html
17
+ <om-layer id="ships" type="IconLayer"
18
+ data="wss://stream.example/feed" source="ais" key="mmsi" flush="250ms"
19
+ get-position="[$lon, $lat]" get-angle="-$cog" ...></om-layer>
20
+ ```
21
+
22
+ - **`key`** declares entity identity: each message *moves* its entity rather than appending a new one. Without a key, an **array** message replaces the whole snapshot (full-snapshot feeds); keyless object messages are dropped with a warning.
23
+ - **`flush`** (default `250ms`) coalesces message bursts into one snapshot push per interval — widgets and the GPU see one update per flush, not one per message.
24
+ - **Reconnects** are automatic, with exponential backoff (1s → 15s).
25
+ - **Host-relative URLs** (`wss:/feed`) resolve host *and* scheme from the page — handy for same-origin bridges.
26
+
27
+ ### Decoding your message format — `registerSource`
28
+
29
+ The transport is generic; your domain format lives in a plugin:
30
+
31
+ ```js
32
+ import { OmMap } from "@nika-js/onlymap";
33
+ OmMap.registerSource("ais", {
34
+ // optional: sent on every (re)connect — subscription/auth handshakes
35
+ onOpen: (send) => send(JSON.stringify({ APIKey: "...", BoundingBoxes: [...] })),
36
+ // raw message (JSON-parsed when parseable) → entity object(s), or null to ignore
37
+ decode: (m) => m?.MessageType === "PositionReport"
38
+ ? { mmsi: m.MetaData.MMSI, lon: m.Message.PositionReport.Longitude, /* … */ }
39
+ : null,
40
+ });
41
+ ```
42
+
43
+ Register the plugin in the same module script that imports the library (registration is also tolerated late — plugins resolve lazily — but same-task is the tidy order). Runnable example: [`dev/examples/ais.html`](../dev/examples/ais.html), which speaks aisstream.io's real message format against a simulated feed.
44
+
45
+ ## Polling (`refresh`)
46
+
47
+ ```html
48
+ <om-layer id="drivers" type="IconLayer"
49
+ data="/api/fleet.json" refresh="5s"
50
+ get-position="[$lon, $lat]" ...></om-layer>
51
+ ```
52
+
53
+ - Each poll **replaces the snapshot** — the endpoint's response is the new truth. If your endpoint returns deltas, you want the WebSocket path instead (validation warns if you combine `refresh` with a `wss:` URL).
54
+ - **Failures don't blank the map**: a failing refresh keeps the last good snapshot and keeps polling, warning once per outage and once on recovery. The *initial* load settles `om-map-ready` even on error.
55
+ - Interval floor is 250ms. Works for Arrow (`.arrow`) URLs too — re-fetched and re-parsed per poll.
56
+
57
+ Runnable example: [`dev/examples/fleet.html`](../dev/examples/fleet.html) — 20 drivers polled at 1s from an **auth-protected** endpoint.
58
+
59
+ ## Authenticated endpoints — `configureData`
60
+
61
+ Private APIs need credentials, and credentials must never appear in manifest markup (the manifest may be agent-generated, and attributes are visible DOM). Configure requests programmatically instead:
62
+
63
+ ```js
64
+ import { OmMap } from "@nika-js/onlymap";
65
+
66
+ OmMap.configureData({ headers: { Authorization: `Bearer ${token}` } });
67
+ // or per-URL:
68
+ OmMap.configureData({ headers: (url) => url.startsWith("/api/") ? { Authorization: `Bearer ${token}` } : undefined });
69
+ // or take over entirely (signing, retries, proxies):
70
+ OmMap.configureData({ fetch: myFetch, credentials: "include" });
71
+ ```
72
+
73
+ This applies to **every** Data Layer request: initial loads, polling refreshes, and Arrow files. WebSocket auth needs no equivalent — put the token in the URL or send it from the plugin's `onOpen`.
74
+
75
+ ## How updates propagate (both transports)
76
+
77
+ Every flush/poll takes the same path a resolved fetch takes: a **new data reference** enters the layer IR → the reconciler hands it to deck.gl, which re-uploads geometry without recompiling any accessor → `data:<layerId>` watch tokens fire, so widgets (stats panels, charts) re-render once per update. `om-map-ready` resolves after the *first* load for polling; for streams it resolves immediately (a stream never "finishes" — don't wait for a first message to consider the map ready).
78
+
79
+ Known limitation: sockets and poll loops are keyed by URL and live for the page — removing the layer stops the data being *read*, not the connection.
80
+
81
+ ## Testing live layers
82
+
83
+ In the [headless harness](testing.md), mock the transport: `vi.stubGlobal("fetch", ...)` for polling (readiness waits for your mock), or `vi.stubGlobal("WebSocket", ...)` for streams. The library's own test suites are the reference patterns — including asserting *movement* (fixed entity count, changing coordinates) rather than just presence.
package/docs/react.md ADDED
@@ -0,0 +1,120 @@
1
+ # React
2
+
3
+ OnlyMapJS ships a first-party React adapter: **`@nika-js/onlymap/react`**. It gives you real components and a typed hook — no HTML strings, no `<om-*>` elements, no expression language:
4
+
5
+ ```tsx
6
+ import { OmMap, OmLayer, OmWidget, OmOverlay, useOmMap } from "@nika-js/onlymap/react";
7
+
8
+ function Quakes() {
9
+ const [visible, setVisible] = useState(true);
10
+ return (
11
+ <OmMap center={[-119, 36]} zoom={5} basemap="maplibre">
12
+ <OmLayer id="quakes" type="ScatterplotLayer" data={features} visible={visible} pickable
13
+ getPosition={d => [d.lon, d.lat]}
14
+ getFillColor={d => (d.mag >= 6 ? [214, 40, 40] : [252, 191, 73])}
15
+ onClick={sel => console.log(sel.object)} />
16
+ <OmWidget position="top-left"><StatsPanel /></OmWidget>
17
+ <OmOverlay anchorFrom="selection" layer="quakes">
18
+ {sel => sel && <QuakeCard feature={sel.object} />}
19
+ </OmOverlay>
20
+ </OmMap>
21
+ );
22
+ }
23
+
24
+ function StatsPanel() {
25
+ const { stats, viewport, emit } = useOmMap(["viewport", "data:quakes"]);
26
+ return <div>{stats("quakes", "mag").count} quakes at z{viewport.zoom.toFixed(1)}</div>;
27
+ }
28
+ ```
29
+
30
+ ## How it works (and why it can't fight React)
31
+
32
+ The core is one IR-centered reconcile engine with two front-ends. The HTML manifest (`<om-map>` + MutationObserver) is one; the React adapter rides the other — a **programmatic front-end** that turns typed descriptors into the same layer IR with no DOM manifest in between. React components never render `om-*` elements, so React's virtual-DOM ownership and the library's MutationObserver never meet. `<OmMap>` renders a wrapper `div` and hands the renderer a dedicated leaf `div` React never puts children in.
33
+
34
+ The same architecture means the two authoring surfaces share every contract: layer semantics, the action payload contract (`emit`), and the `ctx` shape (`useOmMap()` returns exactly what an HTML widget script receives as `ctx` — typed).
35
+
36
+ ## The React way vs. the HTML way
37
+
38
+ | Intent | HTML manifest | React |
39
+ |---|---|---|
40
+ | Accessors | `get-fill-color="scale($mag, …)"` | `getFillColor={d => …}` — plain functions, no trust step |
41
+ | Interactions | `<om-behavior on="click" …>` | `onClick={…}` on `<OmLayer>` |
42
+ | Toggle / filter a layer | `toggle-layer` / `filter-layer` actions | state → `visible` / `filterRange` props |
43
+ | Widgets | `<om-widget type="legend">` / `<script type="om/widget">` | `<OmWidget>` + your JSX + `useOmMap()` |
44
+ | Popups/tooltips | `<om-overlay>` + `show-overlay` | `<OmOverlay>` (projection, rAF tracking, culling managed) |
45
+ | Highlight a feature | `highlight-feature` action | `highlightedObjectIndex` prop (deck.gl-idiomatic) |
46
+ | Stories / draw widget | `<om-story>` / `<om-widget type="draw">` | HTML-manifest only for now |
47
+
48
+ Actions that mutate manifest attributes (`toggle-layer`, `show-overlay`, `fade`, story/draw actions…) are deliberately **not dispatched** on the React path — you'll get a console warning pointing you at props/state instead. Camera actions (`fly-to`, `zoom-in`/`-out`, `zoom-to-feature`), channel effects (`pulse`, `populate`), and your own `OmMap.registerAction` handlers all work.
49
+
50
+ ## Component reference
51
+
52
+ ### `<OmMap>`
53
+
54
+ | Prop | Notes |
55
+ |---|---|
56
+ | `center` `zoom` `pitch` `bearing` | Initial camera; later prop changes move the camera (instant). While they're unchanged, user panning is never fought. |
57
+ | `basemap` | Same contract as the attribute — `"maplibre"`, a style URL, or omit for standalone deck.gl. |
58
+ | `headless` | `true` or `{ width, height }` — no renderer, real projection math (tests under jsdom/happy-dom). |
59
+ | `onReady` | Renderer up + first commit + no data URL still loading. |
60
+ | `onViewStateChange` | Every camera change, with the current `CameraState`. |
61
+ | `onRuntimeError` | deck.gl-level failures in the structured validation shape. |
62
+ | `ref` | The imperative handle — a `MapController`: `flyTo`, `setView`, `emit`, `getLayers`, `injectPick`, `ready`. |
63
+
64
+ Give it a size (`style`/`className`) — it renders a `position: relative` div.
65
+
66
+ ### `<OmLayer>`
67
+
68
+ `id` and `type` (any registered deck.gl layer type), plus:
69
+
70
+ - `data` — inline rows / GeoJSON / columnar **by stable reference** (memoize it — `data:<id>` reactivity diffs by reference), or a URL string. URLs get the full Data Layer: format detection (CSV, Arrow, Shapefile, KML…), `ws(s)://` streams (`source`, `streamKey`, `flush`), `refresh` polling.
71
+ - Any deck.gl prop in camelCase — accessors as plain functions. deck.gl ignores accessor *identity*, so pass `updateTriggers={{ getFillColor: theme }}` when an accessor's output changes.
72
+ - `label` / `color` — legend metadata (`ctx.layers`).
73
+ - `filterField` / `filterRange` (+ soft range, category) — GPU filtering, same wiring as the HTML attributes.
74
+ - `onClick` / `onHover` — picks on this layer, object already flattened/materialized. `onHover(null)` = pointer left.
75
+
76
+ ### `<OmWidget>`
77
+
78
+ A positioning shell: `position="top-left|top-right|bottom-left|bottom-right"` + your JSX. Widgets sharing a corner stack; the shell re-enables pointer events over the map.
79
+
80
+ ### `<OmOverlay>`
81
+
82
+ Anchored HTML at a projected coordinate, culled off-screen/behind-globe, tracked per frame outside React renders:
83
+
84
+ - `anchor={[lng, lat]}` — static, or `anchorFrom="selection"` (+ `layer="id"` to scope which picks move it).
85
+ - Children: static JSX, or `(selection) => JSX` to render the picked feature.
86
+ - `anchorOffset` — attachment point (default `bottom-center`).
87
+ - `interactive={false}` for hover-following tooltips (lets the pointer reach the canvas underneath).
88
+
89
+ ### `useOmMap(watch?)`
90
+
91
+ Returns the typed `RuntimeContext`: `layers`, `viewport` (bounds/zoom/center/project), `selection`, `emit`, `data()`, `dataInViewport()`, `stats()`. The watch list re-renders the component when a token fires: `"viewport"`, `"selection"`, `"layers"`, `"data:<layerId>"`. No list = read-once (still gets fresh state on other re-renders).
92
+
93
+ ## Testing
94
+
95
+ The headless mode works under React exactly like the HTML harness (no WebGL, real projection):
96
+
97
+ ```tsx
98
+ const ref = createRef<OmMapHandle>();
99
+ render(<OmMap ref={ref} headless><OmLayer id="pts" type="ScatterplotLayer" data={rows} getPosition={p} /></OmMap>);
100
+ await ref.current!.ready;
101
+ expect(ref.current!.getLayers()).toHaveLength(1);
102
+ ref.current!.injectPick({ layerId: "pts", object: rows[0], index: 0, coordinate: [0, 0], pixel: [0, 0], type: "click" });
103
+ ```
104
+
105
+ `injectPick` rides the same selection path as real deck.gl picks — `onClick`, `useOmMap(["selection"])`, and `<OmOverlay anchorFrom="selection">` all react to it.
106
+
107
+ ## Install
108
+
109
+ ```bash
110
+ npm install @nika-js/onlymap react react-dom
111
+ ```
112
+
113
+ React ≥ 18 is an optional peer dependency — it's only loaded if you import `@nika-js/onlymap/react`. The adapter is a few KB of glue; the core is shared with the HTML entry, so using both surfaces on one page costs one core.
114
+
115
+ ## Not in the adapter (yet)
116
+
117
+ - **Stories** as React components (a `<Story>`/timeline hook is on the roadmap) — an HTML `<om-story>` needs the HTML front-end.
118
+ - **The draw widget** — HTML front-end only for now.
119
+ - Per-feature `trace` (it animates via runtime manifest elements) — whole-layer `trace` on a TripsLayer works.
120
+ - A `scale()` helper mirroring the expression language — use `d3-scale` or plain functions.
@@ -0,0 +1,134 @@
1
+ # Map stories — guided tours as markup
2
+
3
+ A story turns a map into a narrated sequence: fly here, reveal this, highlight that — authored declaratively, played back with a scrubber, and testable like everything else. Runnable example: [`dev/examples/story.html`](../dev/examples/story.html).
4
+
5
+ ## The one rule
6
+
7
+ **The map describes a scene; the story describes intent over time about that scene.** `<om-story>` is a *sibling* of your layers, never a container — steps reference elements by id, and a step that contains an element is a validation **error**. Delete the story and the map is byte-for-byte the same map.
8
+
9
+ ```html
10
+ <om-map center="[-122.35, 37.85]" zoom="10" basemap="maplibre">
11
+ <om-layer id="regions" ...></om-layer> <!-- a complete, ordinary map -->
12
+ <om-overlay id="chapter" visible="false">…</om-overlay>
13
+
14
+ <om-story id="tour" interrupt="pause">
15
+ <om-step duration="3s" action="fly-to" center="[-122.44, 37.78]" zoom="12" curve></om-step>
16
+ <om-step duration="4s" action="show-overlay" target="chapter" parallel delay="1500ms"></om-step>
17
+ <om-step duration="2s" action="hide-overlay" target="chapter" delay="1s"></om-step>
18
+ <om-step duration="2s" action="toggle-layer" layer="bikes" visible="true"></om-step>
19
+ </om-story>
20
+
21
+ <om-widget type="player" story="tour" position="bottom-left"></om-widget>
22
+ </om-map>
23
+ ```
24
+
25
+ ## Steps
26
+
27
+ A step is `action="…"` plus payload attributes — the **same actions, same kebab-case payload rule** as `<om-behavior>` and `data-emit` buttons. Anything an action can do, a step can do, including your own `OmMap.registerAction` actions.
28
+
29
+ Timing attributes (everything else rides into the payload):
30
+
31
+ | Attribute | Meaning |
32
+ |---|---|
33
+ | `duration` | How long the step's animation runs (`"3s"`, `"800ms"`). Also passed to the action — so a `fly-to` step's camera ease matches its slot. `0`/omitted = instantaneous. |
34
+ | `delay` | Stagger before the step starts. |
35
+ | `parallel` | Start with the *previous step* instead of after everything so far (concurrent camera + reveal is the classic use). |
36
+
37
+ Sequential steps wait for **everything** before them to finish — a short `parallel` step never drags the next step into the middle of a longer one.
38
+
39
+ **Write declarative payloads.** Seeking replays steps, so a step should *set* state, not flip it: `action="toggle-layer" layer="bikes" visible="true"` (sets), not a bare toggle. Custom actions replay on seek but can't be rewound — the library warns once.
40
+
41
+ ## Playback control
42
+
43
+ Playback is three actions — `story-play`, `story-pause`, `story-seek {story, t}` — so every dispatch surface works:
44
+
45
+ - **Attributes:** `autoplay` (starts after `om-map-ready`), `loop`.
46
+ - **The built-in player:** `<om-widget type="player" story="tour">` — play/pause, seek bar, elapsed/total. It's dumb chrome: it emits the three actions and re-renders off the story's `om-story-tick` events, so replacing it is just your own `data-emit` buttons: `<button data-emit="story-play" data-story="tour">▶</button>`.
47
+ - **Behaviors:** `<om-behavior on="click" layer="regions" action="story-play" story="tour">` — click to start a tour.
48
+ - **Script:** `storyEl.play() / pause() / seek(ms)`, plus `state`, `currentTime`, `duration` properties and the `om-story-tick` event.
49
+
50
+ **One active story per map** — playing story B pauses story A. **`interrupt="pause"`** (the default) pauses playback when the user grabs the map (pointer-down or wheel); `interrupt="ignore"` opts out.
51
+
52
+ ## Seeking (the scrub model)
53
+
54
+ `seek(T)` restores the story's captured initial state, then applies the *final* state of every step starting **before** T, instantly. `seek(0)` therefore means "pristine start"; seeking to the very end applies everything. Scrubbing is step-granular — a seek into the middle of a 3s camera flight lands at the flight's end state, not 40% along it.
55
+
56
+ Initial-state capture happens on first play, from the attributes the built-in-action steps will touch (layer visibility, overlay visibility, highlights, filter ranges) plus the camera. That's why declarative payloads matter: the library can know what to snapshot by reading them.
57
+
58
+ ## Testing stories
59
+
60
+ Deterministic, headless, no timers to race ([testing guide](testing.md)):
61
+
62
+ ```js
63
+ const h = await mountForTest(myPageHtml);
64
+ const story = h.story("tour");
65
+ await story.advance(2100); // manual clock — exact, rAF never runs
66
+ expect(overlay.getAttribute("visible")).toBe("true");
67
+ await story.seek(0);
68
+ expect(overlay.getAttribute("visible")).toBe("false");
69
+ ```
70
+
71
+ `h.story(id).advance(ms)` drives the clock manually; `play/pause/seek/state/currentTime/duration` mirror the element API.
72
+
73
+ ## Effect verbs
74
+
75
+ Three intent-level verbs, usable as step shorthands (`<om-step fade layer="regions" duration="1s">`) or as plain actions from behaviors and buttons (`data-emit="pulse" data-layer="quakes"`):
76
+
77
+ | Verb | What it does | Mechanics |
78
+ |---|---|---|
79
+ | `fade` | Animate the layer's opacity to `to` (default 1) over `duration`. Start a reveal layer at `opacity="0"`. | attribute + GPU transition — fully scrub-restorable |
80
+ | `pulse` | Attention oscillation (opacity 1 → 0.3 → 1, `cycles` ≈ duration/600ms) | per-frame channel, cleared cleanly at the end |
81
+ | `trace` | Progressive draw along a `TripsLayer`'s timestamps; the finished trail holds | per-frame `currentTime` sweep |
82
+ | `trace` + `feature-id` | **One feature draws itself on** — inside its own multi-feature layer (Polygon/MultiPolygon outline, or LineString) | see below |
83
+ | `populate` | **Rows drop in one by one** — the layer fills up over `duration` | filterRange sweep on the always-mounted GPU filter |
84
+
85
+ Whole-layer `trace` needs per-vertex time, which is TripsLayer's purpose — give it `get-path`/`get-timestamps` data and start `current-time` below the first timestamp. All verbs honor `prefers-reduced-motion` by jumping to the final state.
86
+
87
+ ### Per-feature trace: `trace layer="regions" feature-id="mission"`
88
+
89
+ ```html
90
+ <om-step duration="3s" trace layer="regions" feature-id="mission"></om-step>
91
+ ```
92
+
93
+ The Mission polygon's outline draws itself around the perimeter (75% of the slot, at uniform speed — timestamps are synthesized from cumulative distance), then the real feature reappears as the outline dissolves. Everything underneath is runtime-managed and removed afterward: the target feature is hidden by swapping the layer's data to "everyone else" through the per-frame channel (the other features are untouched, and your markup never changes), and the outline is a temporary `TripsLayer` element the runtime appends and deletes — it never appears in the legend or `ctx.layers`. Optional `color="[255, 120, 40]"` styles the outline.
94
+
95
+ **Parallel traces compose.** Fire a trace for every feature in the same beat and they all draw at once — the hides accumulate (the layer shows the union of "everyone else", i.e. nothing, while all outlines draw) and each feature reveals independently as its own trace finishes:
96
+
97
+ ```html
98
+ <om-step duration="3s" trace layer="regions" feature-id="north-beach" parallel></om-step>
99
+ <om-step duration="3s" trace layer="regions" feature-id="mission" parallel></om-step>
100
+ <om-step duration="3s" trace layer="regions" feature-id="sunset" parallel></om-step>
101
+ ```
102
+
103
+ Start the layer at `visible="false"` and turn it on in the same beat for a "draws itself onto an empty map" opening.
104
+
105
+ It's also a plain action, so **click-to-trace** is one line — clicking any polygon in the layer makes it draw itself:
106
+
107
+ ```html
108
+ <om-behavior on="click" layer="regions" action="trace" duration="2s"></om-behavior>
109
+ ```
110
+
111
+ One honest limit: point features have no outline to trace, so they fall back to `fade`. Scrubbing a story mid-trace cleans up the temp layer and every patch.
112
+
113
+ ### Populate: `populate layer="bikes"`
114
+
115
+ ```html
116
+ <om-step duration="2500ms" populate layer="bikes"></om-step>
117
+ ```
118
+
119
+ The layer's rows appear one by one until it's fully populated — each frame only moves the GPU filter's range bound (a uniform update, no data re-upload), and the patch clears itself so the layer ends on its authored props. Appearance **order**:
120
+
121
+ - the layer's own `filter-field`, if it has one — the sweep runs over the *authored* range, so rows appear in filter order and the end state *is* the authored filter (racks with `filter-field="size" filter-range="[0, 5]"` populate small-to-large, and anything outside the range stays correctly hidden);
122
+ - `field="capacity"` in the payload — order by any numeric field without authoring a filter;
123
+ - neither — data order.
124
+
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
+
127
+ ## Animation primitives (usable without stories)
128
+
129
+ - **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.
130
+ - **Layer props:** `transition="get-fill-color 800ms, get-radius 400ms"` GPU-animates prop changes; on a live layer, `transition="get-position 300ms"` makes entities glide between updates.
131
+
132
+ ## Not yet (honestly)
133
+
134
+ Mid-step scrub interpolation; simultaneous multi-story playback; video export (app-level — use screen capture).
@@ -0,0 +1,165 @@
1
+ # Testing pages built with OnlyMapJS
2
+
3
+ Maps built on raw deck.gl are famously hard to test: deck.gl needs a real WebGL2 context, jsdom is ruled out entirely, and most teams end up with a handful of slow, flaky browser tests — or nothing. OnlyMapJS is designed so that **almost everything about your map page is testable in your ordinary test runner, in milliseconds, with no browser and no GPU.**
4
+
5
+ This guide is the complete workflow. It assumes a page like this (a manifest in an HTML file you deploy):
6
+
7
+ ```html
8
+ <!-- public/dashboard.html -->
9
+ <om-map center="[-122.42, 37.77]" zoom="11">
10
+ <om-layer id="quakes" type="ScatterplotLayer" data="./quakes.json"
11
+ get-position="[$lon, $lat]"
12
+ get-fill-color="scale($magnitude, sequential, ['#fee8c8','#b30000'], domain=[0,7])"
13
+ filter-field="magnitude" filter-range="[0, 10]" pickable></om-layer>
14
+ <om-overlay id="detail" anchor-from="selection" visible="false">
15
+ <div><b>{{place}}</b> — M {{magnitude}}</div>
16
+ </om-overlay>
17
+ <om-behavior on="click" layer="quakes" action="show-overlay" target="detail"></om-behavior>
18
+ <om-widget id="stats" position="top-left"> ... </om-widget>
19
+ </om-map>
20
+ ```
21
+
22
+ ## The three tiers
23
+
24
+ What decides where a test belongs is the heaviest thing the code under test needs: **nothing**, **a DOM**, or **a GPU**.
25
+
26
+ | Tier | Question | Needs | Speed | Rough share of your tests |
27
+ |---|---|---|---|---|
28
+ | 1 | Is my surrounding logic right? | bare Node | ms | as much as you have |
29
+ | 2a | Is my manifest valid, and did its *meaning* change? | fake DOM (happy-dom/jsdom) | ms | 2–3 tests |
30
+ | 2b | Does my page *behave* right? | fake DOM | ms | **the bulk** |
31
+ | 3 | Does it actually render and pick? | real browser (Playwright) | seconds | 2–5 tests |
32
+
33
+ ## Setup (once)
34
+
35
+ ```bash
36
+ npm i -D vitest happy-dom @playwright/test
37
+ ```
38
+
39
+ ```jsonc
40
+ // package.json
41
+ "scripts": {
42
+ "test": "vitest run", // tiers 1–2: every commit
43
+ "test:e2e": "playwright test" // tier 3: PR / nightly
44
+ }
45
+ ```
46
+
47
+ **Test the real page file, not a copy.** Read the deployed HTML into your tests so there is one source of truth:
48
+
49
+ ```ts
50
+ // test/manifest.ts
51
+ import { readFileSync } from "node:fs";
52
+ export const PAGE = readFileSync("public/dashboard.html", "utf8");
53
+ ```
54
+
55
+ ## Tier 1 — your own logic (bare Node)
56
+
57
+ Whatever surrounds the map — data transforms, URL builders, config generation. Nothing OnlyMapJS-specific, except one inherited guarantee: `import "@nika-js/onlymap"` is **side-effect-safe in Node** (element registration no-ops outside a browser), so shared modules that import the library never break your test runner.
58
+
59
+ ## Tier 2a — static: validate + lock the meaning
60
+
61
+ ```ts
62
+ // test/manifest.test.ts
63
+ // @vitest-environment happy-dom
64
+ import { OmMap } from "@nika-js/onlymap";
65
+ import { PAGE } from "./manifest";
66
+
67
+ it("manifest is valid", () => {
68
+ const result = OmMap.validate(PAGE);
69
+ expect(result.errors).toEqual([]); // on failure: structured entries, each with a `fix` instruction
70
+ });
71
+
72
+ it("manifest meaning is locked", () => {
73
+ expect(OmMap.snapshotIR(PAGE)).toMatchSnapshot();
74
+ });
75
+ ```
76
+
77
+ `snapshotIR` resolves the manifest through the real pipeline (schema, attribute resolution, accessor compilation) into JSON-safe descriptors where **accessors appear as behavioral fingerprints**. Any edit that changes what the map *means* — an expression, a filter range, layer order — shows up as a snapshot diff in code review. Refactors that don't change meaning produce no diff.
78
+
79
+ ## Tier 2b — behavioral: `mountForTest`
80
+
81
+ This is where most of your tests should live. The harness mounts your real page **headlessly**: no deck.gl instance, no canvas — but everything else runs for real, including the projection math (deck.gl's `WebMercatorViewport` is pure math, no WebGL).
82
+
83
+ ```ts
84
+ // test/dashboard.test.ts
85
+ // @vitest-environment happy-dom
86
+ import "@nika-js/onlymap";
87
+ import { mountForTest, type TestHarness } from "@nika-js/onlymap";
88
+ import { PAGE } from "./manifest";
89
+
90
+ let h: TestHarness;
91
+ afterEach(() => h?.unmount());
92
+
93
+ it("clicking a quake opens the detail popup", async () => {
94
+ h = await mountForTest(PAGE);
95
+ await h.pick({ layer: "quakes", featureId: "us7000abcd" }); // or { index: 3 }; type: "click" | "hover" | "drag"
96
+ const popup = h.map.querySelector("#detail")!;
97
+ expect(popup.getAttribute("visible")).toBe("true");
98
+ expect(popup.shadowRoot!.textContent).toContain("M 6.5"); // open shadow roots — plain DOM assertions
99
+ });
100
+
101
+ it("the magnitude filter narrows what widgets see", async () => {
102
+ h = await mountForTest(PAGE);
103
+ await h.emit("filter-layer", { layer: "quakes", field: "magnitude", range: [5, 10] });
104
+ expect(h.map.querySelector("#stats")!.shadowRoot!.textContent).toContain("12 events");
105
+ });
106
+
107
+ it("panning away empties viewport-scoped widgets", async () => {
108
+ h = await mountForTest(PAGE);
109
+ await h.setView({ center: [30, 30], zoom: 4 }); // pitch/bearing supported too
110
+ expect(h.map.querySelector("#histogram")!.shadowRoot!.textContent).toContain("0");
111
+ });
112
+ ```
113
+
114
+ The harness API: `pick` (synthetic picks fed through the exact code path real deck.gl picks take — columnar layers pick object-less by index, exactly like live), `clearSelection` (hover-off; runs the tooltip auto-hide path), `emit` (any action, same payload contract as `ctx.emit`/`data-emit`), `setView`, `layers()` (the live IR), `flush`, `unmount`. Every verb settles the library's internal batching before resolving — **you never write a sleep**.
115
+
116
+ **Remote data:** mock `fetch` and the harness waits for it via the readiness signal:
117
+
118
+ ```ts
119
+ vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => rows })));
120
+ h = await mountForTest(PAGE); // resolves after the mocked fetch settles
121
+ ```
122
+
123
+ A *failing* fetch also settles readiness (the layer is just empty) — `mountForTest` never hangs on a bad URL.
124
+
125
+ **What's real at this tier:** validation, accessor execution, `ctx.stats`/`data`/`dataInViewport`, declarative + viewport filtering, behaviors → actions, overlay anchoring/culling/interpolation (real Mercator math), widget reactivity, XSS escaping, columnar row materialization.
126
+ **What isn't:** pixels, GPU attribute recompute, basemap compositing, and CDN-loaded widgets (`vega-lite` is browser-only — assert its *data* here via `ctx.stats`, its rendering at tier 3 if at all).
127
+
128
+ ## Tier 3 — visual: Playwright
129
+
130
+ Keep this thin — two to five tests — because the logic is already covered below. The library gives you three tools that remove the usual flakiness:
131
+
132
+ 1. **`await mapEl.ready`** — resolves when the renderer initialized *and* the first reconcile ran *and* every declared `data` URL settled. Never `waitForTimeout`.
133
+ 2. **`mapEl.projectInternal([lng, lat])`** — derive click/hover pixels from the map's own projection instead of hardcoding coordinates.
134
+ 3. **Typed lookups** — `document.querySelector("om-map")` is fully typed (no casts) once the library is imported anywhere in your typechecked graph.
135
+
136
+ ```ts
137
+ // tier-3 Playwright spec (see package.json test:e2e)
138
+ import { test, expect } from "@playwright/test";
139
+
140
+ test("dashboard renders and real picking works", async ({ page }) => {
141
+ await page.goto("/dashboard.html");
142
+ await page.evaluate(() => document.querySelector("om-map")!.ready);
143
+
144
+ // Derive the pixel from data the page actually loaded — datasets change.
145
+ const target = await page.evaluate(() => {
146
+ const map = document.querySelector("om-map")!;
147
+ const quake = (map.getLayers().find((l) => l.id === "quakes")!.data as { lon: number; lat: number }[])[0];
148
+ return map.projectInternal([quake.lon, quake.lat]);
149
+ });
150
+ const box = (await page.locator("om-map").boundingBox())!;
151
+ await page.mouse.click(box.x + target![0], box.y + target![1]);
152
+
153
+ await expect(page.locator("#detail").getByText(/M \d/)).toBeVisible(); // locators pierce open shadow roots
154
+ });
155
+ ```
156
+
157
+ For more patterns — settle-by-observation screenshot polling for repaint assertions, hover-candidate loops for overlapping geometry, paint-order probes — the library's own end-to-end suite is written as the reference recipe for exactly these.
158
+
159
+ ## The one rule about distribution
160
+
161
+ **Don't re-test tier-2 facts at tier 3.** If the popup's content is asserted in the harness, the e2e test only needs to prove the click-through-real-pixels path works once. Teams that ignore this end up with a slow suite that duplicates a fast one — and then stop running the slow one.
162
+
163
+ ## Agents
164
+
165
+ Everything above applies to AI agents generating map pages, with one addition: the loop is `OmMap.validate(html)` (is it well-formed? each error carries a `fix`), then `OmMap.snapshotIR(html)` (what does it *mean*? diff against intent), then optionally `mountForTest` to verify interactions — all headless, all deterministic. See [`llms.txt`](../llms.txt).
package/llms.txt CHANGED
@@ -29,10 +29,14 @@ OnlyMapJS is NOT raw deck.gl and NOT generic HTML/JSX. The rules below are the d
29
29
 
30
30
  UI panel (legend, chart, stats) → `<om-widget>`. Rich HTML at one map location (click popup) → `<om-overlay>`. Labels/badges on many features → `<om-layer type="PopupLayer">`.
31
31
 
32
+ ## React projects
33
+
34
+ In a React codebase, do NOT render om-* elements from JSX (React and the library would contend over the same DOM). Use the first-party adapter instead: `import { OmMap, OmLayer, OmWidget, OmOverlay, useOmMap } from "@nika-js/onlymap/react"` — camelCase deck.gl props, accessors as plain JS functions (`getFillColor={d => ...}`, no expression language), interactions as `onClick`/`onHover` handlers, widget state via the `useOmMap(watchTokens)` hook. Guide: [docs/react.md](docs/react.md).
35
+
32
36
  ## Docs
33
37
 
34
38
  - [README](README.md): thesis, authoring overview, build/run commands
35
- - [Docs](docs/): consumer guides for testing, live data, stories, and 3D assets
39
+ - [Docs](docs/): consumer guides for testing, live data, stories, React, and 3D assets
36
40
  - [Examples](examples/index.html): complete reviewed manifests covering widgets, overlays, basemaps, columnar data, drawing, 3D, stories, and live-data patterns
37
41
 
38
42
  ## Verification loop