@map-zero/ol 0.3.1 → 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcos Pérez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@map-zero/ol",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "OpenLayers integration helper for map-zero packages.",
6
6
  "license": "MIT",
@@ -22,10 +22,10 @@
22
22
  "README.md"
23
23
  ],
24
24
  "dependencies": {
25
- "@map-zero/raster": "0.3.1"
25
+ "@map-zero/core": "0.4.0"
26
26
  },
27
27
  "peerDependencies": {
28
- "ol": "^10.9.0",
29
- "pmtiles": "^4.4.1"
28
+ "ol": "10.10.0",
29
+ "pmtiles": "4.4.1"
30
30
  }
31
31
  }
package/src/index.js CHANGED
@@ -1,15 +1,13 @@
1
+ import { LruCache, cachedPromise } from '../../core/src/shared/cache.js';
1
2
  import MVT from 'ol/format/MVT.js';
2
- import TileLayer from 'ol/layer/Tile.js';
3
3
  import WebGLVectorTileLayer from 'ol/layer/WebGLVectorTile.js';
4
4
  import WebGLVectorTileLayerRenderer from 'ol/renderer/webgl/VectorTileLayer.js';
5
- import ImageTileSource from 'ol/source/ImageTile.js';
6
5
  import VectorTileSource from 'ol/source/VectorTile.js';
7
6
  import { createXYZ } from 'ol/tilegrid.js';
8
7
  import { PMTiles } from 'pmtiles';
9
8
 
10
- import { isAipLayer, layerAlias } from '../../raster/src/shared/layers.js';
11
- import { pmtilesInfo } from '../../raster/src/shared/manifest.js';
12
- import { clampInteger, clampNumber } from '../../raster/src/shared/math.js';
9
+ import { isAipLayer, layerAlias } from '../../core/src/shared/layers.js';
10
+ import { maxZoomExpression, minZoomExpression, zoomInterpolateExpression } from './zoom.js';
13
11
 
14
12
  import {
15
13
  activeLabelLayerIdsForZoom,
@@ -56,11 +54,7 @@ const ROAD_HIGHWAY_DRAW_ORDER = [
56
54
  * style?: string | Record<string, unknown>,
57
55
  * visibleLayers?: string[] | Set<string>,
58
56
  * source?: 'auto' | 'pmtiles' | 'dynamic',
59
- * renderMode?: 'vector' | 'raster-worker',
60
- * workerUrl?: string | URL,
61
- * rasterPixelRatio?: number,
62
- * overzoomLevels?: number,
63
- * edgeGuardPixels?: number,
57
+ * hitDetection?: boolean,
64
58
  * apiBaseUrl?: string,
65
59
  * zIndexBase?: number,
66
60
  * onTileLoadStart?: () => void,
@@ -133,9 +127,6 @@ export async function createMapZeroOpenLayersLayers(options) {
133
127
  onTileLoadError: options.onTileLoadError
134
128
  };
135
129
 
136
- if (options.renderMode === 'raster-worker') {
137
- return createRasterWorkerController(context, options);
138
- }
139
130
 
140
131
  patchWebGlVectorTileRenderer();
141
132
 
@@ -144,6 +135,7 @@ export async function createMapZeroOpenLayersLayers(options) {
144
135
  source,
145
136
  preload: 0,
146
137
  useInterimTilesOnError: false,
138
+ disableHitDetection: options.hitDetection === false,
147
139
  style: createWebGlStyles(context)
148
140
  });
149
141
  tagOpenLayersLayer(layer, instanceId, orderedLayers.map((item) => item.id), 'geometry');
@@ -230,193 +222,6 @@ export async function createMapZeroOpenLayersLayers(options) {
230
222
  * destroy: () => void
231
223
  * }}
232
224
  */
233
- function createRasterWorkerController(context, options) {
234
- if (!isPmtilesManifest(context.manifest)) {
235
- throw new Error('raster-worker render mode requires vector PMTiles');
236
- }
237
-
238
- const worker = new MapZeroRasterTileWorker({
239
- manifest: context.manifest,
240
- manifestUrl: context.manifestUrl,
241
- styleDocument: context.styleDocument,
242
- layers: context.orderedLayers.map((layer) => layer.id),
243
- workerUrl: options.workerUrl ?? new URL('@map-zero/raster/imagery-worker.js', import.meta.url),
244
- rasterPixelRatio: options.rasterPixelRatio,
245
- overzoomLevels: options.overzoomLevels,
246
- edgeGuardPixels: options.edgeGuardPixels
247
- });
248
- worker.setVisibleLayers(context.layerVisibility);
249
- const range = pmtilesZoomRange(context.manifest);
250
- const maxZoom = range.maxZoom + clampInteger(options.overzoomLevels ?? 0, 0, 4);
251
- const rasterPixelRatio = clampNumber(options.rasterPixelRatio ?? 2, 1, 2);
252
- const source = new ImageTileSource({
253
- minZoom: range.minZoom,
254
- maxZoom,
255
- tileGrid: createXYZ({
256
- minZoom: range.minZoom,
257
- maxZoom,
258
- tileSize: 512
259
- }),
260
- tileSize: 512,
261
- transition: 0,
262
- interpolate: true,
263
- zDirection: preferNearestZoomLevel,
264
- wrapX: false,
265
- loader: (z, x, y) => worker.render(z, x, y)
266
- });
267
- source.getTilePixelRatio = () => rasterPixelRatio;
268
- const layer = new TileLayer({
269
- source,
270
- cacheSize: 4096,
271
- preload: 0,
272
- useInterimTilesOnError: false
273
- });
274
- tagOpenLayersLayer(layer, context.instanceId, context.orderedLayers.map((item) => item.id), 'raster');
275
- applyLayerZIndex(layer, context.orderedLayers, context.styleDocument, options.zIndexBase);
276
-
277
- const refresh = () => {
278
- worker.setVisibleLayers(context.layerVisibility);
279
- refreshRasterSource(source);
280
- layer.changed();
281
- };
282
-
283
- return {
284
- id: context.instanceId,
285
- manifest: context.manifest,
286
- style: context.styleDocument,
287
- layers: [layer],
288
- setVisible(layerId, visible) {
289
- if (!context.layerVisibility.has(layerId)) {
290
- throw new Error(`unknown map-zero layer: ${layerId}`);
291
- }
292
-
293
- context.layerVisibility.set(layerId, Boolean(visible));
294
- refresh();
295
- },
296
- setOpacity(layerId, opacity) {
297
- if (!context.layerOpacity?.has?.(layerId)) {
298
- throw new Error(`unknown map-zero layer: ${layerId}`);
299
- }
300
-
301
- layer.setOpacity(clamp(Number(opacity), 0, 1));
302
- },
303
- destroy() {
304
- refreshRasterSource(source);
305
- worker.destroy();
306
- layer.dispose();
307
- }
308
- };
309
- }
310
-
311
- function refreshRasterSource(source) {
312
- source.clear();
313
- source.refresh?.();
314
- }
315
-
316
- class MapZeroRasterTileWorker {
317
- /**
318
- * @param {{
319
- * manifest: Record<string, unknown>,
320
- * manifestUrl: string,
321
- * styleDocument: Record<string, unknown>,
322
- * layers: string[],
323
- * workerUrl: string | URL,
324
- * rasterPixelRatio?: number,
325
- * overzoomLevels?: number,
326
- * edgeGuardPixels?: number
327
- * }} options
328
- */
329
- constructor(options) {
330
- assertRasterWorkerSupport();
331
- this.#rasterPixelRatio = clampNumber(options.rasterPixelRatio ?? 2, 1, 2);
332
- this.worker = new Worker(options.workerUrl, { type: 'module' });
333
- this.worker.addEventListener('message', (event) => this.#handleMessage(event.data));
334
- this.worker.addEventListener('error', (event) => this.#rejectAll(new Error(event.message || 'map-zero OpenLayers raster worker failed')));
335
- this.worker.postMessage({
336
- type: 'init',
337
- options: {
338
- manifest: options.manifest,
339
- manifestUrl: resolveUrl(options.manifestUrl, documentBaseUrl()),
340
- styleDocument: options.styleDocument,
341
- layers: options.layers,
342
- tileSize: 512,
343
- pixelRatio: this.#rasterPixelRatio,
344
- sourceMaximumLevel: pmtilesZoomRange(options.manifest).maxZoom,
345
- overzoomLevels: clampInteger(options.overzoomLevels ?? 0, 0, 4),
346
- edgeGuardPixels: clampInteger(options.edgeGuardPixels ?? 0, 0, 8),
347
- source: String(pmtilesInfo(options.manifest).url ?? 'tiles.pmtiles')
348
- }
349
- });
350
- }
351
-
352
- /**
353
- * @param {number} z
354
- * @param {number} x
355
- * @param {number} y
356
- * @returns {Promise<ImageBitmap | HTMLCanvasElement>}
357
- */
358
- render(z, x, y) {
359
- const id = this.#nextId++;
360
- return new Promise((resolve, reject) => {
361
- this.#pending.set(id, { resolve, reject });
362
- this.worker.postMessage({ type: 'render', id, x, y, z });
363
- }).catch(() => emptyCanvas(512 * this.#rasterPixelRatio, 512 * this.#rasterPixelRatio));
364
- }
365
-
366
- /**
367
- * @param {Map<string, boolean>} visibility
368
- */
369
- setVisibleLayers(visibility) {
370
- for (const [layerId, visible] of visibility) {
371
- this.worker.postMessage({ type: 'visibility', layerId, visible });
372
- }
373
- }
374
-
375
- destroy() {
376
- this.#rejectAll(new Error('map-zero OpenLayers raster worker destroyed'));
377
- this.worker.terminate();
378
- }
379
-
380
- /**
381
- * @param {any} message
382
- */
383
- #handleMessage(message) {
384
- if (message?.type !== 'tile') {
385
- return;
386
- }
387
-
388
- const pending = this.#pending.get(message.id);
389
- if (!pending) {
390
- message.image?.close?.();
391
- return;
392
- }
393
-
394
- this.#pending.delete(message.id);
395
- if (message.error) {
396
- pending.reject(new Error(message.error));
397
- return;
398
- }
399
- pending.resolve(message.image ?? emptyCanvas(512, 512));
400
- }
401
-
402
- /**
403
- * @param {Error} error
404
- */
405
- #rejectAll(error) {
406
- for (const pending of this.#pending.values()) {
407
- pending.reject(error);
408
- }
409
- this.#pending.clear();
410
- }
411
-
412
- /** @type {Worker} */
413
- worker;
414
- #rasterPixelRatio = 2;
415
- #nextId = 1;
416
- /** @type {Map<number, { resolve: (image: ImageBitmap | HTMLCanvasElement) => void, reject: (error: Error) => void }>} */
417
- #pending = new Map();
418
- }
419
-
420
225
  /**
421
226
  * Add map-zero layers to an existing OpenLayers map.
422
227
  *
@@ -577,7 +382,7 @@ function applyLayerZIndex(layer, orderedLayers, styleDocument, zIndexBase) {
577
382
  * @param {unknown} layer
578
383
  * @param {string} instanceId
579
384
  * @param {string[]} layerIds
580
- * @param {'geometry' | 'labels' | 'raster'} role
385
+ * @param {'geometry' | 'labels'} role
581
386
  */
582
387
  function tagOpenLayersLayer(layer, instanceId, layerIds, role) {
583
388
  const namespacedLayerIds = layerIds.map((layerId) => namespaceLayerId(instanceId, layerId));
@@ -876,8 +681,14 @@ function createLabelTileDataLoader(context) {
876
681
  * @returns {PMTiles}
877
682
  */
878
683
  function createPmtilesArchive(context) {
684
+ if (context.archive) return context.archive;
879
685
  const tiles = /** @type {{ url: string }} */ (context.manifest.tiles);
880
- return new PMTiles(resolveUrl(tiles.url, context.manifestBaseUrl));
686
+ const archive = new PMTiles(resolveUrl(tiles.url, context.manifestBaseUrl));
687
+ const cache = new LruCache(128);
688
+ const getZxy = archive.getZxy.bind(archive);
689
+ archive.getZxy = (z, x, y) => cachedPromise(cache, `${z}/${x}/${y}`, () => getZxy(z, x, y));
690
+ context.archive = archive;
691
+ return archive;
881
692
  }
882
693
 
883
694
  /**
@@ -1252,7 +1063,7 @@ function onewayFilter(filter) {
1252
1063
  * @returns {unknown[]}
1253
1064
  */
1254
1065
  function minZoomFilter(filter, zoom) {
1255
- return Number.isFinite(zoom) ? ['all', filter, ['>=', ['zoom'], zoom]] : filter;
1066
+ return Number.isFinite(zoom) ? ['all', filter, minZoomExpression(zoom)] : filter;
1256
1067
  }
1257
1068
 
1258
1069
  /**
@@ -1423,10 +1234,10 @@ function roadOverrideZoomFilter(filter, override) {
1423
1234
  const normalized = normalizeStyleRule(/** @type {Record<string, unknown>} */ (override));
1424
1235
  let next = filter;
1425
1236
  if (Number.isFinite(normalized.minZoom)) {
1426
- next = ['all', next, ['>=', ['zoom'], Number(normalized.minZoom)]];
1237
+ next = ['all', next, minZoomExpression(Number(normalized.minZoom))];
1427
1238
  }
1428
1239
  if (Number.isFinite(normalized.maxZoom)) {
1429
- next = ['all', next, ['<=', ['zoom'], Number(normalized.maxZoom)]];
1240
+ next = ['all', next, maxZoomExpression(Number(normalized.maxZoom))];
1430
1241
  }
1431
1242
  return next;
1432
1243
  }
@@ -1647,11 +1458,11 @@ function createLayerFilter(layerId, rule) {
1647
1458
  ];
1648
1459
 
1649
1460
  if (Number.isFinite(rule.minZoom)) {
1650
- filters.push(['>=', ['zoom'], Number(rule.minZoom)]);
1461
+ filters.push(minZoomExpression(Number(rule.minZoom)));
1651
1462
  }
1652
1463
 
1653
1464
  if (Number.isFinite(rule.maxZoom)) {
1654
- filters.push(['<=', ['zoom'], Number(rule.maxZoom)]);
1465
+ filters.push(maxZoomExpression(Number(rule.maxZoom)));
1655
1466
  }
1656
1467
 
1657
1468
  if (layerId === 'pois') {
@@ -1836,7 +1647,7 @@ function zoomWidthScale(rule) {
1836
1647
  return null;
1837
1648
  }
1838
1649
 
1839
- const expression = ['interpolate', ['linear'], ['zoom']];
1650
+ const validStops = [];
1840
1651
  for (const stop of stops) {
1841
1652
  if (!Array.isArray(stop) || stop.length < 2) {
1842
1653
  continue;
@@ -1845,11 +1656,11 @@ function zoomWidthScale(rule) {
1845
1656
  const zoom = Number(stop[0]);
1846
1657
  const scale = Number(stop[1]);
1847
1658
  if (Number.isFinite(zoom) && Number.isFinite(scale) && scale >= 0) {
1848
- expression.push(zoom, scale);
1659
+ validStops.push([zoom, scale]);
1849
1660
  }
1850
1661
  }
1851
1662
 
1852
- return expression.length > 4 ? expression : null;
1663
+ return zoomInterpolateExpression(validStops);
1853
1664
  }
1854
1665
 
1855
1666
  /**
@@ -2096,38 +1907,6 @@ function isValidTileCoord(z, x, y) {
2096
1907
  return Number.isInteger(x) && Number.isInteger(y) && x >= 0 && y >= 0 && x < maxIndex && y < maxIndex;
2097
1908
  }
2098
1909
 
2099
- /**
2100
- * @param {number} width
2101
- * @param {number} height
2102
- * @returns {HTMLCanvasElement}
2103
- */
2104
- function emptyCanvas(width, height) {
2105
- const canvas = document.createElement('canvas');
2106
- canvas.width = width;
2107
- canvas.height = height;
2108
- return canvas;
2109
- }
2110
-
2111
- function assertRasterWorkerSupport() {
2112
- if (typeof Worker !== 'function' || typeof OffscreenCanvas !== 'function' || typeof createImageBitmap !== 'function') {
2113
- throw new Error('map-zero OpenLayers raster-worker mode requires Worker, OffscreenCanvas, and createImageBitmap');
2114
- }
2115
- }
2116
-
2117
- /**
2118
- * Choose the raster tile zoom at the midpoint in zoom space instead of always
2119
- * forcing the parent or child tile. This keeps fractional zooms sharp without
2120
- * holding low-resolution tiles for too long.
2121
- *
2122
- * @param {number} value
2123
- * @param {number} high
2124
- * @param {number} low
2125
- * @returns {number}
2126
- */
2127
- function preferNearestZoomLevel(value, high, low) {
2128
- return value - low * Math.sqrt(high / low);
2129
- }
2130
-
2131
1910
  /**
2132
1911
  * @param {number} z
2133
1912
  * @param {number} x
package/src/labels.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { LruCache } from '../../core/src/shared/cache.js';
1
2
  import Feature from 'ol/Feature.js';
2
3
  import MVT from 'ol/format/MVT.js';
3
4
  import VectorTileLayer from 'ol/layer/VectorTile.js';
@@ -7,155 +8,39 @@ import Stroke from 'ol/style/Stroke.js';
7
8
  import Style from 'ol/style/Style.js';
8
9
  import Text from 'ol/style/Text.js';
9
10
 
10
- const LABEL_SOURCE_LAYERS = ['roads', 'aip', 'aviation', 'pois'];
11
- const ROAD_SOURCE_LAYER = 'roads';
12
- const AIP_SOURCE_LAYERS = new Set(['aip', 'aviation']);
13
- const POI_SOURCE_LAYER = 'pois';
11
+ import {
12
+ activeLabelLayerIdsForZoom,
13
+ hasEnabledLabels,
14
+ isPoiLikeFeature,
15
+ isSelectedPoiCandidate,
16
+ roadLabel,
17
+ aviationLabel,
18
+ isSelectedPoi,
19
+ poiLabelText,
20
+ priorityClassFromNumber,
21
+ poiPriorityClass,
22
+ poiZIndex,
23
+ labelFont,
24
+ priorityClassRule,
25
+ labelOpacityForZoom,
26
+ cleanText,
27
+ isMeaningfulLabel,
28
+ isExplicitAviationLabel,
29
+ labelRuleForSource,
30
+ zoomInRule,
31
+ resolutionToZoom,
32
+ rgba,
33
+ LABEL_SOURCE_LAYERS,
34
+ ROAD_SOURCE_LAYER,
35
+ POI_SOURCE_LAYER
36
+ } from '../../core/src/labels.js';
37
+ export { activeLabelLayerIdsForZoom, hasEnabledLabels } from '../../core/src/labels.js';
14
38
 
15
- const MAJOR_ROADS = new Set(['motorway', 'motorway_link', 'trunk', 'trunk_link', 'primary', 'primary_link']);
16
- const SECONDARY_ROADS = new Set(['secondary', 'secondary_link']);
17
- const LOCAL_ROADS = new Set(['residential', 'living_street', 'unclassified']);
18
- const DISABLED_ROADS = new Set(['service', 'track', 'path', 'footway', 'cycleway', 'steps', 'corridor', 'platform']);
19
- const LABEL_TEXT_FIELDS = ['name', 'ref', 'iata', 'icao', 'operator', 'official_name', 'short_name'];
20
- const GENERIC_LABEL_VALUES = new Set([
21
- 'yes',
22
- 'no',
23
- 'true',
24
- 'false',
25
- 'unknown',
26
- 'none',
27
- 'generator',
28
- 'tower',
29
- 'line',
30
- 'plant',
31
- 'substation',
32
- 'transformer',
33
- 'mast',
34
- 'antenna',
35
- 'station',
36
- 'airport',
37
- 'aerodrome',
38
- 'runway',
39
- 'taxiway',
40
- 'terminal',
41
- 'apron',
42
- 'pharmacy',
43
- 'fuel',
44
- 'charging_station',
45
- 'hospital',
46
- 'clinic',
47
- 'police',
48
- 'fire_station',
49
- 'fire_hydrant',
50
- 'defibrillator',
51
- 'fire_extinguisher',
52
- 'shelter',
53
- 'railway_station',
54
- 'train_station',
55
- 'subway_station',
56
- 'bus_station',
57
- 'ferry_terminal',
58
- 'townhall',
59
- 'courthouse',
60
- 'prison',
61
- 'bunker',
62
- 'checkpoint',
63
- 'communications_tower',
64
- 'protected_area',
65
- 'nature_reserve',
66
- 'restaurant',
67
- 'cafe',
68
- 'bar',
69
- 'fast_food',
70
- 'pub',
71
- 'shop',
72
- 'retail',
73
- 'commercial',
74
- 'tourism',
75
- 'attraction',
76
- 'hotel',
77
- 'transport',
78
- 'emergency',
79
- 'government',
80
- 'energy',
81
- 'communications',
82
- 'protected',
83
- 'industrial',
84
- 'military',
85
- 'operational',
86
- 'consumer'
87
- ]);
88
- const DEFAULT_POI_CLASSES = {
89
- amenity: [
90
- 'hospital',
91
- 'police',
92
- 'fire_station',
93
- 'bus_station',
94
- 'ferry_terminal',
95
- 'shelter',
96
- 'townhall',
97
- 'courthouse',
98
- 'prison',
99
- 'ranger_station',
100
- 'research_institute',
101
- 'airport',
102
- 'railway_station',
103
- 'train_station',
104
- 'subway_station',
105
- 'communications_tower',
106
- 'bunker',
107
- 'checkpoint',
108
- 'depot',
109
- 'warehouse'
110
- ],
111
- tourism: ['information'],
112
- shop: [],
113
- leisure: ['nature_reserve'],
114
- railway: ['station'],
115
- public_transport: ['station'],
116
- station: ['subway'],
117
- aeroway: ['aerodrome', 'airport'],
118
- power: ['plant', 'substation', 'generator', 'tower', 'transformer', 'line'],
119
- man_made: ['communications_tower', 'mast', 'antenna'],
120
- 'tower:type': ['communication'],
121
- military: ['barracks', 'bunker', 'checkpoint', 'airbase', 'naval_base', 'range', 'training_area', 'base', 'airfield', 'danger_area', 'ammunition_dump'],
122
- emergency: ['ambulance_station', 'siren', 'assembly_point', 'disaster_response'],
123
- office: ['government'],
124
- government: ['yes', 'public_safety', 'border_control', 'customs', 'ministry', 'aerospace'],
125
- boundary: ['protected_area'],
126
- industrial: ['refinery', 'depot', 'storage', 'logistics'],
127
- landuse: ['industrial']
128
- };
129
-
130
- /**
131
- * Tiny full-label bitmap atlas kept for future point-label experiments.
132
- */
133
- export class LabelAtlas {
134
- constructor() {
135
- this.map = new Map();
136
- }
137
- }
138
-
139
- /**
140
- * Create a standard OpenLayers vector tile label layer.
141
- *
142
- * Geometry rendering stays on the existing WebGLVectorTileLayer. This layer is
143
- * only for labels, using OpenLayers text placement and decluttering.
144
- *
145
- * @param {{
146
- * instanceId?: string,
147
- * tileUrlFunction: (tileCoord: number[] | null) => string | undefined,
148
- * loadTileData: (tileCoord: number[], url: string | undefined) => Promise<ArrayBuffer | Uint8Array | null>,
149
- * sourceOptions?: Record<string, unknown>,
150
- * styleDocument: Record<string, unknown>,
151
- * onTileLoadStart?: () => void,
152
- * onTileLoadEnd?: () => void,
153
- * onTileLoadError?: () => void
154
- * }} options
155
- * @returns {{ layer: VectorTileLayer, source: VectorTileSource, attachMap: () => void, detachMap: () => void, refresh: () => void, destroy: () => void }}
156
- */
157
39
  export function createMapZeroLabelLayer(options) {
158
- const format = new MVT({ featureClass: Feature });
40
+ // The geometry layer already decodes every map layer. Labels only need these
41
+ // source layers, so do not create Canvas features for buildings, water or
42
+ // other non-label data a second time.
43
+ const format = new MVT({ featureClass: Feature, layers: LABEL_SOURCE_LAYERS });
159
44
  const source = new VectorTileSource({
160
45
  format,
161
46
  maxZoom: 22,
@@ -196,7 +81,7 @@ export function createMapZeroLabelLayer(options) {
196
81
  if (options.onTileLoadEnd) source.on('tileloadend', options.onTileLoadEnd);
197
82
  if (options.onTileLoadError) source.on('tileloaderror', options.onTileLoadError);
198
83
 
199
- const styleCache = new Map();
84
+ const styleCache = new LruCache(2048);
200
85
  const layer = new VectorTileLayer({
201
86
  source,
202
87
  declutter: true,
@@ -222,60 +107,13 @@ export function createMapZeroLabelLayer(options) {
222
107
  layer.changed();
223
108
  },
224
109
  destroy() {
110
+ styleCache.clear();
225
111
  source.clear();
226
112
  layer.dispose();
227
113
  }
228
114
  };
229
115
  }
230
116
 
231
- /**
232
- * Return source MVT layer ids needed by the label renderer.
233
- *
234
- * @param {Array<{ id: string, type?: string, style?: string }>} orderedLayers
235
- * @param {Record<string, unknown>} styleDocument
236
- * @param {Map<string, boolean>} layerVisibility
237
- * @param {number} zoom
238
- * @returns {string[]}
239
- */
240
- export function activeLabelLayerIdsForZoom(orderedLayers, styleDocument, layerVisibility, zoom) {
241
- const labels = labelConfigRoot(styleDocument);
242
- if (!labels || labels.enabled === false) {
243
- return [];
244
- }
245
-
246
- return LABEL_SOURCE_LAYERS.filter((layerId) => {
247
- const rule = labelRuleForSource(styleDocument, layerId);
248
- if (!rule || rule.enabled === false) {
249
- return false;
250
- }
251
-
252
- if (!orderedLayers.some((layer) => layer.id === layerId) || !layerVisibility.get(layerId)) {
253
- return false;
254
- }
255
-
256
- return zoomInRule(zoom, rule);
257
- });
258
- }
259
-
260
- /**
261
- * @param {Record<string, unknown>} styleDocument
262
- * @returns {boolean}
263
- */
264
- export function hasEnabledLabels(styleDocument) {
265
- const labels = labelConfigRoot(styleDocument);
266
- return Boolean(labels && labels.enabled !== false && LABEL_SOURCE_LAYERS.some((layerId) => {
267
- const rule = labelRuleForSource(styleDocument, layerId);
268
- return rule && rule.enabled !== false;
269
- }));
270
- }
271
-
272
- /**
273
- * @param {Feature} feature
274
- * @param {number} resolution
275
- * @param {Record<string, unknown>} styleDocument
276
- * @param {Map<string, Style>} styleCache
277
- * @returns {Style | null}
278
- */
279
117
  function labelStyle(feature, resolution, styleDocument, styleCache) {
280
118
  const zoom = resolutionToZoom(resolution);
281
119
  const sourceLayer = cleanText(feature.get('sourceLayer'));
@@ -298,42 +136,6 @@ function labelStyle(feature, resolution, styleDocument, styleCache) {
298
136
  return null;
299
137
  }
300
138
 
301
- /**
302
- * @param {Feature} feature
303
- * @returns {boolean}
304
- */
305
- function isPoiLikeFeature(feature) {
306
- return [
307
- 'poi_category',
308
- 'amenity',
309
- 'tourism',
310
- 'shop',
311
- 'leisure',
312
- 'railway',
313
- 'public_transport',
314
- 'station',
315
- 'power',
316
- 'man_made',
317
- 'tower:type',
318
- 'military',
319
- 'emergency',
320
- 'office',
321
- 'government',
322
- 'boundary',
323
- 'protect_class',
324
- 'landuse',
325
- 'industrial'
326
- ].some((property) => cleanText(feature.get(property)));
327
- }
328
-
329
- /**
330
- * @param {Feature} feature
331
- * @param {string} sourceLayer
332
- * @param {number} zoom
333
- * @param {Record<string, unknown>} styleDocument
334
- * @param {Map<string, Style>} styleCache
335
- * @returns {Style | null}
336
- */
337
139
  function candidateLabelStyle(feature, sourceLayer, zoom, styleDocument, styleCache) {
338
140
  const rule = labelRuleForSource(styleDocument, sourceLayer);
339
141
  const minZoom = Number(feature.get('minZoom') ?? rule?.minZoom ?? 0);
@@ -362,28 +164,6 @@ function candidateLabelStyle(feature, sourceLayer, zoom, styleDocument, styleCac
362
164
  });
363
165
  }
364
166
 
365
- /**
366
- * @param {Feature} feature
367
- * @param {Record<string, unknown>} rule
368
- * @returns {boolean}
369
- */
370
- function isSelectedPoiCandidate(feature, rule) {
371
- const className = cleanText(feature.get('class'));
372
- if (!className || ['clinic', 'pharmacy', 'fuel', 'charging_station', 'fire_hydrant', 'defibrillator', 'fire_extinguisher', 'restaurant', 'cafe', 'bar', 'fast_food', 'pub'].includes(className)) {
373
- return false;
374
- }
375
-
376
- const selected = objectRule(rule.classes) ?? DEFAULT_POI_CLASSES;
377
- return Object.values(selected).some((values) => Array.isArray(values) && values.map(String).includes(className));
378
- }
379
-
380
- /**
381
- * @param {Feature} feature
382
- * @param {number} zoom
383
- * @param {Record<string, unknown>} styleDocument
384
- * @param {Map<string, Style>} styleCache
385
- * @returns {Style | null}
386
- */
387
167
  function roadLabelStyle(feature, zoom, styleDocument, styleCache) {
388
168
  const rule = labelRuleForSource(styleDocument, ROAD_SOURCE_LAYER);
389
169
  if (!rule || rule.enabled === false || !zoomInRule(zoom, rule)) {
@@ -408,13 +188,6 @@ function roadLabelStyle(feature, zoom, styleDocument, styleCache) {
408
188
  });
409
189
  }
410
190
 
411
- /**
412
- * @param {Feature} feature
413
- * @param {number} zoom
414
- * @param {Record<string, unknown>} styleDocument
415
- * @param {Map<string, Style>} styleCache
416
- * @returns {Style | null}
417
- */
418
191
  function aviationLabelStyle(feature, zoom, styleDocument, styleCache) {
419
192
  const rule = labelRuleForSource(styleDocument, 'aip');
420
193
  if (!rule || rule.enabled === false || !zoomInRule(zoom, rule)) {
@@ -439,13 +212,6 @@ function aviationLabelStyle(feature, zoom, styleDocument, styleCache) {
439
212
  });
440
213
  }
441
214
 
442
- /**
443
- * @param {Feature} feature
444
- * @param {number} zoom
445
- * @param {Record<string, unknown>} styleDocument
446
- * @param {Map<string, Style>} styleCache
447
- * @returns {Style | null}
448
- */
449
215
  function poiLabelStyle(feature, zoom, styleDocument, styleCache) {
450
216
  const rule = labelRuleForSource(styleDocument, POI_SOURCE_LAYER);
451
217
  if (!rule || rule.enabled === false || !zoomInRule(zoom, rule) || !isSelectedPoi(feature, rule)) {
@@ -469,107 +235,6 @@ function poiLabelStyle(feature, zoom, styleDocument, styleCache) {
469
235
  });
470
236
  }
471
237
 
472
- /**
473
- * @param {Feature} feature
474
- * @param {string} highway
475
- * @param {number} zoom
476
- * @param {Record<string, unknown>} rule
477
- * @returns {{ text: string, priorityClass: string, zIndex: number } | null}
478
- */
479
- function roadLabel(feature, highway, zoom, rule) {
480
- if (!highway || DISABLED_ROADS.has(highway)) {
481
- return null;
482
- }
483
-
484
- const local = objectRule(rule.local);
485
- if (LOCAL_ROADS.has(highway) && local?.enabled !== true) {
486
- return null;
487
- }
488
-
489
- const ref = cleanText(feature.get('ref'));
490
- const name = cleanText(feature.get('name'));
491
-
492
- if (MAJOR_ROADS.has(highway)) {
493
- const minZoom = Number(rule.majorMinZoom ?? 12);
494
- if (zoom < minZoom || !isMeaningfulLabel(ref)) return null;
495
- return { text: ref, priorityClass: 'important', zIndex: 820 };
496
- }
497
-
498
- if (SECONDARY_ROADS.has(highway)) {
499
- const minZoom = Number(rule.secondaryMinZoom ?? 16);
500
- if (zoom < minZoom || !isMeaningfulLabel(ref)) return null;
501
- return { text: ref, priorityClass: 'normal', zIndex: 700 };
502
- }
503
-
504
- if (LOCAL_ROADS.has(highway)) {
505
- const minZoom = Number(local?.minZoom ?? 17);
506
- if (zoom < minZoom || !isMeaningfulLabel(name)) return null;
507
- return { text: name, priorityClass: 'low', zIndex: 400 };
508
- }
509
-
510
- return null;
511
- }
512
-
513
- /**
514
- * @param {Feature} feature
515
- * @param {string} aeroway
516
- * @param {number} zoom
517
- * @returns {{ text: string, placement: 'line' | 'point', priorityClass: string, zIndex: number } | null}
518
- */
519
- function aviationLabel(feature, aeroway, zoom) {
520
- if (!aeroway) {
521
- return null;
522
- }
523
-
524
- const ref = cleanText(feature.get('ref'));
525
- const name = cleanText(feature.get('name'));
526
-
527
- if (aeroway === 'aerodrome' || aeroway === 'heliport') {
528
- const text = name || aviationRefText(ref) || aviationFallbackText(aeroway);
529
- return isRenderableAviationText(text, aeroway) && zoom >= 9
530
- ? { text, placement: 'point', priorityClass: 'critical', zIndex: 980 }
531
- : null;
532
- }
533
-
534
- if (aeroway === 'runway') {
535
- const text = aviationRefText(ref) || name || 'RWY';
536
- return isRenderableAviationText(text, aeroway) && zoom >= 11 ? { text, placement: 'line', priorityClass: 'important', zIndex: 900 } : null;
537
- }
538
-
539
- if (aeroway === 'terminal' || aeroway === 'apron') {
540
- const text = name || ref || aviationFallbackText(aeroway);
541
- return isMeaningfulLabel(text) && zoom >= 14 ? { text, placement: 'point', priorityClass: 'normal', zIndex: 720 } : null;
542
- }
543
-
544
- if (aeroway === 'helipad') {
545
- const text = aviationRefText(ref) || name || 'H';
546
- return isRenderableAviationText(text, aeroway) && zoom >= 12 ? { text, placement: 'point', priorityClass: 'critical', zIndex: 980 } : null;
547
- }
548
-
549
- return null;
550
- }
551
-
552
- function aviationFallbackText(aeroway) {
553
- if (aeroway === 'aerodrome') return 'Aerodrome';
554
- if (aeroway === 'heliport') return 'Heliport';
555
- if (aeroway === 'terminal') return 'Terminal';
556
- if (aeroway === 'apron') return 'Apron';
557
- return cleanText(aeroway).replace(/_/g, ' ');
558
- }
559
-
560
- /**
561
- * @param {{
562
- * cache: Map<string, Style>,
563
- * styleDocument: Record<string, unknown>,
564
- * rule: Record<string, unknown>,
565
- * text: string,
566
- * placement: 'line' | 'point',
567
- * priorityClass: string,
568
- * zIndex: number,
569
- * zoom: number
570
- * }} options
571
- * @returns {Style}
572
- */
573
238
  function textStyle(options) {
574
239
  const priorityRule = priorityClassRule(options.styleDocument, options.priorityClass);
575
240
  const opacity = labelOpacityForZoom(options.zoom, Number(priorityRule.opacity ?? options.rule.opacity ?? 0.82));
@@ -606,460 +271,3 @@ function textStyle(options) {
606
271
  options.cache.set(key, style);
607
272
  return style;
608
273
  }
609
-
610
- /**
611
- * @param {Feature} feature
612
- * @param {Record<string, unknown>} rule
613
- * @returns {boolean}
614
- */
615
- function isSelectedPoi(feature, rule) {
616
- const categories = objectRule(rule.categories);
617
- const category = poiCategoryForFeature(feature);
618
- if (category && categories?.[category] === false) {
619
- return false;
620
- }
621
-
622
- const matchesClass = matchesSelectedPoiClass(feature, rule);
623
- return matchesClass;
624
- }
625
-
626
- /**
627
- * @param {Feature} feature
628
- * @param {Record<string, unknown>} rule
629
- * @returns {boolean}
630
- */
631
- function matchesSelectedPoiClass(feature, rule) {
632
- const selected = objectRule(rule.classes) ?? DEFAULT_POI_CLASSES;
633
- for (const property of [
634
- 'amenity',
635
- 'tourism',
636
- 'shop',
637
- 'leisure',
638
- 'railway',
639
- 'public_transport',
640
- 'station',
641
- 'aeroway',
642
- 'power',
643
- 'man_made',
644
- 'tower:type',
645
- 'military',
646
- 'emergency',
647
- 'office',
648
- 'government',
649
- 'boundary',
650
- 'protect_class',
651
- 'landuse',
652
- 'industrial'
653
- ]) {
654
- const value = String(feature.get(property) ?? '');
655
- const allowed = Array.isArray(selected[property]) ? /** @type {unknown[]} */ (selected[property]).map(String) : [];
656
- if (value && allowed.includes(value)) {
657
- return true;
658
- }
659
- }
660
- return false;
661
- }
662
-
663
- /**
664
- * @param {Feature} feature
665
- * @returns {string}
666
- */
667
- function poiLabelText(feature) {
668
- for (const field of LABEL_TEXT_FIELDS) {
669
- const text = cleanText(feature.get(field));
670
- if (isMeaningfulLabel(text)) {
671
- return text;
672
- }
673
- }
674
-
675
- return poiFallbackLabel(feature);
676
- }
677
-
678
- function poiFallbackLabel(feature) {
679
- const candidates = [
680
- ['military', {
681
- airbase: 'Airbase',
682
- airfield: 'Airfield',
683
- barracks: 'Barracks',
684
- bunker: 'Bunker',
685
- checkpoint: 'Checkpoint',
686
- naval_base: 'Naval base',
687
- range: 'Range',
688
- training_area: 'Training area',
689
- danger_area: 'Danger area',
690
- ammunition_dump: 'Ammunition dump',
691
- base: 'Base'
692
- }],
693
- ['emergency', {
694
- ambulance_station: 'Ambulance station',
695
- siren: 'Siren',
696
- assembly_point: 'Assembly point',
697
- disaster_response: 'Disaster response'
698
- }],
699
- ['amenity', {
700
- hospital: 'Hospital',
701
- police: 'Police',
702
- fire_station: 'Fire station',
703
- shelter: 'Shelter',
704
- bunker: 'Bunker',
705
- checkpoint: 'Checkpoint',
706
- depot: 'Depot',
707
- warehouse: 'Warehouse',
708
- prison: 'Prison',
709
- courthouse: 'Courthouse',
710
- townhall: 'Town hall',
711
- bus_station: 'Bus station',
712
- ferry_terminal: 'Ferry terminal'
713
- }],
714
- ['power', {
715
- plant: 'Power plant',
716
- substation: 'Substation',
717
- generator: 'Generator',
718
- tower: 'Power tower',
719
- transformer: 'Transformer',
720
- line: 'Power line'
721
- }],
722
- ['man_made', {
723
- communications_tower: 'Comms tower',
724
- mast: 'Mast',
725
- antenna: 'Antenna'
726
- }],
727
- ['boundary', {
728
- protected_area: 'Protected area'
729
- }],
730
- ['landuse', {
731
- industrial: 'Industrial'
732
- }],
733
- ['industrial', {
734
- refinery: 'Refinery',
735
- depot: 'Depot',
736
- storage: 'Storage',
737
- logistics: 'Logistics'
738
- }]
739
- ];
740
-
741
- for (const [property, labels] of candidates) {
742
- const value = cleanText(feature.get(property));
743
- if (value && labels[value]) {
744
- return labels[value];
745
- }
746
- }
747
-
748
- const category = poiCategoryForFeature(feature);
749
- return category === 'consumer' ? '' : titleCase(category);
750
- }
751
-
752
- /**
753
- * @param {Feature} feature
754
- * @returns {string}
755
- */
756
- function poiCategoryForFeature(feature) {
757
- const existing = cleanText(feature.get('poi_category'));
758
- if (existing) {
759
- return existing;
760
- }
761
-
762
- const amenity = cleanText(feature.get('amenity'));
763
- const tourism = cleanText(feature.get('tourism'));
764
- const shop = cleanText(feature.get('shop'));
765
- const leisure = cleanText(feature.get('leisure'));
766
- const railway = cleanText(feature.get('railway'));
767
- const publicTransport = cleanText(feature.get('public_transport'));
768
- const station = cleanText(feature.get('station'));
769
- const aeroway = cleanText(feature.get('aeroway'));
770
- const power = cleanText(feature.get('power'));
771
- const manMade = cleanText(feature.get('man_made'));
772
- const towerType = cleanText(feature.get('tower:type'));
773
- const military = cleanText(feature.get('military'));
774
- const emergency = cleanText(feature.get('emergency'));
775
- const office = cleanText(feature.get('office'));
776
- const government = cleanText(feature.get('government'));
777
- const boundary = cleanText(feature.get('boundary'));
778
- const protectClass = cleanText(feature.get('protect_class'));
779
- const landuse = cleanText(feature.get('landuse'));
780
- const industrial = cleanText(feature.get('industrial'));
781
-
782
- if (['railway_station', 'train_station', 'subway_station', 'bus_station', 'ferry_terminal', 'airport'].includes(amenity) ||
783
- railway === 'station' || publicTransport === 'station' || station === 'subway' || aeroway === 'aerodrome' || aeroway === 'airport') {
784
- return 'transport';
785
- }
786
- if (['hospital', 'police', 'fire_station', 'shelter'].includes(amenity) || ['ambulance_station', 'siren', 'assembly_point', 'disaster_response'].includes(emergency)) return 'emergency';
787
- if (['townhall', 'courthouse', 'prison', 'embassy'].includes(amenity) || office === 'government' || government) return 'government';
788
- if (power) return 'energy';
789
- if (amenity === 'communications_tower' || manMade === 'communications_tower' || manMade === 'mast' || manMade === 'antenna' || towerType === 'communication') return 'communications';
790
- if (boundary === 'protected_area' || leisure === 'nature_reserve' || tourism === 'national_park' || protectClass) return 'protected';
791
- if (landuse === 'industrial' || industrial || ['depot', 'warehouse'].includes(amenity)) return 'industrial';
792
- if (military || ['bunker', 'checkpoint'].includes(amenity)) return 'military';
793
- if (shop || tourism || leisure || ['restaurant', 'cafe', 'bar', 'fast_food', 'pub'].includes(amenity)) return 'consumer';
794
- return 'operational';
795
- }
796
-
797
- /**
798
- * @param {number} priority
799
- * @returns {string}
800
- */
801
- function priorityClassFromNumber(priority) {
802
- if (priority >= 900) return 'critical';
803
- if (priority >= 760) return 'important';
804
- if (priority >= 500) return 'normal';
805
- return 'low';
806
- }
807
-
808
- /**
809
- * @param {Feature} feature
810
- * @returns {string}
811
- */
812
- function poiPriorityClass(feature) {
813
- const category = String(feature.get('poi_category') ?? '');
814
- if (category === 'emergency' || category === 'military') {
815
- return 'critical';
816
- }
817
- if (['transport', 'energy', 'communications', 'government'].includes(category)) {
818
- return 'important';
819
- }
820
-
821
- const amenity = String(feature.get('amenity') ?? '');
822
- if (amenity === 'hospital' || amenity === 'police' || amenity === 'fire_station') {
823
- return 'critical';
824
- }
825
- return 'normal';
826
- }
827
-
828
- /**
829
- * @param {Feature} feature
830
- * @returns {number}
831
- */
832
- function poiZIndex(feature) {
833
- const priority = poiPriorityClass(feature);
834
- if (priority === 'critical') return 920;
835
- if (priority === 'important') return 820;
836
- return 620;
837
- }
838
-
839
- /**
840
- * @param {Record<string, unknown>} rule
841
- * @param {Record<string, unknown>} priorityRule
842
- * @param {number} zoom
843
- * @returns {string}
844
- */
845
- function labelFont(rule, priorityRule, zoom) {
846
- const configured = String(priorityRule.font ?? rule.font ?? '').trim();
847
- if (configured) {
848
- return configured;
849
- }
850
-
851
- const weight = String(priorityRule.weight ?? rule.weight ?? 600);
852
- const size = zoom >= 17 ? 12 : zoom >= 15 ? 11 : 10;
853
- return `${weight} ${size}px sans-serif`;
854
- }
855
-
856
- /**
857
- * @param {Record<string, unknown>} styleDocument
858
- * @param {string} className
859
- * @returns {Record<string, unknown>}
860
- */
861
- function priorityClassRule(styleDocument, className) {
862
- const labels = labelConfigRoot(styleDocument);
863
- const classes = labels?.priorityClasses && typeof labels.priorityClasses === 'object'
864
- ? /** @type {Record<string, unknown>} */ (labels.priorityClasses)
865
- : {};
866
- const rule = classes[className];
867
- return rule && typeof rule === 'object' ? /** @type {Record<string, unknown>} */ (rule) : {};
868
- }
869
-
870
- /**
871
- * @param {number} zoom
872
- * @param {number} base
873
- * @returns {number}
874
- */
875
- function labelOpacityForZoom(zoom, base) {
876
- if (zoom < 12.5) return base * 0.6;
877
- if (zoom < 14) return base * 0.76;
878
- return base;
879
- }
880
-
881
- /**
882
- * @param {unknown} value
883
- * @returns {string}
884
- */
885
- function cleanText(value) {
886
- return String(value ?? '').replace(/\s+/g, ' ').trim();
887
- }
888
-
889
- function titleCase(value) {
890
- return cleanText(value)
891
- .replace(/_/g, ' ')
892
- .replace(/\b\w/g, (char) => char.toUpperCase());
893
- }
894
-
895
- /**
896
- * @param {string} text
897
- * @returns {boolean}
898
- */
899
- function isMeaningfulLabel(text) {
900
- const normalized = cleanText(text);
901
- if (normalized.length < 2) {
902
- return false;
903
- }
904
-
905
- const key = normalized.toLowerCase().replace(/\s+/g, '_');
906
- if (GENERIC_LABEL_VALUES.has(key)) {
907
- return false;
908
- }
909
-
910
- if (/^(yes|no|true|false|0|1)$/i.test(normalized)) {
911
- return false;
912
- }
913
-
914
- return true;
915
- }
916
-
917
- /**
918
- * @param {Feature} feature
919
- * @param {string} sourceLayer
920
- * @param {string} text
921
- * @returns {boolean}
922
- */
923
- function isExplicitAviationLabel(feature, sourceLayer, text) {
924
- if (!AIP_SOURCE_LAYERS.has(sourceLayer)) {
925
- return false;
926
- }
927
-
928
- const aeroway = cleanText(feature.get('aeroway') || feature.get('class'));
929
- if (text === 'H') {
930
- return aeroway === 'helipad' || aeroway === 'heliport';
931
- }
932
-
933
- if (text === 'RWY') {
934
- return aeroway === 'runway';
935
- }
936
-
937
- return /^[A-Z]$/.test(text) && ['helipad', 'heliport', 'runway'].includes(aeroway);
938
- }
939
-
940
- /**
941
- * @param {string} ref
942
- * @returns {string}
943
- */
944
- function aviationRefText(ref) {
945
- return ref && !/^(yes|no|true|false|0|1)$/i.test(ref) ? ref : '';
946
- }
947
-
948
- /**
949
- * @param {string} text
950
- * @param {string} aeroway
951
- * @returns {boolean}
952
- */
953
- function isRenderableAviationText(text, aeroway) {
954
- if (isMeaningfulLabel(text)) {
955
- return true;
956
- }
957
-
958
- if (text === 'H') {
959
- return aeroway === 'helipad' || aeroway === 'heliport';
960
- }
961
-
962
- if (text === 'RWY') {
963
- return aeroway === 'runway';
964
- }
965
-
966
- return /^[A-Z]$/.test(text) && ['helipad', 'heliport', 'runway'].includes(aeroway);
967
- }
968
-
969
- /**
970
- * @param {Record<string, unknown>} styleDocument
971
- * @returns {Record<string, unknown> | null}
972
- */
973
- function labelConfigRoot(styleDocument) {
974
- return styleDocument.labels && typeof styleDocument.labels === 'object'
975
- ? /** @type {Record<string, unknown>} */ (styleDocument.labels)
976
- : null;
977
- }
978
-
979
- /**
980
- * @param {Record<string, unknown>} styleDocument
981
- * @param {string} sourceLayer
982
- * @returns {Record<string, unknown> | null}
983
- */
984
- function labelRuleForSource(styleDocument, sourceLayer) {
985
- const labels = labelConfigRoot(styleDocument);
986
- const rule = labels?.[sourceLayer] ?? labels?.[labelSourceAlias(sourceLayer)];
987
- return rule && typeof rule === 'object' ? /** @type {Record<string, unknown>} */ (rule) : null;
988
- }
989
-
990
- /**
991
- * @param {string} sourceLayer
992
- * @returns {string}
993
- */
994
- function labelSourceAlias(sourceLayer) {
995
- if (sourceLayer === 'aip') return 'aviation';
996
- if (sourceLayer === 'aviation') return 'aip';
997
- return sourceLayer;
998
- }
999
-
1000
- /**
1001
- * @param {unknown} rule
1002
- * @returns {Record<string, unknown> | null}
1003
- */
1004
- function objectRule(rule) {
1005
- return rule && typeof rule === 'object' ? /** @type {Record<string, unknown>} */ (rule) : null;
1006
- }
1007
-
1008
- /**
1009
- * @param {number} zoom
1010
- * @param {Record<string, unknown>} rule
1011
- * @returns {boolean}
1012
- */
1013
- function zoomInRule(zoom, rule) {
1014
- const minZoom = Number(rule.minZoom ?? 0);
1015
- const maxZoom = Number(rule.maxZoom ?? 22);
1016
- return (!Number.isFinite(minZoom) || zoom >= minZoom) && (!Number.isFinite(maxZoom) || zoom <= maxZoom);
1017
- }
1018
-
1019
- /**
1020
- * @param {number} resolution
1021
- * @returns {number}
1022
- */
1023
- function resolutionToZoom(resolution) {
1024
- const initialResolution = 156543.03392804097;
1025
- return Math.log2(initialResolution / Number(resolution));
1026
- }
1027
-
1028
- /**
1029
- * @param {string} color
1030
- * @param {number} opacity
1031
- * @returns {string}
1032
- */
1033
- function rgba(color, opacity) {
1034
- const rgb = hexToRgb(color);
1035
- if (!rgb) {
1036
- return color;
1037
- }
1038
- return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(opacity, 0, 1)})`;
1039
- }
1040
-
1041
- /**
1042
- * @param {string} color
1043
- * @returns {[number, number, number] | null}
1044
- */
1045
- function hexToRgb(color) {
1046
- const match = /^#?([0-9a-f]{6})$/i.exec(color);
1047
- if (!match) {
1048
- return null;
1049
- }
1050
- const value = Number.parseInt(match[1], 16);
1051
- return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
1052
- }
1053
-
1054
- /**
1055
- * @param {number} value
1056
- * @param {number} min
1057
- * @param {number} max
1058
- * @returns {number}
1059
- */
1060
- function clamp(value, min, max) {
1061
- if (!Number.isFinite(value)) {
1062
- return min;
1063
- }
1064
- return Math.max(min, Math.min(max, value));
1065
- }
package/src/zoom.js ADDED
@@ -0,0 +1,36 @@
1
+ const WEB_MERCATOR_RESOLUTION_AT_ZOOM_0 = 156543.03392804097;
2
+
3
+ /**
4
+ * The WebGL style compiler exposes resolution in all supported render paths.
5
+ * Convert the standard Web Mercator zoom thresholds used by map-zero styles.
6
+ *
7
+ * @param {number} zoom
8
+ * @returns {number}
9
+ */
10
+ export function resolutionForZoom(zoom) {
11
+ return WEB_MERCATOR_RESOLUTION_AT_ZOOM_0 / (2 ** zoom);
12
+ }
13
+
14
+ /** @param {number} zoom @returns {unknown[]} */
15
+ export function minZoomExpression(zoom) {
16
+ return ['<=', ['resolution'], resolutionForZoom(zoom)];
17
+ }
18
+
19
+ /** @param {number} zoom @returns {unknown[]} */
20
+ export function maxZoomExpression(zoom) {
21
+ return ['>=', ['resolution'], resolutionForZoom(zoom)];
22
+ }
23
+
24
+ /**
25
+ * @param {Array<[number, number]>} stops
26
+ * @returns {unknown[] | null}
27
+ */
28
+ export function zoomInterpolateExpression(stops) {
29
+ const entries = stops
30
+ .filter(([zoom, value]) => Number.isFinite(zoom) && Number.isFinite(value))
31
+ .map(([zoom, value]) => ({ resolution: resolutionForZoom(zoom), value }))
32
+ .sort((left, right) => left.resolution - right.resolution);
33
+
34
+ if (entries.length < 2) return null;
35
+ return ['interpolate', ['linear'], ['resolution'], ...entries.flatMap(({ resolution, value }) => [resolution, value])];
36
+ }