@tileflow/dev 0.1.0-alpha.34 → 0.1.0-alpha.35

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.
@@ -58,8 +58,8 @@ License.
58
58
 
59
59
  ## PMTiles JavaScript 4.5.0
60
60
 
61
- The local Streets preview loads the separately installed `pmtiles` browser decoder to retrieve
62
- individual landmark GLB entries with HTTP byte ranges. PMTiles is copyright Protomaps and
61
+ Local tileset inspection uses the separately installed `pmtiles` package to read vector and raster
62
+ tiles from local PMTiles archives. PMTiles is copyright Protomaps and
63
63
  contributors and is distributed under the BSD 3-Clause License. Its `fflate` dependency is
64
64
  copyright 2020 Arjun Barrett and is distributed under the MIT License.
65
65
 
@@ -8976,7 +8976,7 @@ function readUtf8(buf, pos, end) {
8976
8976
  return str;
8977
8977
  }
8978
8978
 
8979
- // src/landmarks.ts
8979
+ // src/bounded-response.ts
8980
8980
  async function readBoundedTileflowResponse(response, maximumBytes) {
8981
8981
  if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
8982
8982
  throw new Error("invalid response byte limit");
@@ -11,8 +11,6 @@ var localMapLibreAssets = /* @__PURE__ */ new Map();
11
11
  var localTileflowBrowserJavaScript;
12
12
  var localThreeCoreJavaScript;
13
13
  var localThreeModuleJavaScript;
14
- var localPmtilesJavaScript;
15
- var localFflateJavaScript;
16
14
  function getTileflowPreviewRuntimeResponse(path) {
17
15
  if (path === "/__runtime/devtools-observer.js") {
18
16
  return textAssetResponse(
@@ -44,18 +42,6 @@ function getTileflowPreviewRuntimeResponse(path) {
44
42
  if (path === "/__runtime/three.core.min.js") {
45
43
  return textAssetResponse(getLocalThreeAsset("core"), "text/javascript; charset=utf-8");
46
44
  }
47
- if (path === "/__runtime/pmtiles.js") {
48
- return textAssetResponse(getLocalPmtilesAsset(), "text/javascript; charset=utf-8");
49
- }
50
- if (path === "/__runtime/fflate.js") {
51
- return textAssetResponse(getLocalFflateAsset(), "text/javascript; charset=utf-8");
52
- }
53
- if (path === "/__runtime/three-addons/libs/draco/gltf/draco_decoder.wasm") {
54
- return binaryAssetResponse(
55
- getLocalThreeAddonBinaryAsset("libs/draco/gltf/draco_decoder.wasm"),
56
- "application/wasm"
57
- );
58
- }
59
45
  if (path.startsWith("/__runtime/three-addons/")) {
60
46
  return textAssetResponse(
61
47
  getLocalThreeAddonAsset(path.slice("/__runtime/three-addons/".length)),
@@ -103,34 +89,6 @@ function getLocalThreeAddonAsset(relativePath) {
103
89
  if (!isPathWithin(addonRoot, assetPath)) throw new Error("Invalid Three.js addon path.");
104
90
  return readFileSync(assetPath, "utf8");
105
91
  }
106
- function getLocalThreeAddonBinaryAsset(relativePath) {
107
- if (!/^[A-Za-z0-9_./-]+\.wasm$/.test(relativePath) || relativePath.includes("..")) {
108
- throw new Error("Invalid Three.js binary addon path.");
109
- }
110
- const commonJsPath = localRequire.resolve("three");
111
- const addonRoot = join(dirname(dirname(commonJsPath)), "examples", "jsm");
112
- const assetPath = join(addonRoot, relativePath);
113
- if (!isPathWithin(addonRoot, assetPath)) throw new Error("Invalid Three.js binary addon path.");
114
- return readFileSync(assetPath);
115
- }
116
- function getLocalPmtilesAsset() {
117
- if (localPmtilesJavaScript !== void 0) return localPmtilesJavaScript;
118
- const packagePath = localRequire.resolve("pmtiles/package.json");
119
- localPmtilesJavaScript = readFileSync(
120
- join(dirname(packagePath), "dist", "esm", "index.js"),
121
- "utf8"
122
- );
123
- return localPmtilesJavaScript;
124
- }
125
- function getLocalFflateAsset() {
126
- if (localFflateJavaScript !== void 0) return localFflateJavaScript;
127
- const commonJsPath = localRequire.resolve("fflate");
128
- localFflateJavaScript = readFileSync(
129
- join(dirname(dirname(commonJsPath)), "esm", "browser.js"),
130
- "utf8"
131
- );
132
- return localFflateJavaScript;
133
- }
134
92
  function textAssetResponse(body, contentType) {
135
93
  return new Response(body, {
136
94
  headers: {
@@ -140,15 +98,6 @@ function textAssetResponse(body, contentType) {
140
98
  }
141
99
  });
142
100
  }
143
- function binaryAssetResponse(body, contentType) {
144
- return new Response(new Uint8Array(body), {
145
- headers: {
146
- "Access-Control-Allow-Origin": "*",
147
- "Cache-Control": "no-store",
148
- "Content-Type": contentType
149
- }
150
- });
151
- }
152
101
  export {
153
102
  getTileflowPreviewRuntimeResponse
154
103
  };
package/dist/preview.d.ts CHANGED
@@ -3,50 +3,10 @@ import { TileflowBuildCatalog } from '@tileflow/core/build';
3
3
 
4
4
  declare function renderTileflowPreviewHtml(preview: ResolvedTileflowPreview | undefined, basePath: string, initialStatus: unknown, isSemanticPreview: boolean, fontFaces?: readonly TileflowStyleFontFace[], fitViewport?: boolean, controls?: string): string;
5
5
 
6
- type TileflowLandmarkAxisConvention = 'ENU_Z_UP' | 'EUN_Y_UP' | 'EUS_Y_UP';
7
- type TileflowLandmarkArchive = {
8
- bytes: number;
9
- id: string;
10
- sha256: string;
11
- url: string;
12
- };
13
- type TileflowLandmarkModel = {
14
- archive: TileflowLandmarkArchive;
15
- archiveId: string;
16
- axisConvention: TileflowLandmarkAxisConvention;
17
- bytes: number;
18
- key: string;
19
- minzoom: number;
20
- sha256: string;
21
- x: number;
22
- y: number;
23
- z: number;
24
- };
25
- type TileflowLandmark = {
26
- bounds: [number, number, number, number];
27
- center: [number, number];
28
- id: string;
29
- models: TileflowLandmarkModel[];
30
- priority: number;
31
- };
32
- type TileflowLandmarkManifestV2 = {
33
- archives: TileflowLandmarkArchive[];
34
- id: string;
35
- landmarks: TileflowLandmark[];
36
- maximumCachedModels: number;
37
- maximumVisibleModels: number;
38
- minzoom: number;
39
- schemaVersion: 2;
40
- };
41
6
  /** Reads a response without ever buffering more than the declared bound. */
42
7
  declare function readBoundedTileflowResponse(response: Response, maximumBytes: number): Promise<Uint8Array>;
43
8
  /** Reads and strictly decodes a bounded UTF-8 JSON response. */
44
9
  declare function readBoundedTileflowJsonResponse(response: Response, maximumBytes: number): Promise<unknown>;
45
- /**
46
- * Validates the browser preview's bounded v2 PMTiles landmark contract. Keep this function
47
- * self-contained: the preview serializes it into its isolated browser document.
48
- */
49
- declare function normalizeTileflowLandmarkManifest(candidate: unknown, manifestUrl: string): TileflowLandmarkManifestV2;
50
10
 
51
11
  type TileflowPreviewSelection = {
52
12
  map?: string;
@@ -68,4 +28,4 @@ declare class TileflowPreviewSelectionError extends Error {
68
28
  }
69
29
  declare function resolveTileflowPreview(project: TileflowBuildCatalog, selection?: TileflowPreviewSelection): ResolvedTileflowPreview;
70
30
 
71
- export { type ResolvedTileflowPreview, type TileflowLandmark, type TileflowLandmarkArchive, type TileflowLandmarkAxisConvention, type TileflowLandmarkManifestV2, type TileflowLandmarkModel, type TileflowPreviewSelection, TileflowPreviewSelectionError, normalizeTileflowLandmarkManifest, readBoundedTileflowJsonResponse, readBoundedTileflowResponse, renderTileflowPreviewHtml, resolveTileflowPreview };
31
+ export { type ResolvedTileflowPreview, type TileflowPreviewSelection, TileflowPreviewSelectionError, readBoundedTileflowJsonResponse, readBoundedTileflowResponse, renderTileflowPreviewHtml, resolveTileflowPreview };
package/dist/preview.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  resolveThemeSelection
12
12
  } from "@tileflow/core";
13
13
 
14
- // src/landmarks.ts
14
+ // src/bounded-response.ts
15
15
  async function readBoundedTileflowResponse(response, maximumBytes) {
16
16
  if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
17
17
  throw new Error("invalid response byte limit");
@@ -51,94 +51,6 @@ async function readBoundedTileflowJsonResponse(response, maximumBytes) {
51
51
  const bytes = await readBoundedTileflowResponse(response, maximumBytes);
52
52
  return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
53
53
  }
54
- function normalizeTileflowLandmarkManifest(candidate, manifestUrl) {
55
- const value = candidate;
56
- const portableId = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
57
- const sha256 = /^[a-f0-9]{64}$/;
58
- const axisConventions = /* @__PURE__ */ new Set(["EUN_Y_UP", "EUS_Y_UP", "ENU_Z_UP"]);
59
- if (value?.schemaVersion !== 2 || typeof value.id !== "string" || !portableId.test(value.id) || !Array.isArray(value.archives) || value.archives.length > 64 || !Array.isArray(value.landmarks) || value.landmarks.length > 4096 || !Number.isInteger(value.minzoom) || value.minzoom < 0 || value.minzoom > 24 || !Number.isInteger(value.maximumVisibleModels) || value.maximumVisibleModels < 1 || value.maximumVisibleModels > 64) {
60
- throw new Error("invalid landmark manifest");
61
- }
62
- const minzoom = value.minzoom;
63
- const maximumVisibleModels = value.maximumVisibleModels;
64
- const maximumCachedModels = value.maximumCachedModels === void 0 ? Math.min(128, Math.max(maximumVisibleModels, maximumVisibleModels * 2)) : value.maximumCachedModels;
65
- if (!Number.isInteger(maximumCachedModels) || maximumCachedModels < maximumVisibleModels || maximumCachedModels > 128) {
66
- throw new Error("invalid landmark cache limit");
67
- }
68
- const archiveIds = /* @__PURE__ */ new Set();
69
- const archiveMap = /* @__PURE__ */ new Map();
70
- for (const input of value.archives) {
71
- const archive = input;
72
- if (typeof archive?.id !== "string" || !portableId.test(archive.id) || archiveIds.has(archive.id) || typeof archive.url !== "string" || archive.url.length < 1 || archive.url.length > 2048 || !Number.isSafeInteger(archive.bytes) || archive.bytes < 1 || archive.bytes > 8 * 1024 * 1024 * 1024 || typeof archive.sha256 !== "string" || !sha256.test(archive.sha256)) {
73
- throw new Error("invalid landmark archive");
74
- }
75
- const url = new URL(archive.url, manifestUrl);
76
- if (url.protocol !== "http:" && url.protocol !== "https:" || url.username !== "" || url.password !== "") {
77
- throw new Error("invalid landmark archive URL");
78
- }
79
- const normalized = {
80
- bytes: archive.bytes,
81
- id: archive.id,
82
- sha256: archive.sha256,
83
- url: url.toString()
84
- };
85
- archiveIds.add(archive.id);
86
- archiveMap.set(archive.id, normalized);
87
- }
88
- const landmarkIds = /* @__PURE__ */ new Set();
89
- const landmarks = value.landmarks.map((input) => {
90
- const landmark = input;
91
- const center = landmark?.center;
92
- const bounds = landmark?.bounds;
93
- if (typeof landmark?.id !== "string" || !portableId.test(landmark.id) || landmarkIds.has(landmark.id) || !Array.isArray(center) || center.length !== 2 || !center.every(Number.isFinite) || center[0] < -180 || center[0] > 180 || center[1] < -90 || center[1] > 90 || !Array.isArray(bounds) || bounds.length !== 4 || !bounds.every(Number.isFinite) || bounds[0] < -180 || bounds[2] > 180 || bounds[1] < -90 || bounds[3] > 90 || bounds[0] > bounds[2] || bounds[1] > bounds[3] || center[0] < bounds[0] || center[0] > bounds[2] || center[1] < bounds[1] || center[1] > bounds[3] || landmark.priority !== void 0 && !Number.isFinite(landmark.priority) || !Array.isArray(landmark.models) || landmark.models.length < 1 || landmark.models.length > 8) {
94
- throw new Error("invalid landmark entry");
95
- }
96
- landmarkIds.add(landmark.id);
97
- const models = landmark.models.map((inputModel) => {
98
- const model = inputModel;
99
- if (!model) throw new Error("invalid landmark model");
100
- const archive = typeof model.archiveId === "string" ? archiveMap.get(model.archiveId) : void 0;
101
- if (!archive || !Number.isInteger(model?.minzoom) || model.minzoom < minzoom || model.minzoom > 24 || !Number.isInteger(model.z) || model.z < 0 || model.z > 26 || !Number.isInteger(model.x) || model.x < 0 || model.x >= 2 ** model.z || !Number.isInteger(model.y) || model.y < 0 || model.y >= 2 ** model.z || !Number.isSafeInteger(model.bytes) || model.bytes < 1 || model.bytes > 64 * 1024 * 1024 || typeof model.sha256 !== "string" || !sha256.test(model.sha256) || typeof model.axisConvention !== "string" || !axisConventions.has(model.axisConvention)) {
102
- throw new Error("invalid landmark model");
103
- }
104
- return {
105
- archive,
106
- archiveId: model.archiveId,
107
- axisConvention: model.axisConvention,
108
- bytes: model.bytes,
109
- key: [archive.sha256, model.z, model.x, model.y, model.sha256].join(":"),
110
- minzoom: model.minzoom,
111
- sha256: model.sha256,
112
- x: model.x,
113
- y: model.y,
114
- z: model.z
115
- };
116
- });
117
- models.sort((left, right) => left.minzoom - right.minzoom || left.key.localeCompare(right.key));
118
- if (new Set(models.map((model) => model.minzoom)).size !== models.length) {
119
- throw new Error("duplicate landmark LOD zoom");
120
- }
121
- if (models[0].minzoom !== minzoom) {
122
- throw new Error("landmark requires a base model");
123
- }
124
- return {
125
- bounds,
126
- center,
127
- id: landmark.id,
128
- models,
129
- priority: landmark.priority ?? 0
130
- };
131
- });
132
- return {
133
- archives: [...archiveMap.values()],
134
- id: value.id,
135
- landmarks,
136
- maximumCachedModels,
137
- maximumVisibleModels,
138
- minzoom,
139
- schemaVersion: 2
140
- };
141
- }
142
54
 
143
55
  // src/preview-scene.ts
144
56
  var previewSceneClient = String.raw`
@@ -230,6 +142,42 @@ function createPreviewSceneThemes(map, readSun) {
230
142
  };
231
143
  }
232
144
 
145
+ // src/preview-style-reads.ts
146
+ function createPreviewStyleReads(map) {
147
+ let snapshot;
148
+ map.on("style.load", () => {
149
+ snapshot = void 0;
150
+ });
151
+ return {
152
+ metadata() {
153
+ if (!snapshot || snapshot.style !== map.style) {
154
+ const style = map.getStyle();
155
+ if (!style) return void 0;
156
+ snapshot = { style: map.style, metadata: style.metadata };
157
+ }
158
+ return snapshot.metadata;
159
+ },
160
+ // Layers change between style loads, so they are read live, in the shape of the style's own
161
+ // layers. Custom layers have no style definition; getStyle leaves them out too.
162
+ layers() {
163
+ const layers = [];
164
+ for (const id of map.getLayersOrder()) {
165
+ const layer = map.getLayer(id);
166
+ if (!layer || layer.type === "custom") continue;
167
+ layers.push({
168
+ id: layer.id,
169
+ type: layer.type,
170
+ metadata: layer.metadata,
171
+ "source-layer": layer.sourceLayer,
172
+ minzoom: layer.minzoom,
173
+ maxzoom: layer.maxzoom
174
+ });
175
+ }
176
+ return layers;
177
+ }
178
+ };
179
+ }
180
+
233
181
  // src/preview-html.ts
234
182
  function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticPreview, fontFaces = [], fitViewport = false, controls) {
235
183
  const styleUrl = preview ? `${basePath}/styles/${preview.mapName}/${preview.themeName}.json${controls ? `?${new URLSearchParams({ controls })}` : ""}` : void 0;
@@ -341,7 +289,7 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
341
289
  <div class="badge" id="badge">Tileflow preview</div>
342
290
  <div class="status" id="status" role="status"></div>
343
291
  <div id="map"></div>
344
- <script type="importmap">{"imports":{"fflate":"${basePath}/__runtime/fflate.js","three":"${basePath}/__runtime/three.module.js"}}</script>
292
+ <script type="importmap">{"imports":{"three":"${basePath}/__runtime/three.module.js"}}</script>
345
293
  <script type="module">
346
294
  import * as maplibregl from "${basePath}/__runtime/maplibre-gl.mjs";
347
295
  import {createPreviewObserver} from "${basePath}/__runtime/devtools-observer.js";
@@ -349,7 +297,7 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
349
297
  import {registerTileflowContourProtocol} from "${basePath}/__runtime/tileflow-browser.js";
350
298
  import {registerTileflowPmtilesProtocol} from "${basePath}/__runtime/tileflow-browser.js";
351
299
  import {attachTileflowNotices} from "${basePath}/__runtime/tileflow-browser.js";
352
- import {createTileflowBuildings3D, createTileflowStreetLights, createTileflowVegetation} from "${basePath}/__runtime/tileflow-browser.js";
300
+ import {createTileflowMap3D} from "${basePath}/__runtime/tileflow-browser.js";
353
301
  import {readTileflowSunPreset, resolveTileflowSun, tileflowDefaultSunPreset} from "${basePath}/__runtime/tileflow-browser.js";
354
302
 
355
303
  maplibregl.setWorkerUrl("${basePath}/__runtime/maplibre-gl-worker.mjs");
@@ -359,7 +307,6 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
359
307
  if (window.parent !== window) document.documentElement.dataset.devtoolsEmbedded = "true";
360
308
 
361
309
  const initialStatus = ${JSON.stringify(initialStatus)};
362
- const normalizeTileflowLandmarkManifest = ${normalizeTileflowLandmarkManifest.toString()};
363
310
  const readBoundedTileflowResponse = ${readBoundedTileflowResponse.toString()};
364
311
  const readBoundedTileflowJsonResponse = ${readBoundedTileflowJsonResponse.toString()};
365
312
  const initialGeneration = initialStatus.generation;
@@ -409,16 +356,6 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
409
356
  }
410
357
  let threeDimensionalEnabled = readToggleFromUrl("buildings3d", true);
411
358
  let treesEnabled = readToggleFromUrl("trees3d", true);
412
- const landmarkState = {
413
- active: [],
414
- cacheLimit: undefined,
415
- enabled: threeDimensionalEnabled,
416
- manifestId: undefined,
417
- loaded: [],
418
- loading: [],
419
- errors: []
420
- };
421
- globalThis.__tileflowLandmarkState = landmarkState;
422
359
  const buildingWireframeMetrics = {
423
360
  buildingCount: 0,
424
361
  refreshMilliseconds: undefined,
@@ -427,22 +364,11 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
427
364
  };
428
365
  globalThis.__tileflowBuildingWireframeMetrics = buildingWireframeMetrics;
429
366
  let THREE;
430
- let GLTFLoader;
431
- let DRACOLoader;
432
- let MeshoptDecoder;
433
367
  let LineMaterial;
434
368
  let LineSegments2;
435
369
  let LineSegmentsGeometry;
436
- let PMTiles;
437
- let FetchSource;
438
- let landmarkDracoLoader;
439
370
  let threeCoreRuntimePromise;
440
371
  let buildingWireframeRuntimePromise;
441
- let landmarkRuntimePromise;
442
- let pmtilesRuntimePromise;
443
- const landmarkManifestPromises = new Map();
444
- const landmarkManifestMaximumBytes = 1024 * 1024;
445
- const landmarkManifestTimeoutMs = 10000;
446
372
 
447
373
  function loadThreeCoreRuntime() {
448
374
  if (!threeCoreRuntimePromise) {
@@ -471,89 +397,6 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
471
397
  return buildingWireframeRuntimePromise;
472
398
  }
473
399
 
474
- function loadLandmarkRuntime() {
475
- if (!landmarkRuntimePromise) {
476
- landmarkRuntimePromise = Promise.all([
477
- loadThreeCoreRuntime(),
478
- import("${basePath}/__runtime/three-addons/loaders/GLTFLoader.js"),
479
- import("${basePath}/__runtime/three-addons/loaders/DRACOLoader.js"),
480
- import("${basePath}/__runtime/three-addons/libs/meshopt_decoder.module.js"),
481
- loadPmtilesRuntime()
482
- ]).then(([, gltfLoaderModule, dracoLoaderModule, meshoptDecoderModule, pmtilesModule]) => {
483
- GLTFLoader = gltfLoaderModule.GLTFLoader;
484
- DRACOLoader = dracoLoaderModule.DRACOLoader;
485
- MeshoptDecoder = meshoptDecoderModule.MeshoptDecoder;
486
- PMTiles = pmtilesModule.PMTiles;
487
- FetchSource = pmtilesModule.FetchSource;
488
- landmarkDracoLoader = new DRACOLoader()
489
- .setDecoderPath("${basePath}/__runtime/three-addons/libs/draco/gltf/")
490
- .setDecoderConfig({type: "wasm"});
491
- landmarkDracoLoader.preload();
492
- });
493
- }
494
- return landmarkRuntimePromise;
495
- }
496
-
497
- function loadPmtilesRuntime() {
498
- if (!pmtilesRuntimePromise) {
499
- pmtilesRuntimePromise = import("${basePath}/__runtime/pmtiles.js").then((pmtilesModule) => {
500
- PMTiles = pmtilesModule.PMTiles;
501
- FetchSource = pmtilesModule.FetchSource;
502
- return pmtilesModule;
503
- });
504
- }
505
- return pmtilesRuntimePromise;
506
- }
507
-
508
- function waitForLandmarkManifest(promise, signal) {
509
- if (!signal) return promise;
510
- if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
511
- return new Promise((resolve, reject) => {
512
- const abort = () => reject(new DOMException("Aborted", "AbortError"));
513
- signal.addEventListener("abort", abort, {once: true});
514
- promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
515
- });
516
- }
517
-
518
- function loadLandmarkManifestCandidate(manifestUrl, signal) {
519
- const existing = landmarkManifestPromises.get(manifestUrl);
520
- if (existing) return waitForLandmarkManifest(existing, signal);
521
- const resolvedUrl = new URL(manifestUrl, location.href);
522
- if (
523
- !["http:", "https:"].includes(resolvedUrl.protocol) ||
524
- resolvedUrl.username ||
525
- resolvedUrl.password
526
- ) throw new Error("invalid landmark manifest URL");
527
- const controller = new AbortController();
528
- const timeout = setTimeout(() => controller.abort(), landmarkManifestTimeoutMs);
529
- const pending = (async () => {
530
- try {
531
- const response = await fetch(resolvedUrl, {
532
- credentials: "include",
533
- signal: controller.signal
534
- });
535
- if (!response.ok) throw new Error("HTTP " + response.status);
536
- return readBoundedTileflowJsonResponse(response, landmarkManifestMaximumBytes);
537
- } finally {
538
- clearTimeout(timeout);
539
- }
540
- })();
541
- landmarkManifestPromises.set(manifestUrl, pending);
542
- pending.catch(() => {
543
- if (landmarkManifestPromises.get(manifestUrl) === pending) {
544
- landmarkManifestPromises.delete(manifestUrl);
545
- }
546
- });
547
- return waitForLandmarkManifest(pending, signal);
548
- }
549
-
550
- function loadLandmarkPrerequisites(manifestUrl, signal) {
551
- return Promise.all([
552
- loadLandmarkRuntime(),
553
- loadLandmarkManifestCandidate(manifestUrl, signal)
554
- ]);
555
- }
556
-
557
400
  function percentile(values, percentileValue) {
558
401
  if (values.length === 0) return undefined;
559
402
  const sorted = [...values].sort((left, right) => left - right);
@@ -1021,7 +864,10 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
1021
864
  }
1022
865
 
1023
866
  function updateGlobeBackdrop(map) {
1024
- const projection = map.getProjection?.()?.type ?? map.getStyle?.()?.projection?.type;
867
+ // getProjection reads the style's projection without serializing the style.
868
+ const projection = map.getProjection
869
+ ? map.getProjection()?.type
870
+ : map.getStyle?.()?.projection?.type;
1025
871
  const container = map.getContainer?.();
1026
872
  if (!container || !projection) return;
1027
873
  if (projection !== "globe") {
@@ -1036,7 +882,7 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
1036
882
  // Uses the map's declared atmosphere for the preview's space and rim, so a themed globe shows
1037
883
  // its own colours; maps without one keep the default backdrop.
1038
884
  function applyAtmosphereBackdrop(map, container) {
1039
- const atmosphere = map.getStyle?.()?.metadata?.["tileflow:atmosphere"];
885
+ const atmosphere = styleReads(map).metadata()?.["tileflow:atmosphere"];
1040
886
  const rgba = (hex, alpha) => {
1041
887
  const match = /^#([0-9a-f]{6})$/i.exec(String(hex ?? ""));
1042
888
  if (!match) return undefined;
@@ -1512,530 +1358,44 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
1512
1358
  return zoom >= minzoom && zoom < maxzoom;
1513
1359
  }
1514
1360
 
1515
- function createLandmarkLayer(map, configLayer, fallbackLayers = []) {
1516
- const manifestUrl = configLayer.metadata?.["tileflow:landmark-manifest-url"];
1517
- const {minzoom: landmarkMinimumZoom, maxzoom: landmarkMaximumZoom} =
1518
- styleLayerZoomRange(configLayer);
1519
- const abortController = new AbortController();
1520
- const loaded = new Map();
1521
- const loading = new Map();
1522
- const archiveReaders = new Map();
1523
- const activeLandmarkIds = new Set();
1524
- let cacheClock = 0;
1525
- let camera;
1526
- let renderer;
1527
- let scene;
1528
- let group;
1529
- let sceneOriginMercator;
1530
- let sceneElevation = 0;
1531
- let manifest;
1532
- let fallbackSignature;
1533
- let queuedRefresh;
1534
- const mapMatrix = new THREE.Matrix4();
1535
- const sceneMatrix = new THREE.Matrix4();
1536
-
1537
- function overlapsViewport(bounds, paddingRatio = 0) {
1538
- const viewport = map.getBounds();
1539
- const longitudePadding = Math.abs(viewport.getEast() - viewport.getWest()) * paddingRatio;
1540
- const latitudePadding = Math.abs(viewport.getNorth() - viewport.getSouth()) * paddingRatio;
1541
- return !(
1542
- bounds[2] < viewport.getWest() - longitudePadding ||
1543
- bounds[0] > viewport.getEast() + longitudePadding ||
1544
- bounds[3] < viewport.getSouth() - latitudePadding ||
1545
- bounds[1] > viewport.getNorth() + latitudePadding
1546
- );
1547
- }
1548
-
1549
- function updateLandmarkFallbackFilters() {
1550
- const readyIds = [...activeLandmarkIds]
1551
- .filter((id) => loaded.has(id))
1552
- .sort();
1553
- const signature = JSON.stringify(readyIds);
1554
- if (signature === fallbackSignature) return;
1555
- fallbackSignature = signature;
1556
- for (const fallback of fallbackLayers) {
1557
- if (!map.getLayer(fallback.id)) continue;
1558
- const spatialExclusions = (fallback.spatialFallbacks ?? [])
1559
- .filter((entry) => readyIds.includes(entry.id))
1560
- .map((entry) => ["!", ["within", {
1561
- type: "Polygon",
1562
- coordinates: [[
1563
- [entry.bounds[0], entry.bounds[1]],
1564
- [entry.bounds[2], entry.bounds[1]],
1565
- [entry.bounds[2], entry.bounds[3]],
1566
- [entry.bounds[0], entry.bounds[3]],
1567
- [entry.bounds[0], entry.bounds[1]]
1568
- ]]
1569
- }]]);
1570
- map.setFilter(
1571
- fallback.id,
1572
- readyIds.length === 0 && spatialExclusions.length === 0
1573
- ? (fallback.filter ?? null)
1574
- : [
1575
- "all",
1576
- ...(fallback.filter ? [fallback.filter] : []),
1577
- [
1578
- "!",
1579
- ["in", ["get", "landmark_id"], ["literal", readyIds]]
1580
- ],
1581
- ...spatialExclusions
1582
- ]
1583
- );
1584
- }
1585
- }
1586
-
1587
- function queueRefresh() {
1588
- if (queuedRefresh !== undefined) return;
1589
- queuedRefresh = setTimeout(() => {
1590
- queuedRefresh = undefined;
1591
- refresh();
1592
- }, 100);
1593
- }
1594
-
1595
- function ensureSceneOrigin(elevation) {
1596
- if (sceneOriginMercator) return;
1597
- sceneElevation = elevation;
1598
- sceneOriginMercator = maplibregl.MercatorCoordinate.fromLngLat(
1599
- map.getCenter(),
1600
- elevation
1601
- );
1602
- }
1603
-
1604
- function localPosition(lng, lat, elevation) {
1605
- const mercator = maplibregl.MercatorCoordinate.fromLngLat([lng, lat]);
1606
- const mercatorPerMeter = sceneOriginMercator.meterInMercatorCoordinateUnits();
1607
- return new THREE.Vector3(
1608
- (mercator.x - sceneOriginMercator.x) / mercatorPerMeter,
1609
- elevation - sceneElevation,
1610
- (sceneOriginMercator.y - mercator.y) / mercatorPerMeter
1611
- );
1612
- }
1613
-
1614
- function disposeModel(model) {
1615
- const disposed = new Set();
1616
- group?.remove(model);
1617
- for (const texture of model.userData?.tileflowDetachedTextures ?? []) {
1618
- if (!texture?.isTexture || disposed.has(texture)) continue;
1619
- disposed.add(texture);
1620
- texture.dispose();
1621
- }
1622
- for (const material of model.userData?.tileflowDetachedMaterials ?? []) {
1623
- if (!material || disposed.has(material)) continue;
1624
- for (const value of Object.values(material)) {
1625
- if (value?.isTexture && !disposed.has(value)) {
1626
- disposed.add(value);
1627
- value.dispose();
1628
- }
1629
- }
1630
- disposed.add(material);
1631
- material.dispose?.();
1632
- }
1633
- model.traverse((object) => {
1634
- if (object.geometry && !disposed.has(object.geometry)) {
1635
- disposed.add(object.geometry);
1636
- object.geometry.dispose?.();
1637
- }
1638
- const materials = Array.isArray(object.material)
1639
- ? object.material
1640
- : [object.material];
1641
- for (const material of materials) {
1642
- if (!material || disposed.has(material)) continue;
1643
- for (const value of Object.values(material)) {
1644
- if (value?.isTexture && !disposed.has(value)) {
1645
- disposed.add(value);
1646
- value.dispose();
1647
- }
1648
- }
1649
- disposed.add(material);
1650
- material.dispose?.();
1651
- }
1652
- });
1653
- }
1654
-
1655
- function harmonizeLandmarkModel(model) {
1656
- const detachedMaterials = new Set();
1657
- const uniformMaterial = new THREE.MeshStandardMaterial({
1658
- alphaTest: 0,
1659
- color: "#EEE4D4",
1660
- depthWrite: true,
1661
- metalness: 0,
1662
- opacity: 1,
1663
- roughness: 0.9,
1664
- side: THREE.DoubleSide,
1665
- transparent: false
1666
- });
1667
- model.traverse((object) => {
1668
- if (!object.isMesh) return;
1669
- const originalMaterials = Array.isArray(object.material)
1670
- ? object.material
1671
- : [object.material];
1672
- for (const material of originalMaterials) {
1673
- if (material) detachedMaterials.add(material);
1674
- }
1675
- object.material = Array.isArray(object.material)
1676
- ? object.material.map(() => uniformMaterial)
1677
- : uniformMaterial;
1678
- });
1679
- model.userData.tileflowDetachedMaterials = [...detachedMaterials];
1680
- }
1681
-
1682
- function normalizeLandmarkAxes(sourceModel, axisConvention) {
1683
- if (axisConvention === "EUN_Y_UP") return sourceModel;
1684
- if (axisConvention === "ENU_Z_UP") {
1685
- sourceModel.rotateX(-Math.PI / 2);
1686
- }
1687
- const normalizedModel = new THREE.Group();
1688
- normalizedModel.scale.z = -1;
1689
- normalizedModel.add(sourceModel);
1690
- return normalizedModel;
1691
- }
1692
-
1693
- function unloadLandmark(id) {
1694
- const entry = loaded.get(id);
1695
- if (!entry) return;
1696
- disposeModel(entry.model);
1697
- loaded.delete(id);
1698
- landmarkState.loaded = [...loaded.keys()];
1699
- fallbackSignature = undefined;
1700
- }
1701
-
1702
- function enforceCacheLimit() {
1703
- if (!manifest || loaded.size <= manifest.maximumCachedModels) return;
1704
- const evictionCandidates = [...loaded.entries()]
1705
- .filter(([id]) => !activeLandmarkIds.has(id))
1706
- .sort(
1707
- ([leftId, left], [rightId, right]) =>
1708
- left.lastUsed - right.lastUsed || leftId.localeCompare(rightId)
1709
- );
1710
- while (
1711
- loaded.size > manifest.maximumCachedModels &&
1712
- evictionCandidates.length > 0
1713
- ) {
1714
- unloadLandmark(evictionCandidates.shift()[0]);
1715
- }
1716
- }
1717
-
1718
- function landmarkModelAtZoom(landmark, zoom) {
1719
- let selected = landmark.models[0];
1720
- for (const candidate of landmark.models) {
1721
- if (candidate.minzoom > zoom) break;
1722
- selected = candidate;
1723
- }
1724
- return selected;
1725
- }
1726
-
1727
- async function sha256Hex(bytes) {
1728
- const digest = await crypto.subtle.digest("SHA-256", bytes);
1729
- return [...new Uint8Array(digest)]
1730
- .map((value) => value.toString(16).padStart(2, "0"))
1731
- .join("");
1732
- }
1733
-
1734
- function archiveReader(archive) {
1735
- let reader = archiveReaders.get(archive.url);
1736
- if (!reader) {
1737
- reader = new PMTiles(
1738
- new FetchSource(archive.url, new Headers(), "include")
1739
- );
1740
- archiveReaders.set(archive.url, reader);
1741
- }
1742
- return reader;
1743
- }
1744
-
1745
- async function loadLandmarkBytes(modelDefinition) {
1746
- const tile = await archiveReader(modelDefinition.archive).getZxy(
1747
- modelDefinition.z,
1748
- modelDefinition.x,
1749
- modelDefinition.y,
1750
- abortController.signal
1751
- );
1752
- if (!tile) throw new Error("landmark entry is missing from PMTiles");
1753
- if (tile.data.byteLength !== modelDefinition.bytes) {
1754
- throw new Error("landmark entry has an unexpected byte length");
1755
- }
1756
- if ((await sha256Hex(tile.data)) !== modelDefinition.sha256) {
1757
- throw new Error("landmark entry has an unexpected SHA-256");
1758
- }
1759
- return tile.data;
1760
- }
1761
-
1762
- function loadLandmark(landmark, requestedModel) {
1763
- const cached = loaded.get(landmark.id);
1764
- const nextModel = cached ? requestedModel : landmark.models[0];
1765
- if (cached?.modelKey === nextModel.key) {
1766
- cached.lastUsed = ++cacheClock;
1767
- cached.model.visible = activeLandmarkIds.has(landmark.id);
1768
- return;
1769
- }
1770
- if (cached) {
1771
- // Keep the previous LOD visible until its replacement has loaded.
1772
- cached.lastUsed = ++cacheClock;
1773
- cached.model.visible = activeLandmarkIds.has(landmark.id);
1774
- }
1775
- if (loading.has(landmark.id)) return;
1776
- loading.set(landmark.id, nextModel.key);
1777
- landmarkState.loading = [...loading.keys()];
1778
- const loader = new GLTFLoader();
1779
- loader.setMeshoptDecoder(MeshoptDecoder);
1780
- loader.setDRACOLoader(landmarkDracoLoader);
1781
- loader.setWithCredentials(true);
1782
- const fail = (error) => {
1783
- if (loading.get(landmark.id) !== nextModel.key) return;
1784
- loading.delete(landmark.id);
1785
- landmarkState.loading = [...loading.keys()];
1786
- landmarkState.errors = [
1787
- ...landmarkState.errors.filter((item) => item.id !== landmark.id),
1788
- {id: landmark.id, message: String(error)}
1789
- ];
1790
- console.warn("Tileflow landmark failed to load", landmark.id, error);
1791
- };
1792
- void loadLandmarkBytes(nextModel).then((bytes) => {
1793
- if (loading.get(landmark.id) !== nextModel.key) return;
1794
- loader.parse(
1795
- bytes,
1796
- new URL(".", nextModel.archive.url).toString(),
1797
- (gltf) => {
1798
- if (loading.get(landmark.id) !== nextModel.key) {
1799
- disposeModel(gltf.scene);
1800
- return;
1801
- }
1802
- loading.delete(landmark.id);
1803
- landmarkState.loading = [...loading.keys()];
1804
- if (abortController.signal.aborted) {
1805
- disposeModel(gltf.scene);
1806
- return;
1807
- }
1808
- const elevation = map.queryTerrainElevation(landmark.center) ?? 0;
1809
- ensureSceneOrigin(elevation);
1810
- const model = normalizeLandmarkAxes(
1811
- gltf.scene,
1812
- nextModel.axisConvention
1813
- );
1814
- model.name = "tileflow-landmark-" + landmark.id;
1815
- harmonizeLandmarkModel(model);
1816
- model.position.copy(
1817
- localPosition(landmark.center[0], landmark.center[1], elevation)
1818
- );
1819
- model.traverse((object) => {
1820
- if (!object.isMesh) return;
1821
- object.frustumCulled = true;
1822
- object.castShadow = false;
1823
- object.receiveShadow = false;
1824
- });
1825
- model.visible = activeLandmarkIds.has(landmark.id);
1826
- if (loaded.has(landmark.id)) unloadLandmark(landmark.id);
1827
- loaded.set(landmark.id, {
1828
- lastUsed: ++cacheClock,
1829
- model,
1830
- modelKey: nextModel.key
1831
- });
1832
- landmarkState.errors = landmarkState.errors.filter(
1833
- (item) => item.id !== landmark.id
1834
- );
1835
- landmarkState.loaded = [...loaded.keys()];
1836
- group.add(model);
1837
- enforceCacheLimit();
1838
- fallbackSignature = undefined;
1839
- refresh();
1840
- },
1841
- fail
1842
- );
1843
- }).catch(fail);
1844
- }
1845
-
1846
- function refresh() {
1847
- if (
1848
- !threeDimensionalEnabled ||
1849
- !manifest ||
1850
- !styleLayerIsVisibleAtZoom(configLayer, map.getZoom())
1851
- ) {
1852
- activeLandmarkIds.clear();
1853
- landmarkState.active = [];
1854
- for (const entry of loaded.values()) entry.model.visible = false;
1855
- updateLandmarkFallbackFilters();
1856
- return;
1857
- }
1858
- const zoom = map.getZoom();
1859
- const visible = manifest.landmarks
1860
- .filter((landmark) => overlapsViewport(landmark.bounds))
1861
- .sort(
1862
- (left, right) =>
1863
- right.priority - left.priority || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
1864
- )
1865
- .slice(0, manifest.maximumVisibleModels);
1866
- activeLandmarkIds.clear();
1867
- for (const landmark of visible) activeLandmarkIds.add(landmark.id);
1868
- landmarkState.active = visible.map((landmark) => landmark.id);
1869
- for (const [id, entry] of loaded) {
1870
- entry.model.visible = activeLandmarkIds.has(id);
1871
- }
1872
- for (const landmark of visible) {
1873
- loadLandmark(landmark, landmarkModelAtZoom(landmark, zoom));
1874
- }
1875
- const nearbyCacheSlots = Math.max(
1876
- 0,
1877
- manifest.maximumCachedModels - visible.length
1878
- );
1879
- const nearby = manifest.landmarks
1880
- .filter(
1881
- (landmark) =>
1882
- !activeLandmarkIds.has(landmark.id) &&
1883
- overlapsViewport(landmark.bounds, 0.35)
1884
- )
1885
- .sort(
1886
- (left, right) =>
1887
- right.priority - left.priority || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
1888
- )
1889
- .slice(0, nearbyCacheSlots);
1890
- for (const landmark of nearby) {
1891
- loadLandmark(landmark, landmark.models[0]);
1892
- }
1893
- enforceCacheLimit();
1894
- updateLandmarkFallbackFilters();
1895
- map.triggerRepaint();
1896
- }
1897
-
1898
- function normalizeManifest(candidate) {
1899
- return normalizeTileflowLandmarkManifest(candidate, manifestUrl);
1900
- }
1901
-
1902
- async function loadManifest() {
1903
- try {
1904
- const candidate = await loadLandmarkManifestCandidate(
1905
- manifestUrl,
1906
- abortController.signal
1907
- );
1908
- if (abortController.signal.aborted) return;
1909
- manifest = normalizeManifest(candidate);
1910
- landmarkState.manifestId = candidate.id;
1911
- landmarkState.cacheLimit = manifest.maximumCachedModels;
1912
- refresh();
1913
- } catch (error) {
1914
- if (error?.name !== "AbortError") {
1915
- console.warn("Tileflow landmark manifest failed to load", error);
1916
- }
1917
- }
1918
- }
1919
-
1920
- return {
1921
- id: "tileflow-landmarks-3d",
1922
- type: "custom",
1923
- renderingMode: "3d",
1924
- minzoom: landmarkMinimumZoom,
1925
- ...(Number.isFinite(landmarkMaximumZoom)
1926
- ? {maxzoom: landmarkMaximumZoom}
1927
- : {}),
1928
- metadata: {"tileflow:3d-toggle": "landmark"},
1929
- onAdd(_map, gl) {
1930
- camera = new THREE.Camera();
1931
- scene = new THREE.Scene();
1932
- scene.rotateX(Math.PI / 2);
1933
- scene.scale.multiply(new THREE.Vector3(1, 1, -1));
1934
- group = new THREE.Group();
1935
- scene.add(group);
1936
- scene.add(new THREE.AmbientLight(0xffffff, 2.2));
1937
- scene.add(new THREE.HemisphereLight(0xfff8e8, 0xd8cfbd, 0.8));
1938
- const sun = new THREE.DirectionalLight(0xffffff, 0.45);
1939
- sun.position.set(-120, 220, 100);
1940
- scene.add(sun);
1941
- renderer = new THREE.WebGLRenderer({
1942
- canvas: map.getCanvas(),
1943
- context: gl
1944
- });
1945
- renderer.autoClear = false;
1946
- renderer.outputColorSpace = THREE.SRGBColorSpace;
1947
- map.on("move", queueRefresh);
1948
- map.on("moveend", refresh);
1949
- map.on("tileflow:3d-toggle", refresh);
1950
- void loadManifest();
1951
- },
1952
- render(_gl, options) {
1953
- if (
1954
- !threeDimensionalEnabled ||
1955
- !sceneOriginMercator ||
1956
- group.children.length === 0
1957
- ) return;
1958
- const meter = sceneOriginMercator.meterInMercatorCoordinateUnits();
1959
- mapMatrix.fromArray(options.defaultProjectionData.mainMatrix);
1960
- sceneMatrix
1961
- .makeTranslation(
1962
- sceneOriginMercator.x,
1963
- sceneOriginMercator.y,
1964
- sceneOriginMercator.z
1965
- )
1966
- .scale(new THREE.Vector3(meter, -meter, meter));
1967
- camera.projectionMatrix.copy(mapMatrix).multiply(sceneMatrix);
1968
- renderer.resetState();
1969
- renderer.render(scene, camera);
1970
- },
1971
- onRemove() {
1972
- abortController.abort();
1973
- if (queuedRefresh !== undefined) clearTimeout(queuedRefresh);
1974
- map.off("move", queueRefresh);
1975
- map.off("moveend", refresh);
1976
- map.off("tileflow:3d-toggle", refresh);
1977
- for (const entry of loaded.values()) disposeModel(entry.model);
1978
- loaded.clear();
1979
- for (const fallback of fallbackLayers) {
1980
- if (map.getLayer(fallback.id)) {
1981
- map.setFilter(fallback.id, fallback.filter ?? null);
1982
- }
1983
- }
1984
- renderer?.dispose();
1985
- }
1986
- };
1987
- }
1988
-
1989
- function addLandmarkLayerIfConfigured(map, styleLayers) {
1990
- const configLayer = styleLayers.find(
1991
- (layer) => layer.metadata?.["tileflow:landmark-manifest-url"]
1992
- );
1993
- if (!configLayer || map.getLayer("tileflow-landmarks-3d")) return;
1994
- const fallbackLayers = styleLayers
1995
- .filter((layer) => layer.metadata?.["tileflow:landmark-fallback"] === true)
1996
- .map((layer) => ({
1997
- filter: layer.filter,
1998
- id: layer.id,
1999
- spatialFallbacks:
2000
- layer.metadata?.["tileflow:landmark-spatial-fallbacks"] ?? []
2001
- }));
2002
- // Landmark custom layers use Mercator world coordinates.
2003
- if (map.getProjection().type !== "mercator") {
2004
- map.setProjection({type: "mercator"});
2005
- }
2006
- const firstForegroundLabel = styleLayers.find(
2007
- (layer) =>
2008
- (layer.id.startsWith("tileflow-label-") &&
2009
- !layer.id.startsWith("tileflow-label-road-")) ||
2010
- layer.id === "tileflow-poi" ||
2011
- layer.id.startsWith("tileflow-poi-")
2012
- )?.id;
2013
- const treeLayer = map.getLayer("tileflow-vegetation-trees")
2014
- ? "tileflow-vegetation-trees"
2015
- : firstForegroundLabel;
2016
- map.addLayer(createLandmarkLayer(map, configLayer, fallbackLayers), treeLayer);
2017
- }
2018
-
2019
- // Core's 3D layers, as an application mounts them: the map's style describes how its
1361
+ // Core's 3D scene, as an application mounts it: the map's style describes how its
2020
1362
  // buildings, trees and street lights look, and the controls only show and hide them. The
2021
1363
  // vegetation control hides all trees when off. The preview shows one theme at a
2022
1364
  // time, so the style on screen holds the sun and the design.
2023
- const coreScene = {buildings: undefined, trees: undefined, lights: undefined, buildingsShown: undefined, animation: "0"};
1365
+ const coreScene = {scene: undefined, shown: undefined, buildingsShown: undefined, animation: "0"};
2024
1366
  const runtimeLayerVisibility = {};
2025
1367
  globalThis.__tileflowPreviewScene = coreScene;
2026
1368
  globalThis.__tileflowRuntimeVisibility = (id, visible) => {
2027
1369
  if (!['tileflow-buildings-3d','tileflow-trees-3d','tileflow-street-lights'].includes(id)) return undefined;
2028
- if (typeof visible === 'boolean') { runtimeLayerVisibility[id]=visible; mountCoreScene(map); map.triggerRepaint(); }
1370
+ const previewMap = globalThis.__tileflowPreviewMap;
1371
+ if (typeof visible === 'boolean') {
1372
+ runtimeLayerVisibility[id] = visible;
1373
+ if (previewMap) { mountCoreScene(previewMap); previewMap.triggerRepaint(); }
1374
+ }
2029
1375
  return runtimeLayerVisibility[id] !== false;
2030
1376
  };
2031
1377
  // Explicit observation capability: controllers stay private to the preview and expose
2032
1378
  // only their public state/statistics to the parent inspector.
2033
- globalThis.__tileflowInspectModels = () => [...(coreScene.buildings?.inspectModels?.() || []), ...(coreScene.trees?.inspectModels?.() || [])];
2034
- globalThis.__tileflowDescribeRuntimeLayers = () => ({
2035
- ...(coreScene.buildings ? {"tileflow-buildings-3d": {controller:"Buildings 3D", state:{visible:runtimeLayerVisibility["tileflow-buildings-3d"] !== false, shown:coreScene.buildingsShown, scale:coreScene.buildings.scale}, stats:coreScene.buildings.stats}} : {}),
2036
- ...(coreScene.trees ? {"tileflow-trees-3d": {controller:"Vegetation", state:{visible:runtimeLayerVisibility["tileflow-trees-3d"] !== false, representation:coreScene.trees.representation}, stats:coreScene.trees.stats}} : {}),
2037
- ...(coreScene.lights ? {"tileflow-street-lights": {controller:"Street lights", state:{visible:runtimeLayerVisibility["tileflow-street-lights"] !== false, mounted:true}, stats:coreScene.lights.stats}} : {})
2038
- });
1379
+ globalThis.__tileflowInspectModels = () => [...(coreScene.scene?.buildings?.inspectModels?.() || []), ...(coreScene.scene?.vegetation?.inspectModels?.() || [])];
1380
+ globalThis.__tileflowDescribeRuntimeLayers = () => {
1381
+ const {buildings, vegetation, streetLights} = coreScene.scene ?? {};
1382
+ return {
1383
+ ...(buildings ? {"tileflow-buildings-3d": {controller:"Buildings 3D", state:{visible:runtimeLayerVisibility["tileflow-buildings-3d"] !== false, shown:coreScene.buildingsShown, scale:buildings.scale}, stats:buildings.stats}} : {}),
1384
+ ...(vegetation ? {"tileflow-trees-3d": {controller:"Vegetation", state:{visible:runtimeLayerVisibility["tileflow-trees-3d"] !== false, representation:vegetation.representation}, stats:vegetation.stats}} : {}),
1385
+ ...(streetLights ? {"tileflow-street-lights": {controller:"Street lights", state:{visible:runtimeLayerVisibility["tileflow-street-lights"] !== false, mounted:true}, stats:streetLights.stats}} : {})
1386
+ };
1387
+ };
1388
+ const createPreviewStyleReads = ${createPreviewStyleReads.toString()};
1389
+ const previewStyleReads = new WeakMap();
1390
+ // Style reads for code that runs on every camera move or style event.
1391
+ function styleReads(map) {
1392
+ let reads = previewStyleReads.get(map);
1393
+ if (!reads) {
1394
+ reads = createPreviewStyleReads(map);
1395
+ previewStyleReads.set(map, reads);
1396
+ }
1397
+ return reads;
1398
+ }
2039
1399
  const createPreviewSceneThemes = ${createPreviewSceneThemes.toString()};
2040
1400
  const coreSceneThemeControllers = new WeakMap();
2041
1401
  function coreSceneThemes(map) {
@@ -2052,26 +1412,34 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
2052
1412
  if (mountingCoreScene) return;
2053
1413
  mountingCoreScene = true;
2054
1414
  try {
2055
- const metadata = map.getStyle()?.metadata ?? {};
2056
- const controller = coreSceneThemes(map);
2057
- if (!coreScene.buildings && metadata["tileflow:buildings"]) {
2058
- coreScene.buildings = createTileflowBuildings3D({map, controller});
2059
- }
2060
- if (!coreScene.trees) {
1415
+ const metadata = styleReads(map).metadata() ?? {};
1416
+ const treesShown = () => treesEnabled && runtimeLayerVisibility["tileflow-trees-3d"] !== false;
1417
+ if (!coreScene.scene) {
2061
1418
  if (!previewSearchParameters.has("trees3d")) {
2062
1419
  treesEnabled = metadata["tileflow:vegetation-display"]?.visible ?? true;
2063
1420
  }
2064
- coreScene.trees = createTileflowVegetation({map, controller, visible: treesEnabled,
2065
- viewMode: threeDimensionalEnabled ? "3d" : "2d", transformRequest: previewTransformRequest});
1421
+ // The scene mounts what the style declares; street lights belong to the 3D view.
1422
+ coreScene.scene = createTileflowMap3D({map, controller: coreSceneThemes(map),
1423
+ viewMode: threeDimensionalEnabled ? "3d" : "2d", vegetation: treesShown(),
1424
+ transformRequest: previewTransformRequest});
1425
+ }
1426
+ const scene = coreScene.scene;
1427
+ scene.setVegetation(treesShown());
1428
+ const instant = {transition: {duration: 0}};
1429
+ const animation = previewSearchParameters.get('animation') || '0';
1430
+ const duration = animation === 'fade' ? 800 : Math.min(3000, Math.max(0, Number(animation) || 0));
1431
+ // Only a change of the controls moves the layers, so a style event does not restart them.
1432
+ if (coreScene.shown !== threeDimensionalEnabled || coreScene.animation !== animation) {
1433
+ coreScene.shown = threeDimensionalEnabled;
1434
+ coreScene.buildingsShown = threeDimensionalEnabled;
1435
+ coreScene.animation = animation;
1436
+ void (threeDimensionalEnabled ? scene.show({transition: {duration, mode: animation === "fade" ? "fade" : "grow"}}) : scene.hide(instant));
2066
1437
  }
2067
- coreScene.trees.setVisible(treesEnabled && runtimeLayerVisibility["tileflow-trees-3d"] !== false);
2068
- coreScene.trees.setViewMode(threeDimensionalEnabled ? "3d" : "2d");
2069
- // Street lights require an explicit map opt-in and the 3D control; defaults stay off.
2070
- if (!coreScene.lights && threeDimensionalEnabled && metadata["tileflow:street-lights"]) {
2071
- coreScene.lights = createTileflowStreetLights({map, controller});
2072
- } else if (coreScene.lights && (!threeDimensionalEnabled || !metadata["tileflow:street-lights"])) {
2073
- coreScene.lights.dispose();
2074
- coreScene.lights = undefined;
1438
+ // The inspector can hide the buildings alone.
1439
+ const showBuildings = threeDimensionalEnabled && runtimeLayerVisibility["tileflow-buildings-3d"] !== false;
1440
+ if (scene.buildings && coreScene.buildingsShown !== showBuildings) {
1441
+ coreScene.buildingsShown = showBuildings;
1442
+ void (showBuildings ? scene.buildings.show(instant) : scene.buildings.hide(instant));
2075
1443
  }
2076
1444
  const lightLayer = map.getLayer("tileflow-street-lights");
2077
1445
  const lightRenderer = lightLayer?.implementation || lightLayer;
@@ -2082,16 +1450,6 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
2082
1450
  };
2083
1451
  lightRenderer.__previewVisibility = true;
2084
1452
  }
2085
- const showBuildings = threeDimensionalEnabled && runtimeLayerVisibility["tileflow-buildings-3d"] !== false;
2086
- const instant = {transition: {duration: 0}};
2087
- const animation = previewSearchParameters.get('animation') || '0';
2088
- const duration = animation === 'fade' ? 800 : Math.min(3000, Math.max(0, Number(animation) || 0));
2089
- // Only a change of the controls moves the layers, so a style event does not restart them.
2090
- if (coreScene.buildings && (coreScene.buildingsShown !== showBuildings || coreScene.animation !== animation)) {
2091
- coreScene.buildingsShown = showBuildings;
2092
- coreScene.animation = animation;
2093
- void (showBuildings ? coreScene.buildings.show({transition: {duration, mode: animation === "fade" ? "fade" : "grow"}}) : coreScene.buildings.hide(instant));
2094
- }
2095
1453
  } finally {mountingCoreScene = false;}
2096
1454
  }
2097
1455
 
@@ -2126,7 +1484,6 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
2126
1484
  if (typeof enabled !== "boolean" || enabled === this.enabled) return;
2127
1485
  this.enabled = enabled;
2128
1486
  threeDimensionalEnabled = this.enabled;
2129
- landmarkState.enabled = this.enabled;
2130
1487
  this.update();
2131
1488
  writeToggleStateToUrl();
2132
1489
  this.map.fire("tileflow:3d-toggle", {enabled: this.enabled});
@@ -2135,11 +1492,12 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
2135
1492
  }
2136
1493
 
2137
1494
  update() {
2138
- for (const layer of this.map.getStyle()?.layers || []) {
1495
+ const reads = styleReads(this.map);
1496
+ for (const layer of reads.layers()) {
2139
1497
  const toggle = layer.metadata?.["tileflow:3d-toggle"];
2140
1498
  if (toggle !== "building" && toggle !== "landmark") continue;
2141
1499
  // Core's 3D buildings stand in for the compiled extrusion where the style describes them.
2142
- const replaced = toggle === "building" && this.map.getStyle()?.metadata?.["tileflow:buildings"];
1500
+ const replaced = toggle === "building" && reads.metadata()?.["tileflow:buildings"];
2143
1501
  const visibility = this.enabled && !replaced ? "visible" : "none";
2144
1502
  if (this.map.getLayoutProperty(layer.id, "visibility") !== visibility) {
2145
1503
  this.map.setLayoutProperty(layer.id, "visibility", visibility);
@@ -2297,9 +1655,10 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
2297
1655
  ...visibleLayerGroups,
2298
1656
  ].join(",");
2299
1657
  }
2300
- const vegetationLayers = map.getStyle()?.metadata?.["tileflow:vegetation-display"]?.flatLayers;
2301
- for (const layer of map.getStyle()?.layers || []) {
2302
- if (layer.type === "symbol" && layer.layout?.["text-field"] !== undefined) {
1658
+ const reads = styleReads(map);
1659
+ const vegetationLayers = reads.metadata()?.["tileflow:vegetation-display"]?.flatLayers;
1660
+ for (const layer of reads.layers()) {
1661
+ if (layer.type === "symbol" && map.getLayoutProperty(layer.id, "text-field") !== undefined) {
2303
1662
  const hideLabels = !visibleLayerGroups.has("labels");
2304
1663
  if (hideLabels && !defaultLayerTextField.has(layer.id)) {
2305
1664
  defaultLayerTextField.set(
@@ -2333,7 +1692,7 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
2333
1692
  const threeDimensionalToggle = layer.metadata?.["tileflow:3d-toggle"];
2334
1693
  const authoredVisibility = defaultLayerVisibility.get(layer.id);
2335
1694
  if (!hideLayer) defaultLayerVisibility.delete(layer.id);
2336
- const styleMetadata = map.getStyle()?.metadata ?? {};
1695
+ const styleMetadata = reads.metadata() ?? {};
2337
1696
  // Core's 3D buildings and trees stand in for compiled extrusions and flat trees.
2338
1697
  const replacedByCore =
2339
1698
  threeDimensionalToggle === "building" && styleMetadata["tileflow:buildings"];
@@ -2408,45 +1767,17 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
2408
1767
  let ensuringThreeDimensionalLayers;
2409
1768
  const ensureThreeDimensionalLayers = () => {
2410
1769
  if (ensuringThreeDimensionalLayers) return ensuringThreeDimensionalLayers;
2411
- const currentStyleLayers = map.getStyle()?.layers || [];
2412
- const landmarkConfigLayer = currentStyleLayers.find(
2413
- (layer) => layer.metadata?.["tileflow:landmark-manifest-url"]
2414
- );
2415
- const buildingWireframeConfigLayer = currentStyleLayers.find(
1770
+ const buildingWireframeConfigLayer = styleReads(map).layers().find(
2416
1771
  (layer) => layer.metadata?.["tileflow:building-wireframe"] === true
2417
1772
  );
2418
- const landmarkManifestUrl =
2419
- landmarkConfigLayer?.metadata?.["tileflow:landmark-manifest-url"];
2420
- const landmarkPreloadZoom = landmarkConfigLayer
2421
- ? Math.max(0, styleLayerZoomRange(landmarkConfigLayer).minzoom - 1)
2422
- : Infinity;
2423
- const shouldPreloadLandmarks =
2424
- landmarkManifestUrl &&
2425
- threeDimensionalEnabled &&
2426
- map.getZoom() >= landmarkPreloadZoom;
2427
- const needsLandmarks =
2428
- landmarkConfigLayer &&
2429
- threeDimensionalEnabled &&
2430
- styleLayerIsVisibleAtZoom(landmarkConfigLayer, map.getZoom()) &&
2431
- !map.getLayer("tileflow-landmarks-3d");
2432
1773
  const needsBuildingWireframe =
2433
1774
  buildingWireframeConfigLayer &&
2434
1775
  threeDimensionalEnabled &&
2435
1776
  styleLayerIsVisibleAtZoom(buildingWireframeConfigLayer, map.getZoom()) &&
2436
1777
  !map.getLayer("tileflow-buildings-wireframe-3d");
2437
- if (
2438
- !needsLandmarks &&
2439
- !needsBuildingWireframe &&
2440
- !shouldPreloadLandmarks
2441
- ) return;
2442
- const runtimePrerequisites = [
2443
- shouldPreloadLandmarks
2444
- ? loadLandmarkPrerequisites(landmarkManifestUrl)
2445
- : loadThreeCoreRuntime(),
2446
- ...(needsBuildingWireframe ? [loadBuildingWireframeRuntime()] : [])
2447
- ];
2448
- ensuringThreeDimensionalLayers = Promise.all(runtimePrerequisites)
2449
- .then(async () => {
1778
+ if (!needsBuildingWireframe) return;
1779
+ ensuringThreeDimensionalLayers = loadBuildingWireframeRuntime()
1780
+ .then(() => {
2450
1781
  const styleLayers = map.getStyle()?.layers || [];
2451
1782
  const buildingWireframeConfigLayer = styleLayers.find(
2452
1783
  (layer) => layer.metadata?.["tileflow:building-wireframe"] === true
@@ -2456,28 +1787,8 @@ function renderTileflowPreviewHtml(preview, basePath, initialStatus, isSemanticP
2456
1787
  threeDimensionalEnabled &&
2457
1788
  styleLayerIsVisibleAtZoom(buildingWireframeConfigLayer, map.getZoom())
2458
1789
  ) {
2459
- // A tree-only load may already be in flight when buildings are
2460
- // enabled. Reassert the line runtime before mounting the layer.
2461
- await loadBuildingWireframeRuntime();
2462
1790
  addBuildingWireframeLayerIfConfigured(map, styleLayers);
2463
1791
  }
2464
- const landmarkConfigLayer = styleLayers.find(
2465
- (layer) => layer.metadata?.["tileflow:landmark-manifest-url"]
2466
- );
2467
- const shouldAddLandmarks =
2468
- landmarkConfigLayer &&
2469
- threeDimensionalEnabled &&
2470
- styleLayerIsVisibleAtZoom(landmarkConfigLayer, map.getZoom()) &&
2471
- !map.getLayer("tileflow-landmarks-3d");
2472
- if (shouldAddLandmarks) {
2473
- // The tree-only path may already be in flight when landmarks
2474
- // are enabled. Reassert the richer runtime here so a shared
2475
- // core promise can never expose an undefined GLTF loader.
2476
- await loadLandmarkPrerequisites(
2477
- landmarkConfigLayer.metadata["tileflow:landmark-manifest-url"]
2478
- );
2479
- addLandmarkLayerIfConfigured(map, styleLayers);
2480
- }
2481
1792
  })
2482
1793
  .catch((error) => console.warn("Tileflow 3D runtime failed to load", error))
2483
1794
  .finally(() => {
@@ -2710,7 +2021,6 @@ function resolvePreviewTheme(map, requested) {
2710
2021
  }
2711
2022
  export {
2712
2023
  TileflowPreviewSelectionError,
2713
- normalizeTileflowLandmarkManifest,
2714
2024
  readBoundedTileflowJsonResponse,
2715
2025
  readBoundedTileflowResponse,
2716
2026
  renderTileflowPreviewHtml,
package/dist/server.js CHANGED
@@ -3271,7 +3271,7 @@ function createTileflowDevRequestHandler(options = {}) {
3271
3271
  return options.session ? eventStreamResponse(options.session) : jsonResponse2({ error: "Live events require a development session." }, 404);
3272
3272
  }
3273
3273
  if (path.startsWith("/__runtime/")) {
3274
- const runtimeResponse = (await import("./preview-assets-KGUICQHG.js")).getTileflowPreviewRuntimeResponse(path);
3274
+ const runtimeResponse = (await import("./preview-assets-MT3RXZMN.js")).getTileflowPreviewRuntimeResponse(path);
3275
3275
  if (runtimeResponse) return runtimeResponse;
3276
3276
  }
3277
3277
  const session = options.session ?? await loadOwnedSession(url.origin);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tileflow/dev",
3
- "version": "0.1.0-alpha.34",
3
+ "version": "0.1.0-alpha.35",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Node development utilities for Tileflow configs and builds.",
6
6
  "type": "module",
@@ -84,9 +84,8 @@
84
84
  "dependencies": {
85
85
  "@mapbox/vector-tile": "3.0.0",
86
86
  "@maplibre/maplibre-gl-style-spec": "26.4.4",
87
- "@tileflow/core": ">=0.1.0-alpha.37 <0.1.0-beta.0",
87
+ "@tileflow/core": ">=0.1.0-alpha.38 <0.1.0-beta.0",
88
88
  "chokidar": "4.0.3",
89
- "fflate": "0.8.3",
90
89
  "fontkit": "2.0.4",
91
90
  "jiti": "^2.7.0",
92
91
  "maplibre-gl": "6.13.0",
@@ -97,7 +96,7 @@
97
96
  "three": "0.185.1"
98
97
  },
99
98
  "devDependencies": {
100
- "@tileflow/maps": "0.1.0-alpha.11"
99
+ "@tileflow/maps": "0.1.0-alpha.12"
101
100
  },
102
101
  "optionalDependencies": {
103
102
  "sharp": "0.35.5"