@map-zero/cesium 0.2.0 → 0.3.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.
package/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # @map-zero/cesium
2
+
3
+ Cesium integration helpers for map-zero 3D Tiles and context overlays.
4
+
5
+ See the main repository for documentation, examples, and release notes:
6
+
7
+ https://github.com/mploscos/map-zero
8
+
package/package.json CHANGED
@@ -1,12 +1,28 @@
1
1
  {
2
2
  "name": "@map-zero/cesium",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Cesium integration helper for map-zero 3D Tiles packages.",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/mploscos/map-zero#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/mploscos/map-zero.git",
11
+ "directory": "packages/cesium"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/mploscos/map-zero/issues"
15
+ },
6
16
  "main": "./src/index.js",
7
17
  "exports": {
8
- ".": "./src/index.js",
9
- "./imagery-worker.js": "./src/imagery-worker.js"
18
+ ".": "./src/index.js"
19
+ },
20
+ "files": [
21
+ "src",
22
+ "README.md"
23
+ ],
24
+ "dependencies": {
25
+ "@map-zero/raster": "0.3.0"
10
26
  },
11
27
  "peerDependencies": {
12
28
  "cesium": "^1.141.0",
package/src/imagery.js CHANGED
@@ -4,10 +4,18 @@ import {
4
4
  WebMercatorTilingScheme
5
5
  } from 'cesium';
6
6
 
7
- const DEFAULT_CONTEXT_LAYERS = ['roads', 'railways', 'water', 'landuse', 'boundaries', 'aviation', 'pois'];
7
+ import { WEB_MERCATOR_MAX_LAT } from '../../raster/src/shared/geo.js';
8
+ import {
9
+ DEFAULT_CONTEXT_LAYERS,
10
+ layerAlias,
11
+ normalizeContextLayers,
12
+ sourceLayerFor
13
+ } from '../../raster/src/shared/layers.js';
14
+ import { pmtilesInfo } from '../../raster/src/shared/manifest.js';
15
+ import { clampInteger } from '../../raster/src/shared/math.js';
16
+
17
+ const DEFAULT_WORKER_URL = new URL('@map-zero/raster/imagery-worker.js', import.meta.url);
8
18
  const TILE_SIZE = 512;
9
- const WEB_MERCATOR_MAX = 20037508.342789244;
10
- const WEB_MERCATOR_MAX_LAT = 85.05112878;
11
19
 
12
20
  /**
13
21
  * Cesium ImageryProvider that rasterizes map-zero PMTiles/MVT tiles in a
@@ -25,7 +33,7 @@ export class MapZeroCesiumImageryProvider {
25
33
  * minimumLevel?: number,
26
34
  * maximumLevel?: number,
27
35
  * overzoomLevels?: number,
28
- * edgeGuardPixels?: number
36
+ * edgeGuardPixels?: number,
29
37
  * workerUrl?: string | URL
30
38
  * }} options
31
39
  */
@@ -63,7 +71,7 @@ export class MapZeroCesiumImageryProvider {
63
71
  this.nextRequestId = 1;
64
72
  this.metrics = createImageryMetrics();
65
73
  this.worker = new Worker(
66
- options.workerUrl ?? new URL('./imagery-worker.js', import.meta.url),
74
+ options.workerUrl ?? DEFAULT_WORKER_URL,
67
75
  { type: 'module' }
68
76
  );
69
77
  this.worker.addEventListener('message', (event) => this.#handleWorkerMessage(event.data));
@@ -219,7 +227,7 @@ function mergeWorkerMetrics(target, patch) {
219
227
 
220
228
  /**
221
229
  * @param {Record<string, unknown>} manifest
222
- * @returns {{ type?: string, source?: string, layers?: string[], overzoomLevels?: number, edgeGuardPixels?: number } | null}
230
+ * @returns {{ type?: string, source?: string, layers?: string[], backend?: string, overzoomLevels?: number, edgeGuardPixels?: number } | null}
223
231
  */
224
232
  export function contextOverlayConfig(manifest) {
225
233
  const pmtiles = pmtilesInfo(manifest);
@@ -247,95 +255,6 @@ function contextLayerIds(manifest) {
247
255
  return layers.length > 0 ? layers : DEFAULT_CONTEXT_LAYERS;
248
256
  }
249
257
 
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
258
  function rectangleFromManifestBbox(manifest, bbox) {
340
259
  const padded = expandBboxByTileMargin(bbox, pmtilesInfo(manifest).minZoom);
341
260
  return rectangleFromBbox(padded);
@@ -381,54 +300,6 @@ function imageToCanvas(image, width, height) {
381
300
  return canvas;
382
301
  }
383
302
 
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
303
  function resolveWorkerBaseUrl(url) {
427
304
  return new URL(url, globalThis.location?.href ?? 'http://localhost/').toString();
428
305
  }
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
- }
package/src/index.js CHANGED
@@ -90,7 +90,10 @@ export async function loadMapZeroStyle(input, options = {}) {
90
90
  * opacity?: number,
91
91
  * tilesetOpacity?: number,
92
92
  * buildingsOpacity?: number,
93
- * buildings3d?: boolean
93
+ * buildings3d?: boolean,
94
+ * tilesetMaximumScreenSpaceError?: number,
95
+ * tilesetCacheBytes?: number,
96
+ * tilesetMaximumCacheOverflowBytes?: number
94
97
  * }} options
95
98
  * @returns {Promise<{ id: string, manifest: MapZeroManifest, style: Record<string, unknown> | null, tilesets: Record<string, Cesium3DTileset> }>}
96
99
  */
@@ -124,7 +127,7 @@ export async function createMapZeroCesiumTilesets(options) {
124
127
  const url = resolveRelativeUrl(entry.url, options.manifestUrl);
125
128
  const tileset = await Cesium3DTileset.fromUrl(url);
126
129
  tagCesiumTileset(tileset, instanceId, entry.layerId);
127
- configureCesiumTilesetStreaming(tileset, entry.layerId);
130
+ configureCesiumTilesetStreaming(tileset, entry.layerId, options);
128
131
  configureCesiumTilesetColor(tileset, entry.layerId);
129
132
  tileset.style = createMapZeroCesiumStyle(styleJson, {
130
133
  layerId: entry.layerId,
@@ -160,8 +163,11 @@ export async function createMapZeroCesiumTilesets(options) {
160
163
  * contextOpacity?: number,
161
164
  * contextOverzoomLevels?: number,
162
165
  * contextEdgeGuardPixels?: number,
163
- * contextWorkerUrl?: string | URL,
166
+ * workerUrl?: string | URL,
164
167
  * buildings3d?: boolean,
168
+ * tilesetMaximumScreenSpaceError?: number,
169
+ * tilesetCacheBytes?: number,
170
+ * tilesetMaximumCacheOverflowBytes?: number,
165
171
  * zoomTo?: boolean,
166
172
  * applyDefaultSceneStyle?: boolean,
167
173
  * sceneStyle?: Record<string, unknown>,
@@ -199,7 +205,7 @@ export async function addMapZeroToCesium(viewer, options) {
199
205
  layers: contextOverlayConfig(result.manifest)?.layers,
200
206
  overzoomLevels: options.contextOverzoomLevels,
201
207
  edgeGuardPixels: options.contextEdgeGuardPixels,
202
- workerUrl: options.contextWorkerUrl
208
+ workerUrl: options.workerUrl
203
209
  })
204
210
  : undefined;
205
211
  const imageryLayer = imageryProvider
@@ -431,13 +437,14 @@ function shouldCreateContextOverlay(manifest, options) {
431
437
  /**
432
438
  * @param {Cesium3DTileset} tileset
433
439
  * @param {string} layerId
440
+ * @param {{ tilesetMaximumScreenSpaceError?: number, tilesetCacheBytes?: number, tilesetMaximumCacheOverflowBytes?: number }} options
434
441
  */
435
- function configureCesiumTilesetStreaming(tileset, layerId) {
442
+ function configureCesiumTilesetStreaming(tileset, layerId, options = {}) {
436
443
  if (layerId !== 'buildings') {
437
444
  return;
438
445
  }
439
446
 
440
- tileset.maximumScreenSpaceError = 24;
447
+ tileset.maximumScreenSpaceError = finiteNumber(options.tilesetMaximumScreenSpaceError, 24);
441
448
  tileset.skipLevelOfDetail = true;
442
449
  tileset.baseScreenSpaceError = 1024;
443
450
  tileset.skipScreenSpaceErrorFactor = 16;
@@ -450,8 +457,8 @@ function configureCesiumTilesetStreaming(tileset, layerId) {
450
457
  tileset.dynamicScreenSpaceErrorFactor = 4;
451
458
  tileset.preloadWhenHidden = false;
452
459
  tileset.preloadFlightDestinations = false;
453
- tileset.cacheBytes = 256 * 1024 * 1024;
454
- tileset.maximumCacheOverflowBytes = 128 * 1024 * 1024;
460
+ tileset.cacheBytes = finiteNumber(options.tilesetCacheBytes, 768 * 1024 * 1024);
461
+ tileset.maximumCacheOverflowBytes = finiteNumber(options.tilesetMaximumCacheOverflowBytes, 512 * 1024 * 1024);
455
462
  }
456
463
 
457
464
  /**
@@ -567,3 +574,13 @@ function colorFromOption(Cesium, value, fallback) {
567
574
  function clamp01(value) {
568
575
  return Math.max(0, Math.min(1, Number.isFinite(value) ? value : 1));
569
576
  }
577
+
578
+ /**
579
+ * @param {unknown} value
580
+ * @param {number} fallback
581
+ * @returns {number}
582
+ */
583
+ function finiteNumber(value, fallback) {
584
+ const number = Number(value);
585
+ return Number.isFinite(number) ? number : fallback;
586
+ }
@@ -1,604 +0,0 @@
1
- import Feature from 'ol/Feature.js';
2
- import MVT from 'ol/format/MVT.js';
3
- import { PMTiles } from 'pmtiles';
4
-
5
- const DEFAULT_CONTEXT_LAYERS = ['roads', 'railways', 'water', 'landuse', 'boundaries', 'aviation', 'pois'];
6
- const WEB_MERCATOR_MAX = 20037508.342789244;
7
- const LABEL_SOURCE_LAYERS = new Set(['roads', 'aip', 'aviation', 'pois']);
8
- const MAJOR_ROADS = new Set(['motorway', 'motorway_link', 'trunk', 'trunk_link', 'primary', 'primary_link']);
9
- const SECONDARY_ROADS = new Set(['secondary', 'secondary_link']);
10
- const LOCAL_ROADS = new Set(['residential', 'living_street', 'unclassified']);
11
- const DISABLED_ROADS = new Set(['service', 'track', 'path', 'footway', 'cycleway', 'steps', 'corridor', 'platform']);
12
- const GENERIC_LABEL_VALUES = new Set(['yes', 'no', 'true', 'false', 'unknown', 'none', 'station', 'airport', 'aerodrome']);
13
-
14
- let state = null;
15
-
16
- self.addEventListener('message', (event) => {
17
- handleMessage(event.data).catch((error) => {
18
- if (event.data?.id != null) {
19
- self.postMessage({ type: 'tile', id: event.data.id, error: error.message, metrics: state?.metrics });
20
- } else {
21
- self.postMessage({ type: 'error', error: error.message, metrics: state?.metrics });
22
- }
23
- });
24
- });
25
-
26
- async function handleMessage(message) {
27
- if (message?.type === 'init') {
28
- state = createState(message.options);
29
- return;
30
- }
31
-
32
- if (!state) {
33
- throw new Error('map-zero imagery worker was not initialized');
34
- }
35
-
36
- if (message?.type === 'visibility') {
37
- state.layerVisibility.set(sourceLayerFor(message.layerId), Boolean(message.visible));
38
- state.layerVisibility.set(layerAlias(message.layerId), Boolean(message.visible));
39
- return;
40
- }
41
-
42
- if (message?.type === 'render') {
43
- const image = await renderTile(message.x, message.y, message.z);
44
- self.postMessage({ type: 'tile', id: message.id, image, metrics: state.metrics }, [image]);
45
- }
46
- }
47
-
48
- function createState(options) {
49
- const manifest = options.manifest;
50
- const styleDocument = options.styleDocument ?? {};
51
- const layerIds = normalizeContextLayers(options.layers);
52
- return {
53
- manifest,
54
- manifestUrl: options.manifestUrl,
55
- styleDocument,
56
- tileSize: Number(options.tileSize ?? 512),
57
- pixelRatio: clampNumber(options.pixelRatio ?? 1, 1, 2),
58
- sourceMaximumLevel: Number(options.sourceMaximumLevel ?? pmtilesInfo(manifest).maxZoom ?? 18),
59
- edgeGuardPixels: clampInteger(options.edgeGuardPixels ?? 0, 0, 8),
60
- layerIds,
61
- layerVisibility: new Map(layerIds.map((layerId) => [layerId, true])),
62
- orderedLayers: orderManifestLayers(manifest, styleDocument)
63
- .filter((layer) => layerIds.includes(layer.id) || layerIds.includes(layerAlias(layer.id))),
64
- format: new MVT({ featureClass: Feature }),
65
- archive: new PMTiles(resolveRelativeUrl(String(options.source ?? pmtilesInfo(manifest).url ?? 'tiles.pmtiles'), options.manifestUrl)),
66
- sourceTileCache: new Map(),
67
- metrics: createMetrics()
68
- };
69
- }
70
-
71
- async function renderTile(x, y, z) {
72
- return renderTileBitmap(x, y, z);
73
- }
74
-
75
- async function renderTileBitmap(x, y, z) {
76
- const started = performance.now();
77
- state.metrics.requested++;
78
- addMetricCount(state.metrics.requestLevels, z);
79
-
80
- const sourceTile = sourceTileForRequest(x, y, z, state.sourceMaximumLevel);
81
- let canvas;
82
- if (sourceTile.z !== z) {
83
- state.metrics.overzoomed++;
84
- addMetricCount(state.metrics.sourceLevels, sourceTile.z);
85
- canvas = await renderOverzoomedTile(sourceTile, x, y, z);
86
- } else {
87
- canvas = await renderSourceTile(sourceTile, tileMercatorExtent(x, y, z), z, state.tileSize);
88
- }
89
-
90
- recordMetricTime(state.metrics, 'renderMs', performance.now() - started);
91
- return canvas.transferToImageBitmap();
92
- }
93
-
94
- async function renderOverzoomedTile(sourceTile, x, y, z) {
95
- const { canvas, ctx } = createRenderCanvas(state.tileSize, state.pixelRatio);
96
- if (!ctx) return canvas;
97
-
98
- const sourceData = await readSourceTile(sourceTile);
99
- if (!sourceData) return canvas;
100
- drawSourceData(ctx, sourceData, tileMercatorExtent(x, y, z), z, state.tileSize);
101
- clearCanvasBorder(ctx, state.tileSize, state.tileSize, state.edgeGuardPixels);
102
- return canvas;
103
- }
104
-
105
- async function renderSourceTile(sourceTile, renderExtent, z, size) {
106
- const { canvas, ctx } = createRenderCanvas(size, state.pixelRatio);
107
- if (!ctx) return canvas;
108
-
109
- const sourceData = await readSourceTile(sourceTile);
110
- if (!sourceData) return canvas;
111
- drawSourceData(ctx, sourceData, renderExtent, z, size);
112
- clearCanvasBorder(ctx, size, size, state.edgeGuardPixels);
113
- return canvas;
114
- }
115
-
116
- function createRenderCanvas(size, pixelRatio) {
117
- const ratio = clampNumber(pixelRatio, 1, 2);
118
- const canvas = new OffscreenCanvas(Math.round(size * ratio), Math.round(size * ratio));
119
- const ctx = canvas.getContext('2d');
120
- if (ctx && ratio !== 1) ctx.scale(ratio, ratio);
121
- return { canvas, ctx };
122
- }
123
-
124
- async function readSourceTile(tile) {
125
- const key = `${tile.z}/${tile.x}/${tile.y}`;
126
- const cached = state.sourceTileCache.get(key);
127
- if (cached) return cached;
128
-
129
- const promise = (async () => {
130
- const started = performance.now();
131
- const result = await state.archive.getZxy(tile.z, tile.x, tile.y);
132
- if (!result) return null;
133
- addMetricCount(state.metrics.sourceLevels, tile.z);
134
- const features = state.format.readFeatures(result.data, {
135
- extent: tileMercatorExtent(tile.x, tile.y, tile.z),
136
- featureProjection: 'EPSG:3857'
137
- });
138
- state.metrics.decoded++;
139
- state.metrics.features += features.length;
140
- recordMetricTime(state.metrics, 'decodeMs', performance.now() - started);
141
- return {
142
- features,
143
- byLayer: groupFeaturesByLayer(features)
144
- };
145
- })();
146
- state.sourceTileCache.set(key, promise);
147
- return promise;
148
- }
149
-
150
- function drawSourceData(ctx, sourceData, renderExtent, z, size) {
151
- const { features, byLayer } = sourceData;
152
- for (const layer of state.orderedLayers) {
153
- if (!isLayerVisible(state.layerVisibility, layer.id) || !zoomMatchesRule(z, getLayerRule(state.styleDocument, layer))) continue;
154
- const layerFeatures = byLayer.get(sourceLayerFor(layer.id)) ?? byLayer.get(layer.id) ?? [];
155
- drawLayer(ctx, layerFeatures, layer, getLayerRule(state.styleDocument, layer), renderExtent, size, z);
156
- }
157
- drawLabels(ctx, features, state.styleDocument, renderExtent, size, z, state.layerVisibility);
158
- }
159
-
160
- function drawLayer(ctx, features, layer, rule, extent, size, zoom) {
161
- for (const feature of features) {
162
- const geometry = feature.getGeometry?.();
163
- if (!geometry) continue;
164
- const featureRule = mergeFeatureRule(rule, feature);
165
- if (featureRule.visible === false) continue;
166
- const type = geometry.getType();
167
- if (layer.id === 'boundaries' && (type === 'Polygon' || type === 'MultiPolygon')) {
168
- drawGeometry(ctx, geometry, { ...featureRule, stroke: null, strokeOpacity: 0, glow: { enabled: false }, casing: { enabled: false } }, extent, size, layer.id, zoom);
169
- } else if (isAipLayer(layer.id) && (type === 'LineString' || type === 'MultiLineString')) {
170
- drawGeometry(ctx, geometry, { ...featureRule, fill: null, fillOpacity: 0 }, extent, size, layer.id, zoom);
171
- } else {
172
- drawGeometry(ctx, geometry, featureRule, extent, size, layer.id, zoom);
173
- }
174
- }
175
- }
176
-
177
- function drawGeometry(ctx, geometry, rule, extent, size, layerId, zoom) {
178
- const type = geometry.getType();
179
- const draw = (style) => {
180
- if (type === 'Polygon') return drawPolygons(ctx, [geometry.getCoordinates()], style, extent, size);
181
- if (type === 'MultiPolygon') return drawPolygons(ctx, geometry.getCoordinates(), style, extent, size);
182
- if (type === 'LineString') return drawLines(ctx, [geometry.getCoordinates()], style, extent, size);
183
- if (type === 'MultiLineString') return drawLines(ctx, geometry.getCoordinates(), style, extent, size);
184
- if (type === 'Point') return drawPoints(ctx, [geometry.getCoordinates()], style, extent, size);
185
- if (type === 'MultiPoint') return drawPoints(ctx, geometry.getCoordinates(), style, extent, size);
186
- return undefined;
187
- };
188
- for (const style of canvasStyleParts(rule, geometryTypeKind(type), layerId, zoom)) draw(style);
189
- }
190
-
191
- function canvasStyleParts(rule, kind, layerId, zoom) {
192
- const parts = [];
193
- if (rule.glow?.enabled && rule.stroke) parts.push({ stroke: colorWithOpacity(String(rule.glow.color || rule.stroke), opacity(rule.glow.opacity, 0.2)), width: styleWidth(rule.glow.width ?? rule.glowWidth, 4, layerId, zoom), lineCap: rule.lineCap, lineJoin: rule.lineJoin });
194
- if (rule.casing?.enabled && rule.stroke && kind !== 'point') parts.push({ stroke: colorWithOpacity(String(rule.casing.color || rule.stroke), opacity(rule.casing.opacity, 0.35)), width: styleWidth(rule.casing.width ?? rule.casingWidth, Number(rule.strokeWidth ?? 1) + 1, layerId, zoom), lineCap: rule.lineCap, lineJoin: rule.lineJoin });
195
- const base = {};
196
- if (rule.fill && kind !== 'line') base.fill = colorWithOpacity(String(rule.fill), opacity(rule.fillOpacity, 1));
197
- if (rule.stroke && kind !== 'polygon-fill-only') {
198
- base.stroke = colorWithOpacity(String(rule.stroke), opacity(rule.strokeOpacity, 1));
199
- base.width = styleWidth(rule.strokeWidth, 1, layerId, zoom);
200
- base.lineCap = rule.lineCap;
201
- base.lineJoin = rule.lineJoin;
202
- }
203
- if (kind === 'point') {
204
- base.radius = styleWidth(rule.radius ?? rule.circleRadius, 4, layerId, zoom);
205
- base.fill = colorWithOpacity(String(rule.fill || rule.stroke || '#ffffff'), opacity(rule.fillOpacity ?? rule.strokeOpacity, 1));
206
- if (rule.stroke) {
207
- base.stroke = colorWithOpacity(String(rule.stroke), opacity(rule.strokeOpacity, 1));
208
- base.width = styleWidth(rule.strokeWidth, 1, layerId, zoom);
209
- }
210
- }
211
- parts.push(base);
212
- if (rule.centerLine?.enabled && rule.stroke && kind !== 'point') parts.push({ stroke: colorWithOpacity(String(rule.centerLine.color || rule.stroke), opacity(rule.centerLine.opacity, 0.5)), width: styleWidth(rule.centerLine.width, 0.5, layerId, zoom), lineCap: rule.lineCap, lineJoin: rule.lineJoin });
213
- return parts;
214
- }
215
-
216
- function drawPolygons(ctx, polygons, style, extent, size) {
217
- if (!style.fill && !style.stroke) return;
218
- ctx.save();
219
- clipCanvas(ctx, size);
220
- ctx.beginPath();
221
- for (const polygon of polygons) for (const ring of polygon) traceLine(ctx, ring, extent, size, true);
222
- if (style.fill) {
223
- ctx.fillStyle = style.fill;
224
- ctx.fill('evenodd');
225
- }
226
- if (style.stroke && style.width > 0) {
227
- applyStroke(ctx, style);
228
- ctx.stroke();
229
- }
230
- ctx.restore();
231
- }
232
-
233
- function drawLines(ctx, lines, style, extent, size) {
234
- if (!style.stroke || !(style.width > 0)) return;
235
- ctx.save();
236
- clipCanvas(ctx, size);
237
- applyStroke(ctx, style);
238
- ctx.beginPath();
239
- for (const line of lines) traceLine(ctx, line, extent, size);
240
- ctx.stroke();
241
- ctx.restore();
242
- }
243
-
244
- function drawPoints(ctx, points, style, extent, size) {
245
- const radius = Number(style.radius ?? 4);
246
- if (!style.fill && !style.stroke) return;
247
- ctx.save();
248
- clipCanvas(ctx, size);
249
- for (const point of points) {
250
- const [px, py] = projectPoint(point, extent, size);
251
- if (!Number.isFinite(px) || !Number.isFinite(py)) continue;
252
- ctx.beginPath();
253
- ctx.arc(px, py, radius, 0, Math.PI * 2);
254
- if (style.fill) {
255
- ctx.fillStyle = style.fill;
256
- ctx.fill();
257
- }
258
- if (style.stroke && style.width > 0) {
259
- applyStroke(ctx, style);
260
- ctx.stroke();
261
- }
262
- }
263
- ctx.restore();
264
- }
265
-
266
- function drawLabels(ctx, features, styleDocument, extent, size, zoom, layerVisibility) {
267
- if (styleDocument.labels?.enabled === false) return;
268
- const labels = [];
269
- for (const feature of features) {
270
- const layer = sourceLayerFor(String(feature.get('layer') ?? ''));
271
- if (!LABEL_SOURCE_LAYERS.has(layer) || layerVisibility.get(layer) === false) continue;
272
- const label = labelForFeature(feature, layer, zoom);
273
- const point = label ? labelPoint(feature.getGeometry?.()) : null;
274
- if (label && point) labels.push({ ...label, point });
275
- }
276
- labels.sort((a, b) => a.priority - b.priority);
277
- const occupied = [];
278
- for (const label of labels.slice(-160)) {
279
- const [x, y] = projectPoint(label.point, extent, size);
280
- const fontSize = zoom >= 17 ? 12 : zoom >= 15 ? 11 : 10;
281
- ctx.save();
282
- ctx.font = `600 ${fontSize}px sans-serif`;
283
- const width = ctx.measureText(label.text).width;
284
- const box = [x - width / 2 - 3, y - fontSize / 2 - 3, x + width / 2 + 3, y + fontSize / 2 + 3];
285
- if (occupied.some((other) => boxesOverlap(box, other))) {
286
- ctx.restore();
287
- continue;
288
- }
289
- occupied.push(box);
290
- ctx.textAlign = 'center';
291
- ctx.textBaseline = 'middle';
292
- ctx.lineJoin = 'round';
293
- ctx.strokeStyle = 'rgba(0,16,20,0.92)';
294
- ctx.lineWidth = 3;
295
- ctx.strokeText(label.text, x, y);
296
- ctx.fillStyle = 'rgba(217,251,255,0.9)';
297
- ctx.fillText(label.text, x, y);
298
- ctx.restore();
299
- }
300
- }
301
-
302
- function labelForFeature(feature, layer, zoom) {
303
- if (layer === 'roads') {
304
- const highway = String(feature.get('highway') ?? '');
305
- if (DISABLED_ROADS.has(highway)) return null;
306
- const ref = cleanText(feature.get('ref'));
307
- const name = cleanText(feature.get('name'));
308
- if (MAJOR_ROADS.has(highway) && zoom >= 12 && isMeaningfulLabel(ref)) return { text: ref, priority: 820 };
309
- if (SECONDARY_ROADS.has(highway) && zoom >= 16 && isMeaningfulLabel(ref)) return { text: ref, priority: 700 };
310
- if (LOCAL_ROADS.has(highway) && zoom >= 17 && isMeaningfulLabel(name)) return { text: name, priority: 400 };
311
- } else if (isAipLayer(layer)) {
312
- const aeroway = String(feature.get('aeroway') ?? '');
313
- const ref = cleanText(feature.get('ref'));
314
- const name = cleanText(feature.get('name'));
315
- if ((aeroway === 'aerodrome' || aeroway === 'heliport') && zoom >= 11 && zoom < 15) return meaningfulLabel(name || ref, 980);
316
- if (aeroway === 'runway' && zoom >= 12) return meaningfulLabel(ref || name || 'RWY', 900);
317
- if ((aeroway === 'terminal' || aeroway === 'apron') && zoom >= 15) return meaningfulLabel(name || ref, 720);
318
- if (aeroway === 'helipad' && zoom >= 14) return meaningfulLabel(ref || name || 'H', 980);
319
- } else if (layer === 'pois' && zoom >= 13) {
320
- return meaningfulLabel(cleanText(feature.get('name') ?? feature.get('ref') ?? feature.get('operator')), 620);
321
- }
322
- return null;
323
- }
324
-
325
- function getLayerRule(styleDocument, layer) {
326
- const layers = styleDocument.layers && typeof styleDocument.layers === 'object' ? styleDocument.layers : {};
327
- const id = layer.style || layer.id;
328
- return normalizeStyleRule(layers[id] || layers[layerAlias(id)] || {});
329
- }
330
-
331
- function normalizeStyleRule(rule) {
332
- const normalized = { ...rule };
333
- const visibility = objectRule(rule.visibility);
334
- const body = objectRule(rule.body);
335
- const center = objectRule(rule.center);
336
- if (visibility) {
337
- normalized.visible = visibility.visible ?? normalized.visible;
338
- normalized.minZoom = visibility.minZoom ?? normalized.minZoom;
339
- normalized.maxZoom = visibility.maxZoom ?? normalized.maxZoom;
340
- }
341
- if (body) {
342
- normalized.stroke = body.color ?? normalized.stroke;
343
- normalized.strokeWidth = body.width ?? normalized.strokeWidth;
344
- normalized.strokeOpacity = body.opacity ?? normalized.strokeOpacity;
345
- normalized.lineCap = body.lineCap ?? normalized.lineCap;
346
- normalized.lineJoin = body.lineJoin ?? normalized.lineJoin;
347
- }
348
- if (center) {
349
- normalized.fill = center.color ?? normalized.fill;
350
- normalized.fillOpacity = center.opacity ?? normalized.fillOpacity;
351
- }
352
- if (!normalized.lineCap) normalized.lineCap = 'round';
353
- if (!normalized.lineJoin) normalized.lineJoin = 'round';
354
- return normalized;
355
- }
356
-
357
- function mergeFeatureRule(rule, feature) {
358
- let merged = { ...rule };
359
- const byProperty = objectRule(rule.byProperty);
360
- if (!byProperty) return merged;
361
- for (const [property, values] of Object.entries(byProperty)) {
362
- const override = objectRule(values?.[String(feature.get(property) ?? '')]);
363
- if (override) merged = normalizeStyleRule({ ...merged, ...override });
364
- }
365
- return merged;
366
- }
367
-
368
- function orderManifestLayers(manifest, styleDocument) {
369
- const layers = Array.isArray(manifest.layers) ? manifest.layers.map(manifestLayer) : [];
370
- const drawOrder = Array.isArray(styleDocument.drawOrder) ? styleDocument.drawOrder : layers.map((layer) => layer.id);
371
- return [...layers].sort((a, b) => {
372
- const ai = drawOrder.indexOf(a.id);
373
- const bi = drawOrder.indexOf(b.id);
374
- const ao = Number(getLayerRule(styleDocument, a).order ?? 0);
375
- const bo = Number(getLayerRule(styleDocument, b).order ?? 0);
376
- return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi) || ao - bo;
377
- });
378
- }
379
-
380
- function manifestLayer(layerId) {
381
- return { id: String(layerId), style: String(layerId) };
382
- }
383
-
384
- function groupFeaturesByLayer(features) {
385
- const groups = new Map();
386
- for (const feature of features) {
387
- const layer = sourceLayerFor(String(feature.get('layer') ?? ''));
388
- if (!groups.has(layer)) groups.set(layer, []);
389
- groups.get(layer).push(feature);
390
- }
391
- return groups;
392
- }
393
-
394
- function zoomMatchesRule(zoom, rule) {
395
- if (Number.isFinite(rule.minZoom) && zoom < Number(rule.minZoom)) return false;
396
- if (Number.isFinite(rule.maxZoom) && zoom > Number(rule.maxZoom)) return false;
397
- return rule.visible !== false;
398
- }
399
-
400
- function styleWidth(value, fallback, layerId, zoom) {
401
- const width = Number(Array.isArray(value) ? fallback : value);
402
- const base = Number.isFinite(width) && width > 0 ? width : fallback;
403
- const scale = layerId === 'roads' ? Math.max(0.65, Math.min(1.35, 0.72 + zoom * 0.035)) : 1;
404
- return Math.max(0, base * scale);
405
- }
406
-
407
- function traceLine(ctx, line, extent, size, close = false) {
408
- let first = true;
409
- for (const point of line) {
410
- const [px, py] = projectPoint(point, extent, size);
411
- if (!Number.isFinite(px) || !Number.isFinite(py)) {
412
- first = true;
413
- } else if (first) {
414
- ctx.moveTo(px, py);
415
- first = false;
416
- } else {
417
- ctx.lineTo(px, py);
418
- }
419
- }
420
- if (close) ctx.closePath();
421
- }
422
-
423
- function labelPoint(geometry) {
424
- if (!geometry) return null;
425
- const type = geometry.getType();
426
- if (type === 'Point') return geometry.getCoordinates();
427
- if (type === 'MultiPoint') return geometry.getCoordinates()[0] ?? null;
428
- if (type === 'LineString') return middlePoint(geometry.getCoordinates());
429
- if (type === 'MultiLineString') return middlePoint(geometry.getCoordinates()[0] ?? []);
430
- if (type === 'Polygon') return ringCenter(geometry.getCoordinates()[0] ?? []);
431
- if (type === 'MultiPolygon') return ringCenter(geometry.getCoordinates()[0]?.[0] ?? []);
432
- return null;
433
- }
434
-
435
- function middlePoint(line) {
436
- return line[Math.max(0, Math.floor(line.length / 2))] ?? null;
437
- }
438
-
439
- function ringCenter(ring) {
440
- if (!ring.length) return null;
441
- let x = 0;
442
- let y = 0;
443
- for (const point of ring) {
444
- x += point[0];
445
- y += point[1];
446
- }
447
- return [x / ring.length, y / ring.length];
448
- }
449
-
450
- function clipCanvas(ctx, size) {
451
- ctx.beginPath();
452
- ctx.rect(0, 0, size, size);
453
- ctx.clip();
454
- }
455
-
456
- function applyStroke(ctx, style) {
457
- ctx.strokeStyle = style.stroke;
458
- ctx.lineWidth = Math.max(0, Number(style.width ?? 1));
459
- ctx.lineCap = String(style.lineCap || 'round');
460
- ctx.lineJoin = String(style.lineJoin || 'round');
461
- }
462
-
463
- function clearCanvasBorder(ctx, width, height, pixels) {
464
- if (!(pixels > 0)) return;
465
- ctx.clearRect(0, 0, width, pixels);
466
- ctx.clearRect(0, height - pixels, width, pixels);
467
- ctx.clearRect(0, 0, pixels, height);
468
- ctx.clearRect(width - pixels, 0, pixels, height);
469
- }
470
-
471
- function boxesOverlap(a, b) {
472
- return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
473
- }
474
-
475
- function projectPoint(point, extent, size) {
476
- const [minX, minY, maxX, maxY] = extent;
477
- return [((point[0] - minX) / (maxX - minX)) * size, ((maxY - point[1]) / (maxY - minY)) * size];
478
- }
479
-
480
- function geometryTypeKind(type) {
481
- if (type === 'Point' || type === 'MultiPoint') return 'point';
482
- if (type === 'Polygon' || type === 'MultiPolygon') return 'polygon';
483
- return 'line';
484
- }
485
-
486
- function tileMercatorExtent(x, y, z) {
487
- const span = (WEB_MERCATOR_MAX * 2) / 2 ** z;
488
- const minX = -WEB_MERCATOR_MAX + x * span;
489
- const maxX = minX + span;
490
- const maxY = WEB_MERCATOR_MAX - y * span;
491
- const minY = maxY - span;
492
- return [minX, minY, maxX, maxY];
493
- }
494
-
495
- function sourceTileForRequest(x, y, z, maxZoom) {
496
- const sourceZ = Math.min(z, maxZoom);
497
- if (sourceZ === z) return { x, y, z };
498
- const shift = z - sourceZ;
499
- return { x: Math.floor(x / 2 ** shift), y: Math.floor(y / 2 ** shift), z: sourceZ };
500
- }
501
-
502
- function normalizeContextLayers(layers) {
503
- const values = Array.isArray(layers) && layers.length > 0 ? layers : DEFAULT_CONTEXT_LAYERS;
504
- return values.map((layer) => sourceLayerFor(String(layer)));
505
- }
506
-
507
- function isLayerVisible(layerVisibility, layer) {
508
- const direct = layerVisibility.get(layer);
509
- if (direct != null) return direct;
510
- const source = layerVisibility.get(sourceLayerFor(layer));
511
- if (source != null) return source;
512
- return layerVisibility.get(layerAlias(layer)) === true;
513
- }
514
-
515
- function sourceLayerFor(layer) {
516
- return layer === 'aviation' ? 'aip' : layer;
517
- }
518
-
519
- function layerAlias(layer) {
520
- if (layer === 'aip') return 'aviation';
521
- if (layer === 'aviation') return 'aip';
522
- return layer;
523
- }
524
-
525
- function isAipLayer(layer) {
526
- return layer === 'aip' || layer === 'aviation';
527
- }
528
-
529
- function colorWithOpacity(color, alpha) {
530
- if (color.startsWith('rgba(') || color.startsWith('hsla(')) return color;
531
- const rgb = parseHexColor(color);
532
- return rgb ? `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha})` : color;
533
- }
534
-
535
- function parseHexColor(color) {
536
- const match = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
537
- if (!match) return null;
538
- const hex = match[1].length === 3 ? match[1].split('').map((char) => char + char).join('') : match[1];
539
- return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
540
- }
541
-
542
- function opacity(value, fallback) {
543
- const number = Number(value ?? fallback);
544
- return Number.isFinite(number) ? Math.max(0, Math.min(1, number)) : fallback;
545
- }
546
-
547
- function cleanText(value) {
548
- return String(value ?? '').replace(/\s+/g, ' ').trim();
549
- }
550
-
551
- function isMeaningfulLabel(text) {
552
- const normalized = cleanText(text);
553
- return normalized.length >= 2 && !GENERIC_LABEL_VALUES.has(normalized.toLowerCase().replace(/\s+/g, '_'));
554
- }
555
-
556
- function meaningfulLabel(text, priority) {
557
- return isMeaningfulLabel(text) ? { text: cleanText(text), priority } : null;
558
- }
559
-
560
- function objectRule(value) {
561
- return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
562
- }
563
-
564
- function pmtilesInfo(manifest) {
565
- const tiles = manifest.tiles && typeof manifest.tiles === 'object' ? manifest.tiles : {};
566
- return tiles.format === 'pmtiles' || tiles.type === 'mvt' ? tiles : {};
567
- }
568
-
569
- function clampInteger(value, min, max) {
570
- const number = Math.trunc(Number(value));
571
- return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : min;
572
- }
573
-
574
- function clampNumber(value, min, max) {
575
- const number = Number(value);
576
- return Number.isFinite(number) ? Math.max(min, Math.min(max, number)) : min;
577
- }
578
-
579
- function resolveRelativeUrl(url, baseUrl) {
580
- return new URL(url, new URL(baseUrl, self.location.href)).toString();
581
- }
582
-
583
- function createMetrics() {
584
- return {
585
- requested: 0,
586
- cacheHits: 0,
587
- decoded: 0,
588
- features: 0,
589
- overzoomed: 0,
590
- requestLevels: {},
591
- sourceLevels: {},
592
- renderMs: { total: 0, max: 0 },
593
- decodeMs: { total: 0, max: 0 }
594
- };
595
- }
596
-
597
- function recordMetricTime(metrics, key, value) {
598
- metrics[key].total += value;
599
- metrics[key].max = Math.max(metrics[key].max, value);
600
- }
601
-
602
- function addMetricCount(counts, key) {
603
- counts[key] = (counts[key] ?? 0) + 1;
604
- }