@osmix/core 0.2.0 → 0.3.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 +31 -0
- package/README.md +54 -7
- package/dist/contracts.d.ts +2 -1
- package/dist/contracts.d.ts.map +1 -1
- package/dist/entities.d.ts +21 -1
- package/dist/entities.d.ts.map +1 -1
- package/dist/entities.js +52 -3
- package/dist/entities.js.map +1 -1
- package/dist/ids.d.ts +18 -5
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +68 -18
- package/dist/ids.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/indirect-kd-index.d.ts +28 -0
- package/dist/indirect-kd-index.d.ts.map +1 -0
- package/dist/indirect-kd-index.js +163 -0
- package/dist/indirect-kd-index.js.map +1 -0
- package/dist/limits.d.ts +30 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +51 -0
- package/dist/limits.js.map +1 -0
- package/dist/nodes.d.ts +28 -13
- package/dist/nodes.d.ts.map +1 -1
- package/dist/nodes.js +181 -51
- package/dist/nodes.js.map +1 -1
- package/dist/osm.d.ts +59 -3
- package/dist/osm.d.ts.map +1 -1
- package/dist/osm.js +31 -2
- package/dist/osm.js.map +1 -1
- package/dist/relations.d.ts +10 -4
- package/dist/relations.d.ts.map +1 -1
- package/dist/relations.js +66 -28
- package/dist/relations.js.map +1 -1
- package/dist/renumber.d.ts +23 -0
- package/dist/renumber.d.ts.map +1 -0
- package/dist/renumber.js +64 -0
- package/dist/renumber.js.map +1 -0
- package/dist/stringtable.d.ts +2 -1
- package/dist/stringtable.d.ts.map +1 -1
- package/dist/stringtable.js +5 -1
- package/dist/stringtable.js.map +1 -1
- package/dist/tags.d.ts +44 -11
- package/dist/tags.d.ts.map +1 -1
- package/dist/tags.js +153 -27
- package/dist/tags.js.map +1 -1
- package/dist/typed-arrays.d.ts +63 -48
- package/dist/typed-arrays.d.ts.map +1 -1
- package/dist/typed-arrays.js +184 -107
- package/dist/typed-arrays.js.map +1 -1
- package/dist/ways.d.ts +50 -2
- package/dist/ways.d.ts.map +1 -1
- package/dist/ways.js +212 -35
- package/dist/ways.js.map +1 -1
- package/package.json +11 -12
- package/src/contracts.ts +2 -1
- package/src/entities.ts +67 -4
- package/src/ids.ts +72 -22
- package/src/index.ts +13 -7
- package/src/indirect-kd-index.ts +212 -0
- package/src/limits.ts +73 -0
- package/src/nodes.ts +228 -54
- package/src/osm.ts +90 -4
- package/src/relations.ts +80 -30
- package/src/renumber.ts +80 -0
- package/src/stringtable.ts +15 -1
- package/src/tags.ts +173 -31
- package/src/typed-arrays.ts +225 -113
- package/src/ways.ts +233 -38
- package/test/entity-index-errors.test.ts +41 -0
- package/test/ids.bench.ts +11 -7
- package/test/ids.test.ts +38 -0
- package/test/limits.test.ts +83 -0
- package/test/no-shared-array-buffer.test.ts +41 -0
- package/test/nodes-spatial-index.test.ts +261 -0
- package/test/osm-bbox.test.ts +30 -0
- package/test/relations.bench.ts +9 -4
- package/test/relations.test.ts +52 -29
- package/test/renumber.test.ts +54 -0
- package/test/spatial-index-rebuild.test.ts +33 -0
- package/test/storage-compaction.test.ts +269 -0
- package/test/tags-search.test.ts +13 -0
- package/test/typed-arrays.test.ts +178 -59
- package/src/types/geokdbush.d.ts +0 -32
- package/src/types/kdbush.d.ts +0 -47
package/src/nodes.ts
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
|
-
import { microToDegrees, toMicroDegrees } from "@osmix/geo/coordinates";
|
|
1
|
+
import { microToDegrees, OSM_COORD_SCALE, toMicroDegrees } from "@osmix/geo/coordinates";
|
|
2
2
|
import type { OsmPbfBlock, OsmPbfDenseNodes } from "@osmix/pbf";
|
|
3
3
|
import { assertValue } from "@osmix/shared/assert";
|
|
4
4
|
import type { ContentHasher } from "@osmix/shared/content-hasher";
|
|
5
5
|
import type { GeoBbox2D, OsmNode, OsmTags } from "@osmix/types";
|
|
6
|
-
import { around as geoAround } from "geokdbush";
|
|
7
|
-
import KDBush from "kdbush";
|
|
8
6
|
|
|
9
7
|
import { Entities, type EntitiesTransferables } from "./entities.ts";
|
|
10
8
|
import { type IdOrIndex, Ids } from "./ids.ts";
|
|
9
|
+
import { IndirectKdIndex } from "./indirect-kd-index.ts";
|
|
11
10
|
import type StringTable from "./stringtable.ts";
|
|
12
11
|
import { Tags } from "./tags.ts";
|
|
13
|
-
import {
|
|
12
|
+
import { type BufferType, ResizeableTypedArray as RTA } from "./typed-arrays.ts";
|
|
13
|
+
|
|
14
|
+
const EARTH_RADIUS_KM = 6371.0088;
|
|
15
|
+
const COORDINATE_PADDING_DEGREES = 1 / OSM_COORD_SCALE;
|
|
16
|
+
|
|
17
|
+
export type NodeSpatialIndexKind = "all" | "tagged";
|
|
18
|
+
|
|
19
|
+
export class SpatialIndexNotBuiltError extends Error {
|
|
20
|
+
readonly code = "SPATIAL_INDEX_NOT_BUILT";
|
|
21
|
+
readonly entityType = "node";
|
|
22
|
+
readonly indexKind: NodeSpatialIndexKind;
|
|
23
|
+
|
|
24
|
+
constructor(indexKind: NodeSpatialIndexKind) {
|
|
25
|
+
super(`The ${indexKind} node spatial index has not been built.`);
|
|
26
|
+
this.name = "SpatialIndexNotBuiltError";
|
|
27
|
+
this.indexKind = indexKind;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
14
30
|
|
|
15
31
|
export interface NodesTransferables<
|
|
16
32
|
T extends BufferType = BufferType,
|
|
@@ -18,8 +34,10 @@ export interface NodesTransferables<
|
|
|
18
34
|
lons: T;
|
|
19
35
|
lats: T;
|
|
20
36
|
bbox: GeoBbox2D;
|
|
21
|
-
/** Optional - can be rebuilt via buildSpatialIndex() */
|
|
22
|
-
|
|
37
|
+
/** Optional all-node Uint32 permutation; can be rebuilt via buildSpatialIndex("all"). */
|
|
38
|
+
allSpatialIndex?: T;
|
|
39
|
+
/** Optional tagged-node Uint32 permutation; can be rebuilt via buildSpatialIndex("tagged"). */
|
|
40
|
+
taggedSpatialIndex?: T;
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
export interface AddNodeOptions {
|
|
@@ -33,17 +51,15 @@ export class Nodes extends Entities<OsmNode> {
|
|
|
33
51
|
*/
|
|
34
52
|
private lons: RTA<Int32Array>;
|
|
35
53
|
private lats: RTA<Int32Array>;
|
|
36
|
-
//
|
|
54
|
+
// Bounding box is exposed and transferred in degrees.
|
|
37
55
|
private bbox: [minLon: number, minLat: number, maxLon: number, maxLat: number] = [
|
|
38
56
|
Number.MAX_SAFE_INTEGER,
|
|
39
57
|
Number.MAX_SAFE_INTEGER,
|
|
40
58
|
Number.MIN_SAFE_INTEGER,
|
|
41
59
|
Number.MIN_SAFE_INTEGER,
|
|
42
60
|
];
|
|
43
|
-
|
|
44
|
-
private
|
|
45
|
-
// Track if spatial index was properly built (vs default empty)
|
|
46
|
-
private spatialIndexBuilt = false;
|
|
61
|
+
private allSpatialIndex?: IndirectKdIndex;
|
|
62
|
+
private taggedSpatialIndex?: IndirectKdIndex;
|
|
47
63
|
|
|
48
64
|
/**
|
|
49
65
|
* Create a new Nodes index.
|
|
@@ -53,10 +69,19 @@ export class Nodes extends Entities<OsmNode> {
|
|
|
53
69
|
super("node", new Ids(transferables), new Tags(stringTable, transferables));
|
|
54
70
|
this.lons = RTA.from(Int32Array, transferables.lons);
|
|
55
71
|
this.lats = RTA.from(Int32Array, transferables.lats);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
if (transferables.allSpatialIndex !== undefined) {
|
|
73
|
+
this.allSpatialIndex = IndirectKdIndex.from(
|
|
74
|
+
this.lons.array,
|
|
75
|
+
this.lats.array,
|
|
76
|
+
transferables.allSpatialIndex,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (transferables.taggedSpatialIndex !== undefined) {
|
|
80
|
+
this.taggedSpatialIndex = IndirectKdIndex.from(
|
|
81
|
+
this.lons.array,
|
|
82
|
+
this.lats.array,
|
|
83
|
+
transferables.taggedSpatialIndex,
|
|
84
|
+
);
|
|
60
85
|
}
|
|
61
86
|
this.bbox = transferables.bbox;
|
|
62
87
|
this.indexBuilt = true;
|
|
@@ -64,8 +89,6 @@ export class Nodes extends Entities<OsmNode> {
|
|
|
64
89
|
super("node", new Ids(), new Tags(stringTable));
|
|
65
90
|
this.lons = new RTA(Int32Array);
|
|
66
91
|
this.lats = new RTA(Int32Array);
|
|
67
|
-
// Spatial index uses Float64Array with degrees for geokdbush compatibility
|
|
68
|
-
this.spatialIndex = new KDBush(0, 128, Float64Array, BufferConstructor);
|
|
69
92
|
}
|
|
70
93
|
}
|
|
71
94
|
|
|
@@ -191,35 +214,62 @@ export class Nodes extends Entities<OsmNode> {
|
|
|
191
214
|
}
|
|
192
215
|
|
|
193
216
|
/**
|
|
194
|
-
* Build the
|
|
195
|
-
* Spatial index stores degrees (Float64Array) for geokdbush compatibility.
|
|
217
|
+
* Build one of the independent node spatial indexes.
|
|
196
218
|
*/
|
|
197
|
-
buildSpatialIndex() {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
219
|
+
buildSpatialIndex(kind: NodeSpatialIndexKind = "all") {
|
|
220
|
+
if (this.hasSpatialIndex(kind)) return;
|
|
221
|
+
|
|
222
|
+
console.time(`NodeIndex.buildSpatialIndex.${kind}`);
|
|
223
|
+
if (kind === "all") {
|
|
224
|
+
this.allSpatialIndex = IndirectKdIndex.build(
|
|
225
|
+
this.lons.array,
|
|
226
|
+
this.lats.array,
|
|
227
|
+
this.size,
|
|
228
|
+
(indexes) => {
|
|
229
|
+
for (let i = 0; i < indexes.length; i++) indexes[i] = i;
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
} else {
|
|
233
|
+
this.taggedSpatialIndex = IndirectKdIndex.build(
|
|
234
|
+
this.lons.array,
|
|
235
|
+
this.lats.array,
|
|
236
|
+
this.taggedSize,
|
|
237
|
+
(indexes) => {
|
|
238
|
+
let position = 0;
|
|
239
|
+
for (const entityIndex of this.tags.taggedEntityIndexes()) {
|
|
240
|
+
indexes[position++] = entityIndex;
|
|
241
|
+
}
|
|
242
|
+
if (position !== indexes.length) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Expected ${indexes.length} tagged nodes while building index, received ${position}`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
);
|
|
206
249
|
}
|
|
207
|
-
|
|
208
|
-
this.spatialIndexBuilt = true;
|
|
209
|
-
console.timeEnd("NodeIndex.buildSpatialIndex");
|
|
250
|
+
console.timeEnd(`NodeIndex.buildSpatialIndex.${kind}`);
|
|
210
251
|
}
|
|
211
252
|
|
|
212
253
|
/**
|
|
213
254
|
* Check if the spatial index has been built.
|
|
214
255
|
*/
|
|
215
|
-
hasSpatialIndex(): boolean {
|
|
216
|
-
return
|
|
256
|
+
hasSpatialIndex(kind: NodeSpatialIndexKind = "all"): boolean {
|
|
257
|
+
return kind === "all"
|
|
258
|
+
? this.allSpatialIndex !== undefined
|
|
259
|
+
: this.taggedSpatialIndex !== undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Number of nodes that carry at least one tag. */
|
|
263
|
+
get taggedSize(): number {
|
|
264
|
+
return this.tags.taggedEntityCount;
|
|
217
265
|
}
|
|
218
266
|
|
|
219
267
|
/**
|
|
220
|
-
* Get the bounding box of all nodes.
|
|
268
|
+
* Get the bounding box of all nodes, or `null` when there are no nodes.
|
|
221
269
|
*/
|
|
222
|
-
getBbox(): GeoBbox2D {
|
|
270
|
+
getBbox(): GeoBbox2D | null {
|
|
271
|
+
// The bbox starts inverted and only becomes valid once a node extends it.
|
|
272
|
+
if (this.bbox[0] > this.bbox[2] || this.bbox[1] > this.bbox[3]) return null;
|
|
223
273
|
return this.bbox;
|
|
224
274
|
}
|
|
225
275
|
|
|
@@ -270,21 +320,42 @@ export class Nodes extends Entities<OsmNode> {
|
|
|
270
320
|
* Find node indexes within a bounding box.
|
|
271
321
|
*/
|
|
272
322
|
findIndexesWithinBbox(bbox: GeoBbox2D): number[] {
|
|
273
|
-
|
|
274
|
-
|
|
323
|
+
return findIndexesWithinBbox(this.getSpatialIndex("all"), bbox);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Find tagged node indexes within a bounding box. */
|
|
327
|
+
findTaggedIndexesWithinBbox(bbox: GeoBbox2D): number[] {
|
|
328
|
+
return findIndexesWithinBbox(this.getSpatialIndex("tagged"), bbox);
|
|
275
329
|
}
|
|
276
330
|
|
|
277
331
|
/**
|
|
278
332
|
* Find node indexes within a radius of a point.
|
|
279
|
-
* Uses
|
|
333
|
+
* Uses an exact haversine-distance filter over a conservative KD bbox query.
|
|
280
334
|
* @param lon - Longitude in degrees.
|
|
281
335
|
* @param lat - Latitude in degrees.
|
|
282
336
|
* @param radiusKm - Radius in kilometers.
|
|
283
337
|
* @returns Array of node indexes within the radius.
|
|
284
338
|
*/
|
|
285
339
|
findIndexesWithinRadius(lon: number, lat: number, radiusKm: number): number[] {
|
|
286
|
-
|
|
287
|
-
|
|
340
|
+
const spatialIndex = this.getSpatialIndex("all");
|
|
341
|
+
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
|
|
342
|
+
throw new RangeError("Longitude and latitude must be finite numbers");
|
|
343
|
+
}
|
|
344
|
+
if (lat < -90 || lat > 90) throw new RangeError("Latitude must be between -90 and 90");
|
|
345
|
+
if (Number.isNaN(radiusKm) || radiusKm < 0) return [];
|
|
346
|
+
|
|
347
|
+
const normalizedLon = normalizeLongitude(lon);
|
|
348
|
+
const bbox = radiusBoundingBox(normalizedLon, lat, radiusKm);
|
|
349
|
+
const candidates = findIndexesWithinBbox(spatialIndex, bbox);
|
|
350
|
+
const matches: { distance: number; index: number }[] = [];
|
|
351
|
+
for (const index of candidates) {
|
|
352
|
+
const nodeLon = microToDegrees(this.lons.at(index));
|
|
353
|
+
const nodeLat = microToDegrees(this.lats.at(index));
|
|
354
|
+
const distance = haversineDistanceKm(normalizedLon, lat, nodeLon, nodeLat);
|
|
355
|
+
if (distance <= radiusKm) matches.push({ distance, index });
|
|
356
|
+
}
|
|
357
|
+
matches.sort((a, b) => a.distance - b.distance || a.index - b.index);
|
|
358
|
+
return matches.map(({ index }) => index);
|
|
288
359
|
}
|
|
289
360
|
|
|
290
361
|
/**
|
|
@@ -326,7 +397,7 @@ export class Nodes extends Entities<OsmNode> {
|
|
|
326
397
|
|
|
327
398
|
/**
|
|
328
399
|
* Get transferable objects for passing to another thread.
|
|
329
|
-
* Only includes
|
|
400
|
+
* Only includes spatial indexes that have been built.
|
|
330
401
|
*/
|
|
331
402
|
override transferables(): NodesTransferables {
|
|
332
403
|
const base = {
|
|
@@ -335,37 +406,140 @@ export class Nodes extends Entities<OsmNode> {
|
|
|
335
406
|
lats: this.lats.array.buffer,
|
|
336
407
|
bbox: this.bbox,
|
|
337
408
|
};
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
409
|
+
return {
|
|
410
|
+
...base,
|
|
411
|
+
...(this.allSpatialIndex ? { allSpatialIndex: this.allSpatialIndex.buffer } : {}),
|
|
412
|
+
...(this.taggedSpatialIndex ? { taggedSpatialIndex: this.taggedSpatialIndex.buffer } : {}),
|
|
413
|
+
};
|
|
343
414
|
}
|
|
344
415
|
|
|
345
416
|
/**
|
|
346
417
|
* Get the approximate memory requirements for a given number of nodes in bytes.
|
|
347
418
|
*/
|
|
348
|
-
static getBytesRequired(count: number) {
|
|
419
|
+
static getBytesRequired(count: number, taggedCount = count, tagCount = 0) {
|
|
349
420
|
if (count === 0) return 0;
|
|
350
|
-
const indexBytes = (count < 65536 ? 2 : 4) * count;
|
|
351
|
-
// Spatial index stores coordinates in degrees (Float64Array)
|
|
352
|
-
const coordsBytes = count * 2 * Float64Array.BYTES_PER_ELEMENT;
|
|
353
|
-
const padding = (8 - (indexBytes % 8)) % 8;
|
|
354
|
-
const spatialIndexBytes = 8 + indexBytes + coordsBytes + padding;
|
|
355
421
|
|
|
356
422
|
return (
|
|
357
423
|
Ids.getBytesRequired(count) +
|
|
358
|
-
Tags.getBytesRequired(count) +
|
|
424
|
+
Tags.getBytesRequired(count, taggedCount, tagCount) +
|
|
359
425
|
count * Int32Array.BYTES_PER_ELEMENT + // lons (stored in microdegrees)
|
|
360
426
|
count * Int32Array.BYTES_PER_ELEMENT + // lats (stored in microdegrees)
|
|
361
|
-
|
|
427
|
+
Nodes.getSpatialIndexBytesRequired(count) +
|
|
428
|
+
Nodes.getSpatialIndexBytesRequired(taggedCount)
|
|
362
429
|
);
|
|
363
430
|
}
|
|
364
431
|
|
|
432
|
+
/** Exact bytes in an indirect node spatial-index permutation. */
|
|
433
|
+
static getSpatialIndexBytesRequired(count: number): number {
|
|
434
|
+
return count * Uint32Array.BYTES_PER_ELEMENT;
|
|
435
|
+
}
|
|
436
|
+
|
|
365
437
|
/**
|
|
366
438
|
* Update a ContentHasher with node-specific data (coordinates).
|
|
367
439
|
*/
|
|
368
440
|
override updateHash(hasher: ContentHasher): ContentHasher {
|
|
369
441
|
return super.updateHash(hasher).update(this.lons.array).update(this.lats.array);
|
|
370
442
|
}
|
|
443
|
+
|
|
444
|
+
private getSpatialIndex(kind: NodeSpatialIndexKind): IndirectKdIndex {
|
|
445
|
+
const index = kind === "all" ? this.allSpatialIndex : this.taggedSpatialIndex;
|
|
446
|
+
if (!index) throw new SpatialIndexNotBuiltError(kind);
|
|
447
|
+
return index;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Round-trip float error tolerance for degree → microdegree conversion.
|
|
453
|
+
* `microToDegrees(x) * OSM_COORD_SCALE` can differ from `x` by a few ulps
|
|
454
|
+
* (~1e-6 at ±1.8e9), so ceil/floor without a tolerance could exclude a node
|
|
455
|
+
* whose exact stored coordinate sits on the query boundary.
|
|
456
|
+
*/
|
|
457
|
+
const MICRO_EPSILON = 1e-5;
|
|
458
|
+
|
|
459
|
+
/** Smallest microdegree integer inside an inclusive degree lower bound. */
|
|
460
|
+
function microLowerBound(degrees: number): number {
|
|
461
|
+
return Math.ceil(degrees * OSM_COORD_SCALE - MICRO_EPSILON);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Largest microdegree integer inside an inclusive degree upper bound. */
|
|
465
|
+
function microUpperBound(degrees: number): number {
|
|
466
|
+
return Math.floor(degrees * OSM_COORD_SCALE + MICRO_EPSILON);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function findIndexesWithinBbox(index: IndirectKdIndex, bbox: GeoBbox2D): number[] {
|
|
470
|
+
const [minLon, minLat, maxLon, maxLat] = bbox;
|
|
471
|
+
if (minLat > maxLat) return [];
|
|
472
|
+
|
|
473
|
+
const microMinLat = microLowerBound(minLat);
|
|
474
|
+
const microMaxLat = microUpperBound(maxLat);
|
|
475
|
+
if (microMinLat > microMaxLat) return [];
|
|
476
|
+
|
|
477
|
+
let result: number[];
|
|
478
|
+
if (minLon <= maxLon) {
|
|
479
|
+
result = index.range(
|
|
480
|
+
microLowerBound(minLon),
|
|
481
|
+
microMinLat,
|
|
482
|
+
microUpperBound(maxLon),
|
|
483
|
+
microMaxLat,
|
|
484
|
+
);
|
|
485
|
+
} else {
|
|
486
|
+
result = index
|
|
487
|
+
.range(microLowerBound(minLon), microMinLat, 180 * OSM_COORD_SCALE, microMaxLat)
|
|
488
|
+
.concat(
|
|
489
|
+
index.range(-180 * OSM_COORD_SCALE, microMinLat, microUpperBound(maxLon), microMaxLat),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return result;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function radiusBoundingBox(lon: number, lat: number, radiusKm: number): GeoBbox2D {
|
|
496
|
+
if (!Number.isFinite(radiusKm)) return [-180, -90, 180, 90];
|
|
497
|
+
const angularRadius = radiusKm / EARTH_RADIUS_KM;
|
|
498
|
+
if (angularRadius >= Math.PI) return [-180, -90, 180, 90];
|
|
499
|
+
|
|
500
|
+
const latRadians = degreesToRadians(lat);
|
|
501
|
+
const minLatRadians = Math.max(-Math.PI / 2, latRadians - angularRadius);
|
|
502
|
+
const maxLatRadians = Math.min(Math.PI / 2, latRadians + angularRadius);
|
|
503
|
+
const minLat = Math.max(-90, radiansToDegrees(minLatRadians) - COORDINATE_PADDING_DEGREES);
|
|
504
|
+
const maxLat = Math.min(90, radiansToDegrees(maxLatRadians) + COORDINATE_PADDING_DEGREES);
|
|
505
|
+
|
|
506
|
+
if (
|
|
507
|
+
angularRadius >= Math.PI / 2 ||
|
|
508
|
+
minLatRadians <= -Math.PI / 2 ||
|
|
509
|
+
maxLatRadians >= Math.PI / 2
|
|
510
|
+
) {
|
|
511
|
+
return [-180, minLat, 180, maxLat];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const ratio = Math.sin(angularRadius) / Math.cos(latRadians);
|
|
515
|
+
const deltaLon =
|
|
516
|
+
radiansToDegrees(Math.asin(Math.min(1, Math.max(-1, ratio)))) + COORDINATE_PADDING_DEGREES;
|
|
517
|
+
const rawMinLon = lon - deltaLon;
|
|
518
|
+
const rawMaxLon = lon + deltaLon;
|
|
519
|
+
return [normalizeLongitude(rawMinLon), minLat, normalizeLongitude(rawMaxLon), maxLat];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function haversineDistanceKm(lon1: number, lat1: number, lon2: number, lat2: number): number {
|
|
523
|
+
const latDelta = degreesToRadians(lat2 - lat1);
|
|
524
|
+
const lonDelta = degreesToRadians(lon2 - lon1);
|
|
525
|
+
const lat1Radians = degreesToRadians(lat1);
|
|
526
|
+
const lat2Radians = degreesToRadians(lat2);
|
|
527
|
+
const a =
|
|
528
|
+
Math.sin(latDelta / 2) ** 2 +
|
|
529
|
+
Math.sin(lonDelta / 2) ** 2 * Math.cos(lat1Radians) * Math.cos(lat2Radians);
|
|
530
|
+
return 2 * EARTH_RADIUS_KM * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0, 1 - a)));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function normalizeLongitude(lon: number): number {
|
|
534
|
+
if (lon >= -180 && lon <= 180) return lon;
|
|
535
|
+
const normalized = ((((lon + 180) % 360) + 360) % 360) - 180;
|
|
536
|
+
return normalized === -180 && lon > 0 ? 180 : normalized;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function degreesToRadians(value: number): number {
|
|
540
|
+
return value * (Math.PI / 180);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function radiansToDegrees(value: number): number {
|
|
544
|
+
return value * (180 / Math.PI);
|
|
371
545
|
}
|
package/src/osm.ts
CHANGED
|
@@ -8,25 +8,79 @@ import StringTable, { type StringTableTransferables } from "./stringtable.ts";
|
|
|
8
8
|
import type { BufferType } from "./typed-arrays.ts";
|
|
9
9
|
import { Ways, type WaysTransferables } from "./ways.ts";
|
|
10
10
|
|
|
11
|
+
export const OSM_TRANSFER_VERSION = 2 as const;
|
|
12
|
+
export const OSM_CONTENT_HASH_VERSION = 2 as const;
|
|
13
|
+
|
|
11
14
|
export interface OsmTransferables<T extends BufferType = BufferType> {
|
|
15
|
+
transferVersion: typeof OSM_TRANSFER_VERSION;
|
|
16
|
+
contentHashVersion: typeof OSM_CONTENT_HASH_VERSION;
|
|
12
17
|
id: string;
|
|
13
18
|
header: OsmPbfHeaderBlock;
|
|
14
19
|
contentHash: string;
|
|
20
|
+
loadDiagnostics?: OsmLoadDiagnostics;
|
|
15
21
|
stringTable: StringTableTransferables<T>;
|
|
16
22
|
nodes: NodesTransferables<T>;
|
|
17
23
|
ways: WaysTransferables<T>;
|
|
18
24
|
relations: RelationsTransferables<T>;
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
export type OsmLoadProfile = "auto" | "full" | "view";
|
|
28
|
+
export type ResolvedOsmLoadProfile = Exclude<OsmLoadProfile, "auto">;
|
|
29
|
+
|
|
30
|
+
export interface OsmLoadDiagnosticReason {
|
|
31
|
+
code: string;
|
|
32
|
+
level: "info" | "warning";
|
|
33
|
+
message: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Plain-data diagnostics attached by loaders and safe to transfer between workers. */
|
|
37
|
+
export interface OsmLoadDiagnostics {
|
|
38
|
+
requestedProfile: OsmLoadProfile;
|
|
39
|
+
selectedProfile: ResolvedOsmLoadProfile;
|
|
40
|
+
reasons: OsmLoadDiagnosticReason[];
|
|
41
|
+
bytes: {
|
|
42
|
+
residentTypedBuffers: number;
|
|
43
|
+
projectedTypedBufferPeak: number;
|
|
44
|
+
largestPlannedAllocation: number;
|
|
45
|
+
storageBytes?: number;
|
|
46
|
+
};
|
|
47
|
+
budgets: {
|
|
48
|
+
workingSet?: number;
|
|
49
|
+
singleAllocation?: number;
|
|
50
|
+
allNodeSpatialIndex?: number;
|
|
51
|
+
arrayBufferCeiling?: number;
|
|
52
|
+
sharedArrayBufferCeiling?: number;
|
|
53
|
+
};
|
|
54
|
+
phaseTimingsMs: Record<string, number>;
|
|
55
|
+
counters: {
|
|
56
|
+
taggedNodes?: number;
|
|
57
|
+
taggedWays?: number;
|
|
58
|
+
taggedRelations?: number;
|
|
59
|
+
nodeTagPairs?: number;
|
|
60
|
+
wayTagPairs?: number;
|
|
61
|
+
relationTagPairs?: number;
|
|
62
|
+
wayReferences?: number;
|
|
63
|
+
missingWayReferences?: number;
|
|
64
|
+
relationMembers?: number;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
21
68
|
export interface OsmInfo {
|
|
22
69
|
id: string;
|
|
23
|
-
|
|
70
|
+
/** Bounding box of all nodes, or `null` when the dataset has no nodes. */
|
|
71
|
+
bbox: GeoBbox2D | null;
|
|
24
72
|
header: OsmPbfHeaderBlock;
|
|
25
73
|
stats: {
|
|
26
74
|
nodes: number;
|
|
27
75
|
ways: number;
|
|
28
76
|
relations: number;
|
|
29
77
|
};
|
|
78
|
+
spatialIndexes: {
|
|
79
|
+
nodes: { all: boolean; tagged: boolean };
|
|
80
|
+
ways: boolean;
|
|
81
|
+
relations: boolean;
|
|
82
|
+
};
|
|
83
|
+
loadDiagnostics?: OsmLoadDiagnostics;
|
|
30
84
|
}
|
|
31
85
|
|
|
32
86
|
export interface OsmOptions {
|
|
@@ -50,6 +104,7 @@ export class Osm {
|
|
|
50
104
|
|
|
51
105
|
private indexBuilt = false;
|
|
52
106
|
private _contentHash = "";
|
|
107
|
+
private _loadDiagnostics?: OsmLoadDiagnostics;
|
|
53
108
|
|
|
54
109
|
/**
|
|
55
110
|
* Create a new OSM Entity index.
|
|
@@ -72,13 +127,25 @@ export class Osm {
|
|
|
72
127
|
opts.relations.transferables(),
|
|
73
128
|
);
|
|
74
129
|
this._contentHash = opts._contentHash;
|
|
130
|
+
this._loadDiagnostics = opts._loadDiagnostics;
|
|
75
131
|
this.indexBuilt = true;
|
|
76
132
|
} else {
|
|
133
|
+
if (opts.transferVersion !== OSM_TRANSFER_VERSION) {
|
|
134
|
+
throw Error(
|
|
135
|
+
`Unsupported OSM transfer version: ${String(opts.transferVersion)}. Expected ${String(OSM_TRANSFER_VERSION)}.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (opts.contentHashVersion !== OSM_CONTENT_HASH_VERSION) {
|
|
139
|
+
throw Error(
|
|
140
|
+
`Unsupported OSM content hash version: ${String(opts.contentHashVersion)}. Expected ${String(OSM_CONTENT_HASH_VERSION)}.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
77
143
|
this.stringTable = new StringTable(opts.stringTable);
|
|
78
144
|
this.nodes = new Nodes(this.stringTable, opts.nodes);
|
|
79
145
|
this.ways = new Ways(this.stringTable, this.nodes, opts.ways);
|
|
80
146
|
this.relations = new Relations(this.stringTable, this.nodes, this.ways, opts.relations);
|
|
81
147
|
this._contentHash = opts.contentHash;
|
|
148
|
+
this._loadDiagnostics = opts.loadDiagnostics;
|
|
82
149
|
this.indexBuilt = true;
|
|
83
150
|
}
|
|
84
151
|
} else {
|
|
@@ -116,6 +183,7 @@ export class Osm {
|
|
|
116
183
|
* Build spatial indexes for all entities.
|
|
117
184
|
*/
|
|
118
185
|
buildSpatialIndexes() {
|
|
186
|
+
this.nodes.buildSpatialIndex("tagged");
|
|
119
187
|
this.nodes.buildSpatialIndex();
|
|
120
188
|
this.ways.buildSpatialIndex();
|
|
121
189
|
this.relations.buildSpatialIndex();
|
|
@@ -127,15 +195,16 @@ export class Osm {
|
|
|
127
195
|
hasSpatialIndexes(): boolean {
|
|
128
196
|
return (
|
|
129
197
|
this.nodes.hasSpatialIndex() &&
|
|
198
|
+
this.nodes.hasSpatialIndex("tagged") &&
|
|
130
199
|
this.ways.hasSpatialIndex() &&
|
|
131
200
|
this.relations.hasSpatialIndex()
|
|
132
201
|
);
|
|
133
202
|
}
|
|
134
203
|
|
|
135
204
|
/**
|
|
136
|
-
* Get the bounding box of all entities in the OSM index.
|
|
205
|
+
* Get the bounding box of all entities in the OSM index, or `null` when there are no nodes.
|
|
137
206
|
*/
|
|
138
|
-
bbox(): GeoBbox2D {
|
|
207
|
+
bbox(): GeoBbox2D | null {
|
|
139
208
|
return this.nodes.getBbox();
|
|
140
209
|
}
|
|
141
210
|
|
|
@@ -152,17 +221,34 @@ export class Osm {
|
|
|
152
221
|
ways: this.ways.size,
|
|
153
222
|
relations: this.relations.size,
|
|
154
223
|
},
|
|
224
|
+
spatialIndexes: {
|
|
225
|
+
nodes: {
|
|
226
|
+
all: this.nodes.hasSpatialIndex("all"),
|
|
227
|
+
tagged: this.nodes.hasSpatialIndex("tagged"),
|
|
228
|
+
},
|
|
229
|
+
ways: this.ways.hasSpatialIndex(),
|
|
230
|
+
relations: this.relations.hasSpatialIndex(),
|
|
231
|
+
},
|
|
232
|
+
...(this._loadDiagnostics ? { loadDiagnostics: this._loadDiagnostics } : {}),
|
|
155
233
|
};
|
|
156
234
|
}
|
|
157
235
|
|
|
236
|
+
/** Attach loader-provided diagnostics for `info()` and worker transfer. */
|
|
237
|
+
setLoadDiagnostics(loadDiagnostics: OsmLoadDiagnostics | undefined): void {
|
|
238
|
+
this._loadDiagnostics = loadDiagnostics;
|
|
239
|
+
}
|
|
240
|
+
|
|
158
241
|
/**
|
|
159
242
|
* Get transferable objects for passing to another thread.
|
|
160
243
|
*/
|
|
161
244
|
transferables(): OsmTransferables {
|
|
162
245
|
return {
|
|
246
|
+
transferVersion: OSM_TRANSFER_VERSION,
|
|
247
|
+
contentHashVersion: OSM_CONTENT_HASH_VERSION,
|
|
163
248
|
id: this.id,
|
|
164
249
|
header: this.header,
|
|
165
250
|
contentHash: this._contentHash,
|
|
251
|
+
...(this._loadDiagnostics ? { loadDiagnostics: this._loadDiagnostics } : {}),
|
|
166
252
|
stringTable: this.stringTable.transferables(),
|
|
167
253
|
nodes: this.nodes.transferables(),
|
|
168
254
|
ways: this.ways.transferables(),
|
|
@@ -199,7 +285,7 @@ export class Osm {
|
|
|
199
285
|
* @returns A hex string hash of the content.
|
|
200
286
|
*/
|
|
201
287
|
private computeContentHash(): string {
|
|
202
|
-
const hasher = new ContentHasher();
|
|
288
|
+
const hasher = new ContentHasher().updateNumber(OSM_CONTENT_HASH_VERSION);
|
|
203
289
|
// Hash string table first (shared by all entities)
|
|
204
290
|
this.stringTable.updateHash(hasher);
|
|
205
291
|
// Hash each entity collection
|