@tileflow/dev 0.1.0-alpha.29 → 0.1.0-alpha.30

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.
@@ -1,4286 +0,0 @@
1
- import {
2
- getFirstTileflowMapName
3
- } from "./chunk-6MGFMKG3.js";
4
-
5
- // src/preview.ts
6
- import {
7
- defaultTileflowRuntimeView,
8
- normalizeTileflowCaptureScene,
9
- parseResolvedTileflowMap,
10
- resolveThemeSelection
11
- } from "@tileflow/core";
12
-
13
- // src/landmarks.ts
14
- async function readBoundedTileflowResponse(response, maximumBytes) {
15
- if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
16
- throw new Error("invalid response byte limit");
17
- }
18
- const declaredBytes = Number(response.headers.get("content-length"));
19
- if (Number.isFinite(declaredBytes) && declaredBytes > maximumBytes) {
20
- await response.body?.cancel("response exceeds byte limit");
21
- throw new Error("response exceeds byte limit");
22
- }
23
- if (!response.body) throw new Error("response body is unavailable");
24
- const reader = response.body.getReader();
25
- const chunks = [];
26
- let byteLength = 0;
27
- try {
28
- while (true) {
29
- const { done, value } = await reader.read();
30
- if (done) break;
31
- if (byteLength + value.byteLength > maximumBytes) {
32
- await reader.cancel("response exceeds byte limit");
33
- throw new Error("response exceeds byte limit");
34
- }
35
- chunks.push(value);
36
- byteLength += value.byteLength;
37
- }
38
- } finally {
39
- reader.releaseLock();
40
- }
41
- const result = new Uint8Array(byteLength);
42
- let offset = 0;
43
- for (const chunk of chunks) {
44
- result.set(chunk, offset);
45
- offset += chunk.byteLength;
46
- }
47
- return result;
48
- }
49
- async function readBoundedTileflowJsonResponse(response, maximumBytes) {
50
- const bytes = await readBoundedTileflowResponse(response, maximumBytes);
51
- return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
52
- }
53
- function normalizeTileflowLandmarkManifest(candidate, manifestUrl) {
54
- const value = candidate;
55
- const portableId = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
56
- const sha256 = /^[a-f0-9]{64}$/;
57
- const axisConventions = /* @__PURE__ */ new Set(["EUN_Y_UP", "EUS_Y_UP", "ENU_Z_UP"]);
58
- 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) {
59
- throw new Error("invalid landmark manifest");
60
- }
61
- const minzoom = value.minzoom;
62
- const maximumVisibleModels = value.maximumVisibleModels;
63
- const maximumCachedModels = value.maximumCachedModels === void 0 ? Math.min(128, Math.max(maximumVisibleModels, maximumVisibleModels * 2)) : value.maximumCachedModels;
64
- if (!Number.isInteger(maximumCachedModels) || maximumCachedModels < maximumVisibleModels || maximumCachedModels > 128) {
65
- throw new Error("invalid landmark cache limit");
66
- }
67
- const archiveIds = /* @__PURE__ */ new Set();
68
- const archiveMap = /* @__PURE__ */ new Map();
69
- for (const input of value.archives) {
70
- const archive = input;
71
- 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)) {
72
- throw new Error("invalid landmark archive");
73
- }
74
- const url = new URL(archive.url, manifestUrl);
75
- if (url.protocol !== "http:" && url.protocol !== "https:" || url.username !== "" || url.password !== "") {
76
- throw new Error("invalid landmark archive URL");
77
- }
78
- const normalized = {
79
- bytes: archive.bytes,
80
- id: archive.id,
81
- sha256: archive.sha256,
82
- url: url.toString()
83
- };
84
- archiveIds.add(archive.id);
85
- archiveMap.set(archive.id, normalized);
86
- }
87
- const landmarkIds = /* @__PURE__ */ new Set();
88
- const landmarks = value.landmarks.map((input) => {
89
- const landmark = input;
90
- const center = landmark?.center;
91
- const bounds = landmark?.bounds;
92
- 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) {
93
- throw new Error("invalid landmark entry");
94
- }
95
- landmarkIds.add(landmark.id);
96
- const models = landmark.models.map((inputModel) => {
97
- const model = inputModel;
98
- if (!model) throw new Error("invalid landmark model");
99
- const archive = typeof model.archiveId === "string" ? archiveMap.get(model.archiveId) : void 0;
100
- 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)) {
101
- throw new Error("invalid landmark model");
102
- }
103
- return {
104
- archive,
105
- archiveId: model.archiveId,
106
- axisConvention: model.axisConvention,
107
- bytes: model.bytes,
108
- key: [archive.sha256, model.z, model.x, model.y, model.sha256].join(":"),
109
- minzoom: model.minzoom,
110
- sha256: model.sha256,
111
- x: model.x,
112
- y: model.y,
113
- z: model.z
114
- };
115
- });
116
- models.sort((left, right) => left.minzoom - right.minzoom || left.key.localeCompare(right.key));
117
- if (new Set(models.map((model) => model.minzoom)).size !== models.length) {
118
- throw new Error("duplicate landmark LOD zoom");
119
- }
120
- if (models[0].minzoom !== minzoom) {
121
- throw new Error("landmark requires a base model");
122
- }
123
- return {
124
- bounds,
125
- center,
126
- id: landmark.id,
127
- models,
128
- priority: landmark.priority ?? 0
129
- };
130
- });
131
- return {
132
- archives: [...archiveMap.values()],
133
- id: value.id,
134
- landmarks,
135
- maximumCachedModels,
136
- maximumVisibleModels,
137
- minzoom,
138
- schemaVersion: 2
139
- };
140
- }
141
-
142
- // src/preview-html.ts
143
- function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticPreview, fontFaces = []) {
144
- const styleUrl = preview ? `${basePath}/styles/${preview.mapName}/${preview.themeName}.json` : void 0;
145
- const mapOptions = preview ? previewMapOptions(preview) : void 0;
146
- const viewportCss = preview?.viewport ? `
147
- html, body { min-height: 100%; margin: 0; }
148
- body {
149
- display: grid;
150
- place-items: center;
151
- overflow: auto;
152
- background: #E8E5DE;
153
- }
154
- #map {
155
- width: ${preview.viewport.width}px;
156
- height: ${preview.viewport.height}px;
157
- box-shadow: 0 24px 80px rgba(37, 34, 29, 0.18);
158
- }` : "html, body, #map { height: 100%; margin: 0; }";
159
- return `<!doctype html>
160
- <html lang="en">
161
- <head>
162
- <meta charset="utf-8" />
163
- <meta name="viewport" content="width=device-width, initial-scale=1" />
164
- <title>Tileflow Preview</title>
165
- <link rel="stylesheet" href="${basePath}/__runtime/maplibre-gl.css" />
166
- <style>
167
- ${viewportCss}
168
- body { font-family: ui-sans-serif, system-ui, sans-serif; }
169
- #map {
170
- --tileflow-globe-radius: 220px;
171
- background-color: var(--tileflow-space, #2F5070);
172
- background-image:
173
- radial-gradient(circle at 18% 24%, rgba(225, 240, 250, 0.34) 0 1px, transparent 1.4px),
174
- radial-gradient(circle at 72% 62%, rgba(225, 240, 250, 0.22) 0 1px, transparent 1.3px),
175
- radial-gradient(circle at 42% 78%, rgba(225, 240, 250, 0.18) 0 0.8px, transparent 1.2px);
176
- background-position: 0 0, 37px 71px, 113px 29px;
177
- background-size: 173px 149px, 257px 211px, 337px 283px;
178
- }
179
- #map.tileflow-globe {
180
- position: relative;
181
- background-image:
182
- radial-gradient(circle at 18% 24%, rgba(225, 240, 250, 0.34) 0 1px, transparent 1.4px),
183
- radial-gradient(circle at 72% 62%, rgba(225, 240, 250, 0.22) 0 1px, transparent 1.3px),
184
- radial-gradient(circle at 42% 78%, rgba(225, 240, 250, 0.18) 0 0.8px, transparent 1.2px);
185
- }
186
- #map.tileflow-globe::before {
187
- content: "";
188
- position: absolute;
189
- z-index: 0;
190
- left: 50%;
191
- top: 50%;
192
- width: calc(var(--tileflow-globe-radius) * 2);
193
- height: calc(var(--tileflow-globe-radius) * 2);
194
- border: 1px solid var(--tileflow-rim-line, rgba(248, 252, 255, 0.88));
195
- border-radius: 50%;
196
- box-shadow:
197
- 0 0 9px 3px var(--tileflow-rim-core, rgba(248, 252, 255, 0.9)),
198
- 0 0 28px 10px var(--tileflow-rim-glow, rgba(176, 220, 246, 0.52)),
199
- 0 0 58px 20px var(--tileflow-rim-haze, rgba(132, 195, 232, 0.2));
200
- pointer-events: none;
201
- transform: translate(-50%, -50%);
202
- }
203
- #map.tileflow-globe > .maplibregl-canvas-container {
204
- position: relative;
205
- z-index: 1;
206
- }
207
- .badge {
208
- display: none;
209
- position: fixed;
210
- left: 16px;
211
- top: 16px;
212
- z-index: 1;
213
- border-radius: 8px;
214
- background: rgba(246, 247, 243, 0.92);
215
- border: 1px solid rgba(60, 64, 67, 0.12);
216
- color: #3C4043;
217
- padding: 10px 12px;
218
- box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12);
219
- }
220
- .status {
221
- position: fixed;
222
- left: 16px;
223
- right: 16px;
224
- bottom: 16px;
225
- z-index: 2;
226
- display: none;
227
- max-width: 720px;
228
- border: 1px solid rgba(160, 46, 46, 0.35);
229
- border-radius: 8px;
230
- background: rgba(255, 244, 244, 0.96);
231
- color: #702020;
232
- padding: 12px;
233
- white-space: pre-wrap;
234
- }
235
- .maplibregl-ctrl-group .tileflow-3d-toggle,
236
- .maplibregl-ctrl-group .tileflow-tree-toggle,
237
- .maplibregl-ctrl-group .tileflow-theme-toggle {
238
- width: auto;
239
- min-width: 58px;
240
- padding: 0 9px;
241
- color: #3C4043;
242
- font: 700 11px/29px ui-sans-serif, system-ui, sans-serif;
243
- letter-spacing: 0.02em;
244
- }
245
- .maplibregl-ctrl-group .tileflow-3d-toggle[aria-pressed="true"],
246
- .maplibregl-ctrl-group .tileflow-tree-toggle[aria-pressed="true"] {
247
- background-color: #E8F0FE;
248
- color: #174EA6;
249
- }
250
- .maplibregl-ctrl-group .tileflow-3d-toggle:hover,
251
- .maplibregl-ctrl-group .tileflow-tree-toggle:hover,
252
- .maplibregl-ctrl-group .tileflow-theme-toggle:hover {
253
- background-color: #F1F3F4;
254
- }
255
- </style>
256
- </head>
257
- <body>
258
- <div class="badge" id="badge">Tileflow preview</div>
259
- <div class="status" id="status" role="status"></div>
260
- <div id="map"></div>
261
- <script type="importmap">{"imports":{"fflate":"${basePath}/__runtime/fflate.js","three":"${basePath}/__runtime/three.module.js"}}</script>
262
- <script type="module">
263
- import * as maplibregl from "${basePath}/__runtime/maplibre-gl.mjs";
264
- import {loadTileflowStyleFonts} from "${basePath}/__runtime/tileflow-browser.js";
265
- import {registerTileflowContourProtocol} from "${basePath}/__runtime/tileflow-browser.js";
266
- import {registerTileflowPmtilesProtocol} from "${basePath}/__runtime/tileflow-browser.js";
267
-
268
- maplibregl.setWorkerUrl("${basePath}/__runtime/maplibre-gl-worker.mjs");
269
-
270
- const initialStatus = ${JSON.stringify(initialStatus)};
271
- const normalizeTileflowLandmarkManifest = ${normalizeTileflowLandmarkManifest.toString()};
272
- const readBoundedTileflowResponse = ${readBoundedTileflowResponse.toString()};
273
- const readBoundedTileflowJsonResponse = ${readBoundedTileflowJsonResponse.toString()};
274
- const initialGeneration = initialStatus.generation;
275
- const badge = document.getElementById("badge");
276
- const status = document.getElementById("status");
277
- const previewLabel = ${JSON.stringify(preview?.label)};
278
- const styleUrl = ${JSON.stringify(styleUrl)};
279
- const previewMapOptions = ${JSON.stringify(mapOptions)};
280
- const previewTheme = ${serializeInlineJson(preview?.themeName ?? null)};
281
- const previewThemes = ${serializeInlineJson(preview?.themeNames ?? [])};
282
- const isSemanticPreview = ${JSON.stringify(isSemanticPreview)};
283
- const previewFontFaces = ${serializeInlineJson(fontFaces)};
284
- const treeSearchParameters = new URL(location.href).searchParams;
285
- const requestedTreeRenderer = treeSearchParameters.get("treeRenderer");
286
- const treeRendererMode = ["circle", "simple", "complex"].includes(
287
- requestedTreeRenderer
288
- ) ? requestedTreeRenderer : "circle";
289
- const treeBackendMode = treeSearchParameters.get("treeBackend") === "three"
290
- ? "three"
291
- : "webgl2";
292
- const treeBenchmarkEnabled = treeSearchParameters.get("treeBenchmark") === "1";
293
- const mapBenchmarkEnabled = treeSearchParameters.get("mapBenchmark") === "1";
294
- const mapSweepEnabled = treeSearchParameters.get("mapSweep") === "1";
295
- const mapCompareEnabled = treeSearchParameters.get("mapCompare") === "1";
296
- const requestedMapCompareLimit = Number(treeSearchParameters.get("mapCompareLimit"));
297
- const mapCompareLimit =
298
- Number.isInteger(requestedMapCompareLimit) &&
299
- requestedMapCompareLimit >= 1 &&
300
- requestedMapCompareLimit <= 64
301
- ? requestedMapCompareLimit
302
- : 16;
303
- const requestedMapWorkerCount = Number(treeSearchParameters.get("mapWorkers"));
304
- const mapWorkerCountOverride =
305
- Number.isInteger(requestedMapWorkerCount) &&
306
- requestedMapWorkerCount >= 1 &&
307
- requestedMapWorkerCount <= 3
308
- ? requestedMapWorkerCount
309
- : undefined;
310
- const mapBenchmarkLongitude = Number(treeSearchParameters.get("lng"));
311
- const mapBenchmarkLatitude = Number(treeSearchParameters.get("lat"));
312
- const mapBenchmarkCenter =
313
- treeSearchParameters.has("lng") &&
314
- treeSearchParameters.has("lat") &&
315
- Number.isFinite(mapBenchmarkLongitude) &&
316
- Number.isFinite(mapBenchmarkLatitude)
317
- ? [mapBenchmarkLongitude, mapBenchmarkLatitude]
318
- : undefined;
319
- function readToggleFromUrl(name, fallback) {
320
- const values = treeSearchParameters.getAll(name);
321
- if (values.length !== 1) return fallback;
322
- if (values[0] === "on") return true;
323
- if (values[0] === "off") return false;
324
- return fallback;
325
- }
326
- const treeFrameSamples = [];
327
- const treeMetrics = {
328
- buildMilliseconds: undefined,
329
- backend: treeBackendMode,
330
- frameP95Milliseconds: undefined,
331
- mode: treeRendererMode,
332
- queryMilliseconds: undefined,
333
- refreshMilliseconds: undefined,
334
- renderCalls: 0,
335
- renderMilliseconds: undefined,
336
- renderedTriangles: 0,
337
- selectionMilliseconds: undefined,
338
- terrainMilliseconds: undefined,
339
- treeCount: 0
340
- };
341
- globalThis.__tileflowTreeMetrics = treeMetrics;
342
- let threeDimensionalEnabled = readToggleFromUrl("buildings3d", false);
343
- let treesEnabled = readToggleFromUrl("trees3d", true);
344
- const landmarkState = {
345
- active: [],
346
- cacheLimit: undefined,
347
- enabled: threeDimensionalEnabled,
348
- manifestId: undefined,
349
- loaded: [],
350
- loading: [],
351
- errors: []
352
- };
353
- globalThis.__tileflowLandmarkState = landmarkState;
354
- const buildingWireframeMetrics = {
355
- buildingCount: 0,
356
- refreshMilliseconds: undefined,
357
- segmentCount: 0,
358
- truncated: false
359
- };
360
- globalThis.__tileflowBuildingWireframeMetrics = buildingWireframeMetrics;
361
- let THREE;
362
- let GLTFLoader;
363
- let DRACOLoader;
364
- let MeshoptDecoder;
365
- let LineMaterial;
366
- let LineSegments2;
367
- let LineSegmentsGeometry;
368
- let PMTiles;
369
- let FetchSource;
370
- let landmarkDracoLoader;
371
- let threeCoreRuntimePromise;
372
- let buildingWireframeRuntimePromise;
373
- let landmarkRuntimePromise;
374
- let pmtilesRuntimePromise;
375
- const landmarkManifestPromises = new Map();
376
- const landmarkManifestMaximumBytes = 1024 * 1024;
377
- const landmarkManifestTimeoutMs = 10000;
378
-
379
- function loadThreeCoreRuntime() {
380
- if (!threeCoreRuntimePromise) {
381
- threeCoreRuntimePromise = import("${basePath}/__runtime/three.module.js").then(
382
- (threeModule) => {
383
- THREE = threeModule;
384
- }
385
- );
386
- }
387
- return threeCoreRuntimePromise;
388
- }
389
-
390
- function loadBuildingWireframeRuntime() {
391
- if (!buildingWireframeRuntimePromise) {
392
- buildingWireframeRuntimePromise = Promise.all([
393
- loadThreeCoreRuntime(),
394
- import("${basePath}/__runtime/three-addons/lines/LineMaterial.js"),
395
- import("${basePath}/__runtime/three-addons/lines/LineSegments2.js"),
396
- import("${basePath}/__runtime/three-addons/lines/LineSegmentsGeometry.js")
397
- ]).then(([, lineMaterialModule, lineSegmentsModule, lineGeometryModule]) => {
398
- LineMaterial = lineMaterialModule.LineMaterial;
399
- LineSegments2 = lineSegmentsModule.LineSegments2;
400
- LineSegmentsGeometry = lineGeometryModule.LineSegmentsGeometry;
401
- });
402
- }
403
- return buildingWireframeRuntimePromise;
404
- }
405
-
406
- function loadLandmarkRuntime() {
407
- if (!landmarkRuntimePromise) {
408
- landmarkRuntimePromise = Promise.all([
409
- loadThreeCoreRuntime(),
410
- import("${basePath}/__runtime/three-addons/loaders/GLTFLoader.js"),
411
- import("${basePath}/__runtime/three-addons/loaders/DRACOLoader.js"),
412
- import("${basePath}/__runtime/three-addons/libs/meshopt_decoder.module.js"),
413
- loadPmtilesRuntime()
414
- ]).then(([, gltfLoaderModule, dracoLoaderModule, meshoptDecoderModule, pmtilesModule]) => {
415
- GLTFLoader = gltfLoaderModule.GLTFLoader;
416
- DRACOLoader = dracoLoaderModule.DRACOLoader;
417
- MeshoptDecoder = meshoptDecoderModule.MeshoptDecoder;
418
- PMTiles = pmtilesModule.PMTiles;
419
- FetchSource = pmtilesModule.FetchSource;
420
- landmarkDracoLoader = new DRACOLoader()
421
- .setDecoderPath("${basePath}/__runtime/three-addons/libs/draco/gltf/")
422
- .setDecoderConfig({type: "wasm"});
423
- landmarkDracoLoader.preload();
424
- });
425
- }
426
- return landmarkRuntimePromise;
427
- }
428
-
429
- function loadPmtilesRuntime() {
430
- if (!pmtilesRuntimePromise) {
431
- pmtilesRuntimePromise = import("${basePath}/__runtime/pmtiles.js").then((pmtilesModule) => {
432
- PMTiles = pmtilesModule.PMTiles;
433
- FetchSource = pmtilesModule.FetchSource;
434
- return pmtilesModule;
435
- });
436
- }
437
- return pmtilesRuntimePromise;
438
- }
439
-
440
- function waitForLandmarkManifest(promise, signal) {
441
- if (!signal) return promise;
442
- if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
443
- return new Promise((resolve, reject) => {
444
- const abort = () => reject(new DOMException("Aborted", "AbortError"));
445
- signal.addEventListener("abort", abort, {once: true});
446
- promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
447
- });
448
- }
449
-
450
- function loadLandmarkManifestCandidate(manifestUrl, signal) {
451
- const existing = landmarkManifestPromises.get(manifestUrl);
452
- if (existing) return waitForLandmarkManifest(existing, signal);
453
- const resolvedUrl = new URL(manifestUrl, location.href);
454
- if (
455
- !["http:", "https:"].includes(resolvedUrl.protocol) ||
456
- resolvedUrl.username ||
457
- resolvedUrl.password
458
- ) throw new Error("invalid landmark manifest URL");
459
- const controller = new AbortController();
460
- const timeout = setTimeout(() => controller.abort(), landmarkManifestTimeoutMs);
461
- const pending = (async () => {
462
- try {
463
- const response = await fetch(resolvedUrl, {
464
- credentials: "include",
465
- signal: controller.signal
466
- });
467
- if (!response.ok) throw new Error("HTTP " + response.status);
468
- return readBoundedTileflowJsonResponse(response, landmarkManifestMaximumBytes);
469
- } finally {
470
- clearTimeout(timeout);
471
- }
472
- })();
473
- landmarkManifestPromises.set(manifestUrl, pending);
474
- pending.catch(() => {
475
- if (landmarkManifestPromises.get(manifestUrl) === pending) {
476
- landmarkManifestPromises.delete(manifestUrl);
477
- }
478
- });
479
- return waitForLandmarkManifest(pending, signal);
480
- }
481
-
482
- function loadLandmarkPrerequisites(manifestUrl, signal) {
483
- return Promise.all([
484
- loadLandmarkRuntime(),
485
- loadLandmarkManifestCandidate(manifestUrl, signal)
486
- ]);
487
- }
488
-
489
- function percentile(values, percentileValue) {
490
- if (values.length === 0) return undefined;
491
- const sorted = [...values].sort((left, right) => left - right);
492
- return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)];
493
- }
494
-
495
- function updateTreeStatus(message, details = {}) {
496
- globalThis.__tileflowTreeState = {message, ...details};
497
- }
498
-
499
- if (treeBenchmarkEnabled) {
500
- let previousFrameTime;
501
- function sampleTreeFrame(frameTime) {
502
- if (previousFrameTime !== undefined) {
503
- treeFrameSamples.push(frameTime - previousFrameTime);
504
- }
505
- previousFrameTime = frameTime;
506
- if (treeFrameSamples.length < 360) {
507
- requestAnimationFrame(sampleTreeFrame);
508
- return;
509
- }
510
- treeMetrics.frameP95Milliseconds = percentile(treeFrameSamples, 0.95);
511
- }
512
- requestAnimationFrame(sampleTreeFrame);
513
- }
514
-
515
- function installMapBenchmark(map) {
516
- if (!mapBenchmarkEnabled) return;
517
- let activeRun;
518
- const metrics = {
519
- activeLayers: 0,
520
- bucketCount: 0,
521
- bucketTypes: {},
522
- errors: [],
523
- frameMaximumMilliseconds: 0,
524
- frameP95Milliseconds: 0,
525
- frames: 0,
526
- idleMilliseconds: 0,
527
- layerTypes: {},
528
- renderedFeatures: 0,
529
- resourceCount: 0,
530
- resourceDecodedBytes: 0,
531
- resourceDurationMaximumMilliseconds: 0,
532
- resourceEncodedBytes: 0,
533
- resourceServerTiming: {},
534
- resourceTransferBytes: 0,
535
- requestedZoom: map.getZoom(),
536
- sourceDataEvents: 0,
537
- timedOut: false,
538
- visibleDecodedTileBytes: 0,
539
- visibleTileCoordinates: [],
540
- visibleTiles: 0,
541
- zoom: map.getZoom()
542
- };
543
-
544
- function inspectRenderBuckets() {
545
- const tileManagers = map.style?.tileManagers || {};
546
- const bucketTypes = {};
547
- const coordinates = new Map();
548
- const resourceTiming = [];
549
- const visitedTiles = new Set();
550
- let bucketCount = 0;
551
- let visibleDecodedTileBytes = 0;
552
- for (const [sourceId, tileManager] of Object.entries(tileManagers)) {
553
- const tiles = tileManager?._inViewTiles?.getAllTiles?.() || [];
554
- for (const tile of tiles) {
555
- const tileKey = sourceId + ":" + (tile.uid ?? tile.tileID?.key);
556
- if (visitedTiles.has(tileKey)) continue;
557
- visitedTiles.add(tileKey);
558
- const buckets = Object.values(tile.buckets || {});
559
- bucketCount += buckets.length;
560
- for (const bucket of buckets) {
561
- const type = bucket?.type || bucket?.constructor?.name || "unknown";
562
- bucketTypes[type] = (bucketTypes[type] || 0) + 1;
563
- }
564
- visibleDecodedTileBytes += tile.latestRawTileData?.byteLength || 0;
565
- if (tile.resourceTiming) resourceTiming.push(...tile.resourceTiming);
566
- const canonical = tile.tileID?.canonical;
567
- if (canonical && canonical.z <= 24) {
568
- const coordinate = {z: canonical.z, x: canonical.x, y: canonical.y};
569
- coordinates.set(coordinate.z + "/" + coordinate.x + "/" + coordinate.y, coordinate);
570
- }
571
- }
572
- }
573
- return {
574
- bucketCount,
575
- bucketTypes,
576
- resourceTiming,
577
- visibleDecodedTileBytes,
578
- visibleTileCoordinates: [...coordinates.values()],
579
- visibleTiles: visitedTiles.size
580
- };
581
- }
582
-
583
- function uniqueResources(resources) {
584
- const unique = new Map();
585
- for (const resource of resources) {
586
- const key = [
587
- resource.name,
588
- resource.startTime,
589
- resource.encodedBodySize,
590
- resource.decodedBodySize
591
- ].join("|");
592
- unique.set(key, resource);
593
- }
594
- return [...unique.values()];
595
- }
596
-
597
- function summarizeServerTiming(resources) {
598
- const totals = {};
599
- for (const resource of resources) {
600
- for (const timing of resource.serverTiming || []) {
601
- const current = totals[timing.name] || {count: 0, maximumMilliseconds: 0, totalMilliseconds: 0};
602
- current.count += 1;
603
- current.maximumMilliseconds = Math.max(current.maximumMilliseconds, timing.duration || 0);
604
- current.totalMilliseconds += timing.duration || 0;
605
- totals[timing.name] = current;
606
- }
607
- }
608
- for (const timing of Object.values(totals)) {
609
- timing.averageMilliseconds = timing.count ? timing.totalMilliseconds / timing.count : 0;
610
- }
611
- return totals;
612
- }
613
-
614
- function sampleMapFrame(frameTime) {
615
- if (activeRun) {
616
- if (activeRun.previousFrameTime !== undefined) {
617
- activeRun.frameSamples.push(frameTime - activeRun.previousFrameTime);
618
- }
619
- activeRun.previousFrameTime = frameTime;
620
- }
621
- requestAnimationFrame(sampleMapFrame);
622
- }
623
-
624
- map.on("sourcedata", (event) => {
625
- if (!activeRun) return;
626
- activeRun.sourceDataEvents += 1;
627
- if (event.resourceTiming) activeRun.workerResources.push(...event.resourceTiming);
628
- });
629
- map.on("error", (event) => {
630
- if (!activeRun) return;
631
- const message = String(event?.error?.message || event?.message || "MapLibre error");
632
- if (!activeRun.errors.includes(message) && activeRun.errors.length < 8) {
633
- activeRun.errors.push(message);
634
- }
635
- });
636
- requestAnimationFrame(sampleMapFrame);
637
-
638
- function finishMapBenchmark(run, timedOut, resolve) {
639
- if (activeRun !== run) return;
640
- activeRun = undefined;
641
- clearTimeout(run.timeout);
642
- map.off("idle", run.handleIdle);
643
- const zoom = map.getZoom();
644
- const styleLayers = map.getStyle()?.layers || [];
645
- const activeLayers = styleLayers.filter((layer) =>
646
- (layer.minzoom === undefined || layer.minzoom <= zoom) &&
647
- (layer.maxzoom === undefined || zoom < layer.maxzoom) &&
648
- layer.layout?.visibility !== "none"
649
- );
650
- const layerTypes = {};
651
- for (const layer of activeLayers) {
652
- layerTypes[layer.type] = (layerTypes[layer.type] || 0) + 1;
653
- }
654
- const renderState = inspectRenderBuckets();
655
- const {resourceTiming, ...renderMetrics} = renderState;
656
- const resources = uniqueResources([
657
- ...performance.getEntriesByType("resource").filter(
658
- (entry) => entry.startTime >= run.startedAt
659
- ),
660
- ...run.workerResources,
661
- ...resourceTiming
662
- ]);
663
- const frameP95 = percentile(run.frameSamples, 0.95) || 0;
664
- Object.assign(metrics, {
665
- activeLayers: activeLayers.length,
666
- errors: run.errors,
667
- frameMaximumMilliseconds: Math.max(0, ...run.frameSamples),
668
- frameP95Milliseconds: frameP95,
669
- frames: run.frameSamples.length,
670
- idleMilliseconds: performance.now() - run.startedAt,
671
- layerTypes,
672
- renderedFeatures: map.queryRenderedFeatures().length,
673
- resourceCount: resources.length,
674
- resourceDecodedBytes: resources.reduce(
675
- (total, entry) => total + (entry.decodedBodySize || 0),
676
- 0
677
- ),
678
- resourceDurationMaximumMilliseconds: Math.max(
679
- 0,
680
- ...resources.map((entry) => entry.duration || 0)
681
- ),
682
- resourceEncodedBytes: resources.reduce(
683
- (total, entry) => total + (entry.encodedBodySize || 0),
684
- 0
685
- ),
686
- resourceServerTiming: summarizeServerTiming(resources),
687
- resourceTransferBytes: resources.reduce(
688
- (total, entry) => total + (entry.transferSize || 0),
689
- 0
690
- ),
691
- requestedZoom: run.requestedZoom,
692
- sourceDataEvents: run.sourceDataEvents,
693
- timedOut,
694
- ...renderMetrics,
695
- zoom
696
- });
697
- resolve({...metrics});
698
- }
699
-
700
- function settleZoom(zoom) {
701
- if (!Number.isFinite(zoom) || zoom < 0 || zoom > 22) {
702
- return Promise.reject(new Error("Benchmark zoom must be between 0 and 22."));
703
- }
704
- if (activeRun) {
705
- return Promise.reject(new Error("A map benchmark run is already active."));
706
- }
707
- return new Promise((resolve) => {
708
- const run = {
709
- errors: [],
710
- frameSamples: [],
711
- handleIdle: undefined,
712
- previousFrameTime: undefined,
713
- requestedZoom: zoom,
714
- sourceDataEvents: 0,
715
- startedAt: performance.now(),
716
- timeout: undefined,
717
- workerResources: []
718
- };
719
- run.handleIdle = () => requestAnimationFrame(() =>
720
- requestAnimationFrame(() => finishMapBenchmark(run, false, resolve))
721
- );
722
- run.timeout = setTimeout(() => finishMapBenchmark(run, true, resolve), 12000);
723
- activeRun = run;
724
- map.on("idle", run.handleIdle);
725
- map.jumpTo({
726
- ...(mapBenchmarkCenter ? {center: mapBenchmarkCenter} : {}),
727
- zoom
728
- });
729
- });
730
- }
731
-
732
- function resolveTileTemplate(template, coordinate) {
733
- return template
734
- .replace("{z}", String(coordinate.z))
735
- .replace("{x}", String(coordinate.x))
736
- .replace("{y}", String(coordinate.y))
737
- .replace("{-y}", String((2 ** coordinate.z) - coordinate.y - 1));
738
- }
739
-
740
- async function digest(bytes) {
741
- const hash = await crypto.subtle.digest("SHA-256", bytes);
742
- return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
743
- }
744
-
745
- async function fetchComparableTile(url, credentials) {
746
- const comparisonTileMaximumBytes = 16 * 1024 * 1024;
747
- const startedAt = performance.now();
748
- const response = await fetch(url, {cache: "no-store", credentials});
749
- if (!response.ok) throw new Error("Tile comparison request failed (" + response.status + "): " + url);
750
- const encodedHeaderValue =
751
- response.headers.get("x-tileflow-compressed-bytes") ||
752
- response.headers.get("content-length");
753
- const encodedHeader = encodedHeaderValue === null
754
- ? undefined
755
- : Number(encodedHeaderValue);
756
- const bytes = await readBoundedTileflowResponse(
757
- response,
758
- comparisonTileMaximumBytes
759
- );
760
- const contentEncoding = response.headers.get("content-encoding");
761
- return {
762
- archive: response.headers.get("x-tileflow-archive") || undefined,
763
- compressed: contentEncoding?.includes("gzip") || false,
764
- contentEncoding: contentEncoding || undefined,
765
- contentLength: Number.isFinite(encodedHeader) ? encodedHeader : undefined,
766
- decodedBytes: bytes.byteLength,
767
- decodedSha256: await digest(bytes),
768
- durationMilliseconds: performance.now() - startedAt,
769
- encodedBytes: Number.isFinite(encodedHeader) ? encodedHeader : undefined,
770
- url
771
- };
772
- }
773
-
774
- async function compareRemote(options = {}) {
775
- const requestedLimit = Number(options.limit ?? 16);
776
- const limit = Number.isInteger(requestedLimit) && requestedLimit >= 1 && requestedLimit <= 64
777
- ? requestedLimit
778
- : 16;
779
- const source = Object.values(map.getStyle()?.sources || {}).find(
780
- (candidate) => candidate.type === "vector" && candidate.url?.includes("/tiles.json")
781
- );
782
- if (!source?.url) throw new Error("No vector TileJSON source is active in the map.");
783
- const localTileJsonUrl = new URL(source.url, location.href);
784
- const comparisonUrl = new URL("/comparison.json", localTileJsonUrl);
785
- const contractResponse = await fetch(comparisonUrl, {cache: "no-store"});
786
- if (!contractResponse.ok) throw new Error("The local comparison contract is unavailable.");
787
- const contract = await readBoundedTileflowJsonResponse(contractResponse, 64 * 1024);
788
- const remoteTileJsonUrl = options.remoteTileJson || contract.remoteTileJson;
789
- if (!remoteTileJsonUrl) throw new Error("No remote TileJSON comparison target is configured.");
790
- const remoteTileJsonResponse = await fetch(remoteTileJsonUrl, {
791
- cache: "no-store",
792
- credentials: "include"
793
- });
794
- if (!remoteTileJsonResponse.ok) {
795
- throw new Error("Remote TileJSON request failed (" + remoteTileJsonResponse.status + ").");
796
- }
797
- const remoteTileJson = await readBoundedTileflowJsonResponse(
798
- remoteTileJsonResponse,
799
- 256 * 1024
800
- );
801
- const remoteTemplate = remoteTileJson.tiles?.[0];
802
- if (!remoteTemplate) throw new Error("Remote TileJSON has no tile template.");
803
- const coordinates = inspectRenderBuckets().visibleTileCoordinates.slice(0, limit);
804
- if (coordinates.length === 0) throw new Error("No visible vector tiles are available to compare.");
805
-
806
- const perTile = [];
807
- for (const coordinate of coordinates) {
808
- const localUrl = resolveTileTemplate(contract.localTileTemplate, coordinate);
809
- const remoteUrl = resolveTileTemplate(remoteTemplate, coordinate);
810
- const [local, remote] = await Promise.all([
811
- fetchComparableTile(localUrl, "omit"),
812
- fetchComparableTile(remoteUrl, "include")
813
- ]);
814
- perTile.push({
815
- coordinate,
816
- identicalDecodedPayload: local.decodedSha256 === remote.decodedSha256,
817
- local,
818
- remote
819
- });
820
- }
821
- const sum = (side, field) => perTile.reduce(
822
- (total, tile) => total + (tile[side][field] || 0),
823
- 0
824
- );
825
- const identicalTiles = perTile.filter((tile) => tile.identicalDecodedPayload).length;
826
- return {
827
- archive: contract.archive,
828
- identicalDecodedPercent: (identicalTiles / perTile.length) * 100,
829
- identicalDecodedTiles: identicalTiles,
830
- local: {
831
- decodedBytes: sum("local", "decodedBytes"),
832
- encodedBytes: sum("local", "encodedBytes"),
833
- durationMilliseconds: sum("local", "durationMilliseconds")
834
- },
835
- perTile,
836
- remote: {
837
- decodedBytes: sum("remote", "decodedBytes"),
838
- encodedBytes: sum("remote", "encodedBytes"),
839
- durationMilliseconds: sum("remote", "durationMilliseconds"),
840
- tileJson: remoteTileJsonUrl
841
- },
842
- tilesCompared: perTile.length
843
- };
844
- }
845
-
846
- globalThis.__tileflowMapBenchmark = {compareRemote, metrics, settleZoom};
847
- if (mapCompareEnabled) {
848
- document.body.dataset.tileflowMapCompareStatus = "running";
849
- const handleInitialMapCompareIdle = async () => {
850
- map.off("idle", handleInitialMapCompareIdle);
851
- try {
852
- document.body.dataset.tileflowMapCompare = JSON.stringify(
853
- await compareRemote({limit: mapCompareLimit})
854
- );
855
- document.body.dataset.tileflowMapCompareStatus = "complete";
856
- } catch (error) {
857
- document.body.dataset.tileflowMapCompare = JSON.stringify({
858
- error: String(error?.message || error)
859
- });
860
- document.body.dataset.tileflowMapCompareStatus = "failed";
861
- }
862
- };
863
- map.on("idle", handleInitialMapCompareIdle);
864
- }
865
- if (mapSweepEnabled) {
866
- document.body.dataset.tileflowMapSweepStatus = "running";
867
- map.once("load", async () => {
868
- const results = [];
869
- try {
870
- const zooms = new Set(Array.from({length: 23}, (_, zoom) => zoom));
871
- for (const layer of map.getStyle()?.layers || []) {
872
- for (const boundary of [layer.minzoom, layer.maxzoom]) {
873
- if (!Number.isFinite(boundary)) continue;
874
- for (const sample of [boundary - 0.01, boundary, boundary + 0.01]) {
875
- if (sample >= 0 && sample <= 22) zooms.add(sample);
876
- }
877
- }
878
- }
879
- for (const zoom of [...zooms].sort((left, right) => left - right)) {
880
- const result = await settleZoom(zoom);
881
- results.push(result);
882
- if (result.timedOut) {
883
- throw new Error("Map sweep timed out at zoom " + zoom + ".");
884
- }
885
- if (result.errors.length > 0) {
886
- throw new Error(
887
- "Map sweep reported an error at zoom " + zoom + ": " + result.errors[0]
888
- );
889
- }
890
- }
891
- document.body.dataset.tileflowMapSweep = JSON.stringify(results);
892
- document.body.dataset.tileflowMapSweepStatus = "complete";
893
- } catch (error) {
894
- document.body.dataset.tileflowMapSweep = JSON.stringify({
895
- error: String(error?.message || error),
896
- results
897
- });
898
- document.body.dataset.tileflowMapSweepStatus = "failed";
899
- }
900
- });
901
- }
902
- }
903
-
904
- const cameraRanges = {
905
- bearing: [-180, 180],
906
- lat: [-90, 90],
907
- lng: [-180, 180],
908
- pitch: [0, 85],
909
- zoom: [0, 24]
910
- };
911
-
912
- function readCameraFromUrl() {
913
- const params = new URL(location.href).searchParams;
914
- const camera = {};
915
-
916
- for (const [name, range] of Object.entries(cameraRanges)) {
917
- const values = params.getAll(name);
918
- if (values.length !== 1 || values[0].trim() === "") return undefined;
919
- const value = Number(values[0]);
920
- if (!Number.isFinite(value) || value < range[0] || value > range[1]) return undefined;
921
- camera[name] = value;
922
- }
923
-
924
- return {
925
- bearing: camera.bearing,
926
- center: [camera.lng, camera.lat],
927
- pitch: camera.pitch,
928
- zoom: camera.zoom
929
- };
930
- }
931
-
932
- function resolveInitialMapOptions(options) {
933
- const camera = readCameraFromUrl();
934
- if (!camera) return options;
935
- const resolved = {...options};
936
- delete resolved.bounds;
937
- delete resolved.fitBoundsOptions;
938
- return {...resolved, ...camera};
939
- }
940
-
941
- function formatCameraNumber(value) {
942
- return String(Number(value.toFixed(6)));
943
- }
944
-
945
- function wrapLongitude(value) {
946
- return ((value + 180) % 360 + 360) % 360 - 180;
947
- }
948
-
949
- function writeCameraToUrl(map) {
950
- const center = map.getCenter();
951
- const url = new URL(location.href);
952
- const camera = {
953
- bearing: map.getBearing(),
954
- lat: center.lat,
955
- lng: wrapLongitude(center.lng),
956
- pitch: map.getPitch(),
957
- zoom: map.getZoom()
958
- };
959
-
960
- for (const [name, value] of Object.entries(camera)) {
961
- url.searchParams.set(name, formatCameraNumber(value));
962
- }
963
- url.searchParams.set("buildings3d", threeDimensionalEnabled ? "on" : "off");
964
- url.searchParams.set("trees3d", treesEnabled ? "on" : "off");
965
- history.replaceState(history.state, "", url.href);
966
- }
967
-
968
- function writeToggleStateToUrl() {
969
- const url = new URL(location.href);
970
- url.searchParams.set("buildings3d", threeDimensionalEnabled ? "on" : "off");
971
- url.searchParams.set("trees3d", treesEnabled ? "on" : "off");
972
- history.replaceState(history.state, "", url.href);
973
- }
974
-
975
- function updateGlobeBackdrop(map) {
976
- const projection = map.getProjection?.()?.type ?? map.getStyle?.()?.projection?.type;
977
- const container = map.getContainer?.();
978
- if (!container || !projection) return;
979
- if (projection !== "globe") {
980
- container.classList.remove("tileflow-globe");
981
- return;
982
- }
983
-
984
- // A Web Mercator world is 512 * 2^zoom pixels wide. Dividing its
985
- // circumference by 2\u03C0 closely tracks MapLibre's on-screen globe radius.
986
- const radius = Math.min(
987
- 4096,
988
- ((512 * Math.pow(2, map.getZoom())) / (2 * Math.PI)) * 0.91
989
- );
990
- container.classList.add("tileflow-globe");
991
- container.style.setProperty("--tileflow-globe-radius", radius.toFixed(2) + "px");
992
- applyAtmosphereBackdrop(map, container);
993
- }
994
-
995
- // Uses the map's declared atmosphere for the preview's space and rim, so a themed globe shows
996
- // its own colours; maps without one keep the default backdrop.
997
- function applyAtmosphereBackdrop(map, container) {
998
- const atmosphere = map.getStyle?.()?.metadata?.["tileflow:atmosphere"];
999
- const rgba = (hex, alpha) => {
1000
- const match = /^#([0-9a-f]{6})$/i.exec(String(hex ?? ""));
1001
- if (!match) return undefined;
1002
- const value = Number.parseInt(match[1], 16);
1003
- return "rgba(" + (value >> 16) + ", " + ((value >> 8) & 255) + ", " + (value & 255) + ", " + alpha + ")";
1004
- };
1005
- const properties = {
1006
- "--tileflow-space": rgba(atmosphere?.spaceColor, 1),
1007
- "--tileflow-rim-line": rgba(atmosphere?.horizonColor, 0.88),
1008
- "--tileflow-rim-core": rgba(atmosphere?.horizonColor, 0.9),
1009
- "--tileflow-rim-glow": rgba(atmosphere?.horizonColor, 0.52),
1010
- "--tileflow-rim-haze": rgba(atmosphere?.skyColor, 0.35),
1011
- };
1012
- for (const [name, value] of Object.entries(properties)) {
1013
- if (value) container.style.setProperty(name, value);
1014
- else container.style.removeProperty(name);
1015
- }
1016
- }
1017
-
1018
- function createBuildingWireframeLayer(map, styleLayer) {
1019
- const maximumBuildings = 10000;
1020
- const maximumSegments = 300000;
1021
- const viewportBufferPixels = 96;
1022
- const sourceId = styleLayer.source;
1023
- const metadata = styleLayer.metadata ?? {};
1024
- const heightField = metadata["tileflow:building-wireframe-height-field"] ??
1025
- "render_height";
1026
- const baseField = metadata["tileflow:building-wireframe-base-field"] ??
1027
- "render_min_height";
1028
- const configuredHeightScale = Number(
1029
- metadata["tileflow:building-wireframe-height-scale"] ?? 1
1030
- );
1031
- const heightScale = Number.isFinite(configuredHeightScale) && configuredHeightScale > 0
1032
- ? configuredHeightScale
1033
- : 1;
1034
- const color = metadata["tileflow:building-wireframe-color"] ?? "#43E4FF";
1035
- const glowColor = metadata["tileflow:building-wireframe-glow-color"] ?? "#147DFF";
1036
- const configuredOpacity = Number(
1037
- metadata["tileflow:building-wireframe-opacity"] ?? 0.76
1038
- );
1039
- const opacity = Number.isFinite(configuredOpacity)
1040
- ? Math.max(0, Math.min(1, configuredOpacity))
1041
- : 0.76;
1042
- const configuredWidth = Number(
1043
- metadata["tileflow:building-wireframe-width"] ?? 1.15
1044
- );
1045
- const width = Number.isFinite(configuredWidth) && configuredWidth > 0
1046
- ? configuredWidth
1047
- : 1.15;
1048
- const configuredGlowOpacity = Number(
1049
- metadata["tileflow:building-wireframe-glow-opacity"] ?? 0.11
1050
- );
1051
- const glowOpacity = Number.isFinite(configuredGlowOpacity)
1052
- ? Math.max(0, Math.min(1, configuredGlowOpacity))
1053
- : 0.11;
1054
- const configuredGlowWidth = Number(
1055
- metadata["tileflow:building-wireframe-glow-width"] ?? 3.5
1056
- );
1057
- const glowWidth = Number.isFinite(configuredGlowWidth) && configuredGlowWidth > width
1058
- ? configuredGlowWidth
1059
- : Math.max(3.5, width * 3);
1060
- let camera;
1061
- let renderer;
1062
- let scene;
1063
- let group;
1064
- let geometry;
1065
- let glowLines;
1066
- let coreLines;
1067
- let glowMaterial;
1068
- let coreMaterial;
1069
- let sceneOriginMercator;
1070
- let refreshTimer;
1071
- let emptyRefreshAttempts = 0;
1072
- let lastGeometrySignature;
1073
- const mapMatrix = new THREE.Matrix4();
1074
- const sceneMatrix = new THREE.Matrix4();
1075
-
1076
- function geometryPolygons(candidate) {
1077
- if (candidate?.type === "Polygon") return [candidate.coordinates];
1078
- if (candidate?.type === "MultiPolygon") return candidate.coordinates;
1079
- return [];
1080
- }
1081
-
1082
- function ringIntersectsViewport(ring, viewportWidth, viewportHeight) {
1083
- if (!Array.isArray(ring) || ring.length < 3) return false;
1084
- let minimumX = Infinity;
1085
- let minimumY = Infinity;
1086
- let maximumX = -Infinity;
1087
- let maximumY = -Infinity;
1088
- for (const coordinate of ring) {
1089
- const longitude = Number(coordinate?.[0]);
1090
- const latitude = Number(coordinate?.[1]);
1091
- if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return false;
1092
- const point = map.project([longitude, latitude]);
1093
- if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return false;
1094
- minimumX = Math.min(minimumX, point.x);
1095
- minimumY = Math.min(minimumY, point.y);
1096
- maximumX = Math.max(maximumX, point.x);
1097
- maximumY = Math.max(maximumY, point.y);
1098
- }
1099
- return (
1100
- maximumX >= -viewportBufferPixels &&
1101
- minimumX <= viewportWidth + viewportBufferPixels &&
1102
- maximumY >= -viewportBufferPixels &&
1103
- minimumY <= viewportHeight + viewportBufferPixels
1104
- );
1105
- }
1106
-
1107
- function ringVertexCount(ring) {
1108
- if (!Array.isArray(ring) || ring.length < 3) return 0;
1109
- const first = ring[0];
1110
- const last = ring.at(-1);
1111
- const closes =
1112
- Number(first?.[0]) === Number(last?.[0]) &&
1113
- Number(first?.[1]) === Number(last?.[1]);
1114
- return closes ? ring.length - 1 : ring.length;
1115
- }
1116
-
1117
- function buildingKey(feature, rings, height, base) {
1118
- let pointCount = 0;
1119
- let minimumLongitude = Infinity;
1120
- let minimumLatitude = Infinity;
1121
- let maximumLongitude = -Infinity;
1122
- let maximumLatitude = -Infinity;
1123
- for (const ring of rings) {
1124
- for (const coordinate of ring) {
1125
- const longitude = Number(coordinate?.[0]);
1126
- const latitude = Number(coordinate?.[1]);
1127
- if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) continue;
1128
- pointCount += 1;
1129
- minimumLongitude = Math.min(minimumLongitude, longitude);
1130
- minimumLatitude = Math.min(minimumLatitude, latitude);
1131
- maximumLongitude = Math.max(maximumLongitude, longitude);
1132
- maximumLatitude = Math.max(maximumLatitude, latitude);
1133
- }
1134
- }
1135
- const properties = feature.properties ?? {};
1136
- const sourceFeatureId =
1137
- feature.id ?? properties.osm_id ?? properties.osmId ?? properties.id ?? "geometry";
1138
- return [
1139
- sourceFeatureId,
1140
- pointCount,
1141
- minimumLongitude.toFixed(6),
1142
- minimumLatitude.toFixed(6),
1143
- maximumLongitude.toFixed(6),
1144
- maximumLatitude.toFixed(6),
1145
- height.toFixed(2),
1146
- base.toFixed(2)
1147
- ].join(":");
1148
- }
1149
-
1150
- function appendSegment(positions, left, right) {
1151
- positions.push(left[0], left[1], left[2], right[0], right[1], right[2]);
1152
- }
1153
-
1154
- function appendRing(positions, ring, base, height, mercatorPerMeter) {
1155
- if (!Array.isArray(ring) || ring.length < 3) return 0;
1156
- const first = ring[0];
1157
- const last = ring.at(-1);
1158
- const closes =
1159
- Number(first?.[0]) === Number(last?.[0]) &&
1160
- Number(first?.[1]) === Number(last?.[1]);
1161
- const vertexCount = closes ? ring.length - 1 : ring.length;
1162
- if (vertexCount < 3) return 0;
1163
- if (positions.length / 6 + vertexCount * 3 > maximumSegments) return 0;
1164
- const lower = [];
1165
- const upper = [];
1166
- for (let index = 0; index < vertexCount; index += 1) {
1167
- const longitude = Number(ring[index]?.[0]);
1168
- const latitude = Number(ring[index]?.[1]);
1169
- if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return 0;
1170
- const mercator = maplibregl.MercatorCoordinate.fromLngLat([longitude, latitude]);
1171
- const x = (mercator.x - sceneOriginMercator.x) / mercatorPerMeter;
1172
- const y = (mercator.y - sceneOriginMercator.y) / mercatorPerMeter;
1173
- lower.push([x, y, base]);
1174
- upper.push([x, y, height]);
1175
- }
1176
- for (let index = 0; index < vertexCount; index += 1) {
1177
- const next = (index + 1) % vertexCount;
1178
- appendSegment(positions, lower[index], lower[next]);
1179
- appendSegment(positions, upper[index], upper[next]);
1180
- appendSegment(positions, lower[index], upper[index]);
1181
- }
1182
- return vertexCount * 3;
1183
- }
1184
-
1185
- function replaceGeometry(positions) {
1186
- const nextGeometry = new LineSegmentsGeometry();
1187
- nextGeometry.setPositions(new Float32Array(positions));
1188
- nextGeometry.computeBoundingSphere();
1189
- if (!glowLines) {
1190
- glowLines = new LineSegments2(nextGeometry, glowMaterial);
1191
- coreLines = new LineSegments2(nextGeometry, coreMaterial);
1192
- glowLines.frustumCulled = false;
1193
- coreLines.frustumCulled = false;
1194
- group.add(glowLines, coreLines);
1195
- } else {
1196
- glowLines.geometry = nextGeometry;
1197
- coreLines.geometry = nextGeometry;
1198
- geometry?.dispose();
1199
- }
1200
- geometry = nextGeometry;
1201
- }
1202
-
1203
- function wireframeShouldBeVisible() {
1204
- return (
1205
- threeDimensionalEnabled &&
1206
- visibleLayerGroups.has("buildings") &&
1207
- styleLayerIsVisibleAtZoom(styleLayer, map.getZoom()) &&
1208
- map.getLayoutProperty(styleLayer.id, "visibility") !== "none"
1209
- );
1210
- }
1211
-
1212
- function hideWireframe() {
1213
- if (group) group.visible = false;
1214
- buildingWireframeMetrics.buildingCount = 0;
1215
- buildingWireframeMetrics.refreshMilliseconds = undefined;
1216
- buildingWireframeMetrics.segmentCount = 0;
1217
- buildingWireframeMetrics.truncated = false;
1218
- }
1219
-
1220
- function refresh() {
1221
- const startedAt = performance.now();
1222
- refreshTimer = undefined;
1223
- if (!wireframeShouldBeVisible() || map.isMoving()) {
1224
- hideWireframe();
1225
- return;
1226
- }
1227
- const canvas = map.getCanvas();
1228
- const viewportWidth = canvas.clientWidth || canvas.width;
1229
- const viewportHeight = canvas.clientHeight || canvas.height;
1230
- let features;
1231
- try {
1232
- features = map.queryRenderedFeatures(
1233
- [
1234
- [-viewportBufferPixels, -viewportBufferPixels],
1235
- [
1236
- viewportWidth + viewportBufferPixels,
1237
- viewportHeight + viewportBufferPixels
1238
- ]
1239
- ],
1240
- {layers: [styleLayer.id]}
1241
- );
1242
- } catch (error) {
1243
- console.warn("Tileflow building wireframe could not read visible buildings", error);
1244
- return;
1245
- }
1246
- if (features.length === 0) {
1247
- hideWireframe();
1248
- if (emptyRefreshAttempts < 12) {
1249
- emptyRefreshAttempts += 1;
1250
- queueRefresh(160);
1251
- }
1252
- return;
1253
- }
1254
- emptyRefreshAttempts = 0;
1255
- sceneOriginMercator = maplibregl.MercatorCoordinate.fromLngLat(map.getCenter(), 0);
1256
- const mercatorPerMeter = sceneOriginMercator.meterInMercatorCoordinateUnits();
1257
- const positions = [];
1258
- const seen = new Set();
1259
- let buildingCount = 0;
1260
- let segmentCount = 0;
1261
- let truncated = false;
1262
- featureLoop: for (const feature of features) {
1263
- const polygons = geometryPolygons(feature.geometry);
1264
- if (polygons.length === 0) continue;
1265
- const properties = feature.properties ?? {};
1266
- const parsedHeight = Number(properties[heightField]);
1267
- const sourceHeight = Math.max(0, Number.isFinite(parsedHeight) ? parsedHeight : 5);
1268
- const parsedBase = Number(properties[baseField]);
1269
- const sourceBase = Math.max(
1270
- 0,
1271
- Math.min(Number.isFinite(parsedBase) ? parsedBase : 0, sourceHeight)
1272
- );
1273
- const height = sourceHeight * heightScale;
1274
- const base = sourceBase * heightScale;
1275
- if (height <= base) continue;
1276
- for (const rings of polygons) {
1277
- if (buildingCount >= maximumBuildings) {
1278
- truncated = true;
1279
- break featureLoop;
1280
- }
1281
- const outerRing = rings?.[0];
1282
- if (!ringIntersectsViewport(outerRing, viewportWidth, viewportHeight)) continue;
1283
- const key = buildingKey(feature, rings, height, base);
1284
- if (seen.has(key)) continue;
1285
- const requiredSegments = rings.reduce(
1286
- (total, ring) => total + ringVertexCount(ring) * 3,
1287
- 0
1288
- );
1289
- if (segmentCount + requiredSegments > maximumSegments) {
1290
- truncated = true;
1291
- break featureLoop;
1292
- }
1293
- seen.add(key);
1294
- const segmentCountBefore = segmentCount;
1295
- for (const ring of rings) {
1296
- segmentCount += appendRing(positions, ring, base, height, mercatorPerMeter);
1297
- }
1298
- if (segmentCount > segmentCountBefore) buildingCount += 1;
1299
- }
1300
- }
1301
- if (positions.length === 0) {
1302
- hideWireframe();
1303
- return;
1304
- }
1305
- const geometrySignature = [
1306
- sceneOriginMercator.x.toFixed(9),
1307
- sceneOriginMercator.y.toFixed(9),
1308
- buildingCount,
1309
- segmentCount,
1310
- ...[...seen].sort()
1311
- ].join("|");
1312
- if (geometrySignature !== lastGeometrySignature || !geometry) {
1313
- replaceGeometry(positions);
1314
- lastGeometrySignature = geometrySignature;
1315
- }
1316
- group.visible = true;
1317
- buildingWireframeMetrics.buildingCount = buildingCount;
1318
- buildingWireframeMetrics.segmentCount = segmentCount;
1319
- buildingWireframeMetrics.refreshMilliseconds = performance.now() - startedAt;
1320
- buildingWireframeMetrics.truncated = truncated;
1321
- map.triggerRepaint();
1322
- }
1323
-
1324
- function queueRefresh(delay = 80) {
1325
- if (refreshTimer !== undefined) return;
1326
- refreshTimer = setTimeout(refresh, delay);
1327
- }
1328
-
1329
- function handleMoveEnd() {
1330
- emptyRefreshAttempts = 0;
1331
- queueRefresh(0);
1332
- }
1333
-
1334
- function handleSourceData(event) {
1335
- if (event.sourceId !== sourceId || event.isSourceLoaded !== true || map.isMoving()) {
1336
- return;
1337
- }
1338
- queueRefresh();
1339
- }
1340
-
1341
- function handleThreeDimensionalToggle() {
1342
- if (!threeDimensionalEnabled) {
1343
- if (refreshTimer !== undefined) clearTimeout(refreshTimer);
1344
- refreshTimer = undefined;
1345
- hideWireframe();
1346
- map.triggerRepaint();
1347
- return;
1348
- }
1349
- emptyRefreshAttempts = 0;
1350
- queueRefresh(0);
1351
- }
1352
-
1353
- function handleVisibleLayerGroups() {
1354
- if (!visibleLayerGroups.has("buildings")) {
1355
- hideWireframe();
1356
- map.triggerRepaint();
1357
- return;
1358
- }
1359
- emptyRefreshAttempts = 0;
1360
- queueRefresh(0);
1361
- }
1362
-
1363
- return {
1364
- id: "tileflow-buildings-wireframe-3d",
1365
- type: "custom",
1366
- renderingMode: "3d",
1367
- onAdd(_map, gl) {
1368
- camera = new THREE.Camera();
1369
- scene = new THREE.Scene();
1370
- group = new THREE.Group();
1371
- group.visible = false;
1372
- scene.add(group);
1373
- glowMaterial = new LineMaterial({
1374
- blending: THREE.AdditiveBlending,
1375
- color: glowColor,
1376
- depthTest: false,
1377
- depthWrite: false,
1378
- linewidth: glowWidth,
1379
- opacity: glowOpacity,
1380
- toneMapped: false,
1381
- transparent: true,
1382
- worldUnits: false
1383
- });
1384
- coreMaterial = new LineMaterial({
1385
- blending: THREE.NormalBlending,
1386
- color,
1387
- depthTest: false,
1388
- depthWrite: false,
1389
- linewidth: width,
1390
- opacity,
1391
- toneMapped: false,
1392
- transparent: true,
1393
- worldUnits: false
1394
- });
1395
- renderer = new THREE.WebGLRenderer({
1396
- canvas: map.getCanvas(),
1397
- context: gl
1398
- });
1399
- renderer.autoClear = false;
1400
- renderer.outputColorSpace = THREE.SRGBColorSpace;
1401
- map.on("moveend", handleMoveEnd);
1402
- map.on("sourcedata", handleSourceData);
1403
- map.on("tileflow:3d-toggle", handleThreeDimensionalToggle);
1404
- map.on("tileflow:visible-layer-groups", handleVisibleLayerGroups);
1405
- queueRefresh(0);
1406
- },
1407
- render(_gl, options) {
1408
- if (!wireframeShouldBeVisible() || !sceneOriginMercator || !group?.visible) return;
1409
- const canvas = map.getCanvas();
1410
- const pixelRatio = map.getPixelRatio?.() ?? globalThis.devicePixelRatio ?? 1;
1411
- glowMaterial.linewidth = glowWidth * pixelRatio;
1412
- coreMaterial.linewidth = width * pixelRatio;
1413
- glowMaterial.resolution.set(canvas.width, canvas.height);
1414
- coreMaterial.resolution.set(canvas.width, canvas.height);
1415
- const meter = sceneOriginMercator.meterInMercatorCoordinateUnits();
1416
- mapMatrix.fromArray(options.defaultProjectionData.mainMatrix);
1417
- sceneMatrix
1418
- .makeTranslation(
1419
- sceneOriginMercator.x,
1420
- sceneOriginMercator.y,
1421
- sceneOriginMercator.z
1422
- )
1423
- .scale(new THREE.Vector3(meter, meter, meter));
1424
- camera.projectionMatrix.copy(mapMatrix).multiply(sceneMatrix);
1425
- renderer.resetState();
1426
- renderer.setViewport(0, 0, canvas.width, canvas.height);
1427
- renderer.render(scene, camera);
1428
- },
1429
- onRemove() {
1430
- if (refreshTimer !== undefined) clearTimeout(refreshTimer);
1431
- map.off("moveend", handleMoveEnd);
1432
- map.off("sourcedata", handleSourceData);
1433
- map.off("tileflow:3d-toggle", handleThreeDimensionalToggle);
1434
- map.off("tileflow:visible-layer-groups", handleVisibleLayerGroups);
1435
- geometry?.dispose();
1436
- glowMaterial?.dispose();
1437
- coreMaterial?.dispose();
1438
- renderer?.dispose();
1439
- hideWireframe();
1440
- }
1441
- };
1442
- }
1443
-
1444
- function addBuildingWireframeLayerIfConfigured(map, styleLayers) {
1445
- const styleLayer = styleLayers.find(
1446
- (layer) => layer.metadata?.["tileflow:building-wireframe"] === true
1447
- );
1448
- if (!styleLayer || map.getLayer("tileflow-buildings-wireframe-3d")) return;
1449
- if (map.getProjection().type !== "mercator") {
1450
- map.setProjection({type: "mercator"});
1451
- }
1452
- if (map.getLayer("tileflow-buildings-wireframe-3d")) return;
1453
- const styleLayerIndex = styleLayers.findIndex((layer) => layer.id === styleLayer.id);
1454
- const layerAboveBuildings = styleLayers[styleLayerIndex + 1]?.id;
1455
- map.addLayer(createBuildingWireframeLayer(map, styleLayer), layerAboveBuildings);
1456
- }
1457
-
1458
- function createTreeLayer(map, styleLayer) {
1459
- const initialTreeInstanceCapacity = 64;
1460
- const maximumCachedTrees = 12000;
1461
- const sourceRefreshIntervalMilliseconds = 120;
1462
- const viewportBufferPixels = 96;
1463
- const treeLods = [
1464
- {
1465
- densityCellPixels: 12,
1466
- name: "low",
1467
- untilZoom: 16.5
1468
- },
1469
- {
1470
- densityCellPixels: 8,
1471
- name: "medium",
1472
- untilZoom: 17.5
1473
- },
1474
- {
1475
- densityCellPixels: 0,
1476
- name: "full",
1477
- untilZoom: Infinity
1478
- }
1479
- ];
1480
- const sourceId = styleLayer.source;
1481
- const heightField = styleLayer.metadata?.["tileflow:tree-height-field"] ?? "height";
1482
- const crownField = styleLayer.metadata?.["tileflow:tree-crown-field"] ?? "diameter_crown";
1483
- const genusField = styleLayer.metadata?.["tileflow:tree-genus-field"] ?? "genus";
1484
- const leafTypeField = styleLayer.metadata?.["tileflow:tree-leaf-type-field"] ?? "leaf_type";
1485
- const speciesField = styleLayer.metadata?.["tileflow:tree-species-field"] ?? "species";
1486
- const heightScaleValue = Number(
1487
- styleLayer.metadata?.["tileflow:tree-height-scale"] ?? 1
1488
- );
1489
- const crownScaleValue = Number(
1490
- styleLayer.metadata?.["tileflow:tree-crown-scale"] ?? 1
1491
- );
1492
- const heightScale = Number.isFinite(heightScaleValue) && heightScaleValue > 0
1493
- ? heightScaleValue
1494
- : 1;
1495
- const crownScale = Number.isFinite(crownScaleValue) && crownScaleValue > 0
1496
- ? crownScaleValue
1497
- : 1;
1498
- let scene;
1499
- let sceneElevation;
1500
- let sceneOriginMercator;
1501
- let camera;
1502
- let renderer;
1503
- let treeGroup;
1504
- let treeMaterial;
1505
- let treeGeometries = [];
1506
- let treeMeshes = [];
1507
- let treeMeshCapacities = [];
1508
- let treeShadowGeometry;
1509
- let treeShadowMesh;
1510
- let treeShadowCapacity = 0;
1511
- let treeShadowMaterial;
1512
- let activeTreeBackend = treeBackendMode;
1513
- let rawGl;
1514
- let rawProgram;
1515
- let rawMatrixUniform;
1516
- let rawTreeResources = [];
1517
- let rawShadowProgram;
1518
- let rawShadowMatrixUniform;
1519
- let rawShadowOpacityUniform;
1520
- let rawShadowResource;
1521
- let rawTreesVisible = false;
1522
- const fallbackCircleOpacity = styleLayer.paint?.["circle-opacity"] ?? 0.82;
1523
- const fallbackCircleStrokeOpacity =
1524
- styleLayer.paint?.["circle-stroke-opacity"] ?? 0.55;
1525
- let fallbackVisible = true;
1526
- let emptyRefreshAttempts = 0;
1527
- let emptyRefreshTimer;
1528
- let lastRefreshTime = 0;
1529
- let lastTreeSelectionSignature;
1530
- let refreshQueued = false;
1531
- let refreshTimer;
1532
- let renderedTreeCount = 0;
1533
- let sourceSettled = false;
1534
- const treeStateCache = new Map();
1535
- const matrix = new THREE.Matrix4();
1536
- const mapMatrix = new THREE.Matrix4();
1537
- const sceneMatrix = new THREE.Matrix4();
1538
- const combinedMatrix = new THREE.Matrix4();
1539
- const position = new THREE.Vector3();
1540
- const scale = new THREE.Vector3();
1541
- const crownRotation = new THREE.Quaternion();
1542
- const yAxis = new THREE.Vector3(0, 1, 0);
1543
- const instanceTint = new THREE.Color();
1544
- const shadowMatrix = new THREE.Matrix4();
1545
- const shadowPosition = new THREE.Vector3();
1546
- const shadowScale = new THREE.Vector3();
1547
- const shadowRotation = new THREE.Quaternion();
1548
- const treeShadowLiftMeters = 0.04;
1549
- const treeShadowOpacity = 0.17;
1550
- const barkColor = new THREE.Color(
1551
- styleLayer.metadata?.["tileflow:tree-bark-color"] ?? "#8F8E79"
1552
- );
1553
- const broadleafColorValues =
1554
- styleLayer.metadata?.["tileflow:tree-broadleaf-colors"];
1555
- const broadleafPalette = (
1556
- Array.isArray(broadleafColorValues) && broadleafColorValues.length > 0
1557
- ? broadleafColorValues
1558
- : ["#87BA8C", "#98C89A", "#AAD4A7", "#B8DDB1"]
1559
- ).map((color) => new THREE.Color(color));
1560
- const coniferColorValues = styleLayer.metadata?.["tileflow:tree-conifer-colors"];
1561
- const coniferPalette = (
1562
- Array.isArray(coniferColorValues) && coniferColorValues.length > 0
1563
- ? coniferColorValues
1564
- : ["#77A77E", "#87B58A", "#98C399"]
1565
- ).map((color) => new THREE.Color(color));
1566
- const palmPalette = ["#82B77C", "#91C487", "#A0D093"].map(
1567
- (color) => new THREE.Color(color)
1568
- );
1569
-
1570
- function addGeometryPart(parts, geometry, partPosition, partScale, rotation, color) {
1571
- const partMatrix = new THREE.Matrix4().compose(
1572
- new THREE.Vector3(...partPosition),
1573
- rotation,
1574
- new THREE.Vector3(...partScale)
1575
- );
1576
- geometry.applyMatrix4(partMatrix);
1577
- if (!geometry.getAttribute("normal")) geometry.computeVertexNormals();
1578
- const renderGeometry = geometry.index ? geometry.toNonIndexed() : geometry;
1579
- const positions = renderGeometry.getAttribute("position").array;
1580
- const normals = renderGeometry.getAttribute("normal").array;
1581
- const colors = new Float32Array((positions.length / 3) * 3);
1582
- const indices = Uint32Array.from(
1583
- {length: positions.length / 3},
1584
- (_value, index) => index
1585
- );
1586
- for (let index = 0; index < colors.length; index += 3) {
1587
- colors[index] = color.r;
1588
- colors[index + 1] = color.g;
1589
- colors[index + 2] = color.b;
1590
- }
1591
- parts.push({
1592
- colors,
1593
- indices,
1594
- normals: new Float32Array(normals),
1595
- positions: new Float32Array(positions)
1596
- });
1597
- if (renderGeometry !== geometry) renderGeometry.dispose();
1598
- geometry.dispose();
1599
- }
1600
-
1601
- function addTaperedBranchPart(
1602
- parts,
1603
- start,
1604
- end,
1605
- baseRadius,
1606
- tipRadius,
1607
- radialSegments = 6
1608
- ) {
1609
- const startPoint = new THREE.Vector3(...start);
1610
- const endPoint = new THREE.Vector3(...end);
1611
- const direction = new THREE.Vector3().subVectors(endPoint, startPoint);
1612
- const branchRotation = new THREE.Quaternion().setFromUnitVectors(
1613
- yAxis,
1614
- direction.clone().normalize()
1615
- );
1616
- const center = startPoint.clone().add(endPoint).multiplyScalar(0.5);
1617
- addGeometryPart(
1618
- parts,
1619
- new THREE.CylinderGeometry(tipRadius, baseRadius, 1, radialSegments, 1, true),
1620
- [center.x, center.y, center.z],
1621
- [1, direction.length(), 1],
1622
- branchRotation,
1623
- barkColor
1624
- );
1625
- }
1626
-
1627
- function createOrganicCrownGeometry(seed, simple) {
1628
- const geometry = new THREE.SphereGeometry(
1629
- 0.5,
1630
- simple ? 8 : 12,
1631
- simple ? 5 : 7
1632
- );
1633
- const positions = geometry.getAttribute("position");
1634
- for (let index = 0; index < positions.count; index += 1) {
1635
- const x = positions.getX(index);
1636
- const y = positions.getY(index);
1637
- const z = positions.getZ(index);
1638
- const angle = Math.atan2(z, x);
1639
- const normalizedHeight = Math.max(-1, Math.min(1, y * 2));
1640
- const latitudeWeight = 1 - normalizedHeight * normalizedHeight;
1641
- const ripple =
1642
- 1 +
1643
- Math.sin(angle * 3 + seed * 1.73) * 0.065 * latitudeWeight +
1644
- Math.sin(angle * 5 - seed * 0.91) * 0.035 * latitudeWeight +
1645
- Math.sin(y * Math.PI * 4 + seed * 0.63) * 0.025;
1646
- positions.setXYZ(
1647
- index,
1648
- x * ripple,
1649
- y * (1 + Math.cos(angle * 2 + seed) * 0.035),
1650
- z * ripple
1651
- );
1652
- }
1653
- positions.needsUpdate = true;
1654
- geometry.deleteAttribute("normal");
1655
- geometry.computeVertexNormals();
1656
- return geometry;
1657
- }
1658
-
1659
- function createScallopedConeGeometry(seed, simple) {
1660
- const geometry = new THREE.ConeGeometry(
1661
- 0.5,
1662
- 1,
1663
- simple ? 8 : 12,
1664
- simple ? 2 : 3
1665
- );
1666
- const positions = geometry.getAttribute("position");
1667
- for (let index = 0; index < positions.count; index += 1) {
1668
- const x = positions.getX(index);
1669
- const y = positions.getY(index);
1670
- const z = positions.getZ(index);
1671
- const angle = Math.atan2(z, x);
1672
- const progress = Math.max(0, Math.min(1, y + 0.5));
1673
- const ripple =
1674
- 1 +
1675
- Math.sin(angle * 3 + seed * 1.31) * 0.08 +
1676
- Math.sin(angle * 5 - seed * 0.77) * 0.045 +
1677
- Math.sin(progress * Math.PI * 3 + seed) * 0.035;
1678
- positions.setXYZ(
1679
- index,
1680
- x * ripple,
1681
- y + Math.sin(angle * 2 + seed) * (1 - progress) * 0.018,
1682
- z * ripple
1683
- );
1684
- }
1685
- positions.needsUpdate = true;
1686
- geometry.deleteAttribute("normal");
1687
- geometry.computeVertexNormals();
1688
- return geometry;
1689
- }
1690
-
1691
- function createCurvedPalmFrondGeometry(seed, simple, fan) {
1692
- const segments = simple ? 5 : 8;
1693
- const length = fan ? 0.49 + (seed % 3) * 0.025 : 0.58 + (seed % 3) * 0.03;
1694
- const droop = fan ? 0.16 + (seed % 2) * 0.025 : 0.25 + (seed % 3) * 0.025;
1695
- const sway = (((seed * 37) % 7) - 3) * 0.008;
1696
- const widthScale = fan ? 0.17 : 0.024;
1697
- const centerAt = (progress) => [
1698
- length * progress,
1699
- Math.sin(Math.PI * progress) * (fan ? 0.08 : 0.11) -
1700
- droop * Math.pow(progress, 1.7),
1701
- sway * Math.sin(Math.PI * progress)
1702
- ];
1703
- const points = [];
1704
- for (let step = 0; step <= segments; step += 1) {
1705
- const progress = step / segments;
1706
- const envelope = Math.pow(Math.sin(Math.PI * progress), 0.7) * (1 - 0.25 * progress);
1707
- const width = Math.max(0.008, widthScale * envelope);
1708
- const [centerX, centerY, centerZ] = centerAt(progress);
1709
- points.push(
1710
- [centerX, centerY, centerZ - width],
1711
- [centerX, centerY, centerZ + width]
1712
- );
1713
- }
1714
- const trianglePositions = [];
1715
- const appendTriangle = (first, second, third) => {
1716
- trianglePositions.push(...first, ...second, ...third);
1717
- };
1718
- const appendDoubleSidedQuad = (leftStart, rightStart, leftEnd, rightEnd) => {
1719
- appendTriangle(leftStart, rightStart, leftEnd);
1720
- appendTriangle(rightStart, rightEnd, leftEnd);
1721
- appendTriangle(leftEnd, rightStart, leftStart);
1722
- appendTriangle(leftEnd, rightEnd, rightStart);
1723
- };
1724
- for (let segment = 0; segment < segments; segment += 1) {
1725
- const leftStart = points[segment * 2];
1726
- const rightStart = points[segment * 2 + 1];
1727
- const leftEnd = points[(segment + 1) * 2];
1728
- const rightEnd = points[(segment + 1) * 2 + 1];
1729
- appendDoubleSidedQuad(leftStart, rightStart, leftEnd, rightEnd);
1730
- }
1731
- if (!fan) {
1732
- const leafletCount = simple ? 4 : 7;
1733
- for (let leaflet = 0; leaflet < leafletCount; leaflet += 1) {
1734
- const progress = 0.17 + (leaflet / (leafletCount - 1)) * 0.66;
1735
- const [centerX, centerY, centerZ] = centerAt(progress);
1736
- const leafletLength =
1737
- (0.075 + Math.sin(Math.PI * progress) * 0.095) *
1738
- (0.94 + ((leaflet + seed) % 3) * 0.035);
1739
- const leafletWidth = 0.018 + Math.sin(Math.PI * progress) * 0.012;
1740
- const leafletBack = 0.018 + ((leaflet + seed) % 2) * 0.012;
1741
- const leafletDrop = 0.022 + progress * 0.045;
1742
- for (const side of [-1, 1]) {
1743
- const base = [centerX - 0.014, centerY, centerZ + side * 0.007];
1744
- const midCenter = [
1745
- centerX + 0.012,
1746
- centerY - leafletDrop * 0.35,
1747
- centerZ + side * leafletLength * 0.58
1748
- ];
1749
- const midLeft = [
1750
- midCenter[0] - leafletWidth,
1751
- midCenter[1],
1752
- midCenter[2] - side * leafletWidth * 0.14
1753
- ];
1754
- const midRight = [
1755
- midCenter[0] + leafletWidth,
1756
- midCenter[1],
1757
- midCenter[2] + side * leafletWidth * 0.14
1758
- ];
1759
- const tip = [
1760
- centerX - leafletBack,
1761
- centerY - leafletDrop,
1762
- centerZ + side * leafletLength
1763
- ];
1764
- appendTriangle(base, midRight, midLeft);
1765
- appendTriangle(midLeft, midRight, tip);
1766
- appendTriangle(midLeft, midRight, base);
1767
- appendTriangle(tip, midRight, midLeft);
1768
- }
1769
- }
1770
- }
1771
- const geometry = new THREE.BufferGeometry();
1772
- geometry.setAttribute(
1773
- "position",
1774
- new THREE.Float32BufferAttribute(trianglePositions, 3)
1775
- );
1776
- geometry.computeVertexNormals();
1777
- return geometry;
1778
- }
1779
-
1780
- function mergeGeometryParts(parts) {
1781
- const positionLength = parts.reduce((total, part) => total + part.positions.length, 0);
1782
- const positions = new Float32Array(positionLength);
1783
- const normals = new Float32Array(positionLength);
1784
- const colors = new Float32Array(positionLength);
1785
- const indexLength = parts.reduce((total, part) => total + part.indices.length, 0);
1786
- const indices = new Uint32Array(indexLength);
1787
- let offset = 0;
1788
- let indexOffset = 0;
1789
- let vertexOffset = 0;
1790
- for (const part of parts) {
1791
- positions.set(part.positions, offset);
1792
- normals.set(part.normals, offset);
1793
- colors.set(part.colors, offset);
1794
- for (let index = 0; index < part.indices.length; index += 1) {
1795
- indices[indexOffset + index] = part.indices[index] + vertexOffset;
1796
- }
1797
- indexOffset += part.indices.length;
1798
- vertexOffset += part.positions.length / 3;
1799
- offset += part.positions.length;
1800
- }
1801
- const geometry = new THREE.BufferGeometry();
1802
- geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
1803
- geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
1804
- geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
1805
- geometry.setIndex(new THREE.BufferAttribute(indices, 1));
1806
- geometry.computeBoundingBox();
1807
- geometry.computeBoundingSphere();
1808
- return geometry;
1809
- }
1810
-
1811
- function createBroadleafTreeGeometry(variant, geometryVariant, simple) {
1812
- const parts = [];
1813
- const trunkTops = [
1814
- [0.008, 0.56, -0.004],
1815
- [-0.012, 0.51, 0.008],
1816
- [0.006, 0.62, 0.004]
1817
- ];
1818
- const trunkTopBase = trunkTops[variant] ?? trunkTops[0];
1819
- const trunkTop = [
1820
- trunkTopBase[0] + (geometryVariant === 0 ? -0.008 : 0.012),
1821
- trunkTopBase[1] + (geometryVariant === 0 ? -0.012 : 0.016),
1822
- trunkTopBase[2] + (geometryVariant === 0 ? 0.006 : -0.01)
1823
- ];
1824
- addTaperedBranchPart(
1825
- parts,
1826
- [0, 0, 0],
1827
- trunkTop,
1828
- 0.04 + variant * 0.001,
1829
- 0.022,
1830
- simple ? 6 : 8
1831
- );
1832
-
1833
- const branchTargetLayouts = [
1834
- [
1835
- [-0.2, 0.72, 0.03],
1836
- [0.01, 0.79, -0.04],
1837
- [0.21, 0.72, -0.02],
1838
- [-0.03, 0.7, 0.18],
1839
- [0.06, 0.73, -0.17]
1840
- ],
1841
- [
1842
- [-0.27, 0.68, 0.02],
1843
- [-0.09, 0.76, -0.08],
1844
- [0.25, 0.69, -0.03],
1845
- [0.06, 0.72, 0.2],
1846
- [0.13, 0.79, -0.13]
1847
- ],
1848
- [
1849
- [-0.15, 0.75, 0.02],
1850
- [0.04, 0.86, -0.04],
1851
- [0.16, 0.77, 0.01],
1852
- [0.02, 0.8, 0.15]
1853
- ]
1854
- ];
1855
- const branchTargets = branchTargetLayouts[variant] ?? branchTargetLayouts[0];
1856
- const branchCount = simple ? Math.min(4, branchTargets.length) : branchTargets.length;
1857
- for (let branch = 0; branch < branchCount; branch += 1) {
1858
- const branchTarget = branchTargets[branch];
1859
- const shelteredBranchTarget = [
1860
- branchTarget[0] * 0.86,
1861
- branchTarget[1] - 0.07,
1862
- branchTarget[2] * 0.86
1863
- ];
1864
- addTaperedBranchPart(
1865
- parts,
1866
- [trunkTop[0] * 0.55, 0.38 + branch * 0.018, trunkTop[2] * 0.55],
1867
- shelteredBranchTarget,
1868
- 0.022 - branch * 0.0015,
1869
- 0.0095 - branch * 0.00065,
1870
- simple ? 5 : 7
1871
- );
1872
- }
1873
-
1874
- const roundCrownLayout = [
1875
- {position: [0, 0.8, 0.01], scale: [0.72, 0.39, 0.66]},
1876
- {position: [-0.27, 0.73, 0.05], scale: [0.45, 0.26, 0.42]},
1877
- {position: [0.27, 0.75, -0.06], scale: [0.44, 0.25, 0.41]},
1878
- {position: [-0.08, 0.7, 0.24], scale: [0.4, 0.23, 0.37]},
1879
- {position: [0.1, 0.72, -0.24], scale: [0.41, 0.24, 0.38]}
1880
- ];
1881
- const openCrownLayout = [
1882
- {position: [-0.29, 0.72, 0.03], scale: [0.47, 0.22, 0.39]},
1883
- {position: [-0.08, 0.83, -0.1], scale: [0.44, 0.24, 0.36]},
1884
- {position: [0.22, 0.79, -0.04], scale: [0.49, 0.22, 0.4]},
1885
- {position: [0.31, 0.67, 0.09], scale: [0.4, 0.19, 0.34]},
1886
- {position: [0.04, 0.68, 0.22], scale: [0.45, 0.21, 0.38]},
1887
- {position: [-0.19, 0.65, 0.18], scale: [0.38, 0.18, 0.32]}
1888
- ];
1889
- const tallCrownLayout = [
1890
- {position: [0, 0.79, 0.01], scale: [0.53, 0.58, 0.48]},
1891
- {position: [-0.19, 0.76, 0.05], scale: [0.34, 0.4, 0.32]},
1892
- {position: [0.18, 0.8, -0.05], scale: [0.34, 0.42, 0.31]},
1893
- {position: [-0.05, 0.94, -0.08], scale: [0.32, 0.34, 0.3]},
1894
- {position: [0.06, 0.69, 0.15], scale: [0.32, 0.35, 0.3]}
1895
- ];
1896
- const crownLayouts = [roundCrownLayout, openCrownLayout, tallCrownLayout];
1897
- const crownLayout = crownLayouts[variant] ?? crownLayouts[0];
1898
- const lobeCount = simple ? Math.max(4, crownLayout.length - 1) : crownLayout.length;
1899
- const crownColorPatterns = [
1900
- [2, 1, 3, 0, 1],
1901
- [1, 2, 0, 3, 1, 2],
1902
- [2, 1, 3, 1, 2]
1903
- ];
1904
- const crownColorPattern = crownColorPatterns[variant] ?? crownColorPatterns[0];
1905
- for (let lobe = 0; lobe < lobeCount; lobe += 1) {
1906
- const lobeShape = crownLayout[lobe];
1907
- const lobeSeed = variant * 23 + geometryVariant * 11 + lobe * 3;
1908
- const lobePosition = [
1909
- lobeShape.position[0] + Math.sin(lobeSeed * 1.7) * 0.018,
1910
- lobeShape.position[1] + Math.cos(lobeSeed * 1.1) * 0.012,
1911
- lobeShape.position[2] + Math.cos(lobeSeed * 1.9) * 0.018
1912
- ];
1913
- addGeometryPart(
1914
- parts,
1915
- createOrganicCrownGeometry(lobeSeed, simple),
1916
- lobePosition,
1917
- lobeShape.scale,
1918
- new THREE.Quaternion().setFromEuler(
1919
- new THREE.Euler(
1920
- ((lobe % 3) - 1) * 0.09,
1921
- variant * 0.41 + geometryVariant * 0.27 + lobe * 0.37,
1922
- (((lobe + variant + geometryVariant) % 3) - 1) * 0.11
1923
- )
1924
- ),
1925
- broadleafPalette[
1926
- (crownColorPattern[lobe] + geometryVariant * 2) % broadleafPalette.length
1927
- ]
1928
- );
1929
- }
1930
- const crownCapShapes = [
1931
- {position: [0.01, 0.9, 0], scale: [0.3, 0.18, 0.28]},
1932
- {position: [0, 0.84, 0.01], scale: [0.29, 0.17, 0.27]},
1933
- {position: [0, 0.96, 0], scale: [0.24, 0.22, 0.23]}
1934
- ];
1935
- const crownCapShape = crownCapShapes[variant] ?? crownCapShapes[0];
1936
- addGeometryPart(
1937
- parts,
1938
- createOrganicCrownGeometry(149 + variant * 17 + geometryVariant * 7, simple),
1939
- crownCapShape.position,
1940
- crownCapShape.scale,
1941
- new THREE.Quaternion().setFromAxisAngle(
1942
- yAxis,
1943
- variant * 0.31 + geometryVariant * 0.43
1944
- ),
1945
- broadleafPalette[(1 + variant + geometryVariant * 2) % broadleafPalette.length]
1946
- );
1947
- return mergeGeometryParts(parts);
1948
- }
1949
-
1950
- function createConiferTreeGeometry(variant, geometryVariant, simple) {
1951
- const parts = [];
1952
- const columnar = variant === 1;
1953
- addTaperedBranchPart(
1954
- parts,
1955
- [0, 0, 0],
1956
- [variant === 0 ? 0.006 : -0.006, columnar ? 0.54 : 0.43, 0],
1957
- 0.035,
1958
- 0.018,
1959
- simple ? 6 : 8
1960
- );
1961
- if (columnar) {
1962
- const columnarCrownLayout = [
1963
- {position: [-0.01, 0.5, 0.01], scale: [0.38, 0.48, 0.36]},
1964
- {position: [0.01, 0.66, -0.01], scale: [0.42, 0.5, 0.39]},
1965
- {position: [-0.015, 0.8, 0.01], scale: [0.34, 0.42, 0.32]},
1966
- {position: [0.008, 0.91, 0], scale: [0.22, 0.28, 0.21]}
1967
- ];
1968
- const massCount = simple ? 3 : columnarCrownLayout.length;
1969
- for (let mass = 0; mass < massCount; mass += 1) {
1970
- const crownMass = columnarCrownLayout[mass];
1971
- addGeometryPart(
1972
- parts,
1973
- createOrganicCrownGeometry(71 + geometryVariant * 13 + mass * 5, simple),
1974
- crownMass.position,
1975
- crownMass.scale,
1976
- new THREE.Quaternion().setFromAxisAngle(yAxis, mass * 0.43),
1977
- coniferPalette[(mass + 1) % coniferPalette.length]
1978
- );
1979
- }
1980
- return mergeGeometryParts(parts);
1981
- }
1982
- const tierCount = simple ? 3 : 5;
1983
- for (let tier = 0; tier < tierCount; tier += 1) {
1984
- const progress = tier / (tierCount - 1);
1985
- const tierDiameter = 0.94 - progress * 0.5;
1986
- const tierHeight = 0.35 - progress * 0.075;
1987
- addGeometryPart(
1988
- parts,
1989
- createScallopedConeGeometry(geometryVariant * 19 + tier * 7, simple),
1990
- [
1991
- Math.sin(tier * 2.1 + geometryVariant) * 0.012,
1992
- 0.45 + progress * 0.44,
1993
- Math.cos(tier * 1.7 + geometryVariant) * 0.012
1994
- ],
1995
- [tierDiameter, tierHeight, tierDiameter],
1996
- new THREE.Quaternion().setFromAxisAngle(
1997
- yAxis,
1998
- variant * 0.6 + geometryVariant * 0.29 + tier * 0.31
1999
- ),
2000
- coniferPalette[(variant + tier) % coniferPalette.length]
2001
- );
2002
- }
2003
- return mergeGeometryParts(parts);
2004
- }
2005
-
2006
- function createPalmTreeGeometry(geometryVariant, simple) {
2007
- const parts = [];
2008
- const fan = geometryVariant === 1;
2009
- const lowerTrunkTop = [fan ? -0.014 : 0.012, 0.42, fan ? 0.006 : -0.004];
2010
- const upperTrunkTop = [fan ? 0.014 : -0.012, fan ? 0.73 : 0.78, fan ? -0.008 : 0.012];
2011
- addTaperedBranchPart(parts, [0, 0, 0], lowerTrunkTop, 0.032, 0.026, simple ? 7 : 9);
2012
- addTaperedBranchPart(
2013
- parts,
2014
- lowerTrunkTop,
2015
- upperTrunkTop,
2016
- 0.026,
2017
- 0.016,
2018
- simple ? 7 : 9
2019
- );
2020
- addGeometryPart(
2021
- parts,
2022
- createOrganicCrownGeometry(113 + geometryVariant * 17, simple),
2023
- [upperTrunkTop[0], upperTrunkTop[1] + 0.018, upperTrunkTop[2]],
2024
- [fan ? 0.19 : 0.16, 0.1, fan ? 0.19 : 0.16],
2025
- new THREE.Quaternion(),
2026
- palmPalette[1]
2027
- );
2028
- const frondCount = simple ? (fan ? 8 : 7) : (fan ? 12 : 11);
2029
- for (let frond = 0; frond < frondCount; frond += 1) {
2030
- const angle =
2031
- (frond / frondCount) * Math.PI * 2 +
2032
- (((frond * 5 + geometryVariant) % 3) - 1) * 0.045;
2033
- addGeometryPart(
2034
- parts,
2035
- createCurvedPalmFrondGeometry(frond + geometryVariant * 17, simple, fan),
2036
- [upperTrunkTop[0], upperTrunkTop[1] + 0.018, upperTrunkTop[2]],
2037
- [0.94 + (frond % 3) * 0.035, 0.96 + (frond % 2) * 0.035, 1],
2038
- new THREE.Quaternion().setFromEuler(
2039
- new THREE.Euler(
2040
- (((frond * 7 + geometryVariant) % 3) - 1) * 0.045,
2041
- -angle,
2042
- (((frond * 5 + geometryVariant) % 5) - 2) * 0.022
2043
- )
2044
- ),
2045
- palmPalette[(frond + geometryVariant) % palmPalette.length]
2046
- );
2047
- }
2048
- return mergeGeometryParts(parts);
2049
- }
2050
-
2051
- function createTreeGeometries() {
2052
- const simple = treeRendererMode === "simple";
2053
- return [
2054
- createBroadleafTreeGeometry(0, 0, simple),
2055
- createBroadleafTreeGeometry(0, 1, simple),
2056
- createBroadleafTreeGeometry(1, 0, simple),
2057
- createBroadleafTreeGeometry(1, 1, simple),
2058
- createBroadleafTreeGeometry(2, 0, simple),
2059
- createBroadleafTreeGeometry(2, 1, simple),
2060
- createConiferTreeGeometry(0, 0, simple),
2061
- createConiferTreeGeometry(1, 0, simple),
2062
- createPalmTreeGeometry(0, simple),
2063
- createPalmTreeGeometry(1, simple)
2064
- ];
2065
- }
2066
-
2067
- function compileRawShader(gl, type, source) {
2068
- const shader = gl.createShader(type);
2069
- if (!shader) throw new Error("Unable to create tree shader");
2070
- gl.shaderSource(shader, source);
2071
- gl.compileShader(shader);
2072
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
2073
- const message = gl.getShaderInfoLog(shader) || "unknown shader error";
2074
- gl.deleteShader(shader);
2075
- throw new Error(message);
2076
- }
2077
- return shader;
2078
- }
2079
-
2080
- function createRawTreeProgram(gl) {
2081
- const vertexShader = compileRawShader(
2082
- gl,
2083
- gl.VERTEX_SHADER,
2084
- "#version 300 es\\n" +
2085
- "precision highp float;\\n" +
2086
- "layout(location=0) in vec3 a_position;\\n" +
2087
- "layout(location=1) in vec3 a_normal;\\n" +
2088
- "layout(location=2) in vec3 a_color;\\n" +
2089
- "layout(location=3) in vec3 a_instance_position;\\n" +
2090
- "layout(location=4) in vec3 a_instance_scale;\\n" +
2091
- "layout(location=5) in vec2 a_instance_rotation;\\n" +
2092
- "layout(location=6) in float a_instance_tint;\\n" +
2093
- "uniform mat4 u_matrix;\\n" +
2094
- "out vec3 v_color;\\n" +
2095
- "void main() {\\n" +
2096
- " vec2 horizontal = a_position.xz * a_instance_scale.xz;\\n" +
2097
- " vec2 rotated = vec2(\\n" +
2098
- " horizontal.x * a_instance_rotation.x - horizontal.y * a_instance_rotation.y,\\n" +
2099
- " horizontal.x * a_instance_rotation.y + horizontal.y * a_instance_rotation.x\\n" +
2100
- " );\\n" +
2101
- " vec3 scaledNormal = normalize(a_normal / max(a_instance_scale, vec3(0.001)));\\n" +
2102
- " vec2 normalHorizontal = vec2(\\n" +
2103
- " scaledNormal.x * a_instance_rotation.x - scaledNormal.z * a_instance_rotation.y,\\n" +
2104
- " scaledNormal.x * a_instance_rotation.y + scaledNormal.z * a_instance_rotation.x\\n" +
2105
- " );\\n" +
2106
- " vec3 mapNormal = normalize(vec3(normalHorizontal.x, normalHorizontal.y, scaledNormal.y));\\n" +
2107
- " float sky = mapNormal.z * 0.5 + 0.5;\\n" +
2108
- " float key = max(dot(mapNormal, normalize(vec3(-0.35, -0.5, 0.78))), 0.0);\\n" +
2109
- " float heightOcclusion = mix(0.84, 1.0, smoothstep(0.16, 0.88, a_position.y));\\n" +
2110
- " float light = (0.67 + sky * 0.18 + key * 0.23) * heightOcclusion;\\n" +
2111
- " v_color = a_color * light * a_instance_tint;\\n" +
2112
- " vec3 mapPosition = vec3(\\n" +
2113
- " a_instance_position.x + rotated.x,\\n" +
2114
- " a_instance_position.z + rotated.y,\\n" +
2115
- " a_instance_position.y + a_position.y * a_instance_scale.y\\n" +
2116
- " );\\n" +
2117
- " gl_Position = u_matrix * vec4(mapPosition, 1.0);\\n" +
2118
- "}\\n"
2119
- );
2120
- const fragmentShader = compileRawShader(
2121
- gl,
2122
- gl.FRAGMENT_SHADER,
2123
- "#version 300 es\\n" +
2124
- "precision highp float;\\n" +
2125
- "in vec3 v_color;\\n" +
2126
- "out vec4 out_color;\\n" +
2127
- "vec3 linearToSrgb(vec3 color) {\\n" +
2128
- " vec3 lower = color * 12.92;\\n" +
2129
- " vec3 upper = 1.055 * pow(color, vec3(1.0 / 2.4)) - 0.055;\\n" +
2130
- " return mix(lower, upper, step(vec3(0.0031308), color));\\n" +
2131
- "}\\n" +
2132
- "void main() { out_color = vec4(linearToSrgb(v_color), 1.0); }\\n"
2133
- );
2134
- const program = gl.createProgram();
2135
- if (!program) throw new Error("Unable to create tree program");
2136
- gl.attachShader(program, vertexShader);
2137
- gl.attachShader(program, fragmentShader);
2138
- gl.linkProgram(program);
2139
- gl.deleteShader(vertexShader);
2140
- gl.deleteShader(fragmentShader);
2141
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
2142
- const message = gl.getProgramInfoLog(program) || "unknown program error";
2143
- gl.deleteProgram(program);
2144
- throw new Error(message);
2145
- }
2146
- return program;
2147
- }
2148
-
2149
- function createRawTreeShadowProgram(gl) {
2150
- const vertexShader = compileRawShader(
2151
- gl,
2152
- gl.VERTEX_SHADER,
2153
- "#version 300 es\\n" +
2154
- "precision highp float;\\n" +
2155
- "layout(location=0) in vec2 a_corner;\\n" +
2156
- "layout(location=1) in vec4 a_instance;\\n" +
2157
- "uniform mat4 u_matrix;\\n" +
2158
- "out vec2 v_shadow_coordinate;\\n" +
2159
- "void main() {\\n" +
2160
- " v_shadow_coordinate = a_corner;\\n" +
2161
- " vec2 shadowOffset = vec2(-0.28, 0.34) * a_instance.w;\\n" +
2162
- " vec2 shadowPosition = a_corner * vec2(1.0, 0.76) * a_instance.w + shadowOffset;\\n" +
2163
- " vec3 mapPosition = vec3(\\n" +
2164
- " a_instance.x + shadowPosition.x,\\n" +
2165
- " a_instance.z + shadowPosition.y,\\n" +
2166
- " a_instance.y + 0.04\\n" +
2167
- " );\\n" +
2168
- " gl_Position = u_matrix * vec4(mapPosition, 1.0);\\n" +
2169
- "}\\n"
2170
- );
2171
- const fragmentShader = compileRawShader(
2172
- gl,
2173
- gl.FRAGMENT_SHADER,
2174
- "#version 300 es\\n" +
2175
- "precision mediump float;\\n" +
2176
- "in vec2 v_shadow_coordinate;\\n" +
2177
- "uniform float u_opacity;\\n" +
2178
- "out vec4 out_color;\\n" +
2179
- "void main() {\\n" +
2180
- " float radiusSquared = dot(v_shadow_coordinate, v_shadow_coordinate);\\n" +
2181
- " if (radiusSquared >= 1.0) discard;\\n" +
2182
- " float coverage = 1.0 - smoothstep(0.90, 1.0, radiusSquared);\\n" +
2183
- " float alpha = u_opacity * coverage;\\n" +
2184
- " out_color = vec4(0.18, 0.21, 0.17, alpha);\\n" +
2185
- "}\\n"
2186
- );
2187
- const program = gl.createProgram();
2188
- if (!program) throw new Error("Unable to create tree shadow program");
2189
- gl.attachShader(program, vertexShader);
2190
- gl.attachShader(program, fragmentShader);
2191
- gl.linkProgram(program);
2192
- gl.deleteShader(vertexShader);
2193
- gl.deleteShader(fragmentShader);
2194
- if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
2195
- const message = gl.getProgramInfoLog(program) || "unknown tree shadow program error";
2196
- gl.deleteProgram(program);
2197
- throw new Error(message);
2198
- }
2199
- return program;
2200
- }
2201
-
2202
- function createRawTreeShadowResource(gl) {
2203
- const vertexArray = gl.createVertexArray();
2204
- const cornerBuffer = gl.createBuffer();
2205
- const instanceBuffer = gl.createBuffer();
2206
- if (!vertexArray || !cornerBuffer || !instanceBuffer) {
2207
- throw new Error("Unable to allocate tree shadow buffers");
2208
- }
2209
- gl.bindVertexArray(vertexArray);
2210
- gl.bindBuffer(gl.ARRAY_BUFFER, cornerBuffer);
2211
- gl.bufferData(
2212
- gl.ARRAY_BUFFER,
2213
- new Float32Array([-1, -1, 1, -1, 1, 1, -1, 1]),
2214
- gl.STATIC_DRAW
2215
- );
2216
- gl.enableVertexAttribArray(0);
2217
- gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
2218
- gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
2219
- gl.bufferData(gl.ARRAY_BUFFER, 0, gl.DYNAMIC_DRAW);
2220
- gl.enableVertexAttribArray(1);
2221
- gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 16, 0);
2222
- gl.vertexAttribDivisor(1, 1);
2223
- gl.bindVertexArray(null);
2224
- return {cornerBuffer, count: 0, instanceBuffer, vertexArray};
2225
- }
2226
-
2227
- function createRawTreeResource(gl, geometry) {
2228
- const vertexArray = gl.createVertexArray();
2229
- const positionBuffer = gl.createBuffer();
2230
- const normalBuffer = gl.createBuffer();
2231
- const colorBuffer = gl.createBuffer();
2232
- const indexBuffer = gl.createBuffer();
2233
- const instanceBuffer = gl.createBuffer();
2234
- if (
2235
- !vertexArray ||
2236
- !positionBuffer ||
2237
- !normalBuffer ||
2238
- !colorBuffer ||
2239
- !indexBuffer ||
2240
- !instanceBuffer
2241
- ) {
2242
- throw new Error("Unable to allocate tree buffers");
2243
- }
2244
- gl.bindVertexArray(vertexArray);
2245
- const attributes = [
2246
- [0, positionBuffer, geometry.getAttribute("position").array],
2247
- [1, normalBuffer, geometry.getAttribute("normal").array],
2248
- [2, colorBuffer, geometry.getAttribute("color").array]
2249
- ];
2250
- for (const [location, buffer, values] of attributes) {
2251
- gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
2252
- gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW);
2253
- gl.enableVertexAttribArray(location);
2254
- gl.vertexAttribPointer(location, 3, gl.FLOAT, false, 0, 0);
2255
- }
2256
- gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
2257
- gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.index.array, gl.STATIC_DRAW);
2258
- gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
2259
- gl.bufferData(gl.ARRAY_BUFFER, 0, gl.DYNAMIC_DRAW);
2260
- gl.enableVertexAttribArray(3);
2261
- gl.vertexAttribPointer(3, 3, gl.FLOAT, false, 36, 0);
2262
- gl.vertexAttribDivisor(3, 1);
2263
- gl.enableVertexAttribArray(4);
2264
- gl.vertexAttribPointer(4, 3, gl.FLOAT, false, 36, 12);
2265
- gl.vertexAttribDivisor(4, 1);
2266
- gl.enableVertexAttribArray(5);
2267
- gl.vertexAttribPointer(5, 2, gl.FLOAT, false, 36, 24);
2268
- gl.vertexAttribDivisor(5, 1);
2269
- gl.enableVertexAttribArray(6);
2270
- gl.vertexAttribPointer(6, 1, gl.FLOAT, false, 36, 32);
2271
- gl.vertexAttribDivisor(6, 1);
2272
- gl.bindVertexArray(null);
2273
- return {
2274
- colorBuffer,
2275
- count: 0,
2276
- indexBuffer,
2277
- indexCount: geometry.index.count,
2278
- instanceBuffer,
2279
- normalBuffer,
2280
- positionBuffer,
2281
- vertexArray
2282
- };
2283
- }
2284
-
2285
- function disposeRawTreeResources() {
2286
- if (!rawGl) return;
2287
- for (const resource of rawTreeResources) {
2288
- rawGl.deleteBuffer(resource.positionBuffer);
2289
- rawGl.deleteBuffer(resource.normalBuffer);
2290
- rawGl.deleteBuffer(resource.colorBuffer);
2291
- rawGl.deleteBuffer(resource.indexBuffer);
2292
- rawGl.deleteBuffer(resource.instanceBuffer);
2293
- rawGl.deleteVertexArray(resource.vertexArray);
2294
- }
2295
- rawTreeResources = [];
2296
- if (rawProgram) rawGl.deleteProgram(rawProgram);
2297
- if (rawShadowResource) {
2298
- rawGl.deleteBuffer(rawShadowResource.cornerBuffer);
2299
- rawGl.deleteBuffer(rawShadowResource.instanceBuffer);
2300
- rawGl.deleteVertexArray(rawShadowResource.vertexArray);
2301
- }
2302
- if (rawShadowProgram) rawGl.deleteProgram(rawShadowProgram);
2303
- rawProgram = undefined;
2304
- rawMatrixUniform = undefined;
2305
- rawShadowProgram = undefined;
2306
- rawShadowMatrixUniform = undefined;
2307
- rawShadowOpacityUniform = undefined;
2308
- rawShadowResource = undefined;
2309
- }
2310
-
2311
- function numberProperty(properties, name) {
2312
- const value = Number(properties?.[name]);
2313
- return Number.isFinite(value) && value > 0 ? value : undefined;
2314
- }
2315
-
2316
- function stableTreeKey(feature, lng, lat) {
2317
- const properties = feature.properties ?? {};
2318
- const sourceFeatureId =
2319
- properties.osm_id ??
2320
- properties.osmId ??
2321
- properties.id ??
2322
- properties["@id"] ??
2323
- feature.id;
2324
- return sourceFeatureId === undefined || sourceFeatureId === null
2325
- ? lng.toFixed(6) + ":" + lat.toFixed(6)
2326
- : String(sourceFeatureId);
2327
- }
2328
-
2329
- function stableUnit(key, salt = 0) {
2330
- let hash = (2166136261 ^ salt) >>> 0;
2331
- for (let index = 0; index < key.length; index += 1) {
2332
- hash ^= key.charCodeAt(index);
2333
- hash = Math.imul(hash, 16777619);
2334
- }
2335
- hash += hash << 13;
2336
- hash ^= hash >>> 7;
2337
- hash += hash << 3;
2338
- hash ^= hash >>> 17;
2339
- hash += hash << 5;
2340
- return (hash >>> 0) / 4294967296;
2341
- }
2342
-
2343
- function treeLodForZoom(zoom) {
2344
- return treeLods.find((lod) => zoom < lod.untilZoom) ?? treeLods.at(-1);
2345
- }
2346
-
2347
- function setFallbackVisible(visible) {
2348
- if (fallbackVisible === visible || !map.getLayer(styleLayer.id)) return;
2349
- map.setPaintProperty(
2350
- styleLayer.id,
2351
- "circle-opacity",
2352
- visible ? fallbackCircleOpacity : 0
2353
- );
2354
- map.setPaintProperty(
2355
- styleLayer.id,
2356
- "circle-stroke-opacity",
2357
- visible ? fallbackCircleStrokeOpacity : 0
2358
- );
2359
- fallbackVisible = visible;
2360
- }
2361
-
2362
- function updateFallbackVisibility() {
2363
- // A non-empty query can still represent only one loaded tile. Keep
2364
- // the vector circles underneath until MapLibre reports the complete
2365
- // source settled, otherwise a partial 3D batch erases neighbouring
2366
- // tiles at source boundaries.
2367
- setFallbackVisible(!sourceSettled || renderedTreeCount === 0);
2368
- }
2369
-
2370
- function treeSelectionSignature(trees, lodName) {
2371
- let hash = 2166136261;
2372
- for (const tree of trees) {
2373
- for (let index = 0; index < tree.key.length; index += 1) {
2374
- hash ^= tree.key.charCodeAt(index);
2375
- hash = Math.imul(hash, 16777619);
2376
- }
2377
- hash ^= 0xff;
2378
- hash = Math.imul(hash, 16777619);
2379
- }
2380
- return lodName + ":" + trees.length + ":" + (hash >>> 0);
2381
- }
2382
-
2383
- function selectVisibleTrees(features, lod) {
2384
- const worldSize = 512 * Math.pow(2, map.getZoom());
2385
- const seen = new Set();
2386
- const candidates = [];
2387
- for (const feature of features) {
2388
- if (feature.geometry?.type !== "Point") continue;
2389
- const [lng, lat] = feature.geometry.coordinates;
2390
- const key = stableTreeKey(feature, lng, lat);
2391
- if (seen.has(key)) continue;
2392
- seen.add(key);
2393
- const latitudeSine = Math.sin(
2394
- (Math.max(-85.051129, Math.min(85.051129, lat)) * Math.PI) / 180
2395
- );
2396
- const worldX = ((lng + 180) / 360) * worldSize;
2397
- const worldY =
2398
- (0.5 - Math.log((1 + latitudeSine) / (1 - latitudeSine)) / (4 * Math.PI)) *
2399
- worldSize;
2400
- candidates.push({feature, key, lat, lng, worldX, worldY});
2401
- }
2402
- candidates.sort((left, right) =>
2403
- left.key < right.key ? -1 : left.key > right.key ? 1 : 0
2404
- );
2405
- if (lod.densityCellPixels === 0) return candidates;
2406
- const occupiedCells = new Set();
2407
- const selected = [];
2408
- for (const candidate of candidates) {
2409
- const cellKey =
2410
- Math.floor(candidate.worldX / lod.densityCellPixels) +
2411
- ":" +
2412
- Math.floor(candidate.worldY / lod.densityCellPixels);
2413
- if (occupiedCells.has(cellKey)) continue;
2414
- occupiedCells.add(cellKey);
2415
- selected.push(candidate);
2416
- }
2417
- return selected;
2418
- }
2419
-
2420
- function nextTreeInstanceCapacity(requiredCapacity) {
2421
- let capacity = initialTreeInstanceCapacity;
2422
- while (capacity < requiredCapacity) capacity *= 2;
2423
- return capacity;
2424
- }
2425
-
2426
- function createThreeTreeMesh(geometry, capacity) {
2427
- const treeMesh = new THREE.InstancedMesh(geometry, treeMaterial, capacity);
2428
- treeMesh.count = 0;
2429
- treeMesh.frustumCulled = false;
2430
- return treeMesh;
2431
- }
2432
-
2433
- function ensureThreeTreeMeshCapacity(variantIndex, requiredCapacity) {
2434
- if (treeMeshCapacities[variantIndex] >= requiredCapacity) return;
2435
- const currentTreeMesh = treeMeshes[variantIndex];
2436
- const treeMesh = createThreeTreeMesh(
2437
- treeGeometries[variantIndex],
2438
- nextTreeInstanceCapacity(requiredCapacity)
2439
- );
2440
- treeGroup.remove(currentTreeMesh);
2441
- currentTreeMesh.dispose();
2442
- treeMeshes[variantIndex] = treeMesh;
2443
- treeMeshCapacities[variantIndex] = treeMesh.instanceMatrix.count;
2444
- treeGroup.add(treeMesh);
2445
- }
2446
-
2447
- function ensureThreeTreeShadowCapacity(requiredCapacity) {
2448
- if (treeShadowCapacity >= requiredCapacity) return;
2449
- const shadowMesh = new THREE.InstancedMesh(
2450
- treeShadowGeometry,
2451
- treeShadowMaterial,
2452
- nextTreeInstanceCapacity(requiredCapacity)
2453
- );
2454
- shadowMesh.count = 0;
2455
- shadowMesh.frustumCulled = false;
2456
- shadowMesh.renderOrder = 1;
2457
- treeGroup.remove(treeShadowMesh);
2458
- treeShadowMesh.dispose();
2459
- treeShadowMesh = shadowMesh;
2460
- treeShadowCapacity = shadowMesh.instanceMatrix.count;
2461
- treeGroup.add(shadowMesh);
2462
- }
2463
-
2464
- function rememberTreeState(key, state) {
2465
- if (treeStateCache.size >= maximumCachedTrees) {
2466
- treeStateCache.delete(treeStateCache.keys().next().value);
2467
- }
2468
- treeStateCache.set(key, state);
2469
- return state;
2470
- }
2471
-
2472
- const treeFormDimensions = {
2473
- round: {crownMaximum: 0.9, crownMinimum: 0.66, heightMaximum: 12, heightMinimum: 6},
2474
- open: {crownMaximum: 1.05, crownMinimum: 0.8, heightMaximum: 10, heightMinimum: 5},
2475
- tall: {crownMaximum: 0.68, crownMinimum: 0.48, heightMaximum: 17, heightMinimum: 9},
2476
- conifer: {crownMaximum: 0.66, crownMinimum: 0.46, heightMaximum: 16, heightMinimum: 7},
2477
- columnar: {crownMaximum: 0.36, crownMinimum: 0.22, heightMaximum: 18, heightMinimum: 9},
2478
- palm: {crownMaximum: 0.5, crownMinimum: 0.34, heightMaximum: 16, heightMinimum: 8}
2479
- };
2480
-
2481
- function resolveTreeState(feature, key, lng, lat, treeElevation) {
2482
- const cached = treeStateCache.get(key);
2483
- if (cached) return cached;
2484
- const variation = stableUnit(key, 1);
2485
- const form = resolveTreeForm(feature.properties, key);
2486
- const dimensions = treeFormDimensions[form] ?? treeFormDimensions.round;
2487
- const baseHeight = Math.min(
2488
- 32,
2489
- numberProperty(feature.properties, heightField) ??
2490
- dimensions.heightMinimum +
2491
- variation * (dimensions.heightMaximum - dimensions.heightMinimum)
2492
- );
2493
- const baseCrownDiameter = Math.min(
2494
- 20,
2495
- numberProperty(feature.properties, crownField) ??
2496
- baseHeight *
2497
- (dimensions.crownMinimum +
2498
- stableUnit(key, 2) * (dimensions.crownMaximum - dimensions.crownMinimum))
2499
- );
2500
- return rememberTreeState(key, {
2501
- colorGain: 0.94 + stableUnit(key, 13) * 0.12,
2502
- crownAspect: 0.92 + stableUnit(key, 11) * 0.16,
2503
- crownDiameter:
2504
- baseCrownDiameter * crownScale * (0.94 + stableUnit(key, 5) * 0.12),
2505
- form,
2506
- height: baseHeight * heightScale,
2507
- lat,
2508
- lng,
2509
- treeElevation,
2510
- variantIndex: treeVariantIndex(form, key)
2511
- });
2512
- }
2513
-
2514
- function ensureSceneOrigin(elevation) {
2515
- if (sceneOriginMercator) return;
2516
- const center = map.getCenter();
2517
- sceneElevation = elevation;
2518
- sceneOriginMercator = maplibregl.MercatorCoordinate.fromLngLat(
2519
- center,
2520
- sceneElevation
2521
- );
2522
- }
2523
-
2524
- function localTreePosition(lng, lat, elevation) {
2525
- const mercator = maplibregl.MercatorCoordinate.fromLngLat([lng, lat]);
2526
- const mercatorPerMeter = sceneOriginMercator.meterInMercatorCoordinateUnits();
2527
- return new THREE.Vector3(
2528
- (mercator.x - sceneOriginMercator.x) / mercatorPerMeter,
2529
- elevation - sceneElevation,
2530
- (sceneOriginMercator.y - mercator.y) / mercatorPerMeter
2531
- );
2532
- }
2533
-
2534
- function createTerrainSampler(trees, fallbackElevation) {
2535
- const gridSize = 5;
2536
- const longitudes = trees.map((tree) => tree.lng);
2537
- const latitudes = trees.map((tree) => tree.lat);
2538
- const minimumLongitude = Math.min(...longitudes);
2539
- const maximumLongitude = Math.max(...longitudes);
2540
- const minimumLatitude = Math.min(...latitudes);
2541
- const maximumLatitude = Math.max(...latitudes);
2542
- const longitudeSpan = Math.max(1e-9, maximumLongitude - minimumLongitude);
2543
- const latitudeSpan = Math.max(1e-9, maximumLatitude - minimumLatitude);
2544
- const elevations = new Float32Array(gridSize * gridSize);
2545
- for (let y = 0; y < gridSize; y += 1) {
2546
- for (let x = 0; x < gridSize; x += 1) {
2547
- const longitude = minimumLongitude + (x / (gridSize - 1)) * longitudeSpan;
2548
- const latitude = minimumLatitude + (y / (gridSize - 1)) * latitudeSpan;
2549
- elevations[y * gridSize + x] =
2550
- map.queryTerrainElevation([longitude, latitude]) ?? fallbackElevation;
2551
- }
2552
- }
2553
- return (longitude, latitude) => {
2554
- const gridX = Math.max(
2555
- 0,
2556
- Math.min(gridSize - 1, ((longitude - minimumLongitude) / longitudeSpan) * (gridSize - 1))
2557
- );
2558
- const gridY = Math.max(
2559
- 0,
2560
- Math.min(gridSize - 1, ((latitude - minimumLatitude) / latitudeSpan) * (gridSize - 1))
2561
- );
2562
- const x0 = Math.floor(gridX);
2563
- const y0 = Math.floor(gridY);
2564
- const x1 = Math.min(gridSize - 1, x0 + 1);
2565
- const y1 = Math.min(gridSize - 1, y0 + 1);
2566
- const xMix = gridX - x0;
2567
- const yMix = gridY - y0;
2568
- const bottom = elevations[y0 * gridSize + x0] * (1 - xMix) +
2569
- elevations[y0 * gridSize + x1] * xMix;
2570
- const top = elevations[y1 * gridSize + x0] * (1 - xMix) +
2571
- elevations[y1 * gridSize + x1] * xMix;
2572
- return bottom * (1 - yMix) + top * yMix;
2573
- };
2574
- }
2575
-
2576
- function botanicalName(properties) {
2577
- return [properties?.[genusField], properties?.[speciesField]]
2578
- .filter(Boolean)
2579
- .join(" ")
2580
- .toLowerCase();
2581
- }
2582
-
2583
- function resolveTreeForm(properties, key) {
2584
- const leafType = String(properties?.[leafTypeField] ?? "").toLowerCase();
2585
- const botanical = botanicalName(properties);
2586
- if (
2587
- leafType.includes("palm") ||
2588
- /(?:^| )(?:archontophoenix|arecaceae|butia|chamaerops|cocos|howea|livistona|phoenix|sabal|syagrus|trachycarpus|washingtonia)(?: |$)/.test(
2589
- botanical
2590
- )
2591
- ) return "palm";
2592
- if (/(?:^| )(?:cupressus|cypress)(?: |$)/.test(botanical)) return "columnar";
2593
- if (
2594
- leafType.includes("needle") ||
2595
- /(?:^| )(?:abies|cedrus|juniperus|picea|pinus|taxodium|taxus)(?: |$)/.test(botanical)
2596
- ) return "conifer";
2597
- if (
2598
- /(?:^| )(?:arbutus|olea|quercus|tamarix)(?: |$)/.test(botanical)
2599
- ) return "open";
2600
- if (
2601
- /(?:^| )(?:celtis|ginkgo|platanus|populus|ulmus)(?: |$)/.test(botanical)
2602
- ) return "tall";
2603
- return ["round", "open", "tall"][Math.floor(stableUnit(key, 67) * 3)];
2604
- }
2605
-
2606
- function treeVariantIndex(form, key) {
2607
- const alternate = Math.floor(stableUnit(key, 101) * 2);
2608
- const base = {
2609
- round: 0,
2610
- open: 2,
2611
- tall: 4,
2612
- conifer: 6,
2613
- columnar: 7,
2614
- palm: 8
2615
- }[form] ?? 0;
2616
- return base + (["round", "open", "tall", "palm"].includes(form) ? alternate : 0);
2617
- }
2618
-
2619
- function refresh() {
2620
- const refreshStartedAt = performance.now();
2621
- refreshQueued = false;
2622
- lastRefreshTime = performance.now();
2623
- if (
2624
- activeTreeBackend === "three"
2625
- ? !treeGroup || treeMeshes.length === 0
2626
- : rawTreeResources.length === 0
2627
- ) return;
2628
- if (!treesEnabled) {
2629
- if (treeGroup) treeGroup.visible = false;
2630
- rawTreesVisible = false;
2631
- setFallbackVisible(false);
2632
- return;
2633
- }
2634
- if (map.getZoom() < 15.5) {
2635
- for (const treeMesh of treeMeshes) treeMesh.count = 0;
2636
- for (const resource of rawTreeResources) resource.count = 0;
2637
- if (treeShadowMesh) treeShadowMesh.count = 0;
2638
- if (rawShadowResource) rawShadowResource.count = 0;
2639
- if (treeGroup) treeGroup.visible = false;
2640
- rawTreesVisible = false;
2641
- renderedTreeCount = 0;
2642
- treeMetrics.treeCount = 0;
2643
- treeMetrics.refreshMilliseconds = performance.now() - refreshStartedAt;
2644
- lastTreeSelectionSignature = undefined;
2645
- setFallbackVisible(true);
2646
- updateTreeStatus("zoom too low", {phase: "refresh", treeCount: 0});
2647
- map.triggerRepaint();
2648
- return;
2649
- }
2650
- let features;
2651
- const queryStartedAt = performance.now();
2652
- try {
2653
- const canvas = map.getCanvas();
2654
- const viewportWidth = canvas.clientWidth || canvas.width;
2655
- const viewportHeight = canvas.clientHeight || canvas.height;
2656
- features = map.queryRenderedFeatures(
2657
- [
2658
- [-viewportBufferPixels, -viewportBufferPixels],
2659
- [
2660
- viewportWidth + viewportBufferPixels,
2661
- viewportHeight + viewportBufferPixels
2662
- ]
2663
- ],
2664
- {layers: [styleLayer.id]}
2665
- );
2666
- } catch (error) {
2667
- updateTreeStatus("error reading source", {
2668
- error: String(error),
2669
- phase: "refresh",
2670
- treeCount: 0
2671
- });
2672
- return;
2673
- }
2674
- treeMetrics.queryMilliseconds = performance.now() - queryStartedAt;
2675
- sourceSettled = map.isSourceLoaded(sourceId);
2676
- const lod = treeLodForZoom(map.getZoom());
2677
- const selectionStartedAt = performance.now();
2678
- const visibleTrees = selectVisibleTrees(features, lod);
2679
- treeMetrics.selectionMilliseconds = performance.now() - selectionStartedAt;
2680
- if (visibleTrees.length === 0) {
2681
- updateFallbackVisibility();
2682
- if (emptyRefreshAttempts < 12) {
2683
- emptyRefreshAttempts += 1;
2684
- clearTimeout(emptyRefreshTimer);
2685
- emptyRefreshTimer = setTimeout(queueRefresh, 150);
2686
- }
2687
- updateTreeStatus("waiting for tiles", {
2688
- lod: lod.name,
2689
- phase: "refresh",
2690
- sourceFeatureCount: features.length,
2691
- sourceLoaded: sourceSettled,
2692
- treeCount: renderedTreeCount
2693
- });
2694
- map.triggerRepaint();
2695
- return;
2696
- }
2697
- const selectionSignature = treeSelectionSignature(visibleTrees, lod.name);
2698
- if (sourceSettled && selectionSignature === lastTreeSelectionSignature) {
2699
- if (treeGroup) treeGroup.visible = true;
2700
- rawTreesVisible = true;
2701
- treeMetrics.refreshMilliseconds = performance.now() - refreshStartedAt;
2702
- updateFallbackVisibility();
2703
- map.triggerRepaint();
2704
- return;
2705
- }
2706
- // The viewport elevation is only a fallback. Each tree uses its own
2707
- // terrain sample so sloped ground cannot clip different parts of the
2708
- // crown as the camera center moves.
2709
- const viewportElevation = map.queryTerrainElevation(map.getCenter()) ?? 0;
2710
- ensureSceneOrigin(viewportElevation);
2711
- const terrainStartedAt = performance.now();
2712
- const terrainElevationAt = createTerrainSampler(visibleTrees, viewportElevation);
2713
- treeMetrics.terrainMilliseconds = performance.now() - terrainStartedAt;
2714
- const buildStartedAt = performance.now();
2715
- let count = 0;
2716
- const variantCount = activeTreeBackend === "three"
2717
- ? treeMeshes.length
2718
- : rawTreeResources.length;
2719
- const variantCounts = Array.from({length: variantCount}, () => 0);
2720
- const rawInstanceValues = Array.from({length: variantCount}, () => []);
2721
- const rawShadowInstanceValues = [];
2722
- for (const {feature, key, lat, lng} of visibleTrees) {
2723
- const treeState = resolveTreeState(
2724
- feature,
2725
- key,
2726
- lng,
2727
- lat,
2728
- terrainElevationAt(lng, lat)
2729
- );
2730
- const {
2731
- colorGain,
2732
- crownAspect,
2733
- crownDiameter,
2734
- height,
2735
- lat: treeLat,
2736
- lng: treeLng,
2737
- treeElevation,
2738
- variantIndex
2739
- } = treeState;
2740
- const treePosition = localTreePosition(treeLng, treeLat, treeElevation);
2741
- const rotation = stableUnit(key, 3) * Math.PI * 2;
2742
- const crownWidth = crownDiameter * crownAspect;
2743
- const crownDepth = crownDiameter / crownAspect;
2744
- const shadowRadius = Math.max(0.75, Math.min(5.5, crownDiameter * 0.42));
2745
- if (activeTreeBackend === "three") {
2746
- ensureThreeTreeMeshCapacity(
2747
- variantIndex,
2748
- variantCounts[variantIndex] + 1
2749
- );
2750
- ensureThreeTreeShadowCapacity(count + 1);
2751
- crownRotation.setFromAxisAngle(yAxis, rotation);
2752
- position.copy(treePosition);
2753
- scale.set(crownWidth, height, crownDepth);
2754
- matrix.compose(position, crownRotation, scale);
2755
- const treeMesh = treeMeshes[variantIndex];
2756
- treeMesh.setMatrixAt(variantCounts[variantIndex], matrix);
2757
- instanceTint.setRGB(colorGain, colorGain, colorGain);
2758
- treeMesh.setColorAt(variantCounts[variantIndex], instanceTint);
2759
- shadowPosition.copy(treePosition);
2760
- shadowPosition.x -= shadowRadius * 0.28;
2761
- shadowPosition.y += treeShadowLiftMeters;
2762
- shadowPosition.z += shadowRadius * 0.34;
2763
- shadowScale.set(shadowRadius, 1, shadowRadius * 0.76);
2764
- shadowMatrix.compose(shadowPosition, shadowRotation, shadowScale);
2765
- treeShadowMesh.setMatrixAt(count, shadowMatrix);
2766
- } else {
2767
- rawInstanceValues[variantIndex].push(
2768
- treePosition.x,
2769
- treePosition.y,
2770
- treePosition.z,
2771
- crownWidth,
2772
- height,
2773
- crownDepth,
2774
- Math.cos(rotation),
2775
- Math.sin(rotation),
2776
- colorGain
2777
- );
2778
- rawShadowInstanceValues.push(
2779
- treePosition.x,
2780
- treePosition.y,
2781
- treePosition.z,
2782
- shadowRadius
2783
- );
2784
- }
2785
- variantCounts[variantIndex] += 1;
2786
- count += 1;
2787
- }
2788
- if (activeTreeBackend === "three") {
2789
- for (let variantIndex = 0; variantIndex < treeMeshes.length; variantIndex += 1) {
2790
- const treeMesh = treeMeshes[variantIndex];
2791
- treeMesh.count = variantCounts[variantIndex];
2792
- treeMesh.instanceMatrix.needsUpdate = true;
2793
- if (treeMesh.instanceColor) treeMesh.instanceColor.needsUpdate = true;
2794
- }
2795
- treeShadowMesh.count = count;
2796
- treeShadowMesh.instanceMatrix.needsUpdate = true;
2797
- } else {
2798
- for (let variantIndex = 0; variantIndex < rawTreeResources.length; variantIndex += 1) {
2799
- const resource = rawTreeResources[variantIndex];
2800
- resource.count = variantCounts[variantIndex];
2801
- rawGl.bindBuffer(rawGl.ARRAY_BUFFER, resource.instanceBuffer);
2802
- rawGl.bufferData(
2803
- rawGl.ARRAY_BUFFER,
2804
- new Float32Array(rawInstanceValues[variantIndex]),
2805
- rawGl.DYNAMIC_DRAW
2806
- );
2807
- }
2808
- rawShadowResource.count = count;
2809
- rawGl.bindBuffer(rawGl.ARRAY_BUFFER, rawShadowResource.instanceBuffer);
2810
- rawGl.bufferData(
2811
- rawGl.ARRAY_BUFFER,
2812
- new Float32Array(rawShadowInstanceValues),
2813
- rawGl.DYNAMIC_DRAW
2814
- );
2815
- rawGl.bindBuffer(rawGl.ARRAY_BUFFER, null);
2816
- }
2817
- treeMetrics.buildMilliseconds = performance.now() - buildStartedAt;
2818
- if (treeGroup) treeGroup.visible = true;
2819
- rawTreesVisible = true;
2820
- renderedTreeCount = count;
2821
- treeMetrics.treeCount = count;
2822
- treeMetrics.refreshMilliseconds = performance.now() - refreshStartedAt;
2823
- lastTreeSelectionSignature = selectionSignature;
2824
- emptyRefreshAttempts = 0;
2825
- clearTimeout(emptyRefreshTimer);
2826
- emptyRefreshTimer = undefined;
2827
- updateTreeStatus(count + " 3D trees", {
2828
- lod: lod.name,
2829
- phase: "refresh",
2830
- renderer: treeRendererMode,
2831
- sourceFeatureCount: features.length,
2832
- sourceLoaded: sourceSettled,
2833
- treeCount: count
2834
- });
2835
- updateFallbackVisibility();
2836
- if (!sourceSettled) {
2837
- clearTimeout(emptyRefreshTimer);
2838
- emptyRefreshTimer = setTimeout(() => queueRefresh(true), 150);
2839
- }
2840
- map.triggerRepaint();
2841
- }
2842
-
2843
- function queueRefresh(immediate = false) {
2844
- if (refreshQueued) return;
2845
- if (immediate && refreshTimer !== undefined) {
2846
- clearTimeout(refreshTimer);
2847
- refreshTimer = undefined;
2848
- }
2849
- if (!immediate) {
2850
- if (refreshTimer !== undefined) return;
2851
- const elapsed = performance.now() - lastRefreshTime;
2852
- const delay = Math.max(0, sourceRefreshIntervalMilliseconds - elapsed);
2853
- if (delay > 0) {
2854
- refreshTimer = setTimeout(() => {
2855
- refreshTimer = undefined;
2856
- queueRefresh(true);
2857
- }, delay);
2858
- return;
2859
- }
2860
- }
2861
- refreshQueued = true;
2862
- requestAnimationFrame(refresh);
2863
- }
2864
-
2865
- function handleMoveStart() {
2866
- if (!treesEnabled) return;
2867
- sourceSettled = false;
2868
- updateFallbackVisibility();
2869
- }
2870
-
2871
- function handleMoveEnd() {
2872
- if (!treesEnabled) return;
2873
- emptyRefreshAttempts = 0;
2874
- sourceSettled = map.isSourceLoaded(sourceId);
2875
- updateFallbackVisibility();
2876
- queueRefresh(true);
2877
- }
2878
-
2879
- function handleSourceLoading(event) {
2880
- if (!treesEnabled || event.sourceId !== sourceId) return;
2881
- sourceSettled = false;
2882
- updateFallbackVisibility();
2883
- }
2884
-
2885
- function handleSourceData(event) {
2886
- if (!treesEnabled || event.sourceId !== sourceId) return;
2887
- sourceSettled = map.isSourceLoaded(sourceId);
2888
- if (map.isMoving()) return;
2889
- queueRefresh();
2890
- }
2891
-
2892
- function handleTreesToggle() {
2893
- clearTimeout(emptyRefreshTimer);
2894
- clearTimeout(refreshTimer);
2895
- emptyRefreshTimer = undefined;
2896
- refreshTimer = undefined;
2897
- if (!treesEnabled) {
2898
- if (treeGroup) treeGroup.visible = false;
2899
- rawTreesVisible = false;
2900
- setFallbackVisible(false);
2901
- updateTreeStatus("disabled", {phase: "toggle", treeCount: renderedTreeCount});
2902
- map.triggerRepaint();
2903
- return;
2904
- }
2905
- if (treeGroup) treeGroup.visible = renderedTreeCount > 0;
2906
- rawTreesVisible = renderedTreeCount > 0;
2907
- updateFallbackVisibility();
2908
- updateTreeStatus(
2909
- renderedTreeCount > 0 ? renderedTreeCount + " 3D trees" : "enabling",
2910
- {phase: "toggle", treeCount: renderedTreeCount}
2911
- );
2912
- queueRefresh(true);
2913
- map.triggerRepaint();
2914
- }
2915
-
2916
- return {
2917
- id: "tileflow-vegetation-trees-3d",
2918
- type: "custom",
2919
- renderingMode: "3d",
2920
- onAdd(_map, gl) {
2921
- updateTreeStatus("layer mounted", {phase: "onAdd", treeCount: 0});
2922
- const geometries = createTreeGeometries();
2923
- if (
2924
- activeTreeBackend === "webgl2" &&
2925
- typeof gl.createVertexArray === "function" &&
2926
- typeof gl.drawArraysInstanced === "function"
2927
- ) {
2928
- rawGl = gl;
2929
- rawProgram = createRawTreeProgram(gl);
2930
- rawMatrixUniform = gl.getUniformLocation(rawProgram, "u_matrix");
2931
- rawShadowProgram = createRawTreeShadowProgram(gl);
2932
- rawShadowMatrixUniform = gl.getUniformLocation(rawShadowProgram, "u_matrix");
2933
- rawShadowOpacityUniform = gl.getUniformLocation(rawShadowProgram, "u_opacity");
2934
- rawShadowResource = createRawTreeShadowResource(gl);
2935
- rawTreeResources = geometries.map((geometry) =>
2936
- createRawTreeResource(gl, geometry)
2937
- );
2938
- for (const geometry of geometries) geometry.dispose();
2939
- } else {
2940
- activeTreeBackend = "three";
2941
- treeMetrics.backend = activeTreeBackend;
2942
- treeGeometries = geometries;
2943
- camera = new THREE.Camera();
2944
- scene = new THREE.Scene();
2945
- scene.rotateX(Math.PI / 2);
2946
- scene.scale.multiply(new THREE.Vector3(1, 1, -1));
2947
- scene.add(new THREE.HemisphereLight(0xf4fff1, 0x808878, 1.35));
2948
- const sunlight = new THREE.DirectionalLight(0xfff3d8, 0.88);
2949
- sunlight.position.set(-2, -3, 6);
2950
- scene.add(sunlight);
2951
- treeGroup = new THREE.Group();
2952
- treeMaterial = new THREE.MeshLambertMaterial({
2953
- flatShading: false,
2954
- vertexColors: true
2955
- });
2956
- treeMeshes = geometries.map((geometry) =>
2957
- createThreeTreeMesh(geometry, initialTreeInstanceCapacity)
2958
- );
2959
- treeMeshCapacities = geometries.map(() => initialTreeInstanceCapacity);
2960
- treeShadowGeometry = new THREE.PlaneGeometry(2, 2);
2961
- treeShadowGeometry.rotateX(-Math.PI / 2);
2962
- treeShadowMaterial = new THREE.ShaderMaterial({
2963
- depthTest: true,
2964
- depthWrite: false,
2965
- fragmentShader:
2966
- "varying vec2 v_shadow_coordinate;\\n" +
2967
- "uniform float u_opacity;\\n" +
2968
- "void main() {\\n" +
2969
- " float radiusSquared = dot(v_shadow_coordinate, v_shadow_coordinate);\\n" +
2970
- " if (radiusSquared >= 1.0) discard;\\n" +
2971
- " float coverage = 1.0 - smoothstep(0.90, 1.0, radiusSquared);\\n" +
2972
- " gl_FragColor = vec4(0.18, 0.21, 0.17, u_opacity * coverage);\\n" +
2973
- "}\\n",
2974
- side: THREE.DoubleSide,
2975
- toneMapped: false,
2976
- transparent: true,
2977
- uniforms: {u_opacity: {value: treeShadowOpacity}},
2978
- vertexShader:
2979
- "varying vec2 v_shadow_coordinate;\\n" +
2980
- "void main() {\\n" +
2981
- " v_shadow_coordinate = uv * 2.0 - 1.0;\\n" +
2982
- " vec4 instancePosition = instanceMatrix * vec4(position, 1.0);\\n" +
2983
- " gl_Position = projectionMatrix * modelViewMatrix * instancePosition;\\n" +
2984
- "}\\n"
2985
- });
2986
- treeShadowMesh = new THREE.InstancedMesh(
2987
- treeShadowGeometry,
2988
- treeShadowMaterial,
2989
- initialTreeInstanceCapacity
2990
- );
2991
- treeShadowCapacity = initialTreeInstanceCapacity;
2992
- treeShadowMesh.count = 0;
2993
- treeShadowMesh.frustumCulled = false;
2994
- treeShadowMesh.renderOrder = 1;
2995
- treeGroup.visible = false;
2996
- treeGroup.add(...treeMeshes, treeShadowMesh);
2997
- scene.add(treeGroup);
2998
- renderer = new THREE.WebGLRenderer({
2999
- canvas: map.getCanvas(),
3000
- context: gl
3001
- });
3002
- renderer.autoClear = false;
3003
- }
3004
- sourceSettled = map.isSourceLoaded(sourceId);
3005
- map.on("movestart", handleMoveStart);
3006
- map.on("moveend", handleMoveEnd);
3007
- map.on("sourcedataloading", handleSourceLoading);
3008
- map.on("sourcedata", handleSourceData);
3009
- map.on("tileflow:trees-toggle", handleTreesToggle);
3010
- if (treesEnabled) queueRefresh(true);
3011
- else handleTreesToggle();
3012
- },
3013
- render(_gl, options) {
3014
- if (
3015
- !treesEnabled ||
3016
- !sceneOriginMercator ||
3017
- (activeTreeBackend === "three" ? !treeGroup?.visible : !rawTreesVisible)
3018
- ) return;
3019
- const renderStartedAt = performance.now();
3020
- const meter = sceneOriginMercator.meterInMercatorCoordinateUnits();
3021
- mapMatrix.fromArray(options.defaultProjectionData.mainMatrix);
3022
- sceneMatrix
3023
- .makeTranslation(
3024
- sceneOriginMercator.x,
3025
- sceneOriginMercator.y,
3026
- sceneOriginMercator.z
3027
- )
3028
- .scale(new THREE.Vector3(meter, -meter, meter));
3029
- combinedMatrix.copy(mapMatrix).multiply(sceneMatrix);
3030
- if (activeTreeBackend === "three") {
3031
- camera.projectionMatrix.copy(combinedMatrix);
3032
- renderer.resetState();
3033
- renderer.render(scene, camera);
3034
- treeMetrics.renderCalls = renderer.info.render.calls;
3035
- treeMetrics.renderedTriangles = renderer.info.render.triangles;
3036
- } else {
3037
- rawGl.useProgram(rawProgram);
3038
- rawGl.uniformMatrix4fv(rawMatrixUniform, false, combinedMatrix.elements);
3039
- rawGl.enable(rawGl.DEPTH_TEST);
3040
- rawGl.depthFunc(rawGl.LEQUAL);
3041
- rawGl.depthMask(true);
3042
- rawGl.enable(rawGl.CULL_FACE);
3043
- rawGl.cullFace(rawGl.BACK);
3044
- rawGl.frontFace(rawGl.CCW);
3045
- rawGl.disable(rawGl.BLEND);
3046
- let renderCalls = 0;
3047
- let renderedTriangles = 0;
3048
- for (const resource of rawTreeResources) {
3049
- if (resource.count === 0) continue;
3050
- rawGl.bindVertexArray(resource.vertexArray);
3051
- rawGl.drawElementsInstanced(
3052
- rawGl.TRIANGLES,
3053
- resource.indexCount,
3054
- rawGl.UNSIGNED_INT,
3055
- 0,
3056
- resource.count
3057
- );
3058
- renderCalls += 1;
3059
- renderedTriangles += (resource.indexCount / 3) * resource.count;
3060
- }
3061
- rawGl.bindVertexArray(null);
3062
- if (rawShadowResource.count > 0) {
3063
- rawGl.useProgram(rawShadowProgram);
3064
- rawGl.uniformMatrix4fv(
3065
- rawShadowMatrixUniform,
3066
- false,
3067
- combinedMatrix.elements
3068
- );
3069
- rawGl.uniform1f(rawShadowOpacityUniform, treeShadowOpacity);
3070
- rawGl.depthMask(false);
3071
- rawGl.disable(rawGl.CULL_FACE);
3072
- rawGl.enable(rawGl.BLEND);
3073
- rawGl.blendEquation(rawGl.FUNC_ADD);
3074
- rawGl.blendFuncSeparate(
3075
- rawGl.SRC_ALPHA,
3076
- rawGl.ONE_MINUS_SRC_ALPHA,
3077
- rawGl.ONE,
3078
- rawGl.ONE_MINUS_SRC_ALPHA
3079
- );
3080
- rawGl.bindVertexArray(rawShadowResource.vertexArray);
3081
- rawGl.drawArraysInstanced(
3082
- rawGl.TRIANGLE_FAN,
3083
- 0,
3084
- 4,
3085
- rawShadowResource.count
3086
- );
3087
- rawGl.bindVertexArray(null);
3088
- rawGl.depthMask(true);
3089
- rawGl.disable(rawGl.BLEND);
3090
- rawGl.enable(rawGl.CULL_FACE);
3091
- renderCalls += 1;
3092
- renderedTriangles += rawShadowResource.count * 2;
3093
- }
3094
- treeMetrics.renderCalls = renderCalls;
3095
- treeMetrics.renderedTriangles = renderedTriangles;
3096
- }
3097
- treeMetrics.renderMilliseconds = performance.now() - renderStartedAt;
3098
- },
3099
- onRemove() {
3100
- clearTimeout(emptyRefreshTimer);
3101
- clearTimeout(refreshTimer);
3102
- map.off("movestart", handleMoveStart);
3103
- map.off("moveend", handleMoveEnd);
3104
- map.off("sourcedataloading", handleSourceLoading);
3105
- map.off("sourcedata", handleSourceData);
3106
- map.off("tileflow:trees-toggle", handleTreesToggle);
3107
- if (!fallbackVisible && map.getLayer(styleLayer.id)) {
3108
- map.setPaintProperty(
3109
- styleLayer.id,
3110
- "circle-opacity",
3111
- fallbackCircleOpacity
3112
- );
3113
- map.setPaintProperty(
3114
- styleLayer.id,
3115
- "circle-stroke-opacity",
3116
- fallbackCircleStrokeOpacity
3117
- );
3118
- }
3119
- for (const treeMesh of treeMeshes) {
3120
- treeMesh.dispose();
3121
- treeMesh.geometry.dispose();
3122
- }
3123
- treeMeshes = [];
3124
- treeMeshCapacities = [];
3125
- treeGeometries = [];
3126
- treeShadowMesh?.dispose();
3127
- treeShadowMesh?.geometry.dispose();
3128
- treeShadowMesh = undefined;
3129
- treeShadowGeometry = undefined;
3130
- treeShadowCapacity = 0;
3131
- treeShadowMaterial?.dispose();
3132
- treeShadowMaterial = undefined;
3133
- treeMaterial?.dispose();
3134
- renderer?.dispose();
3135
- disposeRawTreeResources();
3136
- }
3137
- };
3138
- }
3139
-
3140
- function addTreeLayerIfConfigured(map, styleLayers) {
3141
- const styleLayer = styleLayers.find(
3142
- (layer) => layer.id === "tileflow-vegetation-trees"
3143
- );
3144
- if (
3145
- !styleLayer ||
3146
- styleLayer.metadata?.["tileflow:vegetation-mode"] !== "3d" ||
3147
- map.getLayer("tileflow-vegetation-trees-3d")
3148
- ) return;
3149
- if (treeRendererMode === "circle") {
3150
- treeMetrics.treeCount = 0;
3151
- updateTreeStatus("native circles", {
3152
- phase: "configured",
3153
- renderer: treeRendererMode,
3154
- treeCount: 0
3155
- });
3156
- return;
3157
- }
3158
- updateTreeStatus("configured", {phase: "configured", treeCount: 0});
3159
- // Three.js consumes Mercator world coordinates directly. MapLibre's
3160
- // plain custom-layer matrix only supports those coordinates in the
3161
- // Mercator projection; globe requires a projection-aware shader.
3162
- if (map.getProjection().type !== "mercator") {
3163
- map.setProjection({type: "mercator"});
3164
- }
3165
- if (map.getLayer("tileflow-vegetation-trees-3d")) return;
3166
- const styleLayerIndex = styleLayers.findIndex((layer) => layer.id === styleLayer.id);
3167
- const layerAboveTrees = styleLayers[styleLayerIndex + 1]?.id;
3168
- map.addLayer(createTreeLayer(map, styleLayer), layerAboveTrees);
3169
- }
3170
-
3171
- function styleLayerZoomRange(layer) {
3172
- return {
3173
- minzoom: Number.isFinite(layer?.minzoom) ? layer.minzoom : 0,
3174
- maxzoom: Number.isFinite(layer?.maxzoom) ? layer.maxzoom : Infinity
3175
- };
3176
- }
3177
-
3178
- function styleLayerIsVisibleAtZoom(layer, zoom) {
3179
- const {minzoom, maxzoom} = styleLayerZoomRange(layer);
3180
- return zoom >= minzoom && zoom < maxzoom;
3181
- }
3182
-
3183
- function createLandmarkLayer(map, configLayer, fallbackLayers = []) {
3184
- const manifestUrl = configLayer.metadata?.["tileflow:landmark-manifest-url"];
3185
- const {minzoom: landmarkMinimumZoom, maxzoom: landmarkMaximumZoom} =
3186
- styleLayerZoomRange(configLayer);
3187
- const abortController = new AbortController();
3188
- const loaded = new Map();
3189
- const loading = new Map();
3190
- const archiveReaders = new Map();
3191
- const activeLandmarkIds = new Set();
3192
- let cacheClock = 0;
3193
- let camera;
3194
- let renderer;
3195
- let scene;
3196
- let group;
3197
- let sceneOriginMercator;
3198
- let sceneElevation = 0;
3199
- let manifest;
3200
- let fallbackSignature;
3201
- let queuedRefresh;
3202
- const mapMatrix = new THREE.Matrix4();
3203
- const sceneMatrix = new THREE.Matrix4();
3204
-
3205
- function overlapsViewport(bounds, paddingRatio = 0) {
3206
- const viewport = map.getBounds();
3207
- const longitudePadding = Math.abs(viewport.getEast() - viewport.getWest()) * paddingRatio;
3208
- const latitudePadding = Math.abs(viewport.getNorth() - viewport.getSouth()) * paddingRatio;
3209
- return !(
3210
- bounds[2] < viewport.getWest() - longitudePadding ||
3211
- bounds[0] > viewport.getEast() + longitudePadding ||
3212
- bounds[3] < viewport.getSouth() - latitudePadding ||
3213
- bounds[1] > viewport.getNorth() + latitudePadding
3214
- );
3215
- }
3216
-
3217
- function updateLandmarkFallbackFilters() {
3218
- const readyIds = [...activeLandmarkIds]
3219
- .filter((id) => loaded.has(id))
3220
- .sort();
3221
- const signature = JSON.stringify(readyIds);
3222
- if (signature === fallbackSignature) return;
3223
- fallbackSignature = signature;
3224
- for (const fallback of fallbackLayers) {
3225
- if (!map.getLayer(fallback.id)) continue;
3226
- const spatialExclusions = (fallback.spatialFallbacks ?? [])
3227
- .filter((entry) => readyIds.includes(entry.id))
3228
- .map((entry) => ["!", ["within", {
3229
- type: "Polygon",
3230
- coordinates: [[
3231
- [entry.bounds[0], entry.bounds[1]],
3232
- [entry.bounds[2], entry.bounds[1]],
3233
- [entry.bounds[2], entry.bounds[3]],
3234
- [entry.bounds[0], entry.bounds[3]],
3235
- [entry.bounds[0], entry.bounds[1]]
3236
- ]]
3237
- }]]);
3238
- map.setFilter(
3239
- fallback.id,
3240
- readyIds.length === 0 && spatialExclusions.length === 0
3241
- ? (fallback.filter ?? null)
3242
- : [
3243
- "all",
3244
- ...(fallback.filter ? [fallback.filter] : []),
3245
- [
3246
- "!",
3247
- ["in", ["get", "landmark_id"], ["literal", readyIds]]
3248
- ],
3249
- ...spatialExclusions
3250
- ]
3251
- );
3252
- }
3253
- }
3254
-
3255
- function queueRefresh() {
3256
- if (queuedRefresh !== undefined) return;
3257
- queuedRefresh = setTimeout(() => {
3258
- queuedRefresh = undefined;
3259
- refresh();
3260
- }, 100);
3261
- }
3262
-
3263
- function ensureSceneOrigin(elevation) {
3264
- if (sceneOriginMercator) return;
3265
- sceneElevation = elevation;
3266
- sceneOriginMercator = maplibregl.MercatorCoordinate.fromLngLat(
3267
- map.getCenter(),
3268
- elevation
3269
- );
3270
- }
3271
-
3272
- function localPosition(lng, lat, elevation) {
3273
- const mercator = maplibregl.MercatorCoordinate.fromLngLat([lng, lat]);
3274
- const mercatorPerMeter = sceneOriginMercator.meterInMercatorCoordinateUnits();
3275
- return new THREE.Vector3(
3276
- (mercator.x - sceneOriginMercator.x) / mercatorPerMeter,
3277
- elevation - sceneElevation,
3278
- (sceneOriginMercator.y - mercator.y) / mercatorPerMeter
3279
- );
3280
- }
3281
-
3282
- function disposeModel(model) {
3283
- const disposed = new Set();
3284
- group?.remove(model);
3285
- for (const texture of model.userData?.tileflowDetachedTextures ?? []) {
3286
- if (!texture?.isTexture || disposed.has(texture)) continue;
3287
- disposed.add(texture);
3288
- texture.dispose();
3289
- }
3290
- for (const material of model.userData?.tileflowDetachedMaterials ?? []) {
3291
- if (!material || disposed.has(material)) continue;
3292
- for (const value of Object.values(material)) {
3293
- if (value?.isTexture && !disposed.has(value)) {
3294
- disposed.add(value);
3295
- value.dispose();
3296
- }
3297
- }
3298
- disposed.add(material);
3299
- material.dispose?.();
3300
- }
3301
- model.traverse((object) => {
3302
- if (object.geometry && !disposed.has(object.geometry)) {
3303
- disposed.add(object.geometry);
3304
- object.geometry.dispose?.();
3305
- }
3306
- const materials = Array.isArray(object.material)
3307
- ? object.material
3308
- : [object.material];
3309
- for (const material of materials) {
3310
- if (!material || disposed.has(material)) continue;
3311
- for (const value of Object.values(material)) {
3312
- if (value?.isTexture && !disposed.has(value)) {
3313
- disposed.add(value);
3314
- value.dispose();
3315
- }
3316
- }
3317
- disposed.add(material);
3318
- material.dispose?.();
3319
- }
3320
- });
3321
- }
3322
-
3323
- function harmonizeLandmarkModel(model) {
3324
- const detachedMaterials = new Set();
3325
- const uniformMaterial = new THREE.MeshStandardMaterial({
3326
- alphaTest: 0,
3327
- color: "#EEE4D4",
3328
- depthWrite: true,
3329
- metalness: 0,
3330
- opacity: 1,
3331
- roughness: 0.9,
3332
- side: THREE.DoubleSide,
3333
- transparent: false
3334
- });
3335
- model.traverse((object) => {
3336
- if (!object.isMesh) return;
3337
- const originalMaterials = Array.isArray(object.material)
3338
- ? object.material
3339
- : [object.material];
3340
- for (const material of originalMaterials) {
3341
- if (material) detachedMaterials.add(material);
3342
- }
3343
- object.material = Array.isArray(object.material)
3344
- ? object.material.map(() => uniformMaterial)
3345
- : uniformMaterial;
3346
- });
3347
- model.userData.tileflowDetachedMaterials = [...detachedMaterials];
3348
- }
3349
-
3350
- function normalizeLandmarkAxes(sourceModel, axisConvention) {
3351
- if (axisConvention === "EUN_Y_UP") return sourceModel;
3352
- if (axisConvention === "ENU_Z_UP") {
3353
- sourceModel.rotateX(-Math.PI / 2);
3354
- }
3355
- const normalizedModel = new THREE.Group();
3356
- normalizedModel.scale.z = -1;
3357
- normalizedModel.add(sourceModel);
3358
- return normalizedModel;
3359
- }
3360
-
3361
- function unloadLandmark(id) {
3362
- const entry = loaded.get(id);
3363
- if (!entry) return;
3364
- disposeModel(entry.model);
3365
- loaded.delete(id);
3366
- landmarkState.loaded = [...loaded.keys()];
3367
- fallbackSignature = undefined;
3368
- }
3369
-
3370
- function enforceCacheLimit() {
3371
- if (!manifest || loaded.size <= manifest.maximumCachedModels) return;
3372
- const evictionCandidates = [...loaded.entries()]
3373
- .filter(([id]) => !activeLandmarkIds.has(id))
3374
- .sort(
3375
- ([leftId, left], [rightId, right]) =>
3376
- left.lastUsed - right.lastUsed || leftId.localeCompare(rightId)
3377
- );
3378
- while (
3379
- loaded.size > manifest.maximumCachedModels &&
3380
- evictionCandidates.length > 0
3381
- ) {
3382
- unloadLandmark(evictionCandidates.shift()[0]);
3383
- }
3384
- }
3385
-
3386
- function landmarkModelAtZoom(landmark, zoom) {
3387
- let selected = landmark.models[0];
3388
- for (const candidate of landmark.models) {
3389
- if (candidate.minzoom > zoom) break;
3390
- selected = candidate;
3391
- }
3392
- return selected;
3393
- }
3394
-
3395
- async function sha256Hex(bytes) {
3396
- const digest = await crypto.subtle.digest("SHA-256", bytes);
3397
- return [...new Uint8Array(digest)]
3398
- .map((value) => value.toString(16).padStart(2, "0"))
3399
- .join("");
3400
- }
3401
-
3402
- function archiveReader(archive) {
3403
- let reader = archiveReaders.get(archive.url);
3404
- if (!reader) {
3405
- reader = new PMTiles(
3406
- new FetchSource(archive.url, new Headers(), "include")
3407
- );
3408
- archiveReaders.set(archive.url, reader);
3409
- }
3410
- return reader;
3411
- }
3412
-
3413
- async function loadLandmarkBytes(modelDefinition) {
3414
- const tile = await archiveReader(modelDefinition.archive).getZxy(
3415
- modelDefinition.z,
3416
- modelDefinition.x,
3417
- modelDefinition.y,
3418
- abortController.signal
3419
- );
3420
- if (!tile) throw new Error("landmark entry is missing from PMTiles");
3421
- if (tile.data.byteLength !== modelDefinition.bytes) {
3422
- throw new Error("landmark entry has an unexpected byte length");
3423
- }
3424
- if ((await sha256Hex(tile.data)) !== modelDefinition.sha256) {
3425
- throw new Error("landmark entry has an unexpected SHA-256");
3426
- }
3427
- return tile.data;
3428
- }
3429
-
3430
- function loadLandmark(landmark, requestedModel) {
3431
- const cached = loaded.get(landmark.id);
3432
- const nextModel = cached ? requestedModel : landmark.models[0];
3433
- if (cached?.modelKey === nextModel.key) {
3434
- cached.lastUsed = ++cacheClock;
3435
- cached.model.visible = activeLandmarkIds.has(landmark.id);
3436
- return;
3437
- }
3438
- if (cached) {
3439
- // Keep the previous LOD visible until its replacement has loaded.
3440
- cached.lastUsed = ++cacheClock;
3441
- cached.model.visible = activeLandmarkIds.has(landmark.id);
3442
- }
3443
- if (loading.has(landmark.id)) return;
3444
- loading.set(landmark.id, nextModel.key);
3445
- landmarkState.loading = [...loading.keys()];
3446
- const loader = new GLTFLoader();
3447
- loader.setMeshoptDecoder(MeshoptDecoder);
3448
- loader.setDRACOLoader(landmarkDracoLoader);
3449
- loader.setWithCredentials(true);
3450
- const fail = (error) => {
3451
- if (loading.get(landmark.id) !== nextModel.key) return;
3452
- loading.delete(landmark.id);
3453
- landmarkState.loading = [...loading.keys()];
3454
- landmarkState.errors = [
3455
- ...landmarkState.errors.filter((item) => item.id !== landmark.id),
3456
- {id: landmark.id, message: String(error)}
3457
- ];
3458
- console.warn("Tileflow landmark failed to load", landmark.id, error);
3459
- };
3460
- void loadLandmarkBytes(nextModel).then((bytes) => {
3461
- if (loading.get(landmark.id) !== nextModel.key) return;
3462
- loader.parse(
3463
- bytes,
3464
- new URL(".", nextModel.archive.url).toString(),
3465
- (gltf) => {
3466
- if (loading.get(landmark.id) !== nextModel.key) {
3467
- disposeModel(gltf.scene);
3468
- return;
3469
- }
3470
- loading.delete(landmark.id);
3471
- landmarkState.loading = [...loading.keys()];
3472
- if (abortController.signal.aborted) {
3473
- disposeModel(gltf.scene);
3474
- return;
3475
- }
3476
- const elevation = map.queryTerrainElevation(landmark.center) ?? 0;
3477
- ensureSceneOrigin(elevation);
3478
- const model = normalizeLandmarkAxes(
3479
- gltf.scene,
3480
- nextModel.axisConvention
3481
- );
3482
- model.name = "tileflow-landmark-" + landmark.id;
3483
- harmonizeLandmarkModel(model);
3484
- model.position.copy(
3485
- localPosition(landmark.center[0], landmark.center[1], elevation)
3486
- );
3487
- model.traverse((object) => {
3488
- if (!object.isMesh) return;
3489
- object.frustumCulled = true;
3490
- object.castShadow = false;
3491
- object.receiveShadow = false;
3492
- });
3493
- model.visible = activeLandmarkIds.has(landmark.id);
3494
- if (loaded.has(landmark.id)) unloadLandmark(landmark.id);
3495
- loaded.set(landmark.id, {
3496
- lastUsed: ++cacheClock,
3497
- model,
3498
- modelKey: nextModel.key
3499
- });
3500
- landmarkState.errors = landmarkState.errors.filter(
3501
- (item) => item.id !== landmark.id
3502
- );
3503
- landmarkState.loaded = [...loaded.keys()];
3504
- group.add(model);
3505
- enforceCacheLimit();
3506
- fallbackSignature = undefined;
3507
- refresh();
3508
- },
3509
- fail
3510
- );
3511
- }).catch(fail);
3512
- }
3513
-
3514
- function refresh() {
3515
- if (
3516
- !threeDimensionalEnabled ||
3517
- !manifest ||
3518
- !styleLayerIsVisibleAtZoom(configLayer, map.getZoom())
3519
- ) {
3520
- activeLandmarkIds.clear();
3521
- landmarkState.active = [];
3522
- for (const entry of loaded.values()) entry.model.visible = false;
3523
- updateLandmarkFallbackFilters();
3524
- return;
3525
- }
3526
- const zoom = map.getZoom();
3527
- const visible = manifest.landmarks
3528
- .filter((landmark) => overlapsViewport(landmark.bounds))
3529
- .sort(
3530
- (left, right) =>
3531
- right.priority - left.priority || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
3532
- )
3533
- .slice(0, manifest.maximumVisibleModels);
3534
- activeLandmarkIds.clear();
3535
- for (const landmark of visible) activeLandmarkIds.add(landmark.id);
3536
- landmarkState.active = visible.map((landmark) => landmark.id);
3537
- for (const [id, entry] of loaded) {
3538
- entry.model.visible = activeLandmarkIds.has(id);
3539
- }
3540
- for (const landmark of visible) {
3541
- loadLandmark(landmark, landmarkModelAtZoom(landmark, zoom));
3542
- }
3543
- const nearbyCacheSlots = Math.max(
3544
- 0,
3545
- manifest.maximumCachedModels - visible.length
3546
- );
3547
- const nearby = manifest.landmarks
3548
- .filter(
3549
- (landmark) =>
3550
- !activeLandmarkIds.has(landmark.id) &&
3551
- overlapsViewport(landmark.bounds, 0.35)
3552
- )
3553
- .sort(
3554
- (left, right) =>
3555
- right.priority - left.priority || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
3556
- )
3557
- .slice(0, nearbyCacheSlots);
3558
- for (const landmark of nearby) {
3559
- loadLandmark(landmark, landmark.models[0]);
3560
- }
3561
- enforceCacheLimit();
3562
- updateLandmarkFallbackFilters();
3563
- map.triggerRepaint();
3564
- }
3565
-
3566
- function normalizeManifest(candidate) {
3567
- return normalizeTileflowLandmarkManifest(candidate, manifestUrl);
3568
- }
3569
-
3570
- async function loadManifest() {
3571
- try {
3572
- const candidate = await loadLandmarkManifestCandidate(
3573
- manifestUrl,
3574
- abortController.signal
3575
- );
3576
- if (abortController.signal.aborted) return;
3577
- manifest = normalizeManifest(candidate);
3578
- landmarkState.manifestId = candidate.id;
3579
- landmarkState.cacheLimit = manifest.maximumCachedModels;
3580
- refresh();
3581
- } catch (error) {
3582
- if (error?.name !== "AbortError") {
3583
- console.warn("Tileflow landmark manifest failed to load", error);
3584
- }
3585
- }
3586
- }
3587
-
3588
- return {
3589
- id: "tileflow-landmarks-3d",
3590
- type: "custom",
3591
- renderingMode: "3d",
3592
- minzoom: landmarkMinimumZoom,
3593
- ...(Number.isFinite(landmarkMaximumZoom)
3594
- ? {maxzoom: landmarkMaximumZoom}
3595
- : {}),
3596
- metadata: {"tileflow:3d-toggle": "landmark"},
3597
- onAdd(_map, gl) {
3598
- camera = new THREE.Camera();
3599
- scene = new THREE.Scene();
3600
- scene.rotateX(Math.PI / 2);
3601
- scene.scale.multiply(new THREE.Vector3(1, 1, -1));
3602
- group = new THREE.Group();
3603
- scene.add(group);
3604
- scene.add(new THREE.AmbientLight(0xffffff, 2.2));
3605
- scene.add(new THREE.HemisphereLight(0xfff8e8, 0xd8cfbd, 0.8));
3606
- const sun = new THREE.DirectionalLight(0xffffff, 0.45);
3607
- sun.position.set(-120, 220, 100);
3608
- scene.add(sun);
3609
- renderer = new THREE.WebGLRenderer({
3610
- canvas: map.getCanvas(),
3611
- context: gl
3612
- });
3613
- renderer.autoClear = false;
3614
- renderer.outputColorSpace = THREE.SRGBColorSpace;
3615
- map.on("move", queueRefresh);
3616
- map.on("moveend", refresh);
3617
- map.on("tileflow:3d-toggle", refresh);
3618
- void loadManifest();
3619
- },
3620
- render(_gl, options) {
3621
- if (
3622
- !threeDimensionalEnabled ||
3623
- !sceneOriginMercator ||
3624
- group.children.length === 0
3625
- ) return;
3626
- const meter = sceneOriginMercator.meterInMercatorCoordinateUnits();
3627
- mapMatrix.fromArray(options.defaultProjectionData.mainMatrix);
3628
- sceneMatrix
3629
- .makeTranslation(
3630
- sceneOriginMercator.x,
3631
- sceneOriginMercator.y,
3632
- sceneOriginMercator.z
3633
- )
3634
- .scale(new THREE.Vector3(meter, -meter, meter));
3635
- camera.projectionMatrix.copy(mapMatrix).multiply(sceneMatrix);
3636
- renderer.resetState();
3637
- renderer.render(scene, camera);
3638
- },
3639
- onRemove() {
3640
- abortController.abort();
3641
- if (queuedRefresh !== undefined) clearTimeout(queuedRefresh);
3642
- map.off("move", queueRefresh);
3643
- map.off("moveend", refresh);
3644
- map.off("tileflow:3d-toggle", refresh);
3645
- for (const entry of loaded.values()) disposeModel(entry.model);
3646
- loaded.clear();
3647
- for (const fallback of fallbackLayers) {
3648
- if (map.getLayer(fallback.id)) {
3649
- map.setFilter(fallback.id, fallback.filter ?? null);
3650
- }
3651
- }
3652
- renderer?.dispose();
3653
- }
3654
- };
3655
- }
3656
-
3657
- function addLandmarkLayerIfConfigured(map, styleLayers) {
3658
- const configLayer = styleLayers.find(
3659
- (layer) => layer.metadata?.["tileflow:landmark-manifest-url"]
3660
- );
3661
- if (!configLayer || map.getLayer("tileflow-landmarks-3d")) return;
3662
- const fallbackLayers = styleLayers
3663
- .filter((layer) => layer.metadata?.["tileflow:landmark-fallback"] === true)
3664
- .map((layer) => ({
3665
- filter: layer.filter,
3666
- id: layer.id,
3667
- spatialFallbacks:
3668
- layer.metadata?.["tileflow:landmark-spatial-fallbacks"] ?? []
3669
- }));
3670
- // Landmark custom layers use Mercator world coordinates. Switch here
3671
- // instead of relying on the optional detailed-tree renderer to do it.
3672
- if (map.getProjection().type !== "mercator") {
3673
- map.setProjection({type: "mercator"});
3674
- }
3675
- const firstForegroundLabel = styleLayers.find(
3676
- (layer) =>
3677
- (layer.id.startsWith("tileflow-label-") &&
3678
- !layer.id.startsWith("tileflow-label-road-")) ||
3679
- layer.id.startsWith("tileflow-poi-")
3680
- )?.id;
3681
- const treeLayer = map.getLayer("tileflow-vegetation-trees-3d")
3682
- ? "tileflow-vegetation-trees-3d"
3683
- : map.getLayer("tileflow-vegetation-trees")
3684
- ? "tileflow-vegetation-trees"
3685
- : firstForegroundLabel;
3686
- map.addLayer(createLandmarkLayer(map, configLayer, fallbackLayers), treeLayer);
3687
- }
3688
-
3689
- class ThreeDimensionalControl {
3690
- constructor() {
3691
- this.enabled = threeDimensionalEnabled;
3692
- this.handleClick = this.handleClick.bind(this);
3693
- this.update = this.update.bind(this);
3694
- }
3695
-
3696
- onAdd(map) {
3697
- this.map = map;
3698
- this.container = document.createElement("div");
3699
- this.container.className = "maplibregl-ctrl maplibregl-ctrl-group";
3700
- this.button = document.createElement("button");
3701
- this.button.type = "button";
3702
- this.button.className = "tileflow-3d-toggle";
3703
- this.button.title = "Toggle 3D view";
3704
- this.button.setAttribute("aria-label", "Toggle 3D view");
3705
- this.button.addEventListener("click", this.handleClick);
3706
- this.container.appendChild(this.button);
3707
- map.on("styledata", this.update);
3708
- this.update();
3709
- return this.container;
3710
- }
3711
-
3712
- handleClick() {
3713
- this.setEnabled(!this.enabled);
3714
- }
3715
-
3716
- setEnabled(enabled) {
3717
- if (typeof enabled !== "boolean" || enabled === this.enabled) return;
3718
- this.enabled = enabled;
3719
- threeDimensionalEnabled = this.enabled;
3720
- landmarkState.enabled = this.enabled;
3721
- this.update();
3722
- writeToggleStateToUrl();
3723
- this.map.fire("tileflow:3d-toggle", {enabled: this.enabled});
3724
- this.map.triggerRepaint();
3725
- }
3726
-
3727
- update() {
3728
- for (const layer of this.map.getStyle()?.layers || []) {
3729
- const toggle = layer.metadata?.["tileflow:3d-toggle"];
3730
- if (toggle !== "building" && toggle !== "landmark") continue;
3731
- const visibility = this.enabled ? "visible" : "none";
3732
- if (this.map.getLayoutProperty(layer.id, "visibility") !== visibility) {
3733
- this.map.setLayoutProperty(layer.id, "visibility", visibility);
3734
- }
3735
- }
3736
- this.button.textContent = this.enabled ? "3D ON" : "3D OFF";
3737
- this.button.setAttribute("aria-pressed", String(this.enabled));
3738
- this.button.title = this.enabled ? "Disable 3D buildings" : "Enable 3D buildings";
3739
- }
3740
-
3741
- onRemove() {
3742
- this.map?.off("styledata", this.update);
3743
- this.button?.removeEventListener("click", this.handleClick);
3744
- this.container?.remove();
3745
- this.map = undefined;
3746
- }
3747
- }
3748
-
3749
- class TreeControl {
3750
- constructor() {
3751
- this.enabled = treesEnabled;
3752
- this.handleClick = this.handleClick.bind(this);
3753
- this.update = this.update.bind(this);
3754
- }
3755
-
3756
- onAdd(map) {
3757
- this.map = map;
3758
- this.container = document.createElement("div");
3759
- this.container.className = "maplibregl-ctrl maplibregl-ctrl-group";
3760
- this.button = document.createElement("button");
3761
- this.button.type = "button";
3762
- this.button.className = "tileflow-tree-toggle";
3763
- this.button.setAttribute("aria-label", "Toggle trees");
3764
- this.button.addEventListener("click", this.handleClick);
3765
- this.container.appendChild(this.button);
3766
- map.on("styledata", this.update);
3767
- this.update();
3768
- return this.container;
3769
- }
3770
-
3771
- handleClick() {
3772
- this.setEnabled(!this.enabled);
3773
- }
3774
-
3775
- setEnabled(enabled) {
3776
- if (typeof enabled !== "boolean" || enabled === this.enabled) return;
3777
- this.enabled = enabled;
3778
- treesEnabled = this.enabled;
3779
- this.update();
3780
- writeToggleStateToUrl();
3781
- this.map.fire("tileflow:trees-toggle", {enabled: this.enabled});
3782
- this.map.triggerRepaint();
3783
- }
3784
-
3785
- update() {
3786
- const treeLayer = this.map.getStyle()?.layers?.find(
3787
- (layer) => layer.id === "tileflow-vegetation-trees"
3788
- );
3789
- if (treeLayer) {
3790
- const visibility = this.enabled ? "visible" : "none";
3791
- if (this.map.getLayoutProperty(treeLayer.id, "visibility") !== visibility) {
3792
- this.map.setLayoutProperty(treeLayer.id, "visibility", visibility);
3793
- }
3794
- }
3795
- this.button.textContent = this.enabled ? "TREES ON" : "TREES OFF";
3796
- this.button.setAttribute("aria-pressed", String(this.enabled));
3797
- this.button.title = this.enabled ? "Hide trees" : "Show trees";
3798
- }
3799
-
3800
- onRemove() {
3801
- this.map?.off("styledata", this.update);
3802
- this.button?.removeEventListener("click", this.handleClick);
3803
- this.container?.remove();
3804
- this.map = undefined;
3805
- }
3806
- }
3807
-
3808
- // Switches a map preview between its themes. The preview reloads with the next theme and keeps
3809
- // the camera and toggles already written to the URL; scene previews own their theme.
3810
- class ThemeControl {
3811
- onAdd(map) {
3812
- this.map = map;
3813
- this.container = document.createElement("div");
3814
- this.container.className = "maplibregl-ctrl maplibregl-ctrl-group";
3815
- this.button = document.createElement("button");
3816
- this.button.type = "button";
3817
- this.button.className = "tileflow-theme-toggle";
3818
- const next = previewThemes[(previewThemes.indexOf(previewTheme) + 1) % previewThemes.length];
3819
- this.button.textContent = String(previewTheme).toUpperCase();
3820
- this.button.title = "Switch to the " + next + " theme";
3821
- this.button.setAttribute("aria-label", "Switch to the " + next + " theme");
3822
- this.handleClick = () => {
3823
- writeCameraToUrl(map);
3824
- const url = new URL(location.href);
3825
- url.searchParams.set("theme", next);
3826
- location.assign(url.href);
3827
- };
3828
- this.button.addEventListener("click", this.handleClick);
3829
- this.container.appendChild(this.button);
3830
- return this.container;
3831
- }
3832
-
3833
- onRemove() {
3834
- this.button?.removeEventListener("click", this.handleClick);
3835
- this.container?.remove();
3836
- this.map = undefined;
3837
- }
3838
- }
3839
-
3840
- const previewLayerGroupIds = [
3841
- "labels", "pois", "roads", "transit", "buildings", "landuse", "water"
3842
- ];
3843
- let visibleLayerGroups = new Set(previewLayerGroupIds);
3844
- const defaultLayerVisibility = new Map();
3845
- const defaultLayerTextField = new Map();
3846
-
3847
- function previewLayoutValuesEqual(left, right) {
3848
- return JSON.stringify(left) === JSON.stringify(right);
3849
- }
3850
-
3851
- function previewLayerGroups(layer) {
3852
- const id = layer.id.toLowerCase();
3853
- const sourceLayer = layer["source-layer"] || "";
3854
- const groups = [];
3855
- const isLanduseLayer = [
3856
- "landuse", "landcover", "park", "business_corridor"
3857
- ].includes(sourceLayer);
3858
- if (
3859
- !isLanduseLayer &&
3860
- /poi|parking|business|amenity/u.test(id + " " + sourceLayer)
3861
- ) groups.push("pois");
3862
- if (/transit|rail|ferry|aeroway|aerodrome/u.test(id + " " + sourceLayer)) {
3863
- groups.push("transit");
3864
- } else if (
3865
- [
3866
- "transportation",
3867
- "transportation_name",
3868
- "circular_feature",
3869
- "sidewalk",
3870
- "street_furniture"
3871
- ].includes(sourceLayer)
3872
- ) {
3873
- groups.push("roads");
3874
- }
3875
- if (sourceLayer === "building" || id.includes("building")) {
3876
- groups.push("buildings");
3877
- }
3878
- if (isLanduseLayer) {
3879
- groups.push("landuse");
3880
- }
3881
- if (["water", "waterway", "water_name", "bathymetry"].includes(sourceLayer)) {
3882
- groups.push("water");
3883
- }
3884
- return groups;
3885
- }
3886
-
3887
- function applyVisibleLayerGroups(map) {
3888
- if (document.documentElement) {
3889
- document.documentElement.dataset.visibleLayerGroups = [
3890
- ...visibleLayerGroups,
3891
- ].join(",");
3892
- }
3893
- for (const layer of map.getStyle()?.layers || []) {
3894
- if (!defaultLayerVisibility.has(layer.id)) {
3895
- defaultLayerVisibility.set(
3896
- layer.id,
3897
- map.getLayoutProperty(layer.id, "visibility") || "visible"
3898
- );
3899
- }
3900
- if (layer.type === "symbol" && layer.layout?.["text-field"] !== undefined) {
3901
- if (!defaultLayerTextField.has(layer.id)) {
3902
- defaultLayerTextField.set(
3903
- layer.id,
3904
- map.getLayoutProperty(layer.id, "text-field")
3905
- );
3906
- }
3907
- const textField = visibleLayerGroups.has("labels")
3908
- ? defaultLayerTextField.get(layer.id)
3909
- : "";
3910
- if (
3911
- !previewLayoutValuesEqual(
3912
- map.getLayoutProperty(layer.id, "text-field"),
3913
- textField
3914
- )
3915
- ) {
3916
- map.setLayoutProperty(layer.id, "text-field", textField);
3917
- }
3918
- }
3919
- const groups = previewLayerGroups(layer);
3920
- const threeDimensionalToggle = layer.metadata?.["tileflow:3d-toggle"];
3921
- const authoredVisibility = defaultLayerVisibility.get(layer.id);
3922
- const toggleVisibility =
3923
- threeDimensionalToggle === "building" || threeDimensionalToggle === "landmark"
3924
- ? (threeDimensionalEnabled ? "visible" : "none")
3925
- : authoredVisibility;
3926
- const visibility = groups.some((group) => !visibleLayerGroups.has(group))
3927
- ? "none"
3928
- : toggleVisibility;
3929
- if (map.getLayoutProperty(layer.id, "visibility") !== visibility) {
3930
- map.setLayoutProperty(layer.id, "visibility", visibility);
3931
- }
3932
- }
3933
- }
3934
-
3935
- function setVisibleLayerGroups(map, groups) {
3936
- visibleLayerGroups = new Set(groups);
3937
- applyVisibleLayerGroups(map);
3938
- map.fire("tileflow:visible-layer-groups", {groups: [...visibleLayerGroups]});
3939
- }
3940
-
3941
- if (styleUrl) {
3942
- // Dense vector tiles finish in bursts with multiple workers, causing several
3943
- // bucket uploads in one frame. One worker keeps navigation smooth; benchmarks
3944
- // can still compare up to three workers through the explicit URL override.
3945
- const mapWorkerCount = mapWorkerCountOverride ?? 1;
3946
- maplibregl.setWorkerCount?.(mapWorkerCount);
3947
- await loadTileflowStyleFonts(styleUrl, {fontFaces: previewFontFaces});
3948
- registerTileflowContourProtocol({addProtocol: maplibregl.addProtocol});
3949
- registerTileflowPmtilesProtocol({addProtocol: maplibregl.addProtocol});
3950
- const map = new maplibregl.Map({
3951
- collectResourceTiming: mapBenchmarkEnabled,
3952
- container: "map",
3953
- style: styleUrl,
3954
- transformRequest: (url) => {
3955
- try {
3956
- return new URL(url, location.href).hostname === "dev-tiles.tileflow.dev"
3957
- ? {credentials: "include", url}
3958
- : {url};
3959
- } catch {
3960
- return {url};
3961
- }
3962
- },
3963
- ...resolveInitialMapOptions(previewMapOptions)
3964
- });
3965
- globalThis.__tileflowPreviewMap = map;
3966
- installMapBenchmark(map);
3967
- map.addControl(new maplibregl.NavigationControl(), "top-right");
3968
- let threeDimensionalControl;
3969
- let treeControl;
3970
- if (isSemanticPreview) {
3971
- threeDimensionalControl = new ThreeDimensionalControl();
3972
- treeControl = new TreeControl();
3973
- map.addControl(threeDimensionalControl, "top-right");
3974
- map.addControl(treeControl, "top-right");
3975
- }
3976
- if (previewThemes.length > 1) map.addControl(new ThemeControl(), "top-right");
3977
- let ensuringThreeDimensionalLayers;
3978
- const treeRuntimeMinimumZoom = 16;
3979
- const ensureThreeDimensionalLayers = () => {
3980
- if (ensuringThreeDimensionalLayers) return ensuringThreeDimensionalLayers;
3981
- const currentStyleLayers = map.getStyle()?.layers || [];
3982
- const landmarkConfigLayer = currentStyleLayers.find(
3983
- (layer) => layer.metadata?.["tileflow:landmark-manifest-url"]
3984
- );
3985
- const buildingWireframeConfigLayer = currentStyleLayers.find(
3986
- (layer) => layer.metadata?.["tileflow:building-wireframe"] === true
3987
- );
3988
- const landmarkManifestUrl =
3989
- landmarkConfigLayer?.metadata?.["tileflow:landmark-manifest-url"];
3990
- const landmarkPreloadZoom = landmarkConfigLayer
3991
- ? Math.max(0, styleLayerZoomRange(landmarkConfigLayer).minzoom - 1)
3992
- : Infinity;
3993
- const shouldPreloadLandmarks =
3994
- landmarkManifestUrl &&
3995
- threeDimensionalEnabled &&
3996
- map.getZoom() >= landmarkPreloadZoom;
3997
- const needsTrees =
3998
- treesEnabled &&
3999
- treeRendererMode !== "circle" &&
4000
- map.getZoom() >= treeRuntimeMinimumZoom &&
4001
- !map.getLayer("tileflow-vegetation-trees-3d");
4002
- const needsLandmarks =
4003
- landmarkConfigLayer &&
4004
- threeDimensionalEnabled &&
4005
- styleLayerIsVisibleAtZoom(landmarkConfigLayer, map.getZoom()) &&
4006
- !map.getLayer("tileflow-landmarks-3d");
4007
- const needsBuildingWireframe =
4008
- buildingWireframeConfigLayer &&
4009
- threeDimensionalEnabled &&
4010
- styleLayerIsVisibleAtZoom(buildingWireframeConfigLayer, map.getZoom()) &&
4011
- !map.getLayer("tileflow-buildings-wireframe-3d");
4012
- if (
4013
- !needsTrees &&
4014
- !needsLandmarks &&
4015
- !needsBuildingWireframe &&
4016
- !shouldPreloadLandmarks
4017
- ) return;
4018
- const runtimePrerequisites = [
4019
- shouldPreloadLandmarks
4020
- ? loadLandmarkPrerequisites(landmarkManifestUrl)
4021
- : loadThreeCoreRuntime(),
4022
- ...(needsBuildingWireframe ? [loadBuildingWireframeRuntime()] : [])
4023
- ];
4024
- ensuringThreeDimensionalLayers = Promise.all(runtimePrerequisites)
4025
- .then(async () => {
4026
- const styleLayers = map.getStyle()?.layers || [];
4027
- const buildingWireframeConfigLayer = styleLayers.find(
4028
- (layer) => layer.metadata?.["tileflow:building-wireframe"] === true
4029
- );
4030
- if (
4031
- buildingWireframeConfigLayer &&
4032
- threeDimensionalEnabled &&
4033
- styleLayerIsVisibleAtZoom(buildingWireframeConfigLayer, map.getZoom())
4034
- ) {
4035
- // A tree-only load may already be in flight when buildings are
4036
- // enabled. Reassert the line runtime before mounting the layer.
4037
- await loadBuildingWireframeRuntime();
4038
- addBuildingWireframeLayerIfConfigured(map, styleLayers);
4039
- }
4040
- if (
4041
- treesEnabled &&
4042
- treeRendererMode !== "circle" &&
4043
- map.getZoom() >= treeRuntimeMinimumZoom
4044
- ) addTreeLayerIfConfigured(map, styleLayers);
4045
- const landmarkConfigLayer = styleLayers.find(
4046
- (layer) => layer.metadata?.["tileflow:landmark-manifest-url"]
4047
- );
4048
- const shouldAddLandmarks =
4049
- landmarkConfigLayer &&
4050
- threeDimensionalEnabled &&
4051
- styleLayerIsVisibleAtZoom(landmarkConfigLayer, map.getZoom()) &&
4052
- !map.getLayer("tileflow-landmarks-3d");
4053
- if (shouldAddLandmarks) {
4054
- // The tree-only path may already be in flight when landmarks
4055
- // are enabled. Reassert the richer runtime here so a shared
4056
- // core promise can never expose an undefined GLTF loader.
4057
- await loadLandmarkPrerequisites(
4058
- landmarkConfigLayer.metadata["tileflow:landmark-manifest-url"]
4059
- );
4060
- addLandmarkLayerIfConfigured(map, styleLayers);
4061
- }
4062
- })
4063
- .catch((error) => console.warn("Tileflow 3D runtime failed to load", error))
4064
- .finally(() => {
4065
- ensuringThreeDimensionalLayers = undefined;
4066
- });
4067
- return ensuringThreeDimensionalLayers;
4068
- };
4069
- map.on("styledata", ensureThreeDimensionalLayers);
4070
- map.on("styledata", () => applyVisibleLayerGroups(map));
4071
- map.on("zoomend", ensureThreeDimensionalLayers);
4072
- map.on("tileflow:3d-toggle", ensureThreeDimensionalLayers);
4073
- map.on("tileflow:trees-toggle", ensureThreeDimensionalLayers);
4074
- map.on("load", () => {
4075
- writeCameraToUrl(map);
4076
- updateGlobeBackdrop(map);
4077
- ensureThreeDimensionalLayers();
4078
- });
4079
- map.on("move", () => updateGlobeBackdrop(map));
4080
- map.on("moveend", () => writeCameraToUrl(map));
4081
- addEventListener("message", (event) => {
4082
- if (
4083
- window.parent === window ||
4084
- event.source !== window.parent ||
4085
- event.origin !== location.origin ||
4086
- event.data?.type !== "tileflow:set-map-state" ||
4087
- event.data?.schemaVersion !== 1 ||
4088
- !threeDimensionalControl ||
4089
- !treeControl
4090
- ) return;
4091
- const state = event.data.state;
4092
- const nextVisibleLayerGroups = state?.visibleLayerGroups;
4093
- const center = state?.center;
4094
- const numericValues = {
4095
- bearing: state?.bearing,
4096
- lat: center?.[1],
4097
- lng: center?.[0],
4098
- pitch: state?.pitch,
4099
- zoom: state?.zoom
4100
- };
4101
- if (
4102
- !Array.isArray(center) ||
4103
- center.length !== 2 ||
4104
- Object.entries(numericValues).some(
4105
- ([name, value]) =>
4106
- !Number.isFinite(value) ||
4107
- value < cameraRanges[name][0] ||
4108
- value > cameraRanges[name][1]
4109
- ) ||
4110
- typeof state?.buildings3d !== "boolean" ||
4111
- typeof state?.trees3d !== "boolean" ||
4112
- (nextVisibleLayerGroups !== undefined &&
4113
- (!Array.isArray(nextVisibleLayerGroups) ||
4114
- new Set(nextVisibleLayerGroups).size !== nextVisibleLayerGroups.length ||
4115
- nextVisibleLayerGroups.some(
4116
- (group) => !previewLayerGroupIds.includes(group)
4117
- )))
4118
- ) return;
4119
- threeDimensionalControl.setEnabled(state.buildings3d);
4120
- treeControl.setEnabled(state.trees3d);
4121
- if (nextVisibleLayerGroups) {
4122
- setVisibleLayerGroups(map, nextVisibleLayerGroups);
4123
- }
4124
- map.jumpTo({
4125
- bearing: numericValues.bearing,
4126
- center,
4127
- pitch: numericValues.pitch,
4128
- zoom: numericValues.zoom
4129
- });
4130
- writeCameraToUrl(map);
4131
- });
4132
- }
4133
-
4134
- function applyStatus(next) {
4135
- badge.textContent = ["Tileflow preview", previewLabel, next.status].filter(Boolean).join(" \xB7 ");
4136
- if (next.status === "invalid") {
4137
- const diagnostics = next.diagnostics || [];
4138
- status.textContent = diagnostics.map((item) =>
4139
- (item.path ? item.path + ": " : "") + item.message
4140
- ).join("\\n") || "Tileflow config is invalid.";
4141
- status.style.display = "block";
4142
- return;
4143
- }
4144
- status.style.display = "none";
4145
- if (next.status === "ready" && next.generation > initialGeneration) {
4146
- location.reload();
4147
- }
4148
- }
4149
-
4150
- applyStatus(initialStatus);
4151
- const events = new EventSource(${JSON.stringify(`${basePath}/__events`)});
4152
- // Generations are monotonic only within one dev-server process. A source-code watcher can
4153
- // restart that process at generation 1, so reconnecting after a live connection was lost
4154
- // must refresh the document even when the numeric generation did not increase.
4155
- let eventsConnected = false;
4156
- let eventsDisconnected = false;
4157
- events.addEventListener("open", () => {
4158
- if (eventsConnected && eventsDisconnected) {
4159
- location.reload();
4160
- return;
4161
- }
4162
- eventsConnected = true;
4163
- eventsDisconnected = false;
4164
- });
4165
- events.addEventListener("error", () => {
4166
- if (eventsConnected) eventsDisconnected = true;
4167
- });
4168
- for (const eventName of ["building", "ready", "invalid"]) {
4169
- events.addEventListener(eventName, (event) => applyStatus(JSON.parse(event.data)));
4170
- }
4171
- </script>
4172
- </body>
4173
- </html>`;
4174
- }
4175
- function previewMapOptions(preview) {
4176
- if (preview.camera.type === "center") {
4177
- return {
4178
- bearing: preview.camera.bearing,
4179
- center: preview.camera.center,
4180
- pitch: preview.camera.pitch,
4181
- zoom: preview.camera.zoom
4182
- };
4183
- }
4184
- const [west, south, east, north] = preview.camera.bounds;
4185
- return {
4186
- bearing: preview.camera.bearing,
4187
- bounds: [
4188
- [west, south],
4189
- [east, north]
4190
- ],
4191
- fitBoundsOptions: { padding: preview.camera.padding },
4192
- pitch: preview.camera.pitch
4193
- };
4194
- }
4195
- function serializeInlineJson(value) {
4196
- return JSON.stringify(value).replace(
4197
- /[<>&\u2028\u2029]/gu,
4198
- (character) => `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`
4199
- );
4200
- }
4201
-
4202
- // src/preview.ts
4203
- var TileflowPreviewSelectionError = class extends Error {
4204
- code = "PREVIEW_SELECTION_INVALID";
4205
- constructor(message) {
4206
- super(message);
4207
- this.name = "TileflowPreviewSelectionError";
4208
- }
4209
- };
4210
- function resolveTileflowPreview(project, selection = {}) {
4211
- if (selection.map !== void 0 && selection.scene !== void 0) {
4212
- throw new TileflowPreviewSelectionError(
4213
- "Choose either a Tileflow preview map or scene, not both."
4214
- );
4215
- }
4216
- if (selection.scene !== void 0) {
4217
- if (selection.theme !== void 0) {
4218
- throw new TileflowPreviewSelectionError(
4219
- "A committed Tileflow scene owns its concrete theme; do not combine scene and theme selection."
4220
- );
4221
- }
4222
- const scene = Object.hasOwn(project.scenes ?? {}, selection.scene) ? project.scenes?.[selection.scene] : void 0;
4223
- if (!scene) {
4224
- throw new TileflowPreviewSelectionError(`Unknown Tileflow scene: ${selection.scene}`);
4225
- }
4226
- const normalized = normalizeTileflowCaptureScene(scene);
4227
- if (normalized.target.kind === "application") {
4228
- throw new TileflowPreviewSelectionError(
4229
- `Tileflow scene "${selection.scene}" targets an application. Preview it through the application's development server.`
4230
- );
4231
- }
4232
- if (!Object.hasOwn(project.maps, normalized.map)) {
4233
- throw new TileflowPreviewSelectionError(
4234
- `Tileflow scene "${selection.scene}" references an unknown map: ${normalized.map}`
4235
- );
4236
- }
4237
- const map2 = parseResolvedTileflowMap(project.maps[normalized.map]);
4238
- const selectedTheme2 = resolvePreviewTheme(map2, normalized.theme);
4239
- return {
4240
- camera: normalized.camera,
4241
- label: `${normalized.map} / ${selectedTheme2.name} / ${selection.scene} \xB7 ${normalized.viewport.width}\xD7${normalized.viewport.height}`,
4242
- mapName: normalized.map,
4243
- themeName: selectedTheme2.name,
4244
- viewport: normalized.viewport
4245
- };
4246
- }
4247
- const mapName = selection.map ?? getFirstTileflowMapName(project);
4248
- const map = Object.hasOwn(project.maps, mapName) ? project.maps[mapName] : void 0;
4249
- if (!map) {
4250
- throw new TileflowPreviewSelectionError(`Unknown Tileflow map: ${mapName}`);
4251
- }
4252
- const resolvedMap = parseResolvedTileflowMap(map);
4253
- const selectedTheme = resolvePreviewTheme(resolvedMap, selection.theme);
4254
- return {
4255
- camera: {
4256
- type: "center",
4257
- center: resolvedMap.view?.center ? [resolvedMap.view.center[0], resolvedMap.view.center[1]] : [defaultTileflowRuntimeView.center[0], defaultTileflowRuntimeView.center[1]],
4258
- zoom: resolvedMap.view?.zoom ?? defaultTileflowRuntimeView.zoom,
4259
- bearing: resolvedMap.view?.bearing ?? defaultTileflowRuntimeView.bearing,
4260
- pitch: resolvedMap.view?.pitch ?? defaultTileflowRuntimeView.pitch
4261
- },
4262
- label: `${mapName} / ${selectedTheme.name}`,
4263
- mapName,
4264
- themeName: selectedTheme.name,
4265
- themeNames: Object.keys(resolvedMap.themes)
4266
- };
4267
- }
4268
- function resolvePreviewTheme(map, requested) {
4269
- try {
4270
- return resolveThemeSelection(map, requested);
4271
- } catch {
4272
- const available = Object.keys(map.themes).sort().join(", ");
4273
- throw new TileflowPreviewSelectionError(
4274
- `Tileflow theme selection is invalid or unknown. Choose one of: ${available}.`
4275
- );
4276
- }
4277
- }
4278
-
4279
- export {
4280
- readBoundedTileflowResponse,
4281
- readBoundedTileflowJsonResponse,
4282
- normalizeTileflowLandmarkManifest,
4283
- renderTileflowPreviewHtml,
4284
- TileflowPreviewSelectionError,
4285
- resolveTileflowPreview
4286
- };