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