@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/ways.ts
CHANGED
|
@@ -4,24 +4,32 @@ import type { GeoBbox2D, LonLat, OsmTags, OsmWay } from "@osmix/types";
|
|
|
4
4
|
import Flatbush from "flatbush";
|
|
5
5
|
import { around as geoAround } from "geoflatbush";
|
|
6
6
|
|
|
7
|
-
import { Entities, type EntitiesTransferables } from "./entities.ts";
|
|
7
|
+
import { Entities, type EntitiesTransferables, isValidSpatialBbox } from "./entities.ts";
|
|
8
8
|
import { type IdOrIndex, Ids } from "./ids.ts";
|
|
9
|
+
import { MAX_UINT32_OFFSET, MAX_WAY_REFS, assertCapacity } from "./limits.ts";
|
|
9
10
|
import type { Nodes } from "./nodes.ts";
|
|
10
11
|
import type StringTable from "./stringtable.ts";
|
|
11
12
|
import { Tags } from "./tags.ts";
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
import { BufferConstructor, type BufferType, ResizeableTypedArray as RTA } from "./typed-arrays.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Node-index sentinel stored for way refs whose node is not present in the dataset.
|
|
17
|
+
* Returned by {@link Ways.getRefIndexes} in place of a node index.
|
|
18
|
+
*/
|
|
19
|
+
export const MISSING_NODE_INDEX = 0xffffffff;
|
|
20
|
+
const HASH_REF_CHUNK_SIZE = 8192;
|
|
18
21
|
|
|
19
22
|
export interface WaysTransferables<
|
|
20
23
|
T extends BufferType = BufferType,
|
|
21
24
|
> extends EntitiesTransferables<T> {
|
|
22
25
|
refStart: T;
|
|
23
26
|
refCount: T;
|
|
27
|
+
/** Node indexes, with `0xffffffff` marking an unresolved reference. */
|
|
24
28
|
refs: T;
|
|
29
|
+
/** Sorted positions in `refs` whose node ID could not be resolved. */
|
|
30
|
+
missingRefPositions: T;
|
|
31
|
+
/** OSM node IDs parallel to `missingRefPositions`. */
|
|
32
|
+
missingRefIds: T;
|
|
25
33
|
bbox: T;
|
|
26
34
|
/** Optional - can be rebuilt via buildSpatialIndex() */
|
|
27
35
|
spatialIndex?: T;
|
|
@@ -33,10 +41,15 @@ export class Ways extends Entities<OsmWay> {
|
|
|
33
41
|
private spatialIndexBuilt = false;
|
|
34
42
|
|
|
35
43
|
private refStart: RTA<Uint32Array>;
|
|
36
|
-
private refCount: RTA<Uint16Array>; // Maximum
|
|
44
|
+
private refCount: RTA<Uint16Array>; // Maximum MAX_WAY_REFS (65,535) nodes per way
|
|
45
|
+
|
|
46
|
+
// Node indexes. Missing node IDs are preserved in sparse parallel arrays.
|
|
47
|
+
private refs: RTA<Uint32Array>;
|
|
48
|
+
private missingRefPositions: RTA<Uint32Array>;
|
|
49
|
+
private missingRefIds: RTA<Float64Array>;
|
|
37
50
|
|
|
38
|
-
//
|
|
39
|
-
private
|
|
51
|
+
// Temporary OSM node IDs used when nodes are not indexed during ingestion.
|
|
52
|
+
private pendingRefIds: RTA<Float64Array> | null;
|
|
40
53
|
|
|
41
54
|
// Bounding box of the way in geographic coordinates
|
|
42
55
|
private bbox: RTA<Float64Array>;
|
|
@@ -44,12 +57,24 @@ export class Ways extends Entities<OsmWay> {
|
|
|
44
57
|
// Node reference index
|
|
45
58
|
private nodes: Nodes;
|
|
46
59
|
|
|
47
|
-
private getNodeLonLat(
|
|
48
|
-
const coordinate = this.
|
|
49
|
-
if (!coordinate)
|
|
60
|
+
private getNodeLonLat(refPosition: number): [number, number] {
|
|
61
|
+
const coordinate = this.tryGetNodeLonLat(refPosition);
|
|
62
|
+
if (!coordinate) {
|
|
63
|
+
throw Error(`Node ${this.getMissingRefId(refPosition)} not found for way geometry`);
|
|
64
|
+
}
|
|
50
65
|
return coordinate;
|
|
51
66
|
}
|
|
52
67
|
|
|
68
|
+
/** Resolve a reference position to coordinates, or null when the node is absent. */
|
|
69
|
+
private tryGetNodeLonLat(refPosition: number): [number, number] | null {
|
|
70
|
+
const nodeIndex = this.refs.at(refPosition);
|
|
71
|
+
if (nodeIndex !== MISSING_NODE_INDEX) {
|
|
72
|
+
return this.nodes.getNodeLonLat({ index: nodeIndex });
|
|
73
|
+
}
|
|
74
|
+
// A missing ref may still resolve if the node was added after finalization.
|
|
75
|
+
return this.nodes.getNodeLonLat({ id: this.getMissingRefId(refPosition) });
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
/**
|
|
54
79
|
* Create a new Ways index.
|
|
55
80
|
*/
|
|
@@ -58,7 +83,10 @@ export class Ways extends Entities<OsmWay> {
|
|
|
58
83
|
super("way", new Ids(transferables), new Tags(stringTable, transferables));
|
|
59
84
|
this.refStart = RTA.from(Uint32Array, transferables.refStart);
|
|
60
85
|
this.refCount = RTA.from(Uint16Array, transferables.refCount);
|
|
61
|
-
this.refs = RTA.from(
|
|
86
|
+
this.refs = RTA.from(Uint32Array, transferables.refs);
|
|
87
|
+
this.missingRefPositions = RTA.from(Uint32Array, transferables.missingRefPositions);
|
|
88
|
+
this.missingRefIds = RTA.from(Float64Array, transferables.missingRefIds);
|
|
89
|
+
this.pendingRefIds = null;
|
|
62
90
|
this.bbox = RTA.from(Float64Array, transferables.bbox);
|
|
63
91
|
// Only load spatial index if provided (not stored in IndexedDB)
|
|
64
92
|
if (transferables.spatialIndex?.byteLength) {
|
|
@@ -70,7 +98,10 @@ export class Ways extends Entities<OsmWay> {
|
|
|
70
98
|
super("way", new Ids(), new Tags(stringTable));
|
|
71
99
|
this.refStart = new RTA(Uint32Array);
|
|
72
100
|
this.refCount = new RTA(Uint16Array);
|
|
73
|
-
this.refs = new RTA(
|
|
101
|
+
this.refs = new RTA(Uint32Array);
|
|
102
|
+
this.missingRefPositions = new RTA(Uint32Array);
|
|
103
|
+
this.missingRefIds = new RTA(Float64Array);
|
|
104
|
+
this.pendingRefIds = new RTA(Float64Array);
|
|
74
105
|
this.bbox = new RTA(Float64Array);
|
|
75
106
|
}
|
|
76
107
|
this.nodes = nodes;
|
|
@@ -80,10 +111,11 @@ export class Ways extends Entities<OsmWay> {
|
|
|
80
111
|
* Add a single way to the index.
|
|
81
112
|
*/
|
|
82
113
|
addWay(way: OsmWay) {
|
|
114
|
+
this.assertRefCapacity(way.id, way.refs.length);
|
|
83
115
|
const wayIndex = this.addEntity(way.id, way.tags ?? {});
|
|
84
|
-
this.refStart.push(this.
|
|
116
|
+
this.refStart.push(this.refLength);
|
|
85
117
|
this.refCount.push(way.refs.length);
|
|
86
|
-
|
|
118
|
+
this.appendRefIds(way.refs);
|
|
87
119
|
return wayIndex;
|
|
88
120
|
}
|
|
89
121
|
|
|
@@ -94,6 +126,7 @@ export class Ways extends Entities<OsmWay> {
|
|
|
94
126
|
ways: OsmPbfWay[],
|
|
95
127
|
blockStringIndexMap: Uint32Array,
|
|
96
128
|
filter?: (way: OsmWay) => OsmWay | null,
|
|
129
|
+
nodeIdToIndex?: (nodeId: number) => number,
|
|
97
130
|
) {
|
|
98
131
|
let added = 0;
|
|
99
132
|
for (const way of ways) {
|
|
@@ -121,10 +154,12 @@ export class Ways extends Entities<OsmWay> {
|
|
|
121
154
|
: null;
|
|
122
155
|
if (filter && filteredWay === null) continue;
|
|
123
156
|
|
|
157
|
+
const addedRefs = filteredWay?.refs ?? refs;
|
|
158
|
+
this.assertRefCapacity(way.id, addedRefs.length);
|
|
124
159
|
this.addEntity(way.id, tagKeys, tagValues);
|
|
125
|
-
this.refStart.push(this.
|
|
126
|
-
this.refCount.push(
|
|
127
|
-
this.
|
|
160
|
+
this.refStart.push(this.refLength);
|
|
161
|
+
this.refCount.push(addedRefs.length);
|
|
162
|
+
this.appendRefIds(addedRefs, nodeIdToIndex);
|
|
128
163
|
added++;
|
|
129
164
|
}
|
|
130
165
|
return added;
|
|
@@ -132,20 +167,52 @@ export class Ways extends Entities<OsmWay> {
|
|
|
132
167
|
|
|
133
168
|
/**
|
|
134
169
|
* Compact the internal arrays to free up memory.
|
|
170
|
+
*
|
|
171
|
+
* Must run after the node ID index is built (`Osm.buildIndexes()` orders
|
|
172
|
+
* nodes before ways) so pending OSM node IDs resolve to compact `Uint32`
|
|
173
|
+
* node indexes. If the node index is not ready, every pending ref degrades
|
|
174
|
+
* to the larger missing-ref representation (sentinel + sorted ID lookup).
|
|
135
175
|
*/
|
|
136
176
|
buildEntityIndex() {
|
|
137
177
|
this.refStart.compact();
|
|
138
178
|
this.refCount.compact();
|
|
179
|
+
if (this.pendingRefIds && this.pendingRefIds.length > 0) {
|
|
180
|
+
if (this.refs.length > 0) throw Error("Mixed pending and indexed way references.");
|
|
181
|
+
const refsBuffer = new BufferConstructor(
|
|
182
|
+
this.pendingRefIds.length * Uint32Array.BYTES_PER_ELEMENT,
|
|
183
|
+
);
|
|
184
|
+
this.refs = RTA.from(Uint32Array, refsBuffer);
|
|
185
|
+
const canResolveNodes = this.nodes.ids.isReady();
|
|
186
|
+
if (!canResolveNodes) {
|
|
187
|
+
console.warn(
|
|
188
|
+
"Ways.buildEntityIndex ran before the node index was ready; all way references will use the missing-ref representation.",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
for (let i = 0; i < this.pendingRefIds.length; i++) {
|
|
192
|
+
const refId = this.pendingRefIds.at(i);
|
|
193
|
+
const nodeIndex = canResolveNodes ? this.nodes.ids.getIndexFromId(refId) : -1;
|
|
194
|
+
this.setIndexedRef(i, refId, nodeIndex);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
this.pendingRefIds = null;
|
|
139
198
|
this.refs.compact();
|
|
199
|
+
this.missingRefPositions.compact();
|
|
200
|
+
this.missingRefIds.compact();
|
|
140
201
|
}
|
|
141
202
|
|
|
142
203
|
/**
|
|
143
204
|
* Build the spatial index for ways.
|
|
144
205
|
* If bbox data already exists (e.g., loaded from storage), reuses it.
|
|
206
|
+
* Returns the existing index when it is already built.
|
|
145
207
|
*/
|
|
146
208
|
buildSpatialIndex() {
|
|
147
209
|
if (!this.nodes.isReady()) throw Error("Node index is not ready.");
|
|
148
|
-
if (this.
|
|
210
|
+
if (this.spatialIndexBuilt) return this.spatialIndex;
|
|
211
|
+
if (this.size === 0) {
|
|
212
|
+
this.spatialIndex = new Flatbush(1, 128, Float64Array, BufferConstructor);
|
|
213
|
+
this.spatialIndexBuilt = true;
|
|
214
|
+
return this.spatialIndex;
|
|
215
|
+
}
|
|
149
216
|
|
|
150
217
|
this.spatialIndex = new Flatbush(this.size, 128, Float64Array, BufferConstructor);
|
|
151
218
|
|
|
@@ -164,7 +231,9 @@ export class Ways extends Entities<OsmWay> {
|
|
|
164
231
|
maxX = this.bbox.at(i * 4 + 2);
|
|
165
232
|
maxY = this.bbox.at(i * 4 + 3);
|
|
166
233
|
} else {
|
|
167
|
-
// Calculate bbox from node coordinates
|
|
234
|
+
// Calculate bbox from resolvable node coordinates. Missing refs are
|
|
235
|
+
// preserved losslessly in storage but contribute no geometry; a way
|
|
236
|
+
// with no resolvable refs keeps an inverted bbox that never matches.
|
|
168
237
|
minX = Number.POSITIVE_INFINITY;
|
|
169
238
|
minY = Number.POSITIVE_INFINITY;
|
|
170
239
|
maxX = Number.NEGATIVE_INFINITY;
|
|
@@ -172,8 +241,9 @@ export class Ways extends Entities<OsmWay> {
|
|
|
172
241
|
const start = this.refStart.at(i);
|
|
173
242
|
const count = this.refCount.at(i);
|
|
174
243
|
for (let j = start; j < start + count; j++) {
|
|
175
|
-
const
|
|
176
|
-
|
|
244
|
+
const coordinate = this.tryGetNodeLonLat(j);
|
|
245
|
+
if (!coordinate) continue;
|
|
246
|
+
const [lon, lat] = coordinate;
|
|
177
247
|
if (lon < minX) minX = lon;
|
|
178
248
|
if (lon > maxX) maxX = lon;
|
|
179
249
|
if (lat < minY) minY = lat;
|
|
@@ -219,7 +289,21 @@ export class Ways extends Entities<OsmWay> {
|
|
|
219
289
|
getRefIds(index: number): number[] {
|
|
220
290
|
const start = this.refStart.at(index);
|
|
221
291
|
const count = this.refCount.at(index);
|
|
222
|
-
|
|
292
|
+
const refs = Array.from<number>({ length: count });
|
|
293
|
+
for (let i = 0; i < count; i++) refs[i] = this.getRefId(start + i);
|
|
294
|
+
return refs;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get the node indexes referenced by a way without allocating.
|
|
299
|
+
*
|
|
300
|
+
* Returns a view into internal storage; do not mutate it or hold it across writes. Refs to
|
|
301
|
+
* nodes absent from the dataset are {@link MISSING_NODE_INDEX}. Requires `buildEntityIndex()`.
|
|
302
|
+
*/
|
|
303
|
+
getRefIndexes(index: number): Uint32Array {
|
|
304
|
+
if (this.pendingRefIds !== null) throw Error("Way index is not built.");
|
|
305
|
+
const start = this.refStart.at(index);
|
|
306
|
+
return this.refs.array.subarray(start, start + this.refCount.at(index));
|
|
223
307
|
}
|
|
224
308
|
|
|
225
309
|
/**
|
|
@@ -243,8 +327,7 @@ export class Ways extends Entities<OsmWay> {
|
|
|
243
327
|
const start = this.refStart.at(index);
|
|
244
328
|
const line = new Float64Array(count * 2);
|
|
245
329
|
for (let i = 0; i < count; i++) {
|
|
246
|
-
const
|
|
247
|
-
const [lon, lat] = this.getNodeLonLat(ref);
|
|
330
|
+
const [lon, lat] = this.getNodeLonLat(start + i);
|
|
248
331
|
line[i * 2] = lon;
|
|
249
332
|
line[i * 2 + 1] = lat;
|
|
250
333
|
}
|
|
@@ -253,14 +336,31 @@ export class Ways extends Entities<OsmWay> {
|
|
|
253
336
|
|
|
254
337
|
/**
|
|
255
338
|
* Get the coordinates of a way as an array of [lon, lat] pairs.
|
|
339
|
+
* Throws when a referenced node cannot be resolved; use
|
|
340
|
+
* `getResolvedCoordinates()` for tolerant consumers such as bbox building.
|
|
256
341
|
*/
|
|
257
342
|
getCoordinates(index: number): LonLat[] {
|
|
258
343
|
const count = this.refCount.at(index);
|
|
259
344
|
const start = this.refStart.at(index);
|
|
260
345
|
const coords: [number, number][] = [];
|
|
261
346
|
for (let refIndex = start; refIndex < start + count; refIndex++) {
|
|
262
|
-
|
|
263
|
-
|
|
347
|
+
coords.push(this.getNodeLonLat(refIndex));
|
|
348
|
+
}
|
|
349
|
+
return coords;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Get the resolvable coordinates of a way, skipping missing node refs.
|
|
354
|
+
* Preferred over `getCoordinates()` when partial geometry is acceptable,
|
|
355
|
+
* e.g. computing bounding boxes over referentially incomplete extracts.
|
|
356
|
+
*/
|
|
357
|
+
getResolvedCoordinates(index: number): LonLat[] {
|
|
358
|
+
const count = this.refCount.at(index);
|
|
359
|
+
const start = this.refStart.at(index);
|
|
360
|
+
const coords: [number, number][] = [];
|
|
361
|
+
for (let refIndex = start; refIndex < start + count; refIndex++) {
|
|
362
|
+
const coordinate = this.tryGetNodeLonLat(refIndex);
|
|
363
|
+
if (coordinate) coords.push(coordinate);
|
|
264
364
|
}
|
|
265
365
|
return coords;
|
|
266
366
|
}
|
|
@@ -270,7 +370,13 @@ export class Ways extends Entities<OsmWay> {
|
|
|
270
370
|
*/
|
|
271
371
|
intersects(bbox: GeoBbox2D, filterFn?: (index: number) => boolean): number[] {
|
|
272
372
|
if (this.size === 0) return [];
|
|
273
|
-
|
|
373
|
+
// A way with no resolvable refs stores an inverted bbox. Flatbush's
|
|
374
|
+
// contained-node fast path skips per-leaf intersection tests, so filter
|
|
375
|
+
// inverted boxes out explicitly.
|
|
376
|
+
return this.spatialIndex.search(bbox[0], bbox[1], bbox[2], bbox[3], (index, x0, y0, x1, y1) => {
|
|
377
|
+
if (!isValidSpatialBbox(x0, y0, x1, y1)) return false;
|
|
378
|
+
return filterFn ? filterFn(index) : true;
|
|
379
|
+
});
|
|
274
380
|
}
|
|
275
381
|
|
|
276
382
|
/**
|
|
@@ -284,7 +390,15 @@ export class Ways extends Entities<OsmWay> {
|
|
|
284
390
|
neighbors(lon: number, lat: number, maxResults?: number, maxDistanceKm?: number): number[] {
|
|
285
391
|
if (this.size === 0) return [];
|
|
286
392
|
// Use geoflatbush for proper geographic distance calculations
|
|
287
|
-
return geoAround(this.spatialIndex, lon, lat, maxResults, maxDistanceKm)
|
|
393
|
+
return geoAround(this.spatialIndex, lon, lat, maxResults, maxDistanceKm, (index) => {
|
|
394
|
+
const offset = index * 4;
|
|
395
|
+
return isValidSpatialBbox(
|
|
396
|
+
this.bbox.at(offset),
|
|
397
|
+
this.bbox.at(offset + 1),
|
|
398
|
+
this.bbox.at(offset + 2),
|
|
399
|
+
this.bbox.at(offset + 3),
|
|
400
|
+
);
|
|
401
|
+
});
|
|
288
402
|
}
|
|
289
403
|
|
|
290
404
|
/**
|
|
@@ -338,6 +452,8 @@ export class Ways extends Entities<OsmWay> {
|
|
|
338
452
|
refStart: this.refStart.array.buffer,
|
|
339
453
|
refCount: this.refCount.array.buffer,
|
|
340
454
|
refs: this.refs.array.buffer,
|
|
455
|
+
missingRefPositions: this.missingRefPositions.array.buffer,
|
|
456
|
+
missingRefIds: this.missingRefIds.array.buffer,
|
|
341
457
|
bbox: this.bbox.array.buffer,
|
|
342
458
|
};
|
|
343
459
|
// Only include spatial index if it was built
|
|
@@ -350,7 +466,7 @@ export class Ways extends Entities<OsmWay> {
|
|
|
350
466
|
/**
|
|
351
467
|
* Get the approximate memory requirements for a given number of ways in bytes.
|
|
352
468
|
*/
|
|
353
|
-
static getBytesRequired(count: number) {
|
|
469
|
+
static getBytesRequired(count: number, refCount = 0, missingRefCount = 0, tagCount = 0) {
|
|
354
470
|
if (count === 0) return 0;
|
|
355
471
|
// Approximate nodes per way
|
|
356
472
|
let numNodes = count;
|
|
@@ -365,9 +481,11 @@ export class Ways extends Entities<OsmWay> {
|
|
|
365
481
|
|
|
366
482
|
return (
|
|
367
483
|
Ids.getBytesRequired(count) +
|
|
368
|
-
Tags.getBytesRequired(count) +
|
|
484
|
+
Tags.getBytesRequired(count, count, tagCount) +
|
|
369
485
|
count * Uint32Array.BYTES_PER_ELEMENT + // refStart
|
|
370
486
|
count * Uint16Array.BYTES_PER_ELEMENT + // refCount
|
|
487
|
+
refCount * Uint32Array.BYTES_PER_ELEMENT + // node indexes
|
|
488
|
+
missingRefCount * (Uint32Array.BYTES_PER_ELEMENT + Float64Array.BYTES_PER_ELEMENT) + // missing refs
|
|
371
489
|
count * 4 * Float64Array.BYTES_PER_ELEMENT + // bbox
|
|
372
490
|
spatialIndexBytes
|
|
373
491
|
);
|
|
@@ -377,10 +495,87 @@ export class Ways extends Entities<OsmWay> {
|
|
|
377
495
|
* Update a ContentHasher with way-specific data (node references).
|
|
378
496
|
*/
|
|
379
497
|
override updateHash(hasher: ContentHasher): ContentHasher {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
.
|
|
498
|
+
super.updateHash(hasher).update(this.refCount.array);
|
|
499
|
+
const chunk = new Float64Array(Math.min(HASH_REF_CHUNK_SIZE, this.refs.length));
|
|
500
|
+
let chunkLength = 0;
|
|
501
|
+
for (let i = 0; i < this.refs.length; i++) {
|
|
502
|
+
chunk[chunkLength++] = this.getRefId(i);
|
|
503
|
+
if (chunkLength === chunk.length) {
|
|
504
|
+
hasher.update(chunk);
|
|
505
|
+
chunkLength = 0;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (chunkLength > 0) hasher.update(chunk.subarray(0, chunkLength));
|
|
509
|
+
return hasher;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Logical reference length during either pending-ID or indexed ingestion. */
|
|
513
|
+
/** Reject a way whose ref count would wrap `refCount` or the `refStart` offsets. */
|
|
514
|
+
private assertRefCapacity(wayId: number, refCount: number) {
|
|
515
|
+
assertCapacity("way-refs", refCount, MAX_WAY_REFS, () => `Way ${wayId}`);
|
|
516
|
+
assertCapacity(
|
|
517
|
+
"total-way-refs",
|
|
518
|
+
this.refLength + refCount,
|
|
519
|
+
MAX_UINT32_OFFSET,
|
|
520
|
+
() => `The dataset (at way ${wayId})`,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private get refLength(): number {
|
|
525
|
+
return this.pendingRefIds?.length ?? this.refs.length;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Append OSM node IDs, optionally resolving them directly to node indexes. */
|
|
529
|
+
private appendRefIds(refIds: number[], nodeIdToIndex?: (nodeId: number) => number) {
|
|
530
|
+
if (nodeIdToIndex) {
|
|
531
|
+
if ((this.pendingRefIds?.length ?? 0) > 0) {
|
|
532
|
+
throw Error("Cannot mix unresolved and directly indexed way references.");
|
|
533
|
+
}
|
|
534
|
+
this.pendingRefIds = null;
|
|
535
|
+
for (const refId of refIds) {
|
|
536
|
+
this.setIndexedRef(this.refs.length, refId, nodeIdToIndex(refId));
|
|
537
|
+
}
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (!this.pendingRefIds) {
|
|
541
|
+
throw Error("Directly indexed way references require a node ID resolver.");
|
|
542
|
+
}
|
|
543
|
+
this.pendingRefIds.pushMany(refIds);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Store one resolved or losslessly preserved missing reference. */
|
|
547
|
+
private setIndexedRef(position: number, refId: number, nodeIndex: number) {
|
|
548
|
+
if (nodeIndex < 0) {
|
|
549
|
+
this.refs.set(position, MISSING_NODE_INDEX);
|
|
550
|
+
this.missingRefPositions.push(position);
|
|
551
|
+
this.missingRefIds.push(refId);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (!Number.isInteger(nodeIndex) || nodeIndex >= MISSING_NODE_INDEX) {
|
|
555
|
+
throw Error(`Invalid node index ${nodeIndex} for reference ${refId}`);
|
|
556
|
+
}
|
|
557
|
+
this.refs.set(position, nodeIndex);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Reconstruct the public OSM node ID for one internal reference position. */
|
|
561
|
+
private getRefId(position: number): number {
|
|
562
|
+
const nodeIndex = this.refs.at(position);
|
|
563
|
+
return nodeIndex === MISSING_NODE_INDEX
|
|
564
|
+
? this.getMissingRefId(position)
|
|
565
|
+
: this.nodes.ids.at(nodeIndex);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Find the OSM node ID for a sentinel reference position. */
|
|
569
|
+
private getMissingRefId(position: number): number {
|
|
570
|
+
let low = 0;
|
|
571
|
+
let high = this.missingRefPositions.length - 1;
|
|
572
|
+
while (low <= high) {
|
|
573
|
+
const middle = (low + high) >>> 1;
|
|
574
|
+
const candidate = this.missingRefPositions.at(middle);
|
|
575
|
+
if (candidate === position) return this.missingRefIds.at(middle);
|
|
576
|
+
if (candidate < position) low = middle + 1;
|
|
577
|
+
else high = middle - 1;
|
|
578
|
+
}
|
|
579
|
+
throw Error(`Missing node ID not found for way reference position ${position}`);
|
|
385
580
|
}
|
|
386
581
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { Osm } from "../src/osm.ts";
|
|
4
|
+
import { TypedBufferAllocationError } from "../src/typed-arrays.ts";
|
|
5
|
+
|
|
6
|
+
describe("entity index errors", () => {
|
|
7
|
+
it("adds entity and component context to typed-buffer failures", () => {
|
|
8
|
+
const osm = new Osm({ id: "allocation-error" });
|
|
9
|
+
const allocationError = new TypedBufferAllocationError(
|
|
10
|
+
{
|
|
11
|
+
operation: "compact",
|
|
12
|
+
typedArray: "Float64Array",
|
|
13
|
+
bufferType: "shared-array-buffer",
|
|
14
|
+
elementCount: 273_574_591,
|
|
15
|
+
bytesPerElement: 8,
|
|
16
|
+
requiredBytes: 2_188_596_728,
|
|
17
|
+
},
|
|
18
|
+
new RangeError("Array buffer allocation failed"),
|
|
19
|
+
);
|
|
20
|
+
vi.spyOn(osm.nodes.ids, "buildIndex").mockImplementation(() => {
|
|
21
|
+
throw allocationError;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(() => osm.nodes.buildIndex()).toThrowError(
|
|
25
|
+
expect.objectContaining({
|
|
26
|
+
name: "OsmEntityIndexBuildError",
|
|
27
|
+
code: "OSM_ENTITY_INDEX_BUILD_FAILED",
|
|
28
|
+
stage: "entity-index-finalization",
|
|
29
|
+
entityType: "node",
|
|
30
|
+
component: "ids",
|
|
31
|
+
operation: "compact",
|
|
32
|
+
typedArray: "Float64Array",
|
|
33
|
+
bufferType: "shared-array-buffer",
|
|
34
|
+
elementCount: 273_574_591,
|
|
35
|
+
bytesPerElement: 8,
|
|
36
|
+
requiredBytes: 2_188_596_728,
|
|
37
|
+
cause: allocationError,
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
package/test/ids.bench.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, test } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { Ids } from "../src/ids";
|
|
4
4
|
import { Osm } from "../src/osm";
|
|
@@ -17,13 +17,17 @@ for (const value of values) osm.nodes.addNode({ id: value, lon: value, lat: valu
|
|
|
17
17
|
osm.buildIndexes();
|
|
18
18
|
|
|
19
19
|
describe("sorted ID iteration", () => {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
test("build shuffled sorted index", async ({ bench }) => {
|
|
21
|
+
await bench("build shuffled sorted index", () => {
|
|
22
|
+
buildIds();
|
|
23
|
+
}).run();
|
|
22
24
|
});
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
test("iterate all sorted entities", async ({ bench }) => {
|
|
27
|
+
await bench("iterate all sorted entities", () => {
|
|
28
|
+
let checksum = 0;
|
|
29
|
+
for (const node of osm.nodes.sorted()) checksum += node.id + node.lon;
|
|
30
|
+
if (checksum === 0) throw Error("Unexpected empty benchmark");
|
|
31
|
+
}).run();
|
|
28
32
|
});
|
|
29
33
|
});
|
package/test/ids.test.ts
CHANGED
|
@@ -66,6 +66,44 @@ describe("Ids sorted entries", () => {
|
|
|
66
66
|
]);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
it("orders negative IDs canonically for OSM serialization", () => {
|
|
70
|
+
expect(Array.from(buildIds([3, -3, -1, 2, -2, 1, -2]).osmSortedEntries())).toEqual([
|
|
71
|
+
[-1, 2],
|
|
72
|
+
[-2, 4],
|
|
73
|
+
[-2, 6],
|
|
74
|
+
[-3, 1],
|
|
75
|
+
[1, 5],
|
|
76
|
+
[2, 3],
|
|
77
|
+
[3, 0],
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("omits redundant sorted buffers for ascending IDs", () => {
|
|
82
|
+
const transferables = buildIds([1, 2, 3]).transferables();
|
|
83
|
+
|
|
84
|
+
expect(transferables.idsAreSorted).toBe(true);
|
|
85
|
+
expect(transferables.sortedIds).toBeUndefined();
|
|
86
|
+
expect(transferables.sortedIdPositionToIndex).toBeUndefined();
|
|
87
|
+
expect(Ids.getBytesRequired(3)).toBe(
|
|
88
|
+
3 * Float64Array.BYTES_PER_ELEMENT + Float64Array.BYTES_PER_ELEMENT,
|
|
89
|
+
);
|
|
90
|
+
expect(Array.from(new Ids(transferables).sortedEntries())).toEqual([
|
|
91
|
+
[1, 0],
|
|
92
|
+
[2, 1],
|
|
93
|
+
[3, 2],
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("retains derived lookup buffers for unsorted IDs", () => {
|
|
98
|
+
const transferables = buildIds([3, 1, 2]).transferables();
|
|
99
|
+
|
|
100
|
+
expect(transferables.idsAreSorted).toBe(false);
|
|
101
|
+
expect(transferables.sortedIds?.byteLength).toBe(3 * Float64Array.BYTES_PER_ELEMENT);
|
|
102
|
+
expect(transferables.sortedIdPositionToIndex?.byteLength).toBe(
|
|
103
|
+
3 * Uint32Array.BYTES_PER_ELEMENT,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
69
107
|
it("streams sorted entities without looking up each ID again", () => {
|
|
70
108
|
const osm = new Osm();
|
|
71
109
|
osm.nodes.addNode({ id: 2, lon: 20, lat: 20 });
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
MAX_RELATION_MEMBERS,
|
|
5
|
+
MAX_STRING_BYTES,
|
|
6
|
+
MAX_UINT32_OFFSET,
|
|
7
|
+
MAX_WAY_REFS,
|
|
8
|
+
Osm,
|
|
9
|
+
OsmCapacityError,
|
|
10
|
+
Relations,
|
|
11
|
+
Tags,
|
|
12
|
+
assertCapacity,
|
|
13
|
+
} from "../src";
|
|
14
|
+
import StringTable from "../src/stringtable.ts";
|
|
15
|
+
|
|
16
|
+
describe("storage capacity limits", () => {
|
|
17
|
+
it("stores a way with exactly MAX_WAY_REFS refs", () => {
|
|
18
|
+
const osm = new Osm({ id: "max-way" });
|
|
19
|
+
const refs = Array.from({ length: MAX_WAY_REFS }, (_, i) => i + 1);
|
|
20
|
+
osm.ways.addWay({ id: 1, refs });
|
|
21
|
+
osm.buildIndexes();
|
|
22
|
+
expect(osm.ways.getById(1)?.refs).toHaveLength(MAX_WAY_REFS);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects a way with more than MAX_WAY_REFS refs instead of wrapping", () => {
|
|
26
|
+
const osm = new Osm({ id: "long-way" });
|
|
27
|
+
const refs = Array.from({ length: MAX_WAY_REFS + 1 }, (_, i) => i + 1);
|
|
28
|
+
const add = () => osm.ways.addWay({ id: 7, refs });
|
|
29
|
+
expect(add).toThrow(OsmCapacityError);
|
|
30
|
+
expect(add).toThrow("Way 7 has 65,536 node refs; Osmix stores at most 65,535.");
|
|
31
|
+
expect(osm.ways.size).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects a relation with more than MAX_RELATION_MEMBERS members", () => {
|
|
35
|
+
const osm = new Osm({ id: "big-relation" });
|
|
36
|
+
const members = Array.from({ length: MAX_RELATION_MEMBERS + 1 }, (_, i) => ({
|
|
37
|
+
type: "node" as const,
|
|
38
|
+
ref: i + 1,
|
|
39
|
+
}));
|
|
40
|
+
try {
|
|
41
|
+
osm.relations.addRelation({ id: 9, members });
|
|
42
|
+
expect.unreachable();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
expect(error).toBeInstanceOf(OsmCapacityError);
|
|
45
|
+
expect(error).toMatchObject({
|
|
46
|
+
code: "OSM_CAPACITY_EXCEEDED",
|
|
47
|
+
limit: "relation-members",
|
|
48
|
+
value: MAX_RELATION_MEMBERS + 1,
|
|
49
|
+
max: MAX_RELATION_MEMBERS,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
expect(osm.relations.size).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects a string longer than MAX_STRING_BYTES UTF-8 bytes", () => {
|
|
56
|
+
const table = new StringTable();
|
|
57
|
+
const fits = "a".repeat(MAX_STRING_BYTES);
|
|
58
|
+
expect(table.get(table.add(fits))).toBe(fits);
|
|
59
|
+
// "é" is two UTF-8 bytes, so this string is short in characters but too long in bytes.
|
|
60
|
+
const tooLong = "é".repeat((MAX_STRING_BYTES + 1) / 2);
|
|
61
|
+
expect(() => table.add(tooLong)).toThrow(OsmCapacityError);
|
|
62
|
+
expect(() => table.add(tooLong)).toThrow(/65,536 UTF-8 bytes/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reports dataset-wide offset overflow", () => {
|
|
66
|
+
expect(() =>
|
|
67
|
+
assertCapacity("total-way-refs", MAX_UINT32_OFFSET + 1, MAX_UINT32_OFFSET, () => "Data"),
|
|
68
|
+
).toThrow("Data has 4,294,967,296 node refs; Osmix stores at most 4,294,967,295.");
|
|
69
|
+
expect(() =>
|
|
70
|
+
assertCapacity("total-way-refs", MAX_UINT32_OFFSET, MAX_UINT32_OFFSET, () => "Data"),
|
|
71
|
+
).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("byte estimators", () => {
|
|
76
|
+
it("counts tag pairs at 12 bytes each", () => {
|
|
77
|
+
expect(Tags.getBytesRequired(10, 10, 5) - Tags.getBytesRequired(10, 10)).toBe(60);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("counts relation members at 13 bytes each", () => {
|
|
81
|
+
expect(Relations.getBytesRequired(1, 4) - Relations.getBytesRequired(1)).toBe(52);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
describe("without SharedArrayBuffer", () => {
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.unstubAllGlobals();
|
|
6
|
+
vi.resetModules();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("builds indexes and manages ArrayBuffer-backed typed arrays", async () => {
|
|
10
|
+
vi.stubGlobal("SharedArrayBuffer", undefined);
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
|
|
13
|
+
const { BufferConstructor, ResizeableTypedArray } = await import("../src/typed-arrays.ts");
|
|
14
|
+
const { Osm } = await import("../src/osm.ts");
|
|
15
|
+
|
|
16
|
+
expect(BufferConstructor).toBe(ArrayBuffer);
|
|
17
|
+
|
|
18
|
+
const osm = new Osm({ id: "array-buffer-only" });
|
|
19
|
+
osm.nodes.addNode({ id: 1, lon: 115.2167, lat: -8.65, tags: { place: "city" } });
|
|
20
|
+
osm.nodes.addNode({ id: 2, lon: 115.22, lat: -8.66 });
|
|
21
|
+
osm.ways.addWay({ id: 1, refs: [1, 2], tags: { highway: "residential" } });
|
|
22
|
+
osm.buildIndexes();
|
|
23
|
+
|
|
24
|
+
expect(osm.isReady()).toBe(true);
|
|
25
|
+
expect(osm.nodes.getById(1)?.tags?.["place"]).toBe("city");
|
|
26
|
+
|
|
27
|
+
const restored = ResizeableTypedArray.from(Uint8Array, new ArrayBuffer(4));
|
|
28
|
+
restored.push(9);
|
|
29
|
+
expect(restored.buffer).toBeInstanceOf(ArrayBuffer);
|
|
30
|
+
|
|
31
|
+
const growing = new ResizeableTypedArray(Uint8Array);
|
|
32
|
+
growing.pushMany(new Uint8Array(growing.capacity));
|
|
33
|
+
growing.push(7);
|
|
34
|
+
expect(growing.buffer).toBeInstanceOf(ArrayBuffer);
|
|
35
|
+
expect(growing.at(-1)).toBe(7);
|
|
36
|
+
|
|
37
|
+
const compacted = growing.compact();
|
|
38
|
+
expect(compacted.buffer).toBeInstanceOf(ArrayBuffer);
|
|
39
|
+
expect(compacted.length).toBe(growing.length);
|
|
40
|
+
});
|
|
41
|
+
});
|