map-zero 0.1.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +220 -0
  4. package/docs/api.md +66 -0
  5. package/docs/architecture.md +87 -0
  6. package/docs/cartography.md +77 -0
  7. package/docs/cesium.md +107 -0
  8. package/docs/openlayers.md +98 -0
  9. package/docs/styles.md +103 -0
  10. package/package.json +51 -0
  11. package/packages/cesium/package.json +13 -0
  12. package/packages/cesium/src/index.js +405 -0
  13. package/packages/ol/package.json +14 -0
  14. package/packages/ol/src/index.js +1705 -0
  15. package/packages/ol/src/labels.js +977 -0
  16. package/src/3dtiles/b3dm.js +38 -0
  17. package/src/3dtiles/clipper-surfaces.js +317 -0
  18. package/src/3dtiles/export.js +768 -0
  19. package/src/3dtiles/extrude.js +301 -0
  20. package/src/3dtiles/flat.js +531 -0
  21. package/src/3dtiles/glb.js +178 -0
  22. package/src/3dtiles/gpkg-buildings.js +240 -0
  23. package/src/3dtiles/gpkg-features.js +157 -0
  24. package/src/3dtiles/tileset.js +75 -0
  25. package/src/build.js +134 -0
  26. package/src/cli.js +656 -0
  27. package/src/export-pmtiles.js +962 -0
  28. package/src/geometry-read.js +50 -0
  29. package/src/gpkg-read.js +460 -0
  30. package/src/gpkg.js +567 -0
  31. package/src/html.js +593 -0
  32. package/src/layers.js +357 -0
  33. package/src/manifest.js +29 -0
  34. package/src/mvt.js +2593 -0
  35. package/src/ol.js +5 -0
  36. package/src/osm.js +2110 -0
  37. package/src/pmtiles-worker.js +70 -0
  38. package/src/pmtiles.js +260 -0
  39. package/src/server.js +720 -0
  40. package/src/style-command.js +78 -0
  41. package/src/style-filters.js +76 -0
  42. package/src/style-presets.js +93 -0
  43. package/src/style-themes.js +235 -0
  44. package/src/style.js +13 -0
  45. package/src/tile-cache.js +59 -0
  46. package/src/utils.js +222 -0
  47. package/styles/presets/light.json +4655 -0
  48. package/styles/presets/monochrome.json +4655 -0
  49. package/styles/presets/neon-dark-3d.json +90 -0
  50. package/styles/presets/neon-dark.json +4690 -0
  51. package/styles/presets/tactical.json +4690 -0
  52. package/styles/themes/neon-dark.theme.json +20 -0
@@ -0,0 +1,977 @@
1
+ import Feature from 'ol/Feature.js';
2
+ import MVT from 'ol/format/MVT.js';
3
+ import VectorTileLayer from 'ol/layer/VectorTile.js';
4
+ import VectorTileSource from 'ol/source/VectorTile.js';
5
+ import Fill from 'ol/style/Fill.js';
6
+ import Stroke from 'ol/style/Stroke.js';
7
+ import Style from 'ol/style/Style.js';
8
+ import Text from 'ol/style/Text.js';
9
+
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';
14
+
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
+ export function createMapZeroLabelLayer(options) {
158
+ const format = new MVT({ featureClass: Feature });
159
+ const source = new VectorTileSource({
160
+ format,
161
+ maxZoom: 22,
162
+ ...(options.sourceOptions ?? {}),
163
+ cacheSize: 512,
164
+ transition: 0,
165
+ wrapX: false,
166
+ tileUrlFunction: options.tileUrlFunction,
167
+ tileLoadFunction: (tile) => {
168
+ tile.setLoader((extent, resolution, projection) => {
169
+ const tileCoord = tile.getTileCoord();
170
+ const url = options.tileUrlFunction(tileCoord);
171
+ if (!url) {
172
+ tile.setFeatures([]);
173
+ return;
174
+ }
175
+
176
+ options.loadTileData(tileCoord, url)
177
+ .then((data) => {
178
+ if (!data) {
179
+ tile.setFeatures([]);
180
+ return;
181
+ }
182
+
183
+ tile.setFeatures(format.readFeatures(data, {
184
+ extent,
185
+ featureProjection: projection
186
+ }));
187
+ })
188
+ .catch(() => {
189
+ tile.setFeatures([]);
190
+ });
191
+ });
192
+ }
193
+ });
194
+
195
+ if (options.onTileLoadStart) source.on('tileloadstart', options.onTileLoadStart);
196
+ if (options.onTileLoadEnd) source.on('tileloadend', options.onTileLoadEnd);
197
+ if (options.onTileLoadError) source.on('tileloaderror', options.onTileLoadError);
198
+
199
+ const styleCache = new Map();
200
+ const layer = new VectorTileLayer({
201
+ source,
202
+ declutter: true,
203
+ updateWhileAnimating: false,
204
+ updateWhileInteracting: false,
205
+ style: (feature, resolution) => labelStyle(feature, resolution, options.styleDocument, styleCache)
206
+ });
207
+ if (typeof layer.set === 'function' && options.instanceId) {
208
+ layer.set('mapzero:id', options.instanceId);
209
+ layer.set('mapzero:role', 'labels');
210
+ layer.set('mapzero:sourceLayerIds', LABEL_SOURCE_LAYERS.map((layerId) => `${options.instanceId}:${layerId}`));
211
+ }
212
+
213
+ return {
214
+ layer,
215
+ source,
216
+ attachMap() {},
217
+ detachMap() {},
218
+ refresh() {
219
+ source.setTileUrlFunction(options.tileUrlFunction, String(Date.now()));
220
+ source.clear();
221
+ styleCache.clear();
222
+ layer.changed();
223
+ },
224
+ destroy() {
225
+ source.clear();
226
+ layer.dispose();
227
+ }
228
+ };
229
+ }
230
+
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
+ function labelStyle(feature, resolution, styleDocument, styleCache) {
280
+ const zoom = resolutionToZoom(resolution);
281
+ const sourceLayer = cleanText(feature.get('sourceLayer'));
282
+ if (sourceLayer && cleanText(feature.get('text'))) {
283
+ return candidateLabelStyle(feature, sourceLayer, zoom, styleDocument, styleCache);
284
+ }
285
+
286
+ if (feature.get('highway')) {
287
+ return roadLabelStyle(feature, zoom, styleDocument, styleCache);
288
+ }
289
+
290
+ if (feature.get('aeroway')) {
291
+ return aviationLabelStyle(feature, zoom, styleDocument, styleCache);
292
+ }
293
+
294
+ if (isPoiLikeFeature(feature)) {
295
+ return poiLabelStyle(feature, zoom, styleDocument, styleCache);
296
+ }
297
+
298
+ return null;
299
+ }
300
+
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
+ function candidateLabelStyle(feature, sourceLayer, zoom, styleDocument, styleCache) {
338
+ const rule = labelRuleForSource(styleDocument, sourceLayer);
339
+ const minZoom = Number(feature.get('minZoom') ?? rule?.minZoom ?? 0);
340
+ const text = cleanText(feature.get('text'));
341
+ if (!rule || rule.enabled === false || !zoomInRule(zoom, rule) || zoom < minZoom) {
342
+ return null;
343
+ }
344
+
345
+ if (!isMeaningfulLabel(text) && !isExplicitAviationLabel(feature, sourceLayer, text)) {
346
+ return null;
347
+ }
348
+
349
+ if (sourceLayer === POI_SOURCE_LAYER && !isSelectedPoiCandidate(feature, rule)) {
350
+ return null;
351
+ }
352
+
353
+ return textStyle({
354
+ cache: styleCache,
355
+ styleDocument,
356
+ rule,
357
+ text,
358
+ placement: 'point',
359
+ priorityClass: priorityClassFromNumber(Number(feature.get('priority') ?? 0)),
360
+ zIndex: Number(feature.get('priority') ?? 0),
361
+ zoom
362
+ });
363
+ }
364
+
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
+ function roadLabelStyle(feature, zoom, styleDocument, styleCache) {
388
+ const rule = labelRuleForSource(styleDocument, ROAD_SOURCE_LAYER);
389
+ if (!rule || rule.enabled === false || !zoomInRule(zoom, rule)) {
390
+ return null;
391
+ }
392
+
393
+ const highway = String(feature.get('highway') ?? '');
394
+ const label = roadLabel(feature, highway, zoom, rule);
395
+ if (!label) {
396
+ return null;
397
+ }
398
+
399
+ return textStyle({
400
+ cache: styleCache,
401
+ styleDocument,
402
+ rule,
403
+ text: label.text,
404
+ placement: 'line',
405
+ priorityClass: label.priorityClass,
406
+ zIndex: label.zIndex,
407
+ zoom
408
+ });
409
+ }
410
+
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
+ function aviationLabelStyle(feature, zoom, styleDocument, styleCache) {
419
+ const rule = labelRuleForSource(styleDocument, 'aip');
420
+ if (!rule || rule.enabled === false || !zoomInRule(zoom, rule)) {
421
+ return null;
422
+ }
423
+
424
+ const aeroway = String(feature.get('aeroway') ?? '');
425
+ const label = aviationLabel(feature, aeroway, zoom);
426
+ if (!label) {
427
+ return null;
428
+ }
429
+
430
+ return textStyle({
431
+ cache: styleCache,
432
+ styleDocument,
433
+ rule,
434
+ text: label.text,
435
+ placement: label.placement,
436
+ priorityClass: label.priorityClass,
437
+ zIndex: label.zIndex,
438
+ zoom
439
+ });
440
+ }
441
+
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
+ function poiLabelStyle(feature, zoom, styleDocument, styleCache) {
450
+ const rule = labelRuleForSource(styleDocument, POI_SOURCE_LAYER);
451
+ if (!rule || rule.enabled === false || !zoomInRule(zoom, rule) || !isSelectedPoi(feature, rule)) {
452
+ return null;
453
+ }
454
+
455
+ const text = poiLabelText(feature);
456
+ if (!text) {
457
+ return null;
458
+ }
459
+
460
+ return textStyle({
461
+ cache: styleCache,
462
+ styleDocument,
463
+ rule,
464
+ text,
465
+ placement: 'point',
466
+ priorityClass: poiPriorityClass(feature),
467
+ zIndex: poiZIndex(feature),
468
+ zoom
469
+ });
470
+ }
471
+
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) || (aeroway === 'heliport' ? 'H' : '');
529
+ return isRenderableAviationText(text, aeroway) && zoom >= 11 && zoom < 15
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 >= 12 ? { text, placement: 'line', priorityClass: 'important', zIndex: 900 } : null;
537
+ }
538
+
539
+ if (aeroway === 'terminal' || aeroway === 'apron') {
540
+ const text = name || ref;
541
+ return isMeaningfulLabel(text) && zoom >= 15 ? { 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 >= 14 ? { text, placement: 'point', priorityClass: 'critical', zIndex: 980 } : null;
547
+ }
548
+
549
+ return null;
550
+ }
551
+
552
+ /**
553
+ * @param {{
554
+ * cache: Map<string, Style>,
555
+ * styleDocument: Record<string, unknown>,
556
+ * rule: Record<string, unknown>,
557
+ * text: string,
558
+ * placement: 'line' | 'point',
559
+ * priorityClass: string,
560
+ * zIndex: number,
561
+ * zoom: number
562
+ * }} options
563
+ * @returns {Style}
564
+ */
565
+ function textStyle(options) {
566
+ const priorityRule = priorityClassRule(options.styleDocument, options.priorityClass);
567
+ const opacity = labelOpacityForZoom(options.zoom, Number(priorityRule.opacity ?? options.rule.opacity ?? 0.82));
568
+ const font = labelFont(options.rule, priorityRule, options.zoom);
569
+ const fill = rgba(String(priorityRule.fill ?? options.rule.fill ?? '#d9fbff'), opacity);
570
+ const halo = rgba(String(priorityRule.halo ?? options.rule.halo ?? '#001014'), Math.min(1, opacity + 0.12));
571
+ const haloWidth = Number(priorityRule.haloWidth ?? options.rule.haloWidth ?? 3);
572
+ const key = [
573
+ options.text,
574
+ options.placement,
575
+ font,
576
+ fill,
577
+ halo,
578
+ haloWidth,
579
+ options.zIndex
580
+ ].join('|');
581
+
582
+ if (options.cache.has(key)) {
583
+ return /** @type {Style} */ (options.cache.get(key));
584
+ }
585
+
586
+ const style = new Style({
587
+ zIndex: options.zIndex,
588
+ text: new Text({
589
+ text: options.text,
590
+ placement: options.placement,
591
+ font,
592
+ fill: new Fill({ color: fill }),
593
+ stroke: new Stroke({ color: halo, width: haloWidth }),
594
+ overflow: false,
595
+ maxAngle: Math.PI / 5
596
+ })
597
+ });
598
+ options.cache.set(key, style);
599
+ return style;
600
+ }
601
+
602
+ /**
603
+ * @param {Feature} feature
604
+ * @param {Record<string, unknown>} rule
605
+ * @returns {boolean}
606
+ */
607
+ function isSelectedPoi(feature, rule) {
608
+ const categories = objectRule(rule.categories);
609
+ const category = poiCategoryForFeature(feature);
610
+ if (category && categories?.[category] === false) {
611
+ return false;
612
+ }
613
+
614
+ const matchesClass = matchesSelectedPoiClass(feature, rule);
615
+ return matchesClass;
616
+ }
617
+
618
+ /**
619
+ * @param {Feature} feature
620
+ * @param {Record<string, unknown>} rule
621
+ * @returns {boolean}
622
+ */
623
+ function matchesSelectedPoiClass(feature, rule) {
624
+ const selected = objectRule(rule.classes) ?? DEFAULT_POI_CLASSES;
625
+ for (const property of [
626
+ 'amenity',
627
+ 'tourism',
628
+ 'shop',
629
+ 'leisure',
630
+ 'railway',
631
+ 'public_transport',
632
+ 'station',
633
+ 'aeroway',
634
+ 'power',
635
+ 'man_made',
636
+ 'tower:type',
637
+ 'military',
638
+ 'emergency',
639
+ 'office',
640
+ 'government',
641
+ 'boundary',
642
+ 'protect_class',
643
+ 'landuse',
644
+ 'industrial'
645
+ ]) {
646
+ const value = String(feature.get(property) ?? '');
647
+ const allowed = Array.isArray(selected[property]) ? /** @type {unknown[]} */ (selected[property]).map(String) : [];
648
+ if (value && allowed.includes(value)) {
649
+ return true;
650
+ }
651
+ }
652
+ return false;
653
+ }
654
+
655
+ /**
656
+ * @param {Feature} feature
657
+ * @returns {string}
658
+ */
659
+ function poiLabelText(feature) {
660
+ for (const field of LABEL_TEXT_FIELDS) {
661
+ const text = cleanText(feature.get(field));
662
+ if (isMeaningfulLabel(text)) {
663
+ return text;
664
+ }
665
+ }
666
+
667
+ return '';
668
+ }
669
+
670
+ /**
671
+ * @param {Feature} feature
672
+ * @returns {string}
673
+ */
674
+ function poiCategoryForFeature(feature) {
675
+ const existing = cleanText(feature.get('poi_category'));
676
+ if (existing) {
677
+ return existing;
678
+ }
679
+
680
+ const amenity = cleanText(feature.get('amenity'));
681
+ const tourism = cleanText(feature.get('tourism'));
682
+ const shop = cleanText(feature.get('shop'));
683
+ const leisure = cleanText(feature.get('leisure'));
684
+ const railway = cleanText(feature.get('railway'));
685
+ const publicTransport = cleanText(feature.get('public_transport'));
686
+ const station = cleanText(feature.get('station'));
687
+ const aeroway = cleanText(feature.get('aeroway'));
688
+ const power = cleanText(feature.get('power'));
689
+ const manMade = cleanText(feature.get('man_made'));
690
+ const towerType = cleanText(feature.get('tower:type'));
691
+ const military = cleanText(feature.get('military'));
692
+ const emergency = cleanText(feature.get('emergency'));
693
+ const office = cleanText(feature.get('office'));
694
+ const government = cleanText(feature.get('government'));
695
+ const boundary = cleanText(feature.get('boundary'));
696
+ const protectClass = cleanText(feature.get('protect_class'));
697
+ const landuse = cleanText(feature.get('landuse'));
698
+ const industrial = cleanText(feature.get('industrial'));
699
+
700
+ if (['railway_station', 'train_station', 'subway_station', 'bus_station', 'ferry_terminal', 'airport'].includes(amenity) ||
701
+ railway === 'station' || publicTransport === 'station' || station === 'subway' || aeroway === 'aerodrome' || aeroway === 'airport') {
702
+ return 'transport';
703
+ }
704
+ if (['hospital', 'police', 'fire_station', 'shelter'].includes(amenity) || ['ambulance_station', 'siren', 'assembly_point', 'disaster_response'].includes(emergency)) return 'emergency';
705
+ if (['townhall', 'courthouse', 'prison', 'embassy'].includes(amenity) || office === 'government' || government) return 'government';
706
+ if (power) return 'energy';
707
+ if (amenity === 'communications_tower' || manMade === 'communications_tower' || manMade === 'mast' || manMade === 'antenna' || towerType === 'communication') return 'communications';
708
+ if (boundary === 'protected_area' || leisure === 'nature_reserve' || tourism === 'national_park' || protectClass) return 'protected';
709
+ if (landuse === 'industrial' || industrial || ['depot', 'warehouse'].includes(amenity)) return 'industrial';
710
+ if (military || ['bunker', 'checkpoint'].includes(amenity)) return 'military';
711
+ if (shop || tourism || leisure || ['restaurant', 'cafe', 'bar', 'fast_food', 'pub'].includes(amenity)) return 'consumer';
712
+ return 'operational';
713
+ }
714
+
715
+ /**
716
+ * @param {number} priority
717
+ * @returns {string}
718
+ */
719
+ function priorityClassFromNumber(priority) {
720
+ if (priority >= 900) return 'critical';
721
+ if (priority >= 760) return 'important';
722
+ if (priority >= 500) return 'normal';
723
+ return 'low';
724
+ }
725
+
726
+ /**
727
+ * @param {Feature} feature
728
+ * @returns {string}
729
+ */
730
+ function poiPriorityClass(feature) {
731
+ const category = String(feature.get('poi_category') ?? '');
732
+ if (category === 'emergency' || category === 'military') {
733
+ return 'critical';
734
+ }
735
+ if (['transport', 'energy', 'communications', 'government'].includes(category)) {
736
+ return 'important';
737
+ }
738
+
739
+ const amenity = String(feature.get('amenity') ?? '');
740
+ if (amenity === 'hospital' || amenity === 'police' || amenity === 'fire_station') {
741
+ return 'critical';
742
+ }
743
+ return 'normal';
744
+ }
745
+
746
+ /**
747
+ * @param {Feature} feature
748
+ * @returns {number}
749
+ */
750
+ function poiZIndex(feature) {
751
+ const priority = poiPriorityClass(feature);
752
+ if (priority === 'critical') return 920;
753
+ if (priority === 'important') return 820;
754
+ return 620;
755
+ }
756
+
757
+ /**
758
+ * @param {Record<string, unknown>} rule
759
+ * @param {Record<string, unknown>} priorityRule
760
+ * @param {number} zoom
761
+ * @returns {string}
762
+ */
763
+ function labelFont(rule, priorityRule, zoom) {
764
+ const configured = String(priorityRule.font ?? rule.font ?? '').trim();
765
+ if (configured) {
766
+ return configured;
767
+ }
768
+
769
+ const weight = String(priorityRule.weight ?? rule.weight ?? 600);
770
+ const size = zoom >= 17 ? 12 : zoom >= 15 ? 11 : 10;
771
+ return `${weight} ${size}px sans-serif`;
772
+ }
773
+
774
+ /**
775
+ * @param {Record<string, unknown>} styleDocument
776
+ * @param {string} className
777
+ * @returns {Record<string, unknown>}
778
+ */
779
+ function priorityClassRule(styleDocument, className) {
780
+ const labels = labelConfigRoot(styleDocument);
781
+ const classes = labels?.priorityClasses && typeof labels.priorityClasses === 'object'
782
+ ? /** @type {Record<string, unknown>} */ (labels.priorityClasses)
783
+ : {};
784
+ const rule = classes[className];
785
+ return rule && typeof rule === 'object' ? /** @type {Record<string, unknown>} */ (rule) : {};
786
+ }
787
+
788
+ /**
789
+ * @param {number} zoom
790
+ * @param {number} base
791
+ * @returns {number}
792
+ */
793
+ function labelOpacityForZoom(zoom, base) {
794
+ if (zoom < 12.5) return base * 0.6;
795
+ if (zoom < 14) return base * 0.76;
796
+ return base;
797
+ }
798
+
799
+ /**
800
+ * @param {unknown} value
801
+ * @returns {string}
802
+ */
803
+ function cleanText(value) {
804
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
805
+ }
806
+
807
+ /**
808
+ * @param {string} text
809
+ * @returns {boolean}
810
+ */
811
+ function isMeaningfulLabel(text) {
812
+ const normalized = cleanText(text);
813
+ if (normalized.length < 2) {
814
+ return false;
815
+ }
816
+
817
+ const key = normalized.toLowerCase().replace(/\s+/g, '_');
818
+ if (GENERIC_LABEL_VALUES.has(key)) {
819
+ return false;
820
+ }
821
+
822
+ if (/^(yes|no|true|false|0|1)$/i.test(normalized)) {
823
+ return false;
824
+ }
825
+
826
+ return true;
827
+ }
828
+
829
+ /**
830
+ * @param {Feature} feature
831
+ * @param {string} sourceLayer
832
+ * @param {string} text
833
+ * @returns {boolean}
834
+ */
835
+ function isExplicitAviationLabel(feature, sourceLayer, text) {
836
+ if (!AIP_SOURCE_LAYERS.has(sourceLayer)) {
837
+ return false;
838
+ }
839
+
840
+ const aeroway = cleanText(feature.get('aeroway') || feature.get('class'));
841
+ if (text === 'H') {
842
+ return aeroway === 'helipad' || aeroway === 'heliport';
843
+ }
844
+
845
+ if (text === 'RWY') {
846
+ return aeroway === 'runway';
847
+ }
848
+
849
+ return /^[A-Z]$/.test(text) && ['helipad', 'heliport', 'runway'].includes(aeroway);
850
+ }
851
+
852
+ /**
853
+ * @param {string} ref
854
+ * @returns {string}
855
+ */
856
+ function aviationRefText(ref) {
857
+ return ref && !/^(yes|no|true|false|0|1)$/i.test(ref) ? ref : '';
858
+ }
859
+
860
+ /**
861
+ * @param {string} text
862
+ * @param {string} aeroway
863
+ * @returns {boolean}
864
+ */
865
+ function isRenderableAviationText(text, aeroway) {
866
+ if (isMeaningfulLabel(text)) {
867
+ return true;
868
+ }
869
+
870
+ if (text === 'H') {
871
+ return aeroway === 'helipad' || aeroway === 'heliport';
872
+ }
873
+
874
+ if (text === 'RWY') {
875
+ return aeroway === 'runway';
876
+ }
877
+
878
+ return /^[A-Z]$/.test(text) && ['helipad', 'heliport', 'runway'].includes(aeroway);
879
+ }
880
+
881
+ /**
882
+ * @param {Record<string, unknown>} styleDocument
883
+ * @returns {Record<string, unknown> | null}
884
+ */
885
+ function labelConfigRoot(styleDocument) {
886
+ return styleDocument.labels && typeof styleDocument.labels === 'object'
887
+ ? /** @type {Record<string, unknown>} */ (styleDocument.labels)
888
+ : null;
889
+ }
890
+
891
+ /**
892
+ * @param {Record<string, unknown>} styleDocument
893
+ * @param {string} sourceLayer
894
+ * @returns {Record<string, unknown> | null}
895
+ */
896
+ function labelRuleForSource(styleDocument, sourceLayer) {
897
+ const labels = labelConfigRoot(styleDocument);
898
+ const rule = labels?.[sourceLayer] ?? labels?.[labelSourceAlias(sourceLayer)];
899
+ return rule && typeof rule === 'object' ? /** @type {Record<string, unknown>} */ (rule) : null;
900
+ }
901
+
902
+ /**
903
+ * @param {string} sourceLayer
904
+ * @returns {string}
905
+ */
906
+ function labelSourceAlias(sourceLayer) {
907
+ if (sourceLayer === 'aip') return 'aviation';
908
+ if (sourceLayer === 'aviation') return 'aip';
909
+ return sourceLayer;
910
+ }
911
+
912
+ /**
913
+ * @param {unknown} rule
914
+ * @returns {Record<string, unknown> | null}
915
+ */
916
+ function objectRule(rule) {
917
+ return rule && typeof rule === 'object' ? /** @type {Record<string, unknown>} */ (rule) : null;
918
+ }
919
+
920
+ /**
921
+ * @param {number} zoom
922
+ * @param {Record<string, unknown>} rule
923
+ * @returns {boolean}
924
+ */
925
+ function zoomInRule(zoom, rule) {
926
+ const minZoom = Number(rule.minZoom ?? 0);
927
+ const maxZoom = Number(rule.maxZoom ?? 22);
928
+ return (!Number.isFinite(minZoom) || zoom >= minZoom) && (!Number.isFinite(maxZoom) || zoom <= maxZoom);
929
+ }
930
+
931
+ /**
932
+ * @param {number} resolution
933
+ * @returns {number}
934
+ */
935
+ function resolutionToZoom(resolution) {
936
+ const initialResolution = 156543.03392804097;
937
+ return Math.log2(initialResolution / Number(resolution));
938
+ }
939
+
940
+ /**
941
+ * @param {string} color
942
+ * @param {number} opacity
943
+ * @returns {string}
944
+ */
945
+ function rgba(color, opacity) {
946
+ const rgb = hexToRgb(color);
947
+ if (!rgb) {
948
+ return color;
949
+ }
950
+ return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(opacity, 0, 1)})`;
951
+ }
952
+
953
+ /**
954
+ * @param {string} color
955
+ * @returns {[number, number, number] | null}
956
+ */
957
+ function hexToRgb(color) {
958
+ const match = /^#?([0-9a-f]{6})$/i.exec(color);
959
+ if (!match) {
960
+ return null;
961
+ }
962
+ const value = Number.parseInt(match[1], 16);
963
+ return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
964
+ }
965
+
966
+ /**
967
+ * @param {number} value
968
+ * @param {number} min
969
+ * @param {number} max
970
+ * @returns {number}
971
+ */
972
+ function clamp(value, min, max) {
973
+ if (!Number.isFinite(value)) {
974
+ return min;
975
+ }
976
+ return Math.max(min, Math.min(max, value));
977
+ }