@nika-js/onlymap 0.1.1 → 0.2.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/.vscode/onlymap.code-snippets +4 -4
- package/README.md +28 -6
- package/dist/attribute-resolution.d.ts +39 -0
- package/dist/{basemap-D95W0IIA.js → basemap-COurZNDH.js} +2126 -2105
- package/dist/basemap-registry.d.ts +49 -0
- package/dist/basemap.d.ts +22 -2
- package/dist/html-data.d.ts +2 -2
- package/dist/{index-Do7hmHwi.js → index-COu-3-gN.js} +1 -1
- package/dist/{index-D74olQ9w.js → index-CgyAD98B.js} +1 -1
- package/dist/{index-CU-iOTdr.js → index-CvfuISOc.js} +14602 -14137
- package/dist/{index-C9w78NS9.js → index-D-X8KPA1.js} +1 -1
- package/dist/{index-BFjXVHly.js → index-DuvXK95V.js} +2 -2
- package/dist/index.d.ts +10 -0
- package/dist/ir-diff.d.ts +16 -0
- package/dist/onlymapjs.js +29 -24
- package/dist/onlymapjs.umd.cjs +265 -259
- package/dist/programmatic.d.ts +186 -0
- package/dist/react/context.d.ts +25 -0
- package/dist/react/index.d.ts +20 -0
- package/dist/react/om-layer.d.ts +38 -0
- package/dist/react/om-map.d.ts +33 -0
- package/dist/react/om-overlay.d.ts +27 -0
- package/dist/react/om-widget.d.ts +9 -0
- package/dist/react/use-om-map.d.ts +2 -0
- package/dist/react.js +280 -0
- package/dist/runtime-core.d.ts +34 -1
- package/dist/widget-registry.d.ts +2 -0
- package/docs/3d-assets.md +74 -0
- package/docs/basemaps.md +89 -0
- package/docs/live-data.md +83 -0
- package/docs/react.md +120 -0
- package/docs/stories.md +134 -0
- package/docs/testing.md +165 -0
- package/llms.txt +8 -4
- package/onlymapjs.html-data.json +57 -1
- package/package.json +24 -2
- package/skills/onlymapjs/SKILL.md +15 -3
- package/skills/onlymapjs/references/patterns.md +2 -2
- package/skills/onlymapjs/references/react.md +68 -0
- package/skills/onlymapjs/references/syntax.md +6 -2
package/docs/testing.md
ADDED
|
@@ -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
|
@@ -16,11 +16,11 @@ OnlyMapJS is NOT raw deck.gl and NOT generic HTML/JSX. The rules below are the d
|
|
|
16
16
|
|
|
17
17
|
## Element vocabulary
|
|
18
18
|
|
|
19
|
-
- `<om-map center="[lng, lat]" zoom="11" pitch="55" bearing="20" basemap="
|
|
19
|
+
- `<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), 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). `validate` attribute enables live validation + on-page error panel.
|
|
20
20
|
- `<om-layer id="..." type="ScatterplotLayer" data="./points.json">` — any deck.gl layer class by `type` (all 33 bundled), plus `PopupLayer` (WebGL badges/labels at scale: `layout="badge|pin-label|card"`, `min-zoom`/`max-zoom`). 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})`), inline `<script type="application/json">` (row arrays or column-oriented `{"columns": {"lon": [...], "lat": [...]}}`), 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.
|
|
21
|
-
- `<om-widget type="legend|layer-switcher|zoom-controls|scale-bar|attribution|filter|vega-lite" position="bottom-right">` — static UI panels. 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']`, `this.$(sel)`, `vegaEmbed`/`d3` as globals.
|
|
21
|
+
- `<om-widget type="legend|layer-switcher|basemap-switcher|zoom-controls|scale-bar|attribution|filter|vega-lite" position="bottom-right">` — static UI panels. 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']`, `this.$(sel)`, `vegaEmbed`/`d3` as globals.
|
|
22
22
|
- `<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.
|
|
23
|
-
- `<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`. One payload contract everywhere: `{ layer, target, feature, featureId, coordinate }`.
|
|
23
|
+
- `<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`. One payload contract everywhere: `{ layer, target, feature, featureId, coordinate }`.
|
|
24
24
|
- Animation: `transition="get-fill-color 800ms, get-radius 400ms"` on a layer GPU-animates prop changes (also smooths streaming updates via `get-position`). Camera: the `fly-to` action takes `center`/`zoom`/`pitch`/`bearing`/`duration` (e.g. `duration="2s"`) — use it in behaviors or `data-emit` buttons; `zoom-to-feature` also accepts `duration`.
|
|
25
25
|
- `<om-story id="tour" autoplay loop interrupt="pause|ignore">` — a storyboard of `<om-step>` children. Each step: `action="..."` + payload attributes (same kebab-case rule as behaviors) + `duration`/`delay`/`parallel` timing. Steps REFERENCE layers/overlays by id (`layer=`/`target=`) — a step must NEVER contain elements (validation error). Control: `<om-widget type="player" story="tour">`, the story-play/story-pause/story-seek actions, or `storyEl.play()/pause()/seek(ms)`. Seeking restores initial state then applies steps before T; use declarative payloads (e.g. `action="toggle-layer" visible="true"`, not bare toggles) so scrubbing is deterministic. Effect verbs as bare step attributes: `<om-step fade layer="regions" duration="1s">` (opacity reveal — start the layer at `opacity="0"`), `pulse` (attention flash), `trace` (progressive draw — whole-layer needs a TripsLayer; add `feature-id="..."` to make ONE polygon/line draw itself on inside any layer, or use it from a click behavior for click-to-trace), `populate` (rows drop in one by one — ordered by the authored filter-field, a payload `field`, or data order).
|
|
26
26
|
- Filtering: `filter-field="magnitude" filter-range="[4, 10]"` on a layer (GPU-side, live-updatable via the `filter-layer` action); pair with `<om-widget type="filter" layer="..." field="...">`.
|
|
@@ -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
|
package/onlymapjs.html-data.json
CHANGED
|
@@ -23,10 +23,53 @@
|
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"name": "basemap",
|
|
26
|
-
"description": "Basemap
|
|
26
|
+
"description": "Basemap: a registered preset name, \"maplibre\" (demo style), a style URL, or \"none\" (standalone canvas).",
|
|
27
27
|
"values": [
|
|
28
28
|
{
|
|
29
29
|
"name": "maplibre"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "none"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "liberty"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "bright"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "positron"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "dark-matter"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "voyager"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "osm"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "maptiler-streets"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "maptiler-dataviz"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "maptiler-satellite"
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"name": "basemap-key",
|
|
65
|
+
"description": "Provider API key for keyed basemap presets (maptiler-*) — publishable origin-restricted key; or call OmMap.configureBasemap once."
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "attribution",
|
|
69
|
+
"description": "Set \"false\" to opt out of the compact attribution control shown with basemaps.",
|
|
70
|
+
"values": [
|
|
71
|
+
{
|
|
72
|
+
"name": "false"
|
|
30
73
|
}
|
|
31
74
|
]
|
|
32
75
|
},
|
|
@@ -1358,6 +1401,9 @@
|
|
|
1358
1401
|
},
|
|
1359
1402
|
{
|
|
1360
1403
|
"name": "draw-save"
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
"name": "set-basemap"
|
|
1361
1407
|
}
|
|
1362
1408
|
]
|
|
1363
1409
|
},
|
|
@@ -1405,6 +1451,9 @@
|
|
|
1405
1451
|
},
|
|
1406
1452
|
{
|
|
1407
1453
|
"name": "draw"
|
|
1454
|
+
},
|
|
1455
|
+
{
|
|
1456
|
+
"name": "basemap-switcher"
|
|
1408
1457
|
}
|
|
1409
1458
|
]
|
|
1410
1459
|
},
|
|
@@ -1483,6 +1532,10 @@
|
|
|
1483
1532
|
"name": "autosave",
|
|
1484
1533
|
"description": "Draw widget: localStorage key — mirrors the sketch and restores it on reload."
|
|
1485
1534
|
},
|
|
1535
|
+
{
|
|
1536
|
+
"name": "options",
|
|
1537
|
+
"description": "Basemap-switcher: space-separated preset names to offer (default: every keyless registered preset)."
|
|
1538
|
+
},
|
|
1486
1539
|
{
|
|
1487
1540
|
"name": "title",
|
|
1488
1541
|
"description": "Widget heading."
|
|
@@ -1613,6 +1666,9 @@
|
|
|
1613
1666
|
},
|
|
1614
1667
|
{
|
|
1615
1668
|
"name": "draw-save"
|
|
1669
|
+
},
|
|
1670
|
+
{
|
|
1671
|
+
"name": "set-basemap"
|
|
1616
1672
|
}
|
|
1617
1673
|
]
|
|
1618
1674
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nika-js/onlymap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Interactive WebGL maps from declarative HTML — a custom-element manifest drives deck.gl: rendering, data loading, live updates, picking, widgets, and validation. No build step.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"publishConfig": {
|
|
@@ -45,24 +45,42 @@
|
|
|
45
45
|
"require": "./dist/onlymapjs.umd.cjs",
|
|
46
46
|
"default": "./dist/onlymapjs.js"
|
|
47
47
|
},
|
|
48
|
+
"./react": {
|
|
49
|
+
"types": "./dist/react/index.d.ts",
|
|
50
|
+
"import": "./dist/react.js",
|
|
51
|
+
"default": "./dist/react.js"
|
|
52
|
+
},
|
|
48
53
|
"./onlymapjs.css": "./dist/onlymapjs.css",
|
|
49
54
|
"./onlymapjs.html-data.json": "./onlymapjs.html-data.json",
|
|
50
55
|
"./onlymap.code-snippets": "./.vscode/onlymap.code-snippets",
|
|
51
56
|
"./package.json": "./package.json"
|
|
52
57
|
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"react": ">=18",
|
|
60
|
+
"react-dom": ">=18"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"react": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"react-dom": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
69
|
+
},
|
|
53
70
|
"files": [
|
|
54
71
|
".vscode/onlymap.code-snippets",
|
|
55
72
|
"bin/onlymapjs.mjs",
|
|
56
73
|
"dist",
|
|
57
74
|
"README.md",
|
|
58
75
|
"LICENSE.md",
|
|
76
|
+
"docs",
|
|
59
77
|
"llms.txt",
|
|
60
78
|
"onlymapjs.html-data.json",
|
|
61
79
|
"skills/onlymapjs"
|
|
62
80
|
],
|
|
63
81
|
"scripts": {
|
|
64
82
|
"dev": "vite",
|
|
65
|
-
"build": "npm run typecheck && vite build && npm run build:types",
|
|
83
|
+
"build": "npm run typecheck && vite build && vite build --config vite.react.config.ts && npm run build:types",
|
|
66
84
|
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly",
|
|
67
85
|
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
|
|
68
86
|
"test": "vitest run",
|
|
@@ -91,6 +109,8 @@
|
|
|
91
109
|
"@types/d3-interpolate": "^3.0.4",
|
|
92
110
|
"@types/d3-scale": "^4.0.9",
|
|
93
111
|
"@types/estree": "^1.0.9",
|
|
112
|
+
"@types/react": "^19.2.17",
|
|
113
|
+
"@types/react-dom": "^19.2.3",
|
|
94
114
|
"@types/ws": "^8.18.1",
|
|
95
115
|
"acorn": "^8.17.0",
|
|
96
116
|
"apache-arrow": "^21.1.0",
|
|
@@ -102,6 +122,8 @@
|
|
|
102
122
|
"happy-dom": "^20.10.6",
|
|
103
123
|
"maplibre-gl": "^5.24.0",
|
|
104
124
|
"playwright": "^1.61.1",
|
|
125
|
+
"react": "^19.2.7",
|
|
126
|
+
"react-dom": "^19.2.7",
|
|
105
127
|
"typescript": "^5.6.0",
|
|
106
128
|
"vite": "^6.0.0",
|
|
107
129
|
"vite-node": "^6.0.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: onlymapjs
|
|
3
|
-
description: Build, edit, debug, or review OnlyMapJS declarative HTML maps and dashboards. 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, 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, or help with OnlyMapJS syntax, validation, widgets, data formats, testing, or publishing examples.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# OnlyMapJS
|
|
@@ -19,11 +19,21 @@ Use OnlyMapJS as a declarative HTML map library. Write custom elements such as `
|
|
|
19
19
|
```html
|
|
20
20
|
<script type="module">
|
|
21
21
|
import "@nika-js/onlymap";
|
|
22
|
-
import "
|
|
22
|
+
import "@nika-js/onlymap/onlymapjs.css";
|
|
23
23
|
</script>
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
For no-build CDN examples, use a published module URL such as `https://esm.sh/@nika-js/onlymap@0.
|
|
26
|
+
For no-build CDN examples, use a published module URL such as `https://esm.sh/@nika-js/onlymap@0.2.0`.
|
|
27
|
+
|
|
28
|
+
## React Projects
|
|
29
|
+
|
|
30
|
+
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:
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { OmMap, OmLayer, OmWidget, OmOverlay, useOmMap } from "@nika-js/onlymap/react";
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The adapter inverts several HTML-manifest rules: props are camelCase deck.gl props, accessors are plain JS functions (`getFillColor={d => ...}` — no `$field` expression language, no `js` opt-in), and interactions are `onClick`/`onHover` handlers plus React state, not `<om-behavior>` or state-mutating actions. Load `references/react.md` before writing React map code.
|
|
27
37
|
|
|
28
38
|
## Required References
|
|
29
39
|
|
|
@@ -31,6 +41,7 @@ Load the smallest reference needed for the task:
|
|
|
31
41
|
|
|
32
42
|
- `references/syntax.md` — element vocabulary, attributes, data formats, accessors, actions, widgets, overlays, drawing, 3D, built-in layer types.
|
|
33
43
|
- `references/patterns.md` — copyable manifest patterns for common map requests.
|
|
44
|
+
- `references/react.md` — the React adapter: components, the useOmMap hook, HTML-vs-React rule differences, testing.
|
|
34
45
|
- `references/testing.md` — validation, snapshot, headless harness, and browser testing workflow.
|
|
35
46
|
|
|
36
47
|
## Non-Negotiable Syntax Rules
|
|
@@ -50,6 +61,7 @@ Load the smallest reference needed for the task:
|
|
|
50
61
|
- Sparse rich HTML at one geographic location -> `<om-overlay>`.
|
|
51
62
|
- Many labels/badges -> `<om-layer type="PopupLayer">`.
|
|
52
63
|
- Guided tour or narrative sequence -> `<om-story>` with `<om-step>` siblings that reference existing layers/overlays by id.
|
|
64
|
+
- Basemap choice or user-switchable basemaps -> `basemap` presets (`positron`, `liberty`, `dark-matter`, `osm`, ...) + `<om-widget type="basemap-switcher">`; MapTiler custom styles via a style URL or `basemap-key`.
|
|
53
65
|
- Live entity updates -> `wss://` stream with `key` and optional `source` decoder.
|
|
54
66
|
- REST snapshot that changes over time -> `refresh="5s"`.
|
|
55
67
|
- User sketching -> `data="draw:sketch"` layer plus `<om-widget type="draw" target="sketch">`.
|
|
@@ -12,7 +12,7 @@ Use these patterns as starting points. Replace data URLs, layer ids, fields, cen
|
|
|
12
12
|
<title>OnlyMapJS map</title>
|
|
13
13
|
<script type="module">
|
|
14
14
|
import "@nika-js/onlymap";
|
|
15
|
-
import "
|
|
15
|
+
import "@nika-js/onlymap/onlymapjs.css";
|
|
16
16
|
</script>
|
|
17
17
|
<style>
|
|
18
18
|
html, body { margin: 0; height: 100%; }
|
|
@@ -135,7 +135,7 @@ Use `PopupLayer` instead of many `<om-overlay>` elements for labels/badges at sc
|
|
|
135
135
|
```html
|
|
136
136
|
<script type="module">
|
|
137
137
|
import { OmMap } from "@nika-js/onlymap";
|
|
138
|
-
import "
|
|
138
|
+
import "@nika-js/onlymap/onlymapjs.css";
|
|
139
139
|
|
|
140
140
|
OmMap.registerSource("fleet", {
|
|
141
141
|
decode: (m) => m.type === "position"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# React adapter — `@nika-js/onlymap/react`
|
|
2
|
+
|
|
3
|
+
Real components over the same engine as the HTML manifest. React never renders `om-*` elements — components feed a typed programmatic front-end, so there is no DOM contention with the library's reconciler. React ≥ 18 (`npm install @nika-js/onlymap react react-dom`).
|
|
4
|
+
|
|
5
|
+
## Rules that INVERT the HTML-manifest rules
|
|
6
|
+
|
|
7
|
+
- Props are **camelCase deck.gl props** (`getFillColor`, `radiusMinPixels`), not kebab-case attributes.
|
|
8
|
+
- Accessors are **plain JS functions**: `getFillColor={d => d.mag > 6 ? [214,40,40] : [252,191,73]}`. Never use `$field` expressions or `scale()` strings here; never add a `js` attribute (there is none).
|
|
9
|
+
- Interactions are **event handlers + state**, not `<om-behavior>`. Toggle a layer by rendering `visible={false}`; filter by changing `filterRange`. The state-mutating actions (`toggle-layer`, `show-overlay`, `fade`, story/draw actions) are not dispatched on this path — a console warning points back to props. Camera actions (`fly-to`, `zoom-to-feature`, `zoom-in`/`-out`), `pulse`, `populate`, and custom `OmMap.registerAction` handlers work via `emit`.
|
|
10
|
+
- Keep inline `data` references **stable across renders** (`useMemo`/`useState`) — data reactivity diffs by reference. deck.gl ignores accessor function identity; pass `updateTriggers={{ getFillColor: theme }}` when an accessor's *output* changes.
|
|
11
|
+
- Stories and the draw widget are HTML-manifest-only for now.
|
|
12
|
+
|
|
13
|
+
## Complete example
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { OmMap, OmLayer, OmWidget, OmOverlay, useOmMap } from "@nika-js/onlymap/react";
|
|
18
|
+
|
|
19
|
+
function QuakeMap() {
|
|
20
|
+
const [visible, setVisible] = useState(true);
|
|
21
|
+
return (
|
|
22
|
+
<OmMap center={[-119, 36]} zoom={5} basemap="maplibre" style={{ height: "100vh" }}>
|
|
23
|
+
<OmLayer id="quakes" type="ScatterplotLayer" data="/quakes.csv" label="Earthquakes"
|
|
24
|
+
visible={visible} pickable
|
|
25
|
+
getPosition={(d) => [d.longitude, d.latitude]}
|
|
26
|
+
getFillColor={(d) => (d.mag >= 6 ? [214, 40, 40] : [252, 191, 73])}
|
|
27
|
+
radiusMinPixels={3}
|
|
28
|
+
onClick={(sel) => console.log(sel.object)} />
|
|
29
|
+
<OmWidget position="top-left"><StatsPanel onToggle={() => setVisible(!visible)} /></OmWidget>
|
|
30
|
+
<OmOverlay anchorFrom="selection" layer="quakes">
|
|
31
|
+
{(sel) => sel && <div className="card">M{sel.object.mag} — {sel.object.place}</div>}
|
|
32
|
+
</OmOverlay>
|
|
33
|
+
</OmMap>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function StatsPanel({ onToggle }) {
|
|
38
|
+
const ctx = useOmMap(["viewport", "data:quakes"]); // watch tokens re-render the component
|
|
39
|
+
return (
|
|
40
|
+
<div className="panel">
|
|
41
|
+
{ctx.stats("quakes", "mag").count} quakes at z{ctx.viewport.zoom.toFixed(1)}
|
|
42
|
+
<button onClick={onToggle}>Toggle</button>
|
|
43
|
+
<button onClick={() => ctx.emit("fly-to", { center: [140, 38], zoom: 4, duration: 1200 })}>Japan</button>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Component surface
|
|
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`.
|
|
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="top-left|top-right|bottom-left|bottom-right"` + arbitrary JSX. Widgets sharing a corner stack.
|
|
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>"`.
|
|
56
|
+
|
|
57
|
+
## Testing (no browser, no GPU)
|
|
58
|
+
|
|
59
|
+
`<OmMap headless>` runs real projection math under jsdom/happy-dom:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
const ref = createRef<OmMapHandle>();
|
|
63
|
+
render(<OmMap ref={ref} headless><OmLayer id="pts" type="ScatterplotLayer" data={rows} getPosition={p} /></OmMap>);
|
|
64
|
+
await ref.current!.ready;
|
|
65
|
+
ref.current!.injectPick({ layerId: "pts", object: rows[0], index: 0, coordinate: [0, 0], pixel: [0, 0], type: "click" });
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`injectPick` rides the same selection path as real GPU picks — `onClick`, `useOmMap(["selection"])`, and selection-anchored overlays all react. For browser e2e, expose the `ref` handle (e.g. on `window`) as the readiness/projection probe — a React page has no `<om-map>` element to await.
|
|
@@ -9,7 +9,7 @@ Vite/npm project:
|
|
|
9
9
|
```html
|
|
10
10
|
<script type="module">
|
|
11
11
|
import "@nika-js/onlymap";
|
|
12
|
-
import "
|
|
12
|
+
import "@nika-js/onlymap/onlymapjs.css";
|
|
13
13
|
</script>
|
|
14
14
|
```
|
|
15
15
|
|
|
@@ -33,7 +33,8 @@ Common attributes:
|
|
|
33
33
|
- `zoom="11"`
|
|
34
34
|
- `pitch="55"`
|
|
35
35
|
- `bearing="20"`
|
|
36
|
-
- `basemap
|
|
36
|
+
- `basemap` — a free preset (`liberty`, `bright`, `positron`, `dark-matter`, `voyager`, `osm`; keyed `maptiler-streets|dataviz|satellite` with `basemap-key="…"` or `OmMap.configureBasemap({ maptilerKey })`), `maplibre` (bare demo style), a MapLibre style URL, or `none` (standalone canvas). The attribute is live: writing it switches the basemap in place (camera and layers survive). Register more with `OmMap.registerBasemap(name, { style })`.
|
|
37
|
+
- `attribution="false"` to opt out of the automatic provider-attribution control (only if you render equivalent credits yourself)
|
|
37
38
|
- `validate` to show live validation errors during authoring
|
|
38
39
|
- `headless width="800" height="600"` for test harness use
|
|
39
40
|
|
|
@@ -178,6 +179,7 @@ Built-ins:
|
|
|
178
179
|
- `draw`
|
|
179
180
|
- `vega-lite`
|
|
180
181
|
- `player`
|
|
182
|
+
- `basemap-switcher` — radio list of presets; `options="positron dark-matter osm"` (default: every keyless registered preset)
|
|
181
183
|
|
|
182
184
|
Positions: `top-left`, `top-right`, `bottom-left`, `bottom-right`.
|
|
183
185
|
|
|
@@ -187,6 +189,7 @@ Examples:
|
|
|
187
189
|
<om-widget type="legend" position="bottom-right" title="Layers" interactive></om-widget>
|
|
188
190
|
<om-widget type="filter" layer="quakes" field="magnitude" position="top-left"></om-widget>
|
|
189
191
|
<om-widget type="draw" target="sketch" modes="point line polygon" save="both"></om-widget>
|
|
192
|
+
<om-widget type="basemap-switcher" options="positron dark-matter liberty osm" position="top-right"></om-widget>
|
|
190
193
|
```
|
|
191
194
|
|
|
192
195
|
Custom widget:
|
|
@@ -254,6 +257,7 @@ Common built-in actions:
|
|
|
254
257
|
- `highlight-feature`
|
|
255
258
|
- `zoom-to-feature`
|
|
256
259
|
- `filter-layer`
|
|
260
|
+
- `set-basemap` — payload `{ basemap }`; writes the `<om-map basemap>` attribute
|
|
257
261
|
- `zoom-in`, `zoom-out`
|
|
258
262
|
- `fly-to`
|
|
259
263
|
- story actions: `story-play`, `story-pause`, `story-seek`
|