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/osm.js
ADDED
|
@@ -0,0 +1,2110 @@
|
|
|
1
|
+
import { createReadStream, rmSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import Database from 'better-sqlite3';
|
|
7
|
+
import parseOsmPbf from 'osm-pbf-parser';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
isPoi,
|
|
11
|
+
layersForRelation,
|
|
12
|
+
layersForWay,
|
|
13
|
+
propertiesForLayer
|
|
14
|
+
} from './layers.js';
|
|
15
|
+
import { openGeoPackageWriter } from './gpkg.js';
|
|
16
|
+
import {
|
|
17
|
+
bboxIntersects,
|
|
18
|
+
closeRing,
|
|
19
|
+
dedupeCoordinates,
|
|
20
|
+
geometryBbox,
|
|
21
|
+
pointInBbox,
|
|
22
|
+
pointInRing
|
|
23
|
+
} from './utils.js';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const osmPbfParsers = require('osm-pbf-parser/lib/parsers.js');
|
|
27
|
+
const { BlobParser, BlobDecompressor } = parseOsmPbf;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{ id: string, type: string, tags: Record<string, string>, refs: string[] }} WaySummary
|
|
31
|
+
* @typedef {{ id: string, type: string, tags: Record<string, string>, layers: string[], members: RelationMember[] }} RelationSummary
|
|
32
|
+
* @typedef {{ id: string, type: string, role: string }} RelationMember
|
|
33
|
+
* @typedef {{ type: string, coordinates: unknown }} Geometry
|
|
34
|
+
* @typedef {{ geometry: Geometry, properties: Record<string, string | null>, bbox: [number, number, number, number] }} Feature
|
|
35
|
+
* @typedef {{
|
|
36
|
+
* phase: 'stage' | 'progress' | 'summary',
|
|
37
|
+
* step: string,
|
|
38
|
+
* label?: string,
|
|
39
|
+
* message?: string,
|
|
40
|
+
* bytesRead?: number,
|
|
41
|
+
* totalBytes?: number,
|
|
42
|
+
* entities?: number,
|
|
43
|
+
* itemsDone?: number,
|
|
44
|
+
* totalItems?: number
|
|
45
|
+
* }} ProgressEvent
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Infer the full source extent from OSM node coordinates.
|
|
50
|
+
*
|
|
51
|
+
* This keeps `--bbox` optional while still avoiding a full in-memory node load.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} source
|
|
54
|
+
* @param {{ totalBytes?: number, onProgress?: (event: ProgressEvent) => void }} [options]
|
|
55
|
+
* @returns {Promise<[number, number, number, number]>}
|
|
56
|
+
*/
|
|
57
|
+
export async function inferOsmBbox(source, options = {}) {
|
|
58
|
+
const headerBbox = await readOsmHeaderBbox(source);
|
|
59
|
+
if (headerBbox) {
|
|
60
|
+
options.onProgress?.({
|
|
61
|
+
phase: 'summary',
|
|
62
|
+
step: 'scan-bbox',
|
|
63
|
+
message: `Inferred bbox ${formatBbox(headerBbox)} from PBF header`
|
|
64
|
+
});
|
|
65
|
+
return headerBbox;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @type {[number, number, number, number] | null} */
|
|
69
|
+
let bbox = null;
|
|
70
|
+
let nodeCount = 0;
|
|
71
|
+
|
|
72
|
+
await scanOsm(source, (entity) => {
|
|
73
|
+
if (entity.type !== 'node') {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const coordinate = getNodeCoordinate(entity);
|
|
78
|
+
if (!coordinate) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const [lon, lat] = coordinate;
|
|
83
|
+
nodeCount += 1;
|
|
84
|
+
|
|
85
|
+
if (!bbox) {
|
|
86
|
+
bbox = [lon, lat, lon, lat];
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
bbox[0] = Math.min(bbox[0], lon);
|
|
91
|
+
bbox[1] = Math.min(bbox[1], lat);
|
|
92
|
+
bbox[2] = Math.max(bbox[2], lon);
|
|
93
|
+
bbox[3] = Math.max(bbox[3], lat);
|
|
94
|
+
}, {
|
|
95
|
+
step: 'scan-bbox',
|
|
96
|
+
label: 'PBF scan: inferring bbox from nodes',
|
|
97
|
+
totalBytes: options.totalBytes,
|
|
98
|
+
onProgress: options.onProgress
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!bbox || bbox[0] >= bbox[2] || bbox[1] >= bbox[3]) {
|
|
102
|
+
throw new Error('could not infer a valid bbox from PBF node coordinates');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
options.onProgress?.({
|
|
106
|
+
phase: 'summary',
|
|
107
|
+
step: 'scan-bbox',
|
|
108
|
+
message: `Inferred bbox ${formatBbox(bbox)} from ${nodeCount} nodes`
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return bbox;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build a GeoPackage from OSM data using a disk-backed temporary SQLite store.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} source
|
|
118
|
+
* @param {[number, number, number, number]} bbox
|
|
119
|
+
* @param {string[]} layers
|
|
120
|
+
* @param {string} gpkgPath
|
|
121
|
+
* @param {{
|
|
122
|
+
* totalBytes?: number,
|
|
123
|
+
* batchSize?: number,
|
|
124
|
+
* keepTemp?: boolean,
|
|
125
|
+
* tempPath?: string,
|
|
126
|
+
* debugBuild?: boolean,
|
|
127
|
+
* onProgress?: (event: ProgressEvent) => void
|
|
128
|
+
* }} [options]
|
|
129
|
+
* @returns {Promise<{ counts: Record<string, number>, tempPath: string }>}
|
|
130
|
+
*/
|
|
131
|
+
export async function buildOsmGeoPackage(source, bbox, layers, gpkgPath, options = {}) {
|
|
132
|
+
const selectedLayers = new Set(layers);
|
|
133
|
+
const progress = options.onProgress;
|
|
134
|
+
const batchSize = options.batchSize ?? 5000;
|
|
135
|
+
const tempPath = options.tempPath ?? join(tmpdir(), `map-zero-build-${process.pid}-${randomUUID()}.sqlite`);
|
|
136
|
+
const temp = createBuildTempDatabase(tempPath);
|
|
137
|
+
const writer = openGeoPackageWriter(gpkgPath, layers, bbox);
|
|
138
|
+
let success = false;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
createBuildTempSchema(temp);
|
|
142
|
+
await scanCandidateRows(source, bbox, selectedLayers, temp, options);
|
|
143
|
+
await scanRelationWayRows(source, temp, options);
|
|
144
|
+
await scanReferencedNodesAndPoints(source, bbox, selectedLayers, temp, writer, options);
|
|
145
|
+
await writeWayFeaturesFromTemp(temp, writer, bbox, batchSize, progress, options.debugBuild);
|
|
146
|
+
await writeRelationFeaturesFromTemp(temp, writer, bbox, batchSize, progress, options.debugBuild);
|
|
147
|
+
|
|
148
|
+
success = true;
|
|
149
|
+
return {
|
|
150
|
+
counts: writer.counts(),
|
|
151
|
+
tempPath
|
|
152
|
+
};
|
|
153
|
+
} finally {
|
|
154
|
+
writer.close();
|
|
155
|
+
temp.close();
|
|
156
|
+
if (success && !options.keepTemp) {
|
|
157
|
+
rmSync(tempPath, { force: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Extract normalized logical features from an OSM PBF file.
|
|
164
|
+
*
|
|
165
|
+
* The parser is streamed, but the file is scanned three times so relation
|
|
166
|
+
* member ways and referenced nodes can be resolved without retaining every node.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} source
|
|
169
|
+
* @param {[number, number, number, number]} bbox
|
|
170
|
+
* @param {string[]} layers
|
|
171
|
+
* @param {{ totalBytes?: number, onProgress?: (event: ProgressEvent) => void }} [options]
|
|
172
|
+
* @returns {Promise<Record<string, Feature[]>>}
|
|
173
|
+
*/
|
|
174
|
+
export async function extractOsmFeatures(source, bbox, layers, options = {}) {
|
|
175
|
+
const selectedLayers = new Set(layers);
|
|
176
|
+
const progress = options.onProgress;
|
|
177
|
+
/** @type {Map<string, WaySummary & { layers: string[] }>} */
|
|
178
|
+
const candidateWays = new Map();
|
|
179
|
+
/** @type {Map<string, RelationSummary>} */
|
|
180
|
+
const candidateRelations = new Map();
|
|
181
|
+
const relationWayIds = new Set();
|
|
182
|
+
const neededNodeIds = createNodeIdStore();
|
|
183
|
+
|
|
184
|
+
await scanOsm(source, (entity) => {
|
|
185
|
+
if (entity.type === 'way') {
|
|
186
|
+
const tags = getTags(entity);
|
|
187
|
+
const matchedLayers = layersForWay(tags, selectedLayers);
|
|
188
|
+
|
|
189
|
+
if (matchedLayers.length > 0) {
|
|
190
|
+
const way = summarizeWay(entity, tags);
|
|
191
|
+
candidateWays.set(way.id, { ...way, layers: matchedLayers });
|
|
192
|
+
for (const ref of way.refs) {
|
|
193
|
+
neededNodeIds.add(ref);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (entity.type === 'relation') {
|
|
199
|
+
const tags = getTags(entity);
|
|
200
|
+
const matchedLayers = layersForRelation(tags, selectedLayers);
|
|
201
|
+
|
|
202
|
+
if (matchedLayers.length > 0) {
|
|
203
|
+
const relation = summarizeRelation(entity, tags, matchedLayers);
|
|
204
|
+
candidateRelations.set(relation.id, relation);
|
|
205
|
+
|
|
206
|
+
for (const member of relation.members) {
|
|
207
|
+
if (member.type === 'way') {
|
|
208
|
+
relationWayIds.add(member.id);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}, {
|
|
214
|
+
step: 'scan-candidates',
|
|
215
|
+
label: 'PBF scan: finding candidate ways and relations',
|
|
216
|
+
totalBytes: options.totalBytes,
|
|
217
|
+
onProgress: progress
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
progress?.({
|
|
221
|
+
phase: 'summary',
|
|
222
|
+
step: 'scan-candidates',
|
|
223
|
+
message: `Found ${candidateWays.size} candidate ways and ${candidateRelations.size} candidate relations`
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
/** @type {Map<string, WaySummary>} */
|
|
227
|
+
const relationWays = new Map();
|
|
228
|
+
|
|
229
|
+
if (relationWayIds.size > 0) {
|
|
230
|
+
await scanOsm(source, (entity) => {
|
|
231
|
+
if (entity.type !== 'way') {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const id = String(entity.id);
|
|
236
|
+
if (!relationWayIds.has(id)) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (candidateWays.has(id)) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const way = summarizeWay(entity, getTags(entity));
|
|
245
|
+
relationWays.set(way.id, way);
|
|
246
|
+
for (const ref of way.refs) {
|
|
247
|
+
neededNodeIds.add(ref);
|
|
248
|
+
}
|
|
249
|
+
}, {
|
|
250
|
+
step: 'scan-relation-ways',
|
|
251
|
+
label: 'PBF scan: resolving relation member ways',
|
|
252
|
+
totalBytes: options.totalBytes,
|
|
253
|
+
onProgress: progress
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
progress?.({
|
|
257
|
+
phase: 'summary',
|
|
258
|
+
step: 'scan-relation-ways',
|
|
259
|
+
message: `Resolved ${relationWays.size} relation member ways`
|
|
260
|
+
});
|
|
261
|
+
} else {
|
|
262
|
+
progress?.({
|
|
263
|
+
phase: 'summary',
|
|
264
|
+
step: 'scan-relation-ways',
|
|
265
|
+
message: 'No relation member ways to resolve'
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
neededNodeIds.flush();
|
|
270
|
+
|
|
271
|
+
const nodes = createNodeCoordinateStore();
|
|
272
|
+
const features = createFeatureBuckets(layers);
|
|
273
|
+
|
|
274
|
+
await scanOsm(source, (entity) => {
|
|
275
|
+
if (entity.type !== 'node') {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const coordinate = getNodeCoordinate(entity);
|
|
280
|
+
if (!coordinate) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const id = String(entity.id);
|
|
285
|
+
if (neededNodeIds.has(id)) {
|
|
286
|
+
nodes.set(id, coordinate);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (selectedLayers.has('pois')) {
|
|
290
|
+
const tags = getTags(entity);
|
|
291
|
+
if (isPoi(tags) && pointInBbox(coordinate, bbox)) {
|
|
292
|
+
features.pois.push({
|
|
293
|
+
geometry: {
|
|
294
|
+
type: 'Point',
|
|
295
|
+
coordinates: coordinate
|
|
296
|
+
},
|
|
297
|
+
properties: propertiesForLayer('pois', { id, type: 'node', tags }),
|
|
298
|
+
bbox: [coordinate[0], coordinate[1], coordinate[0], coordinate[1]]
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (selectedLayers.has('aip')) {
|
|
304
|
+
const tags = getTags(entity);
|
|
305
|
+
if (tags.aeroway && pointInBbox(coordinate, bbox)) {
|
|
306
|
+
features.aip.push({
|
|
307
|
+
geometry: {
|
|
308
|
+
type: 'Point',
|
|
309
|
+
coordinates: coordinate
|
|
310
|
+
},
|
|
311
|
+
properties: propertiesForLayer('aip', { id, type: 'node', tags }),
|
|
312
|
+
bbox: [coordinate[0], coordinate[1], coordinate[0], coordinate[1]]
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}, {
|
|
317
|
+
step: 'scan-nodes',
|
|
318
|
+
label: 'PBF scan: loading referenced nodes and point features',
|
|
319
|
+
totalBytes: options.totalBytes,
|
|
320
|
+
onProgress: progress
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
nodes.flush();
|
|
324
|
+
neededNodeIds.close();
|
|
325
|
+
|
|
326
|
+
progress?.({
|
|
327
|
+
phase: 'summary',
|
|
328
|
+
step: 'scan-nodes',
|
|
329
|
+
message: `Loaded ${nodes.size} referenced nodes`
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
progress?.({
|
|
333
|
+
phase: 'stage',
|
|
334
|
+
step: 'build-way-geometries',
|
|
335
|
+
message: `Building geometries for ${candidateWays.size} ways`
|
|
336
|
+
});
|
|
337
|
+
let processedWays = 0;
|
|
338
|
+
for (const way of candidateWays.values()) {
|
|
339
|
+
for (const layer of way.layers) {
|
|
340
|
+
const geometry = buildWayGeometry(layer, way, nodes);
|
|
341
|
+
addFeatureIfInside(features, layer, geometry, bbox, {
|
|
342
|
+
id: way.id,
|
|
343
|
+
type: 'way',
|
|
344
|
+
tags: way.tags
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
processedWays += 1;
|
|
349
|
+
if (processedWays % 5000 === 0 || processedWays === candidateWays.size) {
|
|
350
|
+
progress?.({
|
|
351
|
+
phase: 'progress',
|
|
352
|
+
step: 'build-way-geometries',
|
|
353
|
+
label: 'Building way geometries',
|
|
354
|
+
itemsDone: processedWays,
|
|
355
|
+
totalItems: candidateWays.size
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
progress?.({
|
|
361
|
+
phase: 'stage',
|
|
362
|
+
step: 'build-relation-geometries',
|
|
363
|
+
message: `Building geometries for ${candidateRelations.size} relations`
|
|
364
|
+
});
|
|
365
|
+
let processedRelations = 0;
|
|
366
|
+
for (const relation of candidateRelations.values()) {
|
|
367
|
+
for (const layer of relation.layers) {
|
|
368
|
+
const geometry = buildRelationGeometry(layer, relation, relationWays, candidateWays, nodes);
|
|
369
|
+
addFeatureIfInside(features, layer, geometry, bbox, {
|
|
370
|
+
id: relation.id,
|
|
371
|
+
type: 'relation',
|
|
372
|
+
tags: relation.tags
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
processedRelations += 1;
|
|
377
|
+
if (processedRelations % 500 === 0 || processedRelations === candidateRelations.size) {
|
|
378
|
+
progress?.({
|
|
379
|
+
phase: 'progress',
|
|
380
|
+
step: 'build-relation-geometries',
|
|
381
|
+
label: 'Building relation geometries',
|
|
382
|
+
itemsDone: processedRelations,
|
|
383
|
+
totalItems: candidateRelations.size
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
progress?.({
|
|
389
|
+
phase: 'summary',
|
|
390
|
+
step: 'extract-complete',
|
|
391
|
+
message: `Extracted ${countFeatures(features)} features`
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
nodes.close();
|
|
395
|
+
|
|
396
|
+
return features;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Stream all decoded OSM entities from a PBF file.
|
|
401
|
+
*
|
|
402
|
+
* @param {string} source
|
|
403
|
+
* @param {(entity: Record<string, unknown>) => void} onEntity
|
|
404
|
+
* @param {{ step: string, label: string, totalBytes?: number, onProgress?: (event: ProgressEvent) => void }} options
|
|
405
|
+
* @returns {Promise<void>}
|
|
406
|
+
*/
|
|
407
|
+
function scanOsm(source, onEntity, options) {
|
|
408
|
+
return new Promise((resolve, reject) => {
|
|
409
|
+
const parser = parseOsmPbf();
|
|
410
|
+
const stream = createReadStream(source);
|
|
411
|
+
let bytesRead = 0;
|
|
412
|
+
let entities = 0;
|
|
413
|
+
|
|
414
|
+
options.onProgress?.({
|
|
415
|
+
phase: 'stage',
|
|
416
|
+
step: options.step,
|
|
417
|
+
message: options.label
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
stream
|
|
421
|
+
.on('data', (chunk) => {
|
|
422
|
+
bytesRead += chunk.length;
|
|
423
|
+
options.onProgress?.({
|
|
424
|
+
phase: 'progress',
|
|
425
|
+
step: options.step,
|
|
426
|
+
label: options.label,
|
|
427
|
+
bytesRead,
|
|
428
|
+
totalBytes: options.totalBytes,
|
|
429
|
+
entities
|
|
430
|
+
});
|
|
431
|
+
})
|
|
432
|
+
.on('error', reject)
|
|
433
|
+
.pipe(parser)
|
|
434
|
+
.on('data', (items) => {
|
|
435
|
+
const batch = Array.isArray(items) ? items : [items];
|
|
436
|
+
entities += batch.length;
|
|
437
|
+
for (const item of batch) {
|
|
438
|
+
onEntity(/** @type {Record<string, unknown>} */ (item));
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
.on('error', reject)
|
|
442
|
+
.on('end', () => {
|
|
443
|
+
options.onProgress?.({
|
|
444
|
+
phase: 'progress',
|
|
445
|
+
step: options.step,
|
|
446
|
+
label: options.label,
|
|
447
|
+
bytesRead,
|
|
448
|
+
totalBytes: options.totalBytes,
|
|
449
|
+
entities
|
|
450
|
+
});
|
|
451
|
+
resolve();
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* @param {string} tempPath
|
|
458
|
+
* @returns {Database.Database}
|
|
459
|
+
*/
|
|
460
|
+
function createBuildTempDatabase(tempPath) {
|
|
461
|
+
rmSync(tempPath, { force: true });
|
|
462
|
+
const db = new Database(tempPath);
|
|
463
|
+
db.pragma('journal_mode = OFF');
|
|
464
|
+
db.pragma('synchronous = OFF');
|
|
465
|
+
db.pragma('locking_mode = EXCLUSIVE');
|
|
466
|
+
db.pragma('cache_size = -128000');
|
|
467
|
+
return db;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* @param {Database.Database} db
|
|
472
|
+
*/
|
|
473
|
+
function createBuildTempSchema(db) {
|
|
474
|
+
db.exec(`
|
|
475
|
+
CREATE TABLE needed_nodes (
|
|
476
|
+
node_id TEXT PRIMARY KEY
|
|
477
|
+
) WITHOUT ROWID;
|
|
478
|
+
|
|
479
|
+
CREATE TABLE nodes (
|
|
480
|
+
id TEXT PRIMARY KEY,
|
|
481
|
+
lon REAL NOT NULL,
|
|
482
|
+
lat REAL NOT NULL
|
|
483
|
+
) WITHOUT ROWID;
|
|
484
|
+
|
|
485
|
+
CREATE TABLE candidate_ways (
|
|
486
|
+
id TEXT NOT NULL,
|
|
487
|
+
layer TEXT NOT NULL,
|
|
488
|
+
tags_json TEXT NOT NULL,
|
|
489
|
+
node_ids_json TEXT NOT NULL,
|
|
490
|
+
PRIMARY KEY (id, layer)
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
CREATE TABLE relation_way_ids (
|
|
494
|
+
id TEXT PRIMARY KEY
|
|
495
|
+
) WITHOUT ROWID;
|
|
496
|
+
|
|
497
|
+
CREATE TABLE relation_ways (
|
|
498
|
+
id TEXT PRIMARY KEY,
|
|
499
|
+
tags_json TEXT NOT NULL,
|
|
500
|
+
node_ids_json TEXT NOT NULL
|
|
501
|
+
) WITHOUT ROWID;
|
|
502
|
+
|
|
503
|
+
CREATE TABLE candidate_relations (
|
|
504
|
+
id TEXT NOT NULL,
|
|
505
|
+
layer TEXT NOT NULL,
|
|
506
|
+
tags_json TEXT NOT NULL,
|
|
507
|
+
members_json TEXT NOT NULL,
|
|
508
|
+
PRIMARY KEY (id, layer)
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
CREATE INDEX candidate_ways_layer_idx ON candidate_ways(layer);
|
|
512
|
+
CREATE INDEX candidate_relations_layer_idx ON candidate_relations(layer);
|
|
513
|
+
`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* @param {string} source
|
|
518
|
+
* @param {[number, number, number, number]} bbox
|
|
519
|
+
* @param {Set<string>} selectedLayers
|
|
520
|
+
* @param {Database.Database} db
|
|
521
|
+
* @param {{ totalBytes?: number, onProgress?: (event: ProgressEvent) => void }} options
|
|
522
|
+
*/
|
|
523
|
+
async function scanCandidateRows(source, bbox, selectedLayers, db, options) {
|
|
524
|
+
const insertWay = db.prepare('INSERT OR IGNORE INTO candidate_ways (id, layer, tags_json, node_ids_json) VALUES (?, ?, ?, ?)');
|
|
525
|
+
const insertNeededNode = db.prepare('INSERT OR IGNORE INTO needed_nodes (node_id) VALUES (?)');
|
|
526
|
+
const insertRelationWayId = db.prepare('INSERT OR IGNORE INTO relation_way_ids (id) VALUES (?)');
|
|
527
|
+
const insertRelation = db.prepare('INSERT OR IGNORE INTO candidate_relations (id, layer, tags_json, members_json) VALUES (?, ?, ?, ?)');
|
|
528
|
+
let candidateWayRows = 0;
|
|
529
|
+
let candidateRelations = 0;
|
|
530
|
+
|
|
531
|
+
db.exec('BEGIN');
|
|
532
|
+
try {
|
|
533
|
+
await scanOsm(source, (entity) => {
|
|
534
|
+
if (entity.type === 'way') {
|
|
535
|
+
const tags = getTags(entity);
|
|
536
|
+
const matchedLayers = layersForWay(tags, selectedLayers);
|
|
537
|
+
|
|
538
|
+
if (matchedLayers.length > 0) {
|
|
539
|
+
const way = summarizeWay(entity, tags);
|
|
540
|
+
const tagsJson = JSON.stringify(tags);
|
|
541
|
+
const refsJson = JSON.stringify(way.refs);
|
|
542
|
+
|
|
543
|
+
for (const layer of matchedLayers) {
|
|
544
|
+
insertWay.run(way.id, layer, tagsJson, refsJson);
|
|
545
|
+
candidateWayRows += 1;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
for (const ref of way.refs) {
|
|
549
|
+
insertNeededNode.run(ref);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (entity.type === 'relation') {
|
|
555
|
+
const tags = getTags(entity);
|
|
556
|
+
const matchedLayers = layersForRelation(tags, selectedLayers);
|
|
557
|
+
|
|
558
|
+
if (matchedLayers.length > 0) {
|
|
559
|
+
const relation = summarizeRelation(entity, tags, matchedLayers);
|
|
560
|
+
const tagsJson = JSON.stringify(tags);
|
|
561
|
+
const membersJson = JSON.stringify(relation.members);
|
|
562
|
+
|
|
563
|
+
for (const layer of matchedLayers) {
|
|
564
|
+
insertRelation.run(relation.id, layer, tagsJson, membersJson);
|
|
565
|
+
candidateRelations += 1;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
for (const member of relation.members) {
|
|
569
|
+
if (member.type === 'way') {
|
|
570
|
+
insertRelationWayId.run(member.id);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}, {
|
|
576
|
+
step: 'scan-candidates',
|
|
577
|
+
label: 'PBF scan: finding candidate ways and relations',
|
|
578
|
+
totalBytes: options.totalBytes,
|
|
579
|
+
onProgress: options.onProgress
|
|
580
|
+
});
|
|
581
|
+
db.exec('COMMIT');
|
|
582
|
+
} catch (error) {
|
|
583
|
+
db.exec('ROLLBACK');
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
options.onProgress?.({
|
|
588
|
+
phase: 'summary',
|
|
589
|
+
step: 'scan-candidates',
|
|
590
|
+
message: `Stored ${candidateWayRows} candidate way rows and ${candidateRelations} candidate relation rows`
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* @param {string} source
|
|
596
|
+
* @param {Database.Database} db
|
|
597
|
+
* @param {{ totalBytes?: number, onProgress?: (event: ProgressEvent) => void }} options
|
|
598
|
+
*/
|
|
599
|
+
async function scanRelationWayRows(source, db, options) {
|
|
600
|
+
const relationWayNeeded = db.prepare('SELECT 1 FROM relation_way_ids WHERE id = ?');
|
|
601
|
+
const candidateWayExists = db.prepare('SELECT 1 FROM candidate_ways WHERE id = ? LIMIT 1');
|
|
602
|
+
const insertRelationWay = db.prepare('INSERT OR IGNORE INTO relation_ways (id, tags_json, node_ids_json) VALUES (?, ?, ?)');
|
|
603
|
+
const insertNeededNode = db.prepare('INSERT OR IGNORE INTO needed_nodes (node_id) VALUES (?)');
|
|
604
|
+
const relationWayCount = /** @type {{ count: number }} */ (db.prepare('SELECT COUNT(*) AS count FROM relation_way_ids').get()).count;
|
|
605
|
+
let resolved = 0;
|
|
606
|
+
|
|
607
|
+
if (relationWayCount === 0) {
|
|
608
|
+
options.onProgress?.({
|
|
609
|
+
phase: 'summary',
|
|
610
|
+
step: 'scan-relation-ways',
|
|
611
|
+
message: 'No relation member ways to resolve'
|
|
612
|
+
});
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
db.exec('BEGIN');
|
|
617
|
+
try {
|
|
618
|
+
await scanOsm(source, (entity) => {
|
|
619
|
+
if (entity.type !== 'way') {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const id = String(entity.id);
|
|
624
|
+
if (!relationWayNeeded.get(id) || candidateWayExists.get(id)) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const way = summarizeWay(entity, getTags(entity));
|
|
629
|
+
insertRelationWay.run(way.id, JSON.stringify(way.tags), JSON.stringify(way.refs));
|
|
630
|
+
resolved += 1;
|
|
631
|
+
|
|
632
|
+
for (const ref of way.refs) {
|
|
633
|
+
insertNeededNode.run(ref);
|
|
634
|
+
}
|
|
635
|
+
}, {
|
|
636
|
+
step: 'scan-relation-ways',
|
|
637
|
+
label: 'PBF scan: resolving relation member ways',
|
|
638
|
+
totalBytes: options.totalBytes,
|
|
639
|
+
onProgress: options.onProgress
|
|
640
|
+
});
|
|
641
|
+
db.exec('COMMIT');
|
|
642
|
+
} catch (error) {
|
|
643
|
+
db.exec('ROLLBACK');
|
|
644
|
+
throw error;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
options.onProgress?.({
|
|
648
|
+
phase: 'summary',
|
|
649
|
+
step: 'scan-relation-ways',
|
|
650
|
+
message: `Resolved ${resolved} relation member ways`
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* @param {string} source
|
|
656
|
+
* @param {[number, number, number, number]} bbox
|
|
657
|
+
* @param {Set<string>} selectedLayers
|
|
658
|
+
* @param {Database.Database} db
|
|
659
|
+
* @param {ReturnType<typeof openGeoPackageWriter>} writer
|
|
660
|
+
* @param {{ totalBytes?: number, onProgress?: (event: ProgressEvent) => void }} options
|
|
661
|
+
*/
|
|
662
|
+
async function scanReferencedNodesAndPoints(source, bbox, selectedLayers, db, writer, options) {
|
|
663
|
+
const neededNode = db.prepare('SELECT 1 FROM needed_nodes WHERE node_id = ?');
|
|
664
|
+
const insertNode = db.prepare('INSERT OR REPLACE INTO nodes (id, lon, lat) VALUES (?, ?, ?)');
|
|
665
|
+
let loaded = 0;
|
|
666
|
+
|
|
667
|
+
db.exec('BEGIN');
|
|
668
|
+
try {
|
|
669
|
+
await scanOsm(source, (entity) => {
|
|
670
|
+
if (entity.type !== 'node') {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const coordinate = getNodeCoordinate(entity);
|
|
675
|
+
if (!coordinate) {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const id = String(entity.id);
|
|
680
|
+
if (neededNode.get(id)) {
|
|
681
|
+
insertNode.run(id, coordinate[0], coordinate[1]);
|
|
682
|
+
loaded += 1;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
writePointFeaturesIfNeeded(writer, selectedLayers, entity, id, coordinate, bbox);
|
|
686
|
+
}, {
|
|
687
|
+
step: 'scan-nodes',
|
|
688
|
+
label: 'PBF scan: loading referenced nodes and point features',
|
|
689
|
+
totalBytes: options.totalBytes,
|
|
690
|
+
onProgress: options.onProgress
|
|
691
|
+
});
|
|
692
|
+
db.exec('COMMIT');
|
|
693
|
+
} catch (error) {
|
|
694
|
+
db.exec('ROLLBACK');
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
options.onProgress?.({
|
|
699
|
+
phase: 'summary',
|
|
700
|
+
step: 'scan-nodes',
|
|
701
|
+
message: `Loaded ${loaded} referenced nodes`
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* @param {ReturnType<typeof openGeoPackageWriter>} writer
|
|
707
|
+
* @param {Set<string>} selectedLayers
|
|
708
|
+
* @param {Record<string, unknown>} entity
|
|
709
|
+
* @param {string} id
|
|
710
|
+
* @param {[number, number]} coordinate
|
|
711
|
+
* @param {[number, number, number, number]} bbox
|
|
712
|
+
*/
|
|
713
|
+
function writePointFeaturesIfNeeded(writer, selectedLayers, entity, id, coordinate, bbox) {
|
|
714
|
+
if (!pointInBbox(coordinate, bbox)) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (selectedLayers.has('pois')) {
|
|
719
|
+
const tags = getTags(entity);
|
|
720
|
+
if (isPoi(tags)) {
|
|
721
|
+
writer.insertFeature('pois', {
|
|
722
|
+
geometry: {
|
|
723
|
+
type: 'Point',
|
|
724
|
+
coordinates: coordinate
|
|
725
|
+
},
|
|
726
|
+
properties: propertiesForLayer('pois', { id, type: 'node', tags }),
|
|
727
|
+
bbox: [coordinate[0], coordinate[1], coordinate[0], coordinate[1]]
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (selectedLayers.has('aip')) {
|
|
733
|
+
const tags = getTags(entity);
|
|
734
|
+
if (tags.aeroway) {
|
|
735
|
+
writer.insertFeature('aip', {
|
|
736
|
+
geometry: {
|
|
737
|
+
type: 'Point',
|
|
738
|
+
coordinates: coordinate
|
|
739
|
+
},
|
|
740
|
+
properties: propertiesForLayer('aip', { id, type: 'node', tags }),
|
|
741
|
+
bbox: [coordinate[0], coordinate[1], coordinate[0], coordinate[1]]
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* @param {Database.Database} db
|
|
749
|
+
* @param {ReturnType<typeof openGeoPackageWriter>} writer
|
|
750
|
+
* @param {[number, number, number, number]} bbox
|
|
751
|
+
* @param {number} batchSize
|
|
752
|
+
* @param {((event: ProgressEvent) => void) | undefined} progress
|
|
753
|
+
* @param {boolean | undefined} debugBuild
|
|
754
|
+
*/
|
|
755
|
+
async function writeWayFeaturesFromTemp(db, writer, bbox, batchSize, progress, debugBuild) {
|
|
756
|
+
const total = /** @type {{ count: number }} */ (db.prepare('SELECT COUNT(*) AS count FROM candidate_ways').get()).count;
|
|
757
|
+
const selectBatch = db.prepare(`
|
|
758
|
+
SELECT rowid, id, layer, tags_json, node_ids_json
|
|
759
|
+
FROM candidate_ways
|
|
760
|
+
WHERE rowid > ?
|
|
761
|
+
ORDER BY rowid
|
|
762
|
+
LIMIT ?
|
|
763
|
+
`);
|
|
764
|
+
let lastRowId = 0;
|
|
765
|
+
let processed = 0;
|
|
766
|
+
|
|
767
|
+
progress?.({
|
|
768
|
+
phase: 'stage',
|
|
769
|
+
step: 'build-way-geometries',
|
|
770
|
+
message: `Building geometries for ${total} way rows`
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
while (processed < total) {
|
|
774
|
+
const rows = /** @type {Array<{ rowid: number, id: string, layer: string, tags_json: string, node_ids_json: string }>} */ (selectBatch.all(lastRowId, batchSize));
|
|
775
|
+
if (rows.length === 0) {
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
lastRowId = rows.at(-1)?.rowid ?? lastRowId;
|
|
780
|
+
const ways = rows.map((row) => ({
|
|
781
|
+
id: row.id,
|
|
782
|
+
layer: row.layer,
|
|
783
|
+
tags: /** @type {Record<string, string>} */ (JSON.parse(row.tags_json)),
|
|
784
|
+
refs: /** @type {string[]} */ (JSON.parse(row.node_ids_json))
|
|
785
|
+
}));
|
|
786
|
+
const nodes = loadNodesForWays(db, ways);
|
|
787
|
+
|
|
788
|
+
writer.transaction(() => {
|
|
789
|
+
for (const way of ways) {
|
|
790
|
+
const geometry = buildWayGeometry(way.layer, {
|
|
791
|
+
id: way.id,
|
|
792
|
+
type: 'way',
|
|
793
|
+
tags: way.tags,
|
|
794
|
+
refs: way.refs
|
|
795
|
+
}, nodes);
|
|
796
|
+
writeFeatureIfInside(writer, way.layer, geometry, bbox, {
|
|
797
|
+
id: way.id,
|
|
798
|
+
type: 'way',
|
|
799
|
+
tags: way.tags
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
processed += rows.length;
|
|
805
|
+
if (processed % (batchSize * 4) === 0 || processed >= total) {
|
|
806
|
+
progress?.({
|
|
807
|
+
phase: 'progress',
|
|
808
|
+
step: 'build-way-geometries',
|
|
809
|
+
label: debugBuild ? `Building way geometries (${memorySummary()})` : 'Building way geometries',
|
|
810
|
+
itemsDone: processed,
|
|
811
|
+
totalItems: total
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* @param {Database.Database} db
|
|
819
|
+
* @param {ReturnType<typeof openGeoPackageWriter>} writer
|
|
820
|
+
* @param {[number, number, number, number]} bbox
|
|
821
|
+
* @param {number} batchSize
|
|
822
|
+
* @param {((event: ProgressEvent) => void) | undefined} progress
|
|
823
|
+
* @param {boolean | undefined} debugBuild
|
|
824
|
+
*/
|
|
825
|
+
async function writeRelationFeaturesFromTemp(db, writer, bbox, batchSize, progress, debugBuild) {
|
|
826
|
+
const total = /** @type {{ count: number }} */ (db.prepare('SELECT COUNT(*) AS count FROM candidate_relations').get()).count;
|
|
827
|
+
const selectBatch = db.prepare(`
|
|
828
|
+
SELECT rowid, id, layer, tags_json, members_json
|
|
829
|
+
FROM candidate_relations
|
|
830
|
+
WHERE rowid > ?
|
|
831
|
+
ORDER BY rowid
|
|
832
|
+
LIMIT ?
|
|
833
|
+
`);
|
|
834
|
+
let lastRowId = 0;
|
|
835
|
+
let processed = 0;
|
|
836
|
+
|
|
837
|
+
progress?.({
|
|
838
|
+
phase: 'stage',
|
|
839
|
+
step: 'build-relation-geometries',
|
|
840
|
+
message: `Building geometries for ${total} relation rows`
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
while (processed < total) {
|
|
844
|
+
const rows = /** @type {Array<{ rowid: number, id: string, layer: string, tags_json: string, members_json: string }>} */ (selectBatch.all(lastRowId, batchSize));
|
|
845
|
+
if (rows.length === 0) {
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
lastRowId = rows.at(-1)?.rowid ?? lastRowId;
|
|
850
|
+
writer.transaction(() => {
|
|
851
|
+
for (const row of rows) {
|
|
852
|
+
const relation = {
|
|
853
|
+
id: row.id,
|
|
854
|
+
type: 'relation',
|
|
855
|
+
tags: /** @type {Record<string, string>} */ (JSON.parse(row.tags_json)),
|
|
856
|
+
layers: [row.layer],
|
|
857
|
+
members: /** @type {RelationMember[]} */ (JSON.parse(row.members_json))
|
|
858
|
+
};
|
|
859
|
+
const relationWays = loadRelationWayMaps(db, relation);
|
|
860
|
+
const nodes = loadNodesForWayMaps(db, relationWays.relationWays, relationWays.candidateWays);
|
|
861
|
+
const geometry = buildRelationGeometry(row.layer, relation, relationWays.relationWays, relationWays.candidateWays, nodes);
|
|
862
|
+
writeFeatureIfInside(writer, row.layer, geometry, bbox, {
|
|
863
|
+
id: row.id,
|
|
864
|
+
type: 'relation',
|
|
865
|
+
tags: relation.tags
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
processed += rows.length;
|
|
871
|
+
progress?.({
|
|
872
|
+
phase: 'progress',
|
|
873
|
+
step: 'build-relation-geometries',
|
|
874
|
+
label: debugBuild ? `Building relation geometries (${memorySummary()})` : 'Building relation geometries',
|
|
875
|
+
itemsDone: processed,
|
|
876
|
+
totalItems: total
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* @param {Database.Database} db
|
|
883
|
+
* @param {Array<{ refs: string[] }>} ways
|
|
884
|
+
* @returns {Map<string, [number, number]>}
|
|
885
|
+
*/
|
|
886
|
+
function loadNodesForWays(db, ways) {
|
|
887
|
+
const ids = new Set();
|
|
888
|
+
for (const way of ways) {
|
|
889
|
+
for (const ref of way.refs) {
|
|
890
|
+
ids.add(ref);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return loadNodeMap(db, ids);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* @param {Database.Database} db
|
|
899
|
+
* @param {Set<string>} ids
|
|
900
|
+
* @returns {Map<string, [number, number]>}
|
|
901
|
+
*/
|
|
902
|
+
function loadNodeMap(db, ids) {
|
|
903
|
+
const selectNode = db.prepare('SELECT lon, lat FROM nodes WHERE id = ?');
|
|
904
|
+
const nodes = new Map();
|
|
905
|
+
for (const id of ids) {
|
|
906
|
+
const row = /** @type {{ lon: number, lat: number } | undefined} */ (selectNode.get(id));
|
|
907
|
+
if (row) {
|
|
908
|
+
nodes.set(id, [row.lon, row.lat]);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return nodes;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* @param {Database.Database} db
|
|
916
|
+
* @param {RelationSummary} relation
|
|
917
|
+
* @returns {{ relationWays: Map<string, WaySummary>, candidateWays: Map<string, WaySummary & { layers: string[] }> }}
|
|
918
|
+
*/
|
|
919
|
+
function loadRelationWayMaps(db, relation) {
|
|
920
|
+
const selectRelationWay = db.prepare('SELECT tags_json, node_ids_json FROM relation_ways WHERE id = ?');
|
|
921
|
+
const selectCandidateWay = db.prepare('SELECT layer, tags_json, node_ids_json FROM candidate_ways WHERE id = ? LIMIT 1');
|
|
922
|
+
/** @type {Map<string, WaySummary>} */
|
|
923
|
+
const relationWays = new Map();
|
|
924
|
+
/** @type {Map<string, WaySummary & { layers: string[] }>} */
|
|
925
|
+
const candidateWays = new Map();
|
|
926
|
+
|
|
927
|
+
for (const member of relation.members) {
|
|
928
|
+
if (member.type !== 'way') {
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const relationWay = /** @type {{ tags_json: string, node_ids_json: string } | undefined} */ (selectRelationWay.get(member.id));
|
|
933
|
+
if (relationWay) {
|
|
934
|
+
relationWays.set(member.id, {
|
|
935
|
+
id: member.id,
|
|
936
|
+
type: 'way',
|
|
937
|
+
tags: /** @type {Record<string, string>} */ (JSON.parse(relationWay.tags_json)),
|
|
938
|
+
refs: /** @type {string[]} */ (JSON.parse(relationWay.node_ids_json))
|
|
939
|
+
});
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const candidateWay = /** @type {{ layer: string, tags_json: string, node_ids_json: string } | undefined} */ (selectCandidateWay.get(member.id));
|
|
944
|
+
if (candidateWay) {
|
|
945
|
+
candidateWays.set(member.id, {
|
|
946
|
+
id: member.id,
|
|
947
|
+
type: 'way',
|
|
948
|
+
tags: /** @type {Record<string, string>} */ (JSON.parse(candidateWay.tags_json)),
|
|
949
|
+
refs: /** @type {string[]} */ (JSON.parse(candidateWay.node_ids_json)),
|
|
950
|
+
layers: [candidateWay.layer]
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
return { relationWays, candidateWays };
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* @param {Database.Database} db
|
|
960
|
+
* @param {Map<string, WaySummary>} relationWays
|
|
961
|
+
* @param {Map<string, WaySummary & { layers: string[] }>} candidateWays
|
|
962
|
+
* @returns {Map<string, [number, number]>}
|
|
963
|
+
*/
|
|
964
|
+
function loadNodesForWayMaps(db, relationWays, candidateWays) {
|
|
965
|
+
const ids = new Set();
|
|
966
|
+
for (const way of relationWays.values()) {
|
|
967
|
+
for (const ref of way.refs) {
|
|
968
|
+
ids.add(ref);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
for (const way of candidateWays.values()) {
|
|
972
|
+
for (const ref of way.refs) {
|
|
973
|
+
ids.add(ref);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return loadNodeMap(db, ids);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* @param {ReturnType<typeof openGeoPackageWriter>} writer
|
|
981
|
+
* @param {string} layer
|
|
982
|
+
* @param {Geometry | null} geometry
|
|
983
|
+
* @param {[number, number, number, number]} bbox
|
|
984
|
+
* @param {{ id: string | number, type: string, tags: Record<string, string> }} entity
|
|
985
|
+
*/
|
|
986
|
+
function writeFeatureIfInside(writer, layer, geometry, bbox, entity) {
|
|
987
|
+
if (!geometry) {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const normalizedGeometry = normalizeFeatureGeometryForLayer(layer, geometry);
|
|
992
|
+
if (!normalizedGeometry) {
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const featureBbox = geometryBbox(normalizedGeometry);
|
|
997
|
+
if (!featureBbox || !bboxIntersects(featureBbox, bbox)) {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
writer.insertFeature(layer, {
|
|
1002
|
+
geometry: normalizedGeometry,
|
|
1003
|
+
properties: propertiesForLayer(layer, entity),
|
|
1004
|
+
bbox: featureBbox
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Match feature geometry to the destination GeoPackage layer type.
|
|
1010
|
+
*
|
|
1011
|
+
* Some operational infrastructure is mapped as areas or ways in OSM, but the
|
|
1012
|
+
* `pois` layer is intentionally point-based. Store a representative point so
|
|
1013
|
+
* station buildings, protected-area relations, power plants, etc. can still be
|
|
1014
|
+
* queried and labelled as POIs without changing the public layer schema.
|
|
1015
|
+
*
|
|
1016
|
+
* @param {string} layer
|
|
1017
|
+
* @param {Geometry} geometry
|
|
1018
|
+
* @returns {Geometry | null}
|
|
1019
|
+
*/
|
|
1020
|
+
function normalizeFeatureGeometryForLayer(layer, geometry) {
|
|
1021
|
+
if (layer !== 'pois') {
|
|
1022
|
+
return geometry;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const coordinate = representativePoint(geometry);
|
|
1026
|
+
if (!coordinate) {
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return {
|
|
1031
|
+
type: 'Point',
|
|
1032
|
+
coordinates: coordinate
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* @param {Geometry} geometry
|
|
1038
|
+
* @returns {[number, number] | null}
|
|
1039
|
+
*/
|
|
1040
|
+
function representativePoint(geometry) {
|
|
1041
|
+
if (geometry.type === 'Point' && isCoordinate(geometry.coordinates)) {
|
|
1042
|
+
return geometry.coordinates;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (geometry.type === 'MultiPoint' && Array.isArray(geometry.coordinates)) {
|
|
1046
|
+
return firstCoordinate(geometry.coordinates);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (geometry.type === 'LineString' && Array.isArray(geometry.coordinates)) {
|
|
1050
|
+
return lineAnchor(geometry.coordinates);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (geometry.type === 'MultiLineString' && Array.isArray(geometry.coordinates)) {
|
|
1054
|
+
const line = largestLine(geometry.coordinates);
|
|
1055
|
+
return line ? lineAnchor(line) : bboxCenter(geometry);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (geometry.type === 'Polygon' && Array.isArray(geometry.coordinates)) {
|
|
1059
|
+
return polygonAnchor(geometry.coordinates) ?? bboxCenter(geometry);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (geometry.type === 'MultiPolygon' && Array.isArray(geometry.coordinates)) {
|
|
1063
|
+
const polygon = largestPolygon(geometry.coordinates);
|
|
1064
|
+
return polygon ? polygonAnchor(polygon) ?? bboxCenter(geometry) : bboxCenter(geometry);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
return bboxCenter(geometry);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* @param {unknown} value
|
|
1072
|
+
* @returns {value is [number, number]}
|
|
1073
|
+
*/
|
|
1074
|
+
function isCoordinate(value) {
|
|
1075
|
+
return Array.isArray(value) && value.length >= 2 && Number.isFinite(value[0]) && Number.isFinite(value[1]);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* @param {unknown[]} values
|
|
1080
|
+
* @returns {[number, number] | null}
|
|
1081
|
+
*/
|
|
1082
|
+
function firstCoordinate(values) {
|
|
1083
|
+
for (const value of values) {
|
|
1084
|
+
if (isCoordinate(value)) {
|
|
1085
|
+
return value;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* @param {unknown[]} coordinates
|
|
1093
|
+
* @returns {[number, number] | null}
|
|
1094
|
+
*/
|
|
1095
|
+
function lineAnchor(coordinates) {
|
|
1096
|
+
const line = coordinates.filter(isCoordinate);
|
|
1097
|
+
if (line.length === 0) {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
if (line.length === 1) {
|
|
1101
|
+
return line[0];
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
let totalLength = 0;
|
|
1105
|
+
for (let index = 1; index < line.length; index += 1) {
|
|
1106
|
+
totalLength += coordinateDistance(line[index - 1], line[index]);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (totalLength === 0) {
|
|
1110
|
+
return line[Math.floor(line.length / 2)];
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const target = totalLength / 2;
|
|
1114
|
+
let distance = 0;
|
|
1115
|
+
for (let index = 1; index < line.length; index += 1) {
|
|
1116
|
+
const previous = line[index - 1];
|
|
1117
|
+
const current = line[index];
|
|
1118
|
+
const segmentLength = coordinateDistance(previous, current);
|
|
1119
|
+
if (distance + segmentLength >= target) {
|
|
1120
|
+
const ratio = segmentLength === 0 ? 0 : (target - distance) / segmentLength;
|
|
1121
|
+
return [
|
|
1122
|
+
previous[0] + (current[0] - previous[0]) * ratio,
|
|
1123
|
+
previous[1] + (current[1] - previous[1]) * ratio
|
|
1124
|
+
];
|
|
1125
|
+
}
|
|
1126
|
+
distance += segmentLength;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return line.at(-1) ?? null;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* @param {unknown[]} lines
|
|
1134
|
+
* @returns {unknown[] | null}
|
|
1135
|
+
*/
|
|
1136
|
+
function largestLine(lines) {
|
|
1137
|
+
/** @type {unknown[] | null} */
|
|
1138
|
+
let best = null;
|
|
1139
|
+
let bestLength = -1;
|
|
1140
|
+
|
|
1141
|
+
for (const line of lines) {
|
|
1142
|
+
if (!Array.isArray(line)) {
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
let length = 0;
|
|
1147
|
+
const coordinates = line.filter(isCoordinate);
|
|
1148
|
+
for (let index = 1; index < coordinates.length; index += 1) {
|
|
1149
|
+
length += coordinateDistance(coordinates[index - 1], coordinates[index]);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (length > bestLength) {
|
|
1153
|
+
best = line;
|
|
1154
|
+
bestLength = length;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
return best;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* @param {unknown[]} polygon
|
|
1163
|
+
* @returns {[number, number] | null}
|
|
1164
|
+
*/
|
|
1165
|
+
function polygonAnchor(polygon) {
|
|
1166
|
+
const outerRing = Array.isArray(polygon[0]) ? /** @type {unknown[]} */ (polygon[0]) : [];
|
|
1167
|
+
const ring = outerRing.filter(isCoordinate);
|
|
1168
|
+
if (ring.length === 0) {
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const coordinates = ring.length > 1 && sameCoordinate(ring[0], ring.at(-1)) ? ring.slice(0, -1) : ring;
|
|
1173
|
+
const sum = coordinates.reduce((accumulator, coordinate) => {
|
|
1174
|
+
accumulator[0] += coordinate[0];
|
|
1175
|
+
accumulator[1] += coordinate[1];
|
|
1176
|
+
return accumulator;
|
|
1177
|
+
}, [0, 0]);
|
|
1178
|
+
/** @type {[number, number]} */
|
|
1179
|
+
const center = [sum[0] / coordinates.length, sum[1] / coordinates.length];
|
|
1180
|
+
|
|
1181
|
+
return pointInRing(center, ring) ? center : ring[0];
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* @param {unknown[]} polygons
|
|
1186
|
+
* @returns {unknown[] | null}
|
|
1187
|
+
*/
|
|
1188
|
+
function largestPolygon(polygons) {
|
|
1189
|
+
/** @type {unknown[] | null} */
|
|
1190
|
+
let best = null;
|
|
1191
|
+
let bestArea = -1;
|
|
1192
|
+
|
|
1193
|
+
for (const polygon of polygons) {
|
|
1194
|
+
if (!Array.isArray(polygon) || !Array.isArray(polygon[0])) {
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const area = Math.abs(ringArea(/** @type {unknown[]} */ (polygon[0])));
|
|
1199
|
+
if (area > bestArea) {
|
|
1200
|
+
best = polygon;
|
|
1201
|
+
bestArea = area;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return best;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* @param {unknown[]} ring
|
|
1210
|
+
* @returns {number}
|
|
1211
|
+
*/
|
|
1212
|
+
function ringArea(ring) {
|
|
1213
|
+
const coordinates = ring.filter(isCoordinate);
|
|
1214
|
+
let area = 0;
|
|
1215
|
+
|
|
1216
|
+
for (let index = 0; index < coordinates.length; index += 1) {
|
|
1217
|
+
const current = coordinates[index];
|
|
1218
|
+
const next = coordinates[(index + 1) % coordinates.length];
|
|
1219
|
+
area += current[0] * next[1] - next[0] * current[1];
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
return area / 2;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* @param {[number, number]} a
|
|
1227
|
+
* @param {[number, number] | undefined} b
|
|
1228
|
+
* @returns {boolean}
|
|
1229
|
+
*/
|
|
1230
|
+
function sameCoordinate(a, b) {
|
|
1231
|
+
return Boolean(b) && a[0] === b[0] && a[1] === b[1];
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* @param {[number, number]} a
|
|
1236
|
+
* @param {[number, number]} b
|
|
1237
|
+
* @returns {number}
|
|
1238
|
+
*/
|
|
1239
|
+
function coordinateDistance(a, b) {
|
|
1240
|
+
const dx = a[0] - b[0];
|
|
1241
|
+
const dy = a[1] - b[1];
|
|
1242
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* @param {Geometry} geometry
|
|
1247
|
+
* @returns {[number, number] | null}
|
|
1248
|
+
*/
|
|
1249
|
+
function bboxCenter(geometry) {
|
|
1250
|
+
const bbox = geometryBbox(geometry);
|
|
1251
|
+
if (!bbox) {
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
return [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* @returns {string}
|
|
1260
|
+
*/
|
|
1261
|
+
function memorySummary() {
|
|
1262
|
+
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
|
|
1263
|
+
return `${heap.toFixed(0)} MB heap`;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Create an ID lookup that stays in memory for small extracts and spills to a
|
|
1268
|
+
* temporary SQLite database before hitting V8's Set size limit.
|
|
1269
|
+
*
|
|
1270
|
+
* @returns {{
|
|
1271
|
+
* add: (id: string) => void,
|
|
1272
|
+
* has: (id: string) => boolean,
|
|
1273
|
+
* flush: () => void,
|
|
1274
|
+
* close: () => void
|
|
1275
|
+
* }}
|
|
1276
|
+
*/
|
|
1277
|
+
function createNodeIdStore() {
|
|
1278
|
+
const memoryLimit = 250_000;
|
|
1279
|
+
const batchSize = 50_000;
|
|
1280
|
+
/** @type {Array<Set<string>> | null} */
|
|
1281
|
+
let memory = createSetBuckets(64);
|
|
1282
|
+
let memorySize = 0;
|
|
1283
|
+
/** @type {Database.Database | null} */
|
|
1284
|
+
let db = null;
|
|
1285
|
+
/** @type {Database.Statement<[string]> | null} */
|
|
1286
|
+
let insert = null;
|
|
1287
|
+
/** @type {Database.Statement<[string]> | null} */
|
|
1288
|
+
let select = null;
|
|
1289
|
+
/** @type {string[]} */
|
|
1290
|
+
let pending = [];
|
|
1291
|
+
|
|
1292
|
+
const flushPending = () => {
|
|
1293
|
+
if (!db || !insert || pending.length === 0) {
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const values = pending;
|
|
1298
|
+
pending = [];
|
|
1299
|
+
db.transaction((items) => {
|
|
1300
|
+
for (const id of items) {
|
|
1301
|
+
insert.run(id);
|
|
1302
|
+
}
|
|
1303
|
+
})(values);
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
const spill = () => {
|
|
1307
|
+
if (db || !memory) {
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
db = createTempDatabase();
|
|
1312
|
+
db.exec('CREATE TABLE node_ids (id TEXT PRIMARY KEY) WITHOUT ROWID');
|
|
1313
|
+
insert = db.prepare('INSERT OR IGNORE INTO node_ids (id) VALUES (?)');
|
|
1314
|
+
select = db.prepare('SELECT 1 FROM node_ids WHERE id = ?');
|
|
1315
|
+
pending = [];
|
|
1316
|
+
for (const bucket of memory) {
|
|
1317
|
+
pending.push(...bucket);
|
|
1318
|
+
}
|
|
1319
|
+
memory = null;
|
|
1320
|
+
flushPending();
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
return {
|
|
1324
|
+
add(id) {
|
|
1325
|
+
if (memory) {
|
|
1326
|
+
const bucket = memory[bucketIndex(id, memory.length)];
|
|
1327
|
+
if (!bucket.has(id)) {
|
|
1328
|
+
bucket.add(id);
|
|
1329
|
+
memorySize += 1;
|
|
1330
|
+
}
|
|
1331
|
+
if (memorySize >= memoryLimit) {
|
|
1332
|
+
spill();
|
|
1333
|
+
}
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
pending.push(id);
|
|
1338
|
+
if (pending.length >= batchSize) {
|
|
1339
|
+
flushPending();
|
|
1340
|
+
}
|
|
1341
|
+
},
|
|
1342
|
+
has(id) {
|
|
1343
|
+
if (memory) {
|
|
1344
|
+
return memory[bucketIndex(id, memory.length)].has(id);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
flushPending();
|
|
1348
|
+
return Boolean(select?.get(id));
|
|
1349
|
+
},
|
|
1350
|
+
flush() {
|
|
1351
|
+
flushPending();
|
|
1352
|
+
},
|
|
1353
|
+
close() {
|
|
1354
|
+
flushPending();
|
|
1355
|
+
closeTempDatabase(db);
|
|
1356
|
+
db = null;
|
|
1357
|
+
memory = null;
|
|
1358
|
+
memorySize = 0;
|
|
1359
|
+
pending = [];
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Create a coordinate lookup that spills to SQLite for large extracts.
|
|
1366
|
+
*
|
|
1367
|
+
* @returns {{
|
|
1368
|
+
* size: number,
|
|
1369
|
+
* set: (id: string, coordinate: [number, number]) => void,
|
|
1370
|
+
* get: (id: string) => [number, number] | undefined,
|
|
1371
|
+
* flush: () => void,
|
|
1372
|
+
* close: () => void
|
|
1373
|
+
* }}
|
|
1374
|
+
*/
|
|
1375
|
+
function createNodeCoordinateStore() {
|
|
1376
|
+
const memoryLimit = 250_000;
|
|
1377
|
+
const batchSize = 50_000;
|
|
1378
|
+
/** @type {Array<Map<string, [number, number]>> | null} */
|
|
1379
|
+
let memory = createMapBuckets(64);
|
|
1380
|
+
/** @type {Database.Database | null} */
|
|
1381
|
+
let db = null;
|
|
1382
|
+
/** @type {Database.Statement<[string, number, number]> | null} */
|
|
1383
|
+
let insert = null;
|
|
1384
|
+
/** @type {Database.Statement<[string]> | null} */
|
|
1385
|
+
let select = null;
|
|
1386
|
+
/** @type {Array<[string, number, number]>} */
|
|
1387
|
+
let pending = [];
|
|
1388
|
+
let size = 0;
|
|
1389
|
+
|
|
1390
|
+
const flushPending = () => {
|
|
1391
|
+
if (!db || !insert || pending.length === 0) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const values = pending;
|
|
1396
|
+
pending = [];
|
|
1397
|
+
db.transaction((items) => {
|
|
1398
|
+
for (const [id, lon, lat] of items) {
|
|
1399
|
+
insert.run(id, lon, lat);
|
|
1400
|
+
}
|
|
1401
|
+
})(values);
|
|
1402
|
+
};
|
|
1403
|
+
|
|
1404
|
+
const spill = () => {
|
|
1405
|
+
if (db || !memory) {
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
db = createTempDatabase();
|
|
1410
|
+
db.exec('CREATE TABLE nodes (id TEXT PRIMARY KEY, lon REAL NOT NULL, lat REAL NOT NULL) WITHOUT ROWID');
|
|
1411
|
+
insert = db.prepare('INSERT OR REPLACE INTO nodes (id, lon, lat) VALUES (?, ?, ?)');
|
|
1412
|
+
select = db.prepare('SELECT lon, lat FROM nodes WHERE id = ?');
|
|
1413
|
+
pending = [];
|
|
1414
|
+
for (const bucket of memory) {
|
|
1415
|
+
for (const [id, coordinate] of bucket) {
|
|
1416
|
+
pending.push([id, coordinate[0], coordinate[1]]);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
memory = null;
|
|
1420
|
+
flushPending();
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
return {
|
|
1424
|
+
get size() {
|
|
1425
|
+
return size;
|
|
1426
|
+
},
|
|
1427
|
+
set(id, coordinate) {
|
|
1428
|
+
if (memory) {
|
|
1429
|
+
const bucket = memory[bucketIndex(id, memory.length)];
|
|
1430
|
+
if (!bucket.has(id)) {
|
|
1431
|
+
size += 1;
|
|
1432
|
+
}
|
|
1433
|
+
bucket.set(id, coordinate);
|
|
1434
|
+
if (size >= memoryLimit) {
|
|
1435
|
+
spill();
|
|
1436
|
+
}
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
size += 1;
|
|
1441
|
+
pending.push([id, coordinate[0], coordinate[1]]);
|
|
1442
|
+
if (pending.length >= batchSize) {
|
|
1443
|
+
flushPending();
|
|
1444
|
+
}
|
|
1445
|
+
},
|
|
1446
|
+
get(id) {
|
|
1447
|
+
if (memory) {
|
|
1448
|
+
return memory[bucketIndex(id, memory.length)].get(id);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
flushPending();
|
|
1452
|
+
const row = /** @type {{ lon: number, lat: number } | undefined} */ (select?.get(id));
|
|
1453
|
+
return row ? [row.lon, row.lat] : undefined;
|
|
1454
|
+
},
|
|
1455
|
+
flush() {
|
|
1456
|
+
flushPending();
|
|
1457
|
+
},
|
|
1458
|
+
close() {
|
|
1459
|
+
flushPending();
|
|
1460
|
+
closeTempDatabase(db);
|
|
1461
|
+
db = null;
|
|
1462
|
+
memory = null;
|
|
1463
|
+
pending = [];
|
|
1464
|
+
size = 0;
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* @returns {Database.Database & { __mapZeroTempPath?: string }}
|
|
1471
|
+
*/
|
|
1472
|
+
function createTempDatabase() {
|
|
1473
|
+
const tempPath = join(tmpdir(), `map-zero-${process.pid}-${randomUUID()}.sqlite`);
|
|
1474
|
+
const db = /** @type {Database.Database & { __mapZeroTempPath?: string }} */ (new Database(tempPath));
|
|
1475
|
+
db.__mapZeroTempPath = tempPath;
|
|
1476
|
+
db.pragma('journal_mode = OFF');
|
|
1477
|
+
db.pragma('synchronous = OFF');
|
|
1478
|
+
db.pragma('locking_mode = EXCLUSIVE');
|
|
1479
|
+
db.pragma('cache_size = -64000');
|
|
1480
|
+
return db;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* @param {(Database.Database & { __mapZeroTempPath?: string }) | null} db
|
|
1485
|
+
*/
|
|
1486
|
+
function closeTempDatabase(db) {
|
|
1487
|
+
if (!db) {
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const tempPath = db.__mapZeroTempPath;
|
|
1492
|
+
db.close();
|
|
1493
|
+
if (tempPath) {
|
|
1494
|
+
rmSync(tempPath, { force: true });
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* @param {number} count
|
|
1500
|
+
* @returns {Array<Set<string>>}
|
|
1501
|
+
*/
|
|
1502
|
+
function createSetBuckets(count) {
|
|
1503
|
+
return Array.from({ length: count }, () => new Set());
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* @param {number} count
|
|
1508
|
+
* @returns {Array<Map<string, [number, number]>>}
|
|
1509
|
+
*/
|
|
1510
|
+
function createMapBuckets(count) {
|
|
1511
|
+
return Array.from({ length: count }, () => new Map());
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* @param {string} id
|
|
1516
|
+
* @param {number} count
|
|
1517
|
+
* @returns {number}
|
|
1518
|
+
*/
|
|
1519
|
+
function bucketIndex(id, count) {
|
|
1520
|
+
const numeric = Number(id);
|
|
1521
|
+
if (Number.isSafeInteger(numeric)) {
|
|
1522
|
+
return Math.abs(numeric) % count;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
let hash = 0;
|
|
1526
|
+
for (let index = 0; index < id.length; index += 1) {
|
|
1527
|
+
hash = (hash * 31 + id.charCodeAt(index)) >>> 0;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
return hash % count;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Read the optional bbox from the first OSM PBF header block.
|
|
1535
|
+
*
|
|
1536
|
+
* @param {string} source
|
|
1537
|
+
* @returns {Promise<[number, number, number, number] | null>}
|
|
1538
|
+
*/
|
|
1539
|
+
function readOsmHeaderBbox(source) {
|
|
1540
|
+
return new Promise((resolve, reject) => {
|
|
1541
|
+
const stream = createReadStream(source);
|
|
1542
|
+
const blobParser = new BlobParser();
|
|
1543
|
+
const decompressor = new BlobDecompressor();
|
|
1544
|
+
let done = false;
|
|
1545
|
+
|
|
1546
|
+
const finish = (bbox) => {
|
|
1547
|
+
if (done) {
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
done = true;
|
|
1552
|
+
stream.destroy();
|
|
1553
|
+
blobParser.destroy();
|
|
1554
|
+
decompressor.destroy();
|
|
1555
|
+
resolve(bbox);
|
|
1556
|
+
};
|
|
1557
|
+
|
|
1558
|
+
const fail = (error) => {
|
|
1559
|
+
if (done) {
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
done = true;
|
|
1564
|
+
reject(error);
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
stream
|
|
1568
|
+
.on('error', fail)
|
|
1569
|
+
.pipe(blobParser)
|
|
1570
|
+
.on('error', fail)
|
|
1571
|
+
.pipe(decompressor)
|
|
1572
|
+
.on('error', fail)
|
|
1573
|
+
.on('data', (chunk) => {
|
|
1574
|
+
if (chunk.type !== 'OSMHeader') {
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const header = osmPbfParsers.osm.HeaderBlock.decode(chunk.data);
|
|
1579
|
+
finish(normalizeHeaderBbox(header.bbox));
|
|
1580
|
+
})
|
|
1581
|
+
.on('end', () => {
|
|
1582
|
+
finish(null);
|
|
1583
|
+
});
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* @param {unknown} headerBbox
|
|
1589
|
+
* @returns {[number, number, number, number] | null}
|
|
1590
|
+
*/
|
|
1591
|
+
function normalizeHeaderBbox(headerBbox) {
|
|
1592
|
+
if (!headerBbox || typeof headerBbox !== 'object') {
|
|
1593
|
+
return null;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const bbox = /** @type {{ left?: unknown, bottom?: unknown, right?: unknown, top?: unknown }} */ (headerBbox);
|
|
1597
|
+
const left = osmCoordinateNumber(bbox.left);
|
|
1598
|
+
const bottom = osmCoordinateNumber(bbox.bottom);
|
|
1599
|
+
const right = osmCoordinateNumber(bbox.right);
|
|
1600
|
+
const top = osmCoordinateNumber(bbox.top);
|
|
1601
|
+
|
|
1602
|
+
if (
|
|
1603
|
+
!Number.isFinite(left) ||
|
|
1604
|
+
!Number.isFinite(bottom) ||
|
|
1605
|
+
!Number.isFinite(right) ||
|
|
1606
|
+
!Number.isFinite(top) ||
|
|
1607
|
+
left >= right ||
|
|
1608
|
+
bottom >= top
|
|
1609
|
+
) {
|
|
1610
|
+
return null;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
return [left, bottom, right, top];
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* @param {unknown} value
|
|
1618
|
+
* @returns {number}
|
|
1619
|
+
*/
|
|
1620
|
+
function osmCoordinateNumber(value) {
|
|
1621
|
+
const numeric = Number(value);
|
|
1622
|
+
if (Number.isFinite(numeric)) {
|
|
1623
|
+
return numeric * 1e-9;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
if (value && typeof value === 'object' && typeof value.toString === 'function') {
|
|
1627
|
+
const fromString = Number(value.toString());
|
|
1628
|
+
if (Number.isFinite(fromString)) {
|
|
1629
|
+
return fromString * 1e-9;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
return Number.NaN;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* @param {[number, number, number, number]} bbox
|
|
1638
|
+
* @returns {string}
|
|
1639
|
+
*/
|
|
1640
|
+
function formatBbox(bbox) {
|
|
1641
|
+
return bbox.map((value) => Number(value.toFixed(7))).join(',');
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* @param {Record<string, Feature[]>} features
|
|
1646
|
+
* @returns {number}
|
|
1647
|
+
*/
|
|
1648
|
+
function countFeatures(features) {
|
|
1649
|
+
return Object.values(features).reduce((total, layerFeatures) => total + layerFeatures.length, 0);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* @param {Record<string, unknown>} entity
|
|
1654
|
+
* @param {Record<string, string>} tags
|
|
1655
|
+
* @returns {WaySummary}
|
|
1656
|
+
*/
|
|
1657
|
+
function summarizeWay(entity, tags) {
|
|
1658
|
+
return {
|
|
1659
|
+
id: String(entity.id),
|
|
1660
|
+
type: 'way',
|
|
1661
|
+
tags,
|
|
1662
|
+
refs: getWayRefs(entity)
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* @param {Record<string, unknown>} entity
|
|
1668
|
+
* @param {Record<string, string>} tags
|
|
1669
|
+
* @param {string[]} layers
|
|
1670
|
+
* @returns {RelationSummary}
|
|
1671
|
+
*/
|
|
1672
|
+
function summarizeRelation(entity, tags, layers) {
|
|
1673
|
+
return {
|
|
1674
|
+
id: String(entity.id),
|
|
1675
|
+
type: 'relation',
|
|
1676
|
+
tags,
|
|
1677
|
+
layers,
|
|
1678
|
+
members: getRelationMembers(entity)
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* @param {string[]} layers
|
|
1684
|
+
* @returns {Record<string, Feature[]>}
|
|
1685
|
+
*/
|
|
1686
|
+
function createFeatureBuckets(layers) {
|
|
1687
|
+
/** @type {Record<string, Feature[]>} */
|
|
1688
|
+
const features = {};
|
|
1689
|
+
for (const layer of layers) {
|
|
1690
|
+
features[layer] = [];
|
|
1691
|
+
}
|
|
1692
|
+
return features;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* @param {Record<string, Feature[]>} features
|
|
1697
|
+
* @param {string} layer
|
|
1698
|
+
* @param {Geometry | null} geometry
|
|
1699
|
+
* @param {[number, number, number, number]} bbox
|
|
1700
|
+
* @param {{ id: string | number, type: string, tags: Record<string, string> }} entity
|
|
1701
|
+
*/
|
|
1702
|
+
function addFeatureIfInside(features, layer, geometry, bbox, entity) {
|
|
1703
|
+
if (!geometry) {
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
const normalizedGeometry = normalizeFeatureGeometryForLayer(layer, geometry);
|
|
1708
|
+
if (!normalizedGeometry) {
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
const featureBbox = geometryBbox(normalizedGeometry);
|
|
1713
|
+
if (!featureBbox || !bboxIntersects(featureBbox, bbox)) {
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
features[layer].push({
|
|
1718
|
+
geometry: normalizedGeometry,
|
|
1719
|
+
properties: propertiesForLayer(layer, entity),
|
|
1720
|
+
bbox: featureBbox
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* @param {string} layer
|
|
1726
|
+
* @param {WaySummary} way
|
|
1727
|
+
* @param {Map<string, [number, number]>} nodes
|
|
1728
|
+
* @returns {Geometry | null}
|
|
1729
|
+
*/
|
|
1730
|
+
function buildWayGeometry(layer, way, nodes) {
|
|
1731
|
+
if (layer === 'roads' || layer === 'railways') {
|
|
1732
|
+
return buildLineString(way.refs, nodes);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if (layer === 'boundaries') {
|
|
1736
|
+
return isClosedRefs(way.refs) ? buildWayMultiPolygon(way.refs, nodes) : buildLineString(way.refs, nodes);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
if (layer === 'aip' || layer === 'aviation') {
|
|
1740
|
+
return buildAviationWayGeometry(way, nodes);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
if (layer === 'pois') {
|
|
1744
|
+
return isClosedRefs(way.refs) ? buildWayMultiPolygon(way.refs, nodes) : buildLineString(way.refs, nodes);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
return buildWayMultiPolygon(way.refs, nodes);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* @param {WaySummary} way
|
|
1752
|
+
* @param {Map<string, [number, number]>} nodes
|
|
1753
|
+
* @returns {Geometry | null}
|
|
1754
|
+
*/
|
|
1755
|
+
function buildAviationWayGeometry(way, nodes) {
|
|
1756
|
+
const aeroway = way.tags.aeroway;
|
|
1757
|
+
const polygonPreferred = aeroway === 'apron' || aeroway === 'terminal' || aeroway === 'helipad';
|
|
1758
|
+
|
|
1759
|
+
if (isClosedRefs(way.refs)) {
|
|
1760
|
+
return buildWayMultiPolygon(way.refs, nodes);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
if (polygonPreferred) {
|
|
1764
|
+
return null;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
return buildLineString(way.refs, nodes);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* @param {string} layer
|
|
1772
|
+
* @param {RelationSummary} relation
|
|
1773
|
+
* @param {Map<string, WaySummary>} relationWays
|
|
1774
|
+
* @param {Map<string, WaySummary & { layers: string[] }>} candidateWays
|
|
1775
|
+
* @param {Map<string, [number, number]>} nodes
|
|
1776
|
+
* @returns {Geometry | null}
|
|
1777
|
+
*/
|
|
1778
|
+
function buildRelationGeometry(layer, relation, relationWays, candidateWays, nodes) {
|
|
1779
|
+
const multipolygon = buildRelationMultiPolygon(relation, relationWays, candidateWays, nodes);
|
|
1780
|
+
|
|
1781
|
+
if (multipolygon || (layer !== 'boundaries' && layer !== 'pois')) {
|
|
1782
|
+
return multipolygon;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
return buildRelationMultiLineString(relation, relationWays, candidateWays, nodes);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* @param {string[]} refs
|
|
1790
|
+
* @param {Map<string, [number, number]>} nodes
|
|
1791
|
+
* @returns {Geometry | null}
|
|
1792
|
+
*/
|
|
1793
|
+
function buildLineString(refs, nodes) {
|
|
1794
|
+
const coordinates = dedupeCoordinates(coordsForRefs(refs, nodes));
|
|
1795
|
+
|
|
1796
|
+
if (coordinates.length < 2) {
|
|
1797
|
+
return null;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
return {
|
|
1801
|
+
type: 'LineString',
|
|
1802
|
+
coordinates
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* @param {string[]} refs
|
|
1808
|
+
* @param {Map<string, [number, number]>} nodes
|
|
1809
|
+
* @returns {Geometry | null}
|
|
1810
|
+
*/
|
|
1811
|
+
function buildWayMultiPolygon(refs, nodes) {
|
|
1812
|
+
if (!isClosedRefs(refs)) {
|
|
1813
|
+
return null;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const ring = closeRing(coordsForRefs(refs, nodes));
|
|
1817
|
+
|
|
1818
|
+
if (ring.length < 4) {
|
|
1819
|
+
return null;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
return {
|
|
1823
|
+
type: 'MultiPolygon',
|
|
1824
|
+
coordinates: [[ring]]
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* @param {RelationSummary} relation
|
|
1830
|
+
* @param {Map<string, WaySummary>} relationWays
|
|
1831
|
+
* @param {Map<string, WaySummary & { layers: string[] }>} candidateWays
|
|
1832
|
+
* @param {Map<string, [number, number]>} nodes
|
|
1833
|
+
* @returns {Geometry | null}
|
|
1834
|
+
*/
|
|
1835
|
+
function buildRelationMultiPolygon(relation, relationWays, candidateWays, nodes) {
|
|
1836
|
+
const outerSequences = [];
|
|
1837
|
+
const innerSequences = [];
|
|
1838
|
+
|
|
1839
|
+
for (const member of relation.members) {
|
|
1840
|
+
if (member.type !== 'way') {
|
|
1841
|
+
continue;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
const way = relationWays.get(member.id) ?? candidateWays.get(member.id);
|
|
1845
|
+
if (!way || way.refs.length < 2) {
|
|
1846
|
+
continue;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
if (member.role === 'inner') {
|
|
1850
|
+
innerSequences.push(way.refs);
|
|
1851
|
+
} else {
|
|
1852
|
+
outerSequences.push(way.refs);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
const outerRings = assembleClosedRings(outerSequences)
|
|
1857
|
+
.map((refs) => closeRing(coordsForRefs(refs, nodes)))
|
|
1858
|
+
.filter((ring) => ring.length >= 4);
|
|
1859
|
+
|
|
1860
|
+
if (outerRings.length === 0) {
|
|
1861
|
+
return null;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
const innerRings = assembleClosedRings(innerSequences)
|
|
1865
|
+
.map((refs) => closeRing(coordsForRefs(refs, nodes)))
|
|
1866
|
+
.filter((ring) => ring.length >= 4);
|
|
1867
|
+
|
|
1868
|
+
const polygons = outerRings.map((outer) => [outer]);
|
|
1869
|
+
|
|
1870
|
+
for (const inner of innerRings) {
|
|
1871
|
+
const sample = inner[0];
|
|
1872
|
+
const polygon = polygons.find(([outer]) => pointInRing(sample, outer));
|
|
1873
|
+
if (polygon) {
|
|
1874
|
+
polygon.push(inner);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
return {
|
|
1879
|
+
type: 'MultiPolygon',
|
|
1880
|
+
coordinates: polygons
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/**
|
|
1885
|
+
* @param {RelationSummary} relation
|
|
1886
|
+
* @param {Map<string, WaySummary>} relationWays
|
|
1887
|
+
* @param {Map<string, WaySummary & { layers: string[] }>} candidateWays
|
|
1888
|
+
* @param {Map<string, [number, number]>} nodes
|
|
1889
|
+
* @returns {Geometry | null}
|
|
1890
|
+
*/
|
|
1891
|
+
function buildRelationMultiLineString(relation, relationWays, candidateWays, nodes) {
|
|
1892
|
+
const lines = [];
|
|
1893
|
+
|
|
1894
|
+
for (const member of relation.members) {
|
|
1895
|
+
if (member.type !== 'way') {
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
const way = relationWays.get(member.id) ?? candidateWays.get(member.id);
|
|
1900
|
+
if (!way) {
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
const coordinates = dedupeCoordinates(coordsForRefs(way.refs, nodes));
|
|
1905
|
+
if (coordinates.length >= 2) {
|
|
1906
|
+
lines.push(coordinates);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
if (lines.length === 0) {
|
|
1911
|
+
return null;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
if (lines.length === 1) {
|
|
1915
|
+
return {
|
|
1916
|
+
type: 'LineString',
|
|
1917
|
+
coordinates: lines[0]
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
return {
|
|
1922
|
+
type: 'MultiLineString',
|
|
1923
|
+
coordinates: lines
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* @param {Array<string[]>} sequences
|
|
1929
|
+
* @returns {Array<string[]>}
|
|
1930
|
+
*/
|
|
1931
|
+
function assembleClosedRings(sequences) {
|
|
1932
|
+
const remaining = sequences.map((sequence) => [...sequence]).filter((sequence) => sequence.length >= 2);
|
|
1933
|
+
const rings = [];
|
|
1934
|
+
|
|
1935
|
+
while (remaining.length > 0) {
|
|
1936
|
+
let current = /** @type {string[]} */ (remaining.shift());
|
|
1937
|
+
let changed = true;
|
|
1938
|
+
|
|
1939
|
+
while (!isClosedRefs(current) && changed) {
|
|
1940
|
+
changed = false;
|
|
1941
|
+
|
|
1942
|
+
for (let index = 0; index < remaining.length; index += 1) {
|
|
1943
|
+
const candidate = remaining[index];
|
|
1944
|
+
const first = current[0];
|
|
1945
|
+
const last = current.at(-1);
|
|
1946
|
+
const candidateFirst = candidate[0];
|
|
1947
|
+
const candidateLast = candidate.at(-1);
|
|
1948
|
+
|
|
1949
|
+
if (last === candidateFirst) {
|
|
1950
|
+
current = current.concat(candidate.slice(1));
|
|
1951
|
+
} else if (last === candidateLast) {
|
|
1952
|
+
current = current.concat([...candidate].reverse().slice(1));
|
|
1953
|
+
} else if (first === candidateLast) {
|
|
1954
|
+
current = candidate.concat(current.slice(1));
|
|
1955
|
+
} else if (first === candidateFirst) {
|
|
1956
|
+
current = [...candidate].reverse().concat(current.slice(1));
|
|
1957
|
+
} else {
|
|
1958
|
+
continue;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
remaining.splice(index, 1);
|
|
1962
|
+
changed = true;
|
|
1963
|
+
break;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
if (isClosedRefs(current) && current.length >= 4) {
|
|
1968
|
+
rings.push(current);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
return rings;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/**
|
|
1976
|
+
* @param {string[]} refs
|
|
1977
|
+
* @param {Map<string, [number, number]>} nodes
|
|
1978
|
+
* @returns {Array<[number, number]>}
|
|
1979
|
+
*/
|
|
1980
|
+
function coordsForRefs(refs, nodes) {
|
|
1981
|
+
/** @type {Array<[number, number]>} */
|
|
1982
|
+
const coordinates = [];
|
|
1983
|
+
|
|
1984
|
+
for (const ref of refs) {
|
|
1985
|
+
const coordinate = nodes.get(ref);
|
|
1986
|
+
if (coordinate) {
|
|
1987
|
+
coordinates.push(coordinate);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
return coordinates;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/**
|
|
1995
|
+
* @param {string[]} refs
|
|
1996
|
+
* @returns {boolean}
|
|
1997
|
+
*/
|
|
1998
|
+
function isClosedRefs(refs) {
|
|
1999
|
+
return refs.length >= 4 && refs[0] === refs.at(-1);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
/**
|
|
2003
|
+
* @param {Record<string, unknown>} entity
|
|
2004
|
+
* @returns {Record<string, string>}
|
|
2005
|
+
*/
|
|
2006
|
+
function getTags(entity) {
|
|
2007
|
+
const tags = entity.tags;
|
|
2008
|
+
|
|
2009
|
+
if (!tags) {
|
|
2010
|
+
return {};
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
if (Array.isArray(tags)) {
|
|
2014
|
+
/** @type {Record<string, string>} */
|
|
2015
|
+
const normalized = {};
|
|
2016
|
+
for (const tag of tags) {
|
|
2017
|
+
if (Array.isArray(tag) && tag.length >= 2) {
|
|
2018
|
+
normalized[String(tag[0])] = String(tag[1]);
|
|
2019
|
+
} else if (tag && typeof tag === 'object' && 'k' in tag && 'v' in tag) {
|
|
2020
|
+
normalized[String(tag.k)] = String(tag.v);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
return normalized;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
if (typeof tags === 'object') {
|
|
2027
|
+
/** @type {Record<string, string>} */
|
|
2028
|
+
const normalized = {};
|
|
2029
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
2030
|
+
normalized[key] = String(value);
|
|
2031
|
+
}
|
|
2032
|
+
return normalized;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
return {};
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
/**
|
|
2039
|
+
* @param {Record<string, unknown>} entity
|
|
2040
|
+
* @returns {string[]}
|
|
2041
|
+
*/
|
|
2042
|
+
function getWayRefs(entity) {
|
|
2043
|
+
const refs = entity.refs ?? entity.nodes ?? entity.nodeRefs;
|
|
2044
|
+
|
|
2045
|
+
if (!Array.isArray(refs)) {
|
|
2046
|
+
return [];
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
return refs.map((ref) => String(ref));
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/**
|
|
2053
|
+
* @param {Record<string, unknown>} entity
|
|
2054
|
+
* @returns {RelationMember[]}
|
|
2055
|
+
*/
|
|
2056
|
+
function getRelationMembers(entity) {
|
|
2057
|
+
const members = entity.members;
|
|
2058
|
+
|
|
2059
|
+
if (!Array.isArray(members)) {
|
|
2060
|
+
return [];
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
return members.map((member) => {
|
|
2064
|
+
const record = /** @type {Record<string, unknown>} */ (member);
|
|
2065
|
+
return {
|
|
2066
|
+
id: String(record.id ?? record.ref),
|
|
2067
|
+
type: normalizeMemberType(record.type),
|
|
2068
|
+
role: String(record.role ?? '')
|
|
2069
|
+
};
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* @param {unknown} value
|
|
2075
|
+
* @returns {string}
|
|
2076
|
+
*/
|
|
2077
|
+
function normalizeMemberType(value) {
|
|
2078
|
+
if (typeof value === 'string') {
|
|
2079
|
+
return value;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
if (value === 0) {
|
|
2083
|
+
return 'node';
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
if (value === 1) {
|
|
2087
|
+
return 'way';
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
if (value === 2) {
|
|
2091
|
+
return 'relation';
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
return String(value);
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
/**
|
|
2098
|
+
* @param {Record<string, unknown>} entity
|
|
2099
|
+
* @returns {[number, number] | null}
|
|
2100
|
+
*/
|
|
2101
|
+
function getNodeCoordinate(entity) {
|
|
2102
|
+
const lon = Number(entity.lon ?? entity.longitude ?? entity.x);
|
|
2103
|
+
const lat = Number(entity.lat ?? entity.latitude ?? entity.y);
|
|
2104
|
+
|
|
2105
|
+
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
|
|
2106
|
+
return null;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
return [lon, lat];
|
|
2110
|
+
}
|