@tileflow/dev 0.1.0-alpha.4 → 0.1.0-alpha.41

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 (45) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +264 -5
  3. package/THIRD_PARTY_NOTICES.md +309 -0
  4. package/dist/artifacts.d.ts +269 -0
  5. package/dist/artifacts.js +98 -0
  6. package/dist/chunk-AQUGMUZW.js +324 -0
  7. package/dist/chunk-C4VPHZ3F.js +4062 -0
  8. package/dist/chunk-D4OE4ITL.js +13 -0
  9. package/dist/chunk-FOMTSCOP.js +671 -0
  10. package/dist/chunk-GXLY73PD.js +3199 -0
  11. package/dist/chunk-KDFTE4Q5.js +156 -0
  12. package/dist/chunk-ONYQNLNA.js +1108 -0
  13. package/dist/chunk-RTU7TD4M.js +172 -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-BIHpw5Zy.d.ts +417 -0
  20. package/dist/icons.d.ts +3 -0
  21. package/dist/icons.js +41 -0
  22. package/dist/inspect.d.ts +149 -0
  23. package/dist/inspect.js +805 -0
  24. package/dist/inspector-browser.js +16955 -0
  25. package/dist/inspector-observer.js +95 -0
  26. package/dist/preview-assets-ATLLYY72.js +103 -0
  27. package/dist/preview.d.ts +31 -0
  28. package/dist/preview.js +1974 -0
  29. package/dist/server.d.ts +89 -0
  30. package/dist/server.js +3662 -0
  31. package/dist/tilesets.d.ts +89 -0
  32. package/dist/tilesets.js +6 -0
  33. package/dist/validation.d.ts +65 -0
  34. package/dist/validation.js +24 -0
  35. package/package.json +77 -13
  36. package/ui-fonts/hubot-sans-OFL.txt +93 -0
  37. package/ui-fonts/hubot-sans.woff2 +0 -0
  38. package/ui-fonts/inter-OFL.txt +92 -0
  39. package/ui-fonts/inter.woff2 +0 -0
  40. package/ui-fonts/jetbrains-mono-OFL.txt +93 -0
  41. package/ui-fonts/jetbrains-mono.woff2 +0 -0
  42. package/ui-fonts/mona-sans-OFL.txt +93 -0
  43. package/ui-fonts/mona-sans.woff2 +0 -0
  44. package/dist/index.d.ts +0 -54
  45. package/dist/index.js +0 -314
@@ -0,0 +1,1974 @@
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/bounded-response.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
+
55
+ // src/preview-scene.ts
56
+ var previewSceneClient = String.raw`
57
+ let sceneStyleRevision = 0;
58
+ globalThis.__tileflowApplyScene = async (serialized, isCurrent) => {
59
+ const next = JSON.parse(serialized);
60
+ if (next.style) {
61
+ // getStyle() retains renderer instrumentation on sources. It is not authored
62
+ // style-spec input; MapLibre adds it back from the map's timing option.
63
+ delete next.style.collectResourceTiming;
64
+ for (const source of Object.values(next.style.sources || {})) delete source.collectResourceTiming;
65
+ await loadTileflowStyleFonts(next.style, {fontFaces: previewFontFaces});
66
+ }
67
+ if (!isCurrent()) return false;
68
+ const context = map.getCanvas().getContext("webgl2") || map.getCanvas().getContext("webgl");
69
+ if (context?.isContextLost()) throw new Error("The map's graphics context is recovering. Try the change again.");
70
+ if (next.style) {
71
+ // MapLibre ignores metadata-only diffs. A global-state revision also commits the
72
+ // new 3D design metadata, without replacing sources, tiles or custom layers.
73
+ next.style.state = {...next.style.state, "tileflow:preview-revision": ++sceneStyleRevision};
74
+ const layerOrder = map.getLayersOrder();
75
+ map.setStyle(next.style, {diff:true});
76
+ // Custom layers are omitted from the serialized style. A diff can reinsert the
77
+ // surrounding style layers above them, covering 3D with the flat map. Restore
78
+ // their placement before the nearest surviving successor, without remounting.
79
+ let before;
80
+ for (let index = layerOrder.length - 1; index >= 0; index--) {
81
+ const id = layerOrder[index], layer = map.getLayer(id);
82
+ if (!layer) continue;
83
+ if (layer.type === "custom") map.moveLayer(id, before);
84
+ before = id;
85
+ }
86
+ }
87
+ previewSearchParameters.set("animation", String(next.animation));
88
+ previewSearchParameters.delete("trees3d");
89
+ const visibleTrees = map.getStyle()?.metadata?.["tileflow:vegetation-display"]?.visible ?? true;
90
+ threeDimensionalControl?.setEnabled(next.buildings3d);
91
+ treeControl?.setEnabled(visibleTrees);
92
+ mountCoreScene(map);
93
+ treeControl?.update();
94
+ updateGlobeBackdrop(map);
95
+ const url = new URL(location.href);
96
+ url.searchParams.delete("scene");
97
+ url.searchParams.set("map", next.map);
98
+ url.searchParams.set("theme", next.theme);
99
+ if (Object.keys(next.controls).length) url.searchParams.set("controls", JSON.stringify(next.controls));
100
+ else url.searchParams.delete("controls");
101
+ if (next.animation) url.searchParams.set("animation", String(next.animation));
102
+ else url.searchParams.delete("animation");
103
+ history.replaceState(history.state, "", url.href);
104
+ // Read the current camera here; a slow compile must never undo navigation.
105
+ writeCameraToUrl(map);
106
+ map.triggerRepaint();
107
+ return true;
108
+ };
109
+ `;
110
+
111
+ // src/preview-scene-themes.ts
112
+ function createPreviewSceneThemes(map, readSun) {
113
+ const cache = {
114
+ value: void 0,
115
+ snapshot() {
116
+ if (!cache.value) {
117
+ const metadata = map.getStyle()?.metadata;
118
+ cache.value = { metadata, sun: readSun(metadata) };
119
+ }
120
+ return cache.value;
121
+ },
122
+ invalidate() {
123
+ cache.value = void 0;
124
+ },
125
+ release() {
126
+ map.off("styledata", cache.invalidate);
127
+ map.off("style.load", cache.invalidate);
128
+ map.off("remove", cache.release);
129
+ cache.invalidate();
130
+ }
131
+ };
132
+ map.on("styledata", cache.invalidate);
133
+ map.on("style.load", cache.invalidate);
134
+ map.on("remove", cache.release);
135
+ return {
136
+ getSun() {
137
+ return cache.snapshot().sun;
138
+ },
139
+ getThemeMetadata(key) {
140
+ return { values: [cache.snapshot().metadata?.[key]], position: 0 };
141
+ }
142
+ };
143
+ }
144
+
145
+ // src/preview-style-reads.ts
146
+ function createPreviewStyleReads(map) {
147
+ let snapshot;
148
+ map.on("style.load", () => {
149
+ snapshot = void 0;
150
+ });
151
+ return {
152
+ metadata() {
153
+ if (!snapshot || snapshot.style !== map.style) {
154
+ const style = map.getStyle();
155
+ if (!style) return void 0;
156
+ snapshot = { style: map.style, metadata: style.metadata };
157
+ }
158
+ return snapshot.metadata;
159
+ },
160
+ // Layers change between style loads, so they are read live, in the shape of the style's own
161
+ // layers. Custom layers have no style definition; getStyle leaves them out too.
162
+ layers() {
163
+ const layers = [];
164
+ for (const id of map.getLayersOrder()) {
165
+ const layer = map.getLayer(id);
166
+ if (!layer || layer.type === "custom") continue;
167
+ layers.push({
168
+ id: layer.id,
169
+ type: layer.type,
170
+ metadata: layer.metadata,
171
+ "source-layer": layer.sourceLayer,
172
+ minzoom: layer.minzoom,
173
+ maxzoom: layer.maxzoom
174
+ });
175
+ }
176
+ return layers;
177
+ }
178
+ };
179
+ }
180
+
181
+ // src/preview-html.ts
182
+ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticPreview, fontFaces = [], fitViewport = false, controls) {
183
+ const styleUrl = preview ? `${basePath}/styles/${preview.mapName}/${preview.themeName}.json${controls ? `?${new URLSearchParams({ controls })}` : ""}` : void 0;
184
+ const mapOptions = preview ? previewMapOptions(preview) : void 0;
185
+ const viewportCss = preview?.viewport && !fitViewport ? `
186
+ html, body { min-height: 100%; margin: 0; }
187
+ body {
188
+ display: grid;
189
+ place-items: center;
190
+ overflow: auto;
191
+ background: #E8E5DE;
192
+ }
193
+ #map {
194
+ width: ${preview.viewport.width}px;
195
+ height: ${preview.viewport.height}px;
196
+ box-shadow: 0 24px 80px rgba(37, 34, 29, 0.18);
197
+ }` : "html, body, #map { height: 100%; margin: 0; }";
198
+ return `<!doctype html>
199
+ <html lang="en">
200
+ <head>
201
+ <meta charset="utf-8" />
202
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
203
+ <title>Tileflow Preview</title>
204
+ <link rel="stylesheet" href="${basePath}/__runtime/maplibre-gl.css" />
205
+ <style>
206
+ ${viewportCss}
207
+ body { font-family: ui-sans-serif, system-ui, sans-serif; }
208
+ #map { position: relative; }
209
+ .badge {
210
+ display: none;
211
+ position: fixed;
212
+ left: 16px;
213
+ top: 16px;
214
+ z-index: 1;
215
+ border-radius: 8px;
216
+ background: rgba(246, 247, 243, 0.92);
217
+ border: 1px solid rgba(60, 64, 67, 0.12);
218
+ color: #3C4043;
219
+ padding: 10px 12px;
220
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12);
221
+ }
222
+ .status {
223
+ position: fixed;
224
+ left: 16px;
225
+ right: 16px;
226
+ bottom: 16px;
227
+ z-index: 2;
228
+ display: none;
229
+ max-width: 720px;
230
+ border: 1px solid rgba(160, 46, 46, 0.35);
231
+ border-radius: 8px;
232
+ background: rgba(255, 244, 244, 0.96);
233
+ color: #702020;
234
+ padding: 12px;
235
+ white-space: pre-wrap;
236
+ }
237
+ html[data-inspector-embedded=true] .status { display:none !important; }
238
+ .maplibregl-ctrl-group .tileflow-3d-toggle,
239
+ .maplibregl-ctrl-group .tileflow-tree-toggle,
240
+ .maplibregl-ctrl-group .tileflow-theme-toggle {
241
+ width: auto;
242
+ min-width: 58px;
243
+ padding: 0 9px;
244
+ color: #3C4043;
245
+ font: 700 11px/29px ui-sans-serif, system-ui, sans-serif;
246
+ letter-spacing: 0.02em;
247
+ }
248
+ .maplibregl-ctrl-group .tileflow-3d-toggle[aria-pressed="true"],
249
+ .maplibregl-ctrl-group .tileflow-tree-toggle[aria-pressed="true"] {
250
+ background-color: #E8F0FE;
251
+ color: #174EA6;
252
+ }
253
+ .maplibregl-ctrl-group .tileflow-3d-toggle:hover,
254
+ .maplibregl-ctrl-group .tileflow-tree-toggle:hover,
255
+ .maplibregl-ctrl-group .tileflow-theme-toggle:hover {
256
+ background-color: #F1F3F4;
257
+ }
258
+ .maplibregl-ctrl-group { background:#faf6eb; border:1px solid #e0d5c4; border-radius:3px; box-shadow:0 3px 12px #30251f18; }
259
+ .maplibregl-ctrl-group button { color:#30251f; }
260
+ .maplibregl-ctrl-group button + button { border-top-color:#e0d5c4; }
261
+ .maplibregl-ctrl-group .tileflow-3d-toggle,.maplibregl-ctrl-group .tileflow-tree-toggle,.maplibregl-ctrl-group .tileflow-theme-toggle { color:#30251f; font-weight:500; }
262
+ .maplibregl-ctrl-group .tileflow-3d-toggle[aria-pressed="true"],.maplibregl-ctrl-group .tileflow-tree-toggle[aria-pressed="true"] { background:#f0dfe0; color:#8c2e3f; }
263
+ .maplibregl-ctrl-group .tileflow-3d-toggle:hover,.maplibregl-ctrl-group .tileflow-tree-toggle:hover,.maplibregl-ctrl-group .tileflow-theme-toggle:hover { background:#eee4d3; }
264
+ </style>
265
+ </head>
266
+ <body>
267
+ <div class="badge" id="badge">Tileflow preview</div>
268
+ <div class="status" id="status" role="status"></div>
269
+ <div id="map"></div>
270
+ <script type="importmap">{"imports":{"three":"${basePath}/__runtime/three.module.js"}}</script>
271
+ <script type="module">
272
+ import * as maplibregl from "${basePath}/__runtime/maplibre-gl.mjs";
273
+ import {createPreviewObserver} from "${basePath}/__runtime/inspector-observer.js";
274
+ import {loadTileflowStyleFonts} from "${basePath}/__runtime/tileflow-browser.js";
275
+ import {attachTileflowHorizonQueries, attachTileflowLabelPlacement} from "${basePath}/__runtime/tileflow-browser.js";
276
+ import {registerTileflowContourProtocol, registerTileflowPathGroundProtocol} from "${basePath}/__runtime/tileflow-browser.js";
277
+ import {registerTileflowPmtilesProtocol} from "${basePath}/__runtime/tileflow-browser.js";
278
+ import {attachTileflowNauticalLights, attachTileflowNotices} from "${basePath}/__runtime/tileflow-browser.js";
279
+ import {attachTileflowAtmosphere, tileflowMaxPitch} from "${basePath}/__runtime/tileflow-browser.js";
280
+ import {tileflowCanvasContextAttributes} from "${basePath}/__runtime/tileflow-browser.js";
281
+ import {createTileflowMap3D} from "${basePath}/__runtime/tileflow-browser.js";
282
+ import {readTileflowSunPreset, resolveTileflowSun, tileflowDefaultSunPreset} from "${basePath}/__runtime/tileflow-browser.js";
283
+
284
+ maplibregl.setWorkerUrl("${basePath}/__runtime/maplibre-gl-worker.mjs");
285
+
286
+ const inspectorObserver = window.parent !== window ? createPreviewObserver() : undefined;
287
+ globalThis.__tileflowInspectorObserver = inspectorObserver;
288
+ if (window.parent !== window) document.documentElement.dataset.inspectorEmbedded = "true";
289
+
290
+ const initialStatus = ${JSON.stringify(initialStatus)};
291
+ const readBoundedTileflowResponse = ${readBoundedTileflowResponse.toString()};
292
+ const readBoundedTileflowJsonResponse = ${readBoundedTileflowJsonResponse.toString()};
293
+ const initialGeneration = initialStatus.generation;
294
+ document.documentElement.dataset.tileflowArtifactGeneration = String(initialStatus.lastGoodGeneration ?? initialGeneration);
295
+ const badge = document.getElementById("badge");
296
+ const status = document.getElementById("status");
297
+ const previewLabel = ${JSON.stringify(preview?.label)};
298
+ const styleUrl = ${JSON.stringify(styleUrl)};
299
+ const previewMapOptions = ${JSON.stringify(mapOptions)};
300
+ const previewTheme = ${serializeInlineJson(preview?.themeName ?? null)};
301
+ const previewThemes = ${serializeInlineJson(preview?.themeNames ?? [])};
302
+ const isSemanticPreview = ${JSON.stringify(isSemanticPreview)};
303
+ const previewFontFaces = ${serializeInlineJson(fontFaces)};
304
+ const previewSearchParameters = new URL(location.href).searchParams;
305
+ const mapBenchmarkEnabled = previewSearchParameters.get("mapBenchmark") === "1";
306
+ const mapSweepEnabled = previewSearchParameters.get("mapSweep") === "1";
307
+ const mapCompareEnabled = previewSearchParameters.get("mapCompare") === "1";
308
+ const requestedMapCompareLimit = Number(previewSearchParameters.get("mapCompareLimit"));
309
+ const mapCompareLimit =
310
+ Number.isInteger(requestedMapCompareLimit) &&
311
+ requestedMapCompareLimit >= 1 &&
312
+ requestedMapCompareLimit <= 64
313
+ ? requestedMapCompareLimit
314
+ : 16;
315
+ const requestedMapWorkerCount = Number(previewSearchParameters.get("mapWorkers"));
316
+ const mapWorkerCountOverride =
317
+ Number.isInteger(requestedMapWorkerCount) &&
318
+ requestedMapWorkerCount >= 1 &&
319
+ requestedMapWorkerCount <= 3
320
+ ? requestedMapWorkerCount
321
+ : undefined;
322
+ // Benchmarks compare the multisampled canvas with a single-sampled one through this override.
323
+ const mapAntialias = readToggleFromUrl("mapAntialias", true);
324
+ const mapBenchmarkLongitude = Number(previewSearchParameters.get("lng"));
325
+ const mapBenchmarkLatitude = Number(previewSearchParameters.get("lat"));
326
+ const mapBenchmarkCenter =
327
+ previewSearchParameters.has("lng") &&
328
+ previewSearchParameters.has("lat") &&
329
+ Number.isFinite(mapBenchmarkLongitude) &&
330
+ Number.isFinite(mapBenchmarkLatitude)
331
+ ? [mapBenchmarkLongitude, mapBenchmarkLatitude]
332
+ : undefined;
333
+ function readToggleFromUrl(name, fallback) {
334
+ const values = previewSearchParameters.getAll(name);
335
+ if (values.length !== 1) return fallback;
336
+ if (values[0] === "on") return true;
337
+ if (values[0] === "off") return false;
338
+ return fallback;
339
+ }
340
+ let threeDimensionalEnabled = readToggleFromUrl("buildings3d", true);
341
+ let treesEnabled = readToggleFromUrl("trees3d", true);
342
+ const buildingWireframeMetrics = {
343
+ buildingCount: 0,
344
+ refreshMilliseconds: undefined,
345
+ segmentCount: 0,
346
+ truncated: false
347
+ };
348
+ globalThis.__tileflowBuildingWireframeMetrics = buildingWireframeMetrics;
349
+ let THREE;
350
+ let LineMaterial;
351
+ let LineSegments2;
352
+ let LineSegmentsGeometry;
353
+ let threeCoreRuntimePromise;
354
+ let buildingWireframeRuntimePromise;
355
+
356
+ function loadThreeCoreRuntime() {
357
+ if (!threeCoreRuntimePromise) {
358
+ threeCoreRuntimePromise = import("${basePath}/__runtime/three.module.js").then(
359
+ (threeModule) => {
360
+ THREE = threeModule;
361
+ }
362
+ );
363
+ }
364
+ return threeCoreRuntimePromise;
365
+ }
366
+
367
+ function loadBuildingWireframeRuntime() {
368
+ if (!buildingWireframeRuntimePromise) {
369
+ buildingWireframeRuntimePromise = Promise.all([
370
+ loadThreeCoreRuntime(),
371
+ import("${basePath}/__runtime/three-addons/lines/LineMaterial.js"),
372
+ import("${basePath}/__runtime/three-addons/lines/LineSegments2.js"),
373
+ import("${basePath}/__runtime/three-addons/lines/LineSegmentsGeometry.js")
374
+ ]).then(([, lineMaterialModule, lineSegmentsModule, lineGeometryModule]) => {
375
+ LineMaterial = lineMaterialModule.LineMaterial;
376
+ LineSegments2 = lineSegmentsModule.LineSegments2;
377
+ LineSegmentsGeometry = lineGeometryModule.LineSegmentsGeometry;
378
+ });
379
+ }
380
+ return buildingWireframeRuntimePromise;
381
+ }
382
+
383
+ function percentile(values, percentileValue) {
384
+ if (values.length === 0) return undefined;
385
+ const sorted = [...values].sort((left, right) => left - right);
386
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)];
387
+ }
388
+
389
+ function installMapBenchmark(map) {
390
+ if (!mapBenchmarkEnabled) return;
391
+ let activeRun;
392
+ const metrics = {
393
+ activeLayers: 0,
394
+ bucketCount: 0,
395
+ bucketTypes: {},
396
+ errors: [],
397
+ frameMaximumMilliseconds: 0,
398
+ frameP95Milliseconds: 0,
399
+ frames: 0,
400
+ idleMilliseconds: 0,
401
+ layerTypes: {},
402
+ renderedFeatures: 0,
403
+ resourceCount: 0,
404
+ resourceDecodedBytes: 0,
405
+ resourceDurationMaximumMilliseconds: 0,
406
+ resourceEncodedBytes: 0,
407
+ resourceServerTiming: {},
408
+ resourceTransferBytes: 0,
409
+ requestedZoom: map.getZoom(),
410
+ sourceDataEvents: 0,
411
+ timedOut: false,
412
+ visibleDecodedTileBytes: 0,
413
+ visibleTileCoordinates: [],
414
+ visibleTiles: 0,
415
+ zoom: map.getZoom()
416
+ };
417
+
418
+ function inspectRenderBuckets() {
419
+ const tileManagers = map.style?.tileManagers || {};
420
+ const bucketTypes = {};
421
+ const coordinates = new Map();
422
+ const resourceTiming = [];
423
+ const visitedTiles = new Set();
424
+ let bucketCount = 0;
425
+ let visibleDecodedTileBytes = 0;
426
+ for (const [sourceId, tileManager] of Object.entries(tileManagers)) {
427
+ const tiles = tileManager?._inViewTiles?.getAllTiles?.() || [];
428
+ for (const tile of tiles) {
429
+ const tileKey = sourceId + ":" + (tile.uid ?? tile.tileID?.key);
430
+ if (visitedTiles.has(tileKey)) continue;
431
+ visitedTiles.add(tileKey);
432
+ const buckets = Object.values(tile.buckets || {});
433
+ bucketCount += buckets.length;
434
+ for (const bucket of buckets) {
435
+ const type = bucket?.type || bucket?.constructor?.name || "unknown";
436
+ bucketTypes[type] = (bucketTypes[type] || 0) + 1;
437
+ }
438
+ visibleDecodedTileBytes += tile.latestRawTileData?.byteLength || 0;
439
+ if (tile.resourceTiming) resourceTiming.push(...tile.resourceTiming);
440
+ const canonical = tile.tileID?.canonical;
441
+ if (canonical && canonical.z <= 24) {
442
+ const coordinate = {z: canonical.z, x: canonical.x, y: canonical.y};
443
+ coordinates.set(coordinate.z + "/" + coordinate.x + "/" + coordinate.y, coordinate);
444
+ }
445
+ }
446
+ }
447
+ return {
448
+ bucketCount,
449
+ bucketTypes,
450
+ resourceTiming,
451
+ visibleDecodedTileBytes,
452
+ visibleTileCoordinates: [...coordinates.values()],
453
+ visibleTiles: visitedTiles.size
454
+ };
455
+ }
456
+
457
+ function uniqueResources(resources) {
458
+ const unique = new Map();
459
+ for (const resource of resources) {
460
+ const key = [
461
+ resource.name,
462
+ resource.startTime,
463
+ resource.encodedBodySize,
464
+ resource.decodedBodySize
465
+ ].join("|");
466
+ unique.set(key, resource);
467
+ }
468
+ return [...unique.values()];
469
+ }
470
+
471
+ function summarizeServerTiming(resources) {
472
+ const totals = {};
473
+ for (const resource of resources) {
474
+ for (const timing of resource.serverTiming || []) {
475
+ const current = totals[timing.name] || {count: 0, maximumMilliseconds: 0, totalMilliseconds: 0};
476
+ current.count += 1;
477
+ current.maximumMilliseconds = Math.max(current.maximumMilliseconds, timing.duration || 0);
478
+ current.totalMilliseconds += timing.duration || 0;
479
+ totals[timing.name] = current;
480
+ }
481
+ }
482
+ for (const timing of Object.values(totals)) {
483
+ timing.averageMilliseconds = timing.count ? timing.totalMilliseconds / timing.count : 0;
484
+ }
485
+ return totals;
486
+ }
487
+
488
+ function sampleMapFrame(frameTime) {
489
+ if (activeRun) {
490
+ if (activeRun.previousFrameTime !== undefined) {
491
+ activeRun.frameSamples.push(frameTime - activeRun.previousFrameTime);
492
+ }
493
+ activeRun.previousFrameTime = frameTime;
494
+ }
495
+ requestAnimationFrame(sampleMapFrame);
496
+ }
497
+
498
+ map.on("sourcedata", (event) => {
499
+ if (!activeRun) return;
500
+ activeRun.sourceDataEvents += 1;
501
+ if (event.resourceTiming) activeRun.workerResources.push(...event.resourceTiming);
502
+ });
503
+ map.on("error", (event) => {
504
+ if (!activeRun) return;
505
+ const message = String(event?.error?.message || event?.message || "MapLibre error");
506
+ if (!activeRun.errors.includes(message) && activeRun.errors.length < 8) {
507
+ activeRun.errors.push(message);
508
+ }
509
+ });
510
+ requestAnimationFrame(sampleMapFrame);
511
+
512
+ function finishMapBenchmark(run, timedOut, resolve) {
513
+ if (activeRun !== run) return;
514
+ activeRun = undefined;
515
+ clearTimeout(run.timeout);
516
+ map.off("idle", run.handleIdle);
517
+ const zoom = map.getZoom();
518
+ const styleLayers = map.getStyle()?.layers || [];
519
+ const activeLayers = styleLayers.filter((layer) =>
520
+ (layer.minzoom === undefined || layer.minzoom <= zoom) &&
521
+ (layer.maxzoom === undefined || zoom < layer.maxzoom) &&
522
+ layer.layout?.visibility !== "none"
523
+ );
524
+ const layerTypes = {};
525
+ for (const layer of activeLayers) {
526
+ layerTypes[layer.type] = (layerTypes[layer.type] || 0) + 1;
527
+ }
528
+ const renderState = inspectRenderBuckets();
529
+ const {resourceTiming, ...renderMetrics} = renderState;
530
+ const resources = uniqueResources([
531
+ ...performance.getEntriesByType("resource").filter(
532
+ (entry) => entry.startTime >= run.startedAt
533
+ ),
534
+ ...run.workerResources,
535
+ ...resourceTiming
536
+ ]);
537
+ const frameP95 = percentile(run.frameSamples, 0.95) || 0;
538
+ Object.assign(metrics, {
539
+ activeLayers: activeLayers.length,
540
+ errors: run.errors,
541
+ frameMaximumMilliseconds: Math.max(0, ...run.frameSamples),
542
+ frameP95Milliseconds: frameP95,
543
+ frames: run.frameSamples.length,
544
+ idleMilliseconds: performance.now() - run.startedAt,
545
+ layerTypes,
546
+ renderedFeatures: map.queryRenderedFeatures().length,
547
+ resourceCount: resources.length,
548
+ resourceDecodedBytes: resources.reduce(
549
+ (total, entry) => total + (entry.decodedBodySize || 0),
550
+ 0
551
+ ),
552
+ resourceDurationMaximumMilliseconds: Math.max(
553
+ 0,
554
+ ...resources.map((entry) => entry.duration || 0)
555
+ ),
556
+ resourceEncodedBytes: resources.reduce(
557
+ (total, entry) => total + (entry.encodedBodySize || 0),
558
+ 0
559
+ ),
560
+ resourceServerTiming: summarizeServerTiming(resources),
561
+ resourceTransferBytes: resources.reduce(
562
+ (total, entry) => total + (entry.transferSize || 0),
563
+ 0
564
+ ),
565
+ requestedZoom: run.requestedZoom,
566
+ sourceDataEvents: run.sourceDataEvents,
567
+ timedOut,
568
+ ...renderMetrics,
569
+ zoom
570
+ });
571
+ resolve({...metrics});
572
+ }
573
+
574
+ function settleZoom(zoom) {
575
+ if (!Number.isFinite(zoom) || zoom < 0 || zoom > 22) {
576
+ return Promise.reject(new Error("Benchmark zoom must be between 0 and 22."));
577
+ }
578
+ if (activeRun) {
579
+ return Promise.reject(new Error("A map benchmark run is already active."));
580
+ }
581
+ return new Promise((resolve) => {
582
+ const run = {
583
+ errors: [],
584
+ frameSamples: [],
585
+ handleIdle: undefined,
586
+ previousFrameTime: undefined,
587
+ requestedZoom: zoom,
588
+ sourceDataEvents: 0,
589
+ startedAt: performance.now(),
590
+ timeout: undefined,
591
+ workerResources: []
592
+ };
593
+ run.handleIdle = () => requestAnimationFrame(() =>
594
+ requestAnimationFrame(() => finishMapBenchmark(run, false, resolve))
595
+ );
596
+ run.timeout = setTimeout(() => finishMapBenchmark(run, true, resolve), 12000);
597
+ activeRun = run;
598
+ map.on("idle", run.handleIdle);
599
+ map.jumpTo({
600
+ ...(mapBenchmarkCenter ? {center: mapBenchmarkCenter} : {}),
601
+ zoom
602
+ });
603
+ });
604
+ }
605
+
606
+ function resolveTileTemplate(template, coordinate) {
607
+ return template
608
+ .replace("{z}", String(coordinate.z))
609
+ .replace("{x}", String(coordinate.x))
610
+ .replace("{y}", String(coordinate.y))
611
+ .replace("{-y}", String((2 ** coordinate.z) - coordinate.y - 1));
612
+ }
613
+
614
+ async function digest(bytes) {
615
+ const hash = await crypto.subtle.digest("SHA-256", bytes);
616
+ return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
617
+ }
618
+
619
+ async function fetchComparableTile(url, credentials) {
620
+ const comparisonTileMaximumBytes = 16 * 1024 * 1024;
621
+ const startedAt = performance.now();
622
+ const response = await fetch(url, {cache: "no-store", credentials});
623
+ if (!response.ok) throw new Error("Tile comparison request failed (" + response.status + "): " + url);
624
+ const encodedHeaderValue =
625
+ response.headers.get("x-tileflow-compressed-bytes") ||
626
+ response.headers.get("content-length");
627
+ const encodedHeader = encodedHeaderValue === null
628
+ ? undefined
629
+ : Number(encodedHeaderValue);
630
+ const bytes = await readBoundedTileflowResponse(
631
+ response,
632
+ comparisonTileMaximumBytes
633
+ );
634
+ const contentEncoding = response.headers.get("content-encoding");
635
+ return {
636
+ archive: response.headers.get("x-tileflow-archive") || undefined,
637
+ compressed: contentEncoding?.includes("gzip") || false,
638
+ contentEncoding: contentEncoding || undefined,
639
+ contentLength: Number.isFinite(encodedHeader) ? encodedHeader : undefined,
640
+ decodedBytes: bytes.byteLength,
641
+ decodedSha256: await digest(bytes),
642
+ durationMilliseconds: performance.now() - startedAt,
643
+ encodedBytes: Number.isFinite(encodedHeader) ? encodedHeader : undefined,
644
+ url
645
+ };
646
+ }
647
+
648
+ async function compareRemote(options = {}) {
649
+ const requestedLimit = Number(options.limit ?? 16);
650
+ const limit = Number.isInteger(requestedLimit) && requestedLimit >= 1 && requestedLimit <= 64
651
+ ? requestedLimit
652
+ : 16;
653
+ const source = Object.values(map.getStyle()?.sources || {}).find(
654
+ (candidate) => candidate.type === "vector" && candidate.url?.includes("/tiles.json")
655
+ );
656
+ if (!source?.url) throw new Error("No vector TileJSON source is active in the map.");
657
+ const localTileJsonUrl = new URL(source.url, location.href);
658
+ const comparisonUrl = new URL("/comparison.json", localTileJsonUrl);
659
+ const contractResponse = await fetch(comparisonUrl, {cache: "no-store"});
660
+ if (!contractResponse.ok) throw new Error("The local comparison contract is unavailable.");
661
+ const contract = await readBoundedTileflowJsonResponse(contractResponse, 64 * 1024);
662
+ const remoteTileJsonUrl = options.remoteTileJson || contract.remoteTileJson;
663
+ if (!remoteTileJsonUrl) throw new Error("No remote TileJSON comparison target is configured.");
664
+ const remoteTileJsonResponse = await fetch(remoteTileJsonUrl, {
665
+ cache: "no-store",
666
+ credentials: "include"
667
+ });
668
+ if (!remoteTileJsonResponse.ok) {
669
+ throw new Error("Remote TileJSON request failed (" + remoteTileJsonResponse.status + ").");
670
+ }
671
+ const remoteTileJson = await readBoundedTileflowJsonResponse(
672
+ remoteTileJsonResponse,
673
+ 256 * 1024
674
+ );
675
+ const remoteTemplate = remoteTileJson.tiles?.[0];
676
+ if (!remoteTemplate) throw new Error("Remote TileJSON has no tile template.");
677
+ const coordinates = inspectRenderBuckets().visibleTileCoordinates.slice(0, limit);
678
+ if (coordinates.length === 0) throw new Error("No visible vector tiles are available to compare.");
679
+
680
+ const perTile = [];
681
+ for (const coordinate of coordinates) {
682
+ const localUrl = resolveTileTemplate(contract.localTileTemplate, coordinate);
683
+ const remoteUrl = resolveTileTemplate(remoteTemplate, coordinate);
684
+ const [local, remote] = await Promise.all([
685
+ fetchComparableTile(localUrl, "omit"),
686
+ fetchComparableTile(remoteUrl, "include")
687
+ ]);
688
+ perTile.push({
689
+ coordinate,
690
+ identicalDecodedPayload: local.decodedSha256 === remote.decodedSha256,
691
+ local,
692
+ remote
693
+ });
694
+ }
695
+ const sum = (side, field) => perTile.reduce(
696
+ (total, tile) => total + (tile[side][field] || 0),
697
+ 0
698
+ );
699
+ const identicalTiles = perTile.filter((tile) => tile.identicalDecodedPayload).length;
700
+ return {
701
+ archive: contract.archive,
702
+ identicalDecodedPercent: (identicalTiles / perTile.length) * 100,
703
+ identicalDecodedTiles: identicalTiles,
704
+ local: {
705
+ decodedBytes: sum("local", "decodedBytes"),
706
+ encodedBytes: sum("local", "encodedBytes"),
707
+ durationMilliseconds: sum("local", "durationMilliseconds")
708
+ },
709
+ perTile,
710
+ remote: {
711
+ decodedBytes: sum("remote", "decodedBytes"),
712
+ encodedBytes: sum("remote", "encodedBytes"),
713
+ durationMilliseconds: sum("remote", "durationMilliseconds"),
714
+ tileJson: remoteTileJsonUrl
715
+ },
716
+ tilesCompared: perTile.length
717
+ };
718
+ }
719
+
720
+ globalThis.__tileflowMapBenchmark = {compareRemote, metrics, settleZoom};
721
+ if (mapCompareEnabled) {
722
+ document.body.dataset.tileflowMapCompareStatus = "running";
723
+ const handleInitialMapCompareIdle = async () => {
724
+ map.off("idle", handleInitialMapCompareIdle);
725
+ try {
726
+ document.body.dataset.tileflowMapCompare = JSON.stringify(
727
+ await compareRemote({limit: mapCompareLimit})
728
+ );
729
+ document.body.dataset.tileflowMapCompareStatus = "complete";
730
+ } catch (error) {
731
+ document.body.dataset.tileflowMapCompare = JSON.stringify({
732
+ error: String(error?.message || error)
733
+ });
734
+ document.body.dataset.tileflowMapCompareStatus = "failed";
735
+ }
736
+ };
737
+ map.on("idle", handleInitialMapCompareIdle);
738
+ }
739
+ if (mapSweepEnabled) {
740
+ document.body.dataset.tileflowMapSweepStatus = "running";
741
+ map.once("load", async () => {
742
+ const results = [];
743
+ try {
744
+ const zooms = new Set(Array.from({length: 23}, (_, zoom) => zoom));
745
+ for (const layer of map.getStyle()?.layers || []) {
746
+ for (const boundary of [layer.minzoom, layer.maxzoom]) {
747
+ if (!Number.isFinite(boundary)) continue;
748
+ for (const sample of [boundary - 0.01, boundary, boundary + 0.01]) {
749
+ if (sample >= 0 && sample <= 22) zooms.add(sample);
750
+ }
751
+ }
752
+ }
753
+ for (const zoom of [...zooms].sort((left, right) => left - right)) {
754
+ const result = await settleZoom(zoom);
755
+ results.push(result);
756
+ if (result.timedOut) {
757
+ throw new Error("Map sweep timed out at zoom " + zoom + ".");
758
+ }
759
+ if (result.errors.length > 0) {
760
+ throw new Error(
761
+ "Map sweep reported an error at zoom " + zoom + ": " + result.errors[0]
762
+ );
763
+ }
764
+ }
765
+ document.body.dataset.tileflowMapSweep = JSON.stringify(results);
766
+ document.body.dataset.tileflowMapSweepStatus = "complete";
767
+ } catch (error) {
768
+ document.body.dataset.tileflowMapSweep = JSON.stringify({
769
+ error: String(error?.message || error),
770
+ results
771
+ });
772
+ document.body.dataset.tileflowMapSweepStatus = "failed";
773
+ }
774
+ });
775
+ }
776
+ }
777
+
778
+ const cameraRanges = {
779
+ bearing: [-180, 180],
780
+ lat: [-90, 90],
781
+ lng: [-180, 180],
782
+ pitch: [0, 85],
783
+ zoom: [0, 24]
784
+ };
785
+
786
+ function readCameraFromUrl() {
787
+ const params = new URL(location.href).searchParams;
788
+ const camera = {};
789
+
790
+ for (const [name, range] of Object.entries(cameraRanges)) {
791
+ const values = params.getAll(name);
792
+ if (values.length !== 1 || values[0].trim() === "") return undefined;
793
+ const value = Number(values[0]);
794
+ if (!Number.isFinite(value) || value < range[0] || value > range[1]) return undefined;
795
+ camera[name] = value;
796
+ }
797
+
798
+ return {
799
+ bearing: camera.bearing,
800
+ center: [camera.lng, camera.lat],
801
+ pitch: camera.pitch,
802
+ zoom: camera.zoom
803
+ };
804
+ }
805
+
806
+ function resolveInitialMapOptions(options) {
807
+ const camera = readCameraFromUrl();
808
+ if (!camera) return options;
809
+ const resolved = {...options};
810
+ delete resolved.bounds;
811
+ delete resolved.fitBoundsOptions;
812
+ return {...resolved, ...camera};
813
+ }
814
+
815
+ function formatCameraNumber(value) {
816
+ return String(Number(value.toFixed(6)));
817
+ }
818
+
819
+ function wrapLongitude(value) {
820
+ return ((value + 180) % 360 + 360) % 360 - 180;
821
+ }
822
+
823
+ function writeCameraToUrl(map) {
824
+ const center = map.getCenter();
825
+ const url = new URL(location.href);
826
+ const camera = {
827
+ bearing: map.getBearing(),
828
+ lat: center.lat,
829
+ lng: wrapLongitude(center.lng),
830
+ pitch: map.getPitch(),
831
+ zoom: map.getZoom()
832
+ };
833
+
834
+ for (const [name, value] of Object.entries(camera)) {
835
+ url.searchParams.set(name, formatCameraNumber(value));
836
+ }
837
+ url.searchParams.set("buildings3d", threeDimensionalEnabled ? "on" : "off");
838
+ url.searchParams.set("trees3d", treesEnabled ? "on" : "off");
839
+ history.replaceState(history.state, "", url.href);
840
+ }
841
+
842
+ function writeToggleStateToUrl() {
843
+ const url = new URL(location.href);
844
+ url.searchParams.set("buildings3d", threeDimensionalEnabled ? "on" : "off");
845
+ url.searchParams.set("trees3d", treesEnabled ? "on" : "off");
846
+ history.replaceState(history.state, "", url.href);
847
+ }
848
+
849
+ function createBuildingWireframeLayer(map, styleLayer) {
850
+ const maximumBuildings = 10000;
851
+ const maximumSegments = 300000;
852
+ const viewportBufferPixels = 96;
853
+ const sourceId = styleLayer.source;
854
+ const metadata = styleLayer.metadata ?? {};
855
+ const heightField = metadata["tileflow:building-wireframe-height-field"] ??
856
+ "render_height";
857
+ const baseField = metadata["tileflow:building-wireframe-base-field"] ??
858
+ "render_min_height";
859
+ const configuredHeightScale = Number(
860
+ metadata["tileflow:building-wireframe-height-scale"] ?? 1
861
+ );
862
+ const heightScale = Number.isFinite(configuredHeightScale) && configuredHeightScale > 0
863
+ ? configuredHeightScale
864
+ : 1;
865
+ const color = metadata["tileflow:building-wireframe-color"] ?? "#43E4FF";
866
+ const glowColor = metadata["tileflow:building-wireframe-glow-color"] ?? "#147DFF";
867
+ const configuredOpacity = Number(
868
+ metadata["tileflow:building-wireframe-opacity"] ?? 0.76
869
+ );
870
+ const opacity = Number.isFinite(configuredOpacity)
871
+ ? Math.max(0, Math.min(1, configuredOpacity))
872
+ : 0.76;
873
+ const configuredWidth = Number(
874
+ metadata["tileflow:building-wireframe-width"] ?? 1.15
875
+ );
876
+ const width = Number.isFinite(configuredWidth) && configuredWidth > 0
877
+ ? configuredWidth
878
+ : 1.15;
879
+ const configuredGlowOpacity = Number(
880
+ metadata["tileflow:building-wireframe-glow-opacity"] ?? 0.11
881
+ );
882
+ const glowOpacity = Number.isFinite(configuredGlowOpacity)
883
+ ? Math.max(0, Math.min(1, configuredGlowOpacity))
884
+ : 0.11;
885
+ const configuredGlowWidth = Number(
886
+ metadata["tileflow:building-wireframe-glow-width"] ?? 3.5
887
+ );
888
+ const glowWidth = Number.isFinite(configuredGlowWidth) && configuredGlowWidth > width
889
+ ? configuredGlowWidth
890
+ : Math.max(3.5, width * 3);
891
+ let camera;
892
+ let renderer;
893
+ let scene;
894
+ let group;
895
+ let geometry;
896
+ let glowLines;
897
+ let coreLines;
898
+ let glowMaterial;
899
+ let coreMaterial;
900
+ let sceneOriginMercator;
901
+ let refreshTimer;
902
+ let emptyRefreshAttempts = 0;
903
+ let lastGeometrySignature;
904
+ const mapMatrix = new THREE.Matrix4();
905
+ const sceneMatrix = new THREE.Matrix4();
906
+
907
+ function geometryPolygons(candidate) {
908
+ if (candidate?.type === "Polygon") return [candidate.coordinates];
909
+ if (candidate?.type === "MultiPolygon") return candidate.coordinates;
910
+ return [];
911
+ }
912
+
913
+ function ringIntersectsViewport(ring, viewportWidth, viewportHeight) {
914
+ if (!Array.isArray(ring) || ring.length < 3) return false;
915
+ let minimumX = Infinity;
916
+ let minimumY = Infinity;
917
+ let maximumX = -Infinity;
918
+ let maximumY = -Infinity;
919
+ for (const coordinate of ring) {
920
+ const longitude = Number(coordinate?.[0]);
921
+ const latitude = Number(coordinate?.[1]);
922
+ if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return false;
923
+ const point = map.project([longitude, latitude]);
924
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return false;
925
+ minimumX = Math.min(minimumX, point.x);
926
+ minimumY = Math.min(minimumY, point.y);
927
+ maximumX = Math.max(maximumX, point.x);
928
+ maximumY = Math.max(maximumY, point.y);
929
+ }
930
+ return (
931
+ maximumX >= -viewportBufferPixels &&
932
+ minimumX <= viewportWidth + viewportBufferPixels &&
933
+ maximumY >= -viewportBufferPixels &&
934
+ minimumY <= viewportHeight + viewportBufferPixels
935
+ );
936
+ }
937
+
938
+ function ringVertexCount(ring) {
939
+ if (!Array.isArray(ring) || ring.length < 3) return 0;
940
+ const first = ring[0];
941
+ const last = ring.at(-1);
942
+ const closes =
943
+ Number(first?.[0]) === Number(last?.[0]) &&
944
+ Number(first?.[1]) === Number(last?.[1]);
945
+ return closes ? ring.length - 1 : ring.length;
946
+ }
947
+
948
+ function buildingKey(feature, rings, height, base) {
949
+ let pointCount = 0;
950
+ let minimumLongitude = Infinity;
951
+ let minimumLatitude = Infinity;
952
+ let maximumLongitude = -Infinity;
953
+ let maximumLatitude = -Infinity;
954
+ for (const ring of rings) {
955
+ for (const coordinate of ring) {
956
+ const longitude = Number(coordinate?.[0]);
957
+ const latitude = Number(coordinate?.[1]);
958
+ if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) continue;
959
+ pointCount += 1;
960
+ minimumLongitude = Math.min(minimumLongitude, longitude);
961
+ minimumLatitude = Math.min(minimumLatitude, latitude);
962
+ maximumLongitude = Math.max(maximumLongitude, longitude);
963
+ maximumLatitude = Math.max(maximumLatitude, latitude);
964
+ }
965
+ }
966
+ const properties = feature.properties ?? {};
967
+ const sourceFeatureId =
968
+ feature.id ?? properties.osm_id ?? properties.osmId ?? properties.id ?? "geometry";
969
+ return [
970
+ sourceFeatureId,
971
+ pointCount,
972
+ minimumLongitude.toFixed(6),
973
+ minimumLatitude.toFixed(6),
974
+ maximumLongitude.toFixed(6),
975
+ maximumLatitude.toFixed(6),
976
+ height.toFixed(2),
977
+ base.toFixed(2)
978
+ ].join(":");
979
+ }
980
+
981
+ function appendSegment(positions, left, right) {
982
+ positions.push(left[0], left[1], left[2], right[0], right[1], right[2]);
983
+ }
984
+
985
+ function appendRing(positions, ring, base, height, mercatorPerMeter) {
986
+ if (!Array.isArray(ring) || ring.length < 3) return 0;
987
+ const first = ring[0];
988
+ const last = ring.at(-1);
989
+ const closes =
990
+ Number(first?.[0]) === Number(last?.[0]) &&
991
+ Number(first?.[1]) === Number(last?.[1]);
992
+ const vertexCount = closes ? ring.length - 1 : ring.length;
993
+ if (vertexCount < 3) return 0;
994
+ if (positions.length / 6 + vertexCount * 3 > maximumSegments) return 0;
995
+ const lower = [];
996
+ const upper = [];
997
+ for (let index = 0; index < vertexCount; index += 1) {
998
+ const longitude = Number(ring[index]?.[0]);
999
+ const latitude = Number(ring[index]?.[1]);
1000
+ if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return 0;
1001
+ const mercator = maplibregl.MercatorCoordinate.fromLngLat([longitude, latitude]);
1002
+ const x = (mercator.x - sceneOriginMercator.x) / mercatorPerMeter;
1003
+ const y = (mercator.y - sceneOriginMercator.y) / mercatorPerMeter;
1004
+ lower.push([x, y, base]);
1005
+ upper.push([x, y, height]);
1006
+ }
1007
+ for (let index = 0; index < vertexCount; index += 1) {
1008
+ const next = (index + 1) % vertexCount;
1009
+ appendSegment(positions, lower[index], lower[next]);
1010
+ appendSegment(positions, upper[index], upper[next]);
1011
+ appendSegment(positions, lower[index], upper[index]);
1012
+ }
1013
+ return vertexCount * 3;
1014
+ }
1015
+
1016
+ function replaceGeometry(positions) {
1017
+ const nextGeometry = new LineSegmentsGeometry();
1018
+ nextGeometry.setPositions(new Float32Array(positions));
1019
+ nextGeometry.computeBoundingSphere();
1020
+ if (!glowLines) {
1021
+ glowLines = new LineSegments2(nextGeometry, glowMaterial);
1022
+ coreLines = new LineSegments2(nextGeometry, coreMaterial);
1023
+ glowLines.frustumCulled = false;
1024
+ coreLines.frustumCulled = false;
1025
+ group.add(glowLines, coreLines);
1026
+ } else {
1027
+ glowLines.geometry = nextGeometry;
1028
+ coreLines.geometry = nextGeometry;
1029
+ geometry?.dispose();
1030
+ }
1031
+ geometry = nextGeometry;
1032
+ }
1033
+
1034
+ function wireframeShouldBeVisible() {
1035
+ return (
1036
+ threeDimensionalEnabled &&
1037
+ visibleLayerGroups.has("buildings") &&
1038
+ styleLayerIsVisibleAtZoom(styleLayer, map.getZoom()) &&
1039
+ map.getLayoutProperty(styleLayer.id, "visibility") !== "none"
1040
+ );
1041
+ }
1042
+
1043
+ function hideWireframe() {
1044
+ if (group) group.visible = false;
1045
+ buildingWireframeMetrics.buildingCount = 0;
1046
+ buildingWireframeMetrics.refreshMilliseconds = undefined;
1047
+ buildingWireframeMetrics.segmentCount = 0;
1048
+ buildingWireframeMetrics.truncated = false;
1049
+ }
1050
+
1051
+ function refresh() {
1052
+ const startedAt = performance.now();
1053
+ refreshTimer = undefined;
1054
+ if (!wireframeShouldBeVisible() || map.isMoving()) {
1055
+ hideWireframe();
1056
+ return;
1057
+ }
1058
+ const canvas = map.getCanvas();
1059
+ const viewportWidth = canvas.clientWidth || canvas.width;
1060
+ const viewportHeight = canvas.clientHeight || canvas.height;
1061
+ let features;
1062
+ try {
1063
+ features = map.queryRenderedFeatures(
1064
+ [
1065
+ [-viewportBufferPixels, -viewportBufferPixels],
1066
+ [
1067
+ viewportWidth + viewportBufferPixels,
1068
+ viewportHeight + viewportBufferPixels
1069
+ ]
1070
+ ],
1071
+ {layers: [styleLayer.id]}
1072
+ );
1073
+ } catch (error) {
1074
+ console.warn("Tileflow building wireframe could not read visible buildings", error);
1075
+ return;
1076
+ }
1077
+ if (features.length === 0) {
1078
+ hideWireframe();
1079
+ if (emptyRefreshAttempts < 12) {
1080
+ emptyRefreshAttempts += 1;
1081
+ queueRefresh(160);
1082
+ }
1083
+ return;
1084
+ }
1085
+ emptyRefreshAttempts = 0;
1086
+ sceneOriginMercator = maplibregl.MercatorCoordinate.fromLngLat(map.getCenter(), 0);
1087
+ const mercatorPerMeter = sceneOriginMercator.meterInMercatorCoordinateUnits();
1088
+ const positions = [];
1089
+ const seen = new Set();
1090
+ let buildingCount = 0;
1091
+ let segmentCount = 0;
1092
+ let truncated = false;
1093
+ featureLoop: for (const feature of features) {
1094
+ const polygons = geometryPolygons(feature.geometry);
1095
+ if (polygons.length === 0) continue;
1096
+ const properties = feature.properties ?? {};
1097
+ const parsedHeight = Number(properties[heightField]);
1098
+ const sourceHeight = Math.max(0, Number.isFinite(parsedHeight) ? parsedHeight : 5);
1099
+ const parsedBase = Number(properties[baseField]);
1100
+ const sourceBase = Math.max(
1101
+ 0,
1102
+ Math.min(Number.isFinite(parsedBase) ? parsedBase : 0, sourceHeight)
1103
+ );
1104
+ const height = sourceHeight * heightScale;
1105
+ const base = sourceBase * heightScale;
1106
+ if (height <= base) continue;
1107
+ for (const rings of polygons) {
1108
+ if (buildingCount >= maximumBuildings) {
1109
+ truncated = true;
1110
+ break featureLoop;
1111
+ }
1112
+ const outerRing = rings?.[0];
1113
+ if (!ringIntersectsViewport(outerRing, viewportWidth, viewportHeight)) continue;
1114
+ const key = buildingKey(feature, rings, height, base);
1115
+ if (seen.has(key)) continue;
1116
+ const requiredSegments = rings.reduce(
1117
+ (total, ring) => total + ringVertexCount(ring) * 3,
1118
+ 0
1119
+ );
1120
+ if (segmentCount + requiredSegments > maximumSegments) {
1121
+ truncated = true;
1122
+ break featureLoop;
1123
+ }
1124
+ seen.add(key);
1125
+ const segmentCountBefore = segmentCount;
1126
+ for (const ring of rings) {
1127
+ segmentCount += appendRing(positions, ring, base, height, mercatorPerMeter);
1128
+ }
1129
+ if (segmentCount > segmentCountBefore) buildingCount += 1;
1130
+ }
1131
+ }
1132
+ if (positions.length === 0) {
1133
+ hideWireframe();
1134
+ return;
1135
+ }
1136
+ const geometrySignature = [
1137
+ sceneOriginMercator.x.toFixed(9),
1138
+ sceneOriginMercator.y.toFixed(9),
1139
+ buildingCount,
1140
+ segmentCount,
1141
+ ...[...seen].sort()
1142
+ ].join("|");
1143
+ if (geometrySignature !== lastGeometrySignature || !geometry) {
1144
+ replaceGeometry(positions);
1145
+ lastGeometrySignature = geometrySignature;
1146
+ }
1147
+ group.visible = true;
1148
+ buildingWireframeMetrics.buildingCount = buildingCount;
1149
+ buildingWireframeMetrics.segmentCount = segmentCount;
1150
+ buildingWireframeMetrics.refreshMilliseconds = performance.now() - startedAt;
1151
+ buildingWireframeMetrics.truncated = truncated;
1152
+ map.triggerRepaint();
1153
+ }
1154
+
1155
+ function queueRefresh(delay = 80) {
1156
+ if (refreshTimer !== undefined) return;
1157
+ refreshTimer = setTimeout(refresh, delay);
1158
+ }
1159
+
1160
+ function handleMoveEnd() {
1161
+ emptyRefreshAttempts = 0;
1162
+ queueRefresh(0);
1163
+ }
1164
+
1165
+ function handleSourceData(event) {
1166
+ if (event.sourceId !== sourceId || event.isSourceLoaded !== true || map.isMoving()) {
1167
+ return;
1168
+ }
1169
+ queueRefresh();
1170
+ }
1171
+
1172
+ function handleThreeDimensionalToggle() {
1173
+ if (!threeDimensionalEnabled) {
1174
+ if (refreshTimer !== undefined) clearTimeout(refreshTimer);
1175
+ refreshTimer = undefined;
1176
+ hideWireframe();
1177
+ map.triggerRepaint();
1178
+ return;
1179
+ }
1180
+ emptyRefreshAttempts = 0;
1181
+ queueRefresh(0);
1182
+ }
1183
+
1184
+ function handleVisibleLayerGroups() {
1185
+ if (!visibleLayerGroups.has("buildings")) {
1186
+ hideWireframe();
1187
+ map.triggerRepaint();
1188
+ return;
1189
+ }
1190
+ emptyRefreshAttempts = 0;
1191
+ queueRefresh(0);
1192
+ }
1193
+
1194
+ return {
1195
+ id: "tileflow-buildings-wireframe-3d",
1196
+ type: "custom",
1197
+ renderingMode: "3d",
1198
+ onAdd(_map, gl) {
1199
+ camera = new THREE.Camera();
1200
+ scene = new THREE.Scene();
1201
+ group = new THREE.Group();
1202
+ group.visible = false;
1203
+ scene.add(group);
1204
+ glowMaterial = new LineMaterial({
1205
+ blending: THREE.AdditiveBlending,
1206
+ color: glowColor,
1207
+ depthTest: false,
1208
+ depthWrite: false,
1209
+ linewidth: glowWidth,
1210
+ opacity: glowOpacity,
1211
+ toneMapped: false,
1212
+ transparent: true,
1213
+ worldUnits: false
1214
+ });
1215
+ coreMaterial = new LineMaterial({
1216
+ blending: THREE.NormalBlending,
1217
+ color,
1218
+ depthTest: false,
1219
+ depthWrite: false,
1220
+ linewidth: width,
1221
+ opacity,
1222
+ toneMapped: false,
1223
+ transparent: true,
1224
+ worldUnits: false
1225
+ });
1226
+ renderer = new THREE.WebGLRenderer({
1227
+ canvas: map.getCanvas(),
1228
+ context: gl
1229
+ });
1230
+ renderer.autoClear = false;
1231
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
1232
+ map.on("moveend", handleMoveEnd);
1233
+ map.on("sourcedata", handleSourceData);
1234
+ map.on("tileflow:3d-toggle", handleThreeDimensionalToggle);
1235
+ map.on("tileflow:visible-layer-groups", handleVisibleLayerGroups);
1236
+ queueRefresh(0);
1237
+ },
1238
+ render(_gl, options) {
1239
+ if (!wireframeShouldBeVisible() || !sceneOriginMercator || !group?.visible) return;
1240
+ const canvas = map.getCanvas();
1241
+ const pixelRatio = map.getPixelRatio?.() ?? globalThis.devicePixelRatio ?? 1;
1242
+ glowMaterial.linewidth = glowWidth * pixelRatio;
1243
+ coreMaterial.linewidth = width * pixelRatio;
1244
+ glowMaterial.resolution.set(canvas.width, canvas.height);
1245
+ coreMaterial.resolution.set(canvas.width, canvas.height);
1246
+ const meter = sceneOriginMercator.meterInMercatorCoordinateUnits();
1247
+ mapMatrix.fromArray(options.defaultProjectionData.mainMatrix);
1248
+ sceneMatrix
1249
+ .makeTranslation(
1250
+ sceneOriginMercator.x,
1251
+ sceneOriginMercator.y,
1252
+ sceneOriginMercator.z
1253
+ )
1254
+ .scale(new THREE.Vector3(meter, meter, meter));
1255
+ camera.projectionMatrix.copy(mapMatrix).multiply(sceneMatrix);
1256
+ renderer.resetState();
1257
+ renderer.setViewport(0, 0, canvas.width, canvas.height);
1258
+ renderer.render(scene, camera);
1259
+ },
1260
+ onRemove() {
1261
+ if (refreshTimer !== undefined) clearTimeout(refreshTimer);
1262
+ map.off("moveend", handleMoveEnd);
1263
+ map.off("sourcedata", handleSourceData);
1264
+ map.off("tileflow:3d-toggle", handleThreeDimensionalToggle);
1265
+ map.off("tileflow:visible-layer-groups", handleVisibleLayerGroups);
1266
+ geometry?.dispose();
1267
+ glowMaterial?.dispose();
1268
+ coreMaterial?.dispose();
1269
+ renderer?.dispose();
1270
+ hideWireframe();
1271
+ }
1272
+ };
1273
+ }
1274
+
1275
+ function addBuildingWireframeLayerIfConfigured(map, styleLayers) {
1276
+ const styleLayer = styleLayers.find(
1277
+ (layer) => layer.metadata?.["tileflow:building-wireframe"] === true
1278
+ );
1279
+ if (!styleLayer || map.getLayer("tileflow-buildings-wireframe-3d")) return;
1280
+ if (map.getProjection().type !== "mercator") {
1281
+ map.setProjection({type: "mercator"});
1282
+ }
1283
+ if (map.getLayer("tileflow-buildings-wireframe-3d")) return;
1284
+ const styleLayerIndex = styleLayers.findIndex((layer) => layer.id === styleLayer.id);
1285
+ const layerAboveBuildings = styleLayers[styleLayerIndex + 1]?.id;
1286
+ map.addLayer(createBuildingWireframeLayer(map, styleLayer), layerAboveBuildings);
1287
+ }
1288
+
1289
+ function styleLayerZoomRange(layer) {
1290
+ return {
1291
+ minzoom: Number.isFinite(layer?.minzoom) ? layer.minzoom : 0,
1292
+ maxzoom: Number.isFinite(layer?.maxzoom) ? layer.maxzoom : Infinity
1293
+ };
1294
+ }
1295
+
1296
+ function styleLayerIsVisibleAtZoom(layer, zoom) {
1297
+ const {minzoom, maxzoom} = styleLayerZoomRange(layer);
1298
+ return zoom >= minzoom && zoom < maxzoom;
1299
+ }
1300
+
1301
+ // Core's 3D scene, as an application mounts it: the map's style describes how its
1302
+ // buildings, trees and street lights look, and the controls only show and hide them. The
1303
+ // vegetation control hides all trees when off. The preview shows one theme at a
1304
+ // time, so the style on screen holds the sun and the design.
1305
+ const coreScene = {scene: undefined, shown: undefined, buildingsShown: undefined, animation: "0"};
1306
+ const runtimeLayerVisibility = {};
1307
+ globalThis.__tileflowPreviewScene = coreScene;
1308
+ globalThis.__tileflowRuntimeVisibility = (id, visible) => {
1309
+ if (!['tileflow-buildings-scene-3d','tileflow-trees-3d','tileflow-street-lights'].includes(id)) return undefined;
1310
+ const previewMap = globalThis.__tileflowPreviewMap;
1311
+ if (typeof visible === 'boolean') {
1312
+ runtimeLayerVisibility[id] = visible;
1313
+ if (previewMap) { mountCoreScene(previewMap); previewMap.triggerRepaint(); }
1314
+ }
1315
+ return runtimeLayerVisibility[id] !== false;
1316
+ };
1317
+ // Explicit observation capability: controllers stay private to the preview and expose
1318
+ // only their public state/statistics to the parent inspector.
1319
+ globalThis.__tileflowInspectModels = () => [...(coreScene.scene?.buildings?.inspectModels?.() || []), ...(coreScene.scene?.vegetation?.inspectModels?.() || [])];
1320
+ globalThis.__tileflowDescribeRuntimeLayers = () => {
1321
+ const {buildings, vegetation, streetLights} = coreScene.scene ?? {};
1322
+ return {
1323
+ ...(buildings ? {"tileflow-buildings-scene-3d": {controller:"Buildings 3D", state:{visible:runtimeLayerVisibility["tileflow-buildings-scene-3d"] !== false, shown:coreScene.buildingsShown, scale:buildings.scale}, stats:buildings.stats}} : {}),
1324
+ ...(vegetation ? {"tileflow-trees-3d": {controller:"Vegetation", state:{visible:runtimeLayerVisibility["tileflow-trees-3d"] !== false, representation:vegetation.representation}, stats:vegetation.stats}} : {}),
1325
+ ...(streetLights ? {"tileflow-street-lights": {controller:"Street lights", state:{visible:runtimeLayerVisibility["tileflow-street-lights"] !== false, mounted:true}, stats:streetLights.stats}} : {})
1326
+ };
1327
+ };
1328
+ const createPreviewStyleReads = ${createPreviewStyleReads.toString()};
1329
+ const previewStyleReads = new WeakMap();
1330
+ // Style reads for code that runs on every camera move or style event.
1331
+ function styleReads(map) {
1332
+ let reads = previewStyleReads.get(map);
1333
+ if (!reads) {
1334
+ reads = createPreviewStyleReads(map);
1335
+ previewStyleReads.set(map, reads);
1336
+ }
1337
+ return reads;
1338
+ }
1339
+ const createPreviewSceneThemes = ${createPreviewSceneThemes.toString()};
1340
+ const coreSceneThemeControllers = new WeakMap();
1341
+ function coreSceneThemes(map) {
1342
+ let controller = coreSceneThemeControllers.get(map);
1343
+ if (!controller) {
1344
+ controller = createPreviewSceneThemes(map, metadata =>
1345
+ resolveTileflowSun(readTileflowSunPreset(metadata) ?? tileflowDefaultSunPreset));
1346
+ coreSceneThemeControllers.set(map, controller);
1347
+ }
1348
+ return controller;
1349
+ }
1350
+ let mountingCoreScene = false;
1351
+ function mountCoreScene(map) {
1352
+ if (mountingCoreScene) return;
1353
+ mountingCoreScene = true;
1354
+ try {
1355
+ const metadata = styleReads(map).metadata() ?? {};
1356
+ const treesShown = () => treesEnabled && runtimeLayerVisibility["tileflow-trees-3d"] !== false;
1357
+ if (!coreScene.scene) {
1358
+ if (!previewSearchParameters.has("trees3d")) {
1359
+ treesEnabled = metadata["tileflow:vegetation-display"]?.visible ?? true;
1360
+ }
1361
+ // The scene mounts what the style declares; street lights belong to the 3D view.
1362
+ coreScene.scene = createTileflowMap3D({map, controller: coreSceneThemes(map),
1363
+ viewMode: threeDimensionalEnabled ? "3d" : "2d", vegetation: treesShown(),
1364
+ transformRequest: previewTransformRequest});
1365
+ }
1366
+ const scene = coreScene.scene;
1367
+ scene.setVegetation(treesShown());
1368
+ const instant = {transition: {duration: 0}};
1369
+ const animation = previewSearchParameters.get('animation') || '0';
1370
+ const duration = animation === 'fade' ? 800 : Math.min(3000, Math.max(0, Number(animation) || 0));
1371
+ // Only a change of the controls moves the layers, so a style event does not restart them.
1372
+ if (coreScene.shown !== threeDimensionalEnabled || coreScene.animation !== animation) {
1373
+ coreScene.shown = threeDimensionalEnabled;
1374
+ coreScene.buildingsShown = threeDimensionalEnabled;
1375
+ coreScene.animation = animation;
1376
+ void (threeDimensionalEnabled ? scene.show({transition: {duration, mode: animation === "fade" ? "fade" : "grow"}}) : scene.hide(instant));
1377
+ }
1378
+ // The inspector can hide the buildings alone.
1379
+ const showBuildings = threeDimensionalEnabled && runtimeLayerVisibility["tileflow-buildings-scene-3d"] !== false;
1380
+ if (scene.buildings && coreScene.buildingsShown !== showBuildings) {
1381
+ coreScene.buildingsShown = showBuildings;
1382
+ void (showBuildings ? scene.buildings.show(instant) : scene.buildings.hide(instant));
1383
+ }
1384
+ const lightLayer = map.getLayer("tileflow-street-lights");
1385
+ const lightRenderer = lightLayer?.implementation || lightLayer;
1386
+ if (lightRenderer?.render && !lightRenderer.__previewVisibility) {
1387
+ const render = lightRenderer.render;
1388
+ lightRenderer.render = function(...args) {
1389
+ if (runtimeLayerVisibility["tileflow-street-lights"] !== false) return render.apply(this,args);
1390
+ };
1391
+ lightRenderer.__previewVisibility = true;
1392
+ }
1393
+ } finally {mountingCoreScene = false;}
1394
+ }
1395
+
1396
+ class ThreeDimensionalControl {
1397
+ constructor() {
1398
+ this.enabled = threeDimensionalEnabled;
1399
+ this.handleClick = this.handleClick.bind(this);
1400
+ this.update = this.update.bind(this);
1401
+ }
1402
+
1403
+ onAdd(map) {
1404
+ this.map = map;
1405
+ this.container = document.createElement("div");
1406
+ this.container.className = "maplibregl-ctrl maplibregl-ctrl-group";
1407
+ this.button = document.createElement("button");
1408
+ this.button.type = "button";
1409
+ this.button.className = "tileflow-3d-toggle";
1410
+ this.button.title = "Toggle 3D view";
1411
+ this.button.setAttribute("aria-label", "Toggle 3D view");
1412
+ this.button.addEventListener("click", this.handleClick);
1413
+ this.container.appendChild(this.button);
1414
+ map.on("styledata", this.update);
1415
+ this.update();
1416
+ return this.container;
1417
+ }
1418
+
1419
+ handleClick() {
1420
+ this.setEnabled(!this.enabled);
1421
+ }
1422
+
1423
+ setEnabled(enabled) {
1424
+ if (typeof enabled !== "boolean" || enabled === this.enabled) return;
1425
+ this.enabled = enabled;
1426
+ threeDimensionalEnabled = this.enabled;
1427
+ this.update();
1428
+ writeToggleStateToUrl();
1429
+ this.map.fire("tileflow:3d-toggle", {enabled: this.enabled});
1430
+ mountCoreScene(this.map);
1431
+ this.map.triggerRepaint();
1432
+ }
1433
+
1434
+ update() {
1435
+ const reads = styleReads(this.map);
1436
+ for (const layer of reads.layers()) {
1437
+ const toggle = layer.metadata?.["tileflow:3d-toggle"];
1438
+ if (toggle !== "building" && toggle !== "landmark") continue;
1439
+ // Core's 3D buildings stand in for the compiled extrusion where the style describes them.
1440
+ const replaced = toggle === "building" && reads.metadata()?.["tileflow:buildings"];
1441
+ if (replaced) continue; // Core switches the native extrusion only when its scene can draw.
1442
+ const visibility = this.enabled && !replaced ? "visible" : "none";
1443
+ if (this.map.getLayoutProperty(layer.id, "visibility") !== visibility) {
1444
+ this.map.setLayoutProperty(layer.id, "visibility", visibility);
1445
+ }
1446
+ }
1447
+ if (this.map.isStyleLoaded()) mountCoreScene(this.map);
1448
+ this.button.textContent = this.enabled ? "3D ON" : "3D OFF";
1449
+ this.button.setAttribute("aria-pressed", String(this.enabled));
1450
+ this.button.title = this.enabled ? "Disable 3D buildings" : "Enable 3D buildings";
1451
+ }
1452
+
1453
+ onRemove() {
1454
+ this.map?.off("styledata", this.update);
1455
+ this.button?.removeEventListener("click", this.handleClick);
1456
+ this.container?.remove();
1457
+ this.map = undefined;
1458
+ }
1459
+ }
1460
+
1461
+ class TreeControl {
1462
+ constructor() {
1463
+ this.enabled = treesEnabled;
1464
+ this.handleClick = this.handleClick.bind(this);
1465
+ this.update = this.update.bind(this);
1466
+ }
1467
+
1468
+ onAdd(map) {
1469
+ this.map = map;
1470
+ this.container = document.createElement("div");
1471
+ this.container.className = "maplibregl-ctrl maplibregl-ctrl-group";
1472
+ this.button = document.createElement("button");
1473
+ this.button.type = "button";
1474
+ this.button.className = "tileflow-tree-toggle";
1475
+ this.button.setAttribute("aria-label", "Toggle trees");
1476
+ this.button.addEventListener("click", this.handleClick);
1477
+ this.container.appendChild(this.button);
1478
+ map.on("styledata", this.update);
1479
+ this.update();
1480
+ return this.container;
1481
+ }
1482
+
1483
+ handleClick() {
1484
+ this.setEnabled(!this.enabled);
1485
+ }
1486
+
1487
+ setEnabled(enabled) {
1488
+ if (typeof enabled !== "boolean" || enabled === this.enabled) return;
1489
+ this.enabled = enabled;
1490
+ treesEnabled = this.enabled;
1491
+ this.update();
1492
+ writeToggleStateToUrl();
1493
+ this.map.fire("tileflow:trees-toggle", {enabled: this.enabled});
1494
+ mountCoreScene(this.map);
1495
+ this.map.triggerRepaint();
1496
+ }
1497
+
1498
+ update() {
1499
+ if (this.map.isStyleLoaded()) mountCoreScene(this.map);
1500
+ this.enabled = treesEnabled;
1501
+ this.button.textContent = this.enabled ? "TREES ON" : "TREES OFF";
1502
+ this.button.setAttribute("aria-pressed", String(this.enabled));
1503
+ this.button.title = this.enabled ? "Hide vegetation" : "Show vegetation";
1504
+ }
1505
+
1506
+ onRemove() {
1507
+ this.map?.off("styledata", this.update);
1508
+ this.button?.removeEventListener("click", this.handleClick);
1509
+ this.container?.remove();
1510
+ this.map = undefined;
1511
+ }
1512
+ }
1513
+
1514
+ // Switches a map preview between its themes. The preview reloads with the next theme and keeps
1515
+ // the camera and toggles already written to the URL; scene previews own their theme.
1516
+ class ThemeControl {
1517
+ onAdd(map) {
1518
+ this.map = map;
1519
+ this.container = document.createElement("div");
1520
+ this.container.className = "maplibregl-ctrl maplibregl-ctrl-group";
1521
+ this.button = document.createElement("button");
1522
+ this.button.type = "button";
1523
+ this.button.className = "tileflow-theme-toggle";
1524
+ const next = previewThemes[(previewThemes.indexOf(previewTheme) + 1) % previewThemes.length];
1525
+ this.button.textContent = String(previewTheme).toUpperCase();
1526
+ this.button.title = "Switch to the " + next + " theme";
1527
+ this.button.setAttribute("aria-label", "Switch to the " + next + " theme");
1528
+ this.handleClick = () => {
1529
+ writeCameraToUrl(map);
1530
+ const url = new URL(location.href);
1531
+ url.searchParams.set("theme", next);
1532
+ location.assign(url.href);
1533
+ };
1534
+ this.button.addEventListener("click", this.handleClick);
1535
+ this.container.appendChild(this.button);
1536
+ return this.container;
1537
+ }
1538
+
1539
+ onRemove() {
1540
+ this.button?.removeEventListener("click", this.handleClick);
1541
+ this.container?.remove();
1542
+ this.map = undefined;
1543
+ }
1544
+ }
1545
+
1546
+ const previewLayerGroupIds = [
1547
+ "labels", "pois", "roads", "transit", "buildings", "landuse", "water"
1548
+ ];
1549
+ let visibleLayerGroups = new Set(previewLayerGroupIds);
1550
+ const defaultLayerVisibility = new Map();
1551
+ const defaultLayerTextField = new Map();
1552
+
1553
+ function previewLayoutValuesEqual(left, right) {
1554
+ return JSON.stringify(left) === JSON.stringify(right);
1555
+ }
1556
+
1557
+ function previewLayerGroups(layer) {
1558
+ const id = layer.id.toLowerCase();
1559
+ const sourceLayer = layer["source-layer"] || "";
1560
+ const groups = [];
1561
+ const isLanduseLayer = [
1562
+ "landuse", "landcover", "park", "business_corridor"
1563
+ ].includes(sourceLayer);
1564
+ if (
1565
+ !isLanduseLayer &&
1566
+ /poi|parking|business|amenity/u.test(id + " " + sourceLayer)
1567
+ ) groups.push("pois");
1568
+ if (/transit|rail|ferry|aeroway|aerodrome/u.test(id + " " + sourceLayer)) {
1569
+ groups.push("transit");
1570
+ } else if (
1571
+ [
1572
+ "transportation",
1573
+ "transportation_name",
1574
+ "circular_feature",
1575
+ "sidewalk",
1576
+ "street_furniture"
1577
+ ].includes(sourceLayer)
1578
+ ) {
1579
+ groups.push("roads");
1580
+ }
1581
+ if (sourceLayer === "building" || id.includes("building")) {
1582
+ groups.push("buildings");
1583
+ }
1584
+ if (isLanduseLayer) {
1585
+ groups.push("landuse");
1586
+ }
1587
+ if (["water", "waterway", "water_name", "bathymetry"].includes(sourceLayer)) {
1588
+ groups.push("water");
1589
+ }
1590
+ return groups;
1591
+ }
1592
+
1593
+ function applyVisibleLayerGroups(map) {
1594
+ if (document.documentElement) {
1595
+ document.documentElement.dataset.visibleLayerGroups = [
1596
+ ...visibleLayerGroups,
1597
+ ].join(",");
1598
+ }
1599
+ const reads = styleReads(map);
1600
+ const vegetationLayers = reads.metadata()?.["tileflow:vegetation-display"]?.flatLayers;
1601
+ for (const layer of reads.layers()) {
1602
+ if (layer.type === "symbol" && map.getLayoutProperty(layer.id, "text-field") !== undefined) {
1603
+ const hideLabels = !visibleLayerGroups.has("labels");
1604
+ if (hideLabels && !defaultLayerTextField.has(layer.id)) {
1605
+ defaultLayerTextField.set(
1606
+ layer.id,
1607
+ map.getLayoutProperty(layer.id, "text-field")
1608
+ );
1609
+ }
1610
+ const restoreText = !hideLabels && defaultLayerTextField.has(layer.id);
1611
+ const textField = hideLabels ? "" : defaultLayerTextField.get(layer.id);
1612
+ if (restoreText) defaultLayerTextField.delete(layer.id);
1613
+ if (
1614
+ (hideLabels || restoreText) &&
1615
+ !previewLayoutValuesEqual(
1616
+ map.getLayoutProperty(layer.id, "text-field"),
1617
+ textField
1618
+ )
1619
+ ) {
1620
+ map.setLayoutProperty(layer.id, "text-field", textField);
1621
+ }
1622
+ }
1623
+ // The shared vegetation controller owns every flat representation.
1624
+ if (vegetationLayers?.some((entry) => entry.id === layer.id.split("::")[0])) continue;
1625
+ const groups = previewLayerGroups(layer);
1626
+ const hideLayer = groups.some((group) => !visibleLayerGroups.has(group));
1627
+ // Group controls only own the properties they mask. Ordinary style events must
1628
+ // preserve temporary Inspector edits, including visibility and label text.
1629
+ if (hideLayer && !defaultLayerVisibility.has(layer.id)) {
1630
+ defaultLayerVisibility.set(layer.id, map.getLayoutProperty(layer.id, "visibility") || "visible");
1631
+ }
1632
+ if (!hideLayer && !defaultLayerVisibility.has(layer.id)) continue;
1633
+ const threeDimensionalToggle = layer.metadata?.["tileflow:3d-toggle"];
1634
+ const authoredVisibility = defaultLayerVisibility.get(layer.id);
1635
+ if (!hideLayer) defaultLayerVisibility.delete(layer.id);
1636
+ const styleMetadata = reads.metadata() ?? {};
1637
+ // Core's 3D buildings and trees stand in for compiled extrusions and flat trees.
1638
+ const replacedByCore =
1639
+ threeDimensionalToggle === "building" && styleMetadata["tileflow:buildings"];
1640
+ if (replacedByCore && !hideLayer) continue;
1641
+ const toggleVisibility = replacedByCore
1642
+ ? "none"
1643
+ : threeDimensionalToggle === "building" || threeDimensionalToggle === "landmark"
1644
+ ? (threeDimensionalEnabled ? "visible" : "none")
1645
+ : authoredVisibility;
1646
+ const visibility = hideLayer
1647
+ ? "none"
1648
+ : toggleVisibility;
1649
+ if (map.getLayoutProperty(layer.id, "visibility") !== visibility) {
1650
+ map.setLayoutProperty(layer.id, "visibility", visibility);
1651
+ }
1652
+ }
1653
+ }
1654
+
1655
+ function setVisibleLayerGroups(map, groups) {
1656
+ visibleLayerGroups = new Set(groups);
1657
+ applyVisibleLayerGroups(map);
1658
+ map.fire("tileflow:visible-layer-groups", {groups: [...visibleLayerGroups]});
1659
+ }
1660
+
1661
+ function previewTransformRequest(url) {
1662
+ try {
1663
+ return new URL(url, location.href).hostname === "dev-tiles.tileflow.dev"
1664
+ ? {credentials: "include", url}
1665
+ : {url};
1666
+ } catch {
1667
+ return {url};
1668
+ }
1669
+ }
1670
+
1671
+ if (styleUrl) {
1672
+ // Dense vector tiles finish in bursts with multiple workers, causing several
1673
+ // bucket uploads in one frame. One worker keeps navigation smooth; benchmarks
1674
+ // can still compare up to three workers through the explicit URL override.
1675
+ const mapWorkerCount = mapWorkerCountOverride ?? 1;
1676
+ maplibregl.setWorkerCount?.(mapWorkerCount);
1677
+ await loadTileflowStyleFonts(styleUrl, {fontFaces: previewFontFaces});
1678
+ registerTileflowContourProtocol({addProtocol: maplibregl.addProtocol});
1679
+ registerTileflowPathGroundProtocol({addProtocol: maplibregl.addProtocol});
1680
+ registerTileflowPmtilesProtocol({addProtocol: maplibregl.addProtocol});
1681
+ const map = new maplibregl.Map({
1682
+ canvasContextAttributes: tileflowCanvasContextAttributes({antialias: mapAntialias}),
1683
+ collectResourceTiming: mapBenchmarkEnabled || Boolean(inspectorObserver),
1684
+ container: "map",
1685
+ maxPitch: tileflowMaxPitch,
1686
+ style: styleUrl,
1687
+ transformRequest: previewTransformRequest,
1688
+ ...resolveInitialMapOptions(previewMapOptions)
1689
+ });
1690
+ globalThis.__tileflowPreviewMap = map;
1691
+ attachTileflowNotices(map);
1692
+ attachTileflowNauticalLights(map);
1693
+ attachTileflowAtmosphere(map);
1694
+ attachTileflowLabelPlacement(map);
1695
+ attachTileflowHorizonQueries(map);
1696
+ inspectorObserver?.attach(map);
1697
+ installMapBenchmark(map);
1698
+ if (window.parent === window) map.addControl(new maplibregl.NavigationControl(), "top-right");
1699
+ let threeDimensionalControl;
1700
+ let treeControl;
1701
+ if (isSemanticPreview) {
1702
+ threeDimensionalControl = new ThreeDimensionalControl();
1703
+ treeControl = new TreeControl();
1704
+ map.addControl(threeDimensionalControl, "top-right");
1705
+ map.addControl(treeControl, "top-right");
1706
+ // Embedded Inspector owns the common Controls sidebar. Keep the controls mounted
1707
+ // for their renderer lifecycle and message bridge, but remove duplicate buttons.
1708
+ if (window.parent !== window) {
1709
+ threeDimensionalControl.container.hidden = true;
1710
+ treeControl.container.hidden = true;
1711
+ }
1712
+ }
1713
+ // Inspector has a shared theme selector in its Controls panel; keep the map-level
1714
+ // cycling button for standalone previews only.
1715
+ if (previewThemes.length > 1 && window.parent === window) map.addControl(new ThemeControl(), "top-right");
1716
+ let ensuringThreeDimensionalLayers;
1717
+ const ensureThreeDimensionalLayers = () => {
1718
+ if (ensuringThreeDimensionalLayers) return ensuringThreeDimensionalLayers;
1719
+ const buildingWireframeConfigLayer = styleReads(map).layers().find(
1720
+ (layer) => layer.metadata?.["tileflow:building-wireframe"] === true
1721
+ );
1722
+ const needsBuildingWireframe =
1723
+ buildingWireframeConfigLayer &&
1724
+ threeDimensionalEnabled &&
1725
+ styleLayerIsVisibleAtZoom(buildingWireframeConfigLayer, map.getZoom()) &&
1726
+ !map.getLayer("tileflow-buildings-wireframe-3d");
1727
+ if (!needsBuildingWireframe) return;
1728
+ ensuringThreeDimensionalLayers = loadBuildingWireframeRuntime()
1729
+ .then(() => {
1730
+ const styleLayers = map.getStyle()?.layers || [];
1731
+ const buildingWireframeConfigLayer = styleLayers.find(
1732
+ (layer) => layer.metadata?.["tileflow:building-wireframe"] === true
1733
+ );
1734
+ if (
1735
+ buildingWireframeConfigLayer &&
1736
+ threeDimensionalEnabled &&
1737
+ styleLayerIsVisibleAtZoom(buildingWireframeConfigLayer, map.getZoom())
1738
+ ) {
1739
+ addBuildingWireframeLayerIfConfigured(map, styleLayers);
1740
+ }
1741
+ })
1742
+ .catch((error) => console.warn("Tileflow 3D runtime failed to load", error))
1743
+ .finally(() => {
1744
+ ensuringThreeDimensionalLayers = undefined;
1745
+ });
1746
+ return ensuringThreeDimensionalLayers;
1747
+ };
1748
+ map.on("styledata", ensureThreeDimensionalLayers);
1749
+ map.on("styledata", () => applyVisibleLayerGroups(map));
1750
+ map.on("zoomend", ensureThreeDimensionalLayers);
1751
+ map.on("tileflow:3d-toggle", ensureThreeDimensionalLayers);
1752
+ map.on("tileflow:trees-toggle", ensureThreeDimensionalLayers);
1753
+ map.on("load", () => {
1754
+ mountCoreScene(map);
1755
+ treeControl?.update();
1756
+ writeCameraToUrl(map);
1757
+ ensureThreeDimensionalLayers();
1758
+ });
1759
+ map.on("moveend", () => writeCameraToUrl(map));
1760
+ ${previewSceneClient}
1761
+ addEventListener("message", (event) => {
1762
+ if (
1763
+ window.parent === window ||
1764
+ event.source !== window.parent ||
1765
+ event.origin !== location.origin ||
1766
+ event.data?.type !== "tileflow:set-map-state" ||
1767
+ event.data?.schemaVersion !== 1 ||
1768
+ !threeDimensionalControl ||
1769
+ !treeControl
1770
+ ) return;
1771
+ const state = event.data.state;
1772
+ const nextVisibleLayerGroups = state?.visibleLayerGroups;
1773
+ const center = state?.center;
1774
+ const numericValues = {
1775
+ bearing: state?.bearing,
1776
+ lat: center?.[1],
1777
+ lng: center?.[0],
1778
+ pitch: state?.pitch,
1779
+ zoom: state?.zoom
1780
+ };
1781
+ if (
1782
+ !Array.isArray(center) ||
1783
+ center.length !== 2 ||
1784
+ Object.entries(numericValues).some(
1785
+ ([name, value]) =>
1786
+ !Number.isFinite(value) ||
1787
+ value < cameraRanges[name][0] ||
1788
+ value > cameraRanges[name][1]
1789
+ ) ||
1790
+ typeof state?.buildings3d !== "boolean" ||
1791
+ typeof state?.trees3d !== "boolean" ||
1792
+ (nextVisibleLayerGroups !== undefined &&
1793
+ (!Array.isArray(nextVisibleLayerGroups) ||
1794
+ new Set(nextVisibleLayerGroups).size !== nextVisibleLayerGroups.length ||
1795
+ nextVisibleLayerGroups.some(
1796
+ (group) => !previewLayerGroupIds.includes(group)
1797
+ )))
1798
+ ) return;
1799
+ threeDimensionalControl.setEnabled(state.buildings3d);
1800
+ treeControl.setEnabled(state.trees3d);
1801
+ if (nextVisibleLayerGroups) {
1802
+ setVisibleLayerGroups(map, nextVisibleLayerGroups);
1803
+ }
1804
+ map.jumpTo({
1805
+ bearing: numericValues.bearing,
1806
+ center,
1807
+ pitch: numericValues.pitch,
1808
+ zoom: numericValues.zoom
1809
+ });
1810
+ writeCameraToUrl(map);
1811
+ });
1812
+ }
1813
+
1814
+ function applyStatus(next) {
1815
+ badge.textContent = ["Tileflow preview", previewLabel, next.status].filter(Boolean).join(" \xB7 ");
1816
+ if (next.status === "invalid") {
1817
+ const diagnostics = next.diagnostics || [];
1818
+ status.textContent = diagnostics.map((item) =>
1819
+ (item.path ? item.path + ": " : "") + item.message
1820
+ ).join("\\n") || "Tileflow config is invalid.";
1821
+ status.style.display = "block";
1822
+ return;
1823
+ }
1824
+ status.style.display = "none";
1825
+ if (next.status === "ready" && next.generation > initialGeneration) {
1826
+ location.reload();
1827
+ }
1828
+ }
1829
+
1830
+ applyStatus(initialStatus);
1831
+ // Embedded previews share their host's stream. Two streams per tab can exhaust Chrome's
1832
+ // six HTTP/1 connections and leave even local module/font requests waiting indefinitely.
1833
+ let unsubscribePreviewStatus;
1834
+ try {
1835
+ if (window.parent !== window) unsubscribePreviewStatus = window.parent.__tileflowInspectorHost?.subscribePreviewStatus?.(window, applyStatus);
1836
+ } catch { /* Other embedders keep the standalone stream. */ }
1837
+ if (unsubscribePreviewStatus) {
1838
+ addEventListener("beforeunload", unsubscribePreviewStatus);
1839
+ } else {
1840
+ const events = new EventSource(${JSON.stringify(`${basePath}/__events`)});
1841
+ // Generations are monotonic only within one dev-server process. A source-code watcher can
1842
+ // restart that process at generation 1, so reconnecting after a live connection was lost
1843
+ // must refresh the document even when the numeric generation did not increase.
1844
+ let eventsConnected = false;
1845
+ let eventsDisconnected = false;
1846
+ events.addEventListener("open", () => {
1847
+ if (eventsConnected && eventsDisconnected) {
1848
+ location.reload();
1849
+ return;
1850
+ }
1851
+ eventsConnected = true;
1852
+ eventsDisconnected = false;
1853
+ });
1854
+ events.addEventListener("error", () => {
1855
+ if (eventsConnected) eventsDisconnected = true;
1856
+ });
1857
+ for (const eventName of ["building", "ready", "invalid"]) {
1858
+ events.addEventListener(eventName, (event) => applyStatus(JSON.parse(event.data)));
1859
+ }
1860
+ }
1861
+ </script>
1862
+ </body>
1863
+ </html>`;
1864
+ }
1865
+ function previewMapOptions(preview) {
1866
+ if (preview.camera.type === "center") {
1867
+ return {
1868
+ bearing: preview.camera.bearing,
1869
+ center: preview.camera.center,
1870
+ pitch: preview.camera.pitch,
1871
+ zoom: preview.camera.zoom
1872
+ };
1873
+ }
1874
+ const [west, south, east, north] = preview.camera.bounds;
1875
+ return {
1876
+ bearing: preview.camera.bearing,
1877
+ bounds: [
1878
+ [west, south],
1879
+ [east, north]
1880
+ ],
1881
+ fitBoundsOptions: { padding: preview.camera.padding },
1882
+ pitch: preview.camera.pitch
1883
+ };
1884
+ }
1885
+ function serializeInlineJson(value) {
1886
+ return JSON.stringify(value).replace(
1887
+ /[<>&\u2028\u2029]/gu,
1888
+ (character) => `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`
1889
+ );
1890
+ }
1891
+
1892
+ // src/preview.ts
1893
+ var TileflowPreviewSelectionError = class extends Error {
1894
+ code = "PREVIEW_SELECTION_INVALID";
1895
+ constructor(message) {
1896
+ super(message);
1897
+ this.name = "TileflowPreviewSelectionError";
1898
+ }
1899
+ };
1900
+ function resolveTileflowPreview(project, selection = {}) {
1901
+ if (selection.map !== void 0 && selection.scene !== void 0) {
1902
+ throw new TileflowPreviewSelectionError(
1903
+ "Choose either a Tileflow preview map or scene, not both."
1904
+ );
1905
+ }
1906
+ if (selection.scene !== void 0) {
1907
+ if (selection.theme !== void 0) {
1908
+ throw new TileflowPreviewSelectionError(
1909
+ "A committed Tileflow scene owns its concrete theme; do not combine scene and theme selection."
1910
+ );
1911
+ }
1912
+ const scene = Object.hasOwn(project.scenes ?? {}, selection.scene) ? project.scenes?.[selection.scene] : void 0;
1913
+ if (!scene) {
1914
+ throw new TileflowPreviewSelectionError(`Unknown Tileflow scene: ${selection.scene}`);
1915
+ }
1916
+ const normalized = normalizeTileflowCaptureScene(scene);
1917
+ if (normalized.target.kind === "application") {
1918
+ throw new TileflowPreviewSelectionError(
1919
+ `Tileflow scene "${selection.scene}" targets an application. Preview it through the application's development server.`
1920
+ );
1921
+ }
1922
+ if (!Object.hasOwn(project.maps, normalized.map)) {
1923
+ throw new TileflowPreviewSelectionError(
1924
+ `Tileflow scene "${selection.scene}" references an unknown map: ${normalized.map}`
1925
+ );
1926
+ }
1927
+ const map2 = parseResolvedTileflowMap(project.maps[normalized.map]);
1928
+ const selectedTheme2 = resolvePreviewTheme(map2, normalized.theme);
1929
+ return {
1930
+ camera: normalized.camera,
1931
+ label: `${normalized.map} / ${selectedTheme2.name} / ${selection.scene} \xB7 ${normalized.viewport.width}\xD7${normalized.viewport.height}`,
1932
+ mapName: normalized.map,
1933
+ themeName: selectedTheme2.name,
1934
+ viewport: normalized.viewport
1935
+ };
1936
+ }
1937
+ const mapName = selection.map ?? getFirstTileflowMapName(project);
1938
+ const map = Object.hasOwn(project.maps, mapName) ? project.maps[mapName] : void 0;
1939
+ if (!map) {
1940
+ throw new TileflowPreviewSelectionError(`Unknown Tileflow map: ${mapName}`);
1941
+ }
1942
+ const resolvedMap = parseResolvedTileflowMap(map);
1943
+ const selectedTheme = resolvePreviewTheme(resolvedMap, selection.theme);
1944
+ return {
1945
+ camera: {
1946
+ type: "center",
1947
+ center: resolvedMap.view?.center ? [resolvedMap.view.center[0], resolvedMap.view.center[1]] : [defaultTileflowRuntimeView.center[0], defaultTileflowRuntimeView.center[1]],
1948
+ zoom: resolvedMap.view?.zoom ?? defaultTileflowRuntimeView.zoom,
1949
+ bearing: resolvedMap.view?.bearing ?? defaultTileflowRuntimeView.bearing,
1950
+ pitch: resolvedMap.view?.pitch ?? defaultTileflowRuntimeView.pitch
1951
+ },
1952
+ label: `${mapName} / ${selectedTheme.name}`,
1953
+ mapName,
1954
+ themeName: selectedTheme.name,
1955
+ themeNames: Object.keys(resolvedMap.themes)
1956
+ };
1957
+ }
1958
+ function resolvePreviewTheme(map, requested) {
1959
+ try {
1960
+ return resolveThemeSelection(map, requested);
1961
+ } catch {
1962
+ const available = Object.keys(map.themes).sort().join(", ");
1963
+ throw new TileflowPreviewSelectionError(
1964
+ `Tileflow theme selection is invalid or unknown. Choose one of: ${available}.`
1965
+ );
1966
+ }
1967
+ }
1968
+ export {
1969
+ TileflowPreviewSelectionError,
1970
+ readBoundedTileflowJsonResponse,
1971
+ readBoundedTileflowResponse,
1972
+ renderTileflowPreviewHtml,
1973
+ resolveTileflowPreview
1974
+ };