@osmix/core 0.1.8 → 0.2.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 (71) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +21 -0
  3. package/README.md +86 -69
  4. package/dist/contracts.d.ts +26 -0
  5. package/dist/contracts.d.ts.map +1 -0
  6. package/dist/contracts.js +1 -0
  7. package/dist/contracts.js.map +1 -0
  8. package/dist/entities.d.ts +1 -1
  9. package/dist/entities.d.ts.map +1 -1
  10. package/dist/entities.js +2 -7
  11. package/dist/entities.js.map +1 -1
  12. package/dist/ids.d.ts +2 -0
  13. package/dist/ids.d.ts.map +1 -1
  14. package/dist/ids.js +23 -15
  15. package/dist/ids.js.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +0 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/mocks.d.ts.map +1 -1
  21. package/dist/mocks.js.map +1 -1
  22. package/dist/nodes.d.ts +9 -3
  23. package/dist/nodes.d.ts.map +1 -1
  24. package/dist/nodes.js +6 -13
  25. package/dist/nodes.js.map +1 -1
  26. package/dist/osm.d.ts +1 -1
  27. package/dist/osm.d.ts.map +1 -1
  28. package/dist/osm.js +1 -4
  29. package/dist/osm.js.map +1 -1
  30. package/dist/relations.d.ts +3 -2
  31. package/dist/relations.d.ts.map +1 -1
  32. package/dist/relations.js +15 -9
  33. package/dist/relations.js.map +1 -1
  34. package/dist/stringtable.d.ts.map +1 -1
  35. package/dist/stringtable.js +4 -13
  36. package/dist/stringtable.js.map +1 -1
  37. package/dist/tags.d.ts +1 -1
  38. package/dist/tags.d.ts.map +1 -1
  39. package/dist/tags.js.map +1 -1
  40. package/dist/typed-arrays.d.ts +1 -1
  41. package/dist/typed-arrays.d.ts.map +1 -1
  42. package/dist/typed-arrays.js.map +1 -1
  43. package/dist/ways.d.ts +2 -1
  44. package/dist/ways.d.ts.map +1 -1
  45. package/dist/ways.js +18 -18
  46. package/dist/ways.js.map +1 -1
  47. package/package.json +46 -31
  48. package/src/contracts.ts +28 -0
  49. package/src/entities.ts +179 -194
  50. package/src/ids.ts +228 -235
  51. package/src/index.ts +12 -9
  52. package/src/mocks.ts +114 -113
  53. package/src/nodes.ts +364 -397
  54. package/src/osm.ts +196 -207
  55. package/src/relations.ts +530 -558
  56. package/src/stringtable.ts +174 -183
  57. package/src/tags.ts +263 -271
  58. package/src/typed-arrays.ts +184 -194
  59. package/src/types/geoflatbush.d.ts +28 -33
  60. package/src/types/geokdbush.d.ts +28 -33
  61. package/src/types/kdbush.d.ts +34 -34
  62. package/src/ways.ts +381 -405
  63. package/test/ids.bench.ts +29 -0
  64. package/test/ids.test.ts +84 -0
  65. package/test/memory.test.ts +29 -30
  66. package/test/relations.bench.ts +27 -0
  67. package/test/relations.test.ts +101 -0
  68. package/test/tags-search.test.ts +76 -56
  69. package/test/typed-arrays.test.ts +100 -99
  70. package/tsconfig.build.json +6 -3
  71. package/tsconfig.json +7 -7
package/src/nodes.ts CHANGED
@@ -1,404 +1,371 @@
1
- import type { OsmPbfBlock, OsmPbfDenseNodes } from "@osmix/pbf"
2
- import { assertValue } from "@osmix/shared/assert"
3
- import type { ContentHasher } from "@osmix/shared/content-hasher"
4
- import { microToDegrees, toMicroDegrees } from "@osmix/shared/coordinates"
5
- import type { GeoBbox2D, OsmNode, OsmTags } from "@osmix/shared/types"
6
- import { around as geoAround } from "geokdbush"
7
- import KDBush from "kdbush"
8
- import { Entities, type EntitiesTransferables } from "./entities.ts"
9
- import { type IdOrIndex, Ids } from "./ids.ts"
10
- import type StringTable from "./stringtable.ts"
11
- import { Tags } from "./tags.ts"
12
- import {
13
- BufferConstructor,
14
- type BufferType,
15
- ResizeableTypedArray as RTA,
16
- } from "./typed-arrays.ts"
17
-
18
- export interface NodesTransferables<T extends BufferType = BufferType>
19
- extends EntitiesTransferables<T> {
20
- lons: T
21
- lats: T
22
- bbox: GeoBbox2D
23
- /** Optional - can be rebuilt via buildSpatialIndex() */
24
- spatialIndex?: T
1
+ import { microToDegrees, toMicroDegrees } from "@osmix/geo/coordinates";
2
+ import type { OsmPbfBlock, OsmPbfDenseNodes } from "@osmix/pbf";
3
+ import { assertValue } from "@osmix/shared/assert";
4
+ import type { ContentHasher } from "@osmix/shared/content-hasher";
5
+ import type { GeoBbox2D, OsmNode, OsmTags } from "@osmix/types";
6
+ import { around as geoAround } from "geokdbush";
7
+ import KDBush from "kdbush";
8
+
9
+ import { Entities, type EntitiesTransferables } from "./entities.ts";
10
+ import { type IdOrIndex, Ids } from "./ids.ts";
11
+ import type StringTable from "./stringtable.ts";
12
+ import { Tags } from "./tags.ts";
13
+ import { BufferConstructor, type BufferType, ResizeableTypedArray as RTA } from "./typed-arrays.ts";
14
+
15
+ export interface NodesTransferables<
16
+ T extends BufferType = BufferType,
17
+ > extends EntitiesTransferables<T> {
18
+ lons: T;
19
+ lats: T;
20
+ bbox: GeoBbox2D;
21
+ /** Optional - can be rebuilt via buildSpatialIndex() */
22
+ spatialIndex?: T;
25
23
  }
26
24
 
27
25
  export interface AddNodeOptions {
28
- filter?: (node: OsmNode) => boolean
26
+ filter?: (node: OsmNode) => boolean;
29
27
  }
30
28
 
31
29
  export class Nodes extends Entities<OsmNode> {
32
- /**
33
- * Coordinates are stored as integer microdegrees (Int32Array).
34
- * Use OSM_COORD_SCALE (1e7) to convert between degrees and microdegrees.
35
- */
36
- private lons: RTA<Int32Array>
37
- private lats: RTA<Int32Array>
38
- // Bbox stored in microdegrees internally
39
- private bbox: [
40
- minLon: number,
41
- minLat: number,
42
- maxLon: number,
43
- maxLat: number,
44
- ] = [
45
- Number.MAX_SAFE_INTEGER,
46
- Number.MAX_SAFE_INTEGER,
47
- Number.MIN_SAFE_INTEGER,
48
- Number.MIN_SAFE_INTEGER,
49
- ]
50
- // Spatial index stores coordinates in degrees (Float64Array) for geokdbush compatibility
51
- private spatialIndex: KDBush = new KDBush(
52
- 0,
53
- 128,
54
- Float64Array,
55
- BufferConstructor,
56
- )
57
- // Track if spatial index was properly built (vs default empty)
58
- private spatialIndexBuilt = false
59
-
60
- /**
61
- * Create a new Nodes index.
62
- */
63
- constructor(stringTable: StringTable, transferables?: NodesTransferables) {
64
- if (transferables) {
65
- super(
66
- "node",
67
- new Ids(transferables),
68
- new Tags(stringTable, transferables),
69
- )
70
- this.lons = RTA.from(Int32Array, transferables.lons)
71
- this.lats = RTA.from(Int32Array, transferables.lats)
72
- // Only load spatial index if provided (not stored in IndexedDB)
73
- if (transferables.spatialIndex?.byteLength) {
74
- this.spatialIndex = KDBush.from(transferables.spatialIndex)
75
- this.spatialIndexBuilt = true
76
- }
77
- this.bbox = transferables.bbox
78
- this.indexBuilt = true
79
- } else {
80
- super("node", new Ids(), new Tags(stringTable))
81
- this.lons = new RTA(Int32Array)
82
- this.lats = new RTA(Int32Array)
83
- // Spatial index uses Float64Array with degrees for geokdbush compatibility
84
- this.spatialIndex = new KDBush(0, 128, Float64Array, BufferConstructor)
85
- }
86
- }
87
-
88
- /**
89
- * Add a single node to the index.
90
- */
91
- addNode(node: OsmNode): number {
92
- const nodeIndex = this.addEntity(node.id, node.tags ?? {})
93
-
94
- const lonMicro = toMicroDegrees(node.lon)
95
- const latMicro = toMicroDegrees(node.lat)
96
-
97
- this.lons.push(lonMicro)
98
- this.lats.push(latMicro)
99
-
100
- if (node.lon < this.bbox[0]) this.bbox[0] = node.lon
101
- if (node.lat < this.bbox[1]) this.bbox[1] = node.lat
102
- if (node.lon > this.bbox[2]) this.bbox[2] = node.lon
103
- if (node.lat > this.bbox[3]) this.bbox[3] = node.lat
104
-
105
- return nodeIndex
106
- }
107
-
108
- /**
109
- * Add dense nodes from a PBF block.
110
- */
111
- addDenseNodes(
112
- dense: OsmPbfDenseNodes,
113
- block: OsmPbfBlock,
114
- blockStringIndexMap: Uint32Array,
115
- filter?: (node: OsmNode) => boolean,
116
- ): number {
117
- // PBF block already has offsets and granularity converted to degrees
118
- const lon_offset = block.lon_offset ?? 0
119
- const lat_offset = block.lat_offset ?? 0
120
- const granularity = block.granularity ?? 1e7
121
-
122
- const delta = {
123
- id: 0,
124
- lat: 0,
125
- lon: 0,
126
- timestamp: 0,
127
- changeset: 0,
128
- uid: 0,
129
- user_sid: 0,
130
- }
131
-
132
- const getStringTableIndex = (keyIndex: number) => {
133
- const key = dense.keys_vals[keyIndex]
134
- assertValue(key, "Block string key is undefined")
135
- const index = blockStringIndexMap[key]
136
- assertValue(index, "Block string not found")
137
- return index
138
- }
139
-
140
- let keysValsIndex = 0
141
- let added = 0
142
- for (let i = 0; i < dense.id.length; i++) {
143
- const idSid = dense.id[i]
144
- const latSid = dense.lat[i]
145
- const lonSid = dense.lon[i]
146
- assertValue(idSid, "ID SID is undefined")
147
- assertValue(latSid, "Latitude SID is undefined")
148
- assertValue(lonSid, "Longitude SID is undefined")
149
-
150
- delta.id += idSid
151
- delta.lat += latSid
152
- delta.lon += lonSid
153
-
154
- // Calculate degrees from PBF delta encoding
155
- const lon = lon_offset + delta.lon / granularity
156
- const lat = lat_offset + delta.lat / granularity
157
-
158
- // Convert to microdegrees for storage
159
- const lonMicro = toMicroDegrees(lon)
160
- const latMicro = toMicroDegrees(lat)
161
-
162
- const tagKeys: number[] = []
163
- const tagValues: number[] = []
164
- if (dense.keys_vals.length > 0) {
165
- while (dense.keys_vals[keysValsIndex] !== 0) {
166
- const key = getStringTableIndex(keysValsIndex)
167
- const val = getStringTableIndex(keysValsIndex + 1)
168
- if (key && val) {
169
- tagKeys.push(key)
170
- tagValues.push(val)
171
- }
172
- keysValsIndex += 2
173
- }
174
- keysValsIndex++
175
- }
176
-
177
- const shouldInclude = filter
178
- ? filter({
179
- id: delta.id,
180
- lon,
181
- lat,
182
- tags: this.tags.getTagsFromIndices(tagKeys, tagValues),
183
- })
184
- : true
185
- if (!shouldInclude) continue
186
-
187
- this.addEntity(delta.id, tagKeys, tagValues)
188
- this.lons.push(lonMicro)
189
- this.lats.push(latMicro)
190
-
191
- if (lon < this.bbox[0]) this.bbox[0] = lon
192
- if (lat < this.bbox[1]) this.bbox[1] = lat
193
- if (lon > this.bbox[2]) this.bbox[2] = lon
194
- if (lat > this.bbox[3]) this.bbox[3] = lat
195
- added++
196
- }
197
-
198
- return added
199
- }
200
-
201
- /**
202
- * Compact the internal arrays to free up memory.
203
- */
204
- buildEntityIndex() {
205
- this.lons.compact()
206
- this.lats.compact()
207
- }
208
-
209
- /**
210
- * Build the spatial index for nodes.
211
- * Spatial index stores degrees (Float64Array) for geokdbush compatibility.
212
- */
213
- buildSpatialIndex() {
214
- console.time("NodeIndex.buildSpatialIndex")
215
- // Use Float64Array with degrees for geokdbush geographic queries
216
- this.spatialIndex = new KDBush(
217
- this.size,
218
- 64,
219
- Float64Array,
220
- BufferConstructor,
221
- )
222
- for (let i = 0; i < this.size; i++) {
223
- // Convert microdegrees to degrees for spatial index
224
- const lon = microToDegrees(this.lons.at(i))
225
- const lat = microToDegrees(this.lats.at(i))
226
- this.spatialIndex.add(lon, lat)
227
- }
228
- this.spatialIndex.finish()
229
- this.spatialIndexBuilt = true
230
- console.timeEnd("NodeIndex.buildSpatialIndex")
231
- }
232
-
233
- /**
234
- * Check if the spatial index has been built.
235
- */
236
- hasSpatialIndex(): boolean {
237
- return this.spatialIndexBuilt
238
- }
239
-
240
- /**
241
- * Get the bounding box of all nodes.
242
- */
243
- getBbox(): GeoBbox2D {
244
- return this.bbox
245
- }
246
-
247
- /**
248
- * Get the bounding box of a specific node.
249
- */
250
- getEntityBbox(i: IdOrIndex): GeoBbox2D {
251
- const index = "index" in i ? i.index : this.ids.idOrIndex(i)[0]
252
- const lon = microToDegrees(this.lons.at(index))
253
- const lat = microToDegrees(this.lats.at(index))
254
- return [lon, lat, lon, lat] as GeoBbox2D
255
- }
256
-
257
- /**
258
- * Get the longitude and latitude of a specific node.
259
- */
260
- getNodeLonLat(i: IdOrIndex): [number, number] {
261
- const index = "index" in i ? i.index : this.ids.idOrIndex(i)[0]
262
- return [
263
- microToDegrees(this.lons.at(index)),
264
- microToDegrees(this.lats.at(index)),
265
- ]
266
- }
267
-
268
- /**
269
- * Get the full node entity.
270
- */
271
- getFullEntity(index: number, id: number, tags?: OsmTags): OsmNode {
272
- const [lon, lat] = this.getNodeLonLat({ index })
273
- if (tags) {
274
- return {
275
- id,
276
- lat,
277
- lon,
278
- tags,
279
- }
280
- }
281
- return {
282
- id,
283
- lat,
284
- lon,
285
- }
286
- }
287
-
288
- // Spatial operations
289
- /**
290
- * Find node indexes within a bounding box.
291
- */
292
- findIndexesWithinBbox(bbox: GeoBbox2D): number[] {
293
- // Spatial index stores degrees, so query with degrees directly
294
- return this.spatialIndex.range(bbox[0], bbox[1], bbox[2], bbox[3])
295
- }
296
-
297
- /**
298
- * Find node indexes within a radius of a point.
299
- * Uses geokdbush for proper great-circle distance calculations.
300
- * @param lon - Longitude in degrees.
301
- * @param lat - Latitude in degrees.
302
- * @param radiusKm - Radius in kilometers.
303
- * @returns Array of node indexes within the radius.
304
- */
305
- findIndexesWithinRadius(
306
- lon: number,
307
- lat: number,
308
- radiusKm: number,
309
- ): number[] {
310
- // Use geokdbush for proper geographic distance calculations
311
- return geoAround(
312
- this.spatialIndex,
313
- lon,
314
- lat,
315
- Number.POSITIVE_INFINITY,
316
- radiusKm,
317
- )
318
- }
319
-
320
- /**
321
- * Get nodes within a bounding box.
322
- * @param bbox - The bounding box to search within.
323
- * @param include - A function to filter nodes. If provided, only nodes for which the function returns true will be included.
324
- * @returns An object containing the IDs and positions of the nodes within the bounding box.
325
- */
326
- withinBbox(
327
- bbox: GeoBbox2D,
328
- include?: (i: number) => boolean,
329
- ): {
330
- ids: Float64Array
331
- positions: Float64Array
332
- } {
333
- console.time("Nodes.withinBbox")
334
- const nodeCandidates = this.findIndexesWithinBbox(bbox)
335
- const nodePositions = new Float64Array(nodeCandidates.length * 2)
336
- const ids = new Float64Array(nodeCandidates.length)
337
-
338
- let skipped = 0
339
- nodeCandidates.forEach((nodeIndex, i) => {
340
- if (include && !include(nodeIndex)) {
341
- skipped++
342
- return
343
- }
344
-
345
- const [lon, lat] = this.getNodeLonLat({ index: nodeIndex })
346
- ids[i - skipped] = this.ids.at(nodeIndex)
347
- nodePositions[(i - skipped) * 2] = lon
348
- nodePositions[(i - skipped) * 2 + 1] = lat
349
- })
350
- console.timeEnd("Nodes.withinBbox")
351
- return {
352
- ids: ids.subarray(0, nodeCandidates.length - skipped),
353
- positions: nodePositions.slice(0, (nodeCandidates.length - skipped) * 2),
354
- }
355
- }
356
-
357
- /**
358
- * Get transferable objects for passing to another thread.
359
- * Only includes spatialIndex if it has been built.
360
- */
361
- override transferables(): NodesTransferables {
362
- const base = {
363
- ...super.transferables(),
364
- lons: this.lons.array.buffer,
365
- lats: this.lats.array.buffer,
366
- bbox: this.bbox,
367
- }
368
- // Only include spatial index if it was built
369
- if (this.spatialIndexBuilt) {
370
- return { ...base, spatialIndex: this.spatialIndex.data }
371
- }
372
- return base
373
- }
374
-
375
- /**
376
- * Get the approximate memory requirements for a given number of nodes in bytes.
377
- */
378
- static getBytesRequired(count: number) {
379
- if (count === 0) return 0
380
- const indexBytes = (count < 65536 ? 2 : 4) * count
381
- // Spatial index stores coordinates in degrees (Float64Array)
382
- const coordsBytes = count * 2 * Float64Array.BYTES_PER_ELEMENT
383
- const padding = (8 - (indexBytes % 8)) % 8
384
- const spatialIndexBytes = 8 + indexBytes + coordsBytes + padding
385
-
386
- return (
387
- Ids.getBytesRequired(count) +
388
- Tags.getBytesRequired(count) +
389
- count * Int32Array.BYTES_PER_ELEMENT + // lons (stored in microdegrees)
390
- count * Int32Array.BYTES_PER_ELEMENT + // lats (stored in microdegrees)
391
- spatialIndexBytes
392
- )
393
- }
394
-
395
- /**
396
- * Update a ContentHasher with node-specific data (coordinates).
397
- */
398
- override updateHash(hasher: ContentHasher): ContentHasher {
399
- return super
400
- .updateHash(hasher)
401
- .update(this.lons.array)
402
- .update(this.lats.array)
403
- }
30
+ /**
31
+ * Coordinates are stored as integer microdegrees (Int32Array).
32
+ * Use OSM_COORD_SCALE (1e7) to convert between degrees and microdegrees.
33
+ */
34
+ private lons: RTA<Int32Array>;
35
+ private lats: RTA<Int32Array>;
36
+ // Bbox stored in microdegrees internally
37
+ private bbox: [minLon: number, minLat: number, maxLon: number, maxLat: number] = [
38
+ Number.MAX_SAFE_INTEGER,
39
+ Number.MAX_SAFE_INTEGER,
40
+ Number.MIN_SAFE_INTEGER,
41
+ Number.MIN_SAFE_INTEGER,
42
+ ];
43
+ // Spatial index stores coordinates in degrees (Float64Array) for geokdbush compatibility
44
+ private spatialIndex: KDBush = new KDBush(0, 128, Float64Array, BufferConstructor);
45
+ // Track if spatial index was properly built (vs default empty)
46
+ private spatialIndexBuilt = false;
47
+
48
+ /**
49
+ * Create a new Nodes index.
50
+ */
51
+ constructor(stringTable: StringTable, transferables?: NodesTransferables) {
52
+ if (transferables) {
53
+ super("node", new Ids(transferables), new Tags(stringTable, transferables));
54
+ this.lons = RTA.from(Int32Array, transferables.lons);
55
+ this.lats = RTA.from(Int32Array, transferables.lats);
56
+ // Only load spatial index if provided (not stored in IndexedDB)
57
+ if (transferables.spatialIndex?.byteLength) {
58
+ this.spatialIndex = KDBush.from(transferables.spatialIndex);
59
+ this.spatialIndexBuilt = true;
60
+ }
61
+ this.bbox = transferables.bbox;
62
+ this.indexBuilt = true;
63
+ } else {
64
+ super("node", new Ids(), new Tags(stringTable));
65
+ this.lons = new RTA(Int32Array);
66
+ 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
+ }
70
+ }
71
+
72
+ /**
73
+ * Add a single node to the index.
74
+ */
75
+ addNode(node: OsmNode): number {
76
+ const nodeIndex = this.addEntity(node.id, node.tags ?? {});
77
+
78
+ const lonMicro = toMicroDegrees(node.lon);
79
+ const latMicro = toMicroDegrees(node.lat);
80
+
81
+ this.lons.push(lonMicro);
82
+ this.lats.push(latMicro);
83
+
84
+ if (node.lon < this.bbox[0]) this.bbox[0] = node.lon;
85
+ if (node.lat < this.bbox[1]) this.bbox[1] = node.lat;
86
+ if (node.lon > this.bbox[2]) this.bbox[2] = node.lon;
87
+ if (node.lat > this.bbox[3]) this.bbox[3] = node.lat;
88
+
89
+ return nodeIndex;
90
+ }
91
+
92
+ /**
93
+ * Add dense nodes from a PBF block.
94
+ */
95
+ addDenseNodes(
96
+ dense: OsmPbfDenseNodes,
97
+ block: OsmPbfBlock,
98
+ blockStringIndexMap: Uint32Array,
99
+ filter?: (node: OsmNode) => boolean,
100
+ ): number {
101
+ // PBF block already has offsets and granularity converted to degrees
102
+ const lon_offset = block.lon_offset ?? 0;
103
+ const lat_offset = block.lat_offset ?? 0;
104
+ const granularity = block.granularity ?? 1e7;
105
+
106
+ const delta = {
107
+ id: 0,
108
+ lat: 0,
109
+ lon: 0,
110
+ timestamp: 0,
111
+ changeset: 0,
112
+ uid: 0,
113
+ user_sid: 0,
114
+ };
115
+
116
+ const getStringTableIndex = (keyIndex: number) => {
117
+ const key = dense.keys_vals[keyIndex];
118
+ assertValue(key, "Block string key is undefined");
119
+ const index = blockStringIndexMap[key];
120
+ assertValue(index, "Block string not found");
121
+ return index;
122
+ };
123
+
124
+ let keysValsIndex = 0;
125
+ let added = 0;
126
+ for (let i = 0; i < dense.id.length; i++) {
127
+ const idSid = dense.id[i];
128
+ const latSid = dense.lat[i];
129
+ const lonSid = dense.lon[i];
130
+ assertValue(idSid, "ID SID is undefined");
131
+ assertValue(latSid, "Latitude SID is undefined");
132
+ assertValue(lonSid, "Longitude SID is undefined");
133
+
134
+ delta.id += idSid;
135
+ delta.lat += latSid;
136
+ delta.lon += lonSid;
137
+
138
+ // Calculate degrees from PBF delta encoding
139
+ const lon = lon_offset + delta.lon / granularity;
140
+ const lat = lat_offset + delta.lat / granularity;
141
+
142
+ // Convert to microdegrees for storage
143
+ const lonMicro = toMicroDegrees(lon);
144
+ const latMicro = toMicroDegrees(lat);
145
+
146
+ const tagKeys: number[] = [];
147
+ const tagValues: number[] = [];
148
+ if (dense.keys_vals.length > 0) {
149
+ while (dense.keys_vals[keysValsIndex] !== 0) {
150
+ const key = getStringTableIndex(keysValsIndex);
151
+ const val = getStringTableIndex(keysValsIndex + 1);
152
+ if (key && val) {
153
+ tagKeys.push(key);
154
+ tagValues.push(val);
155
+ }
156
+ keysValsIndex += 2;
157
+ }
158
+ keysValsIndex++;
159
+ }
160
+
161
+ const shouldInclude = filter
162
+ ? filter({
163
+ id: delta.id,
164
+ lon,
165
+ lat,
166
+ tags: this.tags.getTagsFromIndices(tagKeys, tagValues),
167
+ })
168
+ : true;
169
+ if (!shouldInclude) continue;
170
+
171
+ this.addEntity(delta.id, tagKeys, tagValues);
172
+ this.lons.push(lonMicro);
173
+ this.lats.push(latMicro);
174
+
175
+ if (lon < this.bbox[0]) this.bbox[0] = lon;
176
+ if (lat < this.bbox[1]) this.bbox[1] = lat;
177
+ if (lon > this.bbox[2]) this.bbox[2] = lon;
178
+ if (lat > this.bbox[3]) this.bbox[3] = lat;
179
+ added++;
180
+ }
181
+
182
+ return added;
183
+ }
184
+
185
+ /**
186
+ * Compact the internal arrays to free up memory.
187
+ */
188
+ buildEntityIndex() {
189
+ this.lons.compact();
190
+ this.lats.compact();
191
+ }
192
+
193
+ /**
194
+ * Build the spatial index for nodes.
195
+ * Spatial index stores degrees (Float64Array) for geokdbush compatibility.
196
+ */
197
+ buildSpatialIndex() {
198
+ console.time("NodeIndex.buildSpatialIndex");
199
+ // Use Float64Array with degrees for geokdbush geographic queries
200
+ this.spatialIndex = new KDBush(this.size, 64, Float64Array, BufferConstructor);
201
+ for (let i = 0; i < this.size; i++) {
202
+ // Convert microdegrees to degrees for spatial index
203
+ const lon = microToDegrees(this.lons.at(i));
204
+ const lat = microToDegrees(this.lats.at(i));
205
+ this.spatialIndex.add(lon, lat);
206
+ }
207
+ this.spatialIndex.finish();
208
+ this.spatialIndexBuilt = true;
209
+ console.timeEnd("NodeIndex.buildSpatialIndex");
210
+ }
211
+
212
+ /**
213
+ * Check if the spatial index has been built.
214
+ */
215
+ hasSpatialIndex(): boolean {
216
+ return this.spatialIndexBuilt;
217
+ }
218
+
219
+ /**
220
+ * Get the bounding box of all nodes.
221
+ */
222
+ getBbox(): GeoBbox2D {
223
+ return this.bbox;
224
+ }
225
+
226
+ /**
227
+ * Get the bounding box of a specific node.
228
+ */
229
+ getEntityBbox(i: IdOrIndex): GeoBbox2D {
230
+ const index = "index" in i ? i.index : this.ids.idOrIndex(i)[0];
231
+ const lon = microToDegrees(this.lons.at(index));
232
+ const lat = microToDegrees(this.lats.at(index));
233
+ return [lon, lat, lon, lat] as GeoBbox2D;
234
+ }
235
+
236
+ /**
237
+ * Get the longitude and latitude of a specific node.
238
+ * ID lookups return `null` when the node is not present; index lookups remain strict.
239
+ */
240
+ getNodeLonLat(i: { index: number }): [number, number];
241
+ getNodeLonLat(i: { id: number }): [number, number] | null;
242
+ getNodeLonLat(i: IdOrIndex): [number, number] | null {
243
+ const index = "index" in i ? i.index : this.ids.idOrIndex(i)[0];
244
+ if (index === -1) return null;
245
+ return [microToDegrees(this.lons.at(index)), microToDegrees(this.lats.at(index))];
246
+ }
247
+
248
+ /**
249
+ * Get the full node entity.
250
+ */
251
+ getFullEntity(index: number, id: number, tags?: OsmTags): OsmNode {
252
+ const [lon, lat] = this.getNodeLonLat({ index });
253
+ if (tags) {
254
+ return {
255
+ id,
256
+ lat,
257
+ lon,
258
+ tags,
259
+ };
260
+ }
261
+ return {
262
+ id,
263
+ lat,
264
+ lon,
265
+ };
266
+ }
267
+
268
+ // Spatial operations
269
+ /**
270
+ * Find node indexes within a bounding box.
271
+ */
272
+ findIndexesWithinBbox(bbox: GeoBbox2D): number[] {
273
+ // Spatial index stores degrees, so query with degrees directly
274
+ return this.spatialIndex.range(bbox[0], bbox[1], bbox[2], bbox[3]);
275
+ }
276
+
277
+ /**
278
+ * Find node indexes within a radius of a point.
279
+ * Uses geokdbush for proper great-circle distance calculations.
280
+ * @param lon - Longitude in degrees.
281
+ * @param lat - Latitude in degrees.
282
+ * @param radiusKm - Radius in kilometers.
283
+ * @returns Array of node indexes within the radius.
284
+ */
285
+ findIndexesWithinRadius(lon: number, lat: number, radiusKm: number): number[] {
286
+ // Use geokdbush for proper geographic distance calculations
287
+ return geoAround(this.spatialIndex, lon, lat, Number.POSITIVE_INFINITY, radiusKm);
288
+ }
289
+
290
+ /**
291
+ * Get nodes within a bounding box.
292
+ * @param bbox - The bounding box to search within.
293
+ * @param include - A function to filter nodes. If provided, only nodes for which the function returns true will be included.
294
+ * @returns An object containing the IDs and positions of the nodes within the bounding box.
295
+ */
296
+ withinBbox(
297
+ bbox: GeoBbox2D,
298
+ include?: (i: number) => boolean,
299
+ ): {
300
+ ids: Float64Array;
301
+ positions: Float64Array;
302
+ } {
303
+ console.time("Nodes.withinBbox");
304
+ const nodeCandidates = this.findIndexesWithinBbox(bbox);
305
+ const nodePositions = new Float64Array(nodeCandidates.length * 2);
306
+ const ids = new Float64Array(nodeCandidates.length);
307
+
308
+ let skipped = 0;
309
+ nodeCandidates.forEach((nodeIndex, i) => {
310
+ if (include && !include(nodeIndex)) {
311
+ skipped++;
312
+ return;
313
+ }
314
+
315
+ const [lon, lat] = this.getNodeLonLat({ index: nodeIndex });
316
+ ids[i - skipped] = this.ids.at(nodeIndex);
317
+ nodePositions[(i - skipped) * 2] = lon;
318
+ nodePositions[(i - skipped) * 2 + 1] = lat;
319
+ });
320
+ console.timeEnd("Nodes.withinBbox");
321
+ return {
322
+ ids: ids.subarray(0, nodeCandidates.length - skipped),
323
+ positions: nodePositions.slice(0, (nodeCandidates.length - skipped) * 2),
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Get transferable objects for passing to another thread.
329
+ * Only includes spatialIndex if it has been built.
330
+ */
331
+ override transferables(): NodesTransferables {
332
+ const base = {
333
+ ...super.transferables(),
334
+ lons: this.lons.array.buffer,
335
+ lats: this.lats.array.buffer,
336
+ bbox: this.bbox,
337
+ };
338
+ // Only include spatial index if it was built
339
+ if (this.spatialIndexBuilt) {
340
+ return { ...base, spatialIndex: this.spatialIndex.data };
341
+ }
342
+ return base;
343
+ }
344
+
345
+ /**
346
+ * Get the approximate memory requirements for a given number of nodes in bytes.
347
+ */
348
+ static getBytesRequired(count: number) {
349
+ 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
+
356
+ return (
357
+ Ids.getBytesRequired(count) +
358
+ Tags.getBytesRequired(count) +
359
+ count * Int32Array.BYTES_PER_ELEMENT + // lons (stored in microdegrees)
360
+ count * Int32Array.BYTES_PER_ELEMENT + // lats (stored in microdegrees)
361
+ spatialIndexBytes
362
+ );
363
+ }
364
+
365
+ /**
366
+ * Update a ContentHasher with node-specific data (coordinates).
367
+ */
368
+ override updateHash(hasher: ContentHasher): ContentHasher {
369
+ return super.updateHash(hasher).update(this.lons.array).update(this.lats.array);
370
+ }
404
371
  }