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