@tileflow/dev 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +201 -5
  3. package/THIRD_PARTY_NOTICES.md +87 -0
  4. package/dist/artifacts.d.ts +265 -0
  5. package/dist/artifacts.js +81 -0
  6. package/dist/chunk-2QXKVQ6O.js +3062 -0
  7. package/dist/chunk-7C4F6GAX.js +170 -0
  8. package/dist/chunk-AQUGMUZW.js +324 -0
  9. package/dist/chunk-D4OE4ITL.js +13 -0
  10. package/dist/chunk-IR22ZTQW.js +2392 -0
  11. package/dist/chunk-KDFTE4Q5.js +156 -0
  12. package/dist/chunk-ONYQNLNA.js +1108 -0
  13. package/dist/chunk-UTRO4IBQ.js +671 -0
  14. package/dist/chunk-XHJBCVBB.js +241 -0
  15. package/dist/config.d.ts +33 -0
  16. package/dist/config.js +27 -0
  17. package/dist/fonts.d.ts +47 -0
  18. package/dist/fonts.js +16 -0
  19. package/dist/icons.d.ts +339 -0
  20. package/dist/icons.js +39 -0
  21. package/dist/inspect.d.ts +149 -0
  22. package/dist/inspect.js +805 -0
  23. package/dist/preview-assets-MVUJJ5XR.js +148 -0
  24. package/dist/preview.d.ts +71 -0
  25. package/dist/preview.js +2574 -0
  26. package/dist/server.d.ts +82 -0
  27. package/dist/server.js +2094 -0
  28. package/dist/tilesets.d.ts +89 -0
  29. package/dist/tilesets.js +6 -0
  30. package/dist/validation.d.ts +65 -0
  31. package/dist/validation.js +24 -0
  32. package/docs/artifact-lifecycle.md +152 -0
  33. package/docs/bounded-pmtiles-resource-inspection.md +21 -0
  34. package/docs/bounded-vector-feature-inspection.md +100 -0
  35. package/docs/local-comparison-workbench-and-compiler-inspection.md +227 -0
  36. package/docs/native-artifacts.md +285 -0
  37. package/docs/native-icons.md +129 -0
  38. package/docs/structured-config-inspection-and-diagnostics.md +39 -0
  39. package/package.json +77 -13
  40. package/dist/index.d.ts +0 -54
  41. package/dist/index.js +0 -314
@@ -0,0 +1,2574 @@
1
+ import {
2
+ getFirstTileflowMapName
3
+ } from "./chunk-AQUGMUZW.js";
4
+ import "./chunk-D4OE4ITL.js";
5
+
6
+ // src/preview.ts
7
+ import {
8
+ defaultTileflowRuntimeView,
9
+ normalizeTileflowCaptureScene,
10
+ parseResolvedTileflowMap,
11
+ resolveThemeSelection
12
+ } from "@tileflow/core";
13
+
14
+ // src/landmarks.ts
15
+ async function readBoundedTileflowResponse(response, maximumBytes) {
16
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
17
+ throw new Error("invalid response byte limit");
18
+ }
19
+ const declaredBytes = Number(response.headers.get("content-length"));
20
+ if (Number.isFinite(declaredBytes) && declaredBytes > maximumBytes) {
21
+ await response.body?.cancel("response exceeds byte limit");
22
+ throw new Error("response exceeds byte limit");
23
+ }
24
+ if (!response.body) throw new Error("response body is unavailable");
25
+ const reader = response.body.getReader();
26
+ const chunks = [];
27
+ let byteLength = 0;
28
+ try {
29
+ while (true) {
30
+ const { done, value } = await reader.read();
31
+ if (done) break;
32
+ if (byteLength + value.byteLength > maximumBytes) {
33
+ await reader.cancel("response exceeds byte limit");
34
+ throw new Error("response exceeds byte limit");
35
+ }
36
+ chunks.push(value);
37
+ byteLength += value.byteLength;
38
+ }
39
+ } finally {
40
+ reader.releaseLock();
41
+ }
42
+ const result = new Uint8Array(byteLength);
43
+ let offset = 0;
44
+ for (const chunk of chunks) {
45
+ result.set(chunk, offset);
46
+ offset += chunk.byteLength;
47
+ }
48
+ return result;
49
+ }
50
+ async function readBoundedTileflowJsonResponse(response, maximumBytes) {
51
+ const bytes = await readBoundedTileflowResponse(response, maximumBytes);
52
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
53
+ }
54
+ function normalizeTileflowLandmarkManifest(candidate, manifestUrl) {
55
+ const value = candidate;
56
+ const portableId = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
57
+ const sha256 = /^[a-f0-9]{64}$/;
58
+ const axisConventions = /* @__PURE__ */ new Set(["EUN_Y_UP", "EUS_Y_UP", "ENU_Z_UP"]);
59
+ if (value?.schemaVersion !== 2 || typeof value.id !== "string" || !portableId.test(value.id) || !Array.isArray(value.archives) || value.archives.length > 64 || !Array.isArray(value.landmarks) || value.landmarks.length > 4096 || !Number.isInteger(value.minzoom) || value.minzoom < 0 || value.minzoom > 24 || !Number.isInteger(value.maximumVisibleModels) || value.maximumVisibleModels < 1 || value.maximumVisibleModels > 64) {
60
+ throw new Error("invalid landmark manifest");
61
+ }
62
+ const minzoom = value.minzoom;
63
+ const maximumVisibleModels = value.maximumVisibleModels;
64
+ const maximumCachedModels = value.maximumCachedModels === void 0 ? Math.min(128, Math.max(maximumVisibleModels, maximumVisibleModels * 2)) : value.maximumCachedModels;
65
+ if (!Number.isInteger(maximumCachedModels) || maximumCachedModels < maximumVisibleModels || maximumCachedModels > 128) {
66
+ throw new Error("invalid landmark cache limit");
67
+ }
68
+ const archiveIds = /* @__PURE__ */ new Set();
69
+ const archiveMap = /* @__PURE__ */ new Map();
70
+ for (const input of value.archives) {
71
+ const archive = input;
72
+ if (typeof archive?.id !== "string" || !portableId.test(archive.id) || archiveIds.has(archive.id) || typeof archive.url !== "string" || archive.url.length < 1 || archive.url.length > 2048 || !Number.isSafeInteger(archive.bytes) || archive.bytes < 1 || archive.bytes > 8 * 1024 * 1024 * 1024 || typeof archive.sha256 !== "string" || !sha256.test(archive.sha256)) {
73
+ throw new Error("invalid landmark archive");
74
+ }
75
+ const url = new URL(archive.url, manifestUrl);
76
+ if (url.protocol !== "http:" && url.protocol !== "https:" || url.username !== "" || url.password !== "") {
77
+ throw new Error("invalid landmark archive URL");
78
+ }
79
+ const normalized = {
80
+ bytes: archive.bytes,
81
+ id: archive.id,
82
+ sha256: archive.sha256,
83
+ url: url.toString()
84
+ };
85
+ archiveIds.add(archive.id);
86
+ archiveMap.set(archive.id, normalized);
87
+ }
88
+ const landmarkIds = /* @__PURE__ */ new Set();
89
+ const landmarks = value.landmarks.map((input) => {
90
+ const landmark = input;
91
+ const center = landmark?.center;
92
+ const bounds = landmark?.bounds;
93
+ if (typeof landmark?.id !== "string" || !portableId.test(landmark.id) || landmarkIds.has(landmark.id) || !Array.isArray(center) || center.length !== 2 || !center.every(Number.isFinite) || center[0] < -180 || center[0] > 180 || center[1] < -90 || center[1] > 90 || !Array.isArray(bounds) || bounds.length !== 4 || !bounds.every(Number.isFinite) || bounds[0] < -180 || bounds[2] > 180 || bounds[1] < -90 || bounds[3] > 90 || bounds[0] > bounds[2] || bounds[1] > bounds[3] || center[0] < bounds[0] || center[0] > bounds[2] || center[1] < bounds[1] || center[1] > bounds[3] || landmark.priority !== void 0 && !Number.isFinite(landmark.priority) || !Array.isArray(landmark.models) || landmark.models.length < 1 || landmark.models.length > 8) {
94
+ throw new Error("invalid landmark entry");
95
+ }
96
+ landmarkIds.add(landmark.id);
97
+ const models = landmark.models.map((inputModel) => {
98
+ const model = inputModel;
99
+ if (!model) throw new Error("invalid landmark model");
100
+ const archive = typeof model.archiveId === "string" ? archiveMap.get(model.archiveId) : void 0;
101
+ if (!archive || !Number.isInteger(model?.minzoom) || model.minzoom < minzoom || model.minzoom > 24 || !Number.isInteger(model.z) || model.z < 0 || model.z > 26 || !Number.isInteger(model.x) || model.x < 0 || model.x >= 2 ** model.z || !Number.isInteger(model.y) || model.y < 0 || model.y >= 2 ** model.z || !Number.isSafeInteger(model.bytes) || model.bytes < 1 || model.bytes > 64 * 1024 * 1024 || typeof model.sha256 !== "string" || !sha256.test(model.sha256) || typeof model.axisConvention !== "string" || !axisConventions.has(model.axisConvention)) {
102
+ throw new Error("invalid landmark model");
103
+ }
104
+ return {
105
+ archive,
106
+ archiveId: model.archiveId,
107
+ axisConvention: model.axisConvention,
108
+ bytes: model.bytes,
109
+ key: [archive.sha256, model.z, model.x, model.y, model.sha256].join(":"),
110
+ minzoom: model.minzoom,
111
+ sha256: model.sha256,
112
+ x: model.x,
113
+ y: model.y,
114
+ z: model.z
115
+ };
116
+ });
117
+ models.sort((left, right) => left.minzoom - right.minzoom || left.key.localeCompare(right.key));
118
+ if (new Set(models.map((model) => model.minzoom)).size !== models.length) {
119
+ throw new Error("duplicate landmark LOD zoom");
120
+ }
121
+ if (models[0].minzoom !== minzoom) {
122
+ throw new Error("landmark requires a base model");
123
+ }
124
+ return {
125
+ bounds,
126
+ center,
127
+ id: landmark.id,
128
+ models,
129
+ priority: landmark.priority ?? 0
130
+ };
131
+ });
132
+ return {
133
+ archives: [...archiveMap.values()],
134
+ id: value.id,
135
+ landmarks,
136
+ maximumCachedModels,
137
+ maximumVisibleModels,
138
+ minzoom,
139
+ schemaVersion: 2
140
+ };
141
+ }
142
+
143
+ // src/preview-html.ts
144
+ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticPreview, fontFaces = []) {
145
+ const styleUrl = preview ? `${basePath}/styles/${preview.mapName}/${preview.themeName}.json` : void 0;
146
+ const mapOptions = preview ? previewMapOptions(preview) : void 0;
147
+ const viewportCss = preview?.viewport ? `
148
+ html, body { min-height: 100%; margin: 0; }
149
+ body {
150
+ display: grid;
151
+ place-items: center;
152
+ overflow: auto;
153
+ background: #E8E5DE;
154
+ }
155
+ #map {
156
+ width: ${preview.viewport.width}px;
157
+ height: ${preview.viewport.height}px;
158
+ box-shadow: 0 24px 80px rgba(37, 34, 29, 0.18);
159
+ }` : "html, body, #map { height: 100%; margin: 0; }";
160
+ return `<!doctype html>
161
+ <html lang="en">
162
+ <head>
163
+ <meta charset="utf-8" />
164
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
165
+ <title>Tileflow Preview</title>
166
+ <link rel="stylesheet" href="${basePath}/__runtime/maplibre-gl.css" />
167
+ <style>
168
+ ${viewportCss}
169
+ body { font-family: ui-sans-serif, system-ui, sans-serif; }
170
+ #map {
171
+ --tileflow-globe-radius: 220px;
172
+ background-color: var(--tileflow-space, #2F5070);
173
+ background-image:
174
+ radial-gradient(circle at 18% 24%, rgba(225, 240, 250, 0.34) 0 1px, transparent 1.4px),
175
+ radial-gradient(circle at 72% 62%, rgba(225, 240, 250, 0.22) 0 1px, transparent 1.3px),
176
+ radial-gradient(circle at 42% 78%, rgba(225, 240, 250, 0.18) 0 0.8px, transparent 1.2px);
177
+ background-position: 0 0, 37px 71px, 113px 29px;
178
+ background-size: 173px 149px, 257px 211px, 337px 283px;
179
+ }
180
+ #map.tileflow-globe {
181
+ position: relative;
182
+ background-image:
183
+ radial-gradient(circle at 18% 24%, rgba(225, 240, 250, 0.34) 0 1px, transparent 1.4px),
184
+ radial-gradient(circle at 72% 62%, rgba(225, 240, 250, 0.22) 0 1px, transparent 1.3px),
185
+ radial-gradient(circle at 42% 78%, rgba(225, 240, 250, 0.18) 0 0.8px, transparent 1.2px);
186
+ }
187
+ #map.tileflow-globe::before {
188
+ content: "";
189
+ position: absolute;
190
+ z-index: 0;
191
+ left: 50%;
192
+ top: 50%;
193
+ width: calc(var(--tileflow-globe-radius) * 2);
194
+ height: calc(var(--tileflow-globe-radius) * 2);
195
+ border: 1px solid var(--tileflow-rim-line, rgba(248, 252, 255, 0.88));
196
+ border-radius: 50%;
197
+ box-shadow:
198
+ 0 0 9px 3px var(--tileflow-rim-core, rgba(248, 252, 255, 0.9)),
199
+ 0 0 28px 10px var(--tileflow-rim-glow, rgba(176, 220, 246, 0.52)),
200
+ 0 0 58px 20px var(--tileflow-rim-haze, rgba(132, 195, 232, 0.2));
201
+ pointer-events: none;
202
+ transform: translate(-50%, -50%);
203
+ }
204
+ #map.tileflow-globe > .maplibregl-canvas-container {
205
+ position: relative;
206
+ z-index: 1;
207
+ }
208
+ .badge {
209
+ display: none;
210
+ position: fixed;
211
+ left: 16px;
212
+ top: 16px;
213
+ z-index: 1;
214
+ border-radius: 8px;
215
+ background: rgba(246, 247, 243, 0.92);
216
+ border: 1px solid rgba(60, 64, 67, 0.12);
217
+ color: #3C4043;
218
+ padding: 10px 12px;
219
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12);
220
+ }
221
+ .status {
222
+ position: fixed;
223
+ left: 16px;
224
+ right: 16px;
225
+ bottom: 16px;
226
+ z-index: 2;
227
+ display: none;
228
+ max-width: 720px;
229
+ border: 1px solid rgba(160, 46, 46, 0.35);
230
+ border-radius: 8px;
231
+ background: rgba(255, 244, 244, 0.96);
232
+ color: #702020;
233
+ padding: 12px;
234
+ white-space: pre-wrap;
235
+ }
236
+ .maplibregl-ctrl-group .tileflow-3d-toggle,
237
+ .maplibregl-ctrl-group .tileflow-tree-toggle,
238
+ .maplibregl-ctrl-group .tileflow-theme-toggle {
239
+ width: auto;
240
+ min-width: 58px;
241
+ padding: 0 9px;
242
+ color: #3C4043;
243
+ font: 700 11px/29px ui-sans-serif, system-ui, sans-serif;
244
+ letter-spacing: 0.02em;
245
+ }
246
+ .maplibregl-ctrl-group .tileflow-3d-toggle[aria-pressed="true"],
247
+ .maplibregl-ctrl-group .tileflow-tree-toggle[aria-pressed="true"] {
248
+ background-color: #E8F0FE;
249
+ color: #174EA6;
250
+ }
251
+ .maplibregl-ctrl-group .tileflow-3d-toggle:hover,
252
+ .maplibregl-ctrl-group .tileflow-tree-toggle:hover,
253
+ .maplibregl-ctrl-group .tileflow-theme-toggle:hover {
254
+ background-color: #F1F3F4;
255
+ }
256
+ </style>
257
+ </head>
258
+ <body>
259
+ <div class="badge" id="badge">Tileflow preview</div>
260
+ <div class="status" id="status" role="status"></div>
261
+ <div id="map"></div>
262
+ <script type="importmap">{"imports":{"fflate":"${basePath}/__runtime/fflate.js","three":"${basePath}/__runtime/three.module.js"}}</script>
263
+ <script type="module">
264
+ import * as maplibregl from "${basePath}/__runtime/maplibre-gl.mjs";
265
+ import {loadTileflowStyleFonts} from "${basePath}/__runtime/tileflow-browser.js";
266
+ import {registerTileflowContourProtocol} from "${basePath}/__runtime/tileflow-browser.js";
267
+ import {registerTileflowPmtilesProtocol} from "${basePath}/__runtime/tileflow-browser.js";
268
+ import {createTileflowBuildings3D, createTileflowStreetLights, createTileflowTrees3D} from "${basePath}/__runtime/tileflow-browser.js";
269
+ import {readTileflowSunPreset, resolveTileflowSun, tileflowDefaultSunPreset} from "${basePath}/__runtime/tileflow-browser.js";
270
+
271
+ maplibregl.setWorkerUrl("${basePath}/__runtime/maplibre-gl-worker.mjs");
272
+
273
+ const initialStatus = ${JSON.stringify(initialStatus)};
274
+ const normalizeTileflowLandmarkManifest = ${normalizeTileflowLandmarkManifest.toString()};
275
+ const readBoundedTileflowResponse = ${readBoundedTileflowResponse.toString()};
276
+ const readBoundedTileflowJsonResponse = ${readBoundedTileflowJsonResponse.toString()};
277
+ const initialGeneration = initialStatus.generation;
278
+ const badge = document.getElementById("badge");
279
+ const status = document.getElementById("status");
280
+ const previewLabel = ${JSON.stringify(preview?.label)};
281
+ const styleUrl = ${JSON.stringify(styleUrl)};
282
+ const previewMapOptions = ${JSON.stringify(mapOptions)};
283
+ const previewTheme = ${serializeInlineJson(preview?.themeName ?? null)};
284
+ const previewThemes = ${serializeInlineJson(preview?.themeNames ?? [])};
285
+ const isSemanticPreview = ${JSON.stringify(isSemanticPreview)};
286
+ const previewFontFaces = ${serializeInlineJson(fontFaces)};
287
+ const previewSearchParameters = new URL(location.href).searchParams;
288
+ const mapBenchmarkEnabled = previewSearchParameters.get("mapBenchmark") === "1";
289
+ const mapSweepEnabled = previewSearchParameters.get("mapSweep") === "1";
290
+ const mapCompareEnabled = previewSearchParameters.get("mapCompare") === "1";
291
+ const requestedMapCompareLimit = Number(previewSearchParameters.get("mapCompareLimit"));
292
+ const mapCompareLimit =
293
+ Number.isInteger(requestedMapCompareLimit) &&
294
+ requestedMapCompareLimit >= 1 &&
295
+ requestedMapCompareLimit <= 64
296
+ ? requestedMapCompareLimit
297
+ : 16;
298
+ const requestedMapWorkerCount = Number(previewSearchParameters.get("mapWorkers"));
299
+ const mapWorkerCountOverride =
300
+ Number.isInteger(requestedMapWorkerCount) &&
301
+ requestedMapWorkerCount >= 1 &&
302
+ requestedMapWorkerCount <= 3
303
+ ? requestedMapWorkerCount
304
+ : undefined;
305
+ const mapBenchmarkLongitude = Number(previewSearchParameters.get("lng"));
306
+ const mapBenchmarkLatitude = Number(previewSearchParameters.get("lat"));
307
+ const mapBenchmarkCenter =
308
+ previewSearchParameters.has("lng") &&
309
+ previewSearchParameters.has("lat") &&
310
+ Number.isFinite(mapBenchmarkLongitude) &&
311
+ Number.isFinite(mapBenchmarkLatitude)
312
+ ? [mapBenchmarkLongitude, mapBenchmarkLatitude]
313
+ : undefined;
314
+ function readToggleFromUrl(name, fallback) {
315
+ const values = previewSearchParameters.getAll(name);
316
+ if (values.length !== 1) return fallback;
317
+ if (values[0] === "on") return true;
318
+ if (values[0] === "off") return false;
319
+ return fallback;
320
+ }
321
+ let threeDimensionalEnabled = readToggleFromUrl("buildings3d", false);
322
+ let treesEnabled = readToggleFromUrl("trees3d", false);
323
+ const landmarkState = {
324
+ active: [],
325
+ cacheLimit: undefined,
326
+ enabled: threeDimensionalEnabled,
327
+ manifestId: undefined,
328
+ loaded: [],
329
+ loading: [],
330
+ errors: []
331
+ };
332
+ globalThis.__tileflowLandmarkState = landmarkState;
333
+ const buildingWireframeMetrics = {
334
+ buildingCount: 0,
335
+ refreshMilliseconds: undefined,
336
+ segmentCount: 0,
337
+ truncated: false
338
+ };
339
+ globalThis.__tileflowBuildingWireframeMetrics = buildingWireframeMetrics;
340
+ let THREE;
341
+ let GLTFLoader;
342
+ let DRACOLoader;
343
+ let MeshoptDecoder;
344
+ let LineMaterial;
345
+ let LineSegments2;
346
+ let LineSegmentsGeometry;
347
+ let PMTiles;
348
+ let FetchSource;
349
+ let landmarkDracoLoader;
350
+ let threeCoreRuntimePromise;
351
+ let buildingWireframeRuntimePromise;
352
+ let landmarkRuntimePromise;
353
+ let pmtilesRuntimePromise;
354
+ const landmarkManifestPromises = new Map();
355
+ const landmarkManifestMaximumBytes = 1024 * 1024;
356
+ const landmarkManifestTimeoutMs = 10000;
357
+
358
+ function loadThreeCoreRuntime() {
359
+ if (!threeCoreRuntimePromise) {
360
+ threeCoreRuntimePromise = import("${basePath}/__runtime/three.module.js").then(
361
+ (threeModule) => {
362
+ THREE = threeModule;
363
+ }
364
+ );
365
+ }
366
+ return threeCoreRuntimePromise;
367
+ }
368
+
369
+ function loadBuildingWireframeRuntime() {
370
+ if (!buildingWireframeRuntimePromise) {
371
+ buildingWireframeRuntimePromise = Promise.all([
372
+ loadThreeCoreRuntime(),
373
+ import("${basePath}/__runtime/three-addons/lines/LineMaterial.js"),
374
+ import("${basePath}/__runtime/three-addons/lines/LineSegments2.js"),
375
+ import("${basePath}/__runtime/three-addons/lines/LineSegmentsGeometry.js")
376
+ ]).then(([, lineMaterialModule, lineSegmentsModule, lineGeometryModule]) => {
377
+ LineMaterial = lineMaterialModule.LineMaterial;
378
+ LineSegments2 = lineSegmentsModule.LineSegments2;
379
+ LineSegmentsGeometry = lineGeometryModule.LineSegmentsGeometry;
380
+ });
381
+ }
382
+ return buildingWireframeRuntimePromise;
383
+ }
384
+
385
+ function loadLandmarkRuntime() {
386
+ if (!landmarkRuntimePromise) {
387
+ landmarkRuntimePromise = Promise.all([
388
+ loadThreeCoreRuntime(),
389
+ import("${basePath}/__runtime/three-addons/loaders/GLTFLoader.js"),
390
+ import("${basePath}/__runtime/three-addons/loaders/DRACOLoader.js"),
391
+ import("${basePath}/__runtime/three-addons/libs/meshopt_decoder.module.js"),
392
+ loadPmtilesRuntime()
393
+ ]).then(([, gltfLoaderModule, dracoLoaderModule, meshoptDecoderModule, pmtilesModule]) => {
394
+ GLTFLoader = gltfLoaderModule.GLTFLoader;
395
+ DRACOLoader = dracoLoaderModule.DRACOLoader;
396
+ MeshoptDecoder = meshoptDecoderModule.MeshoptDecoder;
397
+ PMTiles = pmtilesModule.PMTiles;
398
+ FetchSource = pmtilesModule.FetchSource;
399
+ landmarkDracoLoader = new DRACOLoader()
400
+ .setDecoderPath("${basePath}/__runtime/three-addons/libs/draco/gltf/")
401
+ .setDecoderConfig({type: "wasm"});
402
+ landmarkDracoLoader.preload();
403
+ });
404
+ }
405
+ return landmarkRuntimePromise;
406
+ }
407
+
408
+ function loadPmtilesRuntime() {
409
+ if (!pmtilesRuntimePromise) {
410
+ pmtilesRuntimePromise = import("${basePath}/__runtime/pmtiles.js").then((pmtilesModule) => {
411
+ PMTiles = pmtilesModule.PMTiles;
412
+ FetchSource = pmtilesModule.FetchSource;
413
+ return pmtilesModule;
414
+ });
415
+ }
416
+ return pmtilesRuntimePromise;
417
+ }
418
+
419
+ function waitForLandmarkManifest(promise, signal) {
420
+ if (!signal) return promise;
421
+ if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
422
+ return new Promise((resolve, reject) => {
423
+ const abort = () => reject(new DOMException("Aborted", "AbortError"));
424
+ signal.addEventListener("abort", abort, {once: true});
425
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
426
+ });
427
+ }
428
+
429
+ function loadLandmarkManifestCandidate(manifestUrl, signal) {
430
+ const existing = landmarkManifestPromises.get(manifestUrl);
431
+ if (existing) return waitForLandmarkManifest(existing, signal);
432
+ const resolvedUrl = new URL(manifestUrl, location.href);
433
+ if (
434
+ !["http:", "https:"].includes(resolvedUrl.protocol) ||
435
+ resolvedUrl.username ||
436
+ resolvedUrl.password
437
+ ) throw new Error("invalid landmark manifest URL");
438
+ const controller = new AbortController();
439
+ const timeout = setTimeout(() => controller.abort(), landmarkManifestTimeoutMs);
440
+ const pending = (async () => {
441
+ try {
442
+ const response = await fetch(resolvedUrl, {
443
+ credentials: "include",
444
+ signal: controller.signal
445
+ });
446
+ if (!response.ok) throw new Error("HTTP " + response.status);
447
+ return readBoundedTileflowJsonResponse(response, landmarkManifestMaximumBytes);
448
+ } finally {
449
+ clearTimeout(timeout);
450
+ }
451
+ })();
452
+ landmarkManifestPromises.set(manifestUrl, pending);
453
+ pending.catch(() => {
454
+ if (landmarkManifestPromises.get(manifestUrl) === pending) {
455
+ landmarkManifestPromises.delete(manifestUrl);
456
+ }
457
+ });
458
+ return waitForLandmarkManifest(pending, signal);
459
+ }
460
+
461
+ function loadLandmarkPrerequisites(manifestUrl, signal) {
462
+ return Promise.all([
463
+ loadLandmarkRuntime(),
464
+ loadLandmarkManifestCandidate(manifestUrl, signal)
465
+ ]);
466
+ }
467
+
468
+ function percentile(values, percentileValue) {
469
+ if (values.length === 0) return undefined;
470
+ const sorted = [...values].sort((left, right) => left - right);
471
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)];
472
+ }
473
+
474
+ function installMapBenchmark(map) {
475
+ if (!mapBenchmarkEnabled) return;
476
+ let activeRun;
477
+ const metrics = {
478
+ activeLayers: 0,
479
+ bucketCount: 0,
480
+ bucketTypes: {},
481
+ errors: [],
482
+ frameMaximumMilliseconds: 0,
483
+ frameP95Milliseconds: 0,
484
+ frames: 0,
485
+ idleMilliseconds: 0,
486
+ layerTypes: {},
487
+ renderedFeatures: 0,
488
+ resourceCount: 0,
489
+ resourceDecodedBytes: 0,
490
+ resourceDurationMaximumMilliseconds: 0,
491
+ resourceEncodedBytes: 0,
492
+ resourceServerTiming: {},
493
+ resourceTransferBytes: 0,
494
+ requestedZoom: map.getZoom(),
495
+ sourceDataEvents: 0,
496
+ timedOut: false,
497
+ visibleDecodedTileBytes: 0,
498
+ visibleTileCoordinates: [],
499
+ visibleTiles: 0,
500
+ zoom: map.getZoom()
501
+ };
502
+
503
+ function inspectRenderBuckets() {
504
+ const tileManagers = map.style?.tileManagers || {};
505
+ const bucketTypes = {};
506
+ const coordinates = new Map();
507
+ const resourceTiming = [];
508
+ const visitedTiles = new Set();
509
+ let bucketCount = 0;
510
+ let visibleDecodedTileBytes = 0;
511
+ for (const [sourceId, tileManager] of Object.entries(tileManagers)) {
512
+ const tiles = tileManager?._inViewTiles?.getAllTiles?.() || [];
513
+ for (const tile of tiles) {
514
+ const tileKey = sourceId + ":" + (tile.uid ?? tile.tileID?.key);
515
+ if (visitedTiles.has(tileKey)) continue;
516
+ visitedTiles.add(tileKey);
517
+ const buckets = Object.values(tile.buckets || {});
518
+ bucketCount += buckets.length;
519
+ for (const bucket of buckets) {
520
+ const type = bucket?.type || bucket?.constructor?.name || "unknown";
521
+ bucketTypes[type] = (bucketTypes[type] || 0) + 1;
522
+ }
523
+ visibleDecodedTileBytes += tile.latestRawTileData?.byteLength || 0;
524
+ if (tile.resourceTiming) resourceTiming.push(...tile.resourceTiming);
525
+ const canonical = tile.tileID?.canonical;
526
+ if (canonical && canonical.z <= 24) {
527
+ const coordinate = {z: canonical.z, x: canonical.x, y: canonical.y};
528
+ coordinates.set(coordinate.z + "/" + coordinate.x + "/" + coordinate.y, coordinate);
529
+ }
530
+ }
531
+ }
532
+ return {
533
+ bucketCount,
534
+ bucketTypes,
535
+ resourceTiming,
536
+ visibleDecodedTileBytes,
537
+ visibleTileCoordinates: [...coordinates.values()],
538
+ visibleTiles: visitedTiles.size
539
+ };
540
+ }
541
+
542
+ function uniqueResources(resources) {
543
+ const unique = new Map();
544
+ for (const resource of resources) {
545
+ const key = [
546
+ resource.name,
547
+ resource.startTime,
548
+ resource.encodedBodySize,
549
+ resource.decodedBodySize
550
+ ].join("|");
551
+ unique.set(key, resource);
552
+ }
553
+ return [...unique.values()];
554
+ }
555
+
556
+ function summarizeServerTiming(resources) {
557
+ const totals = {};
558
+ for (const resource of resources) {
559
+ for (const timing of resource.serverTiming || []) {
560
+ const current = totals[timing.name] || {count: 0, maximumMilliseconds: 0, totalMilliseconds: 0};
561
+ current.count += 1;
562
+ current.maximumMilliseconds = Math.max(current.maximumMilliseconds, timing.duration || 0);
563
+ current.totalMilliseconds += timing.duration || 0;
564
+ totals[timing.name] = current;
565
+ }
566
+ }
567
+ for (const timing of Object.values(totals)) {
568
+ timing.averageMilliseconds = timing.count ? timing.totalMilliseconds / timing.count : 0;
569
+ }
570
+ return totals;
571
+ }
572
+
573
+ function sampleMapFrame(frameTime) {
574
+ if (activeRun) {
575
+ if (activeRun.previousFrameTime !== undefined) {
576
+ activeRun.frameSamples.push(frameTime - activeRun.previousFrameTime);
577
+ }
578
+ activeRun.previousFrameTime = frameTime;
579
+ }
580
+ requestAnimationFrame(sampleMapFrame);
581
+ }
582
+
583
+ map.on("sourcedata", (event) => {
584
+ if (!activeRun) return;
585
+ activeRun.sourceDataEvents += 1;
586
+ if (event.resourceTiming) activeRun.workerResources.push(...event.resourceTiming);
587
+ });
588
+ map.on("error", (event) => {
589
+ if (!activeRun) return;
590
+ const message = String(event?.error?.message || event?.message || "MapLibre error");
591
+ if (!activeRun.errors.includes(message) && activeRun.errors.length < 8) {
592
+ activeRun.errors.push(message);
593
+ }
594
+ });
595
+ requestAnimationFrame(sampleMapFrame);
596
+
597
+ function finishMapBenchmark(run, timedOut, resolve) {
598
+ if (activeRun !== run) return;
599
+ activeRun = undefined;
600
+ clearTimeout(run.timeout);
601
+ map.off("idle", run.handleIdle);
602
+ const zoom = map.getZoom();
603
+ const styleLayers = map.getStyle()?.layers || [];
604
+ const activeLayers = styleLayers.filter((layer) =>
605
+ (layer.minzoom === undefined || layer.minzoom <= zoom) &&
606
+ (layer.maxzoom === undefined || zoom < layer.maxzoom) &&
607
+ layer.layout?.visibility !== "none"
608
+ );
609
+ const layerTypes = {};
610
+ for (const layer of activeLayers) {
611
+ layerTypes[layer.type] = (layerTypes[layer.type] || 0) + 1;
612
+ }
613
+ const renderState = inspectRenderBuckets();
614
+ const {resourceTiming, ...renderMetrics} = renderState;
615
+ const resources = uniqueResources([
616
+ ...performance.getEntriesByType("resource").filter(
617
+ (entry) => entry.startTime >= run.startedAt
618
+ ),
619
+ ...run.workerResources,
620
+ ...resourceTiming
621
+ ]);
622
+ const frameP95 = percentile(run.frameSamples, 0.95) || 0;
623
+ Object.assign(metrics, {
624
+ activeLayers: activeLayers.length,
625
+ errors: run.errors,
626
+ frameMaximumMilliseconds: Math.max(0, ...run.frameSamples),
627
+ frameP95Milliseconds: frameP95,
628
+ frames: run.frameSamples.length,
629
+ idleMilliseconds: performance.now() - run.startedAt,
630
+ layerTypes,
631
+ renderedFeatures: map.queryRenderedFeatures().length,
632
+ resourceCount: resources.length,
633
+ resourceDecodedBytes: resources.reduce(
634
+ (total, entry) => total + (entry.decodedBodySize || 0),
635
+ 0
636
+ ),
637
+ resourceDurationMaximumMilliseconds: Math.max(
638
+ 0,
639
+ ...resources.map((entry) => entry.duration || 0)
640
+ ),
641
+ resourceEncodedBytes: resources.reduce(
642
+ (total, entry) => total + (entry.encodedBodySize || 0),
643
+ 0
644
+ ),
645
+ resourceServerTiming: summarizeServerTiming(resources),
646
+ resourceTransferBytes: resources.reduce(
647
+ (total, entry) => total + (entry.transferSize || 0),
648
+ 0
649
+ ),
650
+ requestedZoom: run.requestedZoom,
651
+ sourceDataEvents: run.sourceDataEvents,
652
+ timedOut,
653
+ ...renderMetrics,
654
+ zoom
655
+ });
656
+ resolve({...metrics});
657
+ }
658
+
659
+ function settleZoom(zoom) {
660
+ if (!Number.isFinite(zoom) || zoom < 0 || zoom > 22) {
661
+ return Promise.reject(new Error("Benchmark zoom must be between 0 and 22."));
662
+ }
663
+ if (activeRun) {
664
+ return Promise.reject(new Error("A map benchmark run is already active."));
665
+ }
666
+ return new Promise((resolve) => {
667
+ const run = {
668
+ errors: [],
669
+ frameSamples: [],
670
+ handleIdle: undefined,
671
+ previousFrameTime: undefined,
672
+ requestedZoom: zoom,
673
+ sourceDataEvents: 0,
674
+ startedAt: performance.now(),
675
+ timeout: undefined,
676
+ workerResources: []
677
+ };
678
+ run.handleIdle = () => requestAnimationFrame(() =>
679
+ requestAnimationFrame(() => finishMapBenchmark(run, false, resolve))
680
+ );
681
+ run.timeout = setTimeout(() => finishMapBenchmark(run, true, resolve), 12000);
682
+ activeRun = run;
683
+ map.on("idle", run.handleIdle);
684
+ map.jumpTo({
685
+ ...(mapBenchmarkCenter ? {center: mapBenchmarkCenter} : {}),
686
+ zoom
687
+ });
688
+ });
689
+ }
690
+
691
+ function resolveTileTemplate(template, coordinate) {
692
+ return template
693
+ .replace("{z}", String(coordinate.z))
694
+ .replace("{x}", String(coordinate.x))
695
+ .replace("{y}", String(coordinate.y))
696
+ .replace("{-y}", String((2 ** coordinate.z) - coordinate.y - 1));
697
+ }
698
+
699
+ async function digest(bytes) {
700
+ const hash = await crypto.subtle.digest("SHA-256", bytes);
701
+ return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
702
+ }
703
+
704
+ async function fetchComparableTile(url, credentials) {
705
+ const comparisonTileMaximumBytes = 16 * 1024 * 1024;
706
+ const startedAt = performance.now();
707
+ const response = await fetch(url, {cache: "no-store", credentials});
708
+ if (!response.ok) throw new Error("Tile comparison request failed (" + response.status + "): " + url);
709
+ const encodedHeaderValue =
710
+ response.headers.get("x-tileflow-compressed-bytes") ||
711
+ response.headers.get("content-length");
712
+ const encodedHeader = encodedHeaderValue === null
713
+ ? undefined
714
+ : Number(encodedHeaderValue);
715
+ const bytes = await readBoundedTileflowResponse(
716
+ response,
717
+ comparisonTileMaximumBytes
718
+ );
719
+ const contentEncoding = response.headers.get("content-encoding");
720
+ return {
721
+ archive: response.headers.get("x-tileflow-archive") || undefined,
722
+ compressed: contentEncoding?.includes("gzip") || false,
723
+ contentEncoding: contentEncoding || undefined,
724
+ contentLength: Number.isFinite(encodedHeader) ? encodedHeader : undefined,
725
+ decodedBytes: bytes.byteLength,
726
+ decodedSha256: await digest(bytes),
727
+ durationMilliseconds: performance.now() - startedAt,
728
+ encodedBytes: Number.isFinite(encodedHeader) ? encodedHeader : undefined,
729
+ url
730
+ };
731
+ }
732
+
733
+ async function compareRemote(options = {}) {
734
+ const requestedLimit = Number(options.limit ?? 16);
735
+ const limit = Number.isInteger(requestedLimit) && requestedLimit >= 1 && requestedLimit <= 64
736
+ ? requestedLimit
737
+ : 16;
738
+ const source = Object.values(map.getStyle()?.sources || {}).find(
739
+ (candidate) => candidate.type === "vector" && candidate.url?.includes("/tiles.json")
740
+ );
741
+ if (!source?.url) throw new Error("No vector TileJSON source is active in the map.");
742
+ const localTileJsonUrl = new URL(source.url, location.href);
743
+ const comparisonUrl = new URL("/comparison.json", localTileJsonUrl);
744
+ const contractResponse = await fetch(comparisonUrl, {cache: "no-store"});
745
+ if (!contractResponse.ok) throw new Error("The local comparison contract is unavailable.");
746
+ const contract = await readBoundedTileflowJsonResponse(contractResponse, 64 * 1024);
747
+ const remoteTileJsonUrl = options.remoteTileJson || contract.remoteTileJson;
748
+ if (!remoteTileJsonUrl) throw new Error("No remote TileJSON comparison target is configured.");
749
+ const remoteTileJsonResponse = await fetch(remoteTileJsonUrl, {
750
+ cache: "no-store",
751
+ credentials: "include"
752
+ });
753
+ if (!remoteTileJsonResponse.ok) {
754
+ throw new Error("Remote TileJSON request failed (" + remoteTileJsonResponse.status + ").");
755
+ }
756
+ const remoteTileJson = await readBoundedTileflowJsonResponse(
757
+ remoteTileJsonResponse,
758
+ 256 * 1024
759
+ );
760
+ const remoteTemplate = remoteTileJson.tiles?.[0];
761
+ if (!remoteTemplate) throw new Error("Remote TileJSON has no tile template.");
762
+ const coordinates = inspectRenderBuckets().visibleTileCoordinates.slice(0, limit);
763
+ if (coordinates.length === 0) throw new Error("No visible vector tiles are available to compare.");
764
+
765
+ const perTile = [];
766
+ for (const coordinate of coordinates) {
767
+ const localUrl = resolveTileTemplate(contract.localTileTemplate, coordinate);
768
+ const remoteUrl = resolveTileTemplate(remoteTemplate, coordinate);
769
+ const [local, remote] = await Promise.all([
770
+ fetchComparableTile(localUrl, "omit"),
771
+ fetchComparableTile(remoteUrl, "include")
772
+ ]);
773
+ perTile.push({
774
+ coordinate,
775
+ identicalDecodedPayload: local.decodedSha256 === remote.decodedSha256,
776
+ local,
777
+ remote
778
+ });
779
+ }
780
+ const sum = (side, field) => perTile.reduce(
781
+ (total, tile) => total + (tile[side][field] || 0),
782
+ 0
783
+ );
784
+ const identicalTiles = perTile.filter((tile) => tile.identicalDecodedPayload).length;
785
+ return {
786
+ archive: contract.archive,
787
+ identicalDecodedPercent: (identicalTiles / perTile.length) * 100,
788
+ identicalDecodedTiles: identicalTiles,
789
+ local: {
790
+ decodedBytes: sum("local", "decodedBytes"),
791
+ encodedBytes: sum("local", "encodedBytes"),
792
+ durationMilliseconds: sum("local", "durationMilliseconds")
793
+ },
794
+ perTile,
795
+ remote: {
796
+ decodedBytes: sum("remote", "decodedBytes"),
797
+ encodedBytes: sum("remote", "encodedBytes"),
798
+ durationMilliseconds: sum("remote", "durationMilliseconds"),
799
+ tileJson: remoteTileJsonUrl
800
+ },
801
+ tilesCompared: perTile.length
802
+ };
803
+ }
804
+
805
+ globalThis.__tileflowMapBenchmark = {compareRemote, metrics, settleZoom};
806
+ if (mapCompareEnabled) {
807
+ document.body.dataset.tileflowMapCompareStatus = "running";
808
+ const handleInitialMapCompareIdle = async () => {
809
+ map.off("idle", handleInitialMapCompareIdle);
810
+ try {
811
+ document.body.dataset.tileflowMapCompare = JSON.stringify(
812
+ await compareRemote({limit: mapCompareLimit})
813
+ );
814
+ document.body.dataset.tileflowMapCompareStatus = "complete";
815
+ } catch (error) {
816
+ document.body.dataset.tileflowMapCompare = JSON.stringify({
817
+ error: String(error?.message || error)
818
+ });
819
+ document.body.dataset.tileflowMapCompareStatus = "failed";
820
+ }
821
+ };
822
+ map.on("idle", handleInitialMapCompareIdle);
823
+ }
824
+ if (mapSweepEnabled) {
825
+ document.body.dataset.tileflowMapSweepStatus = "running";
826
+ map.once("load", async () => {
827
+ const results = [];
828
+ try {
829
+ const zooms = new Set(Array.from({length: 23}, (_, zoom) => zoom));
830
+ for (const layer of map.getStyle()?.layers || []) {
831
+ for (const boundary of [layer.minzoom, layer.maxzoom]) {
832
+ if (!Number.isFinite(boundary)) continue;
833
+ for (const sample of [boundary - 0.01, boundary, boundary + 0.01]) {
834
+ if (sample >= 0 && sample <= 22) zooms.add(sample);
835
+ }
836
+ }
837
+ }
838
+ for (const zoom of [...zooms].sort((left, right) => left - right)) {
839
+ const result = await settleZoom(zoom);
840
+ results.push(result);
841
+ if (result.timedOut) {
842
+ throw new Error("Map sweep timed out at zoom " + zoom + ".");
843
+ }
844
+ if (result.errors.length > 0) {
845
+ throw new Error(
846
+ "Map sweep reported an error at zoom " + zoom + ": " + result.errors[0]
847
+ );
848
+ }
849
+ }
850
+ document.body.dataset.tileflowMapSweep = JSON.stringify(results);
851
+ document.body.dataset.tileflowMapSweepStatus = "complete";
852
+ } catch (error) {
853
+ document.body.dataset.tileflowMapSweep = JSON.stringify({
854
+ error: String(error?.message || error),
855
+ results
856
+ });
857
+ document.body.dataset.tileflowMapSweepStatus = "failed";
858
+ }
859
+ });
860
+ }
861
+ }
862
+
863
+ const cameraRanges = {
864
+ bearing: [-180, 180],
865
+ lat: [-90, 90],
866
+ lng: [-180, 180],
867
+ pitch: [0, 85],
868
+ zoom: [0, 24]
869
+ };
870
+
871
+ function readCameraFromUrl() {
872
+ const params = new URL(location.href).searchParams;
873
+ const camera = {};
874
+
875
+ for (const [name, range] of Object.entries(cameraRanges)) {
876
+ const values = params.getAll(name);
877
+ if (values.length !== 1 || values[0].trim() === "") return undefined;
878
+ const value = Number(values[0]);
879
+ if (!Number.isFinite(value) || value < range[0] || value > range[1]) return undefined;
880
+ camera[name] = value;
881
+ }
882
+
883
+ return {
884
+ bearing: camera.bearing,
885
+ center: [camera.lng, camera.lat],
886
+ pitch: camera.pitch,
887
+ zoom: camera.zoom
888
+ };
889
+ }
890
+
891
+ function resolveInitialMapOptions(options) {
892
+ const camera = readCameraFromUrl();
893
+ if (!camera) return options;
894
+ const resolved = {...options};
895
+ delete resolved.bounds;
896
+ delete resolved.fitBoundsOptions;
897
+ return {...resolved, ...camera};
898
+ }
899
+
900
+ function formatCameraNumber(value) {
901
+ return String(Number(value.toFixed(6)));
902
+ }
903
+
904
+ function wrapLongitude(value) {
905
+ return ((value + 180) % 360 + 360) % 360 - 180;
906
+ }
907
+
908
+ function writeCameraToUrl(map) {
909
+ const center = map.getCenter();
910
+ const url = new URL(location.href);
911
+ const camera = {
912
+ bearing: map.getBearing(),
913
+ lat: center.lat,
914
+ lng: wrapLongitude(center.lng),
915
+ pitch: map.getPitch(),
916
+ zoom: map.getZoom()
917
+ };
918
+
919
+ for (const [name, value] of Object.entries(camera)) {
920
+ url.searchParams.set(name, formatCameraNumber(value));
921
+ }
922
+ url.searchParams.set("buildings3d", threeDimensionalEnabled ? "on" : "off");
923
+ url.searchParams.set("trees3d", treesEnabled ? "on" : "off");
924
+ history.replaceState(history.state, "", url.href);
925
+ }
926
+
927
+ function writeToggleStateToUrl() {
928
+ const url = new URL(location.href);
929
+ url.searchParams.set("buildings3d", threeDimensionalEnabled ? "on" : "off");
930
+ url.searchParams.set("trees3d", treesEnabled ? "on" : "off");
931
+ history.replaceState(history.state, "", url.href);
932
+ }
933
+
934
+ function updateGlobeBackdrop(map) {
935
+ const projection = map.getProjection?.()?.type ?? map.getStyle?.()?.projection?.type;
936
+ const container = map.getContainer?.();
937
+ if (!container || !projection) return;
938
+ if (projection !== "globe") {
939
+ container.classList.remove("tileflow-globe");
940
+ return;
941
+ }
942
+
943
+ // A Web Mercator world is 512 * 2^zoom pixels wide. Dividing its
944
+ // circumference by 2\u03C0 closely tracks MapLibre's on-screen globe radius.
945
+ const radius = Math.min(
946
+ 4096,
947
+ ((512 * Math.pow(2, map.getZoom())) / (2 * Math.PI)) * 0.91
948
+ );
949
+ container.classList.add("tileflow-globe");
950
+ container.style.setProperty("--tileflow-globe-radius", radius.toFixed(2) + "px");
951
+ applyAtmosphereBackdrop(map, container);
952
+ }
953
+
954
+ // Uses the map's declared atmosphere for the preview's space and rim, so a themed globe shows
955
+ // its own colours; maps without one keep the default backdrop.
956
+ function applyAtmosphereBackdrop(map, container) {
957
+ const atmosphere = map.getStyle?.()?.metadata?.["tileflow:atmosphere"];
958
+ const rgba = (hex, alpha) => {
959
+ const match = /^#([0-9a-f]{6})$/i.exec(String(hex ?? ""));
960
+ if (!match) return undefined;
961
+ const value = Number.parseInt(match[1], 16);
962
+ return "rgba(" + (value >> 16) + ", " + ((value >> 8) & 255) + ", " + (value & 255) + ", " + alpha + ")";
963
+ };
964
+ const properties = {
965
+ "--tileflow-space": rgba(atmosphere?.spaceColor, 1),
966
+ "--tileflow-rim-line": rgba(atmosphere?.horizonColor, 0.88),
967
+ "--tileflow-rim-core": rgba(atmosphere?.horizonColor, 0.9),
968
+ "--tileflow-rim-glow": rgba(atmosphere?.horizonColor, 0.52),
969
+ "--tileflow-rim-haze": rgba(atmosphere?.skyColor, 0.35),
970
+ };
971
+ for (const [name, value] of Object.entries(properties)) {
972
+ if (value) container.style.setProperty(name, value);
973
+ else container.style.removeProperty(name);
974
+ }
975
+ }
976
+
977
+ function createBuildingWireframeLayer(map, styleLayer) {
978
+ const maximumBuildings = 10000;
979
+ const maximumSegments = 300000;
980
+ const viewportBufferPixels = 96;
981
+ const sourceId = styleLayer.source;
982
+ const metadata = styleLayer.metadata ?? {};
983
+ const heightField = metadata["tileflow:building-wireframe-height-field"] ??
984
+ "render_height";
985
+ const baseField = metadata["tileflow:building-wireframe-base-field"] ??
986
+ "render_min_height";
987
+ const configuredHeightScale = Number(
988
+ metadata["tileflow:building-wireframe-height-scale"] ?? 1
989
+ );
990
+ const heightScale = Number.isFinite(configuredHeightScale) && configuredHeightScale > 0
991
+ ? configuredHeightScale
992
+ : 1;
993
+ const color = metadata["tileflow:building-wireframe-color"] ?? "#43E4FF";
994
+ const glowColor = metadata["tileflow:building-wireframe-glow-color"] ?? "#147DFF";
995
+ const configuredOpacity = Number(
996
+ metadata["tileflow:building-wireframe-opacity"] ?? 0.76
997
+ );
998
+ const opacity = Number.isFinite(configuredOpacity)
999
+ ? Math.max(0, Math.min(1, configuredOpacity))
1000
+ : 0.76;
1001
+ const configuredWidth = Number(
1002
+ metadata["tileflow:building-wireframe-width"] ?? 1.15
1003
+ );
1004
+ const width = Number.isFinite(configuredWidth) && configuredWidth > 0
1005
+ ? configuredWidth
1006
+ : 1.15;
1007
+ const configuredGlowOpacity = Number(
1008
+ metadata["tileflow:building-wireframe-glow-opacity"] ?? 0.11
1009
+ );
1010
+ const glowOpacity = Number.isFinite(configuredGlowOpacity)
1011
+ ? Math.max(0, Math.min(1, configuredGlowOpacity))
1012
+ : 0.11;
1013
+ const configuredGlowWidth = Number(
1014
+ metadata["tileflow:building-wireframe-glow-width"] ?? 3.5
1015
+ );
1016
+ const glowWidth = Number.isFinite(configuredGlowWidth) && configuredGlowWidth > width
1017
+ ? configuredGlowWidth
1018
+ : Math.max(3.5, width * 3);
1019
+ let camera;
1020
+ let renderer;
1021
+ let scene;
1022
+ let group;
1023
+ let geometry;
1024
+ let glowLines;
1025
+ let coreLines;
1026
+ let glowMaterial;
1027
+ let coreMaterial;
1028
+ let sceneOriginMercator;
1029
+ let refreshTimer;
1030
+ let emptyRefreshAttempts = 0;
1031
+ let lastGeometrySignature;
1032
+ const mapMatrix = new THREE.Matrix4();
1033
+ const sceneMatrix = new THREE.Matrix4();
1034
+
1035
+ function geometryPolygons(candidate) {
1036
+ if (candidate?.type === "Polygon") return [candidate.coordinates];
1037
+ if (candidate?.type === "MultiPolygon") return candidate.coordinates;
1038
+ return [];
1039
+ }
1040
+
1041
+ function ringIntersectsViewport(ring, viewportWidth, viewportHeight) {
1042
+ if (!Array.isArray(ring) || ring.length < 3) return false;
1043
+ let minimumX = Infinity;
1044
+ let minimumY = Infinity;
1045
+ let maximumX = -Infinity;
1046
+ let maximumY = -Infinity;
1047
+ for (const coordinate of ring) {
1048
+ const longitude = Number(coordinate?.[0]);
1049
+ const latitude = Number(coordinate?.[1]);
1050
+ if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return false;
1051
+ const point = map.project([longitude, latitude]);
1052
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return false;
1053
+ minimumX = Math.min(minimumX, point.x);
1054
+ minimumY = Math.min(minimumY, point.y);
1055
+ maximumX = Math.max(maximumX, point.x);
1056
+ maximumY = Math.max(maximumY, point.y);
1057
+ }
1058
+ return (
1059
+ maximumX >= -viewportBufferPixels &&
1060
+ minimumX <= viewportWidth + viewportBufferPixels &&
1061
+ maximumY >= -viewportBufferPixels &&
1062
+ minimumY <= viewportHeight + viewportBufferPixels
1063
+ );
1064
+ }
1065
+
1066
+ function ringVertexCount(ring) {
1067
+ if (!Array.isArray(ring) || ring.length < 3) return 0;
1068
+ const first = ring[0];
1069
+ const last = ring.at(-1);
1070
+ const closes =
1071
+ Number(first?.[0]) === Number(last?.[0]) &&
1072
+ Number(first?.[1]) === Number(last?.[1]);
1073
+ return closes ? ring.length - 1 : ring.length;
1074
+ }
1075
+
1076
+ function buildingKey(feature, rings, height, base) {
1077
+ let pointCount = 0;
1078
+ let minimumLongitude = Infinity;
1079
+ let minimumLatitude = Infinity;
1080
+ let maximumLongitude = -Infinity;
1081
+ let maximumLatitude = -Infinity;
1082
+ for (const ring of rings) {
1083
+ for (const coordinate of ring) {
1084
+ const longitude = Number(coordinate?.[0]);
1085
+ const latitude = Number(coordinate?.[1]);
1086
+ if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) continue;
1087
+ pointCount += 1;
1088
+ minimumLongitude = Math.min(minimumLongitude, longitude);
1089
+ minimumLatitude = Math.min(minimumLatitude, latitude);
1090
+ maximumLongitude = Math.max(maximumLongitude, longitude);
1091
+ maximumLatitude = Math.max(maximumLatitude, latitude);
1092
+ }
1093
+ }
1094
+ const properties = feature.properties ?? {};
1095
+ const sourceFeatureId =
1096
+ feature.id ?? properties.osm_id ?? properties.osmId ?? properties.id ?? "geometry";
1097
+ return [
1098
+ sourceFeatureId,
1099
+ pointCount,
1100
+ minimumLongitude.toFixed(6),
1101
+ minimumLatitude.toFixed(6),
1102
+ maximumLongitude.toFixed(6),
1103
+ maximumLatitude.toFixed(6),
1104
+ height.toFixed(2),
1105
+ base.toFixed(2)
1106
+ ].join(":");
1107
+ }
1108
+
1109
+ function appendSegment(positions, left, right) {
1110
+ positions.push(left[0], left[1], left[2], right[0], right[1], right[2]);
1111
+ }
1112
+
1113
+ function appendRing(positions, ring, base, height, mercatorPerMeter) {
1114
+ if (!Array.isArray(ring) || ring.length < 3) return 0;
1115
+ const first = ring[0];
1116
+ const last = ring.at(-1);
1117
+ const closes =
1118
+ Number(first?.[0]) === Number(last?.[0]) &&
1119
+ Number(first?.[1]) === Number(last?.[1]);
1120
+ const vertexCount = closes ? ring.length - 1 : ring.length;
1121
+ if (vertexCount < 3) return 0;
1122
+ if (positions.length / 6 + vertexCount * 3 > maximumSegments) return 0;
1123
+ const lower = [];
1124
+ const upper = [];
1125
+ for (let index = 0; index < vertexCount; index += 1) {
1126
+ const longitude = Number(ring[index]?.[0]);
1127
+ const latitude = Number(ring[index]?.[1]);
1128
+ if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return 0;
1129
+ const mercator = maplibregl.MercatorCoordinate.fromLngLat([longitude, latitude]);
1130
+ const x = (mercator.x - sceneOriginMercator.x) / mercatorPerMeter;
1131
+ const y = (mercator.y - sceneOriginMercator.y) / mercatorPerMeter;
1132
+ lower.push([x, y, base]);
1133
+ upper.push([x, y, height]);
1134
+ }
1135
+ for (let index = 0; index < vertexCount; index += 1) {
1136
+ const next = (index + 1) % vertexCount;
1137
+ appendSegment(positions, lower[index], lower[next]);
1138
+ appendSegment(positions, upper[index], upper[next]);
1139
+ appendSegment(positions, lower[index], upper[index]);
1140
+ }
1141
+ return vertexCount * 3;
1142
+ }
1143
+
1144
+ function replaceGeometry(positions) {
1145
+ const nextGeometry = new LineSegmentsGeometry();
1146
+ nextGeometry.setPositions(new Float32Array(positions));
1147
+ nextGeometry.computeBoundingSphere();
1148
+ if (!glowLines) {
1149
+ glowLines = new LineSegments2(nextGeometry, glowMaterial);
1150
+ coreLines = new LineSegments2(nextGeometry, coreMaterial);
1151
+ glowLines.frustumCulled = false;
1152
+ coreLines.frustumCulled = false;
1153
+ group.add(glowLines, coreLines);
1154
+ } else {
1155
+ glowLines.geometry = nextGeometry;
1156
+ coreLines.geometry = nextGeometry;
1157
+ geometry?.dispose();
1158
+ }
1159
+ geometry = nextGeometry;
1160
+ }
1161
+
1162
+ function wireframeShouldBeVisible() {
1163
+ return (
1164
+ threeDimensionalEnabled &&
1165
+ visibleLayerGroups.has("buildings") &&
1166
+ styleLayerIsVisibleAtZoom(styleLayer, map.getZoom()) &&
1167
+ map.getLayoutProperty(styleLayer.id, "visibility") !== "none"
1168
+ );
1169
+ }
1170
+
1171
+ function hideWireframe() {
1172
+ if (group) group.visible = false;
1173
+ buildingWireframeMetrics.buildingCount = 0;
1174
+ buildingWireframeMetrics.refreshMilliseconds = undefined;
1175
+ buildingWireframeMetrics.segmentCount = 0;
1176
+ buildingWireframeMetrics.truncated = false;
1177
+ }
1178
+
1179
+ function refresh() {
1180
+ const startedAt = performance.now();
1181
+ refreshTimer = undefined;
1182
+ if (!wireframeShouldBeVisible() || map.isMoving()) {
1183
+ hideWireframe();
1184
+ return;
1185
+ }
1186
+ const canvas = map.getCanvas();
1187
+ const viewportWidth = canvas.clientWidth || canvas.width;
1188
+ const viewportHeight = canvas.clientHeight || canvas.height;
1189
+ let features;
1190
+ try {
1191
+ features = map.queryRenderedFeatures(
1192
+ [
1193
+ [-viewportBufferPixels, -viewportBufferPixels],
1194
+ [
1195
+ viewportWidth + viewportBufferPixels,
1196
+ viewportHeight + viewportBufferPixels
1197
+ ]
1198
+ ],
1199
+ {layers: [styleLayer.id]}
1200
+ );
1201
+ } catch (error) {
1202
+ console.warn("Tileflow building wireframe could not read visible buildings", error);
1203
+ return;
1204
+ }
1205
+ if (features.length === 0) {
1206
+ hideWireframe();
1207
+ if (emptyRefreshAttempts < 12) {
1208
+ emptyRefreshAttempts += 1;
1209
+ queueRefresh(160);
1210
+ }
1211
+ return;
1212
+ }
1213
+ emptyRefreshAttempts = 0;
1214
+ sceneOriginMercator = maplibregl.MercatorCoordinate.fromLngLat(map.getCenter(), 0);
1215
+ const mercatorPerMeter = sceneOriginMercator.meterInMercatorCoordinateUnits();
1216
+ const positions = [];
1217
+ const seen = new Set();
1218
+ let buildingCount = 0;
1219
+ let segmentCount = 0;
1220
+ let truncated = false;
1221
+ featureLoop: for (const feature of features) {
1222
+ const polygons = geometryPolygons(feature.geometry);
1223
+ if (polygons.length === 0) continue;
1224
+ const properties = feature.properties ?? {};
1225
+ const parsedHeight = Number(properties[heightField]);
1226
+ const sourceHeight = Math.max(0, Number.isFinite(parsedHeight) ? parsedHeight : 5);
1227
+ const parsedBase = Number(properties[baseField]);
1228
+ const sourceBase = Math.max(
1229
+ 0,
1230
+ Math.min(Number.isFinite(parsedBase) ? parsedBase : 0, sourceHeight)
1231
+ );
1232
+ const height = sourceHeight * heightScale;
1233
+ const base = sourceBase * heightScale;
1234
+ if (height <= base) continue;
1235
+ for (const rings of polygons) {
1236
+ if (buildingCount >= maximumBuildings) {
1237
+ truncated = true;
1238
+ break featureLoop;
1239
+ }
1240
+ const outerRing = rings?.[0];
1241
+ if (!ringIntersectsViewport(outerRing, viewportWidth, viewportHeight)) continue;
1242
+ const key = buildingKey(feature, rings, height, base);
1243
+ if (seen.has(key)) continue;
1244
+ const requiredSegments = rings.reduce(
1245
+ (total, ring) => total + ringVertexCount(ring) * 3,
1246
+ 0
1247
+ );
1248
+ if (segmentCount + requiredSegments > maximumSegments) {
1249
+ truncated = true;
1250
+ break featureLoop;
1251
+ }
1252
+ seen.add(key);
1253
+ const segmentCountBefore = segmentCount;
1254
+ for (const ring of rings) {
1255
+ segmentCount += appendRing(positions, ring, base, height, mercatorPerMeter);
1256
+ }
1257
+ if (segmentCount > segmentCountBefore) buildingCount += 1;
1258
+ }
1259
+ }
1260
+ if (positions.length === 0) {
1261
+ hideWireframe();
1262
+ return;
1263
+ }
1264
+ const geometrySignature = [
1265
+ sceneOriginMercator.x.toFixed(9),
1266
+ sceneOriginMercator.y.toFixed(9),
1267
+ buildingCount,
1268
+ segmentCount,
1269
+ ...[...seen].sort()
1270
+ ].join("|");
1271
+ if (geometrySignature !== lastGeometrySignature || !geometry) {
1272
+ replaceGeometry(positions);
1273
+ lastGeometrySignature = geometrySignature;
1274
+ }
1275
+ group.visible = true;
1276
+ buildingWireframeMetrics.buildingCount = buildingCount;
1277
+ buildingWireframeMetrics.segmentCount = segmentCount;
1278
+ buildingWireframeMetrics.refreshMilliseconds = performance.now() - startedAt;
1279
+ buildingWireframeMetrics.truncated = truncated;
1280
+ map.triggerRepaint();
1281
+ }
1282
+
1283
+ function queueRefresh(delay = 80) {
1284
+ if (refreshTimer !== undefined) return;
1285
+ refreshTimer = setTimeout(refresh, delay);
1286
+ }
1287
+
1288
+ function handleMoveEnd() {
1289
+ emptyRefreshAttempts = 0;
1290
+ queueRefresh(0);
1291
+ }
1292
+
1293
+ function handleSourceData(event) {
1294
+ if (event.sourceId !== sourceId || event.isSourceLoaded !== true || map.isMoving()) {
1295
+ return;
1296
+ }
1297
+ queueRefresh();
1298
+ }
1299
+
1300
+ function handleThreeDimensionalToggle() {
1301
+ if (!threeDimensionalEnabled) {
1302
+ if (refreshTimer !== undefined) clearTimeout(refreshTimer);
1303
+ refreshTimer = undefined;
1304
+ hideWireframe();
1305
+ map.triggerRepaint();
1306
+ return;
1307
+ }
1308
+ emptyRefreshAttempts = 0;
1309
+ queueRefresh(0);
1310
+ }
1311
+
1312
+ function handleVisibleLayerGroups() {
1313
+ if (!visibleLayerGroups.has("buildings")) {
1314
+ hideWireframe();
1315
+ map.triggerRepaint();
1316
+ return;
1317
+ }
1318
+ emptyRefreshAttempts = 0;
1319
+ queueRefresh(0);
1320
+ }
1321
+
1322
+ return {
1323
+ id: "tileflow-buildings-wireframe-3d",
1324
+ type: "custom",
1325
+ renderingMode: "3d",
1326
+ onAdd(_map, gl) {
1327
+ camera = new THREE.Camera();
1328
+ scene = new THREE.Scene();
1329
+ group = new THREE.Group();
1330
+ group.visible = false;
1331
+ scene.add(group);
1332
+ glowMaterial = new LineMaterial({
1333
+ blending: THREE.AdditiveBlending,
1334
+ color: glowColor,
1335
+ depthTest: false,
1336
+ depthWrite: false,
1337
+ linewidth: glowWidth,
1338
+ opacity: glowOpacity,
1339
+ toneMapped: false,
1340
+ transparent: true,
1341
+ worldUnits: false
1342
+ });
1343
+ coreMaterial = new LineMaterial({
1344
+ blending: THREE.NormalBlending,
1345
+ color,
1346
+ depthTest: false,
1347
+ depthWrite: false,
1348
+ linewidth: width,
1349
+ opacity,
1350
+ toneMapped: false,
1351
+ transparent: true,
1352
+ worldUnits: false
1353
+ });
1354
+ renderer = new THREE.WebGLRenderer({
1355
+ canvas: map.getCanvas(),
1356
+ context: gl
1357
+ });
1358
+ renderer.autoClear = false;
1359
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
1360
+ map.on("moveend", handleMoveEnd);
1361
+ map.on("sourcedata", handleSourceData);
1362
+ map.on("tileflow:3d-toggle", handleThreeDimensionalToggle);
1363
+ map.on("tileflow:visible-layer-groups", handleVisibleLayerGroups);
1364
+ queueRefresh(0);
1365
+ },
1366
+ render(_gl, options) {
1367
+ if (!wireframeShouldBeVisible() || !sceneOriginMercator || !group?.visible) return;
1368
+ const canvas = map.getCanvas();
1369
+ const pixelRatio = map.getPixelRatio?.() ?? globalThis.devicePixelRatio ?? 1;
1370
+ glowMaterial.linewidth = glowWidth * pixelRatio;
1371
+ coreMaterial.linewidth = width * pixelRatio;
1372
+ glowMaterial.resolution.set(canvas.width, canvas.height);
1373
+ coreMaterial.resolution.set(canvas.width, canvas.height);
1374
+ const meter = sceneOriginMercator.meterInMercatorCoordinateUnits();
1375
+ mapMatrix.fromArray(options.defaultProjectionData.mainMatrix);
1376
+ sceneMatrix
1377
+ .makeTranslation(
1378
+ sceneOriginMercator.x,
1379
+ sceneOriginMercator.y,
1380
+ sceneOriginMercator.z
1381
+ )
1382
+ .scale(new THREE.Vector3(meter, meter, meter));
1383
+ camera.projectionMatrix.copy(mapMatrix).multiply(sceneMatrix);
1384
+ renderer.resetState();
1385
+ renderer.setViewport(0, 0, canvas.width, canvas.height);
1386
+ renderer.render(scene, camera);
1387
+ },
1388
+ onRemove() {
1389
+ if (refreshTimer !== undefined) clearTimeout(refreshTimer);
1390
+ map.off("moveend", handleMoveEnd);
1391
+ map.off("sourcedata", handleSourceData);
1392
+ map.off("tileflow:3d-toggle", handleThreeDimensionalToggle);
1393
+ map.off("tileflow:visible-layer-groups", handleVisibleLayerGroups);
1394
+ geometry?.dispose();
1395
+ glowMaterial?.dispose();
1396
+ coreMaterial?.dispose();
1397
+ renderer?.dispose();
1398
+ hideWireframe();
1399
+ }
1400
+ };
1401
+ }
1402
+
1403
+ function addBuildingWireframeLayerIfConfigured(map, styleLayers) {
1404
+ const styleLayer = styleLayers.find(
1405
+ (layer) => layer.metadata?.["tileflow:building-wireframe"] === true
1406
+ );
1407
+ if (!styleLayer || map.getLayer("tileflow-buildings-wireframe-3d")) return;
1408
+ if (map.getProjection().type !== "mercator") {
1409
+ map.setProjection({type: "mercator"});
1410
+ }
1411
+ if (map.getLayer("tileflow-buildings-wireframe-3d")) return;
1412
+ const styleLayerIndex = styleLayers.findIndex((layer) => layer.id === styleLayer.id);
1413
+ const layerAboveBuildings = styleLayers[styleLayerIndex + 1]?.id;
1414
+ map.addLayer(createBuildingWireframeLayer(map, styleLayer), layerAboveBuildings);
1415
+ }
1416
+
1417
+ function styleLayerZoomRange(layer) {
1418
+ return {
1419
+ minzoom: Number.isFinite(layer?.minzoom) ? layer.minzoom : 0,
1420
+ maxzoom: Number.isFinite(layer?.maxzoom) ? layer.maxzoom : Infinity
1421
+ };
1422
+ }
1423
+
1424
+ function styleLayerIsVisibleAtZoom(layer, zoom) {
1425
+ const {minzoom, maxzoom} = styleLayerZoomRange(layer);
1426
+ return zoom >= minzoom && zoom < maxzoom;
1427
+ }
1428
+
1429
+ function createLandmarkLayer(map, configLayer, fallbackLayers = []) {
1430
+ const manifestUrl = configLayer.metadata?.["tileflow:landmark-manifest-url"];
1431
+ const {minzoom: landmarkMinimumZoom, maxzoom: landmarkMaximumZoom} =
1432
+ styleLayerZoomRange(configLayer);
1433
+ const abortController = new AbortController();
1434
+ const loaded = new Map();
1435
+ const loading = new Map();
1436
+ const archiveReaders = new Map();
1437
+ const activeLandmarkIds = new Set();
1438
+ let cacheClock = 0;
1439
+ let camera;
1440
+ let renderer;
1441
+ let scene;
1442
+ let group;
1443
+ let sceneOriginMercator;
1444
+ let sceneElevation = 0;
1445
+ let manifest;
1446
+ let fallbackSignature;
1447
+ let queuedRefresh;
1448
+ const mapMatrix = new THREE.Matrix4();
1449
+ const sceneMatrix = new THREE.Matrix4();
1450
+
1451
+ function overlapsViewport(bounds, paddingRatio = 0) {
1452
+ const viewport = map.getBounds();
1453
+ const longitudePadding = Math.abs(viewport.getEast() - viewport.getWest()) * paddingRatio;
1454
+ const latitudePadding = Math.abs(viewport.getNorth() - viewport.getSouth()) * paddingRatio;
1455
+ return !(
1456
+ bounds[2] < viewport.getWest() - longitudePadding ||
1457
+ bounds[0] > viewport.getEast() + longitudePadding ||
1458
+ bounds[3] < viewport.getSouth() - latitudePadding ||
1459
+ bounds[1] > viewport.getNorth() + latitudePadding
1460
+ );
1461
+ }
1462
+
1463
+ function updateLandmarkFallbackFilters() {
1464
+ const readyIds = [...activeLandmarkIds]
1465
+ .filter((id) => loaded.has(id))
1466
+ .sort();
1467
+ const signature = JSON.stringify(readyIds);
1468
+ if (signature === fallbackSignature) return;
1469
+ fallbackSignature = signature;
1470
+ for (const fallback of fallbackLayers) {
1471
+ if (!map.getLayer(fallback.id)) continue;
1472
+ const spatialExclusions = (fallback.spatialFallbacks ?? [])
1473
+ .filter((entry) => readyIds.includes(entry.id))
1474
+ .map((entry) => ["!", ["within", {
1475
+ type: "Polygon",
1476
+ coordinates: [[
1477
+ [entry.bounds[0], entry.bounds[1]],
1478
+ [entry.bounds[2], entry.bounds[1]],
1479
+ [entry.bounds[2], entry.bounds[3]],
1480
+ [entry.bounds[0], entry.bounds[3]],
1481
+ [entry.bounds[0], entry.bounds[1]]
1482
+ ]]
1483
+ }]]);
1484
+ map.setFilter(
1485
+ fallback.id,
1486
+ readyIds.length === 0 && spatialExclusions.length === 0
1487
+ ? (fallback.filter ?? null)
1488
+ : [
1489
+ "all",
1490
+ ...(fallback.filter ? [fallback.filter] : []),
1491
+ [
1492
+ "!",
1493
+ ["in", ["get", "landmark_id"], ["literal", readyIds]]
1494
+ ],
1495
+ ...spatialExclusions
1496
+ ]
1497
+ );
1498
+ }
1499
+ }
1500
+
1501
+ function queueRefresh() {
1502
+ if (queuedRefresh !== undefined) return;
1503
+ queuedRefresh = setTimeout(() => {
1504
+ queuedRefresh = undefined;
1505
+ refresh();
1506
+ }, 100);
1507
+ }
1508
+
1509
+ function ensureSceneOrigin(elevation) {
1510
+ if (sceneOriginMercator) return;
1511
+ sceneElevation = elevation;
1512
+ sceneOriginMercator = maplibregl.MercatorCoordinate.fromLngLat(
1513
+ map.getCenter(),
1514
+ elevation
1515
+ );
1516
+ }
1517
+
1518
+ function localPosition(lng, lat, elevation) {
1519
+ const mercator = maplibregl.MercatorCoordinate.fromLngLat([lng, lat]);
1520
+ const mercatorPerMeter = sceneOriginMercator.meterInMercatorCoordinateUnits();
1521
+ return new THREE.Vector3(
1522
+ (mercator.x - sceneOriginMercator.x) / mercatorPerMeter,
1523
+ elevation - sceneElevation,
1524
+ (sceneOriginMercator.y - mercator.y) / mercatorPerMeter
1525
+ );
1526
+ }
1527
+
1528
+ function disposeModel(model) {
1529
+ const disposed = new Set();
1530
+ group?.remove(model);
1531
+ for (const texture of model.userData?.tileflowDetachedTextures ?? []) {
1532
+ if (!texture?.isTexture || disposed.has(texture)) continue;
1533
+ disposed.add(texture);
1534
+ texture.dispose();
1535
+ }
1536
+ for (const material of model.userData?.tileflowDetachedMaterials ?? []) {
1537
+ if (!material || disposed.has(material)) continue;
1538
+ for (const value of Object.values(material)) {
1539
+ if (value?.isTexture && !disposed.has(value)) {
1540
+ disposed.add(value);
1541
+ value.dispose();
1542
+ }
1543
+ }
1544
+ disposed.add(material);
1545
+ material.dispose?.();
1546
+ }
1547
+ model.traverse((object) => {
1548
+ if (object.geometry && !disposed.has(object.geometry)) {
1549
+ disposed.add(object.geometry);
1550
+ object.geometry.dispose?.();
1551
+ }
1552
+ const materials = Array.isArray(object.material)
1553
+ ? object.material
1554
+ : [object.material];
1555
+ for (const material of materials) {
1556
+ if (!material || disposed.has(material)) continue;
1557
+ for (const value of Object.values(material)) {
1558
+ if (value?.isTexture && !disposed.has(value)) {
1559
+ disposed.add(value);
1560
+ value.dispose();
1561
+ }
1562
+ }
1563
+ disposed.add(material);
1564
+ material.dispose?.();
1565
+ }
1566
+ });
1567
+ }
1568
+
1569
+ function harmonizeLandmarkModel(model) {
1570
+ const detachedMaterials = new Set();
1571
+ const uniformMaterial = new THREE.MeshStandardMaterial({
1572
+ alphaTest: 0,
1573
+ color: "#EEE4D4",
1574
+ depthWrite: true,
1575
+ metalness: 0,
1576
+ opacity: 1,
1577
+ roughness: 0.9,
1578
+ side: THREE.DoubleSide,
1579
+ transparent: false
1580
+ });
1581
+ model.traverse((object) => {
1582
+ if (!object.isMesh) return;
1583
+ const originalMaterials = Array.isArray(object.material)
1584
+ ? object.material
1585
+ : [object.material];
1586
+ for (const material of originalMaterials) {
1587
+ if (material) detachedMaterials.add(material);
1588
+ }
1589
+ object.material = Array.isArray(object.material)
1590
+ ? object.material.map(() => uniformMaterial)
1591
+ : uniformMaterial;
1592
+ });
1593
+ model.userData.tileflowDetachedMaterials = [...detachedMaterials];
1594
+ }
1595
+
1596
+ function normalizeLandmarkAxes(sourceModel, axisConvention) {
1597
+ if (axisConvention === "EUN_Y_UP") return sourceModel;
1598
+ if (axisConvention === "ENU_Z_UP") {
1599
+ sourceModel.rotateX(-Math.PI / 2);
1600
+ }
1601
+ const normalizedModel = new THREE.Group();
1602
+ normalizedModel.scale.z = -1;
1603
+ normalizedModel.add(sourceModel);
1604
+ return normalizedModel;
1605
+ }
1606
+
1607
+ function unloadLandmark(id) {
1608
+ const entry = loaded.get(id);
1609
+ if (!entry) return;
1610
+ disposeModel(entry.model);
1611
+ loaded.delete(id);
1612
+ landmarkState.loaded = [...loaded.keys()];
1613
+ fallbackSignature = undefined;
1614
+ }
1615
+
1616
+ function enforceCacheLimit() {
1617
+ if (!manifest || loaded.size <= manifest.maximumCachedModels) return;
1618
+ const evictionCandidates = [...loaded.entries()]
1619
+ .filter(([id]) => !activeLandmarkIds.has(id))
1620
+ .sort(
1621
+ ([leftId, left], [rightId, right]) =>
1622
+ left.lastUsed - right.lastUsed || leftId.localeCompare(rightId)
1623
+ );
1624
+ while (
1625
+ loaded.size > manifest.maximumCachedModels &&
1626
+ evictionCandidates.length > 0
1627
+ ) {
1628
+ unloadLandmark(evictionCandidates.shift()[0]);
1629
+ }
1630
+ }
1631
+
1632
+ function landmarkModelAtZoom(landmark, zoom) {
1633
+ let selected = landmark.models[0];
1634
+ for (const candidate of landmark.models) {
1635
+ if (candidate.minzoom > zoom) break;
1636
+ selected = candidate;
1637
+ }
1638
+ return selected;
1639
+ }
1640
+
1641
+ async function sha256Hex(bytes) {
1642
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
1643
+ return [...new Uint8Array(digest)]
1644
+ .map((value) => value.toString(16).padStart(2, "0"))
1645
+ .join("");
1646
+ }
1647
+
1648
+ function archiveReader(archive) {
1649
+ let reader = archiveReaders.get(archive.url);
1650
+ if (!reader) {
1651
+ reader = new PMTiles(
1652
+ new FetchSource(archive.url, new Headers(), "include")
1653
+ );
1654
+ archiveReaders.set(archive.url, reader);
1655
+ }
1656
+ return reader;
1657
+ }
1658
+
1659
+ async function loadLandmarkBytes(modelDefinition) {
1660
+ const tile = await archiveReader(modelDefinition.archive).getZxy(
1661
+ modelDefinition.z,
1662
+ modelDefinition.x,
1663
+ modelDefinition.y,
1664
+ abortController.signal
1665
+ );
1666
+ if (!tile) throw new Error("landmark entry is missing from PMTiles");
1667
+ if (tile.data.byteLength !== modelDefinition.bytes) {
1668
+ throw new Error("landmark entry has an unexpected byte length");
1669
+ }
1670
+ if ((await sha256Hex(tile.data)) !== modelDefinition.sha256) {
1671
+ throw new Error("landmark entry has an unexpected SHA-256");
1672
+ }
1673
+ return tile.data;
1674
+ }
1675
+
1676
+ function loadLandmark(landmark, requestedModel) {
1677
+ const cached = loaded.get(landmark.id);
1678
+ const nextModel = cached ? requestedModel : landmark.models[0];
1679
+ if (cached?.modelKey === nextModel.key) {
1680
+ cached.lastUsed = ++cacheClock;
1681
+ cached.model.visible = activeLandmarkIds.has(landmark.id);
1682
+ return;
1683
+ }
1684
+ if (cached) {
1685
+ // Keep the previous LOD visible until its replacement has loaded.
1686
+ cached.lastUsed = ++cacheClock;
1687
+ cached.model.visible = activeLandmarkIds.has(landmark.id);
1688
+ }
1689
+ if (loading.has(landmark.id)) return;
1690
+ loading.set(landmark.id, nextModel.key);
1691
+ landmarkState.loading = [...loading.keys()];
1692
+ const loader = new GLTFLoader();
1693
+ loader.setMeshoptDecoder(MeshoptDecoder);
1694
+ loader.setDRACOLoader(landmarkDracoLoader);
1695
+ loader.setWithCredentials(true);
1696
+ const fail = (error) => {
1697
+ if (loading.get(landmark.id) !== nextModel.key) return;
1698
+ loading.delete(landmark.id);
1699
+ landmarkState.loading = [...loading.keys()];
1700
+ landmarkState.errors = [
1701
+ ...landmarkState.errors.filter((item) => item.id !== landmark.id),
1702
+ {id: landmark.id, message: String(error)}
1703
+ ];
1704
+ console.warn("Tileflow landmark failed to load", landmark.id, error);
1705
+ };
1706
+ void loadLandmarkBytes(nextModel).then((bytes) => {
1707
+ if (loading.get(landmark.id) !== nextModel.key) return;
1708
+ loader.parse(
1709
+ bytes,
1710
+ new URL(".", nextModel.archive.url).toString(),
1711
+ (gltf) => {
1712
+ if (loading.get(landmark.id) !== nextModel.key) {
1713
+ disposeModel(gltf.scene);
1714
+ return;
1715
+ }
1716
+ loading.delete(landmark.id);
1717
+ landmarkState.loading = [...loading.keys()];
1718
+ if (abortController.signal.aborted) {
1719
+ disposeModel(gltf.scene);
1720
+ return;
1721
+ }
1722
+ const elevation = map.queryTerrainElevation(landmark.center) ?? 0;
1723
+ ensureSceneOrigin(elevation);
1724
+ const model = normalizeLandmarkAxes(
1725
+ gltf.scene,
1726
+ nextModel.axisConvention
1727
+ );
1728
+ model.name = "tileflow-landmark-" + landmark.id;
1729
+ harmonizeLandmarkModel(model);
1730
+ model.position.copy(
1731
+ localPosition(landmark.center[0], landmark.center[1], elevation)
1732
+ );
1733
+ model.traverse((object) => {
1734
+ if (!object.isMesh) return;
1735
+ object.frustumCulled = true;
1736
+ object.castShadow = false;
1737
+ object.receiveShadow = false;
1738
+ });
1739
+ model.visible = activeLandmarkIds.has(landmark.id);
1740
+ if (loaded.has(landmark.id)) unloadLandmark(landmark.id);
1741
+ loaded.set(landmark.id, {
1742
+ lastUsed: ++cacheClock,
1743
+ model,
1744
+ modelKey: nextModel.key
1745
+ });
1746
+ landmarkState.errors = landmarkState.errors.filter(
1747
+ (item) => item.id !== landmark.id
1748
+ );
1749
+ landmarkState.loaded = [...loaded.keys()];
1750
+ group.add(model);
1751
+ enforceCacheLimit();
1752
+ fallbackSignature = undefined;
1753
+ refresh();
1754
+ },
1755
+ fail
1756
+ );
1757
+ }).catch(fail);
1758
+ }
1759
+
1760
+ function refresh() {
1761
+ if (
1762
+ !threeDimensionalEnabled ||
1763
+ !manifest ||
1764
+ !styleLayerIsVisibleAtZoom(configLayer, map.getZoom())
1765
+ ) {
1766
+ activeLandmarkIds.clear();
1767
+ landmarkState.active = [];
1768
+ for (const entry of loaded.values()) entry.model.visible = false;
1769
+ updateLandmarkFallbackFilters();
1770
+ return;
1771
+ }
1772
+ const zoom = map.getZoom();
1773
+ const visible = manifest.landmarks
1774
+ .filter((landmark) => overlapsViewport(landmark.bounds))
1775
+ .sort(
1776
+ (left, right) =>
1777
+ right.priority - left.priority || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
1778
+ )
1779
+ .slice(0, manifest.maximumVisibleModels);
1780
+ activeLandmarkIds.clear();
1781
+ for (const landmark of visible) activeLandmarkIds.add(landmark.id);
1782
+ landmarkState.active = visible.map((landmark) => landmark.id);
1783
+ for (const [id, entry] of loaded) {
1784
+ entry.model.visible = activeLandmarkIds.has(id);
1785
+ }
1786
+ for (const landmark of visible) {
1787
+ loadLandmark(landmark, landmarkModelAtZoom(landmark, zoom));
1788
+ }
1789
+ const nearbyCacheSlots = Math.max(
1790
+ 0,
1791
+ manifest.maximumCachedModels - visible.length
1792
+ );
1793
+ const nearby = manifest.landmarks
1794
+ .filter(
1795
+ (landmark) =>
1796
+ !activeLandmarkIds.has(landmark.id) &&
1797
+ overlapsViewport(landmark.bounds, 0.35)
1798
+ )
1799
+ .sort(
1800
+ (left, right) =>
1801
+ right.priority - left.priority || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
1802
+ )
1803
+ .slice(0, nearbyCacheSlots);
1804
+ for (const landmark of nearby) {
1805
+ loadLandmark(landmark, landmark.models[0]);
1806
+ }
1807
+ enforceCacheLimit();
1808
+ updateLandmarkFallbackFilters();
1809
+ map.triggerRepaint();
1810
+ }
1811
+
1812
+ function normalizeManifest(candidate) {
1813
+ return normalizeTileflowLandmarkManifest(candidate, manifestUrl);
1814
+ }
1815
+
1816
+ async function loadManifest() {
1817
+ try {
1818
+ const candidate = await loadLandmarkManifestCandidate(
1819
+ manifestUrl,
1820
+ abortController.signal
1821
+ );
1822
+ if (abortController.signal.aborted) return;
1823
+ manifest = normalizeManifest(candidate);
1824
+ landmarkState.manifestId = candidate.id;
1825
+ landmarkState.cacheLimit = manifest.maximumCachedModels;
1826
+ refresh();
1827
+ } catch (error) {
1828
+ if (error?.name !== "AbortError") {
1829
+ console.warn("Tileflow landmark manifest failed to load", error);
1830
+ }
1831
+ }
1832
+ }
1833
+
1834
+ return {
1835
+ id: "tileflow-landmarks-3d",
1836
+ type: "custom",
1837
+ renderingMode: "3d",
1838
+ minzoom: landmarkMinimumZoom,
1839
+ ...(Number.isFinite(landmarkMaximumZoom)
1840
+ ? {maxzoom: landmarkMaximumZoom}
1841
+ : {}),
1842
+ metadata: {"tileflow:3d-toggle": "landmark"},
1843
+ onAdd(_map, gl) {
1844
+ camera = new THREE.Camera();
1845
+ scene = new THREE.Scene();
1846
+ scene.rotateX(Math.PI / 2);
1847
+ scene.scale.multiply(new THREE.Vector3(1, 1, -1));
1848
+ group = new THREE.Group();
1849
+ scene.add(group);
1850
+ scene.add(new THREE.AmbientLight(0xffffff, 2.2));
1851
+ scene.add(new THREE.HemisphereLight(0xfff8e8, 0xd8cfbd, 0.8));
1852
+ const sun = new THREE.DirectionalLight(0xffffff, 0.45);
1853
+ sun.position.set(-120, 220, 100);
1854
+ scene.add(sun);
1855
+ renderer = new THREE.WebGLRenderer({
1856
+ canvas: map.getCanvas(),
1857
+ context: gl
1858
+ });
1859
+ renderer.autoClear = false;
1860
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
1861
+ map.on("move", queueRefresh);
1862
+ map.on("moveend", refresh);
1863
+ map.on("tileflow:3d-toggle", refresh);
1864
+ void loadManifest();
1865
+ },
1866
+ render(_gl, options) {
1867
+ if (
1868
+ !threeDimensionalEnabled ||
1869
+ !sceneOriginMercator ||
1870
+ group.children.length === 0
1871
+ ) return;
1872
+ const meter = sceneOriginMercator.meterInMercatorCoordinateUnits();
1873
+ mapMatrix.fromArray(options.defaultProjectionData.mainMatrix);
1874
+ sceneMatrix
1875
+ .makeTranslation(
1876
+ sceneOriginMercator.x,
1877
+ sceneOriginMercator.y,
1878
+ sceneOriginMercator.z
1879
+ )
1880
+ .scale(new THREE.Vector3(meter, -meter, meter));
1881
+ camera.projectionMatrix.copy(mapMatrix).multiply(sceneMatrix);
1882
+ renderer.resetState();
1883
+ renderer.render(scene, camera);
1884
+ },
1885
+ onRemove() {
1886
+ abortController.abort();
1887
+ if (queuedRefresh !== undefined) clearTimeout(queuedRefresh);
1888
+ map.off("move", queueRefresh);
1889
+ map.off("moveend", refresh);
1890
+ map.off("tileflow:3d-toggle", refresh);
1891
+ for (const entry of loaded.values()) disposeModel(entry.model);
1892
+ loaded.clear();
1893
+ for (const fallback of fallbackLayers) {
1894
+ if (map.getLayer(fallback.id)) {
1895
+ map.setFilter(fallback.id, fallback.filter ?? null);
1896
+ }
1897
+ }
1898
+ renderer?.dispose();
1899
+ }
1900
+ };
1901
+ }
1902
+
1903
+ function addLandmarkLayerIfConfigured(map, styleLayers) {
1904
+ const configLayer = styleLayers.find(
1905
+ (layer) => layer.metadata?.["tileflow:landmark-manifest-url"]
1906
+ );
1907
+ if (!configLayer || map.getLayer("tileflow-landmarks-3d")) return;
1908
+ const fallbackLayers = styleLayers
1909
+ .filter((layer) => layer.metadata?.["tileflow:landmark-fallback"] === true)
1910
+ .map((layer) => ({
1911
+ filter: layer.filter,
1912
+ id: layer.id,
1913
+ spatialFallbacks:
1914
+ layer.metadata?.["tileflow:landmark-spatial-fallbacks"] ?? []
1915
+ }));
1916
+ // Landmark custom layers use Mercator world coordinates.
1917
+ if (map.getProjection().type !== "mercator") {
1918
+ map.setProjection({type: "mercator"});
1919
+ }
1920
+ const firstForegroundLabel = styleLayers.find(
1921
+ (layer) =>
1922
+ (layer.id.startsWith("tileflow-label-") &&
1923
+ !layer.id.startsWith("tileflow-label-road-")) ||
1924
+ layer.id.startsWith("tileflow-poi-")
1925
+ )?.id;
1926
+ const treeLayer = map.getLayer("tileflow-vegetation-trees")
1927
+ ? "tileflow-vegetation-trees"
1928
+ : firstForegroundLabel;
1929
+ map.addLayer(createLandmarkLayer(map, configLayer, fallbackLayers), treeLayer);
1930
+ }
1931
+
1932
+ // Core's 3D layers, as an application mounts them: the map's style describes how its
1933
+ // buildings, trees and street lights look, and the controls only show and hide them. With
1934
+ // both controls off the preview draws what Capture draws. The preview shows one theme at a
1935
+ // time, so the style on screen holds the sun and the design.
1936
+ const coreScene = {buildings: undefined, trees: undefined, lights: undefined, buildingsShown: false, treesShown: false};
1937
+ globalThis.__tileflowPreviewScene = coreScene;
1938
+ function coreSceneThemes(map) {
1939
+ return {
1940
+ getSun() {
1941
+ const preset = readTileflowSunPreset(map.getStyle()?.metadata);
1942
+ return resolveTileflowSun(preset ?? tileflowDefaultSunPreset);
1943
+ },
1944
+ getThemeMetadata(key) {
1945
+ return {values: [map.getStyle()?.metadata?.[key]], position: 0};
1946
+ }
1947
+ };
1948
+ }
1949
+ function mountCoreScene(map) {
1950
+ const metadata = map.getStyle()?.metadata ?? {};
1951
+ const controller = coreSceneThemes(map);
1952
+ if (!coreScene.buildings && metadata["tileflow:buildings"]) {
1953
+ coreScene.buildings = createTileflowBuildings3D({map, controller});
1954
+ }
1955
+ if (!coreScene.trees && metadata["tileflow:vegetation"]) {
1956
+ coreScene.trees = createTileflowTrees3D({map, controller});
1957
+ }
1958
+ // Street lights have no capture fallback, so they shine only with the 3D control on.
1959
+ if (!coreScene.lights && threeDimensionalEnabled && metadata["tileflow:street-lights"]) {
1960
+ coreScene.lights = createTileflowStreetLights({map, controller});
1961
+ } else if (coreScene.lights && !threeDimensionalEnabled) {
1962
+ coreScene.lights.dispose();
1963
+ coreScene.lights = undefined;
1964
+ }
1965
+ const instant = {transition: {duration: 0}};
1966
+ // Only a change of the controls moves the layers, so a style event does not restart them.
1967
+ if (coreScene.buildings && coreScene.buildingsShown !== threeDimensionalEnabled) {
1968
+ coreScene.buildingsShown = threeDimensionalEnabled;
1969
+ void (threeDimensionalEnabled ? coreScene.buildings.show() : coreScene.buildings.hide(instant));
1970
+ }
1971
+ if (coreScene.trees && coreScene.treesShown !== treesEnabled) {
1972
+ coreScene.treesShown = treesEnabled;
1973
+ void (treesEnabled ? coreScene.trees.show() : coreScene.trees.hide(instant));
1974
+ }
1975
+ }
1976
+
1977
+ class ThreeDimensionalControl {
1978
+ constructor() {
1979
+ this.enabled = threeDimensionalEnabled;
1980
+ this.handleClick = this.handleClick.bind(this);
1981
+ this.update = this.update.bind(this);
1982
+ }
1983
+
1984
+ onAdd(map) {
1985
+ this.map = map;
1986
+ this.container = document.createElement("div");
1987
+ this.container.className = "maplibregl-ctrl maplibregl-ctrl-group";
1988
+ this.button = document.createElement("button");
1989
+ this.button.type = "button";
1990
+ this.button.className = "tileflow-3d-toggle";
1991
+ this.button.title = "Toggle 3D view";
1992
+ this.button.setAttribute("aria-label", "Toggle 3D view");
1993
+ this.button.addEventListener("click", this.handleClick);
1994
+ this.container.appendChild(this.button);
1995
+ map.on("styledata", this.update);
1996
+ this.update();
1997
+ return this.container;
1998
+ }
1999
+
2000
+ handleClick() {
2001
+ this.setEnabled(!this.enabled);
2002
+ }
2003
+
2004
+ setEnabled(enabled) {
2005
+ if (typeof enabled !== "boolean" || enabled === this.enabled) return;
2006
+ this.enabled = enabled;
2007
+ threeDimensionalEnabled = this.enabled;
2008
+ landmarkState.enabled = this.enabled;
2009
+ this.update();
2010
+ writeToggleStateToUrl();
2011
+ this.map.fire("tileflow:3d-toggle", {enabled: this.enabled});
2012
+ mountCoreScene(this.map);
2013
+ this.map.triggerRepaint();
2014
+ }
2015
+
2016
+ update() {
2017
+ for (const layer of this.map.getStyle()?.layers || []) {
2018
+ const toggle = layer.metadata?.["tileflow:3d-toggle"];
2019
+ if (toggle !== "building" && toggle !== "landmark") continue;
2020
+ // Core's 3D buildings stand in for the compiled extrusion where the style describes them.
2021
+ const replaced = toggle === "building" && this.map.getStyle()?.metadata?.["tileflow:buildings"];
2022
+ const visibility = this.enabled && !replaced ? "visible" : "none";
2023
+ if (this.map.getLayoutProperty(layer.id, "visibility") !== visibility) {
2024
+ this.map.setLayoutProperty(layer.id, "visibility", visibility);
2025
+ }
2026
+ }
2027
+ if (this.map.isStyleLoaded()) mountCoreScene(this.map);
2028
+ this.button.textContent = this.enabled ? "3D ON" : "3D OFF";
2029
+ this.button.setAttribute("aria-pressed", String(this.enabled));
2030
+ this.button.title = this.enabled ? "Disable 3D buildings" : "Enable 3D buildings";
2031
+ }
2032
+
2033
+ onRemove() {
2034
+ this.map?.off("styledata", this.update);
2035
+ this.button?.removeEventListener("click", this.handleClick);
2036
+ this.container?.remove();
2037
+ this.map = undefined;
2038
+ }
2039
+ }
2040
+
2041
+ class TreeControl {
2042
+ constructor() {
2043
+ this.enabled = treesEnabled;
2044
+ this.handleClick = this.handleClick.bind(this);
2045
+ this.update = this.update.bind(this);
2046
+ }
2047
+
2048
+ onAdd(map) {
2049
+ this.map = map;
2050
+ this.container = document.createElement("div");
2051
+ this.container.className = "maplibregl-ctrl maplibregl-ctrl-group";
2052
+ this.button = document.createElement("button");
2053
+ this.button.type = "button";
2054
+ this.button.className = "tileflow-tree-toggle";
2055
+ this.button.setAttribute("aria-label", "Toggle trees");
2056
+ this.button.addEventListener("click", this.handleClick);
2057
+ this.container.appendChild(this.button);
2058
+ map.on("styledata", this.update);
2059
+ this.update();
2060
+ return this.container;
2061
+ }
2062
+
2063
+ handleClick() {
2064
+ this.setEnabled(!this.enabled);
2065
+ }
2066
+
2067
+ setEnabled(enabled) {
2068
+ if (typeof enabled !== "boolean" || enabled === this.enabled) return;
2069
+ this.enabled = enabled;
2070
+ treesEnabled = this.enabled;
2071
+ this.update();
2072
+ writeToggleStateToUrl();
2073
+ this.map.fire("tileflow:trees-toggle", {enabled: this.enabled});
2074
+ mountCoreScene(this.map);
2075
+ this.map.triggerRepaint();
2076
+ }
2077
+
2078
+ update() {
2079
+ const treeLayer = this.map.getStyle()?.layers?.find(
2080
+ (layer) => layer.id === "tileflow-vegetation-trees"
2081
+ );
2082
+ // Core's 3D trees hide the style's flat trees while they stand (mountCoreScene).
2083
+ if (treeLayer && !this.map.getStyle()?.metadata?.["tileflow:vegetation"]) {
2084
+ if (this.map.getLayoutProperty(treeLayer.id, "visibility") !== "visible") {
2085
+ this.map.setLayoutProperty(treeLayer.id, "visibility", "visible");
2086
+ }
2087
+ }
2088
+ this.button.textContent = this.enabled ? "TREES ON" : "TREES OFF";
2089
+ this.button.setAttribute("aria-pressed", String(this.enabled));
2090
+ this.button.title = this.enabled ? "Show flat trees" : "Show 3D trees";
2091
+ }
2092
+
2093
+ onRemove() {
2094
+ this.map?.off("styledata", this.update);
2095
+ this.button?.removeEventListener("click", this.handleClick);
2096
+ this.container?.remove();
2097
+ this.map = undefined;
2098
+ }
2099
+ }
2100
+
2101
+ // Switches a map preview between its themes. The preview reloads with the next theme and keeps
2102
+ // the camera and toggles already written to the URL; scene previews own their theme.
2103
+ class ThemeControl {
2104
+ onAdd(map) {
2105
+ this.map = map;
2106
+ this.container = document.createElement("div");
2107
+ this.container.className = "maplibregl-ctrl maplibregl-ctrl-group";
2108
+ this.button = document.createElement("button");
2109
+ this.button.type = "button";
2110
+ this.button.className = "tileflow-theme-toggle";
2111
+ const next = previewThemes[(previewThemes.indexOf(previewTheme) + 1) % previewThemes.length];
2112
+ this.button.textContent = String(previewTheme).toUpperCase();
2113
+ this.button.title = "Switch to the " + next + " theme";
2114
+ this.button.setAttribute("aria-label", "Switch to the " + next + " theme");
2115
+ this.handleClick = () => {
2116
+ writeCameraToUrl(map);
2117
+ const url = new URL(location.href);
2118
+ url.searchParams.set("theme", next);
2119
+ location.assign(url.href);
2120
+ };
2121
+ this.button.addEventListener("click", this.handleClick);
2122
+ this.container.appendChild(this.button);
2123
+ return this.container;
2124
+ }
2125
+
2126
+ onRemove() {
2127
+ this.button?.removeEventListener("click", this.handleClick);
2128
+ this.container?.remove();
2129
+ this.map = undefined;
2130
+ }
2131
+ }
2132
+
2133
+ const previewLayerGroupIds = [
2134
+ "labels", "pois", "roads", "transit", "buildings", "landuse", "water"
2135
+ ];
2136
+ let visibleLayerGroups = new Set(previewLayerGroupIds);
2137
+ const defaultLayerVisibility = new Map();
2138
+ const defaultLayerTextField = new Map();
2139
+
2140
+ function previewLayoutValuesEqual(left, right) {
2141
+ return JSON.stringify(left) === JSON.stringify(right);
2142
+ }
2143
+
2144
+ function previewLayerGroups(layer) {
2145
+ const id = layer.id.toLowerCase();
2146
+ const sourceLayer = layer["source-layer"] || "";
2147
+ const groups = [];
2148
+ const isLanduseLayer = [
2149
+ "landuse", "landcover", "park", "business_corridor"
2150
+ ].includes(sourceLayer);
2151
+ if (
2152
+ !isLanduseLayer &&
2153
+ /poi|parking|business|amenity/u.test(id + " " + sourceLayer)
2154
+ ) groups.push("pois");
2155
+ if (/transit|rail|ferry|aeroway|aerodrome/u.test(id + " " + sourceLayer)) {
2156
+ groups.push("transit");
2157
+ } else if (
2158
+ [
2159
+ "transportation",
2160
+ "transportation_name",
2161
+ "circular_feature",
2162
+ "sidewalk",
2163
+ "street_furniture"
2164
+ ].includes(sourceLayer)
2165
+ ) {
2166
+ groups.push("roads");
2167
+ }
2168
+ if (sourceLayer === "building" || id.includes("building")) {
2169
+ groups.push("buildings");
2170
+ }
2171
+ if (isLanduseLayer) {
2172
+ groups.push("landuse");
2173
+ }
2174
+ if (["water", "waterway", "water_name", "bathymetry"].includes(sourceLayer)) {
2175
+ groups.push("water");
2176
+ }
2177
+ return groups;
2178
+ }
2179
+
2180
+ function applyVisibleLayerGroups(map) {
2181
+ if (document.documentElement) {
2182
+ document.documentElement.dataset.visibleLayerGroups = [
2183
+ ...visibleLayerGroups,
2184
+ ].join(",");
2185
+ }
2186
+ for (const layer of map.getStyle()?.layers || []) {
2187
+ if (!defaultLayerVisibility.has(layer.id)) {
2188
+ defaultLayerVisibility.set(
2189
+ layer.id,
2190
+ map.getLayoutProperty(layer.id, "visibility") || "visible"
2191
+ );
2192
+ }
2193
+ if (layer.type === "symbol" && layer.layout?.["text-field"] !== undefined) {
2194
+ if (!defaultLayerTextField.has(layer.id)) {
2195
+ defaultLayerTextField.set(
2196
+ layer.id,
2197
+ map.getLayoutProperty(layer.id, "text-field")
2198
+ );
2199
+ }
2200
+ const textField = visibleLayerGroups.has("labels")
2201
+ ? defaultLayerTextField.get(layer.id)
2202
+ : "";
2203
+ if (
2204
+ !previewLayoutValuesEqual(
2205
+ map.getLayoutProperty(layer.id, "text-field"),
2206
+ textField
2207
+ )
2208
+ ) {
2209
+ map.setLayoutProperty(layer.id, "text-field", textField);
2210
+ }
2211
+ }
2212
+ const groups = previewLayerGroups(layer);
2213
+ const threeDimensionalToggle = layer.metadata?.["tileflow:3d-toggle"];
2214
+ const authoredVisibility = defaultLayerVisibility.get(layer.id);
2215
+ const styleMetadata = map.getStyle()?.metadata ?? {};
2216
+ // Core's 3D buildings and trees stand in for compiled extrusions and flat trees.
2217
+ const flatTrees = styleMetadata["tileflow:vegetation"]?.flatLayers ?? [];
2218
+ const replacedByCore =
2219
+ (threeDimensionalToggle === "building" && styleMetadata["tileflow:buildings"]) ||
2220
+ (treesEnabled && flatTrees.includes(layer.id.split("::")[0]));
2221
+ const toggleVisibility = replacedByCore
2222
+ ? "none"
2223
+ : threeDimensionalToggle === "building" || threeDimensionalToggle === "landmark"
2224
+ ? (threeDimensionalEnabled ? "visible" : "none")
2225
+ : authoredVisibility;
2226
+ const visibility = groups.some((group) => !visibleLayerGroups.has(group))
2227
+ ? "none"
2228
+ : toggleVisibility;
2229
+ if (map.getLayoutProperty(layer.id, "visibility") !== visibility) {
2230
+ map.setLayoutProperty(layer.id, "visibility", visibility);
2231
+ }
2232
+ }
2233
+ }
2234
+
2235
+ function setVisibleLayerGroups(map, groups) {
2236
+ visibleLayerGroups = new Set(groups);
2237
+ applyVisibleLayerGroups(map);
2238
+ map.fire("tileflow:visible-layer-groups", {groups: [...visibleLayerGroups]});
2239
+ }
2240
+
2241
+ if (styleUrl) {
2242
+ // Dense vector tiles finish in bursts with multiple workers, causing several
2243
+ // bucket uploads in one frame. One worker keeps navigation smooth; benchmarks
2244
+ // can still compare up to three workers through the explicit URL override.
2245
+ const mapWorkerCount = mapWorkerCountOverride ?? 1;
2246
+ maplibregl.setWorkerCount?.(mapWorkerCount);
2247
+ await loadTileflowStyleFonts(styleUrl, {fontFaces: previewFontFaces});
2248
+ registerTileflowContourProtocol({addProtocol: maplibregl.addProtocol});
2249
+ registerTileflowPmtilesProtocol({addProtocol: maplibregl.addProtocol});
2250
+ const map = new maplibregl.Map({
2251
+ collectResourceTiming: mapBenchmarkEnabled,
2252
+ container: "map",
2253
+ style: styleUrl,
2254
+ transformRequest: (url) => {
2255
+ try {
2256
+ return new URL(url, location.href).hostname === "dev-tiles.tileflow.dev"
2257
+ ? {credentials: "include", url}
2258
+ : {url};
2259
+ } catch {
2260
+ return {url};
2261
+ }
2262
+ },
2263
+ ...resolveInitialMapOptions(previewMapOptions)
2264
+ });
2265
+ globalThis.__tileflowPreviewMap = map;
2266
+ installMapBenchmark(map);
2267
+ map.addControl(new maplibregl.NavigationControl(), "top-right");
2268
+ let threeDimensionalControl;
2269
+ let treeControl;
2270
+ if (isSemanticPreview) {
2271
+ threeDimensionalControl = new ThreeDimensionalControl();
2272
+ treeControl = new TreeControl();
2273
+ map.addControl(threeDimensionalControl, "top-right");
2274
+ map.addControl(treeControl, "top-right");
2275
+ }
2276
+ if (previewThemes.length > 1) map.addControl(new ThemeControl(), "top-right");
2277
+ let ensuringThreeDimensionalLayers;
2278
+ const ensureThreeDimensionalLayers = () => {
2279
+ if (ensuringThreeDimensionalLayers) return ensuringThreeDimensionalLayers;
2280
+ const currentStyleLayers = map.getStyle()?.layers || [];
2281
+ const landmarkConfigLayer = currentStyleLayers.find(
2282
+ (layer) => layer.metadata?.["tileflow:landmark-manifest-url"]
2283
+ );
2284
+ const buildingWireframeConfigLayer = currentStyleLayers.find(
2285
+ (layer) => layer.metadata?.["tileflow:building-wireframe"] === true
2286
+ );
2287
+ const landmarkManifestUrl =
2288
+ landmarkConfigLayer?.metadata?.["tileflow:landmark-manifest-url"];
2289
+ const landmarkPreloadZoom = landmarkConfigLayer
2290
+ ? Math.max(0, styleLayerZoomRange(landmarkConfigLayer).minzoom - 1)
2291
+ : Infinity;
2292
+ const shouldPreloadLandmarks =
2293
+ landmarkManifestUrl &&
2294
+ threeDimensionalEnabled &&
2295
+ map.getZoom() >= landmarkPreloadZoom;
2296
+ const needsLandmarks =
2297
+ landmarkConfigLayer &&
2298
+ threeDimensionalEnabled &&
2299
+ styleLayerIsVisibleAtZoom(landmarkConfigLayer, map.getZoom()) &&
2300
+ !map.getLayer("tileflow-landmarks-3d");
2301
+ const needsBuildingWireframe =
2302
+ buildingWireframeConfigLayer &&
2303
+ threeDimensionalEnabled &&
2304
+ styleLayerIsVisibleAtZoom(buildingWireframeConfigLayer, map.getZoom()) &&
2305
+ !map.getLayer("tileflow-buildings-wireframe-3d");
2306
+ if (
2307
+ !needsLandmarks &&
2308
+ !needsBuildingWireframe &&
2309
+ !shouldPreloadLandmarks
2310
+ ) return;
2311
+ const runtimePrerequisites = [
2312
+ shouldPreloadLandmarks
2313
+ ? loadLandmarkPrerequisites(landmarkManifestUrl)
2314
+ : loadThreeCoreRuntime(),
2315
+ ...(needsBuildingWireframe ? [loadBuildingWireframeRuntime()] : [])
2316
+ ];
2317
+ ensuringThreeDimensionalLayers = Promise.all(runtimePrerequisites)
2318
+ .then(async () => {
2319
+ const styleLayers = map.getStyle()?.layers || [];
2320
+ const buildingWireframeConfigLayer = styleLayers.find(
2321
+ (layer) => layer.metadata?.["tileflow:building-wireframe"] === true
2322
+ );
2323
+ if (
2324
+ buildingWireframeConfigLayer &&
2325
+ threeDimensionalEnabled &&
2326
+ styleLayerIsVisibleAtZoom(buildingWireframeConfigLayer, map.getZoom())
2327
+ ) {
2328
+ // A tree-only load may already be in flight when buildings are
2329
+ // enabled. Reassert the line runtime before mounting the layer.
2330
+ await loadBuildingWireframeRuntime();
2331
+ addBuildingWireframeLayerIfConfigured(map, styleLayers);
2332
+ }
2333
+ const landmarkConfigLayer = styleLayers.find(
2334
+ (layer) => layer.metadata?.["tileflow:landmark-manifest-url"]
2335
+ );
2336
+ const shouldAddLandmarks =
2337
+ landmarkConfigLayer &&
2338
+ threeDimensionalEnabled &&
2339
+ styleLayerIsVisibleAtZoom(landmarkConfigLayer, map.getZoom()) &&
2340
+ !map.getLayer("tileflow-landmarks-3d");
2341
+ if (shouldAddLandmarks) {
2342
+ // The tree-only path may already be in flight when landmarks
2343
+ // are enabled. Reassert the richer runtime here so a shared
2344
+ // core promise can never expose an undefined GLTF loader.
2345
+ await loadLandmarkPrerequisites(
2346
+ landmarkConfigLayer.metadata["tileflow:landmark-manifest-url"]
2347
+ );
2348
+ addLandmarkLayerIfConfigured(map, styleLayers);
2349
+ }
2350
+ })
2351
+ .catch((error) => console.warn("Tileflow 3D runtime failed to load", error))
2352
+ .finally(() => {
2353
+ ensuringThreeDimensionalLayers = undefined;
2354
+ });
2355
+ return ensuringThreeDimensionalLayers;
2356
+ };
2357
+ map.on("styledata", ensureThreeDimensionalLayers);
2358
+ map.on("styledata", () => applyVisibleLayerGroups(map));
2359
+ map.on("zoomend", ensureThreeDimensionalLayers);
2360
+ map.on("tileflow:3d-toggle", ensureThreeDimensionalLayers);
2361
+ map.on("tileflow:trees-toggle", ensureThreeDimensionalLayers);
2362
+ map.on("load", () => {
2363
+ writeCameraToUrl(map);
2364
+ updateGlobeBackdrop(map);
2365
+ ensureThreeDimensionalLayers();
2366
+ mountCoreScene(map);
2367
+ });
2368
+ map.on("move", () => updateGlobeBackdrop(map));
2369
+ map.on("moveend", () => writeCameraToUrl(map));
2370
+ addEventListener("message", (event) => {
2371
+ if (
2372
+ window.parent === window ||
2373
+ event.source !== window.parent ||
2374
+ event.origin !== location.origin ||
2375
+ event.data?.type !== "tileflow:set-map-state" ||
2376
+ event.data?.schemaVersion !== 1 ||
2377
+ !threeDimensionalControl ||
2378
+ !treeControl
2379
+ ) return;
2380
+ const state = event.data.state;
2381
+ const nextVisibleLayerGroups = state?.visibleLayerGroups;
2382
+ const center = state?.center;
2383
+ const numericValues = {
2384
+ bearing: state?.bearing,
2385
+ lat: center?.[1],
2386
+ lng: center?.[0],
2387
+ pitch: state?.pitch,
2388
+ zoom: state?.zoom
2389
+ };
2390
+ if (
2391
+ !Array.isArray(center) ||
2392
+ center.length !== 2 ||
2393
+ Object.entries(numericValues).some(
2394
+ ([name, value]) =>
2395
+ !Number.isFinite(value) ||
2396
+ value < cameraRanges[name][0] ||
2397
+ value > cameraRanges[name][1]
2398
+ ) ||
2399
+ typeof state?.buildings3d !== "boolean" ||
2400
+ typeof state?.trees3d !== "boolean" ||
2401
+ (nextVisibleLayerGroups !== undefined &&
2402
+ (!Array.isArray(nextVisibleLayerGroups) ||
2403
+ new Set(nextVisibleLayerGroups).size !== nextVisibleLayerGroups.length ||
2404
+ nextVisibleLayerGroups.some(
2405
+ (group) => !previewLayerGroupIds.includes(group)
2406
+ )))
2407
+ ) return;
2408
+ threeDimensionalControl.setEnabled(state.buildings3d);
2409
+ treeControl.setEnabled(state.trees3d);
2410
+ if (nextVisibleLayerGroups) {
2411
+ setVisibleLayerGroups(map, nextVisibleLayerGroups);
2412
+ }
2413
+ map.jumpTo({
2414
+ bearing: numericValues.bearing,
2415
+ center,
2416
+ pitch: numericValues.pitch,
2417
+ zoom: numericValues.zoom
2418
+ });
2419
+ writeCameraToUrl(map);
2420
+ });
2421
+ }
2422
+
2423
+ function applyStatus(next) {
2424
+ badge.textContent = ["Tileflow preview", previewLabel, next.status].filter(Boolean).join(" \xB7 ");
2425
+ if (next.status === "invalid") {
2426
+ const diagnostics = next.diagnostics || [];
2427
+ status.textContent = diagnostics.map((item) =>
2428
+ (item.path ? item.path + ": " : "") + item.message
2429
+ ).join("\\n") || "Tileflow config is invalid.";
2430
+ status.style.display = "block";
2431
+ return;
2432
+ }
2433
+ status.style.display = "none";
2434
+ if (next.status === "ready" && next.generation > initialGeneration) {
2435
+ location.reload();
2436
+ }
2437
+ }
2438
+
2439
+ applyStatus(initialStatus);
2440
+ const events = new EventSource(${JSON.stringify(`${basePath}/__events`)});
2441
+ // Generations are monotonic only within one dev-server process. A source-code watcher can
2442
+ // restart that process at generation 1, so reconnecting after a live connection was lost
2443
+ // must refresh the document even when the numeric generation did not increase.
2444
+ let eventsConnected = false;
2445
+ let eventsDisconnected = false;
2446
+ events.addEventListener("open", () => {
2447
+ if (eventsConnected && eventsDisconnected) {
2448
+ location.reload();
2449
+ return;
2450
+ }
2451
+ eventsConnected = true;
2452
+ eventsDisconnected = false;
2453
+ });
2454
+ events.addEventListener("error", () => {
2455
+ if (eventsConnected) eventsDisconnected = true;
2456
+ });
2457
+ for (const eventName of ["building", "ready", "invalid"]) {
2458
+ events.addEventListener(eventName, (event) => applyStatus(JSON.parse(event.data)));
2459
+ }
2460
+ </script>
2461
+ </body>
2462
+ </html>`;
2463
+ }
2464
+ function previewMapOptions(preview) {
2465
+ if (preview.camera.type === "center") {
2466
+ return {
2467
+ bearing: preview.camera.bearing,
2468
+ center: preview.camera.center,
2469
+ pitch: preview.camera.pitch,
2470
+ zoom: preview.camera.zoom
2471
+ };
2472
+ }
2473
+ const [west, south, east, north] = preview.camera.bounds;
2474
+ return {
2475
+ bearing: preview.camera.bearing,
2476
+ bounds: [
2477
+ [west, south],
2478
+ [east, north]
2479
+ ],
2480
+ fitBoundsOptions: { padding: preview.camera.padding },
2481
+ pitch: preview.camera.pitch
2482
+ };
2483
+ }
2484
+ function serializeInlineJson(value) {
2485
+ return JSON.stringify(value).replace(
2486
+ /[<>&\u2028\u2029]/gu,
2487
+ (character) => `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`
2488
+ );
2489
+ }
2490
+
2491
+ // src/preview.ts
2492
+ var TileflowPreviewSelectionError = class extends Error {
2493
+ code = "PREVIEW_SELECTION_INVALID";
2494
+ constructor(message) {
2495
+ super(message);
2496
+ this.name = "TileflowPreviewSelectionError";
2497
+ }
2498
+ };
2499
+ function resolveTileflowPreview(project, selection = {}) {
2500
+ if (selection.map !== void 0 && selection.scene !== void 0) {
2501
+ throw new TileflowPreviewSelectionError(
2502
+ "Choose either a Tileflow preview map or scene, not both."
2503
+ );
2504
+ }
2505
+ if (selection.scene !== void 0) {
2506
+ if (selection.theme !== void 0) {
2507
+ throw new TileflowPreviewSelectionError(
2508
+ "A committed Tileflow scene owns its concrete theme; do not combine scene and theme selection."
2509
+ );
2510
+ }
2511
+ const scene = Object.hasOwn(project.scenes ?? {}, selection.scene) ? project.scenes?.[selection.scene] : void 0;
2512
+ if (!scene) {
2513
+ throw new TileflowPreviewSelectionError(`Unknown Tileflow scene: ${selection.scene}`);
2514
+ }
2515
+ const normalized = normalizeTileflowCaptureScene(scene);
2516
+ if (normalized.target.kind === "application") {
2517
+ throw new TileflowPreviewSelectionError(
2518
+ `Tileflow scene "${selection.scene}" targets an application. Preview it through the application's development server.`
2519
+ );
2520
+ }
2521
+ if (!Object.hasOwn(project.maps, normalized.map)) {
2522
+ throw new TileflowPreviewSelectionError(
2523
+ `Tileflow scene "${selection.scene}" references an unknown map: ${normalized.map}`
2524
+ );
2525
+ }
2526
+ const map2 = parseResolvedTileflowMap(project.maps[normalized.map]);
2527
+ const selectedTheme2 = resolvePreviewTheme(map2, normalized.theme);
2528
+ return {
2529
+ camera: normalized.camera,
2530
+ label: `${normalized.map} / ${selectedTheme2.name} / ${selection.scene} \xB7 ${normalized.viewport.width}\xD7${normalized.viewport.height}`,
2531
+ mapName: normalized.map,
2532
+ themeName: selectedTheme2.name,
2533
+ viewport: normalized.viewport
2534
+ };
2535
+ }
2536
+ const mapName = selection.map ?? getFirstTileflowMapName(project);
2537
+ const map = Object.hasOwn(project.maps, mapName) ? project.maps[mapName] : void 0;
2538
+ if (!map) {
2539
+ throw new TileflowPreviewSelectionError(`Unknown Tileflow map: ${mapName}`);
2540
+ }
2541
+ const resolvedMap = parseResolvedTileflowMap(map);
2542
+ const selectedTheme = resolvePreviewTheme(resolvedMap, selection.theme);
2543
+ return {
2544
+ camera: {
2545
+ type: "center",
2546
+ center: resolvedMap.view?.center ? [resolvedMap.view.center[0], resolvedMap.view.center[1]] : [defaultTileflowRuntimeView.center[0], defaultTileflowRuntimeView.center[1]],
2547
+ zoom: resolvedMap.view?.zoom ?? defaultTileflowRuntimeView.zoom,
2548
+ bearing: resolvedMap.view?.bearing ?? defaultTileflowRuntimeView.bearing,
2549
+ pitch: resolvedMap.view?.pitch ?? defaultTileflowRuntimeView.pitch
2550
+ },
2551
+ label: `${mapName} / ${selectedTheme.name}`,
2552
+ mapName,
2553
+ themeName: selectedTheme.name,
2554
+ themeNames: Object.keys(resolvedMap.themes)
2555
+ };
2556
+ }
2557
+ function resolvePreviewTheme(map, requested) {
2558
+ try {
2559
+ return resolveThemeSelection(map, requested);
2560
+ } catch {
2561
+ const available = Object.keys(map.themes).sort().join(", ");
2562
+ throw new TileflowPreviewSelectionError(
2563
+ `Tileflow theme selection is invalid or unknown. Choose one of: ${available}.`
2564
+ );
2565
+ }
2566
+ }
2567
+ export {
2568
+ TileflowPreviewSelectionError,
2569
+ normalizeTileflowLandmarkManifest,
2570
+ readBoundedTileflowJsonResponse,
2571
+ readBoundedTileflowResponse,
2572
+ renderTileflowPreviewHtml,
2573
+ resolveTileflowPreview
2574
+ };