map-zero 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +220 -0
  4. package/docs/api.md +66 -0
  5. package/docs/architecture.md +87 -0
  6. package/docs/cartography.md +77 -0
  7. package/docs/cesium.md +107 -0
  8. package/docs/openlayers.md +98 -0
  9. package/docs/styles.md +103 -0
  10. package/package.json +51 -0
  11. package/packages/cesium/package.json +13 -0
  12. package/packages/cesium/src/index.js +405 -0
  13. package/packages/ol/package.json +14 -0
  14. package/packages/ol/src/index.js +1705 -0
  15. package/packages/ol/src/labels.js +977 -0
  16. package/src/3dtiles/b3dm.js +38 -0
  17. package/src/3dtiles/clipper-surfaces.js +317 -0
  18. package/src/3dtiles/export.js +768 -0
  19. package/src/3dtiles/extrude.js +301 -0
  20. package/src/3dtiles/flat.js +531 -0
  21. package/src/3dtiles/glb.js +178 -0
  22. package/src/3dtiles/gpkg-buildings.js +240 -0
  23. package/src/3dtiles/gpkg-features.js +157 -0
  24. package/src/3dtiles/tileset.js +75 -0
  25. package/src/build.js +134 -0
  26. package/src/cli.js +656 -0
  27. package/src/export-pmtiles.js +962 -0
  28. package/src/geometry-read.js +50 -0
  29. package/src/gpkg-read.js +460 -0
  30. package/src/gpkg.js +567 -0
  31. package/src/html.js +593 -0
  32. package/src/layers.js +357 -0
  33. package/src/manifest.js +29 -0
  34. package/src/mvt.js +2593 -0
  35. package/src/ol.js +5 -0
  36. package/src/osm.js +2110 -0
  37. package/src/pmtiles-worker.js +70 -0
  38. package/src/pmtiles.js +260 -0
  39. package/src/server.js +720 -0
  40. package/src/style-command.js +78 -0
  41. package/src/style-filters.js +76 -0
  42. package/src/style-presets.js +93 -0
  43. package/src/style-themes.js +235 -0
  44. package/src/style.js +13 -0
  45. package/src/tile-cache.js +59 -0
  46. package/src/utils.js +222 -0
  47. package/styles/presets/light.json +4655 -0
  48. package/styles/presets/monochrome.json +4655 -0
  49. package/styles/presets/neon-dark-3d.json +90 -0
  50. package/styles/presets/neon-dark.json +4690 -0
  51. package/styles/presets/tactical.json +4690 -0
  52. package/styles/themes/neon-dark.theme.json +20 -0
package/src/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
+ }