@map-zero/core 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 +21 -0
- package/README.md +3 -0
- package/package.json +23 -0
- package/src/labels.js +631 -0
- package/src/shared/cache.js +35 -0
- package/src/shared/layers.js +32 -0
- package/src/style.js +74 -0
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/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@map-zero/core",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Shared styles, label rules and caches for map-zero vector viewers.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/mploscos/map-zero#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/mploscos/map-zero.git",
|
|
11
|
+
"directory": "packages/core"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/mploscos/map-zero/issues"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
"./*": "./src/*"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"README.md"
|
|
22
|
+
]
|
|
23
|
+
}
|
package/src/labels.js
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/** Shared label selection and styling; no renderer dependency. */
|
|
2
|
+
export const LABEL_SOURCE_LAYERS = ['roads', 'aip', 'aviation', 'pois'];
|
|
3
|
+
export const ROAD_SOURCE_LAYER = 'roads';
|
|
4
|
+
export const AIP_SOURCE_LAYERS = new Set(['aip', 'aviation']);
|
|
5
|
+
export const POI_SOURCE_LAYER = 'pois';
|
|
6
|
+
|
|
7
|
+
const MAJOR_ROADS = new Set(['motorway', 'motorway_link', 'trunk', 'trunk_link', 'primary', 'primary_link']);
|
|
8
|
+
const SECONDARY_ROADS = new Set(['secondary', 'secondary_link']);
|
|
9
|
+
const LOCAL_ROADS = new Set(['residential', 'living_street', 'unclassified']);
|
|
10
|
+
const DISABLED_ROADS = new Set(['service', 'track', 'path', 'footway', 'cycleway', 'steps', 'corridor', 'platform']);
|
|
11
|
+
const LABEL_TEXT_FIELDS = ['name', 'ref', 'iata', 'icao', 'operator', 'official_name', 'short_name'];
|
|
12
|
+
const GENERIC_LABEL_VALUES = new Set([
|
|
13
|
+
'yes',
|
|
14
|
+
'no',
|
|
15
|
+
'true',
|
|
16
|
+
'false',
|
|
17
|
+
'unknown',
|
|
18
|
+
'none',
|
|
19
|
+
'generator',
|
|
20
|
+
'tower',
|
|
21
|
+
'line',
|
|
22
|
+
'plant',
|
|
23
|
+
'substation',
|
|
24
|
+
'transformer',
|
|
25
|
+
'mast',
|
|
26
|
+
'antenna',
|
|
27
|
+
'station',
|
|
28
|
+
'airport',
|
|
29
|
+
'aerodrome',
|
|
30
|
+
'runway',
|
|
31
|
+
'taxiway',
|
|
32
|
+
'terminal',
|
|
33
|
+
'apron',
|
|
34
|
+
'pharmacy',
|
|
35
|
+
'fuel',
|
|
36
|
+
'charging_station',
|
|
37
|
+
'hospital',
|
|
38
|
+
'clinic',
|
|
39
|
+
'police',
|
|
40
|
+
'fire_station',
|
|
41
|
+
'fire_hydrant',
|
|
42
|
+
'defibrillator',
|
|
43
|
+
'fire_extinguisher',
|
|
44
|
+
'shelter',
|
|
45
|
+
'railway_station',
|
|
46
|
+
'train_station',
|
|
47
|
+
'subway_station',
|
|
48
|
+
'bus_station',
|
|
49
|
+
'ferry_terminal',
|
|
50
|
+
'townhall',
|
|
51
|
+
'courthouse',
|
|
52
|
+
'prison',
|
|
53
|
+
'bunker',
|
|
54
|
+
'checkpoint',
|
|
55
|
+
'communications_tower',
|
|
56
|
+
'protected_area',
|
|
57
|
+
'nature_reserve',
|
|
58
|
+
'restaurant',
|
|
59
|
+
'cafe',
|
|
60
|
+
'bar',
|
|
61
|
+
'fast_food',
|
|
62
|
+
'pub',
|
|
63
|
+
'shop',
|
|
64
|
+
'retail',
|
|
65
|
+
'commercial',
|
|
66
|
+
'tourism',
|
|
67
|
+
'attraction',
|
|
68
|
+
'hotel',
|
|
69
|
+
'transport',
|
|
70
|
+
'emergency',
|
|
71
|
+
'government',
|
|
72
|
+
'energy',
|
|
73
|
+
'communications',
|
|
74
|
+
'protected',
|
|
75
|
+
'industrial',
|
|
76
|
+
'military',
|
|
77
|
+
'operational',
|
|
78
|
+
'consumer'
|
|
79
|
+
]);
|
|
80
|
+
const DEFAULT_POI_CLASSES = {
|
|
81
|
+
amenity: [
|
|
82
|
+
'hospital',
|
|
83
|
+
'police',
|
|
84
|
+
'fire_station',
|
|
85
|
+
'bus_station',
|
|
86
|
+
'ferry_terminal',
|
|
87
|
+
'shelter',
|
|
88
|
+
'townhall',
|
|
89
|
+
'courthouse',
|
|
90
|
+
'prison',
|
|
91
|
+
'ranger_station',
|
|
92
|
+
'research_institute',
|
|
93
|
+
'airport',
|
|
94
|
+
'railway_station',
|
|
95
|
+
'train_station',
|
|
96
|
+
'subway_station',
|
|
97
|
+
'communications_tower',
|
|
98
|
+
'bunker',
|
|
99
|
+
'checkpoint',
|
|
100
|
+
'depot',
|
|
101
|
+
'warehouse'
|
|
102
|
+
],
|
|
103
|
+
tourism: ['information'],
|
|
104
|
+
shop: [],
|
|
105
|
+
leisure: ['nature_reserve'],
|
|
106
|
+
railway: ['station'],
|
|
107
|
+
public_transport: ['station'],
|
|
108
|
+
station: ['subway'],
|
|
109
|
+
aeroway: ['aerodrome', 'airport'],
|
|
110
|
+
power: ['plant', 'substation', 'generator', 'tower', 'transformer', 'line'],
|
|
111
|
+
man_made: ['communications_tower', 'mast', 'antenna'],
|
|
112
|
+
'tower:type': ['communication'],
|
|
113
|
+
military: ['barracks', 'bunker', 'checkpoint', 'airbase', 'naval_base', 'range', 'training_area', 'base', 'airfield', 'danger_area', 'ammunition_dump'],
|
|
114
|
+
emergency: ['ambulance_station', 'siren', 'assembly_point', 'disaster_response'],
|
|
115
|
+
office: ['government'],
|
|
116
|
+
government: ['yes', 'public_safety', 'border_control', 'customs', 'ministry', 'aerospace'],
|
|
117
|
+
boundary: ['protected_area'],
|
|
118
|
+
industrial: ['refinery', 'depot', 'storage', 'logistics'],
|
|
119
|
+
landuse: ['industrial']
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
export function activeLabelLayerIdsForZoom(orderedLayers, styleDocument, layerVisibility, zoom) {
|
|
125
|
+
const labels = labelConfigRoot(styleDocument);
|
|
126
|
+
if (!labels || labels.enabled === false) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return LABEL_SOURCE_LAYERS.filter((layerId) => {
|
|
131
|
+
const rule = labelRuleForSource(styleDocument, layerId);
|
|
132
|
+
if (!rule || rule.enabled === false) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!orderedLayers.some((layer) => layer.id === layerId) || !layerVisibility.get(layerId)) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return zoomInRule(zoom, rule);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function hasEnabledLabels(styleDocument) {
|
|
145
|
+
const labels = labelConfigRoot(styleDocument);
|
|
146
|
+
return Boolean(labels && labels.enabled !== false && LABEL_SOURCE_LAYERS.some((layerId) => {
|
|
147
|
+
const rule = labelRuleForSource(styleDocument, layerId);
|
|
148
|
+
return rule && rule.enabled !== false;
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function isPoiLikeFeature(feature) {
|
|
153
|
+
return [
|
|
154
|
+
'poi_category',
|
|
155
|
+
'amenity',
|
|
156
|
+
'tourism',
|
|
157
|
+
'shop',
|
|
158
|
+
'leisure',
|
|
159
|
+
'railway',
|
|
160
|
+
'public_transport',
|
|
161
|
+
'station',
|
|
162
|
+
'power',
|
|
163
|
+
'man_made',
|
|
164
|
+
'tower:type',
|
|
165
|
+
'military',
|
|
166
|
+
'emergency',
|
|
167
|
+
'office',
|
|
168
|
+
'government',
|
|
169
|
+
'boundary',
|
|
170
|
+
'protect_class',
|
|
171
|
+
'landuse',
|
|
172
|
+
'industrial'
|
|
173
|
+
].some((property) => cleanText(feature.get(property)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function isSelectedPoiCandidate(feature, rule) {
|
|
177
|
+
const className = cleanText(feature.get('class'));
|
|
178
|
+
if (!className || ['clinic', 'pharmacy', 'fuel', 'charging_station', 'fire_hydrant', 'defibrillator', 'fire_extinguisher', 'restaurant', 'cafe', 'bar', 'fast_food', 'pub'].includes(className)) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const selected = objectRule(rule.classes) ?? DEFAULT_POI_CLASSES;
|
|
183
|
+
return Object.values(selected).some((values) => Array.isArray(values) && values.map(String).includes(className));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function roadLabel(feature, highway, zoom, rule) {
|
|
187
|
+
if (!highway || DISABLED_ROADS.has(highway)) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const local = objectRule(rule.local);
|
|
192
|
+
if (LOCAL_ROADS.has(highway) && local?.enabled !== true) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const ref = cleanText(feature.get('ref'));
|
|
197
|
+
const name = cleanText(feature.get('name'));
|
|
198
|
+
|
|
199
|
+
if (MAJOR_ROADS.has(highway)) {
|
|
200
|
+
const minZoom = Number(rule.majorMinZoom ?? 12);
|
|
201
|
+
if (zoom < minZoom || !isMeaningfulLabel(ref)) return null;
|
|
202
|
+
return { text: ref, priorityClass: 'important', zIndex: 820 };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (SECONDARY_ROADS.has(highway)) {
|
|
206
|
+
const minZoom = Number(rule.secondaryMinZoom ?? 16);
|
|
207
|
+
if (zoom < minZoom || !isMeaningfulLabel(ref)) return null;
|
|
208
|
+
return { text: ref, priorityClass: 'normal', zIndex: 700 };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (LOCAL_ROADS.has(highway)) {
|
|
212
|
+
const minZoom = Number(local?.minZoom ?? 17);
|
|
213
|
+
if (zoom < minZoom || !isMeaningfulLabel(name)) return null;
|
|
214
|
+
return { text: name, priorityClass: 'low', zIndex: 400 };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function aviationLabel(feature, aeroway, zoom) {
|
|
221
|
+
if (!aeroway) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const ref = cleanText(feature.get('ref'));
|
|
226
|
+
const name = cleanText(feature.get('name'));
|
|
227
|
+
|
|
228
|
+
if (aeroway === 'aerodrome' || aeroway === 'heliport') {
|
|
229
|
+
const text = name || aviationRefText(ref) || aviationFallbackText(aeroway);
|
|
230
|
+
return isRenderableAviationText(text, aeroway) && zoom >= 9
|
|
231
|
+
? { text, placement: 'point', priorityClass: 'critical', zIndex: 980 }
|
|
232
|
+
: null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (aeroway === 'runway') {
|
|
236
|
+
const text = aviationRefText(ref) || name || 'RWY';
|
|
237
|
+
return isRenderableAviationText(text, aeroway) && zoom >= 11 ? { text, placement: 'line', priorityClass: 'important', zIndex: 900 } : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (aeroway === 'terminal' || aeroway === 'apron') {
|
|
241
|
+
const text = name || ref || aviationFallbackText(aeroway);
|
|
242
|
+
return isMeaningfulLabel(text) && zoom >= 14 ? { text, placement: 'point', priorityClass: 'normal', zIndex: 720 } : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (aeroway === 'helipad') {
|
|
246
|
+
const text = aviationRefText(ref) || name || 'H';
|
|
247
|
+
return isRenderableAviationText(text, aeroway) && zoom >= 12 ? { text, placement: 'point', priorityClass: 'critical', zIndex: 980 } : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function aviationFallbackText(aeroway) {
|
|
254
|
+
if (aeroway === 'aerodrome') return 'Aerodrome';
|
|
255
|
+
if (aeroway === 'heliport') return 'Heliport';
|
|
256
|
+
if (aeroway === 'terminal') return 'Terminal';
|
|
257
|
+
if (aeroway === 'apron') return 'Apron';
|
|
258
|
+
return cleanText(aeroway).replace(/_/g, ' ');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function isSelectedPoi(feature, rule) {
|
|
262
|
+
const categories = objectRule(rule.categories);
|
|
263
|
+
const category = poiCategoryForFeature(feature);
|
|
264
|
+
if (category && categories?.[category] === false) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const matchesClass = matchesSelectedPoiClass(feature, rule);
|
|
269
|
+
return matchesClass;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function matchesSelectedPoiClass(feature, rule) {
|
|
273
|
+
const selected = objectRule(rule.classes) ?? DEFAULT_POI_CLASSES;
|
|
274
|
+
for (const property of [
|
|
275
|
+
'amenity',
|
|
276
|
+
'tourism',
|
|
277
|
+
'shop',
|
|
278
|
+
'leisure',
|
|
279
|
+
'railway',
|
|
280
|
+
'public_transport',
|
|
281
|
+
'station',
|
|
282
|
+
'aeroway',
|
|
283
|
+
'power',
|
|
284
|
+
'man_made',
|
|
285
|
+
'tower:type',
|
|
286
|
+
'military',
|
|
287
|
+
'emergency',
|
|
288
|
+
'office',
|
|
289
|
+
'government',
|
|
290
|
+
'boundary',
|
|
291
|
+
'protect_class',
|
|
292
|
+
'landuse',
|
|
293
|
+
'industrial'
|
|
294
|
+
]) {
|
|
295
|
+
const value = String(feature.get(property) ?? '');
|
|
296
|
+
const allowed = Array.isArray(selected[property]) ? /** @type {unknown[]} */ (selected[property]).map(String) : [];
|
|
297
|
+
if (value && allowed.includes(value)) {
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function poiLabelText(feature) {
|
|
305
|
+
for (const field of LABEL_TEXT_FIELDS) {
|
|
306
|
+
const text = cleanText(feature.get(field));
|
|
307
|
+
if (isMeaningfulLabel(text)) {
|
|
308
|
+
return text;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return poiFallbackLabel(feature);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function poiFallbackLabel(feature) {
|
|
316
|
+
const candidates = [
|
|
317
|
+
['military', {
|
|
318
|
+
airbase: 'Airbase',
|
|
319
|
+
airfield: 'Airfield',
|
|
320
|
+
barracks: 'Barracks',
|
|
321
|
+
bunker: 'Bunker',
|
|
322
|
+
checkpoint: 'Checkpoint',
|
|
323
|
+
naval_base: 'Naval base',
|
|
324
|
+
range: 'Range',
|
|
325
|
+
training_area: 'Training area',
|
|
326
|
+
danger_area: 'Danger area',
|
|
327
|
+
ammunition_dump: 'Ammunition dump',
|
|
328
|
+
base: 'Base'
|
|
329
|
+
}],
|
|
330
|
+
['emergency', {
|
|
331
|
+
ambulance_station: 'Ambulance station',
|
|
332
|
+
siren: 'Siren',
|
|
333
|
+
assembly_point: 'Assembly point',
|
|
334
|
+
disaster_response: 'Disaster response'
|
|
335
|
+
}],
|
|
336
|
+
['amenity', {
|
|
337
|
+
hospital: 'Hospital',
|
|
338
|
+
police: 'Police',
|
|
339
|
+
fire_station: 'Fire station',
|
|
340
|
+
shelter: 'Shelter',
|
|
341
|
+
bunker: 'Bunker',
|
|
342
|
+
checkpoint: 'Checkpoint',
|
|
343
|
+
depot: 'Depot',
|
|
344
|
+
warehouse: 'Warehouse',
|
|
345
|
+
prison: 'Prison',
|
|
346
|
+
courthouse: 'Courthouse',
|
|
347
|
+
townhall: 'Town hall',
|
|
348
|
+
bus_station: 'Bus station',
|
|
349
|
+
ferry_terminal: 'Ferry terminal'
|
|
350
|
+
}],
|
|
351
|
+
['power', {
|
|
352
|
+
plant: 'Power plant',
|
|
353
|
+
substation: 'Substation',
|
|
354
|
+
generator: 'Generator',
|
|
355
|
+
tower: 'Power tower',
|
|
356
|
+
transformer: 'Transformer',
|
|
357
|
+
line: 'Power line'
|
|
358
|
+
}],
|
|
359
|
+
['man_made', {
|
|
360
|
+
communications_tower: 'Comms tower',
|
|
361
|
+
mast: 'Mast',
|
|
362
|
+
antenna: 'Antenna'
|
|
363
|
+
}],
|
|
364
|
+
['boundary', {
|
|
365
|
+
protected_area: 'Protected area'
|
|
366
|
+
}],
|
|
367
|
+
['landuse', {
|
|
368
|
+
industrial: 'Industrial'
|
|
369
|
+
}],
|
|
370
|
+
['industrial', {
|
|
371
|
+
refinery: 'Refinery',
|
|
372
|
+
depot: 'Depot',
|
|
373
|
+
storage: 'Storage',
|
|
374
|
+
logistics: 'Logistics'
|
|
375
|
+
}]
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
for (const [property, labels] of candidates) {
|
|
379
|
+
const value = cleanText(feature.get(property));
|
|
380
|
+
if (value && labels[value]) {
|
|
381
|
+
return labels[value];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const category = poiCategoryForFeature(feature);
|
|
386
|
+
return category === 'consumer' ? '' : titleCase(category);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function poiCategoryForFeature(feature) {
|
|
390
|
+
const existing = cleanText(feature.get('poi_category'));
|
|
391
|
+
if (existing) {
|
|
392
|
+
return existing;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const amenity = cleanText(feature.get('amenity'));
|
|
396
|
+
const tourism = cleanText(feature.get('tourism'));
|
|
397
|
+
const shop = cleanText(feature.get('shop'));
|
|
398
|
+
const leisure = cleanText(feature.get('leisure'));
|
|
399
|
+
const railway = cleanText(feature.get('railway'));
|
|
400
|
+
const publicTransport = cleanText(feature.get('public_transport'));
|
|
401
|
+
const station = cleanText(feature.get('station'));
|
|
402
|
+
const aeroway = cleanText(feature.get('aeroway'));
|
|
403
|
+
const power = cleanText(feature.get('power'));
|
|
404
|
+
const manMade = cleanText(feature.get('man_made'));
|
|
405
|
+
const towerType = cleanText(feature.get('tower:type'));
|
|
406
|
+
const military = cleanText(feature.get('military'));
|
|
407
|
+
const emergency = cleanText(feature.get('emergency'));
|
|
408
|
+
const office = cleanText(feature.get('office'));
|
|
409
|
+
const government = cleanText(feature.get('government'));
|
|
410
|
+
const boundary = cleanText(feature.get('boundary'));
|
|
411
|
+
const protectClass = cleanText(feature.get('protect_class'));
|
|
412
|
+
const landuse = cleanText(feature.get('landuse'));
|
|
413
|
+
const industrial = cleanText(feature.get('industrial'));
|
|
414
|
+
|
|
415
|
+
if (['railway_station', 'train_station', 'subway_station', 'bus_station', 'ferry_terminal', 'airport'].includes(amenity) ||
|
|
416
|
+
railway === 'station' || publicTransport === 'station' || station === 'subway' || aeroway === 'aerodrome' || aeroway === 'airport') {
|
|
417
|
+
return 'transport';
|
|
418
|
+
}
|
|
419
|
+
if (['hospital', 'police', 'fire_station', 'shelter'].includes(amenity) || ['ambulance_station', 'siren', 'assembly_point', 'disaster_response'].includes(emergency)) return 'emergency';
|
|
420
|
+
if (['townhall', 'courthouse', 'prison', 'embassy'].includes(amenity) || office === 'government' || government) return 'government';
|
|
421
|
+
if (power) return 'energy';
|
|
422
|
+
if (amenity === 'communications_tower' || manMade === 'communications_tower' || manMade === 'mast' || manMade === 'antenna' || towerType === 'communication') return 'communications';
|
|
423
|
+
if (boundary === 'protected_area' || leisure === 'nature_reserve' || tourism === 'national_park' || protectClass) return 'protected';
|
|
424
|
+
if (landuse === 'industrial' || industrial || ['depot', 'warehouse'].includes(amenity)) return 'industrial';
|
|
425
|
+
if (military || ['bunker', 'checkpoint'].includes(amenity)) return 'military';
|
|
426
|
+
if (shop || tourism || leisure || ['restaurant', 'cafe', 'bar', 'fast_food', 'pub'].includes(amenity)) return 'consumer';
|
|
427
|
+
return 'operational';
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function priorityClassFromNumber(priority) {
|
|
431
|
+
if (priority >= 900) return 'critical';
|
|
432
|
+
if (priority >= 760) return 'important';
|
|
433
|
+
if (priority >= 500) return 'normal';
|
|
434
|
+
return 'low';
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function poiPriorityClass(feature) {
|
|
438
|
+
const category = String(feature.get('poi_category') ?? '');
|
|
439
|
+
if (category === 'emergency' || category === 'military') {
|
|
440
|
+
return 'critical';
|
|
441
|
+
}
|
|
442
|
+
if (['transport', 'energy', 'communications', 'government'].includes(category)) {
|
|
443
|
+
return 'important';
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const amenity = String(feature.get('amenity') ?? '');
|
|
447
|
+
if (amenity === 'hospital' || amenity === 'police' || amenity === 'fire_station') {
|
|
448
|
+
return 'critical';
|
|
449
|
+
}
|
|
450
|
+
return 'normal';
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function poiZIndex(feature) {
|
|
454
|
+
const priority = poiPriorityClass(feature);
|
|
455
|
+
if (priority === 'critical') return 920;
|
|
456
|
+
if (priority === 'important') return 820;
|
|
457
|
+
return 620;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function labelFont(rule, priorityRule, zoom) {
|
|
461
|
+
const configured = String(priorityRule.font ?? rule.font ?? '').trim();
|
|
462
|
+
if (configured) {
|
|
463
|
+
return configured;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const weight = String(priorityRule.weight ?? rule.weight ?? 600);
|
|
467
|
+
const size = zoom >= 17 ? 12 : zoom >= 15 ? 11 : 10;
|
|
468
|
+
return `${weight} ${size}px sans-serif`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function priorityClassRule(styleDocument, className) {
|
|
472
|
+
const labels = labelConfigRoot(styleDocument);
|
|
473
|
+
const classes = labels?.priorityClasses && typeof labels.priorityClasses === 'object'
|
|
474
|
+
? /** @type {Record<string, unknown>} */ (labels.priorityClasses)
|
|
475
|
+
: {};
|
|
476
|
+
const rule = classes[className];
|
|
477
|
+
return rule && typeof rule === 'object' ? /** @type {Record<string, unknown>} */ (rule) : {};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function labelOpacityForZoom(zoom, base) {
|
|
481
|
+
if (zoom < 12.5) return base * 0.6;
|
|
482
|
+
if (zoom < 14) return base * 0.76;
|
|
483
|
+
return base;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function cleanText(value) {
|
|
487
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function titleCase(value) {
|
|
491
|
+
return cleanText(value)
|
|
492
|
+
.replace(/_/g, ' ')
|
|
493
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function isMeaningfulLabel(text) {
|
|
497
|
+
const normalized = cleanText(text);
|
|
498
|
+
if (normalized.length < 2) {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const key = normalized.toLowerCase().replace(/\s+/g, '_');
|
|
503
|
+
if (GENERIC_LABEL_VALUES.has(key)) {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (/^(yes|no|true|false|0|1)$/i.test(normalized)) {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function isExplicitAviationLabel(feature, sourceLayer, text) {
|
|
515
|
+
if (!AIP_SOURCE_LAYERS.has(sourceLayer)) {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const aeroway = cleanText(feature.get('aeroway') || feature.get('class'));
|
|
520
|
+
if (text === 'H') {
|
|
521
|
+
return aeroway === 'helipad' || aeroway === 'heliport';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (text === 'RWY') {
|
|
525
|
+
return aeroway === 'runway';
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return /^[A-Z]$/.test(text) && ['helipad', 'heliport', 'runway'].includes(aeroway);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function aviationRefText(ref) {
|
|
532
|
+
return ref && !/^(yes|no|true|false|0|1)$/i.test(ref) ? ref : '';
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function isRenderableAviationText(text, aeroway) {
|
|
536
|
+
if (isMeaningfulLabel(text)) {
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (text === 'H') {
|
|
541
|
+
return aeroway === 'helipad' || aeroway === 'heliport';
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (text === 'RWY') {
|
|
545
|
+
return aeroway === 'runway';
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return /^[A-Z]$/.test(text) && ['helipad', 'heliport', 'runway'].includes(aeroway);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function labelConfigRoot(styleDocument) {
|
|
552
|
+
return styleDocument.labels && typeof styleDocument.labels === 'object'
|
|
553
|
+
? /** @type {Record<string, unknown>} */ (styleDocument.labels)
|
|
554
|
+
: null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function labelRuleForSource(styleDocument, sourceLayer) {
|
|
558
|
+
const labels = labelConfigRoot(styleDocument);
|
|
559
|
+
const rule = labels?.[sourceLayer] ?? labels?.[labelSourceAlias(sourceLayer)];
|
|
560
|
+
return rule && typeof rule === 'object' ? /** @type {Record<string, unknown>} */ (rule) : null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function labelSourceAlias(sourceLayer) {
|
|
564
|
+
if (sourceLayer === 'aip') return 'aviation';
|
|
565
|
+
if (sourceLayer === 'aviation') return 'aip';
|
|
566
|
+
return sourceLayer;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function objectRule(rule) {
|
|
570
|
+
return rule && typeof rule === 'object' ? /** @type {Record<string, unknown>} */ (rule) : null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function zoomInRule(zoom, rule) {
|
|
574
|
+
const minZoom = Number(rule.minZoom ?? 0);
|
|
575
|
+
const maxZoom = Number(rule.maxZoom ?? 22);
|
|
576
|
+
return (!Number.isFinite(minZoom) || zoom >= minZoom) && (!Number.isFinite(maxZoom) || zoom <= maxZoom);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function resolutionToZoom(resolution) {
|
|
580
|
+
const initialResolution = 156543.03392804097;
|
|
581
|
+
return Math.log2(initialResolution / Number(resolution));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function rgba(color, opacity) {
|
|
585
|
+
const rgb = hexToRgb(color);
|
|
586
|
+
if (!rgb) {
|
|
587
|
+
return color;
|
|
588
|
+
}
|
|
589
|
+
return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(opacity, 0, 1)})`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function hexToRgb(color) {
|
|
593
|
+
const match = /^#?([0-9a-f]{6})$/i.exec(color);
|
|
594
|
+
if (!match) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
const value = Number.parseInt(match[1], 16);
|
|
598
|
+
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function clamp(value, min, max) {
|
|
602
|
+
if (!Number.isFinite(value)) {
|
|
603
|
+
return min;
|
|
604
|
+
}
|
|
605
|
+
return Math.max(min, Math.min(max, value));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** A renderer-independent label descriptor, using the same policy as OL. */
|
|
609
|
+
export function describeLabel(feature, sourceLayer, zoom, styleDocument) {
|
|
610
|
+
if (!hasEnabledLabels(styleDocument)) return null;
|
|
611
|
+
const rule = labelRuleForSource(styleDocument, sourceLayer);
|
|
612
|
+
if (!rule || rule.enabled === false || !zoomInRule(zoom, rule)) return null;
|
|
613
|
+
let label;
|
|
614
|
+
if (sourceLayer === 'roads') {
|
|
615
|
+
label = roadLabel(feature, String(feature.get('highway') ?? ''), zoom, rule);
|
|
616
|
+
} else if (sourceLayer === 'aip' || sourceLayer === 'aviation') {
|
|
617
|
+
label = aviationLabel(feature, String(feature.get('aeroway') ?? ''), zoom);
|
|
618
|
+
} else if (sourceLayer === 'pois' && isSelectedPoi(feature, rule)) {
|
|
619
|
+
label = { text: poiLabelText(feature), priorityClass: poiPriorityClass(feature), zIndex: poiZIndex(feature) };
|
|
620
|
+
}
|
|
621
|
+
if (!label?.text) return null;
|
|
622
|
+
const priority = priorityClassRule(styleDocument, label.priorityClass);
|
|
623
|
+
const opacity = labelOpacityForZoom(zoom, Number(priority.opacity ?? rule.opacity ?? 0.82));
|
|
624
|
+
return {
|
|
625
|
+
text: label.text, priority: label.zIndex,
|
|
626
|
+
font: labelFont(rule, priority, zoom),
|
|
627
|
+
fill: rgba(String(priority.fill ?? rule.fill ?? '#d9fbff'), opacity),
|
|
628
|
+
halo: rgba(String(priority.halo ?? rule.halo ?? '#001014'), Math.min(1, opacity + 0.12)),
|
|
629
|
+
haloWidth: Number(priority.haloWidth ?? rule.haloWidth ?? 3)
|
|
630
|
+
};
|
|
631
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Bounded least-recently-used cache. Eviction releases references, not images
|
|
2
|
+
* that may still be in use by a renderer. Rejected loads can be retried. */
|
|
3
|
+
export class LruCache extends Map {
|
|
4
|
+
constructor(limit = 128) {
|
|
5
|
+
super();
|
|
6
|
+
if (!Number.isInteger(limit) || limit < 1) throw new RangeError('Cache limit must be a positive integer');
|
|
7
|
+
this.limit = limit;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
get(key) {
|
|
11
|
+
if (!super.has(key)) return undefined;
|
|
12
|
+
const value = super.get(key);
|
|
13
|
+
super.delete(key);
|
|
14
|
+
super.set(key, value);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
set(key, value) {
|
|
19
|
+
super.delete(key);
|
|
20
|
+
super.set(key, value);
|
|
21
|
+
while (this.size > this.limit) super.delete(this.keys().next().value);
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function cachedPromise(cache, key, load) {
|
|
27
|
+
const existing = cache.get(key);
|
|
28
|
+
if (existing) return existing;
|
|
29
|
+
const promise = Promise.resolve().then(load).catch((error) => {
|
|
30
|
+
if (cache.get(key) === promise) cache.delete(key);
|
|
31
|
+
throw error;
|
|
32
|
+
});
|
|
33
|
+
cache.set(key, promise);
|
|
34
|
+
return promise;
|
|
35
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const DEFAULT_CONTEXT_LAYERS = ['landuse', 'terrain', 'water', 'coastline', 'cliffs', 'railways', 'roads', 'boundaries', 'aviation', 'pois'];
|
|
2
|
+
|
|
3
|
+
export function normalizeContextLayers(layers) {
|
|
4
|
+
const values = Array.isArray(layers) && layers.length > 0 ? layers : DEFAULT_CONTEXT_LAYERS;
|
|
5
|
+
return values.map((layer) => sourceLayerFor(String(layer)));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isLayerVisible(layerVisibility, layer) {
|
|
9
|
+
const direct = layerVisibility.get(layer);
|
|
10
|
+
if (direct != null) return direct;
|
|
11
|
+
const source = layerVisibility.get(sourceLayerFor(layer));
|
|
12
|
+
if (source != null) return source;
|
|
13
|
+
return layerVisibility.get(layerAlias(layer)) === true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sourceLayerFor(layer) {
|
|
17
|
+
return layer === 'aviation' ? 'aip' : layer;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function layerAlias(layer) {
|
|
21
|
+
if (layer === 'aip') return 'aviation';
|
|
22
|
+
if (layer === 'aviation') return 'aip';
|
|
23
|
+
return layer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isAipLayer(layer) {
|
|
27
|
+
return layer === 'aip' || layer === 'aviation';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function manifestLayer(layerId) {
|
|
31
|
+
return { id: String(layerId), style: String(layerId) };
|
|
32
|
+
}
|
package/src/style.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { layerAlias, manifestLayer } from './shared/layers.js';
|
|
2
|
+
|
|
3
|
+
export function getLayerRule(styleDocument, layer) {
|
|
4
|
+
const layers = styleDocument.layers && typeof styleDocument.layers === 'object' ? styleDocument.layers : {};
|
|
5
|
+
const id = layer.style || layer.id;
|
|
6
|
+
return normalizeStyleRule(layers[id] || layers[layerAlias(id)] || {});
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function normalizeStyleRule(rule) {
|
|
10
|
+
const normalized = { ...rule };
|
|
11
|
+
const visibility = objectRule(rule.visibility);
|
|
12
|
+
const body = objectRule(rule.body);
|
|
13
|
+
const center = objectRule(rule.center);
|
|
14
|
+
if (visibility) {
|
|
15
|
+
normalized.visible = visibility.visible ?? normalized.visible;
|
|
16
|
+
normalized.minZoom = visibility.minZoom ?? normalized.minZoom;
|
|
17
|
+
normalized.maxZoom = visibility.maxZoom ?? normalized.maxZoom;
|
|
18
|
+
}
|
|
19
|
+
if (body) {
|
|
20
|
+
normalized.stroke = body.color ?? normalized.stroke;
|
|
21
|
+
normalized.strokeWidth = body.width ?? normalized.strokeWidth;
|
|
22
|
+
normalized.strokeOpacity = body.opacity ?? normalized.strokeOpacity;
|
|
23
|
+
normalized.lineCap = body.lineCap ?? normalized.lineCap;
|
|
24
|
+
normalized.lineJoin = body.lineJoin ?? normalized.lineJoin;
|
|
25
|
+
normalized.widthScale = body.widthScale ?? normalized.widthScale;
|
|
26
|
+
}
|
|
27
|
+
if (center) {
|
|
28
|
+
normalized.fill = center.color ?? normalized.fill;
|
|
29
|
+
normalized.fillOpacity = center.opacity ?? normalized.fillOpacity;
|
|
30
|
+
}
|
|
31
|
+
if (!normalized.lineCap) normalized.lineCap = 'round';
|
|
32
|
+
if (!normalized.lineJoin) normalized.lineJoin = 'round';
|
|
33
|
+
return normalized;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function mergeFeatureRule(rule, feature) {
|
|
37
|
+
let merged = { ...rule };
|
|
38
|
+
const byProperty = objectRule(rule.byProperty);
|
|
39
|
+
if (!byProperty) return merged;
|
|
40
|
+
for (const [property, values] of Object.entries(byProperty)) {
|
|
41
|
+
const override = objectRule(values?.[String(feature.get(property) ?? '')]);
|
|
42
|
+
if (override) merged = normalizeStyleRule({ ...merged, ...override });
|
|
43
|
+
}
|
|
44
|
+
return merged;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function orderManifestLayers(manifest, styleDocument) {
|
|
48
|
+
const layers = Array.isArray(manifest.layers) ? manifest.layers.map(manifestLayer) : [];
|
|
49
|
+
const drawOrder = Array.isArray(styleDocument.drawOrder) ? styleDocument.drawOrder : layers.map((layer) => layer.id);
|
|
50
|
+
return [...layers].sort((a, b) => {
|
|
51
|
+
const ai = drawOrder.indexOf(a.id);
|
|
52
|
+
const bi = drawOrder.indexOf(b.id);
|
|
53
|
+
const ao = Number(getLayerRule(styleDocument, a).order ?? 0);
|
|
54
|
+
const bo = Number(getLayerRule(styleDocument, b).order ?? 0);
|
|
55
|
+
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi) || ao - bo;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function zoomMatchesRule(zoom, rule) {
|
|
60
|
+
if (Number.isFinite(rule.minZoom) && zoom < Number(rule.minZoom)) return false;
|
|
61
|
+
if (Number.isFinite(rule.maxZoom) && zoom > Number(rule.maxZoom)) return false;
|
|
62
|
+
return rule.visible !== false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function styleWidth(value, fallback, layerId, zoom) {
|
|
66
|
+
const width = Number(Array.isArray(value) ? fallback : value);
|
|
67
|
+
const base = Number.isFinite(width) && width > 0 ? width : fallback;
|
|
68
|
+
const scale = layerId === 'roads' ? Math.max(0.65, Math.min(1.35, 0.72 + zoom * 0.035)) : 1;
|
|
69
|
+
return Math.max(0, base * scale);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function objectRule(value) {
|
|
73
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
74
|
+
}
|