map-zero 0.1.0 → 0.2.0

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,
@@ -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
@@ -306,7 +544,7 @@ function applyLayerZIndex(layer, orderedLayers, styleDocument, zIndexBase) {
306
544
  * @param {unknown} layer
307
545
  * @param {string} instanceId
308
546
  * @param {string[]} layerIds
309
- * @param {'geometry' | 'labels'} role
547
+ * @param {'geometry' | 'labels' | 'raster'} role
310
548
  */
311
549
  function tagOpenLayersLayer(layer, instanceId, layerIds, role) {
312
550
  const namespacedLayerIds = layerIds.map((layerId) => namespaceLayerId(instanceId, layerId));
@@ -465,7 +703,6 @@ function createTileUrlFunction(context) {
465
703
  */
466
704
  function createPmtilesTileUrlFunction(context) {
467
705
  const { minZoom, maxZoom } = pmtilesZoomRange(context.manifest);
468
- const packageBbox = normalizeBbox(context.manifest.bbox);
469
706
 
470
707
  return (tileCoord) => {
471
708
  if (!tileCoord) {
@@ -477,10 +714,6 @@ function createPmtilesTileUrlFunction(context) {
477
714
  return undefined;
478
715
  }
479
716
 
480
- if (packageBbox && !bboxIntersects(tileToBbox(z, x, y), packageBbox)) {
481
- return undefined;
482
- }
483
-
484
717
  if (activeLayerIdsForZoom(context.orderedLayers, context.styleDocument, context.layerVisibility, z).length === 0) {
485
718
  return undefined;
486
719
  }
@@ -710,7 +943,9 @@ function createWebGlStyles(context) {
710
943
  const filter = createLayerFilter(layer.id, rule);
711
944
  const styleParts = layer.id === 'roads'
712
945
  ? createRoadStyleRules(filter, rule, context.layerOpacity)
713
- : layer.id === 'boundaries' || isAipLayer(layer.id)
946
+ : layer.id === 'boundaries'
947
+ ? createBoundaryStyleRules(filter, rule, context.layerOpacity)
948
+ : isAipLayer(layer.id)
714
949
  ? createGeometryAwareStyleRules(filter, rule, layer.id, context.layerOpacity)
715
950
  : createLayerStyleRules(filter, rule, layer.type || 'line', layer.id, context.layerOpacity);
716
951
  for (const style of styleParts) {
@@ -1021,6 +1256,45 @@ function createPropertyVisibilityFilter(filter, rule) {
1021
1256
  return hidden.length > 0 ? ['all', filter, ...hidden] : filter;
1022
1257
  }
1023
1258
 
1259
+ /**
1260
+ * @param {unknown[]} filter
1261
+ * @param {Record<string, unknown>} rule
1262
+ * @param {Map<string, number>} layerOpacity
1263
+ * @returns {Array<{ filter: unknown[], style: Record<string, unknown> }>}
1264
+ */
1265
+ function createBoundaryStyleRules(filter, rule, layerOpacity) {
1266
+ const polygonFilter = ['all', filter, ['==', ['geometry-type'], 'Polygon']];
1267
+ const lineFilter = ['all', filter, ['==', ['geometry-type'], 'LineString']];
1268
+ const polygonRule = {
1269
+ ...rule,
1270
+ stroke: null,
1271
+ strokeOpacity: 0,
1272
+ strokeWidth: 0,
1273
+ glow: {
1274
+ ...rule.glow,
1275
+ enabled: false
1276
+ },
1277
+ casing: {
1278
+ ...rule.casing,
1279
+ enabled: false
1280
+ },
1281
+ centerLine: {
1282
+ ...rule.centerLine,
1283
+ enabled: false
1284
+ }
1285
+ };
1286
+ const lineRule = {
1287
+ ...rule,
1288
+ fill: null,
1289
+ fillOpacity: 0
1290
+ };
1291
+
1292
+ return [
1293
+ ...createLayerStyleRules(polygonFilter, polygonRule, 'polygon', 'boundaries', layerOpacity),
1294
+ ...createLayerStyleRules(lineFilter, lineRule, 'line', 'boundaries', layerOpacity)
1295
+ ];
1296
+ }
1297
+
1024
1298
  /**
1025
1299
  * @param {unknown[]} filter
1026
1300
  * @param {Record<string, unknown>} rule
@@ -1638,6 +1912,69 @@ function isValidTileCoord(z, x, y) {
1638
1912
  return Number.isInteger(x) && Number.isInteger(y) && x >= 0 && y >= 0 && x < maxIndex && y < maxIndex;
1639
1913
  }
1640
1914
 
1915
+ /**
1916
+ * @param {Record<string, unknown>} manifest
1917
+ * @returns {{ url?: string, minZoom?: unknown, maxZoom?: unknown }}
1918
+ */
1919
+ function pmtilesInfo(manifest) {
1920
+ const tiles = manifest.tiles && typeof manifest.tiles === 'object' ? manifest.tiles : {};
1921
+ return /** @type {{ url?: string, minZoom?: unknown, maxZoom?: unknown }} */ (tiles);
1922
+ }
1923
+
1924
+ /**
1925
+ * @param {number} width
1926
+ * @param {number} height
1927
+ * @returns {HTMLCanvasElement}
1928
+ */
1929
+ function emptyCanvas(width, height) {
1930
+ const canvas = document.createElement('canvas');
1931
+ canvas.width = width;
1932
+ canvas.height = height;
1933
+ return canvas;
1934
+ }
1935
+
1936
+ function assertRasterWorkerSupport() {
1937
+ if (typeof Worker !== 'function' || typeof OffscreenCanvas !== 'function' || typeof createImageBitmap !== 'function') {
1938
+ throw new Error('map-zero OpenLayers raster-worker mode requires Worker, OffscreenCanvas, and createImageBitmap');
1939
+ }
1940
+ }
1941
+
1942
+ /**
1943
+ * Choose the raster tile zoom at the midpoint in zoom space instead of always
1944
+ * forcing the parent or child tile. This keeps fractional zooms sharp without
1945
+ * holding low-resolution tiles for too long.
1946
+ *
1947
+ * @param {number} value
1948
+ * @param {number} high
1949
+ * @param {number} low
1950
+ * @returns {number}
1951
+ */
1952
+ function preferNearestZoomLevel(value, high, low) {
1953
+ return value - low * Math.sqrt(high / low);
1954
+ }
1955
+
1956
+ /**
1957
+ * @param {unknown} value
1958
+ * @param {number} min
1959
+ * @param {number} max
1960
+ * @returns {number}
1961
+ */
1962
+ function clampInteger(value, min, max) {
1963
+ const number = Math.trunc(Number(value));
1964
+ return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : min;
1965
+ }
1966
+
1967
+ /**
1968
+ * @param {unknown} value
1969
+ * @param {number} min
1970
+ * @param {number} max
1971
+ * @returns {number}
1972
+ */
1973
+ function clampNumber(value, min, max) {
1974
+ const number = Number(value);
1975
+ return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : min;
1976
+ }
1977
+
1641
1978
  /**
1642
1979
  * @param {number} z
1643
1980
  * @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}