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
package/src/mvt.js ADDED
@@ -0,0 +1,2593 @@
1
+ import geojsonvt from 'geojson-vt';
2
+ import vtpbf from 'vt-pbf';
3
+
4
+ const TILE_EXTENT = 4096;
5
+ const MAX_ZOOM = 22;
6
+ const DEFAULT_MAX_FEATURES = 12000;
7
+ const TILE_DETAIL_LEVELS = new Set(['overview', 'normal', 'full']);
8
+ const ROAD_ZOOM_CLASSES = {
9
+ major: ['motorway', 'trunk', 'primary', 'secondary', 'motorway_link', 'trunk_link'],
10
+ mid: ['tertiary', 'tertiary_link', 'primary_link', 'secondary_link', 'busway'],
11
+ city: ['residential', 'unclassified', 'living_street'],
12
+ minor: [
13
+ 'service',
14
+ 'track',
15
+ 'path',
16
+ 'footway',
17
+ 'cycleway',
18
+ 'steps',
19
+ 'pedestrian',
20
+ 'corridor',
21
+ 'platform',
22
+ 'construction',
23
+ 'proposed',
24
+ 'road'
25
+ ]
26
+ };
27
+ const LANDUSE_OVERVIEW_FILTERS = [
28
+ { column: 'landuse', include: ['residential', 'industrial', 'commercial', 'retail', 'forest', 'farmland', 'military', 'reservoir'] },
29
+ { column: 'leisure', include: ['park', 'golf_course', 'recreation_ground'] },
30
+ { column: 'natural', include: ['wood', 'water', 'scrub', 'heath'] }
31
+ ];
32
+ const AVIATION_OVERVIEW_CLASSES = ['runway', 'taxiway', 'apron', 'stopway', 'taxilane'];
33
+ const AVIATION_MID_CLASSES = [...AVIATION_OVERVIEW_CLASSES, 'terminal', 'helipad', 'hangar', 'aerodrome', 'heliport'];
34
+ const OPERATIONAL_POI_CATEGORIES = [
35
+ 'transport',
36
+ 'emergency',
37
+ 'government',
38
+ 'energy',
39
+ 'communications',
40
+ 'protected',
41
+ 'industrial',
42
+ 'military',
43
+ 'operational'
44
+ ];
45
+ const POI_CATEGORY_FILTER_COLUMNS = [
46
+ 'poi_category',
47
+ 'amenity',
48
+ 'tourism',
49
+ 'shop',
50
+ 'leisure',
51
+ 'railway',
52
+ 'public_transport',
53
+ 'station',
54
+ 'aeroway',
55
+ 'power',
56
+ 'man_made',
57
+ 'tower:type',
58
+ 'military',
59
+ 'emergency',
60
+ 'office',
61
+ 'government',
62
+ 'boundary',
63
+ 'protect_class',
64
+ 'landuse',
65
+ 'industrial'
66
+ ];
67
+ const LABEL_LAYERS = {
68
+ road_labels: {
69
+ sourceLayer: 'roads',
70
+ minZoom: 13
71
+ },
72
+ aip_labels: {
73
+ sourceLayer: 'aip',
74
+ minZoom: 12
75
+ },
76
+ aviation_labels: {
77
+ sourceLayer: 'aviation',
78
+ minZoom: 12
79
+ },
80
+ poi_labels: {
81
+ sourceLayer: 'pois',
82
+ minZoom: 17
83
+ }
84
+ };
85
+
86
+ function isAipLayer(layerId) {
87
+ return layerId === 'aip' || layerId === 'aviation';
88
+ }
89
+
90
+ function isAipLabelLayer(layerId) {
91
+ return layerId === 'aip_labels' || layerId === 'aviation_labels';
92
+ }
93
+ const LABEL_TEXT_FIELDS = ['name', 'ref', 'iata', 'icao', 'operator', 'official_name', 'short_name'];
94
+ const GENERIC_LABEL_VALUES = new Set([
95
+ 'yes',
96
+ 'no',
97
+ 'true',
98
+ 'false',
99
+ 'unknown',
100
+ 'none',
101
+ 'generator',
102
+ 'tower',
103
+ 'line',
104
+ 'plant',
105
+ 'substation',
106
+ 'transformer',
107
+ 'mast',
108
+ 'antenna',
109
+ 'station',
110
+ 'airport',
111
+ 'aerodrome',
112
+ 'runway',
113
+ 'taxiway',
114
+ 'terminal',
115
+ 'apron',
116
+ 'pharmacy',
117
+ 'fuel',
118
+ 'charging_station',
119
+ 'hospital',
120
+ 'clinic',
121
+ 'police',
122
+ 'fire_station',
123
+ 'fire_hydrant',
124
+ 'defibrillator',
125
+ 'fire_extinguisher',
126
+ 'shelter',
127
+ 'railway_station',
128
+ 'train_station',
129
+ 'subway_station',
130
+ 'bus_station',
131
+ 'ferry_terminal',
132
+ 'townhall',
133
+ 'courthouse',
134
+ 'prison',
135
+ 'bunker',
136
+ 'checkpoint',
137
+ 'communications_tower',
138
+ 'protected_area',
139
+ 'nature_reserve',
140
+ 'restaurant',
141
+ 'cafe',
142
+ 'bar',
143
+ 'fast_food',
144
+ 'pub',
145
+ 'shop',
146
+ 'retail',
147
+ 'commercial',
148
+ 'tourism',
149
+ 'attraction',
150
+ 'hotel',
151
+ 'transport',
152
+ 'emergency',
153
+ 'government',
154
+ 'energy',
155
+ 'communications',
156
+ 'protected',
157
+ 'industrial',
158
+ 'military',
159
+ 'operational',
160
+ 'consumer'
161
+ ]);
162
+
163
+ /**
164
+ * @typedef {{
165
+ * originalVertexCount: number,
166
+ * simplifiedVertexCount: number,
167
+ * droppedSmallFeatures: number,
168
+ * simplificationTolerance: number
169
+ * }} GeneralizationStats
170
+ */
171
+
172
+ /**
173
+ * Encode one logical layer as a Mapbox Vector Tile.
174
+ *
175
+ * @param {{
176
+ * getLayers: () => Array<Record<string, unknown>>,
177
+ * getTileFeatures: (layerId: string, bbox: [number, number, number, number], filters?: import('./gpkg-read.js').TileQueryFilters) => Array<Record<string, unknown>>
178
+ * }} reader
179
+ * @param {string} layerId
180
+ * @param {string | number} zValue
181
+ * @param {string | number} xValue
182
+ * @param {string | number} yValue
183
+ * @param {{ detail?: string, maxFeatures?: number, style?: Record<string, unknown> | null }} [options]
184
+ * @returns {Buffer}
185
+ */
186
+ export function encodeMvtTile(reader, layerId, zValue, xValue, yValue, options = {}) {
187
+ return encodeMvtTileWithStats(reader, layerId, zValue, xValue, yValue, options).buffer;
188
+ }
189
+
190
+ /**
191
+ * Encode one logical layer as a Mapbox Vector Tile and return lightweight timing stats.
192
+ *
193
+ * @param {{
194
+ * getLayers: () => Array<Record<string, unknown>>,
195
+ * getTileFeatures: (layerId: string, bbox: [number, number, number, number], filters?: import('./gpkg-read.js').TileQueryFilters) => Array<Record<string, unknown>>
196
+ * }} reader
197
+ * @param {string} layerId
198
+ * @param {string | number} zValue
199
+ * @param {string | number} xValue
200
+ * @param {string | number} yValue
201
+ * @param {{ detail?: string, maxFeatures?: number }} [options]
202
+ * @returns {{ buffer: Buffer, featureCount: number, originalFeatureCount: number, encodedFeatureCount: number, droppedFeatureCount: number, bbox: [number, number, number, number], layerNames: string[], emptyReason: string, originalVertexCount: number, simplifiedVertexCount: number, droppedSmallFeatures: number, simplificationTolerance: number }}
203
+ */
204
+ export function encodeMvtTileWithStats(reader, layerId, zValue, xValue, yValue, options = {}) {
205
+ const { z, x, y } = parseTileParams(zValue, xValue, yValue);
206
+ const bbox = tileToBbox(z, x, y);
207
+ const detail = normalizeDetail(options.detail, z);
208
+ validateRequestedLayers(reader.getLayers(), new Set([layerId]));
209
+ const layerFeatures = readRequestedLayerFeatures(reader, layerId, bbox, z, options.style ?? null, Boolean(options.debugLabels));
210
+ const limited = applyFeatureLimit([layerFeatures], maxFeaturesForZoom(z, options.maxFeatures), z);
211
+ const layerTile = createLayerTile(limited.layers[0]?.features ?? [], bbox, z, x, y, layerId, detail);
212
+ const tile = layerTile.tile;
213
+ const encodedFeatureCount = tile.features.length;
214
+ const droppedFeatureCount = limited.droppedFeatureCount + layerTile.stats.droppedSmallFeatures;
215
+
216
+ return {
217
+ buffer: Buffer.from(vtpbf.fromGeojsonVt({ [layerId]: tile }, {
218
+ extent: TILE_EXTENT,
219
+ version: 2
220
+ })),
221
+ featureCount: encodedFeatureCount,
222
+ originalFeatureCount: layerFeatures.originalFeatureCount,
223
+ encodedFeatureCount,
224
+ droppedFeatureCount,
225
+ bbox,
226
+ layerNames: [layerId],
227
+ emptyReason: emptyReasonForTile(layerFeatures.originalFeatureCount, encodedFeatureCount, droppedFeatureCount, limited.layers),
228
+ originalVertexCount: layerTile.stats.originalVertexCount,
229
+ simplifiedVertexCount: layerTile.stats.simplifiedVertexCount,
230
+ droppedSmallFeatures: layerTile.stats.droppedSmallFeatures,
231
+ simplificationTolerance: layerTile.stats.simplificationTolerance
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Encode all readable logical layers as one Mapbox Vector Tile.
237
+ *
238
+ * @param {{
239
+ * getLayers: () => Array<Record<string, unknown>>,
240
+ * getTileFeatures: (layerId: string, bbox: [number, number, number, number], filters?: import('./gpkg-read.js').TileQueryFilters) => Array<Record<string, unknown>>
241
+ * }} reader
242
+ * @param {string | number} zValue
243
+ * @param {string | number} xValue
244
+ * @param {string | number} yValue
245
+ * @param {string[]} [layerIds]
246
+ * @param {{ detail?: string, maxFeatures?: number, style?: Record<string, unknown> | null }} [options]
247
+ * @returns {Buffer}
248
+ */
249
+ export function encodeMvtTileSet(reader, zValue, xValue, yValue, layerIds, options = {}) {
250
+ return encodeMvtTileSetWithStats(reader, zValue, xValue, yValue, layerIds, options).buffer;
251
+ }
252
+
253
+ /**
254
+ * Encode all requested logical layers as one Mapbox Vector Tile and return lightweight timing stats.
255
+ *
256
+ * @param {{
257
+ * getLayers: () => Array<Record<string, unknown>>,
258
+ * getTileFeatures: (layerId: string, bbox: [number, number, number, number], filters?: import('./gpkg-read.js').TileQueryFilters) => Array<Record<string, unknown>>
259
+ * }} reader
260
+ * @param {string | number} zValue
261
+ * @param {string | number} xValue
262
+ * @param {string | number} yValue
263
+ * @param {string[]} [layerIds]
264
+ * @param {{ detail?: string, maxFeatures?: number }} [options]
265
+ * @returns {{ buffer: Buffer, featureCount: number, originalFeatureCount: number, encodedFeatureCount: number, droppedFeatureCount: number, bbox: [number, number, number, number], layerNames: string[], emptyReason: string, originalVertexCount: number, simplifiedVertexCount: number, droppedSmallFeatures: number, simplificationTolerance: number }}
266
+ */
267
+ export function encodeMvtTileSetWithStats(reader, zValue, xValue, yValue, layerIds, options = {}) {
268
+ const { z, x, y } = parseTileParams(zValue, xValue, yValue);
269
+ const bbox = tileToBbox(z, x, y);
270
+ const detail = normalizeDetail(options.detail, z);
271
+ /** @type {Record<string, { features: unknown[] }>} */
272
+ const layers = {};
273
+ let originalFeatureCount = 0;
274
+ let encodedFeatureCount = 0;
275
+ /** @type {GeneralizationStats} */
276
+ const generalizationStats = emptyGeneralizationStats();
277
+ const requested = layerIds ? new Set(layerIds) : null;
278
+ const metadata = reader.getLayers();
279
+
280
+ if (requested) {
281
+ validateRequestedLayers(metadata, requested);
282
+ }
283
+
284
+ const layerFeatureBatches = [];
285
+ for (const layerId of requestedLayerIds(metadata, requested)) {
286
+ if (!isLabelLayerId(layerId)) {
287
+ const layer = metadata.find((item) => String(item.id) === layerId);
288
+ if (!layer?.exists || !layer.rtree) {
289
+ if (requested) {
290
+ throw httpError(400, `layer is not tile-readable: ${layerId}`);
291
+ }
292
+ continue;
293
+ }
294
+ }
295
+
296
+ const layerFeatures = readRequestedLayerFeatures(reader, layerId, bbox, z, options.style ?? null, Boolean(options.debugLabels));
297
+ originalFeatureCount += layerFeatures.originalFeatureCount;
298
+ layerFeatureBatches.push(layerFeatures);
299
+ }
300
+
301
+ const limited = applyFeatureLimit(layerFeatureBatches, maxFeaturesForZoom(z, options.maxFeatures), z);
302
+ for (const layerFeatures of limited.layers) {
303
+ const layerTile = createLayerTile(layerFeatures.features, bbox, z, x, y, layerFeatures.layerId, detail);
304
+ layers[layerFeatures.layerId] = layerTile.tile;
305
+ addGeneralizationStats(generalizationStats, layerTile.stats);
306
+ encodedFeatureCount += layers[layerFeatures.layerId].features.length;
307
+ }
308
+ const droppedFeatureCount = limited.droppedFeatureCount + generalizationStats.droppedSmallFeatures;
309
+
310
+ return {
311
+ buffer: Buffer.from(vtpbf.fromGeojsonVt(layers, {
312
+ extent: TILE_EXTENT,
313
+ version: 2
314
+ })),
315
+ featureCount: encodedFeatureCount,
316
+ originalFeatureCount,
317
+ encodedFeatureCount,
318
+ droppedFeatureCount,
319
+ bbox,
320
+ layerNames: limited.layers.map((layer) => layer.layerId),
321
+ emptyReason: emptyReasonForTile(originalFeatureCount, encodedFeatureCount, droppedFeatureCount, limited.layers),
322
+ originalVertexCount: generalizationStats.originalVertexCount,
323
+ simplifiedVertexCount: generalizationStats.simplifiedVertexCount,
324
+ droppedSmallFeatures: generalizationStats.droppedSmallFeatures,
325
+ simplificationTolerance: generalizationStats.simplificationTolerance
326
+ };
327
+ }
328
+
329
+ /**
330
+ * @param {Array<Record<string, unknown>>} metadata
331
+ * @param {Set<string>} requested
332
+ */
333
+ function validateRequestedLayers(metadata, requested) {
334
+ const knownLayerIds = new Set(metadata.map((layer) => String(layer.id)));
335
+ for (const layerId of [...knownLayerIds]) {
336
+ if (isAipLayer(layerId)) {
337
+ knownLayerIds.add(layerId === 'aip' ? 'aviation' : 'aip');
338
+ }
339
+ }
340
+ for (const layerId of requested) {
341
+ if (!knownLayerIds.has(layerId) && !isReadableLabelLayer(metadata, layerId)) {
342
+ throw httpError(404, `unknown layer: ${layerId}`);
343
+ }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * @param {Array<Record<string, unknown>>} metadata
349
+ * @param {Set<string> | null} requested
350
+ * @returns {string[]}
351
+ */
352
+ function requestedLayerIds(metadata, requested) {
353
+ if (requested) {
354
+ return [...requested];
355
+ }
356
+
357
+ return metadata.map((layer) => String(layer.id));
358
+ }
359
+
360
+ /**
361
+ * @param {Array<Record<string, unknown>>} metadata
362
+ * @param {string} layerId
363
+ * @returns {boolean}
364
+ */
365
+ function isReadableLabelLayer(metadata, layerId) {
366
+ const definition = LABEL_LAYERS[layerId];
367
+ if (!definition) {
368
+ return false;
369
+ }
370
+
371
+ const source = metadata.find((layer) => String(layer.id) === definition.sourceLayer ||
372
+ (isAipLayer(String(layer.id)) && isAipLayer(definition.sourceLayer)));
373
+ return Boolean(source?.exists && source?.rtree);
374
+ }
375
+
376
+ /**
377
+ * @param {string} layerId
378
+ * @returns {boolean}
379
+ */
380
+ function isLabelLayerId(layerId) {
381
+ return Boolean(LABEL_LAYERS[layerId]);
382
+ }
383
+
384
+ /**
385
+ * @param {{
386
+ * getTileFeatures: (layerId: string, bbox: [number, number, number, number], filters?: import('./gpkg-read.js').TileQueryFilters) => Array<Record<string, unknown>>
387
+ * }} reader
388
+ * @param {string} layerId
389
+ * @param {[number, number, number, number]} bbox
390
+ * @param {number} z
391
+ * @param {Record<string, unknown> | null} style
392
+ * @param {boolean} debugLabels
393
+ * @returns {{ layerId: string, features: Array<Record<string, unknown>>, originalFeatureCount: number }}
394
+ */
395
+ function readRequestedLayerFeatures(reader, layerId, bbox, z, style, debugLabels) {
396
+ return isLabelLayerId(layerId)
397
+ ? readLabelLayerFeatures(reader, layerId, bbox, z, style, debugLabels)
398
+ : readLayerFeatures(reader, layerId, bbox, z, style);
399
+ }
400
+
401
+ /**
402
+ * @param {{
403
+ * getTileFeatures: (layerId: string, bbox: [number, number, number, number], filters?: import('./gpkg-read.js').TileQueryFilters) => Array<Record<string, unknown>>
404
+ * }} reader
405
+ * @param {string} layerId
406
+ * @param {[number, number, number, number]} bbox
407
+ * @param {number} z
408
+ * @param {Record<string, unknown> | null} style
409
+ * @returns {{ layerId: string, features: Array<Record<string, unknown>>, originalFeatureCount: number }}
410
+ */
411
+ function readLayerFeatures(reader, layerId, bbox, z, style) {
412
+ if (!shouldReadLayerAtZoom(layerId, z)) {
413
+ return {
414
+ layerId,
415
+ features: [],
416
+ originalFeatureCount: 0
417
+ };
418
+ }
419
+
420
+ const filters = tileQueryFiltersForLayer(layerId, z, style, bbox);
421
+ const rawFeatures = reader.getTileFeatures(layerId, bbox, filters);
422
+ const features = layerId === 'pois'
423
+ ? filterPoiFeatures(enrichPoiFeatures(rawFeatures), style)
424
+ : rawFeatures;
425
+ return {
426
+ layerId,
427
+ features,
428
+ originalFeatureCount: rawFeatures.length
429
+ };
430
+ }
431
+
432
+ /**
433
+ * @param {{
434
+ * getTileFeatures: (layerId: string, bbox: [number, number, number, number], filters?: import('./gpkg-read.js').TileQueryFilters) => Array<Record<string, unknown>>
435
+ * }} reader
436
+ * @param {string} layerId
437
+ * @param {[number, number, number, number]} bbox
438
+ * @param {number} z
439
+ * @param {Record<string, unknown> | null} style
440
+ * @param {boolean} debugLabels
441
+ * @returns {{ layerId: string, features: Array<Record<string, unknown>>, originalFeatureCount: number }}
442
+ */
443
+ function readLabelLayerFeatures(reader, layerId, bbox, z, style, debugLabels) {
444
+ const definition = LABEL_LAYERS[layerId];
445
+ if (!definition || z < definition.minZoom) {
446
+ return {
447
+ layerId,
448
+ features: [],
449
+ originalFeatureCount: 0
450
+ };
451
+ }
452
+
453
+ const sourceLayer = definition.sourceLayer;
454
+ if (!shouldReadLayerAtZoom(sourceLayer, z)) {
455
+ return {
456
+ layerId,
457
+ features: [],
458
+ originalFeatureCount: 0
459
+ };
460
+ }
461
+
462
+ const rawSourceFeatures = reader.getTileFeatures(sourceLayer, bbox, tileQueryFiltersForLayer(sourceLayer, z, style, bbox));
463
+ const sourceFeatures = sourceLayer === 'pois'
464
+ ? filterPoiFeatures(enrichPoiFeatures(rawSourceFeatures), style)
465
+ : rawSourceFeatures;
466
+ const candidates = buildLabelCandidates(layerId, sourceLayer, sourceFeatures, z, debugLabels);
467
+ return {
468
+ layerId,
469
+ features: topFeatures(layerId, candidates, labelFeatureLimit(layerId, z), z),
470
+ originalFeatureCount: candidates.length
471
+ };
472
+ }
473
+
474
+ /**
475
+ * @param {string} layerId
476
+ * @param {number} z
477
+ * @returns {boolean}
478
+ */
479
+ function shouldReadLayerAtZoom(layerId, z) {
480
+ if (layerId === 'buildings') {
481
+ return z >= 12;
482
+ }
483
+
484
+ if (layerId === 'pois') {
485
+ return z >= 16;
486
+ }
487
+
488
+ if (isAipLayer(layerId)) {
489
+ return z >= 10;
490
+ }
491
+
492
+ return true;
493
+ }
494
+
495
+ /**
496
+ * @param {string} layerId
497
+ * @param {number} z
498
+ * @param {Record<string, unknown> | null} style
499
+ * @param {[number, number, number, number]} bbox
500
+ * @returns {import('./gpkg-read.js').TileQueryFilters}
501
+ */
502
+ function tileQueryFiltersForLayer(layerId, z, style, bbox) {
503
+ if (layerId === 'roads') {
504
+ return {
505
+ all: [
506
+ {
507
+ column: 'highway',
508
+ include: roadClassesForZoom(z)
509
+ }
510
+ ]
511
+ };
512
+ }
513
+
514
+ if (layerId === 'landuse' && z < 12) {
515
+ return {
516
+ any: LANDUSE_OVERVIEW_FILTERS
517
+ };
518
+ }
519
+
520
+ if (layerId === 'boundaries' && z < 12) {
521
+ return {
522
+ all: [
523
+ {
524
+ column: 'admin_level',
525
+ maxNumber: 7
526
+ }
527
+ ]
528
+ };
529
+ }
530
+
531
+ if (isAipLayer(layerId)) {
532
+ return {
533
+ all: [
534
+ {
535
+ column: 'aeroway',
536
+ include: z < 13 ? AVIATION_OVERVIEW_CLASSES : AVIATION_MID_CLASSES
537
+ }
538
+ ]
539
+ };
540
+ }
541
+
542
+ if (layerId === 'pois') {
543
+ return poiTileQueryFilters(style);
544
+ }
545
+
546
+ if (layerId === 'buildings' && z < 15) {
547
+ return {
548
+ minRtreeSpan: buildingMinRtreeSpan(bbox, z)
549
+ };
550
+ }
551
+
552
+ return {};
553
+ }
554
+
555
+ /**
556
+ * Filter tiny building footprints in SQLite before geometry decoding. This is
557
+ * deliberately stronger than the final screen-size filter at z12/z13 because
558
+ * dense urban tiles can otherwise decode tens of thousands of buildings that
559
+ * will be dropped later.
560
+ *
561
+ * @param {[number, number, number, number]} bbox
562
+ * @param {number} z
563
+ * @returns {number}
564
+ */
565
+ function buildingMinRtreeSpan(bbox, z) {
566
+ const tileSpan = tileSpanForBbox(bbox);
567
+ if (z <= 12) {
568
+ return (tileSpan / TILE_EXTENT) * 60;
569
+ }
570
+
571
+ if (z === 13) {
572
+ return (tileSpan / TILE_EXTENT) * 34;
573
+ }
574
+
575
+ if (z === 14) {
576
+ return (tileSpan / TILE_EXTENT) * 12;
577
+ }
578
+
579
+ return 0;
580
+ }
581
+
582
+ /**
583
+ * @param {Record<string, unknown> | null} style
584
+ * @returns {import('./gpkg-read.js').TileQueryFilters}
585
+ */
586
+ function poiTileQueryFilters(style) {
587
+ const categories = poiCategoryVisibility(style);
588
+ if (categories.consumer === true) {
589
+ return {};
590
+ }
591
+
592
+ const enabledCategories = enabledPoiCategories(categories);
593
+ if (enabledCategories.length === 0) {
594
+ return {
595
+ all: [
596
+ {
597
+ column: 'amenity',
598
+ include: []
599
+ }
600
+ ]
601
+ };
602
+ }
603
+
604
+ return {
605
+ any: poiCategorySqlFilters(enabledCategories, style)
606
+ };
607
+ }
608
+
609
+ /**
610
+ * @param {string[]} enabledCategories
611
+ * @param {Record<string, unknown> | null} style
612
+ * @returns {Array<{ column: string, include: string[] }>}
613
+ */
614
+ function poiCategorySqlFilters(enabledCategories, style) {
615
+ const directlyVisibleCategories = enabledCategories.filter((category) => category !== 'operational' && category !== 'consumer');
616
+ const filters = directlyVisibleCategories.length > 0
617
+ ? [{ column: 'poi_category', include: directlyVisibleCategories }]
618
+ : [];
619
+ const poiRules = [
620
+ objectRule(objectRule(style?.layers)?.pois),
621
+ objectRule(objectRule(style?.labels)?.pois)
622
+ ].filter(Boolean);
623
+
624
+ const configuredClasses = poiRules
625
+ .map((rule) => objectRule(rule?.classes))
626
+ .find(Boolean);
627
+ if (configuredClasses) {
628
+ filters.push(...propertyFiltersFromClassConfig(configuredClasses));
629
+ return filters;
630
+ }
631
+
632
+ filters.push(...defaultPoiClassFilters(enabledCategories));
633
+ return filters;
634
+ }
635
+
636
+ /**
637
+ * @param {Record<string, unknown>} classes
638
+ * @returns {Array<{ column: string, include: string[] }>}
639
+ */
640
+ function propertyFiltersFromClassConfig(classes) {
641
+ const filters = [];
642
+ for (const column of POI_CATEGORY_FILTER_COLUMNS) {
643
+ if (column === 'poi_category') {
644
+ continue;
645
+ }
646
+ const values = Array.isArray(classes[column])
647
+ ? /** @type {unknown[]} */ (classes[column]).map(String).filter(Boolean)
648
+ : [];
649
+ if (values.length > 0) {
650
+ filters.push({ column, include: values });
651
+ }
652
+ }
653
+ return filters;
654
+ }
655
+
656
+ /**
657
+ * @param {string[]} enabledCategories
658
+ * @returns {Array<{ column: string, include: string[] }>}
659
+ */
660
+ function defaultPoiClassFilters(enabledCategories) {
661
+ const filters = [];
662
+ const add = (column, include) => {
663
+ if (include.length > 0) {
664
+ filters.push({ column, include });
665
+ }
666
+ };
667
+
668
+ if (enabledCategories.includes('transport')) {
669
+ add('amenity', ['bus_station', 'ferry_terminal', 'airport', 'railway_station', 'train_station', 'subway_station']);
670
+ add('railway', ['station']);
671
+ add('public_transport', ['station']);
672
+ add('station', ['subway']);
673
+ add('aeroway', ['aerodrome', 'airport']);
674
+ }
675
+
676
+ if (enabledCategories.includes('emergency')) {
677
+ add('amenity', ['hospital', 'police', 'fire_station', 'shelter']);
678
+ add('emergency', ['ambulance_station', 'siren', 'assembly_point', 'disaster_response']);
679
+ }
680
+
681
+ if (enabledCategories.includes('government')) {
682
+ add('amenity', ['townhall', 'courthouse', 'prison', 'embassy', 'research_institute']);
683
+ add('office', ['government']);
684
+ add('government', ['yes', 'public_safety', 'border_control', 'customs', 'ministry', 'aerospace']);
685
+ }
686
+
687
+ if (enabledCategories.includes('energy')) {
688
+ add('power', ['plant', 'substation', 'generator', 'tower', 'transformer', 'line']);
689
+ }
690
+
691
+ if (enabledCategories.includes('communications')) {
692
+ add('amenity', ['communications_tower']);
693
+ add('man_made', ['communications_tower', 'mast', 'antenna']);
694
+ add('tower:type', ['communication']);
695
+ }
696
+
697
+ if (enabledCategories.includes('protected')) {
698
+ add('boundary', ['protected_area']);
699
+ add('leisure', ['nature_reserve']);
700
+ add('tourism', ['national_park']);
701
+ }
702
+
703
+ if (enabledCategories.includes('industrial')) {
704
+ add('landuse', ['industrial']);
705
+ add('industrial', ['refinery', 'depot', 'storage', 'logistics']);
706
+ add('amenity', ['depot', 'warehouse']);
707
+ }
708
+
709
+ if (enabledCategories.includes('military')) {
710
+ add('amenity', ['bunker', 'checkpoint']);
711
+ add('military', ['barracks', 'bunker', 'checkpoint', 'airbase', 'naval_base', 'range', 'training_area', 'base', 'airfield', 'danger_area', 'ammunition_dump']);
712
+ }
713
+
714
+ return filters;
715
+ }
716
+
717
+ /**
718
+ * @param {Array<Record<string, unknown>>} features
719
+ * @returns {Array<Record<string, unknown>>}
720
+ */
721
+ function enrichPoiFeatures(features) {
722
+ return features.map((feature) => {
723
+ const properties = /** @type {Record<string, unknown>} */ (feature.properties ?? {});
724
+ const category = poiCategoryForProperties(properties);
725
+ return {
726
+ ...feature,
727
+ properties: {
728
+ ...properties,
729
+ poi_category: category
730
+ }
731
+ };
732
+ });
733
+ }
734
+
735
+ /**
736
+ * @param {Array<Record<string, unknown>>} features
737
+ * @param {Record<string, unknown> | null} style
738
+ * @returns {Array<Record<string, unknown>>}
739
+ */
740
+ function filterPoiFeatures(features, style) {
741
+ const visibility = poiCategoryVisibility(style);
742
+ const classFilters = poiClassFilters(style, enabledPoiCategories(visibility));
743
+ return features.filter((feature) => {
744
+ const properties = /** @type {Record<string, unknown>} */ (feature.properties ?? {});
745
+ const category = String(properties.poi_category ?? poiCategoryForProperties(properties));
746
+ if (visibility[category] !== true) {
747
+ return false;
748
+ }
749
+
750
+ return matchesAnyPoiClassFilter(properties, classFilters);
751
+ });
752
+ }
753
+
754
+ /**
755
+ * @param {Record<string, unknown> | null} style
756
+ * @param {string[]} enabledCategories
757
+ * @returns {Array<{ column: string, include: string[] }>}
758
+ */
759
+ function poiClassFilters(style, enabledCategories) {
760
+ const poiRules = [
761
+ objectRule(objectRule(style?.layers)?.pois),
762
+ objectRule(objectRule(style?.labels)?.pois)
763
+ ].filter(Boolean);
764
+ const configuredClasses = poiRules
765
+ .map((rule) => objectRule(rule?.classes))
766
+ .find(Boolean);
767
+ return configuredClasses
768
+ ? propertyFiltersFromClassConfig(configuredClasses)
769
+ : defaultPoiClassFilters(enabledCategories);
770
+ }
771
+
772
+ /**
773
+ * @param {Record<string, unknown>} properties
774
+ * @param {Array<{ column: string, include: string[] }>} filters
775
+ * @returns {boolean}
776
+ */
777
+ function matchesAnyPoiClassFilter(properties, filters) {
778
+ for (const filter of filters) {
779
+ const value = String(properties[filter.column] ?? '');
780
+ if (value && filter.include.includes(value)) {
781
+ return true;
782
+ }
783
+ }
784
+
785
+ return false;
786
+ }
787
+
788
+ /**
789
+ * @param {Record<string, unknown> | null} style
790
+ * @returns {Record<string, boolean>}
791
+ */
792
+ function poiCategoryVisibility(style) {
793
+ const defaults = Object.fromEntries(OPERATIONAL_POI_CATEGORIES.map((category) => [category, true]));
794
+ defaults.consumer = false;
795
+
796
+ const ruleCategories = [
797
+ objectRule(objectRule(style?.layers)?.pois)?.categories,
798
+ objectRule(objectRule(style?.labels)?.pois)?.categories
799
+ ].map(objectRule).filter(Boolean);
800
+
801
+ for (const categories of ruleCategories) {
802
+ for (const [category, visible] of Object.entries(categories)) {
803
+ defaults[category] = visible === true;
804
+ }
805
+ }
806
+
807
+ return /** @type {Record<string, boolean>} */ (defaults);
808
+ }
809
+
810
+ /**
811
+ * @param {Record<string, boolean>} visibility
812
+ * @returns {string[]}
813
+ */
814
+ function enabledPoiCategories(visibility) {
815
+ return Object.entries(visibility)
816
+ .filter(([, visible]) => visible === true)
817
+ .map(([category]) => category);
818
+ }
819
+
820
+ /**
821
+ * @param {Record<string, unknown>} properties
822
+ * @returns {string}
823
+ */
824
+ function poiCategoryForProperties(properties) {
825
+ const existing = String(properties.poi_category ?? '').trim();
826
+ if (existing) {
827
+ return existing;
828
+ }
829
+
830
+ const amenity = String(properties.amenity ?? '');
831
+ const tourism = String(properties.tourism ?? '');
832
+ const shop = String(properties.shop ?? '');
833
+ const leisure = String(properties.leisure ?? '');
834
+ const railway = String(properties.railway ?? '');
835
+ const publicTransport = String(properties.public_transport ?? '');
836
+ const station = String(properties.station ?? '');
837
+ const aeroway = String(properties.aeroway ?? '');
838
+ const power = String(properties.power ?? '');
839
+ const manMade = String(properties.man_made ?? '');
840
+ const towerType = String(properties['tower:type'] ?? '');
841
+ const military = String(properties.military ?? '');
842
+ const emergency = String(properties.emergency ?? '');
843
+ const office = String(properties.office ?? '');
844
+ const government = String(properties.government ?? '');
845
+ const boundary = String(properties.boundary ?? '');
846
+ const protectClass = String(properties.protect_class ?? '');
847
+ const landuse = String(properties.landuse ?? '');
848
+ const industrial = String(properties.industrial ?? '');
849
+
850
+ if (
851
+ ['railway_station', 'train_station', 'subway_station', 'bus_station', 'ferry_terminal', 'airport'].includes(amenity) ||
852
+ railway === 'station' ||
853
+ publicTransport === 'station' ||
854
+ station === 'subway' ||
855
+ aeroway === 'aerodrome' ||
856
+ aeroway === 'airport'
857
+ ) {
858
+ return 'transport';
859
+ }
860
+
861
+ if (['hospital', 'police', 'fire_station', 'shelter'].includes(amenity) || ['ambulance_station', 'siren', 'assembly_point', 'disaster_response'].includes(emergency)) {
862
+ return 'emergency';
863
+ }
864
+
865
+ if (['townhall', 'courthouse', 'prison', 'embassy'].includes(amenity) || office === 'government' || government) {
866
+ return 'government';
867
+ }
868
+
869
+ if (power) {
870
+ return 'energy';
871
+ }
872
+
873
+ if (amenity === 'communications_tower' || manMade === 'communications_tower' || manMade === 'mast' || manMade === 'antenna' || towerType === 'communication') {
874
+ return 'communications';
875
+ }
876
+
877
+ if (boundary === 'protected_area' || leisure === 'nature_reserve' || tourism === 'national_park' || protectClass) {
878
+ return 'protected';
879
+ }
880
+
881
+ if (landuse === 'industrial' || industrial || ['depot', 'warehouse'].includes(amenity)) {
882
+ return 'industrial';
883
+ }
884
+
885
+ if (military || ['bunker', 'checkpoint'].includes(amenity)) {
886
+ return 'military';
887
+ }
888
+
889
+ if (shop || tourism || leisure || ['restaurant', 'cafe', 'bar', 'fast_food', 'pub'].includes(amenity)) {
890
+ return 'consumer';
891
+ }
892
+
893
+ return 'operational';
894
+ }
895
+
896
+ /**
897
+ * @param {unknown} rule
898
+ * @returns {Record<string, unknown> | null}
899
+ */
900
+ function objectRule(rule) {
901
+ return rule && typeof rule === 'object' ? /** @type {Record<string, unknown>} */ (rule) : null;
902
+ }
903
+
904
+ /**
905
+ * @param {number} z
906
+ * @returns {string[]}
907
+ */
908
+ function roadClassesForZoom(z) {
909
+ if (z <= 11) {
910
+ return ROAD_ZOOM_CLASSES.major;
911
+ }
912
+
913
+ if (z <= 13) {
914
+ return [...ROAD_ZOOM_CLASSES.major, ...ROAD_ZOOM_CLASSES.mid];
915
+ }
916
+
917
+ if (z === 14) {
918
+ return [...ROAD_ZOOM_CLASSES.major, ...ROAD_ZOOM_CLASSES.mid, ...ROAD_ZOOM_CLASSES.city];
919
+ }
920
+
921
+ return [
922
+ ...ROAD_ZOOM_CLASSES.major,
923
+ ...ROAD_ZOOM_CLASSES.mid,
924
+ ...ROAD_ZOOM_CLASSES.city,
925
+ ...ROAD_ZOOM_CLASSES.minor
926
+ ];
927
+ }
928
+
929
+ /**
930
+ * @param {string} labelLayerId
931
+ * @param {string} sourceLayer
932
+ * @param {Array<Record<string, unknown>>} features
933
+ * @param {number} z
934
+ * @param {boolean} debugLabels
935
+ * @returns {Array<Record<string, unknown>>}
936
+ */
937
+ function buildLabelCandidates(labelLayerId, sourceLayer, features, z, debugLabels) {
938
+ const candidates = [];
939
+ for (const feature of features) {
940
+ const candidate = labelCandidateForFeature(labelLayerId, sourceLayer, feature, z, debugLabels);
941
+ if (candidate) {
942
+ candidates.push(candidate);
943
+ }
944
+ }
945
+ return candidates;
946
+ }
947
+
948
+ /**
949
+ * @param {string} labelLayerId
950
+ * @param {string} sourceLayer
951
+ * @param {Record<string, unknown>} feature
952
+ * @param {number} z
953
+ * @param {boolean} debugLabels
954
+ * @returns {Record<string, unknown> | null}
955
+ */
956
+ function labelCandidateForFeature(labelLayerId, sourceLayer, feature, z, debugLabels) {
957
+ const properties = /** @type {Record<string, unknown>} */ (feature.properties ?? {});
958
+ const geometry = /** @type {{ type?: string, coordinates?: unknown }} */ (feature.geometry ?? {});
959
+
960
+ if (labelLayerId === 'road_labels') {
961
+ const highway = String(properties.highway ?? '');
962
+ if (!shouldLabelRoadClass(highway, z)) {
963
+ return null;
964
+ }
965
+
966
+ const text = firstMeaningfulText(properties, ['ref', 'name']);
967
+ if (!text) {
968
+ debugRejectedLabel(debugLabels, labelLayerId, properties, 'missing meaningful road ref/name');
969
+ return null;
970
+ }
971
+
972
+ return createLabelFeature({
973
+ sourceLayer,
974
+ sourceId: properties.id,
975
+ text,
976
+ className: highway,
977
+ priority: roadLabelPriority(highway, properties, z),
978
+ minZoom: roadLabelMinZoom(highway),
979
+ coordinate: labelAnchorForGeometry(geometry)
980
+ });
981
+ }
982
+
983
+ if (isAipLabelLayer(labelLayerId)) {
984
+ const aeroway = String(properties.aeroway ?? '');
985
+ if (!shouldLabelAviationClass(aeroway, z)) {
986
+ return null;
987
+ }
988
+
989
+ const text = aviationLabelText(properties, aeroway);
990
+ if (!text) {
991
+ debugRejectedLabel(debugLabels, labelLayerId, properties, 'missing meaningful aviation label');
992
+ return null;
993
+ }
994
+
995
+ return createLabelFeature({
996
+ sourceLayer,
997
+ sourceId: properties.id,
998
+ text,
999
+ className: aeroway,
1000
+ aeroway,
1001
+ priority: aviationLabelPriority(aeroway),
1002
+ minZoom: aviationLabelMinZoom(aeroway),
1003
+ coordinate: labelAnchorForGeometry(geometry)
1004
+ });
1005
+ }
1006
+
1007
+ if (labelLayerId === 'poi_labels') {
1008
+ const text = poiLabelText(properties);
1009
+ if (!text) {
1010
+ debugRejectedLabel(debugLabels, labelLayerId, properties, 'missing meaningful POI label');
1011
+ return null;
1012
+ }
1013
+
1014
+ return createLabelFeature({
1015
+ sourceLayer,
1016
+ sourceId: properties.id,
1017
+ text,
1018
+ className: firstText(properties, ['amenity', 'railway', 'public_transport', 'aeroway', 'power', 'man_made', 'military', 'emergency', 'industrial', 'poi_category']) || 'poi',
1019
+ priority: poiLabelPriority(properties),
1020
+ minZoom: 16,
1021
+ coordinate: labelAnchorForGeometry(geometry)
1022
+ });
1023
+ }
1024
+
1025
+ return null;
1026
+ }
1027
+
1028
+ /**
1029
+ * @param {Record<string, unknown>} properties
1030
+ * @returns {string}
1031
+ */
1032
+ function poiLabelText(properties) {
1033
+ return firstMeaningfulText(properties, LABEL_TEXT_FIELDS);
1034
+ }
1035
+
1036
+ /**
1037
+ * @param {Record<string, unknown>} properties
1038
+ * @param {string} aeroway
1039
+ * @returns {string}
1040
+ */
1041
+ function aviationLabelText(properties, aeroway) {
1042
+ const ref = String(properties.ref ?? '').replace(/\s+/g, ' ').trim();
1043
+ if (ref && !/^(yes|no|true|false|0|1)$/i.test(ref)) {
1044
+ return ref;
1045
+ }
1046
+
1047
+ const text = firstMeaningfulText(properties, ['ref', 'name', 'iata', 'icao', 'operator']);
1048
+ if (text) {
1049
+ return text;
1050
+ }
1051
+
1052
+ if (aeroway === 'runway') {
1053
+ return 'RWY';
1054
+ }
1055
+
1056
+ return aeroway === 'helipad' || aeroway === 'heliport' ? 'H' : '';
1057
+ }
1058
+
1059
+ /**
1060
+ * @param {{
1061
+ * sourceLayer: string,
1062
+ * sourceId: unknown,
1063
+ * text: string,
1064
+ * className: string,
1065
+ * aeroway?: string,
1066
+ * priority: number,
1067
+ * minZoom: number,
1068
+ * coordinate: number[] | null
1069
+ * }} options
1070
+ * @returns {Record<string, unknown> | null}
1071
+ */
1072
+ function createLabelFeature(options) {
1073
+ if (!options.coordinate) {
1074
+ return null;
1075
+ }
1076
+
1077
+ return {
1078
+ type: 'Feature',
1079
+ geometry: {
1080
+ type: 'Point',
1081
+ coordinates: options.coordinate
1082
+ },
1083
+ properties: {
1084
+ text: options.text,
1085
+ class: options.className,
1086
+ aeroway: options.aeroway ?? null,
1087
+ priority: options.priority,
1088
+ minZoom: options.minZoom,
1089
+ sourceLayer: options.sourceLayer,
1090
+ sourceId: options.sourceId ?? null
1091
+ }
1092
+ };
1093
+ }
1094
+
1095
+ /**
1096
+ * @param {Record<string, unknown>} properties
1097
+ * @param {string[]} fields
1098
+ * @returns {string}
1099
+ */
1100
+ function firstText(properties, fields) {
1101
+ for (const field of fields) {
1102
+ const text = String(properties[field] ?? '').trim();
1103
+ if (text) {
1104
+ return text;
1105
+ }
1106
+ }
1107
+ return '';
1108
+ }
1109
+
1110
+ /**
1111
+ * @param {Record<string, unknown>} properties
1112
+ * @param {string[]} fields
1113
+ * @returns {string}
1114
+ */
1115
+ function firstMeaningfulText(properties, fields) {
1116
+ for (const field of fields) {
1117
+ const text = String(properties[field] ?? '').replace(/\s+/g, ' ').trim();
1118
+ if (isMeaningfulLabel(text)) {
1119
+ return text;
1120
+ }
1121
+ }
1122
+
1123
+ return '';
1124
+ }
1125
+
1126
+ /**
1127
+ * @param {string} text
1128
+ * @returns {boolean}
1129
+ */
1130
+ function isMeaningfulLabel(text) {
1131
+ const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
1132
+ if (normalized.length < 2) {
1133
+ return false;
1134
+ }
1135
+
1136
+ const key = normalized.toLowerCase().replace(/\s+/g, '_');
1137
+ if (GENERIC_LABEL_VALUES.has(key)) {
1138
+ return false;
1139
+ }
1140
+
1141
+ if (/^(yes|no|true|false|0|1)$/i.test(normalized)) {
1142
+ return false;
1143
+ }
1144
+
1145
+ return true;
1146
+ }
1147
+
1148
+ /**
1149
+ * @param {boolean} enabled
1150
+ * @param {string} labelLayerId
1151
+ * @param {Record<string, unknown>} properties
1152
+ * @param {string} reason
1153
+ */
1154
+ function debugRejectedLabel(enabled, labelLayerId, properties, reason) {
1155
+ if (!enabled) {
1156
+ return;
1157
+ }
1158
+
1159
+ const id = properties.id ? ` id=${properties.id}` : '';
1160
+ const name = properties.name ? ` name=${JSON.stringify(properties.name)}` : '';
1161
+ const ref = properties.ref ? ` ref=${JSON.stringify(properties.ref)}` : '';
1162
+ console.error(`map-zero label rejected ${labelLayerId}:${id}${name}${ref} ${reason}`);
1163
+ }
1164
+
1165
+ /**
1166
+ * @param {string} highway
1167
+ * @param {number} z
1168
+ * @returns {boolean}
1169
+ */
1170
+ function shouldLabelRoadClass(highway, z) {
1171
+ if (!highway) {
1172
+ return false;
1173
+ }
1174
+
1175
+ if (z < 13) {
1176
+ return false;
1177
+ }
1178
+
1179
+ if (z <= 14) {
1180
+ return ROAD_ZOOM_CLASSES.major.includes(highway) || ROAD_ZOOM_CLASSES.mid.includes(highway);
1181
+ }
1182
+
1183
+ if (z <= 16) {
1184
+ return [
1185
+ ...ROAD_ZOOM_CLASSES.major,
1186
+ ...ROAD_ZOOM_CLASSES.mid,
1187
+ ...ROAD_ZOOM_CLASSES.city
1188
+ ].includes(highway);
1189
+ }
1190
+
1191
+ return !['path', 'footway', 'cycleway', 'steps', 'corridor', 'platform'].includes(highway);
1192
+ }
1193
+
1194
+ /**
1195
+ * @param {string} highway
1196
+ * @returns {number}
1197
+ */
1198
+ function roadLabelMinZoom(highway) {
1199
+ if (['motorway', 'trunk', 'primary', 'secondary', 'motorway_link', 'trunk_link'].includes(highway)) {
1200
+ return 13;
1201
+ }
1202
+
1203
+ if (['tertiary', 'tertiary_link', 'primary_link', 'secondary_link'].includes(highway)) {
1204
+ return 14;
1205
+ }
1206
+
1207
+ return 16;
1208
+ }
1209
+
1210
+ /**
1211
+ * @param {string} highway
1212
+ * @param {Record<string, unknown>} properties
1213
+ * @param {number} z
1214
+ * @returns {number}
1215
+ */
1216
+ function roadLabelPriority(highway, properties, z) {
1217
+ const hasRef = Boolean(String(properties.ref ?? '').trim());
1218
+ return roadPriority(highway) + (hasRef ? 80 : 0) + (z >= 15 ? 20 : 0);
1219
+ }
1220
+
1221
+ /**
1222
+ * @param {string} aeroway
1223
+ * @param {number} z
1224
+ * @returns {boolean}
1225
+ */
1226
+ function shouldLabelAviationClass(aeroway, z) {
1227
+ if (!aeroway || z < 12) {
1228
+ return false;
1229
+ }
1230
+
1231
+ if (z < 14) {
1232
+ return ['runway', 'taxiway', 'apron', 'terminal'].includes(aeroway);
1233
+ }
1234
+
1235
+ return ['runway', 'taxiway', 'apron', 'terminal', 'helipad', 'hangar', 'aerodrome', 'heliport'].includes(aeroway);
1236
+ }
1237
+
1238
+ /**
1239
+ * @param {string} aeroway
1240
+ * @returns {number}
1241
+ */
1242
+ function aviationLabelMinZoom(aeroway) {
1243
+ return ['runway', 'taxiway', 'apron', 'terminal'].includes(aeroway) ? 12 : 14;
1244
+ }
1245
+
1246
+ /**
1247
+ * @param {string} aeroway
1248
+ * @returns {number}
1249
+ */
1250
+ function aviationLabelPriority(aeroway) {
1251
+ if (aeroway === 'helipad' || aeroway === 'heliport') return 980;
1252
+ if (aeroway === 'runway') return 930;
1253
+ if (aeroway === 'terminal') return 880;
1254
+ if (aeroway === 'apron') return 820;
1255
+ if (aeroway === 'taxiway') return 760;
1256
+ return 680;
1257
+ }
1258
+
1259
+ /**
1260
+ * @param {Record<string, unknown>} properties
1261
+ * @returns {number}
1262
+ */
1263
+ function poiLabelPriority(properties) {
1264
+ const category = poiCategoryForProperties(properties);
1265
+ const amenity = String(properties.amenity ?? '');
1266
+ if (category === 'emergency' || category === 'military') return 920;
1267
+ if (['transport', 'energy', 'communications', 'government'].includes(category)) return 820;
1268
+ if (amenity === 'hospital' || amenity === 'university') return 650;
1269
+ return 500;
1270
+ }
1271
+
1272
+ /**
1273
+ * @param {string} layerId
1274
+ * @param {number} z
1275
+ * @returns {number}
1276
+ */
1277
+ function labelFeatureLimit(layerId, z) {
1278
+ if (layerId === 'road_labels') {
1279
+ if (z <= 13) return 48;
1280
+ if (z <= 15) return 96;
1281
+ return 160;
1282
+ }
1283
+
1284
+ if (isAipLabelLayer(layerId)) {
1285
+ return z <= 13 ? 40 : 80;
1286
+ }
1287
+
1288
+ if (layerId === 'poi_labels') {
1289
+ return z <= 17 ? 50 : 120;
1290
+ }
1291
+
1292
+ return 80;
1293
+ }
1294
+
1295
+ /**
1296
+ * @param {{ type?: string, coordinates?: unknown }} geometry
1297
+ * @returns {number[] | null}
1298
+ */
1299
+ function labelAnchorForGeometry(geometry) {
1300
+ if (geometry.type === 'Point' && isCoordinate(geometry.coordinates)) {
1301
+ return /** @type {number[]} */ (geometry.coordinates);
1302
+ }
1303
+
1304
+ if (geometry.type === 'MultiPoint' && Array.isArray(geometry.coordinates)) {
1305
+ return geometry.coordinates.find(isCoordinate) ?? null;
1306
+ }
1307
+
1308
+ if (geometry.type === 'LineString' && Array.isArray(geometry.coordinates)) {
1309
+ return lineMidpoint(/** @type {number[][]} */ (geometry.coordinates));
1310
+ }
1311
+
1312
+ if (geometry.type === 'MultiLineString' && Array.isArray(geometry.coordinates)) {
1313
+ const lines = /** @type {number[][][]} */ (geometry.coordinates);
1314
+ return lineMidpoint(longestLine(lines));
1315
+ }
1316
+
1317
+ if (geometry.type === 'Polygon' && Array.isArray(geometry.coordinates)) {
1318
+ return polygonAnchor(/** @type {number[][][]} */ (geometry.coordinates));
1319
+ }
1320
+
1321
+ if (geometry.type === 'MultiPolygon' && Array.isArray(geometry.coordinates)) {
1322
+ const polygons = /** @type {number[][][][]} */ (geometry.coordinates);
1323
+ return polygonAnchor(largestPolygon(polygons));
1324
+ }
1325
+
1326
+ return null;
1327
+ }
1328
+
1329
+ /**
1330
+ * @param {unknown} coordinate
1331
+ * @returns {coordinate is number[]}
1332
+ */
1333
+ function isCoordinate(coordinate) {
1334
+ return Array.isArray(coordinate) &&
1335
+ coordinate.length >= 2 &&
1336
+ Number.isFinite(Number(coordinate[0])) &&
1337
+ Number.isFinite(Number(coordinate[1]));
1338
+ }
1339
+
1340
+ /**
1341
+ * @param {number[][]} coords
1342
+ * @returns {number[] | null}
1343
+ */
1344
+ function lineMidpoint(coords) {
1345
+ if (!Array.isArray(coords) || coords.length === 0) {
1346
+ return null;
1347
+ }
1348
+
1349
+ if (coords.length === 1) {
1350
+ return isCoordinate(coords[0]) ? coords[0] : null;
1351
+ }
1352
+
1353
+ const lengths = [];
1354
+ let total = 0;
1355
+ for (let index = 0; index < coords.length - 1; index += 1) {
1356
+ const a = coords[index];
1357
+ const b = coords[index + 1];
1358
+ if (!isCoordinate(a) || !isCoordinate(b)) {
1359
+ lengths.push(0);
1360
+ continue;
1361
+ }
1362
+ const length = Math.hypot(Number(b[0]) - Number(a[0]), Number(b[1]) - Number(a[1]));
1363
+ lengths.push(length);
1364
+ total += length;
1365
+ }
1366
+
1367
+ if (total <= 0) {
1368
+ return coords.find(isCoordinate) ?? null;
1369
+ }
1370
+
1371
+ const target = total / 2;
1372
+ let accumulated = 0;
1373
+ for (let index = 0; index < lengths.length; index += 1) {
1374
+ const length = lengths[index];
1375
+ const next = accumulated + length;
1376
+ if (target <= next || index === lengths.length - 1) {
1377
+ const a = coords[index];
1378
+ const b = coords[index + 1];
1379
+ if (!isCoordinate(a) || !isCoordinate(b) || length <= 0) {
1380
+ return isCoordinate(a) ? a : null;
1381
+ }
1382
+ const t = Math.max(0, Math.min(1, (target - accumulated) / length));
1383
+ return [
1384
+ Number(a[0]) + (Number(b[0]) - Number(a[0])) * t,
1385
+ Number(a[1]) + (Number(b[1]) - Number(a[1])) * t
1386
+ ];
1387
+ }
1388
+ accumulated = next;
1389
+ }
1390
+
1391
+ return null;
1392
+ }
1393
+
1394
+ /**
1395
+ * @param {number[][][]} lines
1396
+ * @returns {number[][]}
1397
+ */
1398
+ function longestLine(lines) {
1399
+ let best = [];
1400
+ let bestLength = -1;
1401
+ for (const line of lines) {
1402
+ const length = lineLength(line);
1403
+ if (length > bestLength) {
1404
+ best = line;
1405
+ bestLength = length;
1406
+ }
1407
+ }
1408
+ return best;
1409
+ }
1410
+
1411
+ /**
1412
+ * @param {number[][]} line
1413
+ * @returns {number}
1414
+ */
1415
+ function lineLength(line) {
1416
+ let length = 0;
1417
+ for (let index = 0; index < line.length - 1; index += 1) {
1418
+ const a = line[index];
1419
+ const b = line[index + 1];
1420
+ if (isCoordinate(a) && isCoordinate(b)) {
1421
+ length += Math.hypot(Number(b[0]) - Number(a[0]), Number(b[1]) - Number(a[1]));
1422
+ }
1423
+ }
1424
+ return length;
1425
+ }
1426
+
1427
+ /**
1428
+ * @param {number[][][]} polygon
1429
+ * @returns {number[] | null}
1430
+ */
1431
+ function polygonAnchor(polygon) {
1432
+ const ring = polygon?.[0];
1433
+ if (!Array.isArray(ring) || ring.length === 0) {
1434
+ return null;
1435
+ }
1436
+
1437
+ let x = 0;
1438
+ let y = 0;
1439
+ let count = 0;
1440
+ for (const coordinate of ring) {
1441
+ if (isCoordinate(coordinate)) {
1442
+ x += Number(coordinate[0]);
1443
+ y += Number(coordinate[1]);
1444
+ count += 1;
1445
+ }
1446
+ }
1447
+
1448
+ return count > 0 ? [x / count, y / count] : null;
1449
+ }
1450
+
1451
+ /**
1452
+ * @param {number[][][][]} polygons
1453
+ * @returns {number[][][]}
1454
+ */
1455
+ function largestPolygon(polygons) {
1456
+ let best = [];
1457
+ let bestArea = -1;
1458
+ for (const polygon of polygons) {
1459
+ const area = Math.abs(ringArea(polygon?.[0] ?? []));
1460
+ if (area > bestArea) {
1461
+ best = polygon;
1462
+ bestArea = area;
1463
+ }
1464
+ }
1465
+ return best;
1466
+ }
1467
+
1468
+ /**
1469
+ * @param {number[][]} ring
1470
+ * @returns {number}
1471
+ */
1472
+ function ringArea(ring) {
1473
+ let area = 0;
1474
+ for (let index = 0; index < ring.length - 1; index += 1) {
1475
+ const a = ring[index];
1476
+ const b = ring[index + 1];
1477
+ if (isCoordinate(a) && isCoordinate(b)) {
1478
+ area += Number(a[0]) * Number(b[1]) - Number(b[0]) * Number(a[1]);
1479
+ }
1480
+ }
1481
+ return area / 2;
1482
+ }
1483
+
1484
+ /**
1485
+ * @param {Array<{ layerId: string, features: Array<Record<string, unknown>>, originalFeatureCount: number }>} layers
1486
+ * @param {number} maxFeatures
1487
+ * @param {number} z
1488
+ * @returns {{ layers: Array<{ layerId: string, features: Array<Record<string, unknown>>, originalFeatureCount: number }>, droppedFeatureCount: number }}
1489
+ */
1490
+ function applyFeatureLimit(layers, maxFeatures, z) {
1491
+ let droppedFeatureCount = 0;
1492
+ const layerLimited = layers.map((layer) => {
1493
+ const limited = limitLayerFeatures(layer, layerFeatureLimit(layer.layerId, z), z);
1494
+ droppedFeatureCount += layer.features.length - limited.features.length;
1495
+ return limited;
1496
+ });
1497
+
1498
+ const total = layerLimited.reduce((sum, layer) => sum + layer.features.length, 0);
1499
+ if (total <= maxFeatures) {
1500
+ return {
1501
+ layers: layerLimited,
1502
+ droppedFeatureCount
1503
+ };
1504
+ }
1505
+
1506
+ const limited = limitAcrossLayers(layerLimited, maxFeatures, z);
1507
+ return {
1508
+ layers: limited.layers,
1509
+ droppedFeatureCount: droppedFeatureCount + limited.droppedFeatureCount
1510
+ };
1511
+ }
1512
+
1513
+ /**
1514
+ * @param {number} originalFeatureCount
1515
+ * @param {number} encodedFeatureCount
1516
+ * @param {number} droppedFeatureCount
1517
+ * @param {Array<{ layerId: string, features: Array<Record<string, unknown>>, originalFeatureCount: number }>} layers
1518
+ * @returns {string}
1519
+ */
1520
+ function emptyReasonForTile(originalFeatureCount, encodedFeatureCount, droppedFeatureCount, layers) {
1521
+ if (encodedFeatureCount > 0) {
1522
+ return 'none';
1523
+ }
1524
+
1525
+ if (originalFeatureCount === 0) {
1526
+ return 'no_features';
1527
+ }
1528
+
1529
+ const remainingFeatureCount = layers.reduce((sum, layer) => sum + layer.features.length, 0);
1530
+ if ((remainingFeatureCount === 0 || droppedFeatureCount > 0) && droppedFeatureCount > 0) {
1531
+ return 'filtered';
1532
+ }
1533
+
1534
+ return 'no_tile_geometry';
1535
+ }
1536
+
1537
+ /**
1538
+ * @param {{ layerId: string, features: Array<Record<string, unknown>>, originalFeatureCount: number }} layer
1539
+ * @param {number} maxFeatures
1540
+ * @param {number} z
1541
+ * @returns {{ layerId: string, features: Array<Record<string, unknown>>, originalFeatureCount: number }}
1542
+ */
1543
+ function limitLayerFeatures(layer, maxFeatures, z) {
1544
+ if (layer.features.length <= maxFeatures) {
1545
+ return layer;
1546
+ }
1547
+
1548
+ return {
1549
+ ...layer,
1550
+ features: topFeatures(layer.layerId, layer.features, maxFeatures, z)
1551
+ };
1552
+ }
1553
+
1554
+ /**
1555
+ * @param {Array<{ layerId: string, features: Array<Record<string, unknown>>, originalFeatureCount: number }>} layers
1556
+ * @param {number} maxFeatures
1557
+ * @param {number} z
1558
+ * @returns {{ layers: Array<{ layerId: string, features: Array<Record<string, unknown>>, originalFeatureCount: number }>, droppedFeatureCount: number }}
1559
+ */
1560
+ function limitAcrossLayers(layers, maxFeatures, z) {
1561
+ const total = layers.reduce((sum, layer) => sum + layer.features.length, 0);
1562
+ const entries = [];
1563
+ let order = 0;
1564
+ for (const layer of layers) {
1565
+ for (const feature of layer.features) {
1566
+ entries.push({
1567
+ layerId: layer.layerId,
1568
+ feature,
1569
+ priority: featurePriority(layer.layerId, feature, z),
1570
+ order
1571
+ });
1572
+ order += 1;
1573
+ }
1574
+ }
1575
+
1576
+ entries.sort((a, b) => b.priority - a.priority || a.order - b.order);
1577
+ const selected = entries.slice(0, maxFeatures);
1578
+ const selectedByLayer = new Map();
1579
+ for (const entry of selected) {
1580
+ const layerFeatures = selectedByLayer.get(entry.layerId) ?? [];
1581
+ layerFeatures.push(entry.feature);
1582
+ selectedByLayer.set(entry.layerId, layerFeatures);
1583
+ }
1584
+
1585
+ return {
1586
+ layers: layers.map((layer) => ({
1587
+ ...layer,
1588
+ features: selectedByLayer.get(layer.layerId) ?? []
1589
+ })),
1590
+ droppedFeatureCount: total - selected.length
1591
+ };
1592
+ }
1593
+
1594
+ /**
1595
+ * @param {string} layerId
1596
+ * @param {Array<Record<string, unknown>>} features
1597
+ * @param {number} maxFeatures
1598
+ * @param {number} z
1599
+ * @returns {Array<Record<string, unknown>>}
1600
+ */
1601
+ function topFeatures(layerId, features, maxFeatures, z) {
1602
+ return features
1603
+ .map((feature, order) => ({
1604
+ feature,
1605
+ order,
1606
+ priority: featurePriority(layerId, feature, z)
1607
+ }))
1608
+ .sort((a, b) => b.priority - a.priority || a.order - b.order)
1609
+ .slice(0, maxFeatures)
1610
+ .map((entry) => entry.feature);
1611
+ }
1612
+
1613
+ /**
1614
+ * @param {string} layerId
1615
+ * @param {number} z
1616
+ * @returns {number}
1617
+ */
1618
+ function layerFeatureLimit(layerId, z) {
1619
+ if (z <= 10) {
1620
+ const limits = {
1621
+ roads: 1800,
1622
+ landuse: 1600,
1623
+ water: 1200,
1624
+ aip: 500,
1625
+ aviation: 500,
1626
+ railways: 500,
1627
+ boundaries: 500,
1628
+ buildings: 0,
1629
+ pois: 0
1630
+ };
1631
+ return limits[layerId] ?? 500;
1632
+ }
1633
+
1634
+ if (z === 11) {
1635
+ const limits = {
1636
+ roads: 3500,
1637
+ landuse: 2200,
1638
+ water: 1600,
1639
+ aip: 800,
1640
+ aviation: 800,
1641
+ railways: 800,
1642
+ boundaries: 700,
1643
+ buildings: 0,
1644
+ pois: 0
1645
+ };
1646
+ return limits[layerId] ?? 1000;
1647
+ }
1648
+
1649
+ if (z === 12) {
1650
+ const limits = {
1651
+ roads: 5000,
1652
+ landuse: 2600,
1653
+ water: 1800,
1654
+ aip: 1000,
1655
+ aviation: 1000,
1656
+ railways: 1000,
1657
+ boundaries: 900,
1658
+ buildings: 300,
1659
+ pois: 0
1660
+ };
1661
+ return limits[layerId] ?? 1500;
1662
+ }
1663
+
1664
+ if (z === 13) {
1665
+ const limits = {
1666
+ buildings: 900,
1667
+ pois: 0
1668
+ };
1669
+ return limits[layerId] ?? Number.MAX_SAFE_INTEGER;
1670
+ }
1671
+
1672
+ if (z === 14) {
1673
+ const limits = {
1674
+ buildings: 4000,
1675
+ pois: 0
1676
+ };
1677
+ return limits[layerId] ?? Number.MAX_SAFE_INTEGER;
1678
+ }
1679
+
1680
+ return Number.MAX_SAFE_INTEGER;
1681
+ }
1682
+
1683
+ /**
1684
+ * @param {string} layerId
1685
+ * @param {Record<string, unknown>} feature
1686
+ * @param {number} z
1687
+ * @returns {number}
1688
+ */
1689
+ function featurePriority(layerId, feature, z) {
1690
+ const properties = /** @type {Record<string, unknown>} */ (feature.properties ?? {});
1691
+ if (isLabelLayerId(layerId)) {
1692
+ return Number(properties.priority ?? 0);
1693
+ }
1694
+
1695
+ if (layerId === 'roads') {
1696
+ return roadPriority(String(properties.highway ?? '')) + geometryImportance(feature, z);
1697
+ }
1698
+
1699
+ if (layerId === 'boundaries') {
1700
+ return boundaryPriority(Number(properties.admin_level)) + geometryImportance(feature, z);
1701
+ }
1702
+
1703
+ if (isAipLayer(layerId)) {
1704
+ return aviationPriority(String(properties.aeroway ?? '')) + geometryImportance(feature, z);
1705
+ }
1706
+
1707
+ if (layerId === 'water') {
1708
+ return 860 + geometryImportance(feature, z);
1709
+ }
1710
+
1711
+ if (layerId === 'railways') {
1712
+ return 680 + geometryImportance(feature, z);
1713
+ }
1714
+
1715
+ if (layerId === 'landuse') {
1716
+ return landusePriority(properties, z) + geometryImportance(feature, z);
1717
+ }
1718
+
1719
+ if (layerId === 'buildings') {
1720
+ return buildingPriority(feature, z);
1721
+ }
1722
+
1723
+ if (layerId === 'pois') {
1724
+ return 60;
1725
+ }
1726
+
1727
+ return 100;
1728
+ }
1729
+
1730
+ /**
1731
+ * @param {string} highway
1732
+ * @returns {number}
1733
+ */
1734
+ function roadPriority(highway) {
1735
+ const priorities = {
1736
+ motorway: 1000,
1737
+ trunk: 960,
1738
+ primary: 920,
1739
+ secondary: 880,
1740
+ motorway_link: 850,
1741
+ trunk_link: 830,
1742
+ primary_link: 780,
1743
+ tertiary: 740,
1744
+ secondary_link: 700,
1745
+ busway: 650,
1746
+ residential: 460,
1747
+ unclassified: 440,
1748
+ living_street: 420,
1749
+ service: 230,
1750
+ track: 210,
1751
+ cycleway: 190,
1752
+ path: 170,
1753
+ footway: 160,
1754
+ steps: 150
1755
+ };
1756
+ return priorities[highway] ?? 200;
1757
+ }
1758
+
1759
+ /**
1760
+ * @param {number} adminLevel
1761
+ * @returns {number}
1762
+ */
1763
+ function boundaryPriority(adminLevel) {
1764
+ if (!Number.isFinite(adminLevel)) {
1765
+ return 300;
1766
+ }
1767
+
1768
+ return Math.max(100, 1000 - adminLevel * 70);
1769
+ }
1770
+
1771
+ /**
1772
+ * @param {string} aeroway
1773
+ * @returns {number}
1774
+ */
1775
+ function aviationPriority(aeroway) {
1776
+ const priorities = {
1777
+ runway: 870,
1778
+ heliport: 820,
1779
+ helipad: 800,
1780
+ taxiway: 720,
1781
+ apron: 680,
1782
+ stopway: 620,
1783
+ taxilane: 580,
1784
+ terminal: 520
1785
+ };
1786
+ return priorities[aeroway] ?? 240;
1787
+ }
1788
+
1789
+ /**
1790
+ * @param {Record<string, unknown>} properties
1791
+ * @param {number} z
1792
+ * @returns {number}
1793
+ */
1794
+ function landusePriority(properties, z) {
1795
+ const landuse = String(properties.landuse ?? '');
1796
+ const leisure = String(properties.leisure ?? '');
1797
+ const natural = String(properties.natural ?? '');
1798
+ if (['forest', 'reservoir'].includes(landuse) || ['wood', 'water'].includes(natural)) {
1799
+ return 520;
1800
+ }
1801
+
1802
+ if (['residential', 'industrial', 'commercial', 'retail', 'military'].includes(landuse)) {
1803
+ return 430;
1804
+ }
1805
+
1806
+ if (leisure === 'park' || landuse === 'farmland') {
1807
+ return 360;
1808
+ }
1809
+
1810
+ return z < 12 ? 100 : 220;
1811
+ }
1812
+
1813
+ /**
1814
+ * Keep early urban overview tiles useful by preferring larger building
1815
+ * footprints instead of arbitrary row order.
1816
+ *
1817
+ * @param {Record<string, unknown>} feature
1818
+ * @param {number} z
1819
+ * @returns {number}
1820
+ */
1821
+ function buildingPriority(feature, z) {
1822
+ if (z >= 15) {
1823
+ return 90;
1824
+ }
1825
+
1826
+ const geometry = /** @type {{ type?: string, coordinates?: unknown }} */ (feature.geometry);
1827
+ const bbox = geometryBbox(geometry);
1828
+ if (!bbox) {
1829
+ return 90;
1830
+ }
1831
+
1832
+ const width = Math.max(0, bbox[2] - bbox[0]);
1833
+ const height = Math.max(0, bbox[3] - bbox[1]);
1834
+ return 90 + Math.min(260, Math.sqrt(width * height) * 30000);
1835
+ }
1836
+
1837
+ /**
1838
+ * Give overview tiles a slight preference for larger polygons and longer lines.
1839
+ *
1840
+ * @param {Record<string, unknown>} feature
1841
+ * @param {number} z
1842
+ * @returns {number}
1843
+ */
1844
+ function geometryImportance(feature, z) {
1845
+ if (z >= 13) {
1846
+ return 0;
1847
+ }
1848
+
1849
+ const geometry = /** @type {{ type?: string, coordinates?: unknown }} */ (feature.geometry);
1850
+ const bbox = geometryBbox(geometry);
1851
+ if (!bbox) {
1852
+ return 0;
1853
+ }
1854
+
1855
+ const width = Math.max(0, bbox[2] - bbox[0]);
1856
+ const height = Math.max(0, bbox[3] - bbox[1]);
1857
+ const score = geometry?.type?.includes('Polygon')
1858
+ ? Math.sqrt(width * height) * 15000
1859
+ : Math.hypot(width, height) * 900;
1860
+
1861
+ return Math.min(180, score);
1862
+ }
1863
+
1864
+ /**
1865
+ * @param {{ type?: string, coordinates?: unknown }} geometry
1866
+ * @returns {[number, number, number, number] | null}
1867
+ */
1868
+ function geometryBbox(geometry) {
1869
+ let bbox = null;
1870
+ visitCoordinates(geometry?.coordinates, (coordinate) => {
1871
+ const x = Number(coordinate[0]);
1872
+ const y = Number(coordinate[1]);
1873
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
1874
+ return;
1875
+ }
1876
+
1877
+ if (!bbox) {
1878
+ bbox = [x, y, x, y];
1879
+ return;
1880
+ }
1881
+
1882
+ bbox[0] = Math.min(bbox[0], x);
1883
+ bbox[1] = Math.min(bbox[1], y);
1884
+ bbox[2] = Math.max(bbox[2], x);
1885
+ bbox[3] = Math.max(bbox[3], y);
1886
+ });
1887
+
1888
+ return bbox;
1889
+ }
1890
+
1891
+ /**
1892
+ * @param {unknown} coordinates
1893
+ * @param {(coordinate: [number, number]) => void} callback
1894
+ */
1895
+ function visitCoordinates(coordinates, callback) {
1896
+ if (!Array.isArray(coordinates)) {
1897
+ return;
1898
+ }
1899
+
1900
+ if (typeof coordinates[0] === 'number' && typeof coordinates[1] === 'number') {
1901
+ callback(/** @type {[number, number]} */ (coordinates));
1902
+ return;
1903
+ }
1904
+
1905
+ for (const item of coordinates) {
1906
+ visitCoordinates(item, callback);
1907
+ }
1908
+ }
1909
+
1910
+ /**
1911
+ * @param {number} z
1912
+ * @param {number | undefined} configured
1913
+ * @returns {number}
1914
+ */
1915
+ function maxFeaturesForZoom(z, configured) {
1916
+ const requested = Number.isInteger(configured) && Number(configured) > 0
1917
+ ? Number(configured)
1918
+ : DEFAULT_MAX_FEATURES;
1919
+
1920
+ return Math.min(requested, defaultMaxFeaturesForZoom(z));
1921
+ }
1922
+
1923
+ /**
1924
+ * @param {number} z
1925
+ * @returns {number}
1926
+ */
1927
+ function defaultMaxFeaturesForZoom(z) {
1928
+ if (z <= 10) {
1929
+ return 6500;
1930
+ }
1931
+
1932
+ if (z === 11) {
1933
+ return 10000;
1934
+ }
1935
+
1936
+ if (z === 12) {
1937
+ return 12500;
1938
+ }
1939
+
1940
+ if (z === 13) {
1941
+ return 12000;
1942
+ }
1943
+
1944
+ return 30000;
1945
+ }
1946
+
1947
+ /**
1948
+ * Convert XYZ tile coordinates to an EPSG:4326 bbox.
1949
+ *
1950
+ * @param {number} z
1951
+ * @param {number} x
1952
+ * @param {number} y
1953
+ * @returns {[number, number, number, number]}
1954
+ */
1955
+ export function tileToBbox(z, x, y) {
1956
+ const n = 2 ** z;
1957
+ const minLon = (x / n) * 360 - 180;
1958
+ const maxLon = ((x + 1) / n) * 360 - 180;
1959
+ const maxLat = tileYToLat(y, n);
1960
+ const minLat = tileYToLat(y + 1, n);
1961
+ return [minLon, minLat, maxLon, maxLat];
1962
+ }
1963
+
1964
+ /**
1965
+ * @param {string | number} zValue
1966
+ * @param {string | number} xValue
1967
+ * @param {string | number} yValue
1968
+ * @returns {{ z: number, x: number, y: number }}
1969
+ */
1970
+ function parseTileParams(zValue, xValue, yValue) {
1971
+ const z = Number(zValue);
1972
+ const x = Number(xValue);
1973
+ const y = Number(yValue);
1974
+
1975
+ if (!Number.isInteger(z) || z < 0 || z > MAX_ZOOM) {
1976
+ throw httpError(400, `z must be an integer between 0 and ${MAX_ZOOM}`);
1977
+ }
1978
+
1979
+ const maxIndex = 2 ** z;
1980
+ if (!Number.isInteger(x) || x < 0 || x >= maxIndex) {
1981
+ throw httpError(400, `x must be an integer between 0 and ${maxIndex - 1}`);
1982
+ }
1983
+
1984
+ if (!Number.isInteger(y) || y < 0 || y >= maxIndex) {
1985
+ throw httpError(400, `y must be an integer between 0 and ${maxIndex - 1}`);
1986
+ }
1987
+
1988
+ return { z, x, y };
1989
+ }
1990
+
1991
+ /**
1992
+ * @param {number} y
1993
+ * @param {number} n
1994
+ * @returns {number}
1995
+ */
1996
+ function tileYToLat(y, n) {
1997
+ const mercator = Math.PI * (1 - (2 * y) / n);
1998
+ return (Math.atan(Math.sinh(mercator)) * 180) / Math.PI;
1999
+ }
2000
+
2001
+ /**
2002
+ * @param {number} z
2003
+ * @param {string} layerId
2004
+ * @param {string} geometryType
2005
+ * @param {string} detail
2006
+ * @returns {number}
2007
+ */
2008
+ function toleranceForZoom(z, layerId, geometryType, detail) {
2009
+ let tolerance;
2010
+
2011
+ if (z <= 7) {
2012
+ tolerance = 14;
2013
+ } else if (z <= 9) {
2014
+ tolerance = 10;
2015
+ } else if (z <= 10) {
2016
+ tolerance = 8;
2017
+ } else if (z <= 11) {
2018
+ tolerance = 6;
2019
+ } else if (z <= 12) {
2020
+ tolerance = 4;
2021
+ } else if (z <= 13) {
2022
+ tolerance = 2.4;
2023
+ } else if (z <= 14) {
2024
+ tolerance = 1.2;
2025
+ } else if (z <= 15) {
2026
+ tolerance = 0.5;
2027
+ } else {
2028
+ tolerance = 0;
2029
+ }
2030
+
2031
+ tolerance *= layerToleranceScale(layerId, geometryType, z);
2032
+
2033
+ if (detail === 'overview') {
2034
+ tolerance *= 1.1;
2035
+ } else if (detail === 'full') {
2036
+ tolerance *= 0.3;
2037
+ }
2038
+
2039
+ return Math.max(0, Number(tolerance.toFixed(2)));
2040
+ }
2041
+
2042
+ /**
2043
+ * @param {string} layerId
2044
+ * @param {string} geometryType
2045
+ * @param {number} z
2046
+ * @returns {number}
2047
+ */
2048
+ function layerToleranceScale(layerId, geometryType, z) {
2049
+ const polygon = geometryType === 'Polygon' || geometryType === 'MultiPolygon';
2050
+
2051
+ if (layerId === 'roads') {
2052
+ return 0.45;
2053
+ }
2054
+
2055
+ if (layerId === 'railways') {
2056
+ return 0.55;
2057
+ }
2058
+
2059
+ if (isAipLayer(layerId)) {
2060
+ return 0.35;
2061
+ }
2062
+
2063
+ if (layerId === 'water') {
2064
+ return polygon ? 0.65 : 0.55;
2065
+ }
2066
+
2067
+ if (layerId === 'landuse') {
2068
+ return polygon ? 0.7 : 0.6;
2069
+ }
2070
+
2071
+ if (layerId === 'boundaries') {
2072
+ return z <= 10 ? 0.95 : 0.75;
2073
+ }
2074
+
2075
+ if (layerId === 'buildings') {
2076
+ return z <= 14 ? 0.45 : 0.25;
2077
+ }
2078
+
2079
+ return polygon ? 0.75 : 0.65;
2080
+ }
2081
+
2082
+ /**
2083
+ * Simplify geometries before MVT encoding so low zooms stay lightweight.
2084
+ *
2085
+ * @param {Array<Record<string, unknown>>} features
2086
+ * @param {[number, number, number, number]} bbox
2087
+ * @param {number} z
2088
+ * @param {string} layerId
2089
+ * @param {string} detail
2090
+ * @returns {{ features: Array<Record<string, unknown>>, stats: GeneralizationStats }}
2091
+ */
2092
+ function simplifyFeatures(features, bbox, z, layerId, detail) {
2093
+ const stats = emptyGeneralizationStats();
2094
+ const tileSpan = tileSpanForBbox(bbox);
2095
+ const generalized = [];
2096
+
2097
+ for (const feature of features) {
2098
+ const geometry = /** @type {{ type?: string, coordinates?: unknown }} */ (feature.geometry);
2099
+ const originalVertexCount = countGeometryVertices(geometry);
2100
+ stats.originalVertexCount += originalVertexCount;
2101
+
2102
+ if (isSmallFeature(geometry, bbox, z, layerId)) {
2103
+ stats.droppedSmallFeatures += 1;
2104
+ continue;
2105
+ }
2106
+
2107
+ const tolerance = simplifyToleranceDegrees(tileSpan, z, layerId, String(geometry?.type ?? ''), detail);
2108
+ stats.simplificationTolerance = Math.max(stats.simplificationTolerance, tolerance);
2109
+
2110
+ const simplifiedGeometry = tolerance > 0
2111
+ ? simplifyGeometry(geometry, tolerance)
2112
+ : geometry;
2113
+
2114
+ if (!hasUsableGeometry(simplifiedGeometry)) {
2115
+ stats.droppedSmallFeatures += 1;
2116
+ continue;
2117
+ }
2118
+
2119
+ stats.simplifiedVertexCount += countGeometryVertices(simplifiedGeometry);
2120
+ generalized.push({
2121
+ ...feature,
2122
+ geometry: simplifiedGeometry
2123
+ });
2124
+ }
2125
+
2126
+ return {
2127
+ features: generalized,
2128
+ stats
2129
+ };
2130
+ }
2131
+
2132
+ /**
2133
+ * @param {number} tileSpan
2134
+ * @param {number} z
2135
+ * @param {string} layerId
2136
+ * @param {string} geometryType
2137
+ * @param {string} detail
2138
+ * @returns {number}
2139
+ */
2140
+ function simplifyToleranceDegrees(tileSpan, z, layerId, geometryType, detail) {
2141
+ return (tileSpan / TILE_EXTENT) * toleranceForZoom(z, layerId, geometryType, detail);
2142
+ }
2143
+
2144
+ /**
2145
+ * @param {{ type?: string, coordinates?: unknown }} geometry
2146
+ * @param {number} tolerance
2147
+ * @returns {{ type?: string, coordinates?: unknown }}
2148
+ */
2149
+ function simplifyGeometry(geometry, tolerance) {
2150
+ switch (geometry?.type) {
2151
+ case 'LineString':
2152
+ return {
2153
+ ...geometry,
2154
+ coordinates: simplifyLine(/** @type {Array<[number, number]>} */ (geometry.coordinates), tolerance, false)
2155
+ };
2156
+
2157
+ case 'MultiLineString':
2158
+ return {
2159
+ ...geometry,
2160
+ coordinates: /** @type {Array<Array<[number, number]>>} */ (geometry.coordinates)
2161
+ .map((line) => simplifyLine(line, tolerance, false))
2162
+ .filter((line) => line.length >= 2)
2163
+ };
2164
+
2165
+ case 'Polygon':
2166
+ return simplifyPolygonGeometry(geometry, tolerance);
2167
+
2168
+ case 'MultiPolygon':
2169
+ return simplifyMultiPolygonGeometry(geometry, tolerance);
2170
+
2171
+ default:
2172
+ return geometry;
2173
+ }
2174
+ }
2175
+
2176
+ /**
2177
+ * @param {{ type?: string, coordinates?: unknown }} geometry
2178
+ * @param {number} tolerance
2179
+ * @returns {{ type?: string, coordinates?: unknown }}
2180
+ */
2181
+ function simplifyPolygonGeometry(geometry, tolerance) {
2182
+ const rings = /** @type {Array<Array<[number, number]>>} */ (geometry.coordinates)
2183
+ .map((ring) => simplifyLine(ring, tolerance, true))
2184
+ .filter((ring) => ring.length >= 4);
2185
+
2186
+ if (rings.length === 0) {
2187
+ return geometry;
2188
+ }
2189
+
2190
+ return {
2191
+ ...geometry,
2192
+ coordinates: rings
2193
+ };
2194
+ }
2195
+
2196
+ /**
2197
+ * @param {{ type?: string, coordinates?: unknown }} geometry
2198
+ * @param {number} tolerance
2199
+ * @returns {{ type?: string, coordinates?: unknown }}
2200
+ */
2201
+ function simplifyMultiPolygonGeometry(geometry, tolerance) {
2202
+ const polygons = /** @type {Array<Array<Array<[number, number]>>>} */ (geometry.coordinates)
2203
+ .map((polygon) => polygon
2204
+ .map((ring) => simplifyLine(ring, tolerance, true))
2205
+ .filter((ring) => ring.length >= 4))
2206
+ .filter((polygon) => polygon.length > 0);
2207
+
2208
+ if (polygons.length === 0) {
2209
+ return geometry;
2210
+ }
2211
+
2212
+ return {
2213
+ ...geometry,
2214
+ coordinates: polygons
2215
+ };
2216
+ }
2217
+
2218
+ /**
2219
+ * @param {Array<[number, number]>} coordinates
2220
+ * @param {number} tolerance
2221
+ * @param {boolean} closed
2222
+ * @returns {Array<[number, number]>}
2223
+ */
2224
+ function simplifyLine(coordinates, tolerance, closed) {
2225
+ if (!Array.isArray(coordinates) || coordinates.length <= (closed ? 4 : 2)) {
2226
+ return coordinates;
2227
+ }
2228
+
2229
+ const input = closed ? coordinates.slice(0, -1) : coordinates;
2230
+ const radial = simplifyRadialDistance(input, (tolerance * 0.5) ** 2);
2231
+ const simplified = douglasPeucker(radial, tolerance * tolerance);
2232
+
2233
+ if (!closed) {
2234
+ return simplified.length >= 2 ? simplified : coordinates;
2235
+ }
2236
+
2237
+ if (simplified.length < 3) {
2238
+ return coordinates;
2239
+ }
2240
+
2241
+ const first = simplified[0];
2242
+ const last = simplified.at(-1);
2243
+ const ring = first && last && (first[0] !== last[0] || first[1] !== last[1])
2244
+ ? [...simplified, [first[0], first[1]]]
2245
+ : simplified;
2246
+
2247
+ return ring.length >= 4 ? ring : coordinates;
2248
+ }
2249
+
2250
+ /**
2251
+ * @param {Array<[number, number]>} coordinates
2252
+ * @param {number} toleranceSq
2253
+ * @returns {Array<[number, number]>}
2254
+ */
2255
+ function simplifyRadialDistance(coordinates, toleranceSq) {
2256
+ if (coordinates.length <= 2 || toleranceSq <= 0) {
2257
+ return coordinates;
2258
+ }
2259
+
2260
+ const simplified = [coordinates[0]];
2261
+ let previous = coordinates[0];
2262
+
2263
+ for (let i = 1; i < coordinates.length - 1; i += 1) {
2264
+ const point = coordinates[i];
2265
+ if (distanceSq(point, previous) > toleranceSq) {
2266
+ simplified.push(point);
2267
+ previous = point;
2268
+ }
2269
+ }
2270
+
2271
+ simplified.push(coordinates.at(-1));
2272
+ return simplified;
2273
+ }
2274
+
2275
+ /**
2276
+ * @param {Array<[number, number]>} coordinates
2277
+ * @param {number} toleranceSq
2278
+ * @returns {Array<[number, number]>}
2279
+ */
2280
+ function douglasPeucker(coordinates, toleranceSq) {
2281
+ if (coordinates.length <= 2) {
2282
+ return coordinates;
2283
+ }
2284
+
2285
+ let maxDistanceSq = 0;
2286
+ let index = 0;
2287
+ const first = coordinates[0];
2288
+ const last = coordinates.at(-1);
2289
+
2290
+ for (let i = 1; i < coordinates.length - 1; i += 1) {
2291
+ const distanceSq = pointSegmentDistanceSq(coordinates[i], first, last);
2292
+ if (distanceSq > maxDistanceSq) {
2293
+ maxDistanceSq = distanceSq;
2294
+ index = i;
2295
+ }
2296
+ }
2297
+
2298
+ if (maxDistanceSq <= toleranceSq) {
2299
+ return [first, last];
2300
+ }
2301
+
2302
+ const left = douglasPeucker(coordinates.slice(0, index + 1), toleranceSq);
2303
+ const right = douglasPeucker(coordinates.slice(index), toleranceSq);
2304
+ return left.slice(0, -1).concat(right);
2305
+ }
2306
+
2307
+ /**
2308
+ * @param {[number, number]} point
2309
+ * @param {[number, number]} start
2310
+ * @param {[number, number]} end
2311
+ * @returns {number}
2312
+ */
2313
+ function pointSegmentDistanceSq(point, start, end) {
2314
+ const dx = end[0] - start[0];
2315
+ const dy = end[1] - start[1];
2316
+
2317
+ if (dx === 0 && dy === 0) {
2318
+ return distanceSq(point, start);
2319
+ }
2320
+
2321
+ const t = Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy)));
2322
+ return distanceSq(point, [start[0] + t * dx, start[1] + t * dy]);
2323
+ }
2324
+
2325
+ /**
2326
+ * @param {[number, number]} a
2327
+ * @param {[number, number]} b
2328
+ * @returns {number}
2329
+ */
2330
+ function distanceSq(a, b) {
2331
+ const dx = a[0] - b[0];
2332
+ const dy = a[1] - b[1];
2333
+ return dx * dx + dy * dy;
2334
+ }
2335
+
2336
+ /**
2337
+ * @returns {GeneralizationStats}
2338
+ */
2339
+ function emptyGeneralizationStats() {
2340
+ return {
2341
+ originalVertexCount: 0,
2342
+ simplifiedVertexCount: 0,
2343
+ droppedSmallFeatures: 0,
2344
+ simplificationTolerance: 0
2345
+ };
2346
+ }
2347
+
2348
+ /**
2349
+ * @param {GeneralizationStats} target
2350
+ * @param {GeneralizationStats} source
2351
+ */
2352
+ function addGeneralizationStats(target, source) {
2353
+ target.originalVertexCount += source.originalVertexCount;
2354
+ target.simplifiedVertexCount += source.simplifiedVertexCount;
2355
+ target.droppedSmallFeatures += source.droppedSmallFeatures;
2356
+ target.simplificationTolerance = Math.max(target.simplificationTolerance, source.simplificationTolerance);
2357
+ }
2358
+
2359
+ /**
2360
+ * @param {[number, number, number, number]} bbox
2361
+ * @returns {number}
2362
+ */
2363
+ function tileSpanForBbox(bbox) {
2364
+ return Math.max(Math.abs(bbox[2] - bbox[0]), Math.abs(bbox[3] - bbox[1]));
2365
+ }
2366
+
2367
+ /**
2368
+ * @param {{ type?: string, coordinates?: unknown }} geometry
2369
+ * @param {[number, number, number, number]} tileBbox
2370
+ * @param {number} z
2371
+ * @param {string} layerId
2372
+ * @returns {boolean}
2373
+ */
2374
+ function isSmallFeature(geometry, tileBbox, z, layerId) {
2375
+ const minSize = minFeatureSizeTileUnits(layerId, String(geometry?.type ?? ''), z);
2376
+ if (minSize <= 0) {
2377
+ return false;
2378
+ }
2379
+
2380
+ const bbox = geometryBbox(geometry);
2381
+ if (!bbox) {
2382
+ return false;
2383
+ }
2384
+
2385
+ const tileWidth = Math.abs(tileBbox[2] - tileBbox[0]);
2386
+ const tileHeight = Math.abs(tileBbox[3] - tileBbox[1]);
2387
+ if (tileWidth <= 0 || tileHeight <= 0) {
2388
+ return false;
2389
+ }
2390
+
2391
+ const width = (Math.abs(bbox[2] - bbox[0]) / tileWidth) * TILE_EXTENT;
2392
+ const height = (Math.abs(bbox[3] - bbox[1]) / tileHeight) * TILE_EXTENT;
2393
+ return Math.max(width, height) < minSize;
2394
+ }
2395
+
2396
+ /**
2397
+ * @param {string} layerId
2398
+ * @param {string} geometryType
2399
+ * @param {number} z
2400
+ * @returns {number}
2401
+ */
2402
+ function minFeatureSizeTileUnits(layerId, geometryType, z) {
2403
+ if (z >= 15 || geometryType === 'Point') {
2404
+ return 0;
2405
+ }
2406
+
2407
+ if (layerId === 'buildings') {
2408
+ if (z <= 12) return 18;
2409
+ if (z === 13) return 10;
2410
+ if (z === 14) return 5;
2411
+ return 0;
2412
+ }
2413
+
2414
+ if (layerId === 'pois') {
2415
+ return z <= 14 ? 4 : 0;
2416
+ }
2417
+
2418
+ if (layerId === 'boundaries') {
2419
+ return 0;
2420
+ }
2421
+
2422
+ if (layerId === 'roads') {
2423
+ if (z <= 10) return 3;
2424
+ if (z <= 12) return 2;
2425
+ return 1;
2426
+ }
2427
+
2428
+ if (layerId === 'railways' || isAipLayer(layerId)) {
2429
+ if (z <= 10) return 2;
2430
+ if (z <= 12) return 1.2;
2431
+ return 0.8;
2432
+ }
2433
+
2434
+ if (layerId === 'water') {
2435
+ if (z <= 10) return 5;
2436
+ if (z <= 12) return 3;
2437
+ return 1.5;
2438
+ }
2439
+
2440
+ if (layerId === 'landuse') {
2441
+ if (z <= 10) return 6;
2442
+ if (z <= 12) return 4;
2443
+ return 2;
2444
+ }
2445
+
2446
+ return z <= 10 ? 3 : 1;
2447
+ }
2448
+
2449
+ /**
2450
+ * @param {{ type?: string, coordinates?: unknown }} geometry
2451
+ * @returns {boolean}
2452
+ */
2453
+ function hasUsableGeometry(geometry) {
2454
+ switch (geometry?.type) {
2455
+ case 'Point':
2456
+ return Array.isArray(geometry.coordinates) && geometry.coordinates.length >= 2;
2457
+ case 'LineString':
2458
+ return Array.isArray(geometry.coordinates) && geometry.coordinates.length >= 2;
2459
+ case 'MultiLineString':
2460
+ return Array.isArray(geometry.coordinates)
2461
+ && geometry.coordinates.some((line) => Array.isArray(line) && line.length >= 2);
2462
+ case 'Polygon':
2463
+ return Array.isArray(geometry.coordinates)
2464
+ && geometry.coordinates.some((ring) => Array.isArray(ring) && ring.length >= 4);
2465
+ case 'MultiPolygon':
2466
+ return Array.isArray(geometry.coordinates)
2467
+ && geometry.coordinates.some((polygon) => Array.isArray(polygon)
2468
+ && polygon.some((ring) => Array.isArray(ring) && ring.length >= 4));
2469
+ default:
2470
+ return false;
2471
+ }
2472
+ }
2473
+
2474
+ /**
2475
+ * @param {{ coordinates?: unknown }} geometry
2476
+ * @returns {number}
2477
+ */
2478
+ function countGeometryVertices(geometry) {
2479
+ let count = 0;
2480
+ visitCoordinates(geometry?.coordinates, () => {
2481
+ count += 1;
2482
+ });
2483
+ return count;
2484
+ }
2485
+
2486
+ /**
2487
+ * Build a single MVT layer tile. If simplification removes all geometry for a
2488
+ * tile that had candidate features, retry without the pre-simplification pass.
2489
+ *
2490
+ * @param {Array<Record<string, unknown>>} features
2491
+ * @param {[number, number, number, number]} bbox
2492
+ * @param {number} z
2493
+ * @param {number} x
2494
+ * @param {number} y
2495
+ * @param {string} layerId
2496
+ * @param {string} detail
2497
+ * @returns {{ tile: { features: unknown[] }, stats: GeneralizationStats }}
2498
+ */
2499
+ function createLayerTile(features, bbox, z, x, y, layerId, detail) {
2500
+ const simplified = simplifyFeatures(features, bbox, z, layerId, detail);
2501
+ const tile = tileFromFeatures(simplified.features, z, x, y);
2502
+ if (tile.features.length > 0 || features.length === 0) {
2503
+ return {
2504
+ tile,
2505
+ stats: simplified.stats
2506
+ };
2507
+ }
2508
+
2509
+ const fallbackTile = tileFromFeatures(features, z, x, y);
2510
+ return {
2511
+ tile: fallbackTile.features.length > 0 ? fallbackTile : tile,
2512
+ stats: simplified.stats
2513
+ };
2514
+ }
2515
+
2516
+ /**
2517
+ * @param {Array<Record<string, unknown>>} features
2518
+ * @param {number} z
2519
+ * @param {number} x
2520
+ * @param {number} y
2521
+ * @returns {{ features: unknown[] }}
2522
+ */
2523
+ function tileFromFeatures(features, z, x, y) {
2524
+ return createTileIndex(features, z).getTile(z, x, y) ?? { features: [] };
2525
+ }
2526
+
2527
+ /**
2528
+ * @param {Array<Record<string, unknown>>} features
2529
+ * @param {number} z
2530
+ * @returns {ReturnType<typeof geojsonvt>}
2531
+ */
2532
+ function createTileIndex(features, z) {
2533
+ return geojsonvt(
2534
+ {
2535
+ type: 'FeatureCollection',
2536
+ features
2537
+ },
2538
+ {
2539
+ extent: TILE_EXTENT,
2540
+ maxZoom: z,
2541
+ indexMaxZoom: z,
2542
+ buffer: 64,
2543
+ // Geometries are already simplified in lon/lat before indexing.
2544
+ // Keep geojson-vt from applying a second aggressive simplification pass
2545
+ // that can erase small-but-valid low-zoom tile content.
2546
+ tolerance: 0
2547
+ }
2548
+ );
2549
+ }
2550
+
2551
+ /**
2552
+ * @param {string | undefined} detail
2553
+ * @param {number} z
2554
+ * @returns {string}
2555
+ */
2556
+ function normalizeDetail(detail, z) {
2557
+ if (!detail) {
2558
+ return detailForZoom(z);
2559
+ }
2560
+
2561
+ if (!TILE_DETAIL_LEVELS.has(detail)) {
2562
+ throw httpError(400, `detail must be one of: ${[...TILE_DETAIL_LEVELS].join(', ')}`);
2563
+ }
2564
+
2565
+ return detail;
2566
+ }
2567
+
2568
+ /**
2569
+ * @param {number} z
2570
+ * @returns {string}
2571
+ */
2572
+ export function detailForZoom(z) {
2573
+ if (z <= 11) {
2574
+ return 'overview';
2575
+ }
2576
+
2577
+ if (z <= 14) {
2578
+ return 'normal';
2579
+ }
2580
+
2581
+ return 'full';
2582
+ }
2583
+
2584
+ /**
2585
+ * @param {number} statusCode
2586
+ * @param {string} message
2587
+ * @returns {Error & { statusCode: number }}
2588
+ */
2589
+ function httpError(statusCode, message) {
2590
+ const error = /** @type {Error & { statusCode: number }} */ (new Error(message));
2591
+ error.statusCode = statusCode;
2592
+ return error;
2593
+ }