@map-zero/cesium 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.
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@map-zero/cesium",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Cesium integration helper for map-zero 3D Tiles packages.",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
8
- ".": "./src/index.js"
8
+ ".": "./src/index.js",
9
+ "./imagery-worker.js": "./src/imagery-worker.js"
9
10
  },
10
11
  "peerDependencies": {
11
- "cesium": "^1.141.0"
12
+ "cesium": "^1.141.0",
13
+ "ol": "^10.9.0",
14
+ "pmtiles": "^4.4.1"
12
15
  }
13
16
  }
@@ -0,0 +1,604 @@
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
+ }