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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/docs/api.md +66 -0
- package/docs/architecture.md +87 -0
- package/docs/cartography.md +77 -0
- package/docs/cesium.md +107 -0
- package/docs/openlayers.md +98 -0
- package/docs/styles.md +103 -0
- package/package.json +51 -0
- package/packages/cesium/package.json +13 -0
- package/packages/cesium/src/index.js +405 -0
- package/packages/ol/package.json +14 -0
- package/packages/ol/src/index.js +1705 -0
- package/packages/ol/src/labels.js +977 -0
- package/src/3dtiles/b3dm.js +38 -0
- package/src/3dtiles/clipper-surfaces.js +317 -0
- package/src/3dtiles/export.js +768 -0
- package/src/3dtiles/extrude.js +301 -0
- package/src/3dtiles/flat.js +531 -0
- package/src/3dtiles/glb.js +178 -0
- package/src/3dtiles/gpkg-buildings.js +240 -0
- package/src/3dtiles/gpkg-features.js +157 -0
- package/src/3dtiles/tileset.js +75 -0
- package/src/build.js +134 -0
- package/src/cli.js +656 -0
- package/src/export-pmtiles.js +962 -0
- package/src/geometry-read.js +50 -0
- package/src/gpkg-read.js +460 -0
- package/src/gpkg.js +567 -0
- package/src/html.js +593 -0
- package/src/layers.js +357 -0
- package/src/manifest.js +29 -0
- package/src/mvt.js +2593 -0
- package/src/ol.js +5 -0
- package/src/osm.js +2110 -0
- package/src/pmtiles-worker.js +70 -0
- package/src/pmtiles.js +260 -0
- package/src/server.js +720 -0
- package/src/style-command.js +78 -0
- package/src/style-filters.js +76 -0
- package/src/style-presets.js +93 -0
- package/src/style-themes.js +235 -0
- package/src/style.js +13 -0
- package/src/tile-cache.js +59 -0
- package/src/utils.js +222 -0
- package/styles/presets/light.json +4655 -0
- package/styles/presets/monochrome.json +4655 -0
- package/styles/presets/neon-dark-3d.json +90 -0
- package/styles/presets/neon-dark.json +4690 -0
- package/styles/presets/tactical.json +4690 -0
- 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
|
+
}
|