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.
@@ -0,0 +1,434 @@
1
+ import {
2
+ Event,
3
+ Rectangle,
4
+ WebMercatorTilingScheme
5
+ } from 'cesium';
6
+
7
+ const DEFAULT_CONTEXT_LAYERS = ['roads', 'railways', 'water', 'landuse', 'boundaries', 'aviation', 'pois'];
8
+ const TILE_SIZE = 512;
9
+ const WEB_MERCATOR_MAX = 20037508.342789244;
10
+ const WEB_MERCATOR_MAX_LAT = 85.05112878;
11
+
12
+ /**
13
+ * Cesium ImageryProvider that rasterizes map-zero PMTiles/MVT tiles in a
14
+ * dedicated worker. OffscreenCanvas is required by design so MVT decoding and
15
+ * canvas drawing never block Cesium's main render thread.
16
+ */
17
+ export class MapZeroCesiumImageryProvider {
18
+ /**
19
+ * @param {{
20
+ * manifest: Record<string, unknown>,
21
+ * manifestUrl: string,
22
+ * styleDocument?: Record<string, unknown> | null,
23
+ * layers?: string[],
24
+ * tileSize?: number,
25
+ * minimumLevel?: number,
26
+ * maximumLevel?: number,
27
+ * overzoomLevels?: number,
28
+ * edgeGuardPixels?: number
29
+ * workerUrl?: string | URL
30
+ * }} options
31
+ */
32
+ constructor(options) {
33
+ assertWorkerRasterSupport();
34
+ this.manifest = options.manifest;
35
+ this.manifestUrl = resolveWorkerBaseUrl(options.manifestUrl);
36
+ this.styleDocument = options.styleDocument ?? {};
37
+ this.tileWidth = Number(options.tileSize ?? TILE_SIZE);
38
+ this.tileHeight = Number(options.tileSize ?? TILE_SIZE);
39
+ this.tilingScheme = new WebMercatorTilingScheme();
40
+ this.rectangle = rectangleFromManifestBbox(
41
+ this.manifest,
42
+ /** @type {any} */ (this.manifest).bbox
43
+ );
44
+ this.minimumLevel = Number.isFinite(options.minimumLevel) ? Number(options.minimumLevel) : 0;
45
+ this.sourceMaximumLevel = Number(pmtilesInfo(this.manifest).maxZoom ?? 18);
46
+ const contextOverlay = contextOverlayConfig(this.manifest);
47
+ this.overzoomLevels = clampInteger(options.overzoomLevels ?? contextOverlay?.overzoomLevels ?? 0, 0, 4);
48
+ this.edgeGuardPixels = clampInteger(options.edgeGuardPixels ?? contextOverlay?.edgeGuardPixels ?? 0, 0, 8);
49
+ this.maximumLevel = Number.isFinite(options.maximumLevel)
50
+ ? Number(options.maximumLevel)
51
+ : this.sourceMaximumLevel + this.overzoomLevels;
52
+ this.ready = true;
53
+ this.readyPromise = Promise.resolve(true);
54
+ this.hasAlphaChannel = true;
55
+ this.errorEvent = new Event();
56
+ this.credit = undefined;
57
+ this.proxy = undefined;
58
+
59
+ this.layerIds = normalizeContextLayers(options.layers ?? contextOverlay?.layers ?? DEFAULT_CONTEXT_LAYERS);
60
+ this.layerVisibility = new Map(this.layerIds.map((layerId) => [layerId, true]));
61
+ this.cache = new Map();
62
+ this.pending = new Map();
63
+ this.nextRequestId = 1;
64
+ this.metrics = createImageryMetrics();
65
+ this.worker = new Worker(
66
+ options.workerUrl ?? new URL('./imagery-worker.js', import.meta.url),
67
+ { type: 'module' }
68
+ );
69
+ this.worker.addEventListener('message', (event) => this.#handleWorkerMessage(event.data));
70
+ this.worker.addEventListener('error', (event) => {
71
+ const error = new Error(event.message || 'map-zero imagery worker failed');
72
+ this.errorEvent.raiseEvent(error);
73
+ this.#rejectPending(error);
74
+ });
75
+ this.worker.postMessage({
76
+ type: 'init',
77
+ options: {
78
+ manifest: this.manifest,
79
+ manifestUrl: this.manifestUrl,
80
+ styleDocument: this.styleDocument,
81
+ layers: this.layerIds,
82
+ tileSize: this.tileWidth,
83
+ sourceMaximumLevel: this.sourceMaximumLevel,
84
+ overzoomLevels: this.overzoomLevels,
85
+ edgeGuardPixels: this.edgeGuardPixels,
86
+ source: String(contextOverlay?.source ?? pmtilesInfo(this.manifest).url ?? 'tiles.pmtiles')
87
+ }
88
+ });
89
+ }
90
+
91
+ /**
92
+ * @param {string} layerId
93
+ * @param {boolean} visible
94
+ */
95
+ setLayerVisible(layerId, visible) {
96
+ this.layerVisibility.set(sourceLayerFor(layerId), visible);
97
+ this.layerVisibility.set(layerAlias(layerId), visible);
98
+ this.cache.clear();
99
+ this.worker.postMessage({
100
+ type: 'visibility',
101
+ layerId,
102
+ visible
103
+ });
104
+ }
105
+
106
+ /**
107
+ * @param {number} x
108
+ * @param {number} y
109
+ * @param {number} level
110
+ * @returns {Promise<HTMLCanvasElement>}
111
+ */
112
+ async requestImage(x, y, level) {
113
+ const key = `${level}/${x}/${y}`;
114
+ const cached = this.cache.get(key);
115
+ if (cached) {
116
+ this.metrics.cacheHits++;
117
+ return cached;
118
+ }
119
+
120
+ const id = this.nextRequestId++;
121
+ const promise = new Promise((resolve, reject) => {
122
+ this.pending.set(id, { resolve, reject });
123
+ this.worker.postMessage({
124
+ type: 'render',
125
+ id,
126
+ x,
127
+ y,
128
+ z: level
129
+ });
130
+ }).catch((error) => {
131
+ this.errorEvent.raiseEvent(error);
132
+ return emptyCanvas(this.tileWidth, this.tileHeight);
133
+ });
134
+ this.cache.set(key, promise);
135
+ return promise;
136
+ }
137
+
138
+ getTileCredits() {
139
+ return undefined;
140
+ }
141
+
142
+ pickFeatures() {
143
+ return undefined;
144
+ }
145
+
146
+ destroy() {
147
+ this.#rejectPending(new Error('map-zero imagery provider destroyed'));
148
+ this.worker.terminate();
149
+ this.cache.clear();
150
+ }
151
+
152
+ #handleWorkerMessage(message) {
153
+ if (message?.type === 'metrics') {
154
+ mergeWorkerMetrics(this.metrics, message.metrics);
155
+ return;
156
+ }
157
+
158
+ if (message?.type !== 'tile') {
159
+ return;
160
+ }
161
+
162
+ if (message.metrics) {
163
+ mergeWorkerMetrics(this.metrics, message.metrics);
164
+ }
165
+ const pending = this.pending.get(message.id);
166
+ if (!pending) {
167
+ message.image?.close?.();
168
+ return;
169
+ }
170
+ this.pending.delete(message.id);
171
+ if (message.error) {
172
+ pending.reject(new Error(message.error));
173
+ return;
174
+ }
175
+ pending.resolve(imageToCanvas(message.image, this.tileWidth, this.tileHeight));
176
+ }
177
+
178
+ #rejectPending(error) {
179
+ for (const pending of this.pending.values()) {
180
+ pending.reject(error);
181
+ }
182
+ this.pending.clear();
183
+ }
184
+ }
185
+
186
+ function assertWorkerRasterSupport() {
187
+ if (typeof Worker !== 'function' || typeof OffscreenCanvas !== 'function' || typeof createImageBitmap !== 'function') {
188
+ throw new Error('map-zero Cesium context overlay requires Worker, OffscreenCanvas, and createImageBitmap');
189
+ }
190
+ }
191
+
192
+ function createImageryMetrics() {
193
+ const metrics = {
194
+ requested: 0,
195
+ cacheHits: 0,
196
+ decoded: 0,
197
+ features: 0,
198
+ overzoomed: 0,
199
+ requestLevels: {},
200
+ sourceLevels: {},
201
+ renderMs: { total: 0, max: 0 },
202
+ decodeMs: { total: 0, max: 0 }
203
+ };
204
+ const root = globalThis.__mapZeroCesiumMetrics ??= { imageryProviders: [] };
205
+ root.imageryProviders.push(metrics);
206
+ return metrics;
207
+ }
208
+
209
+ function mergeWorkerMetrics(target, patch) {
210
+ if (!patch) return;
211
+ for (const key of ['requested', 'cacheHits', 'decoded', 'features', 'overzoomed']) {
212
+ target[key] = Number(patch[key] ?? target[key] ?? 0);
213
+ }
214
+ target.requestLevels = { ...patch.requestLevels };
215
+ target.sourceLevels = { ...patch.sourceLevels };
216
+ target.renderMs = { ...patch.renderMs };
217
+ target.decodeMs = { ...patch.decodeMs };
218
+ }
219
+
220
+ /**
221
+ * @param {Record<string, unknown>} manifest
222
+ * @returns {{ type?: string, source?: string, layers?: string[], overzoomLevels?: number, edgeGuardPixels?: number } | null}
223
+ */
224
+ export function contextOverlayConfig(manifest) {
225
+ const pmtiles = pmtilesInfo(manifest);
226
+ if (!pmtiles.url) return null;
227
+ return {
228
+ type: 'client-rasterized-pmtiles',
229
+ source: pmtiles.url,
230
+ layers: contextLayerIds(manifest)
231
+ };
232
+ }
233
+
234
+ /**
235
+ * @param {Record<string, unknown>} manifest
236
+ * @returns {boolean}
237
+ */
238
+ export function hasMapZeroContextOverlay(manifest) {
239
+ const overlay = contextOverlayConfig(manifest);
240
+ return Boolean(overlay?.source || pmtilesInfo(manifest).url);
241
+ }
242
+
243
+ function contextLayerIds(manifest) {
244
+ const layers = Array.isArray(manifest.layers)
245
+ ? manifest.layers.map(String).filter((layerId) => layerId !== 'buildings')
246
+ : [];
247
+ return layers.length > 0 ? layers : DEFAULT_CONTEXT_LAYERS;
248
+ }
249
+
250
+ function getLayerRule(styleDocument, layer) {
251
+ const layers = styleDocument.layers && typeof styleDocument.layers === 'object' ? styleDocument.layers : {};
252
+ const id = layer.style || layer.id;
253
+ return normalizeStyleRule(layers[id] || layers[layerAlias(id)] || {});
254
+ }
255
+
256
+ function normalizeStyleRule(rule) {
257
+ const normalized = { ...rule };
258
+ const visibility = objectRule(rule.visibility);
259
+ const body = objectRule(rule.body);
260
+ const center = objectRule(rule.center);
261
+ if (visibility) {
262
+ normalized.visible = visibility.visible ?? normalized.visible;
263
+ normalized.minZoom = visibility.minZoom ?? normalized.minZoom;
264
+ normalized.maxZoom = visibility.maxZoom ?? normalized.maxZoom;
265
+ }
266
+ if (body) {
267
+ normalized.stroke = body.color ?? normalized.stroke;
268
+ normalized.strokeWidth = body.width ?? normalized.strokeWidth;
269
+ normalized.strokeOpacity = body.opacity ?? normalized.strokeOpacity;
270
+ normalized.lineCap = body.lineCap ?? normalized.lineCap;
271
+ normalized.lineJoin = body.lineJoin ?? normalized.lineJoin;
272
+ normalized.widthScale = body.widthScale ?? normalized.widthScale;
273
+ }
274
+ if (center) {
275
+ normalized.fill = center.color ?? normalized.fill;
276
+ normalized.fillOpacity = center.opacity ?? normalized.fillOpacity;
277
+ }
278
+ if (!normalized.lineCap) normalized.lineCap = 'round';
279
+ if (!normalized.lineJoin) normalized.lineJoin = 'round';
280
+ return normalized;
281
+ }
282
+
283
+ function mergeFeatureRule(rule, feature) {
284
+ let merged = { ...rule };
285
+ const byProperty = objectRule(rule.byProperty);
286
+ if (!byProperty) return merged;
287
+ for (const [property, values] of Object.entries(byProperty)) {
288
+ const value = String(feature.get(property) ?? '');
289
+ const override = objectRule(values?.[value]);
290
+ if (override) {
291
+ merged = normalizeStyleRule({ ...merged, ...override });
292
+ }
293
+ }
294
+ return merged;
295
+ }
296
+
297
+ function zoomMatchesRule(zoom, rule) {
298
+ if (Number.isFinite(rule.minZoom) && zoom < Number(rule.minZoom)) return false;
299
+ if (Number.isFinite(rule.maxZoom) && zoom > Number(rule.maxZoom)) return false;
300
+ return rule.visible !== false;
301
+ }
302
+
303
+ function styleWidth(value, fallback, layerId, zoom) {
304
+ const width = Number(Array.isArray(value) ? fallback : value);
305
+ const base = Number.isFinite(width) && width > 0 ? width : fallback;
306
+ const scale = layerId === 'roads' ? Math.max(0.65, Math.min(1.35, 0.72 + zoom * 0.035)) : 1;
307
+ return Math.max(0, base * scale);
308
+ }
309
+
310
+ function geometryTypeKind(type) {
311
+ if (type === 'Point' || type === 'MultiPoint') return 'point';
312
+ if (type === 'Polygon' || type === 'MultiPolygon') return 'polygon';
313
+ return 'line';
314
+ }
315
+
316
+ function tileMercatorExtent(x, y, z) {
317
+ const tiles = 2 ** z;
318
+ const span = (WEB_MERCATOR_MAX * 2) / tiles;
319
+ const minX = -WEB_MERCATOR_MAX + x * span;
320
+ const maxX = minX + span;
321
+ const maxY = WEB_MERCATOR_MAX - y * span;
322
+ const minY = maxY - span;
323
+ return [minX, minY, maxX, maxY];
324
+ }
325
+
326
+ function sourceTileForRequest(x, y, z, maxZoom) {
327
+ const sourceZ = Math.min(z, maxZoom);
328
+ if (sourceZ === z) {
329
+ return { x, y, z };
330
+ }
331
+ const shift = z - sourceZ;
332
+ return {
333
+ x: Math.floor(x / 2 ** shift),
334
+ y: Math.floor(y / 2 ** shift),
335
+ z: sourceZ
336
+ };
337
+ }
338
+
339
+ function rectangleFromManifestBbox(manifest, bbox) {
340
+ const padded = expandBboxByTileMargin(bbox, pmtilesInfo(manifest).minZoom);
341
+ return rectangleFromBbox(padded);
342
+ }
343
+
344
+ function rectangleFromBbox(bbox) {
345
+ if (Array.isArray(bbox) && bbox.length === 4) {
346
+ return Rectangle.fromDegrees(Number(bbox[0]), Number(bbox[1]), Number(bbox[2]), Number(bbox[3]));
347
+ }
348
+ return Rectangle.MAX_VALUE;
349
+ }
350
+
351
+ function expandBboxByTileMargin(bbox, minZoom) {
352
+ if (!Array.isArray(bbox) || bbox.length !== 4) return bbox;
353
+ const z = clampInteger(minZoom ?? 8, 0, 22);
354
+ const margin = 360 / 2 ** z;
355
+ return [
356
+ Math.max(-180, Number(bbox[0]) - margin),
357
+ Math.max(-WEB_MERCATOR_MAX_LAT, Number(bbox[1]) - margin),
358
+ Math.min(180, Number(bbox[2]) + margin),
359
+ Math.min(WEB_MERCATOR_MAX_LAT, Number(bbox[3]) + margin)
360
+ ];
361
+ }
362
+
363
+ function emptyCanvas(width, height) {
364
+ const canvas = document.createElement('canvas');
365
+ canvas.width = width;
366
+ canvas.height = height;
367
+ return canvas;
368
+ }
369
+
370
+ function imageToCanvas(image, width, height) {
371
+ const canvas = emptyCanvas(width, height);
372
+ if (!image) {
373
+ return canvas;
374
+ }
375
+
376
+ const ctx = canvas.getContext('2d');
377
+ if (ctx) {
378
+ ctx.drawImage(image, 0, 0, width, height);
379
+ }
380
+ image.close?.();
381
+ return canvas;
382
+ }
383
+
384
+ function clearCanvasBorder(ctx, width, height, pixels) {
385
+ if (!(pixels > 0)) return;
386
+ ctx.clearRect(0, 0, width, pixels);
387
+ ctx.clearRect(0, height - pixels, width, pixels);
388
+ ctx.clearRect(0, 0, pixels, height);
389
+ ctx.clearRect(width - pixels, 0, pixels, height);
390
+ }
391
+
392
+ function pmtilesInfo(manifest) {
393
+ const tiles = manifest.tiles && typeof manifest.tiles === 'object' ? manifest.tiles : {};
394
+ if (tiles.format === 'pmtiles' || tiles.type === 'mvt') {
395
+ return tiles;
396
+ }
397
+ const vector = manifest.vectorTiles && typeof manifest.vectorTiles === 'object' ? manifest.vectorTiles : {};
398
+ const pmtiles = vector.pmtiles && typeof vector.pmtiles === 'object' ? vector.pmtiles : {};
399
+ return pmtiles;
400
+ }
401
+
402
+ function normalizeContextLayers(layers) {
403
+ const values = Array.isArray(layers) && layers.length > 0 ? layers : DEFAULT_CONTEXT_LAYERS;
404
+ return values.map((layer) => sourceLayerFor(String(layer)));
405
+ }
406
+
407
+ function isLayerVisible(layerVisibility, layer) {
408
+ const direct = layerVisibility.get(layer);
409
+ if (direct != null) return direct;
410
+ const source = layerVisibility.get(sourceLayerFor(layer));
411
+ if (source != null) return source;
412
+ const alias = layerVisibility.get(layerAlias(layer));
413
+ return alias === true;
414
+ }
415
+
416
+ function sourceLayerFor(layer) {
417
+ return layer === 'aviation' ? 'aip' : layer;
418
+ }
419
+
420
+ function layerAlias(layer) {
421
+ if (layer === 'aip') return 'aviation';
422
+ if (layer === 'aviation') return 'aip';
423
+ return layer;
424
+ }
425
+
426
+ function resolveWorkerBaseUrl(url) {
427
+ return new URL(url, globalThis.location?.href ?? 'http://localhost/').toString();
428
+ }
429
+
430
+ function clampInteger(value, min, max) {
431
+ const number = Math.trunc(Number(value));
432
+ if (!Number.isFinite(number)) return min;
433
+ return Math.max(min, Math.min(max, number));
434
+ }