@osmix/core 0.1.10 → 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 (97) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +136 -72
  4. package/dist/contracts.d.ts +27 -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 +22 -2
  9. package/dist/entities.d.ts.map +1 -1
  10. package/dist/entities.js +54 -10
  11. package/dist/entities.js.map +1 -1
  12. package/dist/ids.d.ts +20 -5
  13. package/dist/ids.d.ts.map +1 -1
  14. package/dist/ids.js +87 -29
  15. package/dist/ids.js.map +1 -1
  16. package/dist/index.d.ts +7 -4
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +6 -4
  19. package/dist/index.js.map +1 -1
  20. package/dist/indirect-kd-index.d.ts +28 -0
  21. package/dist/indirect-kd-index.d.ts.map +1 -0
  22. package/dist/indirect-kd-index.js +163 -0
  23. package/dist/indirect-kd-index.js.map +1 -0
  24. package/dist/limits.d.ts +30 -0
  25. package/dist/limits.d.ts.map +1 -0
  26. package/dist/limits.js +51 -0
  27. package/dist/limits.js.map +1 -0
  28. package/dist/mocks.d.ts.map +1 -1
  29. package/dist/mocks.js.map +1 -1
  30. package/dist/nodes.d.ts +36 -15
  31. package/dist/nodes.d.ts.map +1 -1
  32. package/dist/nodes.js +185 -62
  33. package/dist/nodes.js.map +1 -1
  34. package/dist/osm.d.ts +60 -4
  35. package/dist/osm.d.ts.map +1 -1
  36. package/dist/osm.js +32 -6
  37. package/dist/osm.js.map +1 -1
  38. package/dist/relations.d.ts +11 -4
  39. package/dist/relations.d.ts.map +1 -1
  40. package/dist/relations.js +73 -29
  41. package/dist/relations.js.map +1 -1
  42. package/dist/renumber.d.ts +23 -0
  43. package/dist/renumber.d.ts.map +1 -0
  44. package/dist/renumber.js +64 -0
  45. package/dist/renumber.js.map +1 -0
  46. package/dist/stringtable.d.ts +2 -1
  47. package/dist/stringtable.d.ts.map +1 -1
  48. package/dist/stringtable.js +9 -14
  49. package/dist/stringtable.js.map +1 -1
  50. package/dist/tags.d.ts +45 -12
  51. package/dist/tags.d.ts.map +1 -1
  52. package/dist/tags.js +153 -27
  53. package/dist/tags.js.map +1 -1
  54. package/dist/typed-arrays.d.ts +63 -48
  55. package/dist/typed-arrays.d.ts.map +1 -1
  56. package/dist/typed-arrays.js +184 -107
  57. package/dist/typed-arrays.js.map +1 -1
  58. package/dist/ways.d.ts +52 -3
  59. package/dist/ways.d.ts.map +1 -1
  60. package/dist/ways.js +223 -46
  61. package/dist/ways.js.map +1 -1
  62. package/package.json +45 -31
  63. package/src/contracts.ts +29 -0
  64. package/src/entities.ts +242 -194
  65. package/src/ids.ts +278 -235
  66. package/src/index.ts +22 -13
  67. package/src/indirect-kd-index.ts +212 -0
  68. package/src/limits.ts +73 -0
  69. package/src/mocks.ts +114 -113
  70. package/src/nodes.ts +538 -397
  71. package/src/osm.ts +282 -207
  72. package/src/relations.ts +580 -558
  73. package/src/renumber.ts +80 -0
  74. package/src/stringtable.ts +189 -184
  75. package/src/tags.ts +406 -272
  76. package/src/typed-arrays.ts +307 -205
  77. package/src/types/geoflatbush.d.ts +28 -33
  78. package/src/ways.ts +577 -406
  79. package/test/entity-index-errors.test.ts +41 -0
  80. package/test/ids.bench.ts +33 -0
  81. package/test/ids.test.ts +122 -0
  82. package/test/limits.test.ts +83 -0
  83. package/test/memory.test.ts +29 -30
  84. package/test/no-shared-array-buffer.test.ts +41 -0
  85. package/test/nodes-spatial-index.test.ts +261 -0
  86. package/test/osm-bbox.test.ts +30 -0
  87. package/test/relations.bench.ts +32 -0
  88. package/test/relations.test.ts +124 -0
  89. package/test/renumber.test.ts +54 -0
  90. package/test/spatial-index-rebuild.test.ts +33 -0
  91. package/test/storage-compaction.test.ts +269 -0
  92. package/test/tags-search.test.ts +89 -56
  93. package/test/typed-arrays.test.ts +252 -132
  94. package/tsconfig.build.json +6 -6
  95. package/tsconfig.json +7 -7
  96. package/src/types/geokdbush.d.ts +0 -37
  97. package/src/types/kdbush.d.ts +0 -47
package/src/relations.ts CHANGED
@@ -1,564 +1,586 @@
1
- import type { OsmPbfRelation } from "@osmix/pbf"
2
- import { assertValue } from "@osmix/shared/assert"
3
- import type { ContentHasher } from "@osmix/shared/content-hasher"
4
- import {
5
- buildRelationLineStrings,
6
- collectRelationPoints,
7
- isAreaRelation,
8
- isLineRelation,
9
- isPointRelation,
10
- isSuperRelation,
11
- resolveRelationMembers,
12
- } from "@osmix/shared/relation-kind"
13
- import { buildRelationRings } from "@osmix/shared/relation-multipolygon"
1
+ import { buildRelationRings } from "@osmix/geo/relation-multipolygon";
2
+ import type { OsmPbfRelation } from "@osmix/pbf";
3
+ import { assertValue } from "@osmix/shared/assert";
4
+ import { BitSet } from "@osmix/shared/bit-set";
5
+ import type { ContentHasher } from "@osmix/shared/content-hasher";
14
6
  import type {
15
- GeoBbox2D,
16
- LonLat,
17
- OsmEntityType,
18
- OsmRelation,
19
- OsmRelationMember,
20
- OsmTags,
21
- } from "@osmix/shared/types"
22
- import { bboxFromLonLats } from "@osmix/shared/utils"
23
- import Flatbush from "flatbush"
24
- import { around as geoAround } from "geoflatbush"
25
- import { Entities, type EntitiesTransferables } from "./entities.ts"
26
- import { type IdOrIndex, Ids } from "./ids.ts"
27
- import type { Nodes } from "./nodes.ts"
28
- import type StringTable from "./stringtable.ts"
29
- import { Tags } from "./tags.ts"
7
+ GeoBbox2D,
8
+ LonLat,
9
+ OsmEntityType,
10
+ OsmRelation,
11
+ OsmRelationMember,
12
+ OsmTags,
13
+ } from "@osmix/types";
14
+ import {
15
+ buildRelationLineStrings,
16
+ collectRelationPoints,
17
+ isAreaRelation,
18
+ isLineRelation,
19
+ isPointRelation,
20
+ isSuperRelation,
21
+ resolveRelationMembers,
22
+ } from "@osmix/types/relation-kind";
23
+ import { bboxFromLonLats } from "@osmix/types/utils";
24
+ import Flatbush from "flatbush";
25
+ import { around as geoAround } from "geoflatbush";
26
+
27
+ import { Entities, type EntitiesTransferables, isValidSpatialBbox } from "./entities.ts";
28
+ import { type IdOrIndex, Ids } from "./ids.ts";
29
+ import { MAX_RELATION_MEMBERS, MAX_UINT32_OFFSET, assertCapacity } from "./limits.ts";
30
+ import type { Nodes } from "./nodes.ts";
31
+ import type StringTable from "./stringtable.ts";
32
+ import { Tags } from "./tags.ts";
30
33
  import {
31
- BufferConstructor,
32
- type BufferType,
33
- IdArrayType,
34
- ResizeableTypedArray as RTA,
35
- } from "./typed-arrays.ts"
36
- import type { Ways } from "./ways.ts"
37
-
38
- const RELATION_MEMBER_TYPES: OsmEntityType[] = ["node", "way", "relation"]
39
-
40
- export interface RelationsTransferables<T extends BufferType = BufferType>
41
- extends EntitiesTransferables<T> {
42
- memberStart: T
43
- memberCount: T
44
- memberRefs: T
45
- memberTypes: T
46
- memberRoles: T
47
- bbox: T
48
- /** Optional - can be rebuilt via buildSpatialIndex() */
49
- spatialIndex?: T
34
+ BufferConstructor,
35
+ type BufferType,
36
+ IdArrayType,
37
+ ResizeableTypedArray as RTA,
38
+ } from "./typed-arrays.ts";
39
+ import type { Ways } from "./ways.ts";
40
+
41
+ const RELATION_MEMBER_TYPES: OsmEntityType[] = ["node", "way", "relation"];
42
+
43
+ export interface RelationsTransferables<
44
+ T extends BufferType = BufferType,
45
+ > extends EntitiesTransferables<T> {
46
+ memberStart: T;
47
+ memberCount: T;
48
+ memberRefs: T;
49
+ memberTypes: T;
50
+ memberRoles: T;
51
+ bbox: T;
52
+ /** Optional - can be rebuilt via buildSpatialIndex() */
53
+ spatialIndex?: T;
50
54
  }
51
55
 
52
56
  export class Relations extends Entities<OsmRelation> {
53
- private stringTable: StringTable
54
-
55
- private memberStart: RTA<Uint32Array>
56
- private memberCount: RTA<Uint16Array> // Maximum 65,535 members per relation
57
-
58
- // Store the ID of the member because relations have other relations as members.
59
- private memberRefs: RTA<Float64Array>
60
- private memberTypes: RTA<Uint8Array>
61
- private memberRoles: RTA<Uint32Array>
62
-
63
- // Spatial index
64
- private spatialIndex: Flatbush = new Flatbush(1)
65
- // Track if spatial index was properly built (vs default empty)
66
- private spatialIndexBuilt = false
67
-
68
- // Bounding box of the relation in geographic coordinates
69
- private bbox: RTA<Float64Array>
70
-
71
- // Node and Way indexes
72
- private nodes: Nodes
73
- private ways: Ways
74
-
75
- /**
76
- * Create a new Relations index.
77
- */
78
- constructor(
79
- stringTable: StringTable,
80
- nodes: Nodes,
81
- ways: Ways,
82
- transferables?: RelationsTransferables,
83
- ) {
84
- if (transferables) {
85
- super(
86
- "relation",
87
- new Ids(transferables),
88
- new Tags(stringTable, transferables),
89
- )
90
- this.memberStart = RTA.from(Uint32Array, transferables.memberStart)
91
- this.memberCount = RTA.from(Uint16Array, transferables.memberCount)
92
- this.memberRefs = RTA.from(IdArrayType, transferables.memberRefs)
93
- this.memberTypes = RTA.from(Uint8Array, transferables.memberTypes)
94
- this.memberRoles = RTA.from(Uint32Array, transferables.memberRoles)
95
- this.bbox = RTA.from(Float64Array, transferables.bbox)
96
- // Only load spatial index if provided (not stored in IndexedDB)
97
- if (transferables.spatialIndex?.byteLength) {
98
- this.spatialIndex = Flatbush.from(transferables.spatialIndex)
99
- this.spatialIndexBuilt = true
100
- }
101
- this.indexBuilt = true
102
- } else {
103
- super("relation", new Ids(), new Tags(stringTable))
104
- this.memberStart = new RTA(Uint32Array)
105
- this.memberCount = new RTA(Uint16Array)
106
- this.memberRefs = new RTA(IdArrayType)
107
- this.memberTypes = new RTA(Uint8Array)
108
- this.memberRoles = new RTA(Uint32Array)
109
- this.bbox = new RTA(Float64Array)
110
- }
111
- this.nodes = nodes
112
- this.ways = ways
113
- this.stringTable = stringTable
114
- }
115
-
116
- /**
117
- * Add a single relation to the index.
118
- */
119
- addRelation(relation: OsmRelation) {
120
- const relationIndex = this.addEntity(relation.id, relation.tags ?? {})
121
- this.memberStart.push(this.memberRefs.length)
122
- this.memberCount.push(relation.members.length)
123
- for (const member of relation.members) {
124
- this.memberRefs.push(member.ref)
125
- this.memberTypes.push(RELATION_MEMBER_TYPES.indexOf(member.type))
126
- this.memberRoles.push(this.stringTable.add(member.role ?? ""))
127
- }
128
- return relationIndex
129
- }
130
-
131
- /**
132
- * Bulk add relations directly from a PBF PrimitiveBlock.
133
- */
134
- addRelations(
135
- relations: OsmPbfRelation[],
136
- blockStringIndexMap: Uint32Array,
137
- filter?: (relation: OsmRelation) => OsmRelation | null,
138
- ): number {
139
- const blockToStringTable = (k: number) => {
140
- const index = blockStringIndexMap[k]
141
- if (index === undefined) throw Error("Tag key not found")
142
- return index
143
- }
144
-
145
- let added = 0
146
- for (const relation of relations) {
147
- const members: OsmRelationMember[] = []
148
- const memberRefs: number[] = []
149
- const memberTypes: number[] = []
150
- const memberRoles: number[] = []
151
-
152
- let refId = 0
153
- for (let i = 0; i < relation.memids.length; i++) {
154
- const memid = relation.memids[i]
155
- const roleSid = relation.roles_sid[i]
156
- const typeIndex = relation.types[i]
157
- assertValue(memid, "Relation member ID is undefined")
158
- assertValue(roleSid, "Relation member role SID is undefined")
159
- assertValue(typeIndex, "Relation member type is undefined")
160
-
161
- refId += memid
162
- const roleIndex = blockToStringTable(roleSid)
163
- const type = RELATION_MEMBER_TYPES[typeIndex]
164
- assertValue(type, "Relation member type not found")
165
-
166
- if (filter) {
167
- members.push({
168
- type,
169
- ref: refId,
170
- role: this.stringTable.get(roleIndex),
171
- })
172
- }
173
-
174
- memberRefs.push(refId)
175
- memberTypes.push(typeIndex)
176
- memberRoles.push(roleIndex)
177
- }
178
-
179
- const tagKeys: number[] = relation.keys.map(blockToStringTable)
180
- const tagValues: number[] = relation.vals.map(blockToStringTable)
181
-
182
- const filteredRelation = filter
183
- ? filter({
184
- id: relation.id,
185
- members,
186
- tags: this.tags.getTagsFromIndices(tagKeys, tagValues),
187
- })
188
- : null
189
- if (filter && filteredRelation === null) continue
190
- added++
191
-
192
- this.addEntity(relation.id, tagKeys, tagValues)
193
- this.memberStart.push(this.memberRefs.length)
194
- this.memberCount.push(
195
- filteredRelation?.members.length ?? memberRefs.length,
196
- )
197
- this.memberRefs.pushMany(
198
- filteredRelation?.members.map((m) => m.ref) ?? memberRefs,
199
- )
200
- this.memberTypes.pushMany(
201
- filteredRelation?.members.map((m) =>
202
- RELATION_MEMBER_TYPES.indexOf(m.type),
203
- ) ?? memberTypes,
204
- )
205
- this.memberRoles.pushMany(
206
- filteredRelation?.members.map((m) =>
207
- this.stringTable.add(m.role ?? ""),
208
- ) ?? memberRoles,
209
- )
210
- }
211
- return added
212
- }
213
-
214
- /**
215
- * Compact the internal arrays to free up memory.
216
- */
217
- buildEntityIndex() {
218
- this.memberStart.compact()
219
- this.memberCount.compact()
220
- this.memberRefs.compact()
221
- this.memberTypes.compact()
222
- this.memberRoles.compact()
223
- }
224
-
225
- /**
226
- * Build the spatial index for relations.
227
- * Handles nested relations by resolving all descendant nodes and ways.
228
- * If bbox data already exists (e.g., loaded from storage), reuses it.
229
- */
230
- buildSpatialIndex() {
231
- if (!this.nodes.isReady()) throw Error("Node index is not ready.")
232
- if (!this.ways.isReady()) throw Error("Way index is not ready.")
233
- if (this.size === 0) return this.spatialIndex
234
- console.time("RelationIndex.buildSpatialIndex")
235
-
236
- this.spatialIndex = new Flatbush(
237
- this.size,
238
- 128,
239
- Float64Array,
240
- BufferConstructor,
241
- )
242
-
243
- // If bbox already has data (loaded from storage), use it directly
244
- const hasBboxData = this.bbox.length >= this.size * 4
245
- for (let i = 0; i < this.size; i++) {
246
- let minX: number
247
- let minY: number
248
- let maxX: number
249
- let maxY: number
250
-
251
- if (hasBboxData) {
252
- // Use stored bbox values
253
- minX = this.bbox.at(i * 4)
254
- minY = this.bbox.at(i * 4 + 1)
255
- maxX = this.bbox.at(i * 4 + 2)
256
- maxY = this.bbox.at(i * 4 + 3)
257
- } else {
258
- // Calculate bbox from coordinates
259
- const lls = this.collectRelationCoordinates(i)
260
- const bbox = bboxFromLonLats(lls)
261
- minX = bbox[0]
262
- minY = bbox[1]
263
- maxX = bbox[2]
264
- maxY = bbox[3]
265
- this.bbox.push(minX)
266
- this.bbox.push(minY)
267
- this.bbox.push(maxX)
268
- this.bbox.push(maxY)
269
- }
270
- this.spatialIndex.add(minX, minY, maxX, maxY)
271
- }
272
- if (!hasBboxData) {
273
- this.bbox.compact()
274
- }
275
- this.spatialIndex.finish()
276
- this.spatialIndexBuilt = true
277
- console.timeEnd("RelationIndex.buildSpatialIndex")
278
- return this.spatialIndex
279
- }
280
-
281
- /**
282
- * Check if the spatial index has been built.
283
- */
284
- hasSpatialIndex(): boolean {
285
- return this.spatialIndexBuilt
286
- }
287
-
288
- /**
289
- * Collect all coordinates from a relation, including nested relations.
290
- * Used for building bounding boxes and spatial indexes.
291
- */
292
- private collectRelationCoordinates(index: number): LonLat[] {
293
- const lls: LonLat[] = []
294
- const relation = this.getByIndex(index)
295
-
296
- // Resolve nested relations to get all descendant nodes and ways
297
- const resolved = resolveRelationMembers(
298
- relation,
299
- (relId) => {
300
- const relIndex = this.ids.getIndexFromId(relId)
301
- if (relIndex === -1) return null
302
- return this.getByIndex(relIndex)
303
- },
304
- 10, // max depth
305
- )
306
-
307
- // Collect coordinates from resolved nodes
308
- for (const nodeId of resolved.nodes) {
309
- const ll = this.nodes.getNodeLonLat({ id: nodeId })
310
- if (ll) lls.push(ll)
311
- }
312
-
313
- // Collect coordinates from resolved ways
314
- for (const wayId of resolved.ways) {
315
- const wayIndex = this.ways.ids.getIndexFromId(wayId)
316
- if (wayIndex === -1) continue
317
- const wayPositions = this.ways.getCoordinates(wayIndex)
318
- lls.push(...wayPositions)
319
- }
320
-
321
- return lls
322
- }
323
-
324
- /**
325
- * Get the bounding box of a relation.
326
- */
327
- getEntityBbox(i: IdOrIndex): GeoBbox2D {
328
- const index = "index" in i ? i.index : this.ids.idOrIndex(i)[0]
329
- return [
330
- this.bbox.at(index * 4),
331
- this.bbox.at(index * 4 + 1),
332
- this.bbox.at(index * 4 + 2),
333
- this.bbox.at(index * 4 + 3),
334
- ]
335
- }
336
-
337
- /**
338
- * Get the full relation entity.
339
- */
340
- getFullEntity(index: number, id: number, tags?: OsmTags): OsmRelation {
341
- return {
342
- id,
343
- members: this.getMembersByIndex(index),
344
- tags,
345
- }
346
- }
347
-
348
- /**
349
- * Get the members of a relation.
350
- */
351
- getMembersByIndex(
352
- index: number,
353
- relationMemberTypes = RELATION_MEMBER_TYPES,
354
- ) {
355
- const start = this.memberStart.at(index)
356
- const count = this.memberCount.at(index)
357
- const members: OsmRelationMember[] = []
358
- for (let i = start; i < start + count; i++) {
359
- const ref = this.memberRefs.at(i)
360
- const type = RELATION_MEMBER_TYPES[this.memberTypes.at(i)]
361
- if (type === undefined) throw Error(`Member type not found: ${i}`)
362
- if (!relationMemberTypes.includes(type)) continue
363
- const role = this.stringTable.get(this.memberRoles.at(i))
364
- members.push({ ref, type, role })
365
- }
366
- return members
367
- }
368
-
369
- /**
370
- * Check if a relation includes a specific member.
371
- */
372
- includesMember(
373
- index: number,
374
- memberRef: number,
375
- memberType: OsmEntityType,
376
- memberRole?: string,
377
- ) {
378
- const start = this.memberStart.at(index)
379
- const count = this.memberCount.at(index)
380
- for (let i = start; i < start + count; i++) {
381
- const type = RELATION_MEMBER_TYPES[this.memberTypes.at(i)]
382
- if (type !== memberType) continue
383
- const ref = this.memberRefs.at(i)
384
- if (ref !== memberRef) continue
385
- if (
386
- memberRole !== undefined &&
387
- this.stringTable.get(this.memberRoles.at(i)) !== memberRole
388
- )
389
- continue
390
- return true
391
- }
392
- return false
393
- }
394
-
395
- /**
396
- * Get all way IDs that are members of relations, including nested relations.
397
- * Used to exclude these ways from individual rendering.
398
- */
399
- getWayMemberIds(): Set<number> {
400
- const wayIds = new Set<number>()
401
- for (let i = 0; i < this.size; i++) {
402
- const relation = this.getByIndex(i)
403
- const resolved = resolveRelationMembers(
404
- relation,
405
- (relId) => {
406
- const relIndex = this.ids.getIndexFromId(relId)
407
- if (relIndex === -1) return null
408
- return this.getByIndex(relIndex)
409
- },
410
- 10, // max depth
411
- )
412
- for (const wayId of resolved.ways) {
413
- wayIds.add(wayId)
414
- }
415
- }
416
- return wayIds
417
- }
418
-
419
- /**
420
- * Get relation geometry based on its kind.
421
- * Returns coordinates suitable for rendering based on relation type.
422
- * @param index - Relation index
423
- * @returns Object with geometry data based on relation kind
424
- */
425
- getRelationGeometry(index: number): {
426
- points?: LonLat[]
427
- lineStrings?: LonLat[][]
428
- rings?: LonLat[][][]
429
- } {
430
- const relation = this.getByIndex(index)
431
- if (isAreaRelation(relation)) {
432
- return {
433
- rings: buildRelationRings(
434
- relation,
435
- (ref) => this.ways.getById(ref),
436
- (id) => this.nodes.getNodeLonLat({ id }),
437
- ),
438
- }
439
- }
440
-
441
- // Point relations
442
- if (isPointRelation(relation)) {
443
- return {
444
- points: collectRelationPoints(relation, (id) =>
445
- this.nodes.getNodeLonLat({ id }),
446
- ),
447
- }
448
- }
449
-
450
- // Line relations
451
- if (isLineRelation(relation)) {
452
- return {
453
- lineStrings: buildRelationLineStrings(
454
- relation,
455
- (ref) => this.ways.getById(ref),
456
- (id) => this.nodes.getNodeLonLat({ id }),
457
- ),
458
- }
459
- }
460
-
461
- // Super relations are handled by individual relation geometry methods
462
- if (isSuperRelation(relation)) {
463
- return {}
464
- }
465
-
466
- // Area relations are handled by buildRelationRings in shared/relation-multipolygon
467
- // This method doesn't duplicate that logic
468
-
469
- return {}
470
- }
471
-
472
- /**
473
- * Find relations that intersect a bounding box.
474
- */
475
- intersects(bbox: GeoBbox2D, filterFn?: (index: number) => boolean): number[] {
476
- if (this.size === 0) return []
477
- return this.spatialIndex.search(
478
- bbox[0],
479
- bbox[1],
480
- bbox[2],
481
- bbox[3],
482
- filterFn,
483
- )
484
- }
485
-
486
- /**
487
- * Find relation indexes near a point using great-circle distance.
488
- * @param lon - Longitude in degrees.
489
- * @param lat - Latitude in degrees.
490
- * @param maxResults - Maximum number of results to return.
491
- * @param maxDistanceKm - Maximum distance in kilometers.
492
- * @returns Array of relation indexes sorted by distance.
493
- */
494
- neighbors(
495
- lon: number,
496
- lat: number,
497
- maxResults?: number,
498
- maxDistanceKm?: number,
499
- ): number[] {
500
- if (this.size === 0) return []
501
- // Use geoflatbush for proper geographic distance calculations
502
- return geoAround(this.spatialIndex, lon, lat, maxResults, maxDistanceKm)
503
- }
504
-
505
- /**
506
- * Get transferable objects for passing to another thread.
507
- * Only includes spatialIndex if it has been built.
508
- */
509
- override transferables(): RelationsTransferables {
510
- const base = {
511
- ...super.transferables(),
512
- memberStart: this.memberStart.array.buffer,
513
- memberCount: this.memberCount.array.buffer,
514
- memberRefs: this.memberRefs.array.buffer,
515
- memberTypes: this.memberTypes.array.buffer,
516
- memberRoles: this.memberRoles.array.buffer,
517
- bbox: this.bbox.array.buffer,
518
- }
519
- // Only include spatial index if it was built
520
- if (this.spatialIndexBuilt) {
521
- return { ...base, spatialIndex: this.spatialIndex.data }
522
- }
523
- return base
524
- }
525
-
526
- /**
527
- * Get the approximate memory requirements for a given number of relations in bytes.
528
- */
529
- static getBytesRequired(count: number) {
530
- if (count === 0) return 0
531
- // Approximate members per relation
532
- let numNodes = count
533
- let n = count
534
- while (n !== 1) {
535
- n = Math.ceil(n / 128)
536
- numNodes += n
537
- }
538
- const indexBytes = (numNodes < 16384 ? 2 : 4) * numNodes
539
- const boxesBytes = numNodes * 4 * Float64Array.BYTES_PER_ELEMENT
540
- const spatialIndexBytes = 8 + indexBytes + boxesBytes
541
-
542
- return (
543
- Ids.getBytesRequired(count) +
544
- Tags.getBytesRequired(count) +
545
- count * Uint32Array.BYTES_PER_ELEMENT + // memberStart
546
- count * Uint16Array.BYTES_PER_ELEMENT + // memberCount
547
- count * 4 * Float64Array.BYTES_PER_ELEMENT + // bbox
548
- spatialIndexBytes
549
- )
550
- }
551
-
552
- /**
553
- * Update a ContentHasher with relation-specific data (members).
554
- */
555
- override updateHash(hasher: ContentHasher): ContentHasher {
556
- return super
557
- .updateHash(hasher)
558
- .update(this.memberStart.array)
559
- .update(this.memberCount.array)
560
- .update(this.memberRefs.array)
561
- .update(this.memberTypes.array)
562
- .update(this.memberRoles.array)
563
- }
57
+ private stringTable: StringTable;
58
+
59
+ private memberStart: RTA<Uint32Array>;
60
+ private memberCount: RTA<Uint16Array>; // Maximum MAX_RELATION_MEMBERS (65,535) per relation
61
+
62
+ // Store the ID of the member because relations have other relations as members.
63
+ private memberRefs: RTA<Float64Array>;
64
+ private memberTypes: RTA<Uint8Array>;
65
+ private memberRoles: RTA<Uint32Array>;
66
+
67
+ // Spatial index
68
+ private spatialIndex: Flatbush = new Flatbush(1);
69
+ // Track if spatial index was properly built (vs default empty)
70
+ private spatialIndexBuilt = false;
71
+
72
+ // Bounding box of the relation in geographic coordinates
73
+ private bbox: RTA<Float64Array>;
74
+
75
+ // Node and Way indexes
76
+ private nodes: Nodes;
77
+ private ways: Ways;
78
+
79
+ // Lazily computed relation way membership keyed by way index. Invalidated whenever relations
80
+ // change, and rebuilt if the way count no longer matches.
81
+ private wayMemberCache: BitSet | null = null;
82
+
83
+ /**
84
+ * Create a new Relations index.
85
+ */
86
+ constructor(
87
+ stringTable: StringTable,
88
+ nodes: Nodes,
89
+ ways: Ways,
90
+ transferables?: RelationsTransferables,
91
+ ) {
92
+ if (transferables) {
93
+ super("relation", new Ids(transferables), new Tags(stringTable, transferables));
94
+ this.memberStart = RTA.from(Uint32Array, transferables.memberStart);
95
+ this.memberCount = RTA.from(Uint16Array, transferables.memberCount);
96
+ this.memberRefs = RTA.from(IdArrayType, transferables.memberRefs);
97
+ this.memberTypes = RTA.from(Uint8Array, transferables.memberTypes);
98
+ this.memberRoles = RTA.from(Uint32Array, transferables.memberRoles);
99
+ this.bbox = RTA.from(Float64Array, transferables.bbox);
100
+ // Only load spatial index if provided (not stored in IndexedDB)
101
+ if (transferables.spatialIndex?.byteLength) {
102
+ this.spatialIndex = Flatbush.from(transferables.spatialIndex);
103
+ this.spatialIndexBuilt = true;
104
+ }
105
+ this.indexBuilt = true;
106
+ } else {
107
+ super("relation", new Ids(), new Tags(stringTable));
108
+ this.memberStart = new RTA(Uint32Array);
109
+ this.memberCount = new RTA(Uint16Array);
110
+ this.memberRefs = new RTA(IdArrayType);
111
+ this.memberTypes = new RTA(Uint8Array);
112
+ this.memberRoles = new RTA(Uint32Array);
113
+ this.bbox = new RTA(Float64Array);
114
+ }
115
+ this.nodes = nodes;
116
+ this.ways = ways;
117
+ this.stringTable = stringTable;
118
+ }
119
+
120
+ /** Reject a relation whose member count would wrap `memberCount` or `memberStart`. */
121
+ private assertMemberCapacity(relationId: number, memberCount: number) {
122
+ assertCapacity(
123
+ "relation-members",
124
+ memberCount,
125
+ MAX_RELATION_MEMBERS,
126
+ () => `Relation ${relationId}`,
127
+ );
128
+ assertCapacity(
129
+ "total-relation-members",
130
+ this.memberRefs.length + memberCount,
131
+ MAX_UINT32_OFFSET,
132
+ () => `The dataset (at relation ${relationId})`,
133
+ );
134
+ }
135
+
136
+ /**
137
+ * Add a single relation to the index.
138
+ */
139
+ addRelation(relation: OsmRelation) {
140
+ this.wayMemberCache = null;
141
+ this.assertMemberCapacity(relation.id, relation.members.length);
142
+ const relationIndex = this.addEntity(relation.id, relation.tags ?? {});
143
+ this.memberStart.push(this.memberRefs.length);
144
+ this.memberCount.push(relation.members.length);
145
+ for (const member of relation.members) {
146
+ this.memberRefs.push(member.ref);
147
+ this.memberTypes.push(RELATION_MEMBER_TYPES.indexOf(member.type));
148
+ this.memberRoles.push(this.stringTable.add(member.role ?? ""));
149
+ }
150
+ return relationIndex;
151
+ }
152
+
153
+ /**
154
+ * Bulk add relations directly from a PBF PrimitiveBlock.
155
+ */
156
+ addRelations(
157
+ relations: OsmPbfRelation[],
158
+ blockStringIndexMap: Uint32Array,
159
+ filter?: (relation: OsmRelation) => OsmRelation | null,
160
+ ): number {
161
+ this.wayMemberCache = null;
162
+ const blockToStringTable = (k: number) => {
163
+ const index = blockStringIndexMap[k];
164
+ if (index === undefined) throw Error("Tag key not found");
165
+ return index;
166
+ };
167
+
168
+ let added = 0;
169
+ for (const relation of relations) {
170
+ const members: OsmRelationMember[] = [];
171
+ const memberRefs: number[] = [];
172
+ const memberTypes: number[] = [];
173
+ const memberRoles: number[] = [];
174
+
175
+ let refId = 0;
176
+ for (let i = 0; i < relation.memids.length; i++) {
177
+ const memid = relation.memids[i];
178
+ const roleSid = relation.roles_sid[i];
179
+ const typeIndex = relation.types[i];
180
+ assertValue(memid, "Relation member ID is undefined");
181
+ assertValue(roleSid, "Relation member role SID is undefined");
182
+ assertValue(typeIndex, "Relation member type is undefined");
183
+
184
+ refId += memid;
185
+ const roleIndex = blockToStringTable(roleSid);
186
+ const type = RELATION_MEMBER_TYPES[typeIndex];
187
+ assertValue(type, "Relation member type not found");
188
+
189
+ if (filter) {
190
+ members.push({
191
+ type,
192
+ ref: refId,
193
+ role: this.stringTable.get(roleIndex),
194
+ });
195
+ }
196
+
197
+ memberRefs.push(refId);
198
+ memberTypes.push(typeIndex);
199
+ memberRoles.push(roleIndex);
200
+ }
201
+
202
+ const tagKeys: number[] = relation.keys.map(blockToStringTable);
203
+ const tagValues: number[] = relation.vals.map(blockToStringTable);
204
+
205
+ const filteredRelation = filter
206
+ ? filter({
207
+ id: relation.id,
208
+ members,
209
+ tags: this.tags.getTagsFromIndices(tagKeys, tagValues),
210
+ })
211
+ : null;
212
+ if (filter && filteredRelation === null) continue;
213
+ this.assertMemberCapacity(relation.id, filteredRelation?.members.length ?? memberRefs.length);
214
+ added++;
215
+
216
+ this.addEntity(relation.id, tagKeys, tagValues);
217
+ this.memberStart.push(this.memberRefs.length);
218
+ this.memberCount.push(filteredRelation?.members.length ?? memberRefs.length);
219
+ this.memberRefs.pushMany(filteredRelation?.members.map((m) => m.ref) ?? memberRefs);
220
+ this.memberTypes.pushMany(
221
+ filteredRelation?.members.map((m) => RELATION_MEMBER_TYPES.indexOf(m.type)) ?? memberTypes,
222
+ );
223
+ this.memberRoles.pushMany(
224
+ filteredRelation?.members.map((m) => this.stringTable.add(m.role ?? "")) ?? memberRoles,
225
+ );
226
+ }
227
+ return added;
228
+ }
229
+
230
+ /**
231
+ * Compact the internal arrays to free up memory.
232
+ */
233
+ buildEntityIndex() {
234
+ this.memberStart.compact();
235
+ this.memberCount.compact();
236
+ this.memberRefs.compact();
237
+ this.memberTypes.compact();
238
+ this.memberRoles.compact();
239
+ }
240
+
241
+ /**
242
+ * Build the spatial index for relations.
243
+ * Handles nested relations by resolving all descendant nodes and ways.
244
+ * If bbox data already exists (e.g., loaded from storage), reuses it.
245
+ * Returns the existing index when it is already built.
246
+ */
247
+ buildSpatialIndex() {
248
+ if (!this.nodes.isReady()) throw Error("Node index is not ready.");
249
+ if (!this.ways.isReady()) throw Error("Way index is not ready.");
250
+ if (this.spatialIndexBuilt) return this.spatialIndex;
251
+ if (this.size === 0) {
252
+ this.spatialIndex = new Flatbush(1, 128, Float64Array, BufferConstructor);
253
+ this.spatialIndexBuilt = true;
254
+ return this.spatialIndex;
255
+ }
256
+ console.time("RelationIndex.buildSpatialIndex");
257
+
258
+ this.spatialIndex = new Flatbush(this.size, 128, Float64Array, BufferConstructor);
259
+
260
+ // If bbox already has data (loaded from storage), use it directly
261
+ const hasBboxData = this.bbox.length >= this.size * 4;
262
+ for (let i = 0; i < this.size; i++) {
263
+ let minX: number;
264
+ let minY: number;
265
+ let maxX: number;
266
+ let maxY: number;
267
+
268
+ if (hasBboxData) {
269
+ // Use stored bbox values
270
+ minX = this.bbox.at(i * 4);
271
+ minY = this.bbox.at(i * 4 + 1);
272
+ maxX = this.bbox.at(i * 4 + 2);
273
+ maxY = this.bbox.at(i * 4 + 3);
274
+ } else {
275
+ // Calculate bbox from coordinates
276
+ const lls = this.collectRelationCoordinates(i);
277
+ const bbox = bboxFromLonLats(lls);
278
+ minX = bbox[0];
279
+ minY = bbox[1];
280
+ maxX = bbox[2];
281
+ maxY = bbox[3];
282
+ this.bbox.push(minX);
283
+ this.bbox.push(minY);
284
+ this.bbox.push(maxX);
285
+ this.bbox.push(maxY);
286
+ }
287
+ this.spatialIndex.add(minX, minY, maxX, maxY);
288
+ }
289
+ if (!hasBboxData) {
290
+ this.bbox.compact();
291
+ }
292
+ this.spatialIndex.finish();
293
+ this.spatialIndexBuilt = true;
294
+ console.timeEnd("RelationIndex.buildSpatialIndex");
295
+ return this.spatialIndex;
296
+ }
297
+
298
+ /**
299
+ * Check if the spatial index has been built.
300
+ */
301
+ hasSpatialIndex(): boolean {
302
+ return this.spatialIndexBuilt;
303
+ }
304
+
305
+ /**
306
+ * Collect all coordinates from a relation, including nested relations.
307
+ * Used for building bounding boxes and spatial indexes.
308
+ */
309
+ private collectRelationCoordinates(index: number): LonLat[] {
310
+ const lls: LonLat[] = [];
311
+ const relation = this.getByIndex(index);
312
+
313
+ // Resolve nested relations to get all descendant nodes and ways
314
+ const resolved = resolveRelationMembers(
315
+ relation,
316
+ (relId) => {
317
+ const relIndex = this.ids.getIndexFromId(relId);
318
+ if (relIndex === -1) return null;
319
+ return this.getByIndex(relIndex);
320
+ },
321
+ 10, // max depth
322
+ );
323
+
324
+ // Collect coordinates from resolved nodes
325
+ for (const nodeId of resolved.nodes) {
326
+ const ll = this.nodes.getNodeLonLat({ id: nodeId });
327
+ if (ll) lls.push(ll);
328
+ }
329
+
330
+ // Collect coordinates from resolved ways. Member ways may reference nodes
331
+ // outside a referentially incomplete extract, so skip unresolvable refs.
332
+ for (const wayId of resolved.ways) {
333
+ const wayIndex = this.ways.ids.getIndexFromId(wayId);
334
+ if (wayIndex === -1) continue;
335
+ const wayPositions = this.ways.getResolvedCoordinates(wayIndex);
336
+ lls.push(...wayPositions);
337
+ }
338
+
339
+ return lls;
340
+ }
341
+
342
+ /**
343
+ * Get the bounding box of a relation.
344
+ */
345
+ getEntityBbox(i: IdOrIndex): GeoBbox2D {
346
+ const index = "index" in i ? i.index : this.ids.idOrIndex(i)[0];
347
+ return [
348
+ this.bbox.at(index * 4),
349
+ this.bbox.at(index * 4 + 1),
350
+ this.bbox.at(index * 4 + 2),
351
+ this.bbox.at(index * 4 + 3),
352
+ ];
353
+ }
354
+
355
+ /**
356
+ * Get the full relation entity.
357
+ */
358
+ getFullEntity(index: number, id: number, tags?: OsmTags): OsmRelation {
359
+ return {
360
+ id,
361
+ members: this.getMembersByIndex(index),
362
+ tags,
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Get the members of a relation.
368
+ */
369
+ getMembersByIndex(index: number, relationMemberTypes = RELATION_MEMBER_TYPES) {
370
+ const start = this.memberStart.at(index);
371
+ const count = this.memberCount.at(index);
372
+ const members: OsmRelationMember[] = [];
373
+ for (let i = start; i < start + count; i++) {
374
+ const ref = this.memberRefs.at(i);
375
+ const type = RELATION_MEMBER_TYPES[this.memberTypes.at(i)];
376
+ if (type === undefined) throw Error(`Member type not found: ${i}`);
377
+ if (!relationMemberTypes.includes(type)) continue;
378
+ const role = this.stringTable.get(this.memberRoles.at(i));
379
+ members.push({ ref, type, role });
380
+ }
381
+ return members;
382
+ }
383
+
384
+ /**
385
+ * Check if a relation includes a specific member.
386
+ */
387
+ includesMember(index: number, memberRef: number, memberType: OsmEntityType, memberRole?: string) {
388
+ const start = this.memberStart.at(index);
389
+ const count = this.memberCount.at(index);
390
+ for (let i = start; i < start + count; i++) {
391
+ const type = RELATION_MEMBER_TYPES[this.memberTypes.at(i)];
392
+ if (type !== memberType) continue;
393
+ const ref = this.memberRefs.at(i);
394
+ if (ref !== memberRef) continue;
395
+ if (memberRole !== undefined && this.stringTable.get(this.memberRoles.at(i)) !== memberRole)
396
+ continue;
397
+ return true;
398
+ }
399
+ return false;
400
+ }
401
+
402
+ /**
403
+ * Check whether a way is a member of any relation, including through nested relations.
404
+ * Used to exclude these ways from individual rendering.
405
+ *
406
+ * Membership is computed once and cached as one bit per way index. Requires the way ID index.
407
+ */
408
+ isWayMember(wayIndex: number): boolean {
409
+ return this.getWayMembers().has(wayIndex);
410
+ }
411
+
412
+ private getWayMembers(): BitSet {
413
+ if (this.wayMemberCache !== null && this.wayMemberCache.size === this.ways.size) {
414
+ return this.wayMemberCache;
415
+ }
416
+
417
+ const members = new BitSet(this.ways.size);
418
+ const getRelation = (relId: number) => {
419
+ const relIndex = this.ids.getIndexFromId(relId);
420
+ if (relIndex === -1) return null;
421
+ return this.getByIndex(relIndex);
422
+ };
423
+ for (let i = 0; i < this.size; i++) {
424
+ const resolved = resolveRelationMembers(this.getByIndex(i), getRelation, 10);
425
+ for (const wayId of resolved.ways) {
426
+ const wayIndex = this.ways.ids.getIndexFromId(wayId);
427
+ if (wayIndex !== -1) members.add(wayIndex);
428
+ }
429
+ }
430
+ this.wayMemberCache = members;
431
+ return members;
432
+ }
433
+
434
+ /**
435
+ * Get relation geometry based on its kind.
436
+ * Returns coordinates suitable for rendering based on relation type.
437
+ * @param index - Relation index
438
+ * @returns Object with geometry data based on relation kind
439
+ */
440
+ getRelationGeometry(index: number): {
441
+ points?: LonLat[];
442
+ lineStrings?: LonLat[][];
443
+ rings?: LonLat[][][];
444
+ } {
445
+ const relation = this.getByIndex(index);
446
+ if (isAreaRelation(relation)) {
447
+ return {
448
+ rings: buildRelationRings(
449
+ relation,
450
+ (ref) => this.ways.getById(ref),
451
+ (id) => this.nodes.getNodeLonLat({ id }) ?? undefined,
452
+ ),
453
+ };
454
+ }
455
+
456
+ // Point relations
457
+ if (isPointRelation(relation)) {
458
+ return {
459
+ points: collectRelationPoints(
460
+ relation,
461
+ (id) => this.nodes.getNodeLonLat({ id }) ?? undefined,
462
+ ),
463
+ };
464
+ }
465
+
466
+ // Line relations
467
+ if (isLineRelation(relation)) {
468
+ return {
469
+ lineStrings: buildRelationLineStrings(
470
+ relation,
471
+ (ref) => this.ways.getById(ref),
472
+ (id) => this.nodes.getNodeLonLat({ id }) ?? undefined,
473
+ ),
474
+ };
475
+ }
476
+
477
+ // Super relations are handled by individual relation geometry methods
478
+ if (isSuperRelation(relation)) {
479
+ return {};
480
+ }
481
+
482
+ // Area relations are handled by buildRelationRings in shared/relation-multipolygon
483
+ // This method doesn't duplicate that logic
484
+
485
+ return {};
486
+ }
487
+
488
+ /**
489
+ * Find relations that intersect a bounding box.
490
+ */
491
+ intersects(bbox: GeoBbox2D, filterFn?: (index: number) => boolean): number[] {
492
+ if (this.size === 0) return [];
493
+ // A relation with no resolvable member geometry stores an inverted bbox.
494
+ // Flatbush's contained-node fast path skips per-leaf intersection tests,
495
+ // so filter inverted boxes out explicitly.
496
+ return this.spatialIndex.search(bbox[0], bbox[1], bbox[2], bbox[3], (index, x0, y0, x1, y1) => {
497
+ if (!isValidSpatialBbox(x0, y0, x1, y1)) return false;
498
+ return filterFn ? filterFn(index) : true;
499
+ });
500
+ }
501
+
502
+ /**
503
+ * Find relation indexes near a point using great-circle distance.
504
+ * @param lon - Longitude in degrees.
505
+ * @param lat - Latitude in degrees.
506
+ * @param maxResults - Maximum number of results to return.
507
+ * @param maxDistanceKm - Maximum distance in kilometers.
508
+ * @returns Array of relation indexes sorted by distance.
509
+ */
510
+ neighbors(lon: number, lat: number, maxResults?: number, maxDistanceKm?: number): number[] {
511
+ if (this.size === 0) return [];
512
+ // Use geoflatbush for proper geographic distance calculations
513
+ return geoAround(this.spatialIndex, lon, lat, maxResults, maxDistanceKm, (index) => {
514
+ const offset = index * 4;
515
+ return isValidSpatialBbox(
516
+ this.bbox.at(offset),
517
+ this.bbox.at(offset + 1),
518
+ this.bbox.at(offset + 2),
519
+ this.bbox.at(offset + 3),
520
+ );
521
+ });
522
+ }
523
+
524
+ /**
525
+ * Get transferable objects for passing to another thread.
526
+ * Only includes spatialIndex if it has been built.
527
+ */
528
+ override transferables(): RelationsTransferables {
529
+ const base = {
530
+ ...super.transferables(),
531
+ memberStart: this.memberStart.array.buffer,
532
+ memberCount: this.memberCount.array.buffer,
533
+ memberRefs: this.memberRefs.array.buffer,
534
+ memberTypes: this.memberTypes.array.buffer,
535
+ memberRoles: this.memberRoles.array.buffer,
536
+ bbox: this.bbox.array.buffer,
537
+ };
538
+ // Only include spatial index if it was built
539
+ if (this.spatialIndexBuilt) {
540
+ return { ...base, spatialIndex: this.spatialIndex.data };
541
+ }
542
+ return base;
543
+ }
544
+
545
+ /**
546
+ * Get the approximate memory requirements for a given number of relations in bytes.
547
+ */
548
+ static getBytesRequired(count: number, memberCount = 0, tagCount = 0) {
549
+ if (count === 0) return 0;
550
+ // Approximate members per relation
551
+ let numNodes = count;
552
+ let n = count;
553
+ while (n !== 1) {
554
+ n = Math.ceil(n / 128);
555
+ numNodes += n;
556
+ }
557
+ const indexBytes = (numNodes < 16384 ? 2 : 4) * numNodes;
558
+ const boxesBytes = numNodes * 4 * Float64Array.BYTES_PER_ELEMENT;
559
+ const spatialIndexBytes = 8 + indexBytes + boxesBytes;
560
+
561
+ return (
562
+ Ids.getBytesRequired(count) +
563
+ Tags.getBytesRequired(count, count, tagCount) +
564
+ count * Uint32Array.BYTES_PER_ELEMENT + // memberStart
565
+ count * Uint16Array.BYTES_PER_ELEMENT + // memberCount
566
+ memberCount * Float64Array.BYTES_PER_ELEMENT + // memberRefs
567
+ memberCount * Uint8Array.BYTES_PER_ELEMENT + // memberTypes
568
+ memberCount * Uint32Array.BYTES_PER_ELEMENT + // memberRoles
569
+ count * 4 * Float64Array.BYTES_PER_ELEMENT + // bbox
570
+ spatialIndexBytes
571
+ );
572
+ }
573
+
574
+ /**
575
+ * Update a ContentHasher with relation-specific data (members).
576
+ */
577
+ override updateHash(hasher: ContentHasher): ContentHasher {
578
+ return super
579
+ .updateHash(hasher)
580
+ .update(this.memberStart.array)
581
+ .update(this.memberCount.array)
582
+ .update(this.memberRefs.array)
583
+ .update(this.memberTypes.array)
584
+ .update(this.memberRoles.array);
585
+ }
564
586
  }