@nika-js/onlymap 0.3.5 → 0.4.1
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.
- package/README.md +5 -4
- package/dist/{LercDecode.es-C1xVetIa.js → LercDecode.es-B35eeLUh.js} +1 -1
- package/dist/{basemap-BkoYlZXu.js → basemap--znNIrJq.js} +8 -2
- package/dist/basemap.d.ts +8 -1
- package/dist/ctx.d.ts +4 -0
- package/dist/elements/om-map.d.ts +33 -3
- package/dist/elements/om-widget.d.ts +15 -0
- package/dist/external-store.d.ts +95 -0
- package/dist/{index-i_KAvtQZ.js → index-BKiyX-jQ.js} +10349 -10051
- package/dist/{index-CY7o8j7i.js → index-C8twrrDP.js} +2 -2
- package/dist/{index-Ck0N90Ri.js → index-CHmCCmPV.js} +1 -1
- package/dist/{index-BJvRiLIi.js → index-ClqgDar6.js} +1 -1
- package/dist/{index-DAx8ax6u.js → index-_K3U-Ww1.js} +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/{lerc-DWGhMWvo.js → lerc-filCwJiM.js} +2 -2
- package/dist/onlymap.standalone.js +14343 -14039
- package/dist/onlymapjs.js +57 -48
- package/dist/programmatic.d.ts +42 -5
- package/dist/{raster-Da1auFQH.js → raster-xUPg8Z-C.js} +2 -2
- package/dist/react/context.d.ts +5 -3
- package/dist/react/om-map.d.ts +2 -0
- package/dist/react/om-widget.d.ts +4 -1
- package/dist/react.js +216 -186
- package/dist/runtime-core.d.ts +9 -2
- package/dist/version.d.ts +1 -1
- package/dist/widget-layout.d.ts +62 -0
- package/docs/external-stores.md +135 -0
- package/docs/react.md +3 -1
- package/llms.txt +3 -3
- package/onlymapjs.html-data.json +36 -1
- package/package.json +11 -6
- package/skills/onlymapjs/SKILL.md +2 -2
- package/skills/onlymapjs/references/react.md +4 -3
- package/skills/onlymapjs/references/syntax.md +1 -1
|
@@ -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
|
|
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
|
@@ -24,9 +24,9 @@ OnlyMapJS is NOT raw deck.gl and NOT generic HTML/JSX. The rules below are the d
|
|
|
24
24
|
|
|
25
25
|
## Element vocabulary
|
|
26
26
|
|
|
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}` — 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.
|
|
28
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).
|
|
29
|
-
- `<om-widget type="legend|layer-switcher|basemap-switcher|lighting|zoom-controls|undo-redo|scale-bar|attribution|filter|vega-lite" position="bottom-
|
|
29
|
+
- `<om-widget type="legend|layer-switcher|basemap-switcher|lighting|zoom-controls|undo-redo|scale-bar|attribution|filter|vega-lite" 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); `order="1"` orders within a slot; `position="manual"` renders a plain block you place with your own CSS (even outside the map). 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/-y/-opacity/-radius` 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. 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.
|
|
30
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.
|
|
31
31
|
- `<om-behavior on="click|hover|drag|load|data-loaded" layer="..." action="...">` — declarative interaction. Built-in actions: `show-overlay`, `hide-overlay`, `show-tooltip`, `hide-tooltip`, `toggle-layer`, `filter-layer`, `highlight-feature`, `zoom-to-feature`, `set-basemap`, `undo`, `redo`. One payload contract everywhere: `{ layer, target, feature, featureId, coordinate }`.
|
|
32
32
|
- 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")`.
|
|
@@ -41,7 +41,7 @@ UI panel (legend, chart, stats) → `<om-widget>`. Rich HTML at one map location
|
|
|
41
41
|
|
|
42
42
|
## React projects
|
|
43
43
|
|
|
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. 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).
|
|
45
45
|
|
|
46
46
|
## Docs
|
|
47
47
|
|
package/onlymapjs.html-data.json
CHANGED
|
@@ -154,6 +154,10 @@
|
|
|
154
154
|
"name": "terrain-texture",
|
|
155
155
|
"description": "Optional {z}/{x}/{y} imagery template draped over the surface (satellite, etc.)."
|
|
156
156
|
},
|
|
157
|
+
{
|
|
158
|
+
"name": "widget-style",
|
|
159
|
+
"description": "Layout-token sugar: space-separated key:number pairs → --om-widget-* custom properties (e.g. \"gap:10 opacity:0.9\"). Keys: inset, gap (shorthands), inset-x/-y, gap-x/-y, opacity, radius, size. Numbers are px except opacity."
|
|
160
|
+
},
|
|
157
161
|
{
|
|
158
162
|
"name": "validate",
|
|
159
163
|
"description": "Run manifest validation and show the on-page error panel."
|
|
@@ -1644,8 +1648,35 @@
|
|
|
1644
1648
|
},
|
|
1645
1649
|
{
|
|
1646
1650
|
"name": "position",
|
|
1647
|
-
"description": "
|
|
1651
|
+
"description": "Managed slot (8 logical, RTL-aware: top/center/bottom × start/center/end minus center-center), a legacy corner alias (top-left, …), or \"manual\" (author-styled placement — plain block, no forced positioning). Same-slot widgets stack with flush edges and a shared gap.",
|
|
1648
1652
|
"values": [
|
|
1653
|
+
{
|
|
1654
|
+
"name": "top-start"
|
|
1655
|
+
},
|
|
1656
|
+
{
|
|
1657
|
+
"name": "top-center"
|
|
1658
|
+
},
|
|
1659
|
+
{
|
|
1660
|
+
"name": "top-end"
|
|
1661
|
+
},
|
|
1662
|
+
{
|
|
1663
|
+
"name": "center-start"
|
|
1664
|
+
},
|
|
1665
|
+
{
|
|
1666
|
+
"name": "center-end"
|
|
1667
|
+
},
|
|
1668
|
+
{
|
|
1669
|
+
"name": "bottom-start"
|
|
1670
|
+
},
|
|
1671
|
+
{
|
|
1672
|
+
"name": "bottom-center"
|
|
1673
|
+
},
|
|
1674
|
+
{
|
|
1675
|
+
"name": "bottom-end"
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
"name": "manual"
|
|
1679
|
+
},
|
|
1649
1680
|
{
|
|
1650
1681
|
"name": "top-left"
|
|
1651
1682
|
},
|
|
@@ -1660,6 +1691,10 @@
|
|
|
1660
1691
|
}
|
|
1661
1692
|
]
|
|
1662
1693
|
},
|
|
1694
|
+
{
|
|
1695
|
+
"name": "order",
|
|
1696
|
+
"description": "Deterministic in-slot ordering (flex order; lower renders first). Default: DOM order."
|
|
1697
|
+
},
|
|
1663
1698
|
{
|
|
1664
1699
|
"name": "watch",
|
|
1665
1700
|
"description": "Space-separated watch tokens: viewport, selection, layers, history, basemap, lighting, terrain, data:<layerId>."
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nika-js/onlymap",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Declarative deck.gl maps for HTML and React
|
|
3
|
+
"version": "0.4.1",
|
|
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"
|
|
@@ -113,6 +113,7 @@
|
|
|
113
113
|
"prepublishOnly": "npm run build",
|
|
114
114
|
"try": "vite-node dev/expr-repl.mjs",
|
|
115
115
|
"test:e2e": "playwright test",
|
|
116
|
+
"check-layout": "playwright test e2e/layout-audit.spec.ts",
|
|
116
117
|
"gen:html-data": "vite-node dev/generate-html-data.ts",
|
|
117
118
|
"gen:public": "npm run gen:html-data && vite-node dev/build-public.ts",
|
|
118
119
|
"test:public": "npm run gen:public -- --dry-run --strict",
|
|
@@ -120,7 +121,7 @@
|
|
|
120
121
|
"dev:telemetry": "wrangler dev --config cloud/workers/telemetry/wrangler.toml",
|
|
121
122
|
"gen:licenses": "node dev/gen-third-party-licenses.mjs"
|
|
122
123
|
},
|
|
123
|
-
"comment:deps": "This library ships a fully self-contained bundle in dist/ (verified: zero external bare imports), so it has NO runtime dependencies
|
|
124
|
+
"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
125
|
"devDependencies": {
|
|
125
126
|
"@deck.gl/aggregation-layers": "9.3.5",
|
|
126
127
|
"@deck.gl/core": "9.3.5",
|
|
@@ -134,6 +135,7 @@
|
|
|
134
135
|
"@loaders.gl/kml": "^4.4.3",
|
|
135
136
|
"@loaders.gl/shapefile": "^4.4.3",
|
|
136
137
|
"@playwright/test": "^1.61.1",
|
|
138
|
+
"@reduxjs/toolkit": "^2.12.0",
|
|
137
139
|
"@types/d3-array": "^3.2.2",
|
|
138
140
|
"@types/d3-color": "^3.1.3",
|
|
139
141
|
"@types/d3-interpolate": "^3.0.4",
|
|
@@ -150,7 +152,9 @@
|
|
|
150
152
|
"d3-scale": "^4.0.2",
|
|
151
153
|
"fzstd": "^0.1.1",
|
|
152
154
|
"happy-dom": "^20.10.6",
|
|
155
|
+
"jotai": "^2.20.2",
|
|
153
156
|
"maplibre-gl": "^5.24.0",
|
|
157
|
+
"mobx": "^6.16.1",
|
|
154
158
|
"playwright": "^1.61.1",
|
|
155
159
|
"react": "^19.2.7",
|
|
156
160
|
"react-dom": "^19.2.7",
|
|
@@ -159,7 +163,8 @@
|
|
|
159
163
|
"vite-node": "^6.0.0",
|
|
160
164
|
"vitest": "^4.1.9",
|
|
161
165
|
"wrangler": "^4.110.0",
|
|
162
|
-
"ws": "^8.21.0"
|
|
166
|
+
"ws": "^8.21.0",
|
|
167
|
+
"zustand": "^5.0.14"
|
|
163
168
|
},
|
|
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
|
|
165
|
-
}
|
|
169
|
+
"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."
|
|
170
|
+
}
|
|
@@ -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.
|
|
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.1` (the bare package URL serves `dist/onlymap.standalone.js`) — plus `<link rel="stylesheet" href="https://unpkg.com/@nika-js/onlymap@0.4.1/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
|
|
|
@@ -48,11 +48,12 @@ function StatsPanel({ onToggle }) {
|
|
|
48
48
|
|
|
49
49
|
## Component surface
|
|
50
50
|
|
|
51
|
-
- **`<OmMap>`** — `center`/`zoom`/`pitch`/`bearing` (initial; later changes move the camera, unchanged props never fight user panning), `basemap`, `headless`, `onReady`, `onViewStateChange`, `onRuntimeError`. Give it a size via `style`/`className`. `ref` exposes the imperative `MapController` handle: `flyTo`, `setView`, `emit`, `getLayers`, `getSelection`, `injectPick`, `ready` (promise), `project`.
|
|
51
|
+
- **`<OmMap>`** — `center`/`zoom`/`pitch`/`bearing` (initial; later changes move the camera, unchanged props never fight user panning), `basemap`, `headless`, `widgetStyle` (layout-token sugar, the `widget-style` attribute's twin: `"gap:10 opacity:0.9"` → `--om-widget-*` custom properties), `onReady`, `onViewStateChange`, `onRuntimeError`. Give it a size via `style`/`className`. `ref` exposes the imperative `MapController` handle: `flyTo`, `setView`, `emit`, `getLayers`, `getSelection`, `injectPick`, `ready` (promise), `project`.
|
|
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
|
-
- **`<OmWidget>`** — positioning shell: `position
|
|
53
|
+
- **`<OmWidget>`** — positioning shell: `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) + arbitrary JSX. Same-slot widgets stack with flush edges and a shared gap; `order={1}` sets deterministic in-slot ordering. `position="manual"` renders a plain block at the JSX site — note it sits inside OmMap's overflow-hidden box, so for UI OUTSIDE the map render your own element next to `<OmMap>` and drive the map via `useOmMap()`/the ref instead.
|
|
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
|
|
|
@@ -213,7 +213,7 @@ Built-ins:
|
|
|
213
213
|
- `lighting` — scene-lighting controller: preset radios (Off/daylight/studio/flat/custom) + ambient/sun/azimuth/elevation/camera sliders, all over the lighting* attributes via `set-lighting` (undoable; re-syncs when anything else writes them). A bare preset click is a clean RESET (stale lighting-* overrides removed); a slider edit flips to `custom` and sets only the touched key.
|
|
214
214
|
- `undo-redo` — undo/redo buttons over the manifest history (layer toggles, filters, basemap switches, element edits, drawn sketches). Keyboard works without the widget: Cmd/Ctrl-Z, Shift-Cmd/Ctrl-Z, Ctrl-Y. Camera moves, hover effects, and story playback are not undo steps.
|
|
215
215
|
|
|
216
|
-
Positions: `top-left`, `top-right`, `bottom-left`, `bottom-right
|
|
216
|
+
Positions — 8 managed slots (logical, RTL-aware): `top-start`, `top-center`, `top-end`, `center-start`, `center-end`, `bottom-start`, `bottom-center`, `bottom-end`. Legacy corner names (`top-left`, `top-right`, `bottom-left`, `bottom-right`) are aliases. Same-slot widgets stack in one library-owned flex container: flush edges, shared gap — never overlapping. `order="1"` sets deterministic in-slot ordering (default: DOM order). `position="manual"` opts out of management: the widget renders as a plain block you place with your own CSS (even outside the map, e.g. in an app header, driving the map through actions). Layout tokens: `--om-widget-inset-x/-y` (slot inset, default 12px), `--om-widget-gap-x/-y` (stack gap, default 8px), `--om-widget-opacity`, `--om-widget-radius` — or the no-CSS sugar attribute `<om-map widget-style="gap:10 opacity:0.9 inset:16">` (keys: inset, gap, inset-x/-y, gap-x/-y, opacity, radius, size; numbers are px except opacity).
|
|
217
217
|
|
|
218
218
|
Theming: built-in widgets read `--om-widget-*` CSS custom properties, which inherit through their shadow roots — so plain page CSS themes them, no JS:
|
|
219
219
|
|