@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.
@@ -0,0 +1,371 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+
3
+ import { TileJsonPlugin, type TileJson } from "./TileJsonPlugin";
4
+
5
+ // Importing the real @navaramap/three touches WASM/os at module load, which fails
6
+ // in the test environment. TileJsonPlugin only needs `Plugin` and `EventHandler`
7
+ // as runtime values (everything else it imports from there is type-only), so
8
+ // stub the module. The EventHandler stub mirrors the real on/once/off/emit/clear.
9
+ /* eslint-disable @typescript-eslint/no-extraneous-class */
10
+ vi.mock("@navaramap/three", () => ({
11
+ default: class ThreeView {},
12
+ Plugin: class Plugin {},
13
+ EventHandler: class EventHandler {
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ private listeners = new Map<string, Set<(...a: any[]) => void>>();
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ on(k: string, f: (...a: any[]) => void) {
18
+ let set = this.listeners.get(k);
19
+ if (!set) {
20
+ set = new Set();
21
+ this.listeners.set(k, set);
22
+ }
23
+ set.add(f);
24
+ }
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ once(k: string, f: (...a: any[]) => void) {
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ const wrap = (...a: any[]) => {
29
+ this.off(k, wrap);
30
+ f(...a);
31
+ };
32
+ this.on(k, wrap);
33
+ }
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ off(k: string, f: (...a: any[]) => void) {
36
+ this.listeners.get(k)?.delete(f);
37
+ }
38
+ clear(k: string) {
39
+ this.listeners.get(k)?.clear();
40
+ }
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ emit(k: string, ...a: any[]) {
43
+ [...(this.listeners.get(k) ?? [])].forEach((f) => f(...a));
44
+ }
45
+ },
46
+ }));
47
+ /* eslint-enable @typescript-eslint/no-extraneous-class */
48
+
49
+ // A spyable fake of the view-owned attribution UI (`view.attribution`).
50
+ function makeFakeAttribution() {
51
+ return { add: vi.fn(), remove: vi.fn() };
52
+ }
53
+
54
+ // A minimal fake of the pieces of ThreeView / Source that TileJsonPlugin touches.
55
+ function makeFakeView(attribution: unknown = makeFakeAttribution()) {
56
+ let counter = 0;
57
+ const view = {
58
+ attribution,
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ addSource: vi.fn((desc: any) => ({
61
+ id: desc.id ?? `src-${counter++}`,
62
+ type: desc.type,
63
+ })),
64
+ };
65
+ return { view };
66
+ }
67
+
68
+ function initPlugin(view: unknown) {
69
+ const plugin = new TileJsonPlugin();
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ plugin.init(view as any, {} as any);
72
+ return plugin;
73
+ }
74
+
75
+ // Stub fetch so addSource() resolves the given TileJSON document from its URL.
76
+ function stubTileJson(doc: unknown, ok = true) {
77
+ vi.stubGlobal(
78
+ "fetch",
79
+ vi.fn(async () => ({
80
+ ok,
81
+ status: ok ? 200 : 404,
82
+ statusText: ok ? "OK" : "Not Found",
83
+ json: async () => doc,
84
+ })),
85
+ );
86
+ }
87
+
88
+ const RASTER_DOC: TileJson = {
89
+ tilejson: "3.0.0",
90
+ tiles: ["https://a.example/{z}/{x}/{y}.png"],
91
+ attribution: "© Example",
92
+ minzoom: 3,
93
+ maxzoom: 18,
94
+ };
95
+
96
+ const DOC_URL = "https://example/tiles.json";
97
+
98
+ afterEach(() => {
99
+ vi.unstubAllGlobals();
100
+ vi.clearAllMocks();
101
+ });
102
+
103
+ describe("TileJsonPlugin.addSource", () => {
104
+ it("fetches the document and creates a raster-tile source with zoom + id mapped", async () => {
105
+ const { view } = makeFakeView();
106
+ const plugin = initPlugin(view);
107
+ stubTileJson({
108
+ tilejson: "3.0.0",
109
+ tiles: ["https://a.example/{z}/{x}/{y}.png"],
110
+ minzoom: 5,
111
+ maxzoom: 16,
112
+ } satisfies TileJson);
113
+
114
+ const source = await plugin.addSource({
115
+ type: "raster-tile",
116
+ id: "basemap",
117
+ url: DOC_URL,
118
+ });
119
+
120
+ expect(fetch).toHaveBeenCalledWith(DOC_URL);
121
+ expect(view.addSource).toHaveBeenCalledTimes(1);
122
+ expect(view.addSource).toHaveBeenCalledWith({
123
+ type: "raster-tile",
124
+ url: "https://a.example/{z}/{x}/{y}.png",
125
+ id: "basemap",
126
+ minZoom: 5,
127
+ maxZoom: 16,
128
+ });
129
+ // A single Source handle is returned, carrying the caller-provided id.
130
+ expect(source).toMatchObject({ id: "basemap", type: "raster-tile" });
131
+ });
132
+
133
+ it("uses only the first tile endpoint when the document lists mirrors", async () => {
134
+ const { view } = makeFakeView();
135
+ const plugin = initPlugin(view);
136
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
137
+ stubTileJson({
138
+ tilejson: "3.0.0",
139
+ tiles: [
140
+ "https://a.example/{z}/{x}/{y}.png",
141
+ "https://b.example/{z}/{x}/{y}.png",
142
+ ],
143
+ } satisfies TileJson);
144
+
145
+ await plugin.addSource({ type: "raster-tile", url: DOC_URL });
146
+
147
+ expect(view.addSource).toHaveBeenCalledTimes(1);
148
+ expect(view.addSource).toHaveBeenCalledWith(
149
+ expect.objectContaining({ url: "https://a.example/{z}/{x}/{y}.png" }),
150
+ );
151
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(/mirror/));
152
+ warn.mockRestore();
153
+ });
154
+
155
+ it("sets tms on raster sources when scheme is tms", async () => {
156
+ const { view } = makeFakeView();
157
+ const plugin = initPlugin(view);
158
+ stubTileJson({
159
+ tilejson: "3.0.0",
160
+ tiles: ["https://a/{z}/{x}/{y}.png"],
161
+ scheme: "tms",
162
+ } satisfies TileJson);
163
+
164
+ await plugin.addSource({ type: "raster-tile", url: DOC_URL });
165
+
166
+ expect(view.addSource).toHaveBeenCalledWith(
167
+ expect.objectContaining({ tms: true }),
168
+ );
169
+ });
170
+
171
+ it("omits minZoom/tms for vector-tile sources (engine has no such fields)", async () => {
172
+ const { view } = makeFakeView();
173
+ const plugin = initPlugin(view);
174
+ stubTileJson({
175
+ tilejson: "3.0.0",
176
+ tiles: ["https://a/{z}/{x}/{y}.pbf"],
177
+ minzoom: 4,
178
+ maxzoom: 14,
179
+ scheme: "tms",
180
+ } satisfies TileJson);
181
+
182
+ await plugin.addSource({ type: "vector-tile", id: "v", url: DOC_URL });
183
+
184
+ const desc = view.addSource.mock.calls[0][0];
185
+ expect(desc).toEqual({
186
+ type: "vector-tile",
187
+ url: "https://a/{z}/{x}/{y}.pbf",
188
+ id: "v",
189
+ maxZoom: 14,
190
+ });
191
+ expect(desc).not.toHaveProperty("minZoom");
192
+ expect(desc).not.toHaveProperty("tms");
193
+ });
194
+
195
+ it("throws when the TileJSON fetch fails", async () => {
196
+ const { view } = makeFakeView();
197
+ const plugin = initPlugin(view);
198
+ stubTileJson({}, false);
199
+
200
+ await expect(
201
+ plugin.addSource({
202
+ type: "raster-tile",
203
+ url: "https://example/missing.json",
204
+ }),
205
+ ).rejects.toThrow(/failed to fetch TileJSON/);
206
+ });
207
+
208
+ it("rejects an invalid tilejson version field", async () => {
209
+ const { view } = makeFakeView();
210
+ const plugin = initPlugin(view);
211
+ stubTileJson({ tilejson: "nope", tiles: ["https://a/{z}/{x}/{y}.png"] });
212
+
213
+ await expect(
214
+ plugin.addSource({ type: "raster-tile", url: DOC_URL }),
215
+ ).rejects.toThrow(/tilejson.*version/i);
216
+ });
217
+
218
+ it("rejects a missing/empty tiles array", async () => {
219
+ const { view } = makeFakeView();
220
+ const plugin = initPlugin(view);
221
+ stubTileJson({ tilejson: "3.0.0", tiles: [] });
222
+
223
+ await expect(
224
+ plugin.addSource({ type: "raster-tile", url: DOC_URL }),
225
+ ).rejects.toThrow(/tiles.*non-empty/);
226
+ });
227
+
228
+ it("throws when called before init()", async () => {
229
+ const plugin = new TileJsonPlugin();
230
+ await expect(
231
+ plugin.addSource({ type: "raster-tile", url: DOC_URL }),
232
+ ).rejects.toThrow(/after view\.init/);
233
+ });
234
+ });
235
+
236
+ describe("TileJsonPlugin attribution wiring", () => {
237
+ it("shows the document's attribution through the view's built-in attribution UI", async () => {
238
+ const attribution = makeFakeAttribution();
239
+ const { view } = makeFakeView(attribution);
240
+ const plugin = initPlugin(view);
241
+ stubTileJson(RASTER_DOC);
242
+
243
+ await plugin.addSource({ type: "raster-tile", url: DOC_URL });
244
+ expect(attribution.add).toHaveBeenCalledWith([
245
+ { attributionHtml: "© Example" },
246
+ ]);
247
+ });
248
+
249
+ it("skips attribution when the built-in UI is disabled (view.attribution undefined)", async () => {
250
+ const { view } = makeFakeView(undefined);
251
+ const plugin = initPlugin(view);
252
+ stubTileJson(RASTER_DOC);
253
+
254
+ // No throw even though there's no attribution UI to feed.
255
+ await expect(
256
+ plugin.addSource({ type: "raster-tile", url: DOC_URL }),
257
+ ).resolves.toMatchObject({ type: "raster-tile" });
258
+ });
259
+
260
+ it("removes only its own credits from the view-owned UI on dispose", async () => {
261
+ const attribution = makeFakeAttribution();
262
+ const { view } = makeFakeView(attribution);
263
+ const plugin = initPlugin(view);
264
+
265
+ stubTileJson(RASTER_DOC);
266
+ await plugin.addSource({ type: "raster-tile", url: DOC_URL });
267
+ attribution.add.mockClear();
268
+
269
+ plugin.dispose();
270
+ // The view owns the UI, so dispose drops only the credits this plugin added
271
+ // (not the whole UI), matched structurally by their html.
272
+ expect(attribution.remove).toHaveBeenCalledWith([
273
+ { attributionHtml: "© Example" },
274
+ ]);
275
+ expect(attribution.add).not.toHaveBeenCalled();
276
+ });
277
+
278
+ it("does not touch the view-owned UI on dispose when it contributed no credits", async () => {
279
+ const attribution = makeFakeAttribution();
280
+ const { view } = makeFakeView(attribution);
281
+ const plugin = initPlugin(view);
282
+
283
+ plugin.dispose();
284
+ expect(attribution.remove).not.toHaveBeenCalled();
285
+ });
286
+
287
+ it("de-duplicates credits across multiple addSource() calls", async () => {
288
+ const attribution = makeFakeAttribution();
289
+ const { view } = makeFakeView(attribution);
290
+ const plugin = initPlugin(view);
291
+
292
+ stubTileJson(RASTER_DOC);
293
+ await plugin.addSource({ type: "raster-tile", url: DOC_URL });
294
+
295
+ stubTileJson({
296
+ tilejson: "3.0.0",
297
+ tiles: ["https://c/{z}/{x}/{y}.png"],
298
+ attribution: "© Other",
299
+ } satisfies TileJson);
300
+ await plugin.addSource({ type: "raster-tile", url: DOC_URL });
301
+
302
+ // Same credit again — should not be re-added to the UI.
303
+ stubTileJson({
304
+ tilejson: "3.0.0",
305
+ tiles: ["https://d/{z}/{x}/{y}.png"],
306
+ attribution: "© Example",
307
+ } satisfies TileJson);
308
+ await plugin.addSource({ type: "raster-tile", url: DOC_URL });
309
+
310
+ expect(attribution.add).toHaveBeenCalledTimes(2);
311
+ expect(attribution.add).toHaveBeenNthCalledWith(1, [
312
+ { attributionHtml: "© Example" },
313
+ ]);
314
+ expect(attribution.add).toHaveBeenNthCalledWith(2, [
315
+ { attributionHtml: "© Other" },
316
+ ]);
317
+ });
318
+ });
319
+
320
+ describe("TileJsonPlugin loaded event", () => {
321
+ it("emits `loaded` with the source, document, and attribution after addSource", async () => {
322
+ const { view } = makeFakeView();
323
+ const plugin = initPlugin(view);
324
+ stubTileJson(RASTER_DOC);
325
+
326
+ const onLoaded = vi.fn();
327
+ plugin.on("loaded", onLoaded);
328
+
329
+ const source = await plugin.addSource({
330
+ type: "raster-tile",
331
+ url: DOC_URL,
332
+ });
333
+
334
+ expect(onLoaded).toHaveBeenCalledTimes(1);
335
+ expect(onLoaded).toHaveBeenCalledWith({
336
+ source,
337
+ tilejson: RASTER_DOC,
338
+ attribution: "© Example",
339
+ });
340
+ });
341
+
342
+ it("emits `loaded` with attribution undefined when the document declares none", async () => {
343
+ const { view } = makeFakeView();
344
+ const plugin = initPlugin(view);
345
+ stubTileJson({
346
+ tilejson: "3.0.0",
347
+ tiles: ["https://a/{z}/{x}/{y}.png"],
348
+ } satisfies TileJson);
349
+
350
+ const onLoaded = vi.fn();
351
+ plugin.on("loaded", onLoaded);
352
+
353
+ await plugin.addSource({ type: "raster-tile", url: DOC_URL });
354
+ expect(onLoaded).toHaveBeenCalledWith(
355
+ expect.objectContaining({ attribution: undefined }),
356
+ );
357
+ });
358
+
359
+ it("off() stops delivery to a listener", async () => {
360
+ const { view } = makeFakeView();
361
+ const plugin = initPlugin(view);
362
+ stubTileJson(RASTER_DOC);
363
+
364
+ const onLoaded = vi.fn();
365
+ plugin.on("loaded", onLoaded);
366
+ plugin.off("loaded", onLoaded);
367
+
368
+ await plugin.addSource({ type: "raster-tile", url: DOC_URL });
369
+ expect(onLoaded).not.toHaveBeenCalled();
370
+ });
371
+ });
@@ -0,0 +1,301 @@
1
+ /**
2
+ * TileJsonPlugin — Navara Plugin for TileJSON 3.0.0 tile sources.
3
+ *
4
+ * Fetches a
5
+ * {@link https://github.com/mapbox/tilejson-spec/tree/master/3.0.0 | TileJSON 3.0.0}
6
+ * document and registers it as a single Navara source. {@link TileJsonPlugin.addSource}
7
+ * mirrors {@link ThreeView.addSource}: a discriminated `type` plus an optional
8
+ * `id`, with `url` pointing at the TileJSON document. The plugin derives the tile
9
+ * URL, `minzoom`/`maxzoom`, and `scheme` from the document.
10
+ *
11
+ * Each document's `attribution` is surfaced automatically through the view's
12
+ * built-in attribution UI (`view.attribution`). Callers who want a custom credit
13
+ * UI can opt out of the built-in one (`new ThreeView({ defaultAttribution: false })`)
14
+ * and read credits from the {@link TileJsonPlugin.on | `loaded`} event instead.
15
+ *
16
+ * ## Usage
17
+ *
18
+ * ```ts
19
+ * import ThreeView from "@navaramap/three";
20
+ * import { TileJsonPlugin } from "@navaramap/three-plugins";
21
+ *
22
+ * const view = new ThreeView({ container });
23
+ * const tilejson = new TileJsonPlugin();
24
+ * view.addPlugin(tilejson);
25
+ * await view.init();
26
+ *
27
+ * // Fetch a TileJSON document and register it as a source. Its `attribution`
28
+ * // shows in the built-in credit UI automatically.
29
+ * const source = await tilejson.addSource({
30
+ * type: "raster-tile",
31
+ * id: "basemap",
32
+ * url: "https://example.com/tiles.json",
33
+ * });
34
+ *
35
+ * // Reference the source by handle...
36
+ * view.addLayer({ type: "raster", source });
37
+ * // ...or directly by the id passed above.
38
+ * view.addLayer({ type: "raster", source: "basemap" });
39
+ * ```
40
+ *
41
+ * ### Custom attribution UI
42
+ *
43
+ * ```ts
44
+ * // Turn off the built-in UI and render credits yourself.
45
+ * const view = new ThreeView({ container, defaultAttribution: false });
46
+ * const tilejson = new TileJsonPlugin();
47
+ * view.addPlugin(tilejson);
48
+ * await view.init();
49
+ *
50
+ * tilejson.on("loaded", ({ source, attribution }) => {
51
+ * if (attribution) renderMyCredit(source.id, attribution);
52
+ * });
53
+ * ```
54
+ */
55
+ import ThreeView, {
56
+ Plugin,
57
+ EventHandler,
58
+ type Source,
59
+ type ViewContext,
60
+ } from "@navaramap/three";
61
+ import type { DefaultDescriptions } from "@navaramap/three-default-plugin";
62
+
63
+ type View = ThreeView<DefaultDescriptions>;
64
+
65
+ /** Matches a TileJSON version such as "3.0.0" (major.minor.patch). */
66
+ const TILEJSON_VERSION = /^3\.\d+\.\d+$/;
67
+
68
+ /**
69
+ * The subset of a TileJSON 3.0.0 document this plugin consumes. Other spec
70
+ * fields (`bounds`, `center`, `grids`, ...) are ignored.
71
+ */
72
+ export type TileJson = {
73
+ /** Semver of the TileJSON spec the document conforms to, e.g. `"3.0.0"`. */
74
+ tilejson: string;
75
+ /** Tile URL templates (`{z}/{x}/{y}`). Required and non-empty per the spec. */
76
+ tiles: string[];
77
+ /** Attribution/credit HTML shown through the view's attribution UI. */
78
+ attribution?: string;
79
+ /** Minimum zoom level. Applied to raster sources only. @defaultValue 0 */
80
+ minzoom?: number;
81
+ /** Maximum zoom level. @defaultValue 30 */
82
+ maxzoom?: number;
83
+ /** Tiling scheme. `"tms"` flips the Y axis (raster sources only). @defaultValue "xyz" */
84
+ scheme?: "xyz" | "tms";
85
+ };
86
+
87
+ /**
88
+ * Which Navara source type a TileJSON document is materialized into. Declared by
89
+ * the caller because TileJSON has no field that reliably distinguishes raster
90
+ * imagery from vector tiles.
91
+ */
92
+ export type TileJsonSourceType = "raster-tile" | "vector-tile";
93
+
94
+ /**
95
+ * Describes a TileJSON tile source to register via {@link TileJsonPlugin.addSource}.
96
+ * Mirrors the shape of {@link ThreeView.addSource}, except `url` is the TileJSON
97
+ * document URL (not a tile template) — the plugin fetches it and derives the tile
98
+ * URL, zoom range, and attribution from the document.
99
+ */
100
+ export type TileJsonSourceDescription = {
101
+ /** Navara source type to create, as in `addSource`. */
102
+ type: TileJsonSourceType;
103
+ /** URL of the TileJSON 3.0.0 document to fetch and expand. */
104
+ url: string;
105
+ /**
106
+ * Optional caller-provided source id. Handy for referencing the source from
107
+ * layers by id (`addLayer({ source: "<id>" })`) without holding the returned
108
+ * handle. When omitted, the engine generates one.
109
+ */
110
+ id?: string;
111
+ };
112
+
113
+ /** Detail passed to the {@link TileJsonPlugin} `loaded` event. */
114
+ export type TileJsonLoadedEvent = {
115
+ /** The Navara source created for the document. */
116
+ source: Source;
117
+ /** The fetched and validated TileJSON document. */
118
+ tilejson: TileJson;
119
+ /** The document's `attribution` HTML, when it declares one. */
120
+ attribution?: string;
121
+ };
122
+
123
+ /**
124
+ * Events emitted by {@link TileJsonPlugin}. Subscribe via
125
+ * {@link TileJsonPlugin.on} / {@link TileJsonPlugin.once}.
126
+ */
127
+ export type TileJsonPluginEventMap = {
128
+ /**
129
+ * Fired once per successful {@link TileJsonPlugin.addSource}, after the source
130
+ * is registered. Carries the created source, the parsed document, and its
131
+ * attribution so callers can drive a custom credit UI.
132
+ */
133
+ loaded: (event: TileJsonLoadedEvent) => void;
134
+ };
135
+
136
+ /**
137
+ * Validates the fields this plugin relies on. Throws with an actionable message
138
+ * when the `tilejson` version or the `tiles` array is missing or malformed.
139
+ */
140
+ function validateTileJson(doc: TileJson): void {
141
+ if (
142
+ typeof doc.tilejson !== "string" ||
143
+ !TILEJSON_VERSION.test(doc.tilejson)
144
+ ) {
145
+ throw new Error(
146
+ `TileJsonPlugin: invalid or missing "tilejson" version field (got ${JSON.stringify(doc.tilejson)}).`,
147
+ );
148
+ }
149
+ if (!Array.isArray(doc.tiles) || doc.tiles.length === 0) {
150
+ throw new Error(
151
+ 'TileJsonPlugin: "tiles" must be a non-empty array of URL templates.',
152
+ );
153
+ }
154
+ }
155
+
156
+ export class TileJsonPlugin extends Plugin<View, ViewContext> {
157
+ private view?: View;
158
+ private readonly events = new EventHandler<TileJsonPluginEventMap>();
159
+ /** Credits pushed into `view.attribution`, tracked so dispose() can drop them. */
160
+ private readonly credits: string[] = [];
161
+
162
+ async init(view: View, _ctx: ViewContext): Promise<void> {
163
+ this.view = view;
164
+ }
165
+
166
+ /**
167
+ * Subscribe to a plugin event. Use `loaded` to receive each document's
168
+ * attribution (and the created source) for a custom credit UI.
169
+ */
170
+ on<E extends keyof TileJsonPluginEventMap>(
171
+ event: E,
172
+ listener: TileJsonPluginEventMap[E],
173
+ ): void {
174
+ this.events.on(event, listener);
175
+ }
176
+
177
+ /** Subscribe to a plugin event for a single emission, then auto-unsubscribe. */
178
+ once<E extends keyof TileJsonPluginEventMap>(
179
+ event: E,
180
+ listener: TileJsonPluginEventMap[E],
181
+ ): void {
182
+ this.events.once(event, listener);
183
+ }
184
+
185
+ /** Remove a previously registered listener. */
186
+ off<E extends keyof TileJsonPluginEventMap>(
187
+ event: E,
188
+ listener: TileJsonPluginEventMap[E],
189
+ ): void {
190
+ this.events.off(event, listener);
191
+ }
192
+
193
+ /**
194
+ * Fetch a TileJSON document and register it as a single Navara source.
195
+ *
196
+ * `desc.url` is the TileJSON document URL; the plugin fetches it, then creates
197
+ * one source of the requested `desc.type` using the document's first tile
198
+ * endpoint, `minzoom`/`maxzoom`, and `scheme`. The document's `attribution` is
199
+ * surfaced through the view's built-in attribution UI (`view.attribution`), and
200
+ * a `loaded` event is emitted carrying the source, document, and attribution.
201
+ *
202
+ * A TileJSON `tiles` array lists mirror endpoints for the same tileset (as in
203
+ * MapLibre, which shards requests across them). Navara sources take a single
204
+ * URL, so only the first endpoint is used; extra endpoints are ignored with a
205
+ * warning.
206
+ *
207
+ * Must be called after `view.init()`. Returns the created {@link Source}
208
+ * handle; layers can reference it by handle or by `desc.id`.
209
+ */
210
+ async addSource(desc: TileJsonSourceDescription): Promise<Source> {
211
+ const view = this.view;
212
+ if (!view) {
213
+ throw new Error(
214
+ "TileJsonPlugin: addSource() must be called after view.init().",
215
+ );
216
+ }
217
+
218
+ const doc = await this.fetchTileJson(desc.url);
219
+ validateTileJson(doc);
220
+
221
+ // `tiles` lists mirror endpoints for one tileset; Navara sources take a
222
+ // single URL, so use the first and note any dropped mirrors.
223
+ const [url, ...mirrors] = doc.tiles;
224
+ if (mirrors.length > 0) {
225
+ console.warn(
226
+ `TileJsonPlugin: TileJSON at ${desc.url} lists ${doc.tiles.length} mirror ` +
227
+ `tile endpoints; Navara sources use a single URL, so only the first is used.`,
228
+ );
229
+ }
230
+
231
+ const { minzoom, maxzoom, scheme } = doc;
232
+ const id = desc.id;
233
+ const source =
234
+ desc.type === "raster-tile"
235
+ ? view.addSource({
236
+ type: "raster-tile",
237
+ url,
238
+ ...(id !== undefined ? { id } : {}),
239
+ ...(minzoom !== undefined ? { minZoom: minzoom } : {}),
240
+ ...(maxzoom !== undefined ? { maxZoom: maxzoom } : {}),
241
+ ...(scheme === "tms" ? { tms: true } : {}),
242
+ })
243
+ : // vector-tile sources have no minZoom / tms fields in the engine, so
244
+ // only maxzoom carries over.
245
+ view.addSource({
246
+ type: "vector-tile",
247
+ url,
248
+ ...(id !== undefined ? { id } : {}),
249
+ ...(maxzoom !== undefined ? { maxZoom: maxzoom } : {}),
250
+ });
251
+
252
+ if (doc.attribution) {
253
+ this.addCredit(doc.attribution);
254
+ }
255
+
256
+ this.events.emit("loaded", {
257
+ source,
258
+ tilejson: doc,
259
+ attribution: doc.attribution,
260
+ });
261
+
262
+ return source;
263
+ }
264
+
265
+ dispose(): void {
266
+ // Drop the credits this plugin contributed to the view-owned attribution UI
267
+ // so a disposed plugin doesn't leave stale credits behind on a live view.
268
+ // The view owns the UI's lifecycle; when the view is disposed first,
269
+ // `view.attribution` is already gone and this is a no-op.
270
+ const attribution = this.view?.attribution;
271
+ if (attribution && this.credits.length > 0) {
272
+ attribution.remove(
273
+ this.credits.map((attributionHtml) => ({ attributionHtml })),
274
+ );
275
+ }
276
+ this.credits.length = 0;
277
+ this.events.clear("loaded");
278
+ this.view = undefined;
279
+ }
280
+
281
+ private async fetchTileJson(url: string): Promise<TileJson> {
282
+ const res = await fetch(url);
283
+ if (!res.ok) {
284
+ throw new Error(
285
+ `TileJsonPlugin: failed to fetch TileJSON from ${url} (${res.status} ${res.statusText})`,
286
+ );
287
+ }
288
+ return (await res.json()) as TileJson;
289
+ }
290
+
291
+ /**
292
+ * Add a credit to the view's built-in attribution UI, deduped so repeated or
293
+ * shared credits render once. No-op when the built-in UI is disabled
294
+ * (`defaultAttribution: false`) — those callers use the `loaded` event instead.
295
+ */
296
+ private addCredit(attribution: string): void {
297
+ if (this.credits.includes(attribution)) return;
298
+ this.credits.push(attribution);
299
+ this.view?.attribution?.add([{ attributionHtml: attribution }]);
300
+ }
301
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ export {
2
+ PersonViewPlugin,
3
+ type PersonViewConfig,
4
+ type PersonViewState,
5
+ type CharacterConfig,
6
+ type AnimationConfig,
7
+ type ModelRotationOffset,
8
+ type KeyBindings,
9
+ type ViewMode,
10
+ type TeleportOptions,
11
+ } from "./PersonViewPlugin";
12
+
13
+ export {
14
+ OverlayPlugin,
15
+ moveOverlayElement,
16
+ type OverlayConfig,
17
+ type OverlayState,
18
+ type WorldPosition,
19
+ type ProjectedPosition,
20
+ } from "./OverlayPlugin";
21
+
22
+ export {
23
+ CesiumIonPlugin,
24
+ type CesiumIonConfig,
25
+ type CesiumIonTerrainOptions,
26
+ } from "./CesiumIonPlugin";
27
+
28
+ export {
29
+ TileJsonPlugin,
30
+ type TileJson,
31
+ type TileJsonSourceType,
32
+ type TileJsonSourceDescription,
33
+ type TileJsonLoadedEvent,
34
+ type TileJsonPluginEventMap,
35
+ } from "./TileJsonPlugin";