@nika-js/onlymap 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.vscode/onlymap.code-snippets +27 -4
  2. package/LICENSE.md +22 -12
  3. package/README.md +29 -7
  4. package/dist/actions.d.ts +2 -2
  5. package/dist/badge.d.ts +4 -0
  6. package/dist/{basemap-COurZNDH.js → basemap-BdvB-SHy.js} +1 -1
  7. package/dist/ctx.d.ts +10 -1
  8. package/dist/elements/om-fallback.d.ts +22 -0
  9. package/dist/elements/om-map.d.ts +1 -0
  10. package/dist/error-reporting.d.ts +17 -0
  11. package/dist/history.d.ts +53 -0
  12. package/dist/html-data.d.ts +2 -2
  13. package/dist/{index-COu-3-gN.js → index-BVQ0s1Zh.js} +1 -1
  14. package/dist/{index-CvfuISOc.js → index-BxzDKZpu.js} +14525 -13990
  15. package/dist/{index-D-X8KPA1.js → index-CJOMgp8k.js} +1 -1
  16. package/dist/{index-DuvXK95V.js → index-CvBHiOGD.js} +2 -2
  17. package/dist/{index-CgyAD98B.js → index-Vc8QfNEf.js} +1 -1
  18. package/dist/index.d.ts +10 -0
  19. package/dist/internal-ids.d.ts +7 -0
  20. package/dist/ir-diff.d.ts +1 -1
  21. package/dist/license.d.ts +64 -0
  22. package/dist/onlymapjs.css +1 -1
  23. package/dist/onlymapjs.js +27 -24
  24. package/dist/onlymapjs.umd.cjs +290 -280
  25. package/dist/runtime-core.d.ts +6 -0
  26. package/dist/telemetry-schema.d.ts +53 -0
  27. package/dist/telemetry.d.ts +60 -0
  28. package/dist/version.d.ts +8 -0
  29. package/docs/react.md +1 -0
  30. package/docs/telemetry.md +78 -0
  31. package/docs/testing.md +2 -2
  32. package/llms.txt +5 -3
  33. package/onlymapjs.html-data.json +44 -1
  34. package/package.json +7 -4
  35. package/skills/onlymapjs/SKILL.md +8 -2
  36. package/skills/onlymapjs/references/patterns.md +6 -0
  37. package/skills/onlymapjs/references/syntax.md +30 -2
  38. package/skills/onlymapjs/references/testing.md +2 -0
@@ -90,6 +90,12 @@ export declare class RuntimeCore {
90
90
  private basemapOptions?;
91
91
  /** Bumped on every renderer (re)init — a superseded lazy chunk load must not install its adapter over a newer renderer. */
92
92
  private rendererGeneration;
93
+ /** Pre-gate descriptors — what a license settle re-reconciles from (lastIRs holds the GATED set the renderer sees). */
94
+ private pregateIRs;
95
+ /** (layer, reason) pairs already reported — gate errors fire once per violation, not per reconcile. */
96
+ private readonly gateWarned;
97
+ private badge?;
98
+ private unsubscribeLicense?;
93
99
  constructor(parent: HTMLElement, initialView: InitialView, callbacks?: RuntimeCoreCallbacks, basemapAttr?: string, headless?: HeadlessOptions, basemapOptions?: BasemapRuntimeOptions);
94
100
  /** `basemap` attribute → concrete style, logging resolution problems (unknown preset / missing key) — the demo-style fallback still renders. */
95
101
  private resolveBasemap;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The telemetry WIRE SCHEMA (spec: "Monetization Gates / Usage telemetry";
3
+ * public doc: docs/telemetry.md — keep all three in sync). Deliberately a
4
+ * types-only module with zero DOM/browser references: the ingestion Worker
5
+ * (cloud/workers/telemetry, WebWorker lib) type-imports this same file, so
6
+ * client and server can never drift apart silently — a schema change breaks
7
+ * the other side's typecheck in the same commit.
8
+ */
9
+ export interface TelemetryLayerSnapshot {
10
+ type: string;
11
+ rows: number;
12
+ streaming: boolean;
13
+ refresh: boolean;
14
+ }
15
+ /** Library-caused error report (spec: "Monetization Gates / Usage telemetry" — library-error reporting). Scrubbed: no manifest content, no layer data, no page paths. */
16
+ export interface TelemetryErrorReport {
17
+ event: "library_error";
18
+ pageLoadId: string;
19
+ version: string;
20
+ /** Hostname only — never the path. */
21
+ origin: string;
22
+ dev: boolean;
23
+ /** Hash of message + top own-code frame — the dedup/grouping key. */
24
+ signature: string;
25
+ message: string;
26
+ /** Top stack lines from the library's own code, query strings stripped. */
27
+ frames: string[];
28
+ ua: string;
29
+ }
30
+ export interface TelemetrySnapshot {
31
+ event: "map_ready";
32
+ /** Random per page load — dedups beacon retries & multi-map pages. NOT persistent, NOT a visitor id. */
33
+ pageLoadId: string;
34
+ /** The authored `map-id` attribute — identifies the map artifact, not the visitor. */
35
+ mapId: string | null;
36
+ version: string;
37
+ /** "free" until the license module (monetization M1) exists. */
38
+ plan: string;
39
+ keyId: string | null;
40
+ /** Hostname only — never the path. */
41
+ origin: string;
42
+ frontend: "html" | "react" | "programmatic";
43
+ renderer: "maplibre" | "standalone";
44
+ /** Dev context (localhost et al.) — the only beacons the server may GeoIP (country code, IP discarded). */
45
+ dev: boolean;
46
+ layers: TelemetryLayerSnapshot[];
47
+ story: {
48
+ steps: number;
49
+ } | null;
50
+ widgets: string[];
51
+ draw: boolean;
52
+ undoRedo: boolean;
53
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Usage telemetry — client side (spec: "Monetization Gates / Usage
3
+ * telemetry", phase M3 client half). One snapshot per <om-map> instance per
4
+ * page load, built when that map reaches `ready` (manifest fully parsed —
5
+ * late mutations go uncounted, by design). Deployment-scoped, never
6
+ * visitor-scoped: the payload describes the page's use of the library.
7
+ *
8
+ * LIVE by default since the ingestion Worker deployment (2026-07-14): the
9
+ * default endpoint is the first-party Worker at om-api.nika.eco (spec's
10
+ * "beacon must not ship before something is listening" — it is listening).
11
+ * Disable with `configureTelemetry({ disabled: true })`, per map with
12
+ * `telemetry="off"`, or clear the endpoint (`{ endpoint: undefined }`).
13
+ * Disclosure: LICENSE.md §11; public schema: docs/telemetry.md.
14
+ *
15
+ * Non-negotiables (spec, binding):
16
+ * - telemetry never affects function: everything is wrapped, fire-and-
17
+ * forget, no retry, silent on failure; nothing awaits it;
18
+ * - no PII, no page URLs (hostname only), no visitor identifiers —
19
+ * `pageLoadId` dies with the page; `map-id` identifies the map ARTIFACT;
20
+ * - opt-out: `OmMap.configureTelemetry({ disabled: true })` or
21
+ * `telemetry="off"` on the map element;
22
+ * - headless maps (consumer test suites) never report.
23
+ */
24
+ import type { LayerIR } from "./ir";
25
+ import type { TelemetrySnapshot, TelemetryErrorReport } from "./telemetry-schema";
26
+ export type { TelemetrySnapshot, TelemetryLayerSnapshot } from "./telemetry-schema";
27
+ export interface TelemetryConfig {
28
+ /** Kill switch — disables the map-ready beacon and library-error reports globally. */
29
+ disabled?: boolean;
30
+ /** Ingestion endpoint — the first-party Worker by default; explicitly set `undefined` to silence all sends. */
31
+ endpoint?: string;
32
+ }
33
+ /** The first-party ingestion Worker (cloud/workers/telemetry) — a domain we control, never a vendor's. */
34
+ export declare const DEFAULT_TELEMETRY_ENDPOINT = "https://om-api.nika.eco/v1/t";
35
+ /** Merge-assign: only keys present in `partial` change (so `{ endpoint: undefined }` explicitly clears). */
36
+ export declare function configureTelemetry(partial: TelemetryConfig): void;
37
+ /** Read-only view for the error-reporting module (same gates, same switch). */
38
+ export declare function getTelemetryConfig(): Readonly<TelemetryConfig>;
39
+ /** One id per page load, shared by every map on the page. */
40
+ export declare const pageLoadId: string;
41
+ /** Hostname-only origin + dev flag — shared with error reporting. */
42
+ export declare function hostContext(): {
43
+ origin: string;
44
+ dev: boolean;
45
+ };
46
+ export declare function buildMapSnapshot(mapEl: Element, layerIRs: ReadonlyMap<string, LayerIR>, frontend?: TelemetrySnapshot["frontend"]): TelemetrySnapshot;
47
+ /**
48
+ * Fire-and-forget POST. `text/plain` is deliberate: it keeps the request
49
+ * CORS-simple (no preflight from every customer origin) — the Worker parses
50
+ * the body as JSON regardless of the content type. sendBeacon survives page
51
+ * unload; `fetch keepalive` is the fallback where sendBeacon is missing.
52
+ * Never throws, never retries, nothing observes the result.
53
+ */
54
+ export declare function postPayload(payload: TelemetrySnapshot | TelemetryErrorReport): void;
55
+ /**
56
+ * The om-map `ready` hook — one call per map instance per page load
57
+ * (guarded by the caller's readyFired latch). All gates checked here so the
58
+ * call site stays a single unconditional line.
59
+ */
60
+ export declare function reportMapReady(mapEl: Element, layerIRs: ReadonlyMap<string, LayerIR>): void;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The library version, as a compile-time constant (telemetry payloads,
3
+ * future diagnostics). Kept in sync with package.json by a drift test
4
+ * (telemetry.test.ts) rather than a JSON import — package.json sits outside
5
+ * the build rootDir, and a `define` would need repeating across vite/vitest/
6
+ * vite-node configs.
7
+ */
8
+ export declare const LIBRARY_VERSION = "0.2.3";
package/docs/react.md CHANGED
@@ -116,5 +116,6 @@ React ≥ 18 is an optional peer dependency — it's only loaded if you import `
116
116
 
117
117
  - **Stories** as React components (a `<Story>`/timeline hook is on the roadmap) — an HTML `<om-story>` needs the HTML front-end.
118
118
  - **The draw widget** — HTML front-end only for now.
119
+ - **Undo/redo** — manifest history is a DOM-front-end feature; in React your state (and its undo) belongs to the app. `ctx.history` reads `{ canUndo: false, canRedo: false }` here.
119
120
  - Per-feature `trace` (it animates via runtime manifest elements) — whole-layer `trace` on a TripsLayer works.
120
121
  - A `scale()` helper mirroring the expression language — use `d3-scale` or plain functions.
@@ -0,0 +1,78 @@
1
+ # Telemetry
2
+
3
+ OnlyMapJS reports **one usage snapshot per map, per page load** — sent when a map reaches `ready` — and, separately, **errors caused by the library's own code**. This page documents exactly what is (and is not) collected, and how to turn it off.
4
+
5
+ > **Status: active.** Reports go to the first-party endpoint `https://om-api.nika.eco/v1/t` (never to a third-party domain from your pages). Disclosure lives in the license (LICENSE.md §11). Both opt-outs below are always honored.
6
+
7
+ ## What a snapshot contains
8
+
9
+ The payload is **deployment-scoped**: it describes the page's use of the library, never the visitor.
10
+
11
+ ```jsonc
12
+ {
13
+ "event": "map_ready",
14
+ "pageLoadId": "…", // random UUID per page load — dedups retries; dies with the page
15
+ "mapId": "…" | null, // the authored map-id attribute (see below)
16
+ "version": "0.2.3",
17
+ "plan": "free", "keyId": null,
18
+ "origin": "dashboard.example.com", // hostname ONLY — never the path or query
19
+ "frontend": "html", // html | react | programmatic
20
+ "renderer": "maplibre" | "standalone",
21
+ "dev": false, // true on localhost / *.local
22
+ "layers": [ { "type": "ScatterplotLayer", "rows": 1200, "streaming": false, "refresh": false } ],
23
+ "story": { "steps": 7 } | null,
24
+ "widgets": ["legend", "basemap-switcher"], // widget types only
25
+ "draw": false, "undoRedo": false
26
+ }
27
+ ```
28
+
29
+ What is **never** collected: page paths or URLs, your data or its contents, coordinates, IP addresses in the payload, cookies, or any persistent visitor identifier. `pageLoadId` is regenerated on every page load and cannot link visits. Snapshots are also never sent from `headless` maps, so test suites stay silent.
30
+
31
+ ## Library-error reporting
32
+
33
+ Unexpected exceptions **from the library's own code** — never your page scripts, never other libraries (the stack must point into OnlyMapJS's own bundle), and never manifest/validation mistakes (those are surfaced to you in the dev error panel instead) — are reported so bugs get fixed:
34
+
35
+ ```jsonc
36
+ {
37
+ "event": "library_error",
38
+ "pageLoadId": "…", "version": "0.2.3",
39
+ "origin": "dashboard.example.com", // hostname only
40
+ "dev": false,
41
+ "signature": "…", // hash of message + top frame — the grouping key
42
+ "message": "…", // truncated; query strings stripped
43
+ "frames": ["…"], // top library-code stack lines; query strings stripped
44
+ "ua": "…" // browser user-agent string
45
+ }
46
+ ```
47
+
48
+ Stacks are scrubbed before sending: query strings are stripped from every URL (they can carry keys), and no manifest content, layer data, or accessor source is ever included. At most one report per distinct error per page load, capped at five per page. The same opt-outs below disable error reporting — one switch, no fine print.
49
+
50
+ ## `map-id` — identifying the map, not the visitor
51
+
52
+ An optional authored attribute:
53
+
54
+ ```html
55
+ <om-map map-id="0f2c6a1e-88f7-4c3e-9d41-7b1f3f9f2ab7" ...>
56
+ ```
57
+
58
+ It identifies the **map artifact** — the same id on every visit by every visitor — so usage can distinguish "one popular dashboard" from "many different maps". It is the same category as an analytics measurement id in page source: author-controlled, nothing stored on the visitor's device. **Opt out by deleting or changing it**; it is never required, and validation never asks for it. The VS Code `!map` snippet generates one automatically.
59
+
60
+ ## Opting out
61
+
62
+ ```ts
63
+ OmMap.configureTelemetry({ disabled: true }); // global — kills usage snapshots AND error reports
64
+ ```
65
+
66
+ ```html
67
+ <om-map telemetry="off" ...> <!-- per map -->
68
+ ```
69
+
70
+ Clearing the endpoint (`OmMap.configureTelemetry({ endpoint: undefined })`) is a third, equivalent switch: no endpoint, no network.
71
+
72
+ ## The binding rules
73
+
74
+ These are design constraints, not promises of restraint:
75
+
76
+ 1. **Telemetry never affects function.** Nothing in the library waits on, retries, or reacts to a report; sends are fire-and-forget (`navigator.sendBeacon`) and silent on failure. Error reporting can never itself throw into your page.
77
+ 2. **No endpoint, no network.** The send layer is a no-op when the endpoint is cleared.
78
+ 3. **Dev-context geography only.** Only `dev: true` reports (a developer's own localhost) are GeoIP-resolved at the server — to a country code, with the IP discarded unwritten. Production reports are never GeoIP'd and visitor IPs are never stored.
package/docs/testing.md CHANGED
@@ -122,8 +122,8 @@ h = await mountForTest(PAGE); // resolves after the mocked fetch
122
122
 
123
123
  A *failing* fetch also settles readiness (the layer is just empty) — `mountForTest` never hangs on a bad URL.
124
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).
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 — **and the free-plan license gates** (5 layers / 25k rows per layer), which apply in headless tests exactly as in production so a passing suite can't hide a gated page. If your page legitimately exceeds the free limits, configure your license key in test setup — keys are publishable and verify **offline**, so CI needs no network or secrets vault: `OmMap.configureLicense("om_live_…")`. Gate violations surface as errors on the validation stream (`om-validation-error` / the `validate` attribute), naming the limit.
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). Telemetry is also silent here — headless maps never report.
127
127
 
128
128
  ## Tier 3 — visual: Playwright
129
129
 
package/llms.txt CHANGED
@@ -16,11 +16,13 @@ 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="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.
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. 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.
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|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.
21
+ - `<om-widget type="legend|layer-switcher|basemap-switcher|zoom-controls|undo-redo|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', 'history', 'basemap']` (`layers` also fires on visibility/filter changes; `basemap` on basemap switches; `history` on undo/redo availability), `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`, `set-basemap`. 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`, `undo`, `redo`. One payload contract everywhere: `{ layer, target, feature, featureId, coordinate }`.
24
+ - 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")`.
25
+ - `<om-fallback>` — static no-JS fallback, direct child of `<om-map>` (one per map, no attributes, plain HTML content — links allowed). Shown ONLY where scripts never run (chat-app/email file previews — iOS QuickLook renders HTML attachments with JS off — file managers, sandboxed webviews); hidden automatically once the map boots. GOOD PRACTICE: include one on every complete page, especially pages that may be shared as a file ("This interactive map requires JavaScript — open this file in a web browser", plus a hosted-version link when one exists). Without one, the stylesheet shows a generic text-only banner. The gate is pure CSS (`om-map:not(:defined)` in onlymapjs.css), so the CSS must load without JS — a real `<link rel="stylesheet">` or inlined `<style>` on no-build pages; a bundler-emitted stylesheet is fine in npm projects.
24
26
  - 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
27
  - `<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
28
  - 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="...">`.
@@ -77,6 +77,23 @@
77
77
  "name": "validate",
78
78
  "description": "Run manifest validation and show the on-page error panel."
79
79
  },
80
+ {
81
+ "name": "map-id",
82
+ "description": "Optional authored UUID identifying this map artifact (telemetry dedup across visits/pages — not a visitor id). Opt-out: delete it."
83
+ },
84
+ {
85
+ "name": "license-key",
86
+ "description": "OnlyMapJS license token (om_live_…) — lifts the free-tier limits (5 layers / 25k rows per layer) and removes the badge. Publishable, origin-restricted; or call OmMap.configureLicense once."
87
+ },
88
+ {
89
+ "name": "telemetry",
90
+ "description": "Set \"off\" to disable usage telemetry for this map (global opt-out: OmMap.configureTelemetry).",
91
+ "values": [
92
+ {
93
+ "name": "off"
94
+ }
95
+ ]
96
+ },
80
97
  {
81
98
  "name": "headless",
82
99
  "description": "No renderer — parsing/IR only (tests, SSR)."
@@ -93,7 +110,7 @@
93
110
  "references": [
94
111
  {
95
112
  "name": "README",
96
- "url": "https://github.com/NikaGeospatial/onlymapjs/blob/main/docs/../README.md"
113
+ "url": "https://github.com/NikaGeospatial/onlymapjs/blob/main/README.md"
97
114
  }
98
115
  ]
99
116
  },
@@ -1294,6 +1311,17 @@
1294
1311
  }
1295
1312
  ]
1296
1313
  },
1314
+ {
1315
+ "name": "om-fallback",
1316
+ "description": "Static fallback shown only when the library never boots — JS-disabled contexts like chat-app/email file previews (iOS QuickLook), file managers, sandboxed webviews. Direct child of <om-map>; hidden automatically once the map upgrades. Good practice on any page that may be shared as a file. Without one, the stylesheet shows a text-only default banner. Requires onlymapjs.css to load without JS (link tag / bundled / inlined).",
1317
+ "attributes": [],
1318
+ "references": [
1319
+ {
1320
+ "name": "README",
1321
+ "url": "https://github.com/NikaGeospatial/onlymapjs/blob/main/README.md"
1322
+ }
1323
+ ]
1324
+ },
1297
1325
  {
1298
1326
  "name": "om-behavior",
1299
1327
  "description": "Declarative event → action wiring. Every other attribute becomes a payload key (kebab→camel).",
@@ -1404,6 +1432,12 @@
1404
1432
  },
1405
1433
  {
1406
1434
  "name": "set-basemap"
1435
+ },
1436
+ {
1437
+ "name": "undo"
1438
+ },
1439
+ {
1440
+ "name": "redo"
1407
1441
  }
1408
1442
  ]
1409
1443
  },
@@ -1454,6 +1488,9 @@
1454
1488
  },
1455
1489
  {
1456
1490
  "name": "basemap-switcher"
1491
+ },
1492
+ {
1493
+ "name": "undo-redo"
1457
1494
  }
1458
1495
  ]
1459
1496
  },
@@ -1669,6 +1706,12 @@
1669
1706
  },
1670
1707
  {
1671
1708
  "name": "set-basemap"
1709
+ },
1710
+ {
1711
+ "name": "undo"
1712
+ },
1713
+ {
1714
+ "name": "redo"
1672
1715
  }
1673
1716
  ]
1674
1717
  },
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@nika-js/onlymap",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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": {
7
7
  "access": "public"
8
8
  },
9
9
  "author": "NikaGeospatial",
10
- "homepage": "https://github.com/NikaGeospatial/onlymapjs#readme",
10
+ "homepage": "https://www.nikaplanet.com/onlymap",
11
11
  "repository": {
12
12
  "type": "git",
13
13
  "url": "git+https://github.com/NikaGeospatial/onlymapjs.git"
@@ -82,14 +82,16 @@
82
82
  "dev": "vite",
83
83
  "build": "npm run typecheck && vite build && vite build --config vite.react.config.ts && npm run build:types",
84
84
  "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly",
85
- "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
85
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.e2e.json --noEmit && tsc -p cloud/workers/telemetry/tsconfig.json",
86
86
  "test": "vitest run",
87
87
  "prepublishOnly": "npm run build",
88
88
  "try": "vite-node dev/expr-repl.mjs",
89
89
  "test:e2e": "playwright test",
90
90
  "gen:html-data": "vite-node dev/generate-html-data.ts",
91
91
  "gen:public": "npm run gen:html-data && vite-node dev/build-public.ts",
92
- "test:public": "npm run gen:public -- --dry-run --strict"
92
+ "test:public": "npm run gen:public -- --dry-run --strict",
93
+ "deploy:telemetry": "wrangler deploy --config cloud/workers/telemetry/wrangler.toml",
94
+ "dev:telemetry": "wrangler dev --config cloud/workers/telemetry/wrangler.toml"
93
95
  },
94
96
  "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.",
95
97
  "devDependencies": {
@@ -128,6 +130,7 @@
128
130
  "vite": "^6.0.0",
129
131
  "vite-node": "^6.0.0",
130
132
  "vitest": "^4.1.9",
133
+ "wrangler": "^4.110.0",
131
134
  "ws": "^8.21.0"
132
135
  }
133
136
  }
@@ -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, 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), 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 examples, use a published module URL such as `https://esm.sh/@nika-js/onlymap@0.2.0`.
26
+ For no-build CDN examples, use a published module URL such as `https://esm.sh/@nika-js/onlymap@0.2.3`.
27
27
 
28
28
  ## React Projects
29
29
 
@@ -62,12 +62,18 @@ Load the smallest reference needed for the task:
62
62
  - Many labels/badges -> `<om-layer type="PopupLayer">`.
63
63
  - Guided tour or narrative sequence -> `<om-story>` with `<om-step>` siblings that reference existing layers/overlays by id.
64
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`.
65
+ - Undoable UI (step back after layer toggles, filter changes, basemap switches, sketch edits) -> `<om-widget type="undo-redo">`; Cmd/Ctrl-Z works even without the widget. Camera moves and story playback are not undo steps.
65
66
  - Live entity updates -> `wss://` stream with `key` and optional `source` decoder.
66
67
  - REST snapshot that changes over time -> `refresh="5s"`.
67
68
  - User sketching -> `data="draw:sketch"` layer plus `<om-widget type="draw" target="sketch">`.
69
+ - Page may travel as a file (shared, emailed, downloaded) or be embedded -> add an `<om-fallback>` child to `<om-map>`. Chat-app and email previews render HTML with JavaScript disabled (iOS QuickLook), so the map cannot boot there; the fallback is what recipients see instead. It is hidden automatically once the map boots. Good practice on every complete page — without one, the stylesheet shows a generic text-only banner.
68
70
 
69
71
  ## Output Expectations
70
72
 
71
73
  When creating a map page, output a complete runnable HTML file unless the user asks for a fragment. Include CSS only as needed for page sizing or custom widgets/overlays. Keep the first screen the usable map, not a landing page.
72
74
 
75
+ Include a `map-id="<random UUID>"` attribute on `<om-map>` when creating a new complete page (generate a fresh UUID — never copy one from an example). It identifies the map artifact for usage telemetry, not the visitor; the author can delete it to opt out.
76
+
77
+ Include an `<om-fallback>` element (a short "this map requires JavaScript — open in a browser" message, optionally with a hosted-version link) as a direct child of `<om-map>` on any complete page. For the fallback to render in no-JS previews, `onlymapjs.css` must load without JavaScript — a real `<link rel="stylesheet">` or inlined `<style>`, not only a runtime `import` (bundler-emitted stylesheets are fine).
78
+
73
79
  When modifying an existing page, preserve the user's data URLs, layer ids, and styling unless the request requires changing them.
@@ -29,11 +29,17 @@ Use these patterns as starting points. Replace data URLs, layer ids, fields, cen
29
29
  pickable></om-layer>
30
30
  <om-widget type="legend" position="bottom-right" title="Layers"></om-widget>
31
31
  <om-widget type="zoom-controls" position="top-right"></om-widget>
32
+ <!-- Shown only where scripts never run (chat-app/email file previews) -->
33
+ <om-fallback>
34
+ <p>This interactive map requires JavaScript — open this file in a web browser.</p>
35
+ </om-fallback>
32
36
  </om-map>
33
37
  </body>
34
38
  </html>
35
39
  ```
36
40
 
41
+ Every complete-page pattern below should also carry an `<om-fallback>` (omitted for brevity) — see the good-practice rule in SKILL.md.
42
+
37
43
  ## Click Popup
38
44
 
39
45
  ```html
@@ -19,13 +19,13 @@ Static CDN page:
19
19
  <script type="module" src="https://esm.sh/@nika-js/onlymap@0.1.0"></script>
20
20
  ```
21
21
 
22
- If using MapLibre basemaps from a built package, include the CSS import or stylesheet. Standalone `basemap="none"` maps do not need the CSS.
22
+ 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).
23
23
 
24
24
  ## Elements
25
25
 
26
26
  ### `<om-map>`
27
27
 
28
- Root element. Children are layers, widgets, overlays, behaviors, and stories.
28
+ Root element. Children are layers, widgets, overlays, behaviors, stories, and an optional fallback.
29
29
 
30
30
  Common attributes:
31
31
 
@@ -36,6 +36,9 @@ Common attributes:
36
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
37
  - `attribution="false"` to opt out of the automatic provider-attribution control (only if you render equivalent credits yourself)
38
38
  - `validate` to show live validation errors during authoring
39
+ - `map-id="<random UUID>"` — identifies the map artifact for usage telemetry (not a visitor id). Include one on new complete pages, freshly generated — never copied from an example; deleting it is the opt-out.
40
+ - `telemetry="off"` disables usage telemetry for this map (global: `OmMap.configureTelemetry({ disabled: true })`)
41
+ - `license-key="om_live_…"` — lifts the free-plan limits (5 layers, 25k rows per layer) and removes the corner badge; publishable origin-restricted token, safe in page source (or `OmMap.configureLicense(key)` once). Free-plan violations don't break the map: the offending layer isn't rendered and validation names the limit.
39
42
  - `headless width="800" height="600"` for test harness use
40
43
 
41
44
  Example:
@@ -180,6 +183,7 @@ Built-ins:
180
183
  - `vega-lite`
181
184
  - `player`
182
185
  - `basemap-switcher` — radio list of presets; `options="positron dark-matter osm"` (default: every keyless registered preset)
186
+ - `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.
183
187
 
184
188
  Positions: `top-left`, `top-right`, `bottom-left`, `bottom-right`.
185
189
 
@@ -215,8 +219,11 @@ Widget context:
215
219
  - `ctx.stats(id, field, { scope: "viewport" })`
216
220
  - `ctx.selection`
217
221
  - `ctx.viewport`
222
+ - `ctx.history` — `{ canUndo, canRedo }`; re-render on changes via the `history` watch token
218
223
  - `ctx.emit(action, payload)`
219
224
 
225
+ Watch tokens: `data:<layerId>`, `viewport`, `selection`, `layers` (fires on layer add/remove, visibility, and filter changes), `basemap`, `history`.
226
+
220
227
  Use `this.$()` and `this.root`; widgets render in shadow DOM.
221
228
 
222
229
  ### `<om-overlay>`
@@ -243,6 +250,26 @@ Example:
243
250
  <om-behavior on="click" layer="quakes" action="show-overlay" target="detail"></om-behavior>
244
251
  ```
245
252
 
253
+ ### `<om-fallback>`
254
+
255
+ Static content shown only where scripts never run — chat-app/email file previews (iOS QuickLook), file managers, sandboxed webviews. Hidden automatically once the map boots. Good practice on every complete page, especially one that may be shared as a file.
256
+
257
+ Rules:
258
+
259
+ - Direct child of `<om-map>` (validation warns elsewhere), one per map.
260
+ - No attributes; plain HTML content — links work, so include a hosted-version URL when one exists.
261
+ - Without an `<om-fallback>`, the stylesheet shows a generic text-only banner instead.
262
+ - Requires `onlymapjs.css` to load without JavaScript (see Import Patterns above).
263
+
264
+ Example:
265
+
266
+ ```html
267
+ <om-fallback>
268
+ <p><strong>This interactive map requires JavaScript.</strong><br />
269
+ Open this file in a web browser, or visit <a href="https://example.com/map">the hosted version</a>.</p>
270
+ </om-fallback>
271
+ ```
272
+
246
273
  ### `<om-behavior>`
247
274
 
248
275
  Declarative event to action binding.
@@ -258,6 +285,7 @@ Common built-in actions:
258
285
  - `zoom-to-feature`
259
286
  - `filter-layer`
260
287
  - `set-basemap` — payload `{ basemap }`; writes the `<om-map basemap>` attribute
288
+ - `undo`, `redo` — step the manifest history (no payload)
261
289
  - `zoom-in`, `zoom-out`
262
290
  - `fly-to`
263
291
  - story actions: `story-play`, `story-pause`, `story-seek`
@@ -92,6 +92,8 @@ Harness operations:
92
92
 
93
93
  Use `vi.stubGlobal("fetch", ...)` for URL data and `vi.stubGlobal("WebSocket", ...)` for streams.
94
94
 
95
+ Free-plan license gates (5 layers, 25k rows per layer) apply in headless tests exactly as in production — gated layers emit errors on the validation stream instead of rendering. If the page under test legitimately exceeds the limits, call `OmMap.configureLicense("om_live_…")` in test setup; keys verify offline, so CI needs no network. Telemetry never fires from headless maps.
96
+
95
97
  ## Browser/E2E Testing
96
98
 
97
99
  Use Playwright only for pixels, real GPU picking, basemap composition, or asset rendering.