@navaramap/three-plugins 0.0.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.
package/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2026 MapLibre contributors
2
+
3
+ This project is dual-licensed under either of
4
+
5
+ - Apache License, Version 2.0 (see LICENSE-APACHE)
6
+ - MIT license (see LICENSE-MIT)
7
+
8
+ at your option.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @navaramap/three-plugins
2
+
3
+ Optional feature plugins for `@navaramap/three`. Each plugin packages a self-contained capability on top of the core view:
4
+
5
+ - **`PersonViewPlugin`** — first-person walk mode with a controllable character (GLTF model, animations, key bindings, teleport, view-mode switching).
6
+ - **`OverlayPlugin`** — anchors DOM elements to geographic positions and keeps them projected to screen space as the camera moves.
7
+ - **`CesiumIonPlugin`** — resolves a Cesium Ion asset endpoint and registers the asset as a quantized-mesh terrain layer via `addTerrain()`.
8
+ - **`TileJsonPlugin`** — fetches a TileJSON 3.0.0 document and registers it as a Navara source, surfacing its attribution through the view's built-in credit UI.
9
+
10
+ ## Usage
11
+
12
+ Like all Navara plugins, add them before `view.init()`:
13
+
14
+ ```typescript
15
+ import ThreeView from "@navaramap/three";
16
+ import {
17
+ DefaultPlugin,
18
+ type DefaultDescriptions,
19
+ } from "@navaramap/three-default-plugin";
20
+ import { OverlayPlugin, moveOverlayElement } from "@navaramap/three-plugins";
21
+
22
+ const view = new ThreeView<DefaultDescriptions>();
23
+ view.addPlugin(new DefaultPlugin());
24
+ const overlay = new OverlayPlugin();
25
+ view.addPlugin(overlay); // must happen before init()
26
+ await view.init();
27
+
28
+ // Basemap, credited through the built-in attribution UI.
29
+ const source = view.addSource({
30
+ type: "raster-tile",
31
+ url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
32
+ maxZoom: 18,
33
+ });
34
+ view.addLayer({ type: "raster", source });
35
+ view.attribution?.add([
36
+ {
37
+ attribution: "© OpenStreetMap contributors",
38
+ attributionUrl: "https://www.openstreetmap.org/copyright",
39
+ },
40
+ ]);
41
+
42
+ // Anchor a DOM element to a geographic position.
43
+ overlay.setPositions([{ id: "tokyo", lng: 139.77, lat: 35.68, alt: 0 }]);
44
+ overlay.onUpdate(({ projected }) => {
45
+ const pos = projected.get("tokyo");
46
+ if (pos) moveOverlayElement(markerElement, pos.x, pos.y);
47
+ });
48
+ ```
49
+
50
+ `@navaramap/three`, `@navaramap/three-default-plugin`, and `three` are peer dependencies.
51
+
52
+ ## Documentation
53
+
54
+ Each plugin's options and events are documented at https://navara-docs.netlify.app/.
55
+
56
+ ## License
57
+
58
+ MIT OR Apache-2.0
@@ -0,0 +1,504 @@
1
+ import { default as default_2 } from '@navaramap/three';
2
+ import { DefaultDescriptions } from '@navaramap/three-default-plugin';
3
+ import { Layer } from '@navaramap/three';
4
+ import { Plugin as Plugin_2 } from '@navaramap/three';
5
+ import { QuantizedMeshSource } from '@navaramap/three';
6
+ import { Source } from '@navaramap/three';
7
+ import { TerrainSourceLayer } from '@navaramap/three';
8
+ import { ViewContext } from '@navaramap/three';
9
+
10
+ /**
11
+ * Animation configuration for the model.
12
+ * Clip names are model-specific — check your GLTF file for available clips.
13
+ */
14
+ export declare type AnimationConfig = {
15
+ /** Clip name played while the model is idle (no movement keys held). */
16
+ idleClip: string;
17
+ /**
18
+ * Clip name played while the model is moving without dashing.
19
+ * Omit to keep `idleClip` playing while walking — useful for models
20
+ * that only ship with idle + dash animations.
21
+ */
22
+ walkClip?: string;
23
+ /** Clip name played while the model is dashing (dash key held). */
24
+ dashClip: string;
25
+ /** Playback speed multiplier. */
26
+ speed: number;
27
+ /** Duration in seconds for cross-fade transitions between clips. */
28
+ crossfadeDuration: number;
29
+ };
30
+
31
+ export declare type CesiumIonConfig = {
32
+ /** Cesium Ion asset id. */
33
+ assetId: number | string;
34
+ /** Cesium Ion access token used to resolve the asset endpoint. */
35
+ accessToken: string;
36
+ endpoint?: string;
37
+ };
38
+
39
+ export declare class CesiumIonPlugin extends Plugin_2<View_3, ViewContext> {
40
+ private view?;
41
+ private readonly config;
42
+ private endpoint?;
43
+ constructor(config: CesiumIonConfig);
44
+ init(view: View_3, _ctx: ViewContext): Promise<void>;
45
+ /**
46
+ * Register the resolved Cesium Ion asset as a quantized-mesh terrain layer.
47
+ * Must be called after `view.init()`.
48
+ *
49
+ * Internally this creates an implicit quantized-mesh source for the asset and
50
+ * a terrain layer that references it. The source is owned by the returned
51
+ * layer: deleting the layer reclaims the source too, so callers only manage
52
+ * the layer handle.
53
+ */
54
+ addTerrain(options?: CesiumIonTerrainOptions): Layer;
55
+ }
56
+
57
+ /**
58
+ * Quantized-mesh fetch/decode options forwarded to the source.
59
+ * The `url` and `token` fields are provided by the plugin from the resolved
60
+ * Cesium Ion endpoint.
61
+ */
62
+ declare type CesiumIonSourceOptions = Omit<QuantizedMeshSource, "type" | "url" | "token" | "id">;
63
+
64
+ /**
65
+ * Options for {@link CesiumIonPlugin.addTerrain}. A flat object combining the
66
+ * quantized-mesh source's fetch/decode options with the terrain layer's render
67
+ * options; the plugin routes each field to the source or the layer.
68
+ */
69
+ export declare type CesiumIonTerrainOptions = CesiumIonSourceOptions & CesiumIonTerrainRenderOptions;
70
+
71
+ /** Terrain mesh rendering options forwarded to the terrain layer. */
72
+ declare type CesiumIonTerrainRenderOptions = NonNullable<TerrainSourceLayer["terrain"]>;
73
+
74
+ export declare type CharacterConfig = {
75
+ /** URL of the GLTF model to load. */
76
+ modelUrl: string;
77
+ /** Animation clip configuration. */
78
+ animation: AnimationConfig;
79
+ /** Rotation offset to correct the model's default orientation. */
80
+ modelRotationOffset?: ModelRotationOffset;
81
+ modelScale?: number;
82
+ /** Hide the model while the camera is in FPV. Defaults to true. */
83
+ hideModelInFpv?: boolean;
84
+ /** Whether the character casts shadows. Defaults to false. */
85
+ castShadow?: boolean;
86
+ /** Whether the character receives shadows. Defaults to false. */
87
+ receiveShadow?: boolean;
88
+ };
89
+
90
+ /**
91
+ * Keyboard bindings. Each entry takes an array of `KeyboardEvent.code`
92
+ * values (e.g. `["KeyW"]`, `["ArrowUp", "ControlLeft"]`) so multiple
93
+ * keys can trigger the same action.
94
+ */
95
+ export declare type KeyBindings = {
96
+ forward?: string[];
97
+ backward?: string[];
98
+ turnLeft?: string[];
99
+ turnRight?: string[];
100
+ ascend?: string[];
101
+ descend?: string[];
102
+ dash?: string[];
103
+ /** Hold to enable free camera (orbit) while allowCameraControl is false. */
104
+ orbitCamera?: string[];
105
+ /** Toggle between TPV and FPV. */
106
+ toggleView?: string[];
107
+ };
108
+
109
+ /**
110
+ * Model rotation offset applied to the loaded GLTF model.
111
+ * Different models face different directions by default, so this
112
+ * allows you to correct the orientation.
113
+ */
114
+ export declare type ModelRotationOffset = {
115
+ x: number;
116
+ y: number;
117
+ z: number;
118
+ };
119
+
120
+ /**
121
+ * Position an absolutely-positioned HTML element using a GPU-accelerated
122
+ * CSS `translate()` transform.
123
+ */
124
+ export declare function moveOverlayElement(el: HTMLElement, x: number, y: number): void;
125
+
126
+ export declare type OverlayConfig = {
127
+ /** Positions farther than this (in meters) are skipped. @defaultValue `100_000` (100 km) */
128
+ maxDistance?: number;
129
+ };
130
+
131
+ /**
132
+ * Projects geographic positions to screen coordinates on every render frame.
133
+ *
134
+ * Register via `view.addPlugin(overlayPlugin)` **before** `view.init()`.
135
+ * The `preRender` hook is attached automatically during initialization.
136
+ */
137
+ export declare class OverlayPlugin extends Plugin_2<View_2, ViewContext> {
138
+ private view?;
139
+ private maxDistance;
140
+ private positions;
141
+ private projected;
142
+ private listeners;
143
+ private boundHandler;
144
+ constructor(config?: OverlayConfig);
145
+ init(view: View_2, _ctx: ViewContext): Promise<void>;
146
+ /**
147
+ * Replace the set of world positions to track.
148
+ * Positions are re-projected on the next render frame automatically.
149
+ */
150
+ setPositions(positions: WorldPosition[]): void;
151
+ onUpdate(fn: UpdateListener): () => void;
152
+ dispose(): void;
153
+ private worldToScreen;
154
+ private handlePreRender;
155
+ }
156
+
157
+ export declare type OverlayState = {
158
+ projected: Map<string, ProjectedPosition>;
159
+ };
160
+
161
+ export declare type PersonViewConfig = {
162
+ character?: CharacterConfig;
163
+ /** When true, the camera is always free (no Alt-hold required). */
164
+ allowCameraControl?: boolean;
165
+ /** Initial view mode. */
166
+ initialView?: ViewMode;
167
+ /** m/s */
168
+ moveSpeed?: number;
169
+ /** deg/frame */
170
+ rotationSpeed?: number;
171
+ /** m/s */
172
+ altSpeed?: number;
173
+ minAlt?: number;
174
+ maxAlt?: number;
175
+ cameraDistance?: number;
176
+ /**
177
+ * Downward camera pitch in radians for **TPV**. `0` keeps the camera
178
+ * behind the model at eye level; positive values orbit the camera up and
179
+ * over so it looks down at the model while keeping it centered. Has no
180
+ * effect while the free camera is active (Alt-hold or `allowCameraControl`),
181
+ * where mouse drag controls the pitch instead.
182
+ */
183
+ cameraPitch?: number;
184
+ cameraLerpSpeed?: number;
185
+ /** Forward offset (meters) applied to the FPV eye position. */
186
+ fpvForwardOffset?: number;
187
+ /**
188
+ * Eye-line height offset (meters) added to the position. Used in FPV for the
189
+ * eye position and in TPV as the shared eye-line height the camera orbits
190
+ * around and aims at.
191
+ */
192
+ fpvHeightOffset?: number;
193
+ /**
194
+ * Downward camera pitch in radians for **FPV**. `0` looks straight ahead
195
+ * (horizontal); positive values tilt the view down in place without moving
196
+ * the eye. Has no effect while the free camera is active.
197
+ */
198
+ fpvPitch?: number;
199
+ startLat?: number;
200
+ startLng?: number;
201
+ startHeight?: number;
202
+ /** radians */
203
+ startHeading?: number;
204
+ keys?: KeyBindings;
205
+ };
206
+
207
+ /**
208
+ * Register via `view.addPlugin(plugin)` **before** `view.init()`,
209
+ * then call `start()` after initialization completes.
210
+ *
211
+ * ### Default controls
212
+ *
213
+ * | Key | Action |
214
+ * |-----|--------|
215
+ * | W / S | Forward / backward |
216
+ * | A / D | Turn left / right |
217
+ * | Arrow Up / Space | Climb |
218
+ * | Arrow Down / Ctrl | Descend |
219
+ * | Shift | Dash (2.5x speed) |
220
+ * | Alt | Free camera (when allowCameraControl is false) |
221
+ * | V | Toggle TPV / FPV |
222
+ */
223
+ export declare class PersonViewPlugin extends Plugin_2<View, ViewContext> {
224
+ private view?;
225
+ private config;
226
+ private character;
227
+ private keys;
228
+ private keyToAction;
229
+ private handle;
230
+ private modelRef;
231
+ private animId;
232
+ private lastTime;
233
+ private state;
234
+ private listeners;
235
+ private heldActions;
236
+ private dashMultiplier;
237
+ private orbitKeyHeld;
238
+ private orbitLatched;
239
+ private currentAnimState;
240
+ private modelHeading;
241
+ private cameraHeading;
242
+ private viewMode;
243
+ /** Set to `true` to ignore all movement keys (e.g. while a modal is open). */
244
+ movementSuppressed: boolean;
245
+ private boundKeyDown;
246
+ private boundKeyUp;
247
+ private _curPos;
248
+ private _east;
249
+ private _north;
250
+ private _worldForward;
251
+ private _offset;
252
+ private _headingMatrix;
253
+ private _characterFrame;
254
+ constructor(config?: PersonViewConfig);
255
+ init(view: View, _ctx: ViewContext): Promise<void>;
256
+ start(): void;
257
+ /** Instantly move to a new geographic position. */
258
+ teleport(options: TeleportOptions): void;
259
+ getState(): PersonViewState;
260
+ onStateChange(fn: StateListener): () => void;
261
+ setViewMode(mode: ViewMode): void;
262
+ toggleViewMode(): void;
263
+ setAllowCameraControl(value: boolean): void;
264
+ /**
265
+ * Rotate the character to the given heading in radians (0 = north,
266
+ * increasing clockwise) without changing position. Snaps the chase camera to
267
+ * match; in free-camera mode only the model rotates.
268
+ */
269
+ setHeading(radians: number): void;
270
+ /** Current character heading in radians. */
271
+ getHeading(): number;
272
+ /**
273
+ * Set the downward TPV camera pitch in radians (0 = behind at eye level,
274
+ * positive orbits up and over the model). Takes effect immediately for the
275
+ * chase / locked camera.
276
+ */
277
+ setCameraPitch(radians: number): void;
278
+ /** Current downward TPV camera pitch in radians. */
279
+ getCameraPitch(): number;
280
+ /**
281
+ * Set the downward FPV camera pitch in radians (0 = horizontal, positive
282
+ * tilts the view down in place). Takes effect immediately for the chase /
283
+ * locked camera.
284
+ */
285
+ setFpvPitch(radians: number): void;
286
+ /** Current downward FPV camera pitch in radians. */
287
+ getFpvPitch(): number;
288
+ /**
289
+ * Set the FPV eye height offset in meters (added to the position to get the
290
+ * eye height). Also used as the shared eye-line height in TPV. Takes effect
291
+ * immediately for the chase / locked camera.
292
+ */
293
+ setFpvHeightOffset(meters: number): void;
294
+ /** Current FPV eye height offset in meters. */
295
+ getFpvHeightOffset(): number;
296
+ dispose(): void;
297
+ private resolveCharacter;
298
+ private buildKeyMap;
299
+ private isFreeCamera;
300
+ private isMovementAction;
301
+ private applyModelVisibility;
302
+ private applyInitialCamera;
303
+ /**
304
+ * Chase / locked camera placement. Caller supplies the heading (radians)
305
+ * to use — lerped during the per-frame loop, snapped on teleport / init.
306
+ *
307
+ * Pitch is per-mode: FPV uses `fpvPitch` and tilts the view down in place
308
+ * (the eye stays fixed); TPV uses `cameraPitch` and orbits the camera around
309
+ * the model, staying aimed at it so the model remains centered.
310
+ */
311
+ private placeChaseCamera;
312
+ /**
313
+ * Compose the character's world matrix as `ENU * Rz(-heading)`.
314
+ *
315
+ * The ENU frame already places the model at the right spot on the
316
+ * globe with +Z pointing world-up. Multiplying by a Z-rotation of
317
+ * `-heading` (negated because three.js Rz is counter-clockwise when
318
+ * viewed from +Z, while our heading convention is clockwise from
319
+ * north) bakes the heading into the world matrix.
320
+ *
321
+ * The character's `rotation` field is therefore left as the static
322
+ * `modelRotationOffset` only — its job is to position the model in
323
+ * its "facing-north, upright" rest pose. Heading then rotates the
324
+ * already-upright model around world-up, regardless of which axis
325
+ * the GLTF treats as forward.
326
+ */
327
+ private composeCharacterFrame;
328
+ /** Advance a (lat,lng) point by `meters` along the given ENU heading. */
329
+ private advanceLatLng;
330
+ private notify;
331
+ private onKeyDown;
332
+ private onKeyUp;
333
+ private tick;
334
+ private hasStateChanged;
335
+ }
336
+
337
+ export declare type PersonViewState = {
338
+ lng: number;
339
+ lat: number;
340
+ alt: number;
341
+ /** Heading in radians — 0 = north, increasing clockwise. */
342
+ heading: number;
343
+ speed: number;
344
+ /** Current animation clip name, or null when no character is configured. */
345
+ animationState: string | null;
346
+ mode: ViewMode;
347
+ };
348
+
349
+ export declare type ProjectedPosition = {
350
+ x: number;
351
+ y: number;
352
+ /** Distance from the camera in meters (ECEF euclidean). */
353
+ distance: number;
354
+ };
355
+
356
+ declare type StateListener = (s: PersonViewState) => void;
357
+
358
+ /** Options for {@link PersonViewPlugin.teleport}. */
359
+ export declare type TeleportOptions = {
360
+ /** Longitude in degrees. */
361
+ lng: number;
362
+ /** Latitude in degrees. */
363
+ lat: number;
364
+ /** Altitude in meters. */
365
+ alt: number;
366
+ /**
367
+ * Heading in radians (0 = north, increasing clockwise). If omitted, the
368
+ * current camera heading is kept. (Use `setHeading` to rotate in place
369
+ * without moving, and `setCameraPitch` / `setFpvPitch` for camera pitch.)
370
+ */
371
+ heading?: number;
372
+ };
373
+
374
+ /**
375
+ * The subset of a TileJSON 3.0.0 document this plugin consumes. Other spec
376
+ * fields (`bounds`, `center`, `grids`, ...) are ignored.
377
+ */
378
+ export declare type TileJson = {
379
+ /** Semver of the TileJSON spec the document conforms to, e.g. `"3.0.0"`. */
380
+ tilejson: string;
381
+ /** Tile URL templates (`{z}/{x}/{y}`). Required and non-empty per the spec. */
382
+ tiles: string[];
383
+ /** Attribution/credit HTML shown through the view's attribution UI. */
384
+ attribution?: string;
385
+ /** Minimum zoom level. Applied to raster sources only. @defaultValue 0 */
386
+ minzoom?: number;
387
+ /** Maximum zoom level. @defaultValue 30 */
388
+ maxzoom?: number;
389
+ /** Tiling scheme. `"tms"` flips the Y axis (raster sources only). @defaultValue "xyz" */
390
+ scheme?: "xyz" | "tms";
391
+ };
392
+
393
+ /** Detail passed to the {@link TileJsonPlugin} `loaded` event. */
394
+ export declare type TileJsonLoadedEvent = {
395
+ /** The Navara source created for the document. */
396
+ source: Source;
397
+ /** The fetched and validated TileJSON document. */
398
+ tilejson: TileJson;
399
+ /** The document's `attribution` HTML, when it declares one. */
400
+ attribution?: string;
401
+ };
402
+
403
+ export declare class TileJsonPlugin extends Plugin_2<View_4, ViewContext> {
404
+ private view?;
405
+ private readonly events;
406
+ /** Credits pushed into `view.attribution`, tracked so dispose() can drop them. */
407
+ private readonly credits;
408
+ init(view: View_4, _ctx: ViewContext): Promise<void>;
409
+ /**
410
+ * Subscribe to a plugin event. Use `loaded` to receive each document's
411
+ * attribution (and the created source) for a custom credit UI.
412
+ */
413
+ on<E extends keyof TileJsonPluginEventMap>(event: E, listener: TileJsonPluginEventMap[E]): void;
414
+ /** Subscribe to a plugin event for a single emission, then auto-unsubscribe. */
415
+ once<E extends keyof TileJsonPluginEventMap>(event: E, listener: TileJsonPluginEventMap[E]): void;
416
+ /** Remove a previously registered listener. */
417
+ off<E extends keyof TileJsonPluginEventMap>(event: E, listener: TileJsonPluginEventMap[E]): void;
418
+ /**
419
+ * Fetch a TileJSON document and register it as a single Navara source.
420
+ *
421
+ * `desc.url` is the TileJSON document URL; the plugin fetches it, then creates
422
+ * one source of the requested `desc.type` using the document's first tile
423
+ * endpoint, `minzoom`/`maxzoom`, and `scheme`. The document's `attribution` is
424
+ * surfaced through the view's built-in attribution UI (`view.attribution`), and
425
+ * a `loaded` event is emitted carrying the source, document, and attribution.
426
+ *
427
+ * A TileJSON `tiles` array lists mirror endpoints for the same tileset (as in
428
+ * MapLibre, which shards requests across them). Navara sources take a single
429
+ * URL, so only the first endpoint is used; extra endpoints are ignored with a
430
+ * warning.
431
+ *
432
+ * Must be called after `view.init()`. Returns the created {@link Source}
433
+ * handle; layers can reference it by handle or by `desc.id`.
434
+ */
435
+ addSource(desc: TileJsonSourceDescription): Promise<Source>;
436
+ dispose(): void;
437
+ private fetchTileJson;
438
+ /**
439
+ * Add a credit to the view's built-in attribution UI, deduped so repeated or
440
+ * shared credits render once. No-op when the built-in UI is disabled
441
+ * (`defaultAttribution: false`) — those callers use the `loaded` event instead.
442
+ */
443
+ private addCredit;
444
+ }
445
+
446
+ /**
447
+ * Events emitted by {@link TileJsonPlugin}. Subscribe via
448
+ * {@link TileJsonPlugin.on} / {@link TileJsonPlugin.once}.
449
+ */
450
+ export declare type TileJsonPluginEventMap = {
451
+ /**
452
+ * Fired once per successful {@link TileJsonPlugin.addSource}, after the source
453
+ * is registered. Carries the created source, the parsed document, and its
454
+ * attribution so callers can drive a custom credit UI.
455
+ */
456
+ loaded: (event: TileJsonLoadedEvent) => void;
457
+ };
458
+
459
+ /**
460
+ * Describes a TileJSON tile source to register via {@link TileJsonPlugin.addSource}.
461
+ * Mirrors the shape of {@link ThreeView.addSource}, except `url` is the TileJSON
462
+ * document URL (not a tile template) — the plugin fetches it and derives the tile
463
+ * URL, zoom range, and attribution from the document.
464
+ */
465
+ export declare type TileJsonSourceDescription = {
466
+ /** Navara source type to create, as in `addSource`. */
467
+ type: TileJsonSourceType;
468
+ /** URL of the TileJSON 3.0.0 document to fetch and expand. */
469
+ url: string;
470
+ /**
471
+ * Optional caller-provided source id. Handy for referencing the source from
472
+ * layers by id (`addLayer({ source: "<id>" })`) without holding the returned
473
+ * handle. When omitted, the engine generates one.
474
+ */
475
+ id?: string;
476
+ };
477
+
478
+ /**
479
+ * Which Navara source type a TileJSON document is materialized into. Declared by
480
+ * the caller because TileJSON has no field that reliably distinguishes raster
481
+ * imagery from vector tiles.
482
+ */
483
+ export declare type TileJsonSourceType = "raster-tile" | "vector-tile";
484
+
485
+ declare type UpdateListener = (state: OverlayState) => void;
486
+
487
+ declare type View = default_2<DefaultDescriptions>;
488
+
489
+ declare type View_2 = default_2<DefaultDescriptions>;
490
+
491
+ declare type View_3 = default_2<DefaultDescriptions>;
492
+
493
+ declare type View_4 = default_2<DefaultDescriptions>;
494
+
495
+ export declare type ViewMode = "tpv" | "fpv";
496
+
497
+ export declare type WorldPosition = {
498
+ id: string;
499
+ lng: number;
500
+ lat: number;
501
+ alt: number;
502
+ };
503
+
504
+ export { }