@nika-js/onlymap 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,9 @@
15
15
  * runtime state through the same RuntimeContext contract widgets get (HU5).
16
16
  */
17
17
  import { type CameraOptions } from "./runtime-core";
18
+ import { type LightingIR, type LightingOptions } from "./scene-lighting";
19
+ import type { TerrainIR } from "./terrain";
20
+ import { type SnapshotOptions } from "./snapshot";
18
21
  import type { LayerIR } from "./ir";
19
22
  import { type RuntimeContext } from "./ctx";
20
23
  import type { Selection } from "./selection";
@@ -61,6 +64,8 @@ export interface LayerDescriptor {
61
64
  updateTriggers?: Record<string, unknown>;
62
65
  /** deck.gl props (camelCase), accessors as plain functions — passed through. */
63
66
  props?: Record<string, unknown>;
67
+ /** Per-layer terrain mode (spec: "Terrain") — the `terrain` attribute's twin; absent = the type default. */
68
+ terrain?: "drape" | "offset" | "off";
64
69
  }
65
70
  /** Camera state, both an input (options/setView) and an output (getViewState). */
66
71
  export interface CameraState {
@@ -88,6 +93,14 @@ export interface MapControllerOptions {
88
93
  };
89
94
  /** Runtime error boundary — deck.gl-level failures in the structured validation shape. */
90
95
  onRuntimeError?: (entry: ValidationEntry) => void;
96
+ /**
97
+ * Camera settled (trailing-debounced, the `om-view-changed` twin) — for
98
+ * camera persistence. Per-frame viewport churn goes to `watch(["viewport"])`;
99
+ * this fires once with the destination.
100
+ */
101
+ onViewChange?: (view: CameraState) => void;
102
+ /** Scene lighting (spec: "Scene Lighting") — the lighting* attributes' programmatic twin; omit for deck's default lights. */
103
+ lighting?: LightingOptions;
91
104
  }
92
105
  /** Descriptor → layer IR: the programmatic counterpart of one parse-manifest.ts loop iteration. */
93
106
  export declare function descriptorToIR(desc: LayerDescriptor, onDataLoaded: () => void): LayerIR | null;
@@ -118,6 +131,7 @@ export declare class MapController {
118
131
  private commitPending;
119
132
  private destroyed;
120
133
  private firstCommitDone;
134
+ private viewChangeTimer;
121
135
  private readyFired;
122
136
  private resolveReady;
123
137
  readonly ready: Promise<void>;
@@ -176,6 +190,25 @@ export declare class MapController {
176
190
  * (none ↔ maplibre) remount seeded with the current camera.
177
191
  */
178
192
  setBasemap(basemap: string | null): void;
193
+ /** Scene lighting (spec: "Scene Lighting") — swap the LightingEffect live; null restores deck's default lights. */
194
+ setLighting(options: LightingOptions | null): void;
195
+ /** Resolved scene-lighting IR (test inspection) — null = deck default lights. */
196
+ getLighting(): LightingIR | null;
197
+ /**
198
+ * Terrain (spec: "Terrain") — a resolved TerrainIR, or null to turn the
199
+ * surface off. Note: terrain suppresses an active basemap while on
200
+ * (restored on null) — the flat-canvas desync rule.
201
+ */
202
+ setTerrain(ir: TerrainIR | null): void;
203
+ /** Resolved terrain IR (test inspection) — null = no surface. */
204
+ getTerrain(): TerrainIR | null;
205
+ /**
206
+ * Canvas-only scene snapshot (spec: "Snapshot API") — basemap + deck
207
+ * composited at device pixels; framework-rendered widgets/overlays are
208
+ * NOT captured (render provider credits yourself on exports). Await
209
+ * `ready` first; headless controllers reject.
210
+ */
211
+ snapshot(opts?: SnapshotOptions): Promise<string | Blob>;
179
212
  flyTo(center: [number, number], zoom?: number, opts?: CameraOptions): void;
180
213
  flyToBounds(bounds: [[number, number], [number, number]], padding?: number, opts?: CameraOptions): void;
181
214
  setView(partial: Partial<CameraState>, opts?: CameraOptions): void;
@@ -183,4 +216,6 @@ export declare class MapController {
183
216
  /** Projects a lng/lat through the current viewport — undefined before the viewport resolves. */
184
217
  project(lngLat: [number, number]): [number, number] | undefined;
185
218
  destroy(): void;
219
+ /** Trailing-debounced onViewChange — see MapControllerOptions.onViewChange. */
220
+ private scheduleViewChange;
186
221
  }
@@ -26,6 +26,8 @@ export interface OmLayerProps {
26
26
  flush?: string;
27
27
  /** Polling interval for REST snapshot URLs, e.g. "5s". */
28
28
  refresh?: string;
29
+ /** Behavior under active map terrain; absent uses the layer type's default. */
30
+ terrain?: "drape" | "offset" | "off";
29
31
  /** deck.gl updateTriggers — supply a changing value when an accessor's OUTPUT changes (deck ignores function identity). */
30
32
  updateTriggers?: Record<string, unknown>;
31
33
  /** Click pick on this layer (object flattened/materialized, same as ctx.selection). */
package/dist/react.js CHANGED
@@ -1,7 +1,7 @@
1
1
  var J = Object.defineProperty;
2
2
  var Q = (e, t, n) => t in e ? J(e, t, { enumerable: !0, configurable: !0, writable: !0, value: n }) : e[t] = n;
3
3
  var A = (e, t, n) => Q(e, typeof t != "symbol" ? t + "" : t, n);
4
- import { jsxs as U, jsx as x } from "react/jsx-runtime";
4
+ import { jsxs as U, jsx as C } from "react/jsx-runtime";
5
5
  import { createContext as X, useContext as Y, forwardRef as Z, useRef as f, useState as k, useCallback as ee, useMemo as I, useLayoutEffect as te, useEffect as g, useImperativeHandle as re } from "react";
6
6
  import { MapController as ne } from "@nika-js/onlymap";
7
7
  import { createPortal as B } from "react-dom";
@@ -53,7 +53,7 @@ function ie(e, t) {
53
53
  return Object.entries(t).some(([n, r]) => r !== void 0 && e[n] !== r);
54
54
  }
55
55
  const he = Z(function(t, n) {
56
- const { center: r, zoom: l, pitch: a, bearing: u, basemap: i, basemapKey: c, attribution: y, headless: m, className: O, style: z, children: R } = t, d = f(null), [o, T] = k(null), [C, M] = k(null), [j, L] = k({
56
+ const { center: r, zoom: l, pitch: a, bearing: u, basemap: i, basemapKey: c, attribution: y, headless: m, className: O, style: z, children: R } = t, d = f(null), [o, T] = k(null), [p, M] = k(null), [j, L] = k({
57
57
  "top-left": null,
58
58
  "top-right": null,
59
59
  "bottom-left": null,
@@ -65,7 +65,7 @@ const he = Z(function(t, n) {
65
65
  q.map((s) => [
66
66
  s,
67
67
  (h) => {
68
- L((p) => p[s] === h ? p : { ...p, [s]: h });
68
+ L((x) => x[s] === h ? x : { ...x, [s]: h });
69
69
  }
70
70
  ])
71
71
  ),
@@ -100,12 +100,12 @@ const he = Z(function(t, n) {
100
100
  }
101
101
  });
102
102
  T(h);
103
- let p = !0;
103
+ let x = !0;
104
104
  return h.ready.then(() => {
105
105
  var F;
106
- p && ((F = b.current) == null || F.call(b));
106
+ x && ((F = b.current) == null || F.call(b));
107
107
  }), () => {
108
- p = !1, T(null), h.destroy();
108
+ x = !1, T(null), h.destroy();
109
109
  };
110
110
  }, [G]), g(() => {
111
111
  !o || V.current === i || (V.current = i, o.setBasemap(i ?? null));
@@ -121,19 +121,19 @@ const he = Z(function(t, n) {
121
121
  });
122
122
  }, [o]);
123
123
  const W = I(() => o ? new se(o) : null, [o]), _ = I(
124
- () => o && W && C ? { controller: o, layers: W, overlayPane: C, corners: j } : null,
125
- [o, W, C, j]
124
+ () => o && W && p ? { controller: o, layers: W, overlayPane: p, corners: j } : null,
125
+ [o, W, p, j]
126
126
  );
127
127
  return /* @__PURE__ */ U("div", { className: O, style: { position: "relative", overflow: "hidden", ...z }, children: [
128
- /* @__PURE__ */ x("div", { ref: d, style: { position: "absolute", inset: 0 } }),
129
- /* @__PURE__ */ x(
128
+ /* @__PURE__ */ C("div", { ref: d, style: { position: "absolute", inset: 0 } }),
129
+ /* @__PURE__ */ C(
130
130
  "div",
131
131
  {
132
132
  ref: v,
133
133
  style: { position: "absolute", top: 0, left: 0, zIndex: 10, pointerEvents: "none" }
134
134
  }
135
135
  ),
136
- q.map((s) => /* @__PURE__ */ x(
136
+ q.map((s) => /* @__PURE__ */ C(
137
137
  "div",
138
138
  {
139
139
  "data-om-widget-corner": s,
@@ -142,7 +142,7 @@ const he = Z(function(t, n) {
142
142
  },
143
143
  s
144
144
  )),
145
- _ && /* @__PURE__ */ x(H.Provider, { value: _, children: R })
145
+ _ && /* @__PURE__ */ C(H.Provider, { value: _, children: R })
146
146
  ] });
147
147
  }), ae = /* @__PURE__ */ new Set([
148
148
  "id",
@@ -159,6 +159,7 @@ const he = Z(function(t, n) {
159
159
  "streamKey",
160
160
  "flush",
161
161
  "refresh",
162
+ "terrain",
162
163
  "updateTriggers",
163
164
  "onClick",
164
165
  "onHover"
@@ -182,6 +183,7 @@ function ge(e) {
182
183
  key: e.streamKey,
183
184
  flush: e.flush,
184
185
  refresh: e.refresh,
186
+ terrain: e.terrain,
185
187
  updateTriggers: e.updateTriggers,
186
188
  props: l
187
189
  };
@@ -206,7 +208,7 @@ function ge(e) {
206
208
  function ve({ position: e = "top-left", className: t, style: n, children: r }) {
207
209
  const { corners: l } = K("OmWidget"), a = l[e];
208
210
  return a ? B(
209
- /* @__PURE__ */ x("div", { className: t, style: { pointerEvents: "auto", ...n }, children: r }),
211
+ /* @__PURE__ */ C("div", { className: t, style: { pointerEvents: "auto", ...n }, children: r }),
210
212
  a
211
213
  ) : null;
212
214
  }
@@ -227,9 +229,9 @@ function be(e) {
227
229
  () => t.registerOverlay((R, d) => {
228
230
  const o = r.current;
229
231
  if (!o) return;
230
- const { anchor: T, anchorFrom: C, layer: M, visible: j = !0, anchorOffset: L } = i.current;
232
+ const { anchor: T, anchorFrom: p, layer: M, visible: j = !0, anchorOffset: L } = i.current;
231
233
  let v;
232
- if (C === "selection" ? ((!M || (d == null ? void 0 : d.layerId) === M) && (l.current = (d == null ? void 0 : d.coordinate) ?? void 0, u(d)), v = l.current) : v = T, !j || !v || !R) {
234
+ if (p === "selection" ? ((!M || (d == null ? void 0 : d.layerId) === M) && (l.current = (d == null ? void 0 : d.coordinate) ?? void 0, u(d)), v = l.current) : v = T, !j || !v || !R) {
233
235
  o.style.visibility = "hidden";
234
236
  return;
235
237
  }
@@ -246,7 +248,7 @@ function be(e) {
246
248
  });
247
249
  const { children: c, className: y, style: m, interactive: O = !0 } = e, z = typeof c == "function" ? c(a) : c;
248
250
  return B(
249
- /* @__PURE__ */ x(
251
+ /* @__PURE__ */ C(
250
252
  "div",
251
253
  {
252
254
  ref: r,
@@ -1,8 +1,10 @@
1
+ import { type LightingIR } from "./scene-lighting";
2
+ import { type TerrainIR } from "./terrain";
1
3
  import type { LayerIR } from "./ir";
2
4
  import { type Selection } from "./selection";
3
5
  import type { ValidationEntry } from "./validation";
4
6
  import type { MapViewport } from "./basemap";
5
- /** `basemap="mapbox-*"` isn't implemented — kept maplibre-free so validation/runtime can check without loading the chunk. */
7
+ /** Mapbox presets/protocol URLs aren't implemented — kept maplibre-free so validation/runtime can check without loading the chunk. */
6
8
  export declare function isMapboxBasemap(raw: string): boolean;
7
9
  export interface InitialView {
8
10
  longitude: number;
@@ -92,6 +94,42 @@ export declare class RuntimeCore {
92
94
  private rendererGeneration;
93
95
  /** Pre-gate descriptors — what a license settle re-reconciles from (lastIRs holds the GATED set the renderer sees). */
94
96
  private pregateIRs;
97
+ /**
98
+ * Scene lighting (spec: "Scene Lighting") — the retained IR, rebuilt into
99
+ * a LightingEffect on apply. Survives renderer remounts (initRenderer
100
+ * reads it, the lastIRs contract) and the lazy basemap-chunk window (the
101
+ * adapter constructor reads it on arrival). null = deck default lights.
102
+ */
103
+ private lighting;
104
+ /**
105
+ * Terrain (spec: "Terrain") — the retained surface IR. Non-null appends
106
+ * the internal terrain layer and patches drape/offset layers in
107
+ * buildLayers. `terrainGeneration` bumps on every terrain state change:
108
+ * extension sets must be BIRTH-stable, so patched layers get
109
+ * `#t<generation>`-based deck ids (fresh instances per flip — see
110
+ * applyTerrain); an explicit renderer-id map restores authored pick ids.
111
+ */
112
+ private terrain;
113
+ private terrainGeneration;
114
+ /** Rendered deck layer id → authored manifest/controller id (terrain uses fresh renderer ids). */
115
+ private renderedLayerIds;
116
+ /**
117
+ * The basemap attribute suppressed while terrain is active — a flat
118
+ * MapLibre canvas at sea level visibly desyncs from a raised surface
119
+ * (true coexistence is the interleaved-compositing TODO), so terrain
120
+ * forces standalone rendering and restores the basemap when it turns off.
121
+ */
122
+ private suppressedBasemapAttr?;
123
+ /**
124
+ * Snapshot capture queue (spec: "Snapshot API") — the WebGL context has
125
+ * no preserveDrawingBuffer, so pixels are only readable synchronously
126
+ * inside a post-render callback. snapshot() enqueues a capture and forces
127
+ * a redraw; the permanent onAfterRender (standalone Deck constructor)
128
+ * drains the queue while the drawing buffer is valid. `cancel` settles the
129
+ * promise when the renderer goes away before that frame renders (teardown/
130
+ * destroy) — a queued capture must never leave its caller hanging.
131
+ */
132
+ private snapshotCaptures;
95
133
  /** (layer, reason) pairs already reported — gate errors fire once per violation, not per reconcile. */
96
134
  private readonly gateWarned;
97
135
  private badge?;
@@ -105,6 +143,40 @@ export declare class RuntimeCore {
105
143
  * (none ↔ maplibre). Reads/seeds the camera from `this.viewState`.
106
144
  */
107
145
  private initRenderer;
146
+ /** The current lighting as a deck `effects` array — [] restores deck's default lights. */
147
+ private buildEffects;
148
+ /**
149
+ * Scene lighting (spec: "Scene Lighting") — swap the LightingEffect on
150
+ * the live renderer. null restores deck's default lights. Headless stores
151
+ * the IR (inspectable via the map's internal getter) with no renderer to
152
+ * drive; a change during the lazy basemap-chunk window is picked up by
153
+ * the adapter constructor (which reads `this.lighting` on arrival). A
154
+ * sunDate-driven sun resolves az/el HERE, against the current map center
155
+ * — deterministic per apply, not tracked per frame.
156
+ */
157
+ setLighting(ir: LightingIR | null): void;
158
+ getLightingInternal(): LightingIR | null;
159
+ /**
160
+ * Terrain on/off/source change (spec: "Terrain"). Bumps the generation
161
+ * (fresh deck ids for extension-carrying layers — birth-stable extension
162
+ * sets), swaps the renderer mode when needed (terrain suppresses an
163
+ * active basemap; turning it off restores the suppressed one), and
164
+ * re-applies layers. Headless stores the IR for inspection.
165
+ */
166
+ setTerrain(ir: TerrainIR | null): void;
167
+ getTerrainInternal(): TerrainIR | null;
168
+ private drainSnapshotCaptures;
169
+ /** Settle queued snapshot promises when the renderer they were waiting on goes away (mode flip / destroy) — never leave a caller hanging. */
170
+ private cancelSnapshotCaptures;
171
+ /**
172
+ * Canvas-only scene snapshot (spec: "Snapshot API") — the composite of
173
+ * basemap + deck canvases at device pixels, captured right after a forced
174
+ * repaint (no preserveDrawingBuffer in either context). DOM widgets,
175
+ * overlays, the badge, and the attribution control are NOT captured —
176
+ * consumers rendering exports must add provider credits themselves.
177
+ * Headless rejects (no renderer); pre-ready rejects (await map.ready).
178
+ */
179
+ snapshot(): Promise<HTMLCanvasElement>;
108
180
  /**
109
181
  * Live basemap change (spec: "Basemap presets & switching"), both paths:
110
182
  * - maplibre → maplibre: `map.setStyle()` — deck layers survive (the
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Scene lighting (spec: "Scene Lighting") — the manifest surface over
3
+ * deck.gl's LightingEffect, resolving the backlog TODO ("Lighting/effects
4
+ * manifest surface"). Attributes on <om-map>, the basemap= precedent:
5
+ * singleton scene state as attributes gets undo/redo for free (attribute
6
+ * history) and live switching through the same MutationObserver path.
7
+ *
8
+ * <om-map lighting="daylight">
9
+ * <om-map lighting="custom" lighting-ambient="0.6" lighting-sun="1.5"
10
+ * lighting-sun-azimuth="135" lighting-sun-elevation="45">
11
+ * <om-map lighting="daylight" lighting-sun-date="2026-07-15T14:00:00Z">
12
+ *
13
+ * The sun is ALWAYS a DirectionalLight driven by azimuth/elevation;
14
+ * `lighting-sun-date` is sugar that computes az/el from solar position at
15
+ * the map center when lighting is applied (deliberately NOT deck's
16
+ * experimental `_SunLight`, which recomputes per frame from the viewport —
17
+ * an attribute-declared scene should be deterministic, and the underscore
18
+ * API is upgrade churn). Absent `lighting` = deck's default lights —
19
+ * today's behavior, so the whole surface is additive.
20
+ */
21
+ import { AmbientLight, _CameraLight as CameraLight, DirectionalLight, LightingEffect } from "@deck.gl/core";
22
+ export interface LightingIR {
23
+ /** Preset the values were seeded from (validation/UI echo; "custom" for a fully hand-tuned scene). */
24
+ preset: LightingPresetName;
25
+ /** Ambient intensity — always-on fill. */
26
+ ambient: number;
27
+ /** Sun (directional) intensity; 0 omits the sun light entirely. */
28
+ sun: number;
29
+ /** Sun compass bearing, ° clockwise from north. */
30
+ sunAzimuth: number;
31
+ /** Sun height above the horizon, ° (0 = horizon, 90 = zenith). */
32
+ sunElevation: number;
33
+ /** Camera-following fill intensity (model inspection); 0 omits it. */
34
+ camera: number;
35
+ /** Epoch ms — when set, az/el are recomputed from solar position at apply time. */
36
+ sunDate?: number;
37
+ }
38
+ export type LightingPresetName = "daylight" | "studio" | "flat" | "custom";
39
+ export declare const LIGHTING_PRESET_NAMES: readonly ["daylight", "studio", "flat", "custom"];
40
+ export declare function isLightingPreset(name: string): name is LightingPresetName;
41
+ /** Programmatic twin of the lighting-* attributes (MapControllerOptions.lighting / setLighting). */
42
+ export interface LightingOptions {
43
+ preset?: LightingPresetName;
44
+ ambient?: number;
45
+ sun?: number;
46
+ sunAzimuth?: number;
47
+ sunElevation?: number;
48
+ camera?: number;
49
+ /** Epoch ms or ISO 8601 — the lighting-sun-date twin. */
50
+ sunDate?: number | string;
51
+ }
52
+ /** Preset seeds values; explicit fields override individual seeds; sunDate (resolved at apply time) wins over az/el. */
53
+ export declare function resolveLighting(options: LightingOptions): LightingIR;
54
+ /**
55
+ * `lighting*` attributes → IR. Returns null when `lighting` itself is
56
+ * absent (deck default lights; stray lighting-* overrides without it are
57
+ * inert — validation warns). Malformed numbers fall back to the preset seed
58
+ * (validation flags them; the live path must still render something).
59
+ */
60
+ export declare function parseLightingAttrs(getAttr: (name: string) => string | null): LightingIR | null;
61
+ /**
62
+ * Solar azimuth/elevation at a timestamp + location, in the manual-sun
63
+ * convention (azimuth ° CW from north, elevation ° above horizon). The
64
+ * standard suncalc derivation (the same math deck's `_SunLight` uses via
65
+ * @math.gl/sun): solar azimuth measured south→west, so matching the
66
+ * DirectionalLight convention is `azimuth = solar + 180°`. Below-horizon
67
+ * sun clamps to the horizon — a flat scene can't render a sun beneath it.
68
+ */
69
+ export declare function solarAzElDegrees(timestamp: number, latitude: number, longitude: number): {
70
+ azimuth: number;
71
+ elevation: number;
72
+ };
73
+ /**
74
+ * IR → the named lights map a LightingEffect is constructed from —
75
+ * separated so tests can assert composition without reaching into
76
+ * LightingEffect's private fields. The ambient light is always present;
77
+ * sun and camera lights only at intensity > 0 (deck renders a 0-intensity
78
+ * light as subtle noise, not nothing). `center` (current map center) feeds
79
+ * the sunDate → az/el resolution.
80
+ */
81
+ export declare function buildLights(ir: LightingIR, center?: [longitude: number, latitude: number]): Record<string, AmbientLight | DirectionalLight | CameraLight>;
82
+ /** IR → deck.gl LightingEffect (see buildLights for the composition rules). */
83
+ export declare function buildLightingEffect(ir: LightingIR, center?: [longitude: number, latitude: number]): LightingEffect;
@@ -27,5 +27,5 @@ interface PickingInfoLike {
27
27
  x: number;
28
28
  y: number;
29
29
  }
30
- export declare function toSelection(info: PickingInfoLike, type: "hover" | "click"): Selection | null;
30
+ export declare function toSelection(info: PickingInfoLike, type: "hover" | "click", resolveLayerId?: (renderedId: string) => string): Selection | null;
31
31
  export {};
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Snapshot serialization (spec: "Snapshot API") — shared by OmMapElement
3
+ * and MapController: the composite canvas RuntimeCore.snapshot() produces,
4
+ * serialized to the consumer's preferred shape.
5
+ */
6
+ export interface SnapshotOptions {
7
+ /** Image MIME type — default "image/png". */
8
+ type?: "image/png" | "image/jpeg" | "image/webp";
9
+ /** Lossy-format quality 0–1 (jpeg/webp). */
10
+ quality?: number;
11
+ /** Output shape — default "dataURL" (what print/export pipelines embed directly); "blob" for uploads/files. */
12
+ as?: "dataURL" | "blob";
13
+ }
14
+ export declare function serializeSnapshot(canvas: HTMLCanvasElement, opts: SnapshotOptions): Promise<string | Blob>;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Terrain (spec: "Terrain") — 3D elevation surface + per-layer draping,
3
+ * declared as `terrain*` attributes on <om-map> (the basemap=/lighting=
4
+ * scene-attribute precedent: free undo/redo, live MutationObserver
5
+ * switching).
6
+ *
7
+ * <om-map terrain="terrarium" terrain-exaggeration="1.5" pitch="55">
8
+ * <om-map terrain="https://tiles.example.com/dem/{z}/{x}/{y}.png"
9
+ * terrain-decoder="terrarium" terrain-max-zoom="14"
10
+ * terrain-texture="https://…/satellite/{z}/{x}/{y}.jpg">
11
+ *
12
+ * The seventh plugin registry (`OmMap.registerTerrain`) resolves preset
13
+ * names; a `{z}/{x}/{y}` URL is bring-your-own DEM (requires
14
+ * terrain-decoder). Geographic layers DRAPE onto the surface by default;
15
+ * a per-layer `terrain="drape|offset|off"` attribute overrides (3D-model
16
+ * layers default to `offset` — models sit ON terrain, not painted onto
17
+ * it).
18
+ *
19
+ * Terrain REPLACES an active basemap: a flat MapLibre canvas sits at sea
20
+ * level and visibly desyncs from a raised surface (true coexistence is
21
+ * the interleaved-compositing TODO). RuntimeCore suppresses the basemap
22
+ * while terrain is active and restores it when terrain turns off.
23
+ *
24
+ * `applyTerrain` is PURE (unit-testable without a GPU — headless never
25
+ * reaches the deck handoff): it computes each layer's terrain patches and
26
+ * the terrain-surface layer props; RuntimeCore feeds them to buildLayers.
27
+ */
28
+ import type { Layer } from "@deck.gl/core";
29
+ import type { LayerIR } from "./ir";
30
+ /** RGB DEM decoder: height = offset + R·rScaler + G·gScaler + B·bScaler. */
31
+ export interface ElevationDecoder {
32
+ rScaler: number;
33
+ gScaler: number;
34
+ bScaler: number;
35
+ offset: number;
36
+ }
37
+ export declare const DECODERS: Readonly<Record<string, ElevationDecoder>>;
38
+ export interface TerrainPreset {
39
+ /** `{z}/{x}/{y}` DEM tile URL template (may carry a `{key}` placeholder for keyed providers). */
40
+ elevationData: string;
41
+ /** Decoder name ("terrarium" / "mapbox-rgb") or explicit scalers. */
42
+ decoder: keyof typeof DECODERS | ElevationDecoder;
43
+ /**
44
+ * The provider's REAL tileset cap — deck over-samples past it for closer
45
+ * cameras; a too-high cap requests tiles that 404 and blanks the terrain.
46
+ */
47
+ maxZoom: number;
48
+ /** Optional draped imagery `{z}/{x}/{y}` URL template. */
49
+ texture?: string;
50
+ /** Display name (switcher UIs). */
51
+ label?: string;
52
+ /** Which key `{key}` substitutes — the basemap-registry convention. */
53
+ requiresKey?: "maptiler";
54
+ }
55
+ export declare function registerTerrain(name: string, preset: TerrainPreset): void;
56
+ export declare function getTerrain(name: string): TerrainPreset | undefined;
57
+ export declare function getAllTerrains(): ReadonlyMap<string, TerrainPreset>;
58
+ export interface TerrainIR {
59
+ elevationData: string;
60
+ elevationDecoder: ElevationDecoder;
61
+ maxZoom: number;
62
+ /** Multiplies decoded DEM heights (1 = true relief). */
63
+ exaggeration: number;
64
+ texture?: string;
65
+ }
66
+ export interface ResolvedTerrain {
67
+ ir: TerrainIR | null;
68
+ /** Resolution problem (unknown preset / missing key / missing decoder) — the caller decides how loudly to fail; `ir` is null when unresolvable. */
69
+ error?: string;
70
+ }
71
+ /** Is this `terrain` value a bring-your-own DEM URL rather than a preset name? (The basemap isStyleUrl convention.) */
72
+ export declare function isDemUrl(raw: string): boolean;
73
+ /**
74
+ * `terrain*` attributes → TerrainIR. Returns `{ir: null}` when `terrain`
75
+ * is absent/"off"; `{ir: null, error}` when set but unresolvable (unknown
76
+ * preset, keyless keyed preset, raw URL without a decoder) — terrain
77
+ * renders NOTHING in that case rather than a wrong surface, and validation
78
+ * carries the loud version of the same message.
79
+ */
80
+ export declare function parseTerrainAttrs(getAttr: (name: string) => string | null, maptilerKey?: string): ResolvedTerrain;
81
+ export declare const TERRAIN_LAYER_ID = "__onlymapjs-terrain";
82
+ export type LayerTerrainMode = "drape" | "offset" | "off";
83
+ export declare const LAYER_TERRAIN_MODES: readonly ["drape", "offset", "off"];
84
+ export declare function defaultTerrainMode(type: string): LayerTerrainMode;
85
+ export interface TerrainLayerPatch {
86
+ /**
87
+ * The deck-layer id to construct with. Suffixed `#t<generation>` while
88
+ * terrain is active: adding/removing an extension on a LIVE layer
89
+ * recompiles shaders and corrupts attribute state (the DataFilterExtension
90
+ * birth-mount rule), and a stale TerrainExtension vertical offset survives
91
+ * a terrain toggle on a stable id — a fresh id forces clean teardown +
92
+ * rebuild. RuntimeCore maps these generated ids back to authored ids for
93
+ * picks without guessing from the string shape.
94
+ */
95
+ deckId: string;
96
+ /** Extra constructor props: extensions (APPENDED to existing — DataFilterExtension stays mounted) + terrainDrawMode. Empty for mode "off". */
97
+ extraProps: Record<string, unknown>;
98
+ }
99
+ /**
100
+ * Per-layer terrain patches, PURE. `terrainActive` false → identity patches
101
+ * (stable ids, no props). Runtime-internal layers (trace temps, draw
102
+ * preview, tooltip plumbing) never drape — they're screen-space/transient.
103
+ */
104
+ export declare function applyTerrain(irs: readonly LayerIR[], terrainActive: boolean, generation: number): Map<string, TerrainLayerPatch>;
105
+ /**
106
+ * The terrain-surface layer itself. `operation: "terrain+draw"` makes it
107
+ * double as the elevation surface extension-carrying layers drape onto;
108
+ * `pickable: "3d"` runs the depth pick pass so click/hover coordinates
109
+ * unproject at the SURFACE's depth instead of z=0 sea level (without it,
110
+ * tilted-view picks/drawn vertices land behind the cursor). Exempt from
111
+ * license gates by the `__onlymapjs-` prefix.
112
+ */
113
+ export declare function buildTerrainLayer(ir: TerrainIR): Layer;
package/dist/version.d.ts CHANGED
@@ -5,4 +5,4 @@
5
5
  * the build rootDir, and a `define` would need repeating across vite/vitest/
6
6
  * vite-node configs.
7
7
  */
8
- export declare const LIBRARY_VERSION = "0.2.3";
8
+ export declare const LIBRARY_VERSION = "0.3.0";
@@ -0,0 +1,99 @@
1
+ # Registering an external layer class
2
+
3
+ `OmMap.registerLayer` makes any deck.gl layer class a first-class manifest
4
+ type: validation, IntelliSense generation, and attribute resolution all read
5
+ the same schema the built-ins use. This page is the recipe for bringing a
6
+ layer class the library doesn't bundle — a community layer, a company shim, a
7
+ composite that wraps a whole data pipeline.
8
+
9
+ ## Rule 1: build on `@nika-js/onlymap/deck`, never on your own deck.gl
10
+
11
+ OnlyMapJS bundles deck.gl (it has zero runtime dependencies). If your class
12
+ extends a `@deck.gl/*` copy you installed yourself, it belongs to a
13
+ **different class hierarchy** — two `Layer` base classes, two luma.gl
14
+ runtimes — and fails inside the bundled renderer. The `deck` subpath
15
+ re-exports the bundled classes, so your shim extends the exact objects the
16
+ core renders with:
17
+
18
+ ```js
19
+ import { CompositeLayer, TileLayer, BitmapLayer } from "@nika-js/onlymap/deck";
20
+ ```
21
+
22
+ Available: `Layer`, `CompositeLayer`, `LayerExtension`, `WebMercatorViewport`,
23
+ `COORDINATE_SYSTEM`, `GeoJsonLayer`, `ScatterplotLayer`, `IconLayer`,
24
+ `BitmapLayer`, `TileLayer`, `Tile3DLayer`, `SimpleMeshLayer`,
25
+ `ScenegraphLayer` — plus authoring types (`LayerProps`, `DefaultProps`,
26
+ `UpdateParameters`, `PickingInfo`, `LayersList`; typechecking against them
27
+ needs `@deck.gl/core` as a dev dependency).
28
+
29
+ ## Rule 2: functions ride `static defaultProps`, not attributes
30
+
31
+ HTML attributes are strings — function-valued props (`renderSubLayers`,
32
+ `getTileData`, load callbacks) are inexpressible by design. Put them on a
33
+ thin subclass; deck.gl's own defaultProps merge delivers them to every
34
+ instance:
35
+
36
+ ```js
37
+ class CogLayer extends CompositeLayer {
38
+ static layerName = "CogLayer";
39
+ static defaultProps = {
40
+ renderTile: { type: "function", value: renderTile },
41
+ onRasterLoad: { type: "function", value: () => {} },
42
+ };
43
+ renderLayers() {
44
+ /* compose TileLayer/BitmapLayer from the subpath here */
45
+ }
46
+ }
47
+ ```
48
+
49
+ ## Rule 3: the schema wires attributes to deck props
50
+
51
+ ```js
52
+ import { OmMap } from "@nika-js/onlymap";
53
+
54
+ OmMap.registerLayer({
55
+ type: "CogLayer",
56
+ deckClass: CogLayer,
57
+ props: [
58
+ // deck's URL prop is `data`, but OnlyMapJS reserves the `data` attribute
59
+ // for its own loader — alias it, and the layer class fetches for itself
60
+ // (the built-in Tile3DLayer `tileset` attribute uses the same trick).
61
+ { attr: "src", kind: "scalar", deckProp: "data", type: "string", required: true },
62
+ // JSON attributes and dot-path descriptors compose: the JSON sets the
63
+ // object, later dot-paths merge into it.
64
+ { attr: "load-options", kind: "scalar", deckProp: "loadOptions", type: "json" },
65
+ { attr: "max-error", kind: "scalar", deckProp: "loadOptions.cog.maxError", type: "number" },
66
+ { attr: "opacity", kind: "scalar", deckProp: "opacity", type: "number", default: 1 },
67
+ { attr: "visible", kind: "scalar", deckProp: "visible", type: "boolean", default: true },
68
+ // accessor-kind props get the full expression language:
69
+ // { attr: "get-color", kind: "accessor", deckProp: "getColor" },
70
+ ],
71
+ });
72
+ ```
73
+
74
+ Then the manifest just works:
75
+
76
+ ```html
77
+ <om-layer id="rast" type="CogLayer" src="https://example.com/landcover.tif"
78
+ max-error="16" opacity="0.9"></om-layer>
79
+ ```
80
+
81
+ ## Rule 4: register before the manifest mounts
82
+
83
+ Registration after mount does not retrigger reconciles — an unknown-type
84
+ layer is warn-skipped until the next DOM mutation. Call `registerLayer` at
85
+ module top level (the `registerSource`/`registerFormat` convention), before
86
+ the `<om-map>` connects.
87
+
88
+ ## Verifying without a GPU
89
+
90
+ `OmMap.snapshotIR(html)` resolves your registered type through the same
91
+ pipeline the live reconciler runs — assert the aliased URL lands on
92
+ `props.data`, dot-paths nest, functions appear as `[function]` — in plain
93
+ jsdom/happy-dom, no WebGL.
94
+
95
+ ## The programmatic alternative
96
+
97
+ On the `MapController` front-end (and the React adapter), `props` passes
98
+ function values directly — no subclass needed. The subclass recipe exists so
99
+ the **manifest** front-end can express what attributes can't.