map-zero 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import MVT from 'ol/format/MVT.js';
2
+ import TileLayer from 'ol/layer/Tile.js';
2
3
  import WebGLVectorTileLayer from 'ol/layer/WebGLVectorTile.js';
3
4
  import WebGLVectorTileLayerRenderer from 'ol/renderer/webgl/VectorTileLayer.js';
5
+ import ImageTileSource from 'ol/source/ImageTile.js';
4
6
  import VectorTileSource from 'ol/source/VectorTile.js';
5
7
  import { createXYZ } from 'ol/tilegrid.js';
6
8
  import { PMTiles } from 'pmtiles';
@@ -21,6 +23,12 @@ let autoInstanceCounter = 0;
21
23
  * style?: string | Record<string, unknown>,
22
24
  * visibleLayers?: string[] | Set<string>,
23
25
  * source?: 'auto' | 'pmtiles' | 'dynamic',
26
+ * renderMode?: 'vector' | 'raster-worker',
27
+ * workerUrl?: string | URL,
28
+ * rasterWorkerUrl?: string | URL,
29
+ * rasterPixelRatio?: number,
30
+ * overzoomLevels?: number,
31
+ * edgeGuardPixels?: number,
24
32
  * apiBaseUrl?: string,
25
33
  * zIndexBase?: number,
26
34
  * onTileLoadStart?: () => void,
@@ -69,8 +77,6 @@ export async function loadMapZeroStyle(styleUrl) {
69
77
  * }>}
70
78
  */
71
79
  export async function createMapZeroOpenLayersLayers(options) {
72
- patchWebGlVectorTileRenderer();
73
-
74
80
  const manifestUrl = resolveUrl(options.manifestUrl, documentBaseUrl());
75
81
  const manifestBaseUrl = new URL('.', manifestUrl).href;
76
82
  const manifest = options.manifest ?? await loadMapZeroManifest(manifestUrl);
@@ -95,6 +101,12 @@ export async function createMapZeroOpenLayersLayers(options) {
95
101
  onTileLoadError: options.onTileLoadError
96
102
  };
97
103
 
104
+ if (options.renderMode === 'raster-worker') {
105
+ return createRasterWorkerController(context, options);
106
+ }
107
+
108
+ patchWebGlVectorTileRenderer();
109
+
98
110
  const source = createTileSource(context);
99
111
  const layer = new WebGLVectorTileLayer({
100
112
  source,
@@ -119,7 +131,7 @@ export async function createMapZeroOpenLayersLayers(options) {
119
131
  applyLayerZIndex(layer, orderedLayers, styleDocument, options.zIndexBase);
120
132
  if (labelController) {
121
133
  tagOpenLayersLayer(labelController.layer, instanceId, orderedLayers.map((item) => item.id), 'labels');
122
- labelController.layer.setZIndex(layer.getZIndex() + 100);
134
+ labelController.layer.setZIndex(layer.getZIndex() + 1);
123
135
  }
124
136
 
125
137
  const refresh = () => {
@@ -165,6 +177,209 @@ export async function createMapZeroOpenLayersLayers(options) {
165
177
  };
166
178
  }
167
179
 
180
+ /**
181
+ * @param {{
182
+ * instanceId: string,
183
+ * manifest: Record<string, unknown>,
184
+ * manifestUrl: string,
185
+ * styleDocument: Record<string, unknown>,
186
+ * orderedLayers: Array<{ id: string, type?: string, style?: string }>,
187
+ * layerVisibility: Map<string, boolean>,
188
+ * layerOpacity: Map<string, number>
189
+ * }} context
190
+ * @param {MapZeroOpenLayersOptions} options
191
+ * @returns {{
192
+ * id: string,
193
+ * manifest: Record<string, unknown>,
194
+ * style: Record<string, unknown>,
195
+ * layers: TileLayer[],
196
+ * setVisible: (layerId: string, visible: boolean) => void,
197
+ * setOpacity: (layerId: string, opacity: number) => void,
198
+ * destroy: () => void
199
+ * }}
200
+ */
201
+ function createRasterWorkerController(context, options) {
202
+ if (!isPmtilesManifest(context.manifest)) {
203
+ throw new Error('raster-worker render mode requires vector PMTiles');
204
+ }
205
+
206
+ const worker = new MapZeroRasterTileWorker({
207
+ manifest: context.manifest,
208
+ manifestUrl: context.manifestUrl,
209
+ styleDocument: context.styleDocument,
210
+ layers: context.orderedLayers.map((layer) => layer.id),
211
+ workerUrl: options.rasterWorkerUrl ?? options.workerUrl ?? new URL('@map-zero/cesium/imagery-worker.js', import.meta.url),
212
+ rasterPixelRatio: options.rasterPixelRatio,
213
+ overzoomLevels: options.overzoomLevels,
214
+ edgeGuardPixels: options.edgeGuardPixels
215
+ });
216
+ worker.setVisibleLayers(context.layerVisibility);
217
+ const range = pmtilesZoomRange(context.manifest);
218
+ const maxZoom = range.maxZoom + clampInteger(options.overzoomLevels ?? 0, 0, 4);
219
+ const rasterPixelRatio = clampNumber(options.rasterPixelRatio ?? 2, 1, 2);
220
+ const source = new ImageTileSource({
221
+ minZoom: range.minZoom,
222
+ maxZoom,
223
+ tileGrid: createXYZ({
224
+ minZoom: range.minZoom,
225
+ maxZoom,
226
+ tileSize: 512
227
+ }),
228
+ tileSize: 512,
229
+ transition: 0,
230
+ interpolate: true,
231
+ zDirection: preferNearestZoomLevel,
232
+ wrapX: false,
233
+ loader: (z, x, y) => worker.render(z, x, y)
234
+ });
235
+ source.getTilePixelRatio = () => rasterPixelRatio;
236
+ const layer = new TileLayer({
237
+ source,
238
+ cacheSize: 4096,
239
+ preload: 0,
240
+ useInterimTilesOnError: false
241
+ });
242
+ tagOpenLayersLayer(layer, context.instanceId, context.orderedLayers.map((item) => item.id), 'raster');
243
+ applyLayerZIndex(layer, context.orderedLayers, context.styleDocument, options.zIndexBase);
244
+
245
+ const refresh = () => {
246
+ worker.setVisibleLayers(context.layerVisibility);
247
+ source.clear();
248
+ layer.changed();
249
+ };
250
+
251
+ return {
252
+ id: context.instanceId,
253
+ manifest: context.manifest,
254
+ style: context.styleDocument,
255
+ layers: [layer],
256
+ setVisible(layerId, visible) {
257
+ if (!context.layerVisibility.has(layerId)) {
258
+ throw new Error(`unknown map-zero layer: ${layerId}`);
259
+ }
260
+
261
+ context.layerVisibility.set(layerId, Boolean(visible));
262
+ refresh();
263
+ },
264
+ setOpacity(layerId, opacity) {
265
+ if (!context.layerOpacity?.has?.(layerId)) {
266
+ throw new Error(`unknown map-zero layer: ${layerId}`);
267
+ }
268
+
269
+ layer.setOpacity(clamp(Number(opacity), 0, 1));
270
+ },
271
+ destroy() {
272
+ source.clear();
273
+ worker.destroy();
274
+ layer.dispose();
275
+ }
276
+ };
277
+ }
278
+
279
+ class MapZeroRasterTileWorker {
280
+ /**
281
+ * @param {{
282
+ * manifest: Record<string, unknown>,
283
+ * manifestUrl: string,
284
+ * styleDocument: Record<string, unknown>,
285
+ * layers: string[],
286
+ * workerUrl: string | URL,
287
+ * rasterPixelRatio?: number,
288
+ * overzoomLevels?: number,
289
+ * edgeGuardPixels?: number
290
+ * }} options
291
+ */
292
+ constructor(options) {
293
+ assertRasterWorkerSupport();
294
+ this.#rasterPixelRatio = clampNumber(options.rasterPixelRatio ?? 2, 1, 2);
295
+ this.worker = new Worker(options.workerUrl, { type: 'module' });
296
+ this.worker.addEventListener('message', (event) => this.#handleMessage(event.data));
297
+ this.worker.addEventListener('error', (event) => this.#rejectAll(new Error(event.message || 'map-zero OpenLayers raster worker failed')));
298
+ this.worker.postMessage({
299
+ type: 'init',
300
+ options: {
301
+ manifest: options.manifest,
302
+ manifestUrl: resolveUrl(options.manifestUrl, documentBaseUrl()),
303
+ styleDocument: options.styleDocument,
304
+ layers: options.layers,
305
+ tileSize: 512,
306
+ pixelRatio: this.#rasterPixelRatio,
307
+ sourceMaximumLevel: pmtilesZoomRange(options.manifest).maxZoom,
308
+ overzoomLevels: clampInteger(options.overzoomLevels ?? 0, 0, 4),
309
+ edgeGuardPixels: clampInteger(options.edgeGuardPixels ?? 0, 0, 8),
310
+ source: String(pmtilesInfo(options.manifest).url ?? 'tiles.pmtiles')
311
+ }
312
+ });
313
+ }
314
+
315
+ /**
316
+ * @param {number} z
317
+ * @param {number} x
318
+ * @param {number} y
319
+ * @returns {Promise<ImageBitmap | HTMLCanvasElement>}
320
+ */
321
+ render(z, x, y) {
322
+ const id = this.#nextId++;
323
+ return new Promise((resolve, reject) => {
324
+ this.#pending.set(id, { resolve, reject });
325
+ this.worker.postMessage({ type: 'render', id, x, y, z });
326
+ }).catch(() => emptyCanvas(512 * this.#rasterPixelRatio, 512 * this.#rasterPixelRatio));
327
+ }
328
+
329
+ /**
330
+ * @param {Map<string, boolean>} visibility
331
+ */
332
+ setVisibleLayers(visibility) {
333
+ for (const [layerId, visible] of visibility) {
334
+ this.worker.postMessage({ type: 'visibility', layerId, visible });
335
+ }
336
+ }
337
+
338
+ destroy() {
339
+ this.#rejectAll(new Error('map-zero OpenLayers raster worker destroyed'));
340
+ this.worker.terminate();
341
+ }
342
+
343
+ /**
344
+ * @param {any} message
345
+ */
346
+ #handleMessage(message) {
347
+ if (message?.type !== 'tile') {
348
+ return;
349
+ }
350
+
351
+ const pending = this.#pending.get(message.id);
352
+ if (!pending) {
353
+ message.image?.close?.();
354
+ return;
355
+ }
356
+
357
+ this.#pending.delete(message.id);
358
+ if (message.error) {
359
+ pending.reject(new Error(message.error));
360
+ return;
361
+ }
362
+ pending.resolve(message.image ?? emptyCanvas(512, 512));
363
+ }
364
+
365
+ /**
366
+ * @param {Error} error
367
+ */
368
+ #rejectAll(error) {
369
+ for (const pending of this.#pending.values()) {
370
+ pending.reject(error);
371
+ }
372
+ this.#pending.clear();
373
+ }
374
+
375
+ /** @type {Worker} */
376
+ worker;
377
+ #rasterPixelRatio = 2;
378
+ #nextId = 1;
379
+ /** @type {Map<number, { resolve: (image: ImageBitmap | HTMLCanvasElement) => void, reject: (error: Error) => void }>} */
380
+ #pending = new Map();
381
+ }
382
+
168
383
  /**
169
384
  * Add map-zero layers to an existing OpenLayers map.
170
385
  *
@@ -255,7 +470,7 @@ async function loadStyleDocument(manifest, manifestBaseUrl, style) {
255
470
  */
256
471
  function orderManifestLayers(manifest, styleDocument) {
257
472
  const layers = Array.isArray(manifest.layers)
258
- ? /** @type {Array<{ id: string, type?: string, style?: string }>} */ (manifest.layers)
473
+ ? /** @type {string[]} */ (manifest.layers).map(manifestLayer)
259
474
  : [];
260
475
  const drawOrder = Array.isArray(styleDocument.drawOrder)
261
476
  ? /** @type {string[]} */ (styleDocument.drawOrder)
@@ -270,6 +485,29 @@ function orderManifestLayers(manifest, styleDocument) {
270
485
  });
271
486
  }
272
487
 
488
+ /**
489
+ * @param {string} layerId
490
+ * @returns {{ id: string, type?: string, style?: string }}
491
+ */
492
+ function manifestLayer(layerId) {
493
+ return {
494
+ id: layerId,
495
+ type: layerType(layerId),
496
+ style: layerId
497
+ };
498
+ }
499
+
500
+ /**
501
+ * @param {string} layerId
502
+ * @returns {string}
503
+ */
504
+ function layerType(layerId) {
505
+ if (layerId === 'buildings' || layerId === 'water' || layerId === 'landuse') return 'polygon';
506
+ if (layerId === 'pois') return 'point';
507
+ if (layerId === 'aip' || layerId === 'aviation') return 'mixed';
508
+ return 'line';
509
+ }
510
+
273
511
  /**
274
512
  * @param {Array<{ id: string, type?: string, style?: string }>} orderedLayers
275
513
  * @param {Record<string, unknown>} styleDocument
@@ -295,18 +533,14 @@ function createLayerVisibility(orderedLayers, styleDocument, visibleLayers) {
295
533
  * @param {number | undefined} zIndexBase
296
534
  */
297
535
  function applyLayerZIndex(layer, orderedLayers, styleDocument, zIndexBase) {
298
- const maxOrder = orderedLayers.reduce((max, item) => {
299
- const order = Number(getLayerRule(styleDocument, item).order);
300
- return Number.isFinite(order) ? Math.max(max, order) : max;
301
- }, 0);
302
- layer.setZIndex((Number.isFinite(Number(zIndexBase)) ? Number(zIndexBase) : 0) + maxOrder);
536
+ layer.setZIndex(Number.isFinite(Number(zIndexBase)) ? Number(zIndexBase) : 0);
303
537
  }
304
538
 
305
539
  /**
306
540
  * @param {unknown} layer
307
541
  * @param {string} instanceId
308
542
  * @param {string[]} layerIds
309
- * @param {'geometry' | 'labels'} role
543
+ * @param {'geometry' | 'labels' | 'raster'} role
310
544
  */
311
545
  function tagOpenLayersLayer(layer, instanceId, layerIds, role) {
312
546
  const namespacedLayerIds = layerIds.map((layerId) => namespaceLayerId(instanceId, layerId));
@@ -465,7 +699,6 @@ function createTileUrlFunction(context) {
465
699
  */
466
700
  function createPmtilesTileUrlFunction(context) {
467
701
  const { minZoom, maxZoom } = pmtilesZoomRange(context.manifest);
468
- const packageBbox = normalizeBbox(context.manifest.bbox);
469
702
 
470
703
  return (tileCoord) => {
471
704
  if (!tileCoord) {
@@ -477,10 +710,6 @@ function createPmtilesTileUrlFunction(context) {
477
710
  return undefined;
478
711
  }
479
712
 
480
- if (packageBbox && !bboxIntersects(tileToBbox(z, x, y), packageBbox)) {
481
- return undefined;
482
- }
483
-
484
713
  if (activeLayerIdsForZoom(context.orderedLayers, context.styleDocument, context.layerVisibility, z).length === 0) {
485
714
  return undefined;
486
715
  }
@@ -710,7 +939,9 @@ function createWebGlStyles(context) {
710
939
  const filter = createLayerFilter(layer.id, rule);
711
940
  const styleParts = layer.id === 'roads'
712
941
  ? createRoadStyleRules(filter, rule, context.layerOpacity)
713
- : layer.id === 'boundaries' || isAipLayer(layer.id)
942
+ : layer.id === 'boundaries'
943
+ ? createBoundaryStyleRules(filter, rule, context.layerOpacity)
944
+ : isAipLayer(layer.id)
714
945
  ? createGeometryAwareStyleRules(filter, rule, layer.id, context.layerOpacity)
715
946
  : createLayerStyleRules(filter, rule, layer.type || 'line', layer.id, context.layerOpacity);
716
947
  for (const style of styleParts) {
@@ -1021,6 +1252,45 @@ function createPropertyVisibilityFilter(filter, rule) {
1021
1252
  return hidden.length > 0 ? ['all', filter, ...hidden] : filter;
1022
1253
  }
1023
1254
 
1255
+ /**
1256
+ * @param {unknown[]} filter
1257
+ * @param {Record<string, unknown>} rule
1258
+ * @param {Map<string, number>} layerOpacity
1259
+ * @returns {Array<{ filter: unknown[], style: Record<string, unknown> }>}
1260
+ */
1261
+ function createBoundaryStyleRules(filter, rule, layerOpacity) {
1262
+ const polygonFilter = ['all', filter, ['==', ['geometry-type'], 'Polygon']];
1263
+ const lineFilter = ['all', filter, ['==', ['geometry-type'], 'LineString']];
1264
+ const polygonRule = {
1265
+ ...rule,
1266
+ stroke: null,
1267
+ strokeOpacity: 0,
1268
+ strokeWidth: 0,
1269
+ glow: {
1270
+ ...rule.glow,
1271
+ enabled: false
1272
+ },
1273
+ casing: {
1274
+ ...rule.casing,
1275
+ enabled: false
1276
+ },
1277
+ centerLine: {
1278
+ ...rule.centerLine,
1279
+ enabled: false
1280
+ }
1281
+ };
1282
+ const lineRule = {
1283
+ ...rule,
1284
+ fill: null,
1285
+ fillOpacity: 0
1286
+ };
1287
+
1288
+ return [
1289
+ ...createLayerStyleRules(polygonFilter, polygonRule, 'polygon', 'boundaries', layerOpacity),
1290
+ ...createLayerStyleRules(lineFilter, lineRule, 'line', 'boundaries', layerOpacity)
1291
+ ];
1292
+ }
1293
+
1024
1294
  /**
1025
1295
  * @param {unknown[]} filter
1026
1296
  * @param {Record<string, unknown>} rule
@@ -1638,6 +1908,69 @@ function isValidTileCoord(z, x, y) {
1638
1908
  return Number.isInteger(x) && Number.isInteger(y) && x >= 0 && y >= 0 && x < maxIndex && y < maxIndex;
1639
1909
  }
1640
1910
 
1911
+ /**
1912
+ * @param {Record<string, unknown>} manifest
1913
+ * @returns {{ url?: string, minZoom?: unknown, maxZoom?: unknown }}
1914
+ */
1915
+ function pmtilesInfo(manifest) {
1916
+ const tiles = manifest.tiles && typeof manifest.tiles === 'object' ? manifest.tiles : {};
1917
+ return /** @type {{ url?: string, minZoom?: unknown, maxZoom?: unknown }} */ (tiles);
1918
+ }
1919
+
1920
+ /**
1921
+ * @param {number} width
1922
+ * @param {number} height
1923
+ * @returns {HTMLCanvasElement}
1924
+ */
1925
+ function emptyCanvas(width, height) {
1926
+ const canvas = document.createElement('canvas');
1927
+ canvas.width = width;
1928
+ canvas.height = height;
1929
+ return canvas;
1930
+ }
1931
+
1932
+ function assertRasterWorkerSupport() {
1933
+ if (typeof Worker !== 'function' || typeof OffscreenCanvas !== 'function' || typeof createImageBitmap !== 'function') {
1934
+ throw new Error('map-zero OpenLayers raster-worker mode requires Worker, OffscreenCanvas, and createImageBitmap');
1935
+ }
1936
+ }
1937
+
1938
+ /**
1939
+ * Choose the raster tile zoom at the midpoint in zoom space instead of always
1940
+ * forcing the parent or child tile. This keeps fractional zooms sharp without
1941
+ * holding low-resolution tiles for too long.
1942
+ *
1943
+ * @param {number} value
1944
+ * @param {number} high
1945
+ * @param {number} low
1946
+ * @returns {number}
1947
+ */
1948
+ function preferNearestZoomLevel(value, high, low) {
1949
+ return value - low * Math.sqrt(high / low);
1950
+ }
1951
+
1952
+ /**
1953
+ * @param {unknown} value
1954
+ * @param {number} min
1955
+ * @param {number} max
1956
+ * @returns {number}
1957
+ */
1958
+ function clampInteger(value, min, max) {
1959
+ const number = Math.trunc(Number(value));
1960
+ return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : min;
1961
+ }
1962
+
1963
+ /**
1964
+ * @param {unknown} value
1965
+ * @param {number} min
1966
+ * @param {number} max
1967
+ * @returns {number}
1968
+ */
1969
+ function clampNumber(value, min, max) {
1970
+ const number = Number(value);
1971
+ return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : min;
1972
+ }
1973
+
1641
1974
  /**
1642
1975
  * @param {number} z
1643
1976
  * @param {number} x
@@ -1,11 +1,22 @@
1
1
  /**
2
+ * Minimal Batched 3D Model (b3dm) container writer.
3
+ *
4
+ * map-zero currently emits one GLB per tile and does not use batch IDs or per
5
+ * feature metadata, so the feature table only declares BATCH_LENGTH and an
6
+ * optional RTC_CENTER for high precision rendering in Cesium.
7
+ *
2
8
  * Wrap a GLB buffer in a minimal valid B3DM container.
3
9
  *
4
10
  * @param {Buffer} glb
11
+ * @param {{ rtcCenter?: [number, number, number] }} [options]
5
12
  * @returns {Buffer}
6
13
  */
7
- export function buildB3dm(glb) {
8
- const featureTableJson = padJsonForSection({ BATCH_LENGTH: 0 }, 28);
14
+ export function buildB3dm(glb, options = {}) {
15
+ const featureTable = { BATCH_LENGTH: 0 };
16
+ if (options.rtcCenter) {
17
+ featureTable.RTC_CENTER = options.rtcCenter;
18
+ }
19
+ const featureTableJson = padJsonForSection(featureTable, 28);
9
20
  const header = Buffer.alloc(28);
10
21
  header.write('b3dm', 0, 4, 'ascii');
11
22
  header.writeUInt32LE(1, 4);
@@ -18,6 +29,9 @@ export function buildB3dm(glb) {
18
29
  }
19
30
 
20
31
  /**
32
+ * Serialize and pad feature/batch table JSON so the following section starts on
33
+ * an 8-byte boundary, as required by the b3dm container.
34
+ *
21
35
  * @param {unknown} value
22
36
  * @param {number} sectionOffset
23
37
  * @returns {Buffer}
@@ -29,6 +43,8 @@ function padJsonForSection(value, sectionOffset) {
29
43
  }
30
44
 
31
45
  /**
46
+ * Round a byte length up to the next multiple of alignment.
47
+ *
32
48
  * @param {number} value
33
49
  * @param {number} alignment
34
50
  * @returns {number}