@nika-js/onlymap 0.3.4 → 0.4.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,135 @@
1
+ # External stores — Redux, MobX, Zustand, Jotai
2
+
3
+ OnlyMapJS exposes runtime state as a **framework-free store contract** on `MapController`: per watch-token `{subscribe, getSnapshot}` stores that anything can consume — React's `useSyncExternalStore` (the adapter's own hooks ride it internally), a MobX `autorun`, a Redux listener, a Zustand mirror. There are deliberately **no** `@nika-js/onlymap/redux`-style binding packages: every recipe below is ~20 lines against one contract, and each is covered by an integration test in the library's own suite, run against the real store libraries.
4
+
5
+ ## The contract
6
+
7
+ ```ts
8
+ const viewport = controller.getStore("viewport");
9
+ // { subscribe(cb): unsubscribe, getSnapshot(): Readonly<ViewportSnapshot> }
10
+
11
+ viewport.getSnapshot();
12
+ // { longitude, latitude, zoom, pitch, bearing,
13
+ // bounds: [[west, south], [east, north]],
14
+ // origin: "user" | "programmatic" }
15
+ ```
16
+
17
+ Tokens and snapshot types:
18
+
19
+ | Token | Snapshot | Fires on |
20
+ |---|---|---|
21
+ | `"viewport"` | `ViewportSnapshot` (above) | every camera change |
22
+ | `"selection"` | `Selection \| null` | hover/click picks |
23
+ | `"layers"` | `LayerMetaSnapshot[]` (same shape as `ctx.layers`) | layer add/remove, visibility, filter, legend meta |
24
+ | `"data:<layerId>"` | `{ version, rows }` | that layer's data load / stream tick / poll |
25
+
26
+ Three guarantees the recipes rely on:
27
+
28
+ 1. **Cached identity.** `getSnapshot()` returns the *same object* until that token's next event. Safe for `useSyncExternalStore` (a fresh object per call would render-loop) and for identity-based dirty checks in any store.
29
+ 2. **Plain data.** Snapshots contain no functions — Redux devtools, `redux-persist`, and structured cloning all work. Rich reads (`project`, `data()`, `stats()`) stay on `ctx` / the controller.
30
+ 3. **Version stamps, not rows.** `data:<id>` gives you `{version, rows}` — a change signal. Fetch actual rows with `ctx.data(id)` when the version moves; mirroring a 100k-row array into a store per stream tick is the classic performance anti-pattern.
31
+
32
+ ## Origin tagging & echo suppression
33
+
34
+ Two-way camera binding creates a feedback loop: store → `flyTo` → map moves → map event → store. Two mechanisms break it:
35
+
36
+ - **`origin`** — every viewport change is tagged `"user"` (canvas gesture: drag, wheel, keyboard, double-click zoom) or `"programmatic"` (camera APIs, actions, story steps, transition frames). Both the settled signals (`om-view-changed` on `<om-map>`, `onViewChange` on `MapController`) **and** the viewport snapshot's `origin` field report the **burst** origin: if any change since the last settle was a gesture, it's `"user"` — so a drag's inertia tail can't relabel the gesture, and the snapshot can never disagree with the settled callback about the same gesture.
37
+ - **Idempotent setters** — writing an unchanged camera back to the map is a no-op frame, and the React adapter's camera props only call `setView` when values actually differ.
38
+
39
+ Sync **map → store** on the settled signal with `origin === "user"`; drive **store → map** through the camera APIs. Neither direction can then re-trigger the other.
40
+
41
+ ## Redux Toolkit
42
+
43
+ ```ts
44
+ import { createAction, createSlice, createListenerMiddleware, configureStore } from "@reduxjs/toolkit";
45
+
46
+ const viewportSynced = createAction<{ longitude: number; latitude: number; zoom: number }>("map/viewportSynced");
47
+ const flyToRequested = createAction<{ center: [number, number]; zoom?: number }>("map/flyToRequested");
48
+
49
+ // map → redux: settled camera only (never 60fps dispatches), user-originated only.
50
+ const controller = new MapController(el, {
51
+ onViewChange: (view, origin) => {
52
+ if (origin !== "user") return; // echo suppression: your own flyTo settles as "programmatic"
53
+ store.dispatch(viewportSynced({ longitude: view.longitude, latitude: view.latitude, zoom: view.zoom }));
54
+ },
55
+ });
56
+
57
+ // redux → map: intents flow through the camera API.
58
+ listener.startListening({
59
+ actionCreator: flyToRequested,
60
+ effect: (action) => controller.flyTo(action.payload.center, action.payload.zoom),
61
+ });
62
+ ```
63
+
64
+ Store only the plain camera fields (Redux's serializability rule is why snapshots are plain data), and keep dispatches on the settled signal — Redux's own guidance warns against 60fps viewport dispatch.
65
+
66
+ ## Zustand
67
+
68
+ A mirror store — Zustand's own `useSyncExternalStore` integration does the React half:
69
+
70
+ ```ts
71
+ import { createStore } from "zustand/vanilla"; // or `create` from "zustand" in React
72
+
73
+ const useMapState = createStore(() => ({
74
+ viewport: controller.getStore("viewport").getSnapshot(),
75
+ selection: null as Selection | null,
76
+ }));
77
+ controller.getStore("viewport").subscribe(() =>
78
+ useMapState.setState({ viewport: controller.getStore("viewport").getSnapshot() }));
79
+ controller.getStore("selection").subscribe(() =>
80
+ useMapState.setState({ selection: controller.getStore("selection").getSnapshot() }));
81
+
82
+ // any component: const zoom = useMapState(s => s.viewport.zoom)
83
+ ```
84
+
85
+ The mirror is read-only, so there is no echo to suppress; write back via `controller.setView`/`flyTo` from your actions.
86
+
87
+ ## MobX (and mobx-keystone)
88
+
89
+ An observable mirror plus a reaction for write-back — the mirror update never re-fires the reaction because the *intent* observable (`targetCenter`) is separate from the mirrored state:
90
+
91
+ ```ts
92
+ class MapVM {
93
+ viewport = controller.getStore("viewport").getSnapshot();
94
+ targetCenter: [number, number] | null = null;
95
+ constructor() {
96
+ makeAutoObservable(this, {}, { autoBind: true });
97
+ controller.getStore("viewport").subscribe(() =>
98
+ runInAction(() => { this.viewport = controller.getStore("viewport").getSnapshot(); }));
99
+ reaction(() => this.targetCenter, (c) => c && controller.flyTo(c));
100
+ }
101
+ }
102
+ ```
103
+
104
+ mobx-keystone is the same bridge with a lifecycle-scoped subscription — return the unsubscribe from `onAttachedToRootStore` and keystone disposes it when the model detaches:
105
+
106
+ ```ts
107
+ @model("app/MapState")
108
+ class MapState extends Model({ viewport: prop<ViewportSnapshot>() }) {
109
+ onAttachedToRootStore() {
110
+ return controller.getStore("viewport").subscribe(() =>
111
+ this.setViewport(controller.getStore("viewport").getSnapshot()));
112
+ }
113
+ @modelAction setViewport(v: ViewportSnapshot) { this.viewport = v; }
114
+ }
115
+ ```
116
+
117
+ ## Jotai
118
+
119
+ ```ts
120
+ import { atom } from "jotai";
121
+
122
+ const viewportAtom = atom(controller.getStore("viewport").getSnapshot());
123
+ viewportAtom.onMount = (set) =>
124
+ controller.getStore("viewport").subscribe(() => set(controller.getStore("viewport").getSnapshot()));
125
+ ```
126
+
127
+ Caveat, documented rather than solved: jotai deliberately avoids `useSyncExternalStore` (uSES updates are always sync-urgent, incompatible with time-slicing). Updates through `set` here are transition-friendly, which also means a jotai read can briefly trail the map during a transition — for camera state that is normally what you want.
128
+
129
+ ## The HTML front-end
130
+
131
+ `getStore` lives on `MapController` — the programmatic/React lane — by design. The HTML manifest lane's subscription surface is DOM events and widget `watch` tokens: listen for `om-view-changed` (its `detail.origin` carries the same burst-origin signal) and read state from widget `ctx`. If you're embedding `<om-map>` and need full store bridging, drive the map through `MapController` instead — the two front-ends share one core.
132
+
133
+ ## React without a store
134
+
135
+ You usually don't need any of the above — `useOmMap(["viewport", "selection"])` already rides `useSyncExternalStore` over these same stores, with tearing-safe reads and stable `ctx` identity between events. Reach for an external store when map state must live alongside app state (routing, persistence, devtools, cross-page continuity).
package/docs/react.md CHANGED
@@ -88,7 +88,9 @@ Anchored HTML at a projected coordinate, culled off-screen/behind-globe, tracked
88
88
 
89
89
  ### `useOmMap(watch?)`
90
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).
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.
92
+
93
+ Reads ride React's `useSyncExternalStore` over the controller's per-token stores, so they are concurrent-rendering-safe: two components watching the same token can never observe different values in one committed frame, and the returned `ctx` keeps a stable identity until a watched token actually fires (safe to use in dependency arrays). The same stores are public — `controller.getStore(token)` — for syncing map state into Redux, MobX, Zustand, or Jotai: see [external-stores.md](external-stores.md).
92
94
 
93
95
  ## Testing
94
96
 
package/llms.txt CHANGED
@@ -15,6 +15,7 @@ OnlyMapJS is NOT raw deck.gl and NOT generic HTML/JSX. The rules below are the d
15
15
  - Always write explicit closing tags: `<om-layer ...></om-layer>`. NEVER self-close (`<om-layer ... />`) — HTML5 ignores the slash on custom elements and every following sibling silently becomes a child.
16
16
  - Attributes are kebab-case, not camelCase: `get-fill-color`, `radius-units`, `line-width-min-pixels`. Every hyphenated attribute maps to the camelCase deck.gl prop.
17
17
  - Accessors are expressions in attributes, not JS functions: `get-position="[$lon, $lat]"`, `get-radius="$population * 0.001"`. `$field` reads a datum field; the library resolves flat vs. GeoJSON shape for you — never write `d.properties.x`.
18
+ - ScatterplotLayer points need an explicit size: `radius="6" radius-units="pixels"` (constant), `get-radius="..."` (data-driven), or `radius-min-pixels="..."` (floor). Deck's default is getRadius 1 METER — sub-pixel at city zooms, the layer renders nothing visible — and validation warns when no radius source is present.
18
19
  - A literal color inside a `get-*` accessor is a string IN the expression — quote it: `get-line-color="'#ffffff'"` or an RGBA array `get-line-color="[255,255,255,200]"`. Bare `get-line-color="#ffffff"` is an expression parse error (plain attributes like `color="#dc2626"` take bare hex, accessors do not).
19
20
  - Inline event handlers (`onclick="..."`) are rejected. Use `data-emit` attributes (`<span data-emit="hide-overlay" data-target="popup1">`) or `addEventListener` inside a `<script type="om/widget">` block where `ctx` is in scope.
20
21
  - `scale()` requires an explicit `domain=`: `get-fill-color="scale($depth, sequential, ['#ffffcc','#800026'], domain=[0,700])"`. A missing domain is a validation error.
@@ -23,7 +24,7 @@ OnlyMapJS is NOT raw deck.gl and NOT generic HTML/JSX. The rules below are the d
23
24
 
24
25
  ## Element vocabulary
25
26
 
26
- - `<om-map center="[lng, lat]" zoom="11" pitch="55" bearing="20" basemap="positron">` — the root. `basemap` accepts a free preset (`liberty`, `bright`, `positron`, `dark-matter`, `voyager`, `osm` — no keys; or `maptiler-streets`/`maptiler-dataviz`/`maptiler-satellite` with `basemap-key="…"` or `OmMap.configureBasemap({ maptilerKey })`), `maplibre` (bare demo style), a style URL (e.g. a MapTiler-customized style; any scheme fetch supports, including desktop asset protocols like Tauri's, query strings fine), or `none` (standalone canvas). The attribute is LIVE — writing it switches the basemap in place (camera + layers survive); the `set-basemap {basemap}` action and `<om-widget type="basemap-switcher" options="positron dark-matter osm">` do the same. Register more via `OmMap.registerBasemap(name, { style })`. Required provider attribution renders automatically (`attribution="false"` opts out). `pitch`/`bearing` tilt the initial camera (use for 3D content). Terrain: `terrain="terrarium"` (keyless AWS DEM; also `maptiler-terrain` with a key, a raw `{z}/{x}/{y}` DEM URL + `terrain-decoder="terrarium|mapbox-rgb|<JSON>"`, or `off`) raises a 3D surface — geographic layers DRAPE onto it automatically (per-layer `terrain="drape|offset|off"` overrides; 3D-model layers sit ON it), `terrain-exaggeration` scales relief, `terrain-texture` drapes imagery; terrain REPLACES an active basemap while on (restored when off); `set-terrain` action, `terrain` watch token, `OmMap.registerTerrain(name, {...})` for more sources. Scene lighting for 3D content: `lighting="daylight|studio|flat|custom"` (absent = deck defaults) with `lighting-ambient`/`lighting-sun`/`lighting-sun-azimuth`/`lighting-sun-elevation`/`lighting-camera` overrides and `lighting-sun-date` (ISO/epoch — solar-position sun computed at the map center, wins over azimuth/elevation); attribute-backed (undoable, live-editable), story-steppable via the `set-lighting {lighting, sunAzimuth, ...}` action (`lighting="default"` removes the attribute set; a bare preset is a clean reset — stale overrides clear), native UI via `<om-widget type="lighting">` (preset radios + tuning sliders), widget watch token `lighting`. GeoJsonLayer extrudes polygons declaratively: `extruded get-elevation="$height"` (+ `wireframe`). `validate` attribute enables live validation + on-page error panel. Include a `map-id="<random UUID>"` on complete pages (identifies the map artifact for usage telemetry — not a visitor id; the page author deletes it to opt out); `telemetry="off"` disables usage telemetry + library-error reporting for the map (global: `OmMap.configureTelemetry({ disabled: true })`; schema: docs/telemetry.md). Free plan limits: 5 layers, 25k rows per layer — layers past a limit don't render and validation says why; a `license-key="om_live_…"` attribute (publishable, origin-restricted) or `OmMap.configureLicense(key)` lifts limits and removes the corner badge. When a user's map exceeds free limits, say so and point at the validation message rather than silently restructuring their data. Events on the element: `om-map-ready` (boot complete; `await mapEl.ready` is the promise twin), `om-validation-error`, `om-view-changed` (camera settled after a move, debounced; `detail = {longitude, latitude, zoom, pitch, bearing}` — the camera-persistence hook). `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.
27
+ - `<om-map center="[lng, lat]" zoom="11" pitch="55" bearing="20" basemap="positron">` — the root. `basemap` accepts a free preset (`liberty`, `bright`, `positron`, `dark-matter`, `voyager`, `osm` — no keys; or `maptiler-streets`/`maptiler-dataviz`/`maptiler-satellite` with `basemap-key="…"` or `OmMap.configureBasemap({ maptilerKey })`), `maplibre` (bare demo style), a style URL (e.g. a MapTiler-customized style; any scheme fetch supports, including desktop asset protocols like Tauri's, query strings fine), or `none` (standalone canvas). The attribute is LIVE — writing it switches the basemap in place (camera + layers survive); the `set-basemap {basemap}` action and `<om-widget type="basemap-switcher" options="positron dark-matter osm">` do the same. Register more via `OmMap.registerBasemap(name, { style })`. Required provider attribution renders automatically (`attribution="false"` opts out). `pitch`/`bearing` tilt the initial camera (use for 3D content). Terrain: `terrain="terrarium"` (keyless AWS DEM; also `maptiler-terrain` with a key, a raw `{z}/{x}/{y}` DEM URL + `terrain-decoder="terrarium|mapbox-rgb|<JSON>"`, or `off`) raises a 3D surface — geographic layers DRAPE onto it automatically (per-layer `terrain="drape|offset|off"` overrides; 3D-model layers sit ON it), `terrain-exaggeration` scales relief, `terrain-texture` drapes imagery; terrain REPLACES an active basemap while on (restored when off); `set-terrain` action, `terrain` watch token, `OmMap.registerTerrain(name, {...})` for more sources. Scene lighting for 3D content: `lighting="daylight|studio|flat|custom"` (absent = deck defaults) with `lighting-ambient`/`lighting-sun`/`lighting-sun-azimuth`/`lighting-sun-elevation`/`lighting-camera` overrides and `lighting-sun-date` (ISO/epoch — solar-position sun computed at the map center, wins over azimuth/elevation); attribute-backed (undoable, live-editable), story-steppable via the `set-lighting {lighting, sunAzimuth, ...}` action (`lighting="default"` removes the attribute set; a bare preset is a clean reset — stale overrides clear), native UI via `<om-widget type="lighting">` (preset radios + tuning sliders), widget watch token `lighting`. GeoJsonLayer extrudes polygons declaratively: `extruded get-elevation="$height"` (+ `wireframe`). `validate` attribute enables live validation + on-page error panel. Include a `map-id="<random UUID>"` on complete pages (identifies the map artifact for usage telemetry — not a visitor id; the page author deletes it to opt out); `telemetry="off"` disables usage telemetry + library-error reporting for the map (global: `OmMap.configureTelemetry({ disabled: true })`; schema: docs/telemetry.md). Free plan limits: 5 layers, 25k rows per layer — layers past a limit don't render and validation says why; a `license-key="om_live_…"` attribute (publishable, origin-restricted) or `OmMap.configureLicense(key)` lifts limits and removes the corner badge. When a user's map exceeds free limits, say so and point at the validation message rather than silently restructuring their data. Events on the element: `om-map-ready` (boot complete; `await mapEl.ready` is the promise twin), `om-validation-error`, `om-view-changed` (camera settled after a move, debounced; `detail = {longitude, latitude, zoom, pitch, bearing, origin}` where `origin` is `"user"` for gesture bursts vs `"programmatic"` for API/story moves — the camera-persistence hook, and the echo-suppression signal when syncing camera state to an app store). `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.
27
28
  - `<om-layer id="..." type="ScatterplotLayer" data="./points.json">` — any deck.gl layer class by `type` (all 33 bundled, plus the native `COGLayer` raster type), plus `PopupLayer` (WebGL badges/labels at scale: `layout="badge|pin-label|card"`, `min-zoom`/`max-zoom`). Every type's full attribute list ships in the package's `onlymapjs.html-data.json` — consult it instead of guessing attribute names or reading the minified dist. TextLayer's default font atlas covers ASCII only: set `character-set` when label text carries other glyphs (`—`, `·`, accents) or deck warns and renders them blank. External layer classes register via `OmMap.registerLayer({type, deckClass, props})` — build them on `@nika-js/onlymap/deck` (the bundled `CompositeLayer`/`TileLayer`/… re-exports), never a separately-installed deck.gl (different class hierarchy, breaks in the renderer); function-valued props ride the subclass's `static defaultProps`; register at module top level BEFORE the manifest mounts (see docs/custom-layers.md). Data: `data` URL (JSON, GeoJSON, CSV/TSV `.csv` — parsed to typed columns — Shapefile `.shp` (+`.dbf` attributes) and KML `.kml` as GeoJSON features, 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). Authenticated endpoints: call `OmMap.configureData({ headers: {...} })` in a script — never put tokens in attributes. `$field` accessors work identically on all of them — never write column-index code yourself. One columnar restriction: the `js` full-JS opt-in is not allowed on Arrow/columnar layers (validation will tell you; use `$field` accessors instead). For 3D models use `type="ScenegraphLayer"` with `scenegraph="./model.glb"` (required) and `get-orientation="[0, $heading, 90]"` — the roll of 90 stands Y-up glTF models upright; see docs/3d-assets.md. GeoTIFF/COG rasters use `type="COGLayer"` with `src="./dem.tif"` (NOT `data` — rasters stream tiles by Range request, they are not parsed rows): `min`/`max` set the rescale window (default 0–255; ALWAYS set them for float/16-bit data like DEMs), `colormap` picks a bundled ramp for single-band sources (gray, viridis, plasma, inferno, magma, cividis, rdylgn, rdbu, spectral, terrain, jet, turbo), `nodata` overrides the source sentinel (renders transparent); plain 8-bit RGB COGs need no styling attributes; restretch/recolor are GPU uniforms (no refetch) and the legend ramp derives from colormap+min/max automatically. Sources must be Cloud-Optimized (`gdal_translate -of COG` otherwise).
28
29
  - `<om-widget type="legend|layer-switcher|basemap-switcher|lighting|zoom-controls|undo-redo|scale-bar|attribution|filter|vega-lite" position="bottom-right">` — static UI panels. 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. 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.
29
30
  - `<om-overlay id="..." anchor-from="selection">` — rich geo-anchored HTML (≤ ~20 per map). Anchors: `anchor="[lng, lat]"` (static), `anchor-from="selection"` (follows picks), or `anchor-layer="regions" anchor-feature-id="mission"` (anchored to a feature's own geometry — bbox center — no coordinates in markup; `{{field}}` interpolates that feature's attributes). `{{field}}` interpolates the picked feature HTML-escaped; `{{{field}}}` is raw (avoid). For labels on many features use `PopupLayer`, not overlays.
@@ -40,7 +41,7 @@ UI panel (legend, chart, stats) → `<om-widget>`. Rich HTML at one map location
40
41
 
41
42
  ## React projects
42
43
 
43
- 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).
44
+ 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 (tearing-safe: it rides `useSyncExternalStore` over the controller's per-token stores). To sync map state into Redux/MobX/Zustand/Jotai, use `controller.getStore(token)` — a framework-free `{subscribe, getSnapshot}` per watch token with cached plain-data snapshots and `origin: "user"|"programmatic"` tagging for echo-free two-way camera binding; ~20-line recipes: [docs/external-stores.md](docs/external-stores.md). Guide: [docs/react.md](docs/react.md).
44
45
 
45
46
  ## Docs
46
47
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nika-js/onlymap",
3
- "version": "0.3.4",
4
- "description": "Declarative deck.gl maps for HTML and React \u2014 interactive WebGL mapping with GeoJSON/CSV/Arrow data, MapLibre basemaps, widgets, popups, and live streams from a custom-element manifest or typed React components. TypeScript, no build step.",
3
+ "version": "0.4.0",
4
+ "description": "Declarative deck.gl maps for HTML and React interactive WebGL mapping with GeoJSON/CSV/Arrow data, MapLibre basemaps, widgets, popups, and live streams from a custom-element manifest or typed React components. TypeScript, no build step.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -120,7 +120,7 @@
120
120
  "dev:telemetry": "wrangler dev --config cloud/workers/telemetry/wrangler.toml",
121
121
  "gen:licenses": "node dev/gen-third-party-licenses.mjs"
122
122
  },
123
- "comment:deps": "This library ships a fully self-contained bundle in dist/ (verified: zero external bare imports), so it has NO runtime dependencies \u2014 deck.gl, loaders.gl, MapLibre, Arrow, d3, acorn, etc. are build-time-only and get bundled by Vite. Keeping them here (not in dependencies) is what stops every consumer install from pulling ~250 MB it never uses.",
123
+ "comment:deps": "This library ships a fully self-contained bundle in dist/ (verified: zero external bare imports), so it has NO runtime dependencies deck.gl, loaders.gl, MapLibre, Arrow, d3, acorn, etc. are build-time-only and get bundled by Vite. Keeping them here (not in dependencies) is what stops every consumer install from pulling ~250 MB it never uses.",
124
124
  "devDependencies": {
125
125
  "@deck.gl/aggregation-layers": "9.3.5",
126
126
  "@deck.gl/core": "9.3.5",
@@ -134,6 +134,7 @@
134
134
  "@loaders.gl/kml": "^4.4.3",
135
135
  "@loaders.gl/shapefile": "^4.4.3",
136
136
  "@playwright/test": "^1.61.1",
137
+ "@reduxjs/toolkit": "^2.12.0",
137
138
  "@types/d3-array": "^3.2.2",
138
139
  "@types/d3-color": "^3.1.3",
139
140
  "@types/d3-interpolate": "^3.0.4",
@@ -150,7 +151,9 @@
150
151
  "d3-scale": "^4.0.2",
151
152
  "fzstd": "^0.1.1",
152
153
  "happy-dom": "^20.10.6",
154
+ "jotai": "^2.20.2",
153
155
  "maplibre-gl": "^5.24.0",
156
+ "mobx": "^6.16.1",
154
157
  "playwright": "^1.61.1",
155
158
  "react": "^19.2.7",
156
159
  "react-dom": "^19.2.7",
@@ -159,7 +162,8 @@
159
162
  "vite-node": "^6.0.0",
160
163
  "vitest": "^4.1.9",
161
164
  "wrangler": "^4.110.0",
162
- "ws": "^8.21.0"
165
+ "ws": "^8.21.0",
166
+ "zustand": "^5.0.14"
163
167
  },
164
- "comment:deck-pin": "deck.gl/luma.gl devDeps are EXACT pins: they get BUNDLED into dist, and both libraries hard-throw on duplicate-version detection \u2014 consumers that also ship their own deck.gl (nika-agent) must match these versions exactly, so bumps are breaking-coordination events, never side effects of a reinstall."
165
- }
168
+ "comment:deck-pin": "deck.gl/luma.gl devDeps are EXACT pins: they get BUNDLED into dist, and both libraries hard-throw on duplicate-version detection consumers that also ship their own deck.gl (nika-agent) must match these versions exactly, so bumps are breaking-coordination events, never side effects of a reinstall."
169
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: onlymapjs
3
- description: Build, edit, debug, or review OnlyMapJS declarative HTML maps and dashboards, or React maps via the @nika-js/onlymap/react adapter. Use when a user asks for an interactive map, deck.gl-style visualization, geospatial dashboard, live fleet/telemetry map, choropleth, popup/tooltip map, map story/tour, manual drawing/sketch map, 3D map assets, a React map component, a map page shared as a single HTML file (incl. no-JS fallbacks for chat/email previews), or help with OnlyMapJS syntax, validation, widgets, data formats, testing, or publishing examples.
3
+ description: Build, edit, debug, or review OnlyMapJS declarative HTML maps and dashboards, or React maps via the @nika-js/onlymap/react adapter. Use when a user asks for an interactive map, deck.gl-style visualization, geospatial dashboard, live fleet/telemetry map, choropleth, popup/tooltip map, map story/tour, manual drawing/sketch map, 3D map assets, a React map component, a map page shared as a single HTML file (incl. no-JS fallbacks for chat/email previews), syncing OnlyMapJS map/camera state into an app state store (Redux, MobX, Zustand, Jotai — the getStore contract), or help with OnlyMapJS syntax, validation, widgets, data formats, testing, or publishing examples.
4
4
  ---
5
5
 
6
6
  # OnlyMapJS
@@ -23,7 +23,7 @@ Use OnlyMapJS as a declarative HTML map library. Write custom elements such as `
23
23
  </script>
24
24
  ```
25
25
 
26
- For no-build CDN pages, use the single-file standalone bundle from a raw-file CDN — `https://unpkg.com/@nika-js/onlymap@0.3.4` (the bare package URL serves `dist/onlymap.standalone.js`) — plus `<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.3.4/dist/onlymapjs.css">`. Never a rebundling CDN (esm.sh, skypack): re-bundling duplicates the deck.gl/luma.gl runtime and every layer fails shader compilation.
26
+ For no-build CDN pages, use the single-file standalone bundle from a raw-file CDN — `https://unpkg.com/@nika-js/onlymap@0.4.0` (the bare package URL serves `dist/onlymap.standalone.js`) — plus `<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.4.0/dist/onlymapjs.css">`. Never a rebundling CDN (esm.sh, skypack): re-bundling duplicates the deck.gl/luma.gl runtime and every layer fails shader compilation.
27
27
 
28
28
  ## React Projects
29
29
 
@@ -51,6 +51,7 @@ Load the smallest reference needed for the task:
51
51
  - Attribute names are kebab-case: `get-fill-color`, `radius-units`, `line-width-min-pixels`.
52
52
  - Accessor values are expressions: `get-position="[$lon, $lat]"`.
53
53
  - `scale()` always needs an explicit `domain=`.
54
+ - ScatterplotLayer points need an explicit size — `radius="6" radius-units="pixels"`, `get-radius="..."`, or `radius-min-pixels="..."`: deck's default is 1 METER, sub-pixel at city zooms, and validation warns on layers with no radius source.
54
55
  - Prefer canonical color expressions — a `sequential`/`diverging`/`threshold` `scale()` or an equality ternary chain — over hand-rolled arithmetic: the legend widget parses these shapes and renders a matching gradient ramp / class ranges / category palette automatically.
55
56
  - Inline handlers such as `onclick` are wrong. Use `data-emit`, `<om-behavior>`, or widget scripts.
56
57
  - Full JavaScript accessor blocks require the `js` attribute on `<om-layer>`.
@@ -52,7 +52,8 @@ function StatsPanel({ onToggle }) {
52
52
  - **`<OmLayer>`** — `id` + `type` (any registered deck.gl layer type) + deck props. `data`: stable inline reference or URL string (full Data Layer: CSV/Arrow/Shapefile/KML formats, `ws(s)://` streams via `source`/`streamKey`/`flush`, `refresh` polling). `label`/`color` feed `ctx.layers`; `filterField`/`filterRange` = GPU filter; `onClick`/`onHover` receive the flattened picked object (`onHover(null)` = pointer left).
53
53
  - **`<OmWidget>`** — positioning shell: `position="top-left|top-right|bottom-left|bottom-right"` + arbitrary JSX. Widgets sharing a corner stack.
54
54
  - **`<OmOverlay>`** — geo-anchored HTML with managed projection/tracking/culling. `anchor={[lng, lat]}` or `anchorFrom="selection"` (+ `layer` to scope which picks move it); children may be `(selection) => JSX`; `anchorOffset` (default `bottom-center`); `interactive={false}` for hover-following tooltips.
55
- - **`useOmMap(watch?)`** — the same `ctx` contract HTML widget scripts get, typed: `layers`, `viewport`, `selection`, `emit`, `data()`, `dataInViewport()`, `stats()`. Watch tokens: `"viewport"`, `"selection"`, `"layers"`, `"data:<layerId>"`.
55
+ - **`useOmMap(watch?)`** — the same `ctx` contract HTML widget scripts get, typed: `layers`, `viewport`, `selection`, `emit`, `data()`, `dataInViewport()`, `stats()`. Watch tokens: `"viewport"`, `"selection"`, `"layers"`, `"data:<layerId>"`. Rides `useSyncExternalStore` (tearing-safe; `ctx` identity stable until a watched token fires).
56
+ - **`controller.getStore(token)`** — framework-free `{subscribe, getSnapshot}` per watch token (cached plain-data snapshots; viewport snapshots carry `origin: "user"|"programmatic"` for echo-free two-way binding). Use it to sync map state into Redux/MobX/Zustand/Jotai — recipes in docs/external-stores.md; never mirror row arrays into a store (use the `data:<id>` version stamp + `ctx.data()`).
56
57
 
57
58
  ## Testing (no browser, no GPU)
58
59
 
@@ -16,8 +16,8 @@ Vite/npm project:
16
16
  Static CDN page (raw-file CDNs only — unpkg/jsDelivr; never esm.sh or another rebundling CDN, which duplicates the WebGL runtime and breaks layer shaders):
17
17
 
18
18
  ```html
19
- <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.3.4/dist/onlymapjs.css">
20
- <script type="module" src="https://unpkg.com/@nika-js/onlymap@0.3.4"></script>
19
+ <link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.3.5/dist/onlymapjs.css">
20
+ <script type="module" src="https://unpkg.com/@nika-js/onlymap@0.3.5"></script>
21
21
  ```
22
22
 
23
23
  Always include `onlymapjs.css` — it carries the MapLibre basemap styles and the no-JS fallback rules (`<om-fallback>` / default banner). For the fallback to work in script-disabled previews it must load without JavaScript: a real `<link rel="stylesheet">` or inlined `<style>` on no-build pages (a bundler-emitted stylesheet is fine in npm projects).