@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +54 -7
  3. package/dist/contracts.d.ts +2 -1
  4. package/dist/contracts.d.ts.map +1 -1
  5. package/dist/entities.d.ts +21 -1
  6. package/dist/entities.d.ts.map +1 -1
  7. package/dist/entities.js +52 -3
  8. package/dist/entities.js.map +1 -1
  9. package/dist/ids.d.ts +18 -5
  10. package/dist/ids.d.ts.map +1 -1
  11. package/dist/ids.js +68 -18
  12. package/dist/ids.js.map +1 -1
  13. package/dist/index.d.ts +6 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6 -3
  16. package/dist/index.js.map +1 -1
  17. package/dist/indirect-kd-index.d.ts +28 -0
  18. package/dist/indirect-kd-index.d.ts.map +1 -0
  19. package/dist/indirect-kd-index.js +163 -0
  20. package/dist/indirect-kd-index.js.map +1 -0
  21. package/dist/limits.d.ts +30 -0
  22. package/dist/limits.d.ts.map +1 -0
  23. package/dist/limits.js +51 -0
  24. package/dist/limits.js.map +1 -0
  25. package/dist/nodes.d.ts +28 -13
  26. package/dist/nodes.d.ts.map +1 -1
  27. package/dist/nodes.js +181 -51
  28. package/dist/nodes.js.map +1 -1
  29. package/dist/osm.d.ts +59 -3
  30. package/dist/osm.d.ts.map +1 -1
  31. package/dist/osm.js +31 -2
  32. package/dist/osm.js.map +1 -1
  33. package/dist/relations.d.ts +10 -4
  34. package/dist/relations.d.ts.map +1 -1
  35. package/dist/relations.js +66 -28
  36. package/dist/relations.js.map +1 -1
  37. package/dist/renumber.d.ts +23 -0
  38. package/dist/renumber.d.ts.map +1 -0
  39. package/dist/renumber.js +64 -0
  40. package/dist/renumber.js.map +1 -0
  41. package/dist/stringtable.d.ts +2 -1
  42. package/dist/stringtable.d.ts.map +1 -1
  43. package/dist/stringtable.js +5 -1
  44. package/dist/stringtable.js.map +1 -1
  45. package/dist/tags.d.ts +44 -11
  46. package/dist/tags.d.ts.map +1 -1
  47. package/dist/tags.js +153 -27
  48. package/dist/tags.js.map +1 -1
  49. package/dist/typed-arrays.d.ts +63 -48
  50. package/dist/typed-arrays.d.ts.map +1 -1
  51. package/dist/typed-arrays.js +184 -107
  52. package/dist/typed-arrays.js.map +1 -1
  53. package/dist/ways.d.ts +50 -2
  54. package/dist/ways.d.ts.map +1 -1
  55. package/dist/ways.js +212 -35
  56. package/dist/ways.js.map +1 -1
  57. package/package.json +11 -12
  58. package/src/contracts.ts +2 -1
  59. package/src/entities.ts +67 -4
  60. package/src/ids.ts +72 -22
  61. package/src/index.ts +13 -7
  62. package/src/indirect-kd-index.ts +212 -0
  63. package/src/limits.ts +73 -0
  64. package/src/nodes.ts +228 -54
  65. package/src/osm.ts +90 -4
  66. package/src/relations.ts +80 -30
  67. package/src/renumber.ts +80 -0
  68. package/src/stringtable.ts +15 -1
  69. package/src/tags.ts +173 -31
  70. package/src/typed-arrays.ts +225 -113
  71. package/src/ways.ts +233 -38
  72. package/test/entity-index-errors.test.ts +41 -0
  73. package/test/ids.bench.ts +11 -7
  74. package/test/ids.test.ts +38 -0
  75. package/test/limits.test.ts +83 -0
  76. package/test/no-shared-array-buffer.test.ts +41 -0
  77. package/test/nodes-spatial-index.test.ts +261 -0
  78. package/test/osm-bbox.test.ts +30 -0
  79. package/test/relations.bench.ts +9 -4
  80. package/test/relations.test.ts +52 -29
  81. package/test/renumber.test.ts +54 -0
  82. package/test/spatial-index-rebuild.test.ts +33 -0
  83. package/test/storage-compaction.test.ts +269 -0
  84. package/test/tags-search.test.ts +13 -0
  85. package/test/typed-arrays.test.ts +178 -59
  86. package/src/types/geokdbush.d.ts +0 -32
  87. 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
- BufferConstructor,
14
- type BufferType,
15
- IdArrayType,
16
- ResizeableTypedArray as RTA,
17
- } from "./typed-arrays.ts";
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 2,000 nodes per way
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
- // Node IDs
39
- private refs: RTA<Float64Array>;
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(id: number): [number, number] {
48
- const coordinate = this.nodes.getNodeLonLat({ id });
49
- if (!coordinate) throw Error(`Node ${id} not found for way geometry`);
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(IdArrayType, transferables.refs);
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(IdArrayType);
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.refs.length);
116
+ this.refStart.push(this.refLength);
85
117
  this.refCount.push(way.refs.length);
86
- for (const ref of way.refs) this.refs.push(ref);
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.refs.length);
126
- this.refCount.push(filteredWay?.refs.length ?? refs.length);
127
- this.refs.pushMany(filteredWay?.refs ?? refs);
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.size === 0) return this.spatialIndex;
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 refId = this.refs.at(j);
176
- const [lon, lat] = this.getNodeLonLat(refId);
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
- return Array.from(this.refs.slice(start, start + count));
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 ref = this.refs.at(start + i);
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
- const ref = this.refs.at(refIndex);
263
- coords.push(this.getNodeLonLat(ref));
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
- return this.spatialIndex.search(bbox[0], bbox[1], bbox[2], bbox[3], filterFn);
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
- return super
381
- .updateHash(hasher)
382
- .update(this.refStart.array)
383
- .update(this.refCount.array)
384
- .update(this.refs.array);
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 { bench, describe } from "vitest";
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
- bench("build shuffled sorted index", () => {
21
- buildIds();
20
+ test("build shuffled sorted index", async ({ bench }) => {
21
+ await bench("build shuffled sorted index", () => {
22
+ buildIds();
23
+ }).run();
22
24
  });
23
25
 
24
- bench("iterate all sorted entities", () => {
25
- let checksum = 0;
26
- for (const node of osm.nodes.sorted()) checksum += node.id + node.lon;
27
- if (checksum === 0) throw Error("Unexpected empty benchmark");
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
+ });