@osmix/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +54 -7
  3. package/dist/contracts.d.ts +2 -1
  4. package/dist/contracts.d.ts.map +1 -1
  5. package/dist/entities.d.ts +21 -1
  6. package/dist/entities.d.ts.map +1 -1
  7. package/dist/entities.js +52 -3
  8. package/dist/entities.js.map +1 -1
  9. package/dist/ids.d.ts +18 -5
  10. package/dist/ids.d.ts.map +1 -1
  11. package/dist/ids.js +68 -18
  12. package/dist/ids.js.map +1 -1
  13. package/dist/index.d.ts +6 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6 -3
  16. package/dist/index.js.map +1 -1
  17. package/dist/indirect-kd-index.d.ts +28 -0
  18. package/dist/indirect-kd-index.d.ts.map +1 -0
  19. package/dist/indirect-kd-index.js +163 -0
  20. package/dist/indirect-kd-index.js.map +1 -0
  21. package/dist/limits.d.ts +30 -0
  22. package/dist/limits.d.ts.map +1 -0
  23. package/dist/limits.js +51 -0
  24. package/dist/limits.js.map +1 -0
  25. package/dist/nodes.d.ts +28 -13
  26. package/dist/nodes.d.ts.map +1 -1
  27. package/dist/nodes.js +181 -51
  28. package/dist/nodes.js.map +1 -1
  29. package/dist/osm.d.ts +59 -3
  30. package/dist/osm.d.ts.map +1 -1
  31. package/dist/osm.js +31 -2
  32. package/dist/osm.js.map +1 -1
  33. package/dist/relations.d.ts +10 -4
  34. package/dist/relations.d.ts.map +1 -1
  35. package/dist/relations.js +66 -28
  36. package/dist/relations.js.map +1 -1
  37. package/dist/renumber.d.ts +23 -0
  38. package/dist/renumber.d.ts.map +1 -0
  39. package/dist/renumber.js +64 -0
  40. package/dist/renumber.js.map +1 -0
  41. package/dist/stringtable.d.ts +2 -1
  42. package/dist/stringtable.d.ts.map +1 -1
  43. package/dist/stringtable.js +5 -1
  44. package/dist/stringtable.js.map +1 -1
  45. package/dist/tags.d.ts +44 -11
  46. package/dist/tags.d.ts.map +1 -1
  47. package/dist/tags.js +153 -27
  48. package/dist/tags.js.map +1 -1
  49. package/dist/typed-arrays.d.ts +63 -48
  50. package/dist/typed-arrays.d.ts.map +1 -1
  51. package/dist/typed-arrays.js +184 -107
  52. package/dist/typed-arrays.js.map +1 -1
  53. package/dist/ways.d.ts +50 -2
  54. package/dist/ways.d.ts.map +1 -1
  55. package/dist/ways.js +212 -35
  56. package/dist/ways.js.map +1 -1
  57. package/package.json +11 -12
  58. package/src/contracts.ts +2 -1
  59. package/src/entities.ts +67 -4
  60. package/src/ids.ts +72 -22
  61. package/src/index.ts +13 -7
  62. package/src/indirect-kd-index.ts +212 -0
  63. package/src/limits.ts +73 -0
  64. package/src/nodes.ts +228 -54
  65. package/src/osm.ts +90 -4
  66. package/src/relations.ts +80 -30
  67. package/src/renumber.ts +80 -0
  68. package/src/stringtable.ts +15 -1
  69. package/src/tags.ts +173 -31
  70. package/src/typed-arrays.ts +225 -113
  71. package/src/ways.ts +233 -38
  72. package/test/entity-index-errors.test.ts +41 -0
  73. package/test/ids.bench.ts +11 -7
  74. package/test/ids.test.ts +38 -0
  75. package/test/limits.test.ts +83 -0
  76. package/test/no-shared-array-buffer.test.ts +41 -0
  77. package/test/nodes-spatial-index.test.ts +261 -0
  78. package/test/osm-bbox.test.ts +30 -0
  79. package/test/relations.bench.ts +9 -4
  80. package/test/relations.test.ts +52 -29
  81. package/test/renumber.test.ts +54 -0
  82. package/test/spatial-index-rebuild.test.ts +33 -0
  83. package/test/storage-compaction.test.ts +269 -0
  84. package/test/tags-search.test.ts +13 -0
  85. package/test/typed-arrays.test.ts +178 -59
  86. package/src/types/geokdbush.d.ts +0 -32
  87. package/src/types/kdbush.d.ts +0 -47
@@ -0,0 +1,261 @@
1
+ import type { GeoBbox2D } from "@osmix/types";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { IndirectKdIndex } from "../src/indirect-kd-index.ts";
5
+ import { Nodes, SpatialIndexNotBuiltError } from "../src/nodes.ts";
6
+ import { Osm } from "../src/osm.ts";
7
+
8
+ const EARTH_RADIUS_KM = 6371.0088;
9
+
10
+ describe("indirect node spatial indexes", () => {
11
+ it("stores exactly one Uint32 per indexed node", () => {
12
+ const osm = new Osm();
13
+ for (let i = 0; i < 10; i++) {
14
+ osm.nodes.addNode({
15
+ id: i + 1,
16
+ lon: i,
17
+ lat: i,
18
+ ...(i % 3 === 0 ? { tags: { name: `${i}` } } : {}),
19
+ });
20
+ }
21
+ osm.buildIndexes();
22
+ osm.nodes.buildSpatialIndex("all");
23
+ osm.nodes.buildSpatialIndex("tagged");
24
+
25
+ const transferables = osm.nodes.transferables();
26
+ expect(transferables.allSpatialIndex?.byteLength).toBe(10 * Uint32Array.BYTES_PER_ELEMENT);
27
+ expect(transferables.taggedSpatialIndex?.byteLength).toBe(4 * Uint32Array.BYTES_PER_ELEMENT);
28
+ expect(osm.nodes.taggedSize).toBe(4);
29
+ expect(Nodes.getSpatialIndexBytesRequired(133_881_054)).toBe(535_524_216);
30
+ });
31
+
32
+ it("keeps all-node and tagged-node capabilities independent", () => {
33
+ const osm = new Osm();
34
+ osm.nodes.addNode({ id: 1, lon: 1, lat: 1, tags: { amenity: "bench" } });
35
+ osm.nodes.addNode({ id: 2, lon: 2, lat: 2 });
36
+ osm.nodes.addNode({ id: 3, lon: 3, lat: 3, tags: { name: "three" } });
37
+ osm.buildIndexes();
38
+
39
+ osm.nodes.buildSpatialIndex("tagged");
40
+ expect(osm.nodes.hasSpatialIndex("tagged")).toBe(true);
41
+ expect(osm.nodes.hasSpatialIndex("all")).toBe(false);
42
+ expect(osm.nodes.findTaggedIndexesWithinBbox([-180, -90, 180, 90])).toEqual([0, 2]);
43
+ expect(() => osm.nodes.findIndexesWithinBbox([-180, -90, 180, 90])).toThrowError(
44
+ SpatialIndexNotBuiltError,
45
+ );
46
+
47
+ osm.nodes.buildSpatialIndex("all");
48
+ expect(osm.nodes.findIndexesWithinBbox([-180, -90, 180, 90])).toEqual([0, 1, 2]);
49
+ });
50
+
51
+ it("throws a structured error when the requested capability is absent", () => {
52
+ const osm = new Osm();
53
+ osm.nodes.addNode({ id: 1, lon: 0, lat: 0 });
54
+ osm.buildIndexes();
55
+
56
+ let error: unknown;
57
+ try {
58
+ osm.nodes.findIndexesWithinRadius(0, 0, 1);
59
+ } catch (caught) {
60
+ error = caught;
61
+ }
62
+ expect(error).toBeInstanceOf(SpatialIndexNotBuiltError);
63
+ expect(error).toMatchObject({
64
+ name: "SpatialIndexNotBuiltError",
65
+ code: "SPATIAL_INDEX_NOT_BUILT",
66
+ entityType: "node",
67
+ indexKind: "all",
68
+ });
69
+ });
70
+
71
+ it("round-trips both capabilities through transferables, including empty indexes", () => {
72
+ const source = new Osm();
73
+ source.nodes.addNode({ id: 1, lon: -1, lat: -1, tags: { name: "one" } });
74
+ source.nodes.addNode({ id: 2, lon: 1, lat: 1 });
75
+ source.buildIndexes();
76
+ source.nodes.buildSpatialIndex("all");
77
+ source.nodes.buildSpatialIndex("tagged");
78
+
79
+ const reconstructed = new Osm(source);
80
+ expect(reconstructed.nodes.hasSpatialIndex("all")).toBe(true);
81
+ expect(reconstructed.nodes.hasSpatialIndex("tagged")).toBe(true);
82
+ expect(reconstructed.nodes.findIndexesWithinBbox([-2, -2, 2, 2])).toEqual([0, 1]);
83
+ expect(reconstructed.nodes.findTaggedIndexesWithinBbox([-2, -2, 2, 2])).toEqual([0]);
84
+
85
+ const empty = new Osm();
86
+ empty.buildIndexes();
87
+ empty.nodes.buildSpatialIndex("all");
88
+ empty.nodes.buildSpatialIndex("tagged");
89
+ const emptyReconstructed = new Osm(empty);
90
+ expect(emptyReconstructed.nodes.hasSpatialIndex("all")).toBe(true);
91
+ expect(emptyReconstructed.nodes.hasSpatialIndex("tagged")).toBe(true);
92
+ expect(emptyReconstructed.nodes.findIndexesWithinBbox([-180, -90, 180, 90])).toEqual([]);
93
+ });
94
+
95
+ it("reconstructs a permutation from ArrayBuffer as well as SharedArrayBuffer", () => {
96
+ const lons = new Int32Array([-1_000_000, 0, 1_000_000]);
97
+ const lats = new Int32Array([-1_000_000, 0, 1_000_000]);
98
+ const built = IndirectKdIndex.build(lons, lats, 3, (indexes) => indexes.set([0, 1, 2]));
99
+ const arrayBuffer = new ArrayBuffer(built.buffer.byteLength);
100
+ new Uint8Array(arrayBuffer).set(new Uint8Array(built.buffer));
101
+
102
+ const reconstructed = IndirectKdIndex.from(lons, lats, arrayBuffer);
103
+ expect(reconstructed.range(-1, -1, 1, 1)).toEqual([1]);
104
+ });
105
+
106
+ it("matches naive inclusive bbox scans across randomized data", () => {
107
+ const osm = randomOsm(1_000);
108
+ osm.nodes.buildSpatialIndex("all");
109
+
110
+ const random = mulberry32(20260717);
111
+ for (let i = 0; i < 100; i++) {
112
+ const lonA = random() * 360 - 180;
113
+ const lonB = random() * 360 - 180;
114
+ const latA = random() * 180 - 90;
115
+ const latB = random() * 180 - 90;
116
+ const bbox: GeoBbox2D = [
117
+ Math.min(lonA, lonB),
118
+ Math.min(latA, latB),
119
+ Math.max(lonA, lonB),
120
+ Math.max(latA, latB),
121
+ ];
122
+ const expected = naiveBbox(osm, bbox);
123
+ expect(osm.nodes.findIndexesWithinBbox(bbox).sort((a, b) => a - b)).toEqual(expected);
124
+ }
125
+ });
126
+
127
+ it("matches naive haversine scans and orders equal distances by node index", () => {
128
+ const osm = randomOsm(1_000, [
129
+ { id: 10_001, lon: 1, lat: 0 },
130
+ { id: 10_002, lon: -1, lat: 0 },
131
+ ]);
132
+ osm.nodes.buildSpatialIndex("all");
133
+
134
+ const queries: [number, number, number][] = [
135
+ [0, 0, 120],
136
+ [179.8, 0, 500],
137
+ [-73.9, 40.7, 1_000],
138
+ [20, 89.8, 100],
139
+ [0, -89.8, 100],
140
+ [30, 20, Number.POSITIVE_INFINITY],
141
+ ];
142
+ for (const [lon, lat, radius] of queries) {
143
+ expect(osm.nodes.findIndexesWithinRadius(lon, lat, radius)).toEqual(
144
+ naiveRadius(osm, lon, lat, radius),
145
+ );
146
+ }
147
+
148
+ const eastIndex = osm.nodes.ids.getIndexFromId(10_001);
149
+ const westIndex = osm.nodes.ids.getIndexFromId(10_002);
150
+ const equalDistanceResults = osm.nodes.findIndexesWithinRadius(0, 0, 112);
151
+ expect(equalDistanceResults.indexOf(eastIndex)).toBeLessThan(
152
+ equalDistanceResults.indexOf(westIndex),
153
+ );
154
+ });
155
+
156
+ it("handles antimeridian-crossing bboxes and polar radii", () => {
157
+ const osm = new Osm();
158
+ osm.nodes.addNode({ id: 1, lon: 179.9, lat: 0 });
159
+ osm.nodes.addNode({ id: 2, lon: -179.9, lat: 0 });
160
+ osm.nodes.addNode({ id: 3, lon: 0, lat: 0 });
161
+ osm.nodes.addNode({ id: 4, lon: 160, lat: 89.9 });
162
+ osm.nodes.addNode({ id: 5, lon: -20, lat: 89.9 });
163
+ osm.buildIndexes();
164
+ osm.nodes.buildSpatialIndex("all");
165
+
166
+ expect(osm.nodes.findIndexesWithinBbox([179, -1, -179, 1])).toEqual([0, 1]);
167
+ expect(osm.nodes.findIndexesWithinRadius(179.95, 0, 20)).toEqual([0, 1]);
168
+ expect(osm.nodes.findIndexesWithinRadius(70, 90, 20)).toEqual([3, 4]);
169
+ });
170
+
171
+ it("handles large runs of duplicate coordinates and inclusive boundaries", () => {
172
+ const osm = new Osm();
173
+ for (let i = 0; i < 256; i++) {
174
+ osm.nodes.addNode({ id: i + 1, lon: 12.3456789, lat: -45.6789123 });
175
+ }
176
+ osm.nodes.addNode({ id: 257, lon: 12.345679, lat: -45.6789123 });
177
+ osm.buildIndexes();
178
+ osm.nodes.buildSpatialIndex("all");
179
+
180
+ expect(
181
+ osm.nodes.findIndexesWithinBbox([12.3456789, -45.6789123, 12.3456789, -45.6789123]),
182
+ ).toHaveLength(256);
183
+ expect(osm.nodes.findIndexesWithinRadius(12.3456789, -45.6789123, 0)).toEqual(
184
+ Array.from({ length: 256 }, (_, index) => index),
185
+ );
186
+ });
187
+
188
+ it("includes a stored microdegree coordinate on its exact floating-point boundary", () => {
189
+ const osm = new Osm();
190
+ const lon = -179.9382715;
191
+ osm.nodes.addNode({ id: 1, lon, lat: 0 });
192
+ osm.buildIndexes();
193
+ osm.nodes.buildSpatialIndex("all");
194
+
195
+ // This degree value does not multiply back to its stored integer exactly.
196
+ expect((lon * 1e7) % 1).not.toBe(0);
197
+ expect(osm.nodes.findIndexesWithinBbox([lon, 0, lon, 0])).toEqual([0]);
198
+ });
199
+ });
200
+
201
+ function randomOsm(
202
+ count: number,
203
+ extraNodes: { id: number; lon: number; lat: number }[] = [],
204
+ ): Osm {
205
+ const osm = new Osm();
206
+ const random = mulberry32(42);
207
+ for (let i = 0; i < count; i++) {
208
+ osm.nodes.addNode({
209
+ id: i + 1,
210
+ lon: random() * 360 - 180,
211
+ lat: random() * 180 - 90,
212
+ ...(i % 11 === 0 ? { tags: { name: `${i}` } } : {}),
213
+ });
214
+ }
215
+ for (const node of extraNodes) osm.nodes.addNode(node);
216
+ osm.buildIndexes();
217
+ return osm;
218
+ }
219
+
220
+ function naiveBbox(osm: Osm, bbox: GeoBbox2D): number[] {
221
+ const indexes: number[] = [];
222
+ for (let i = 0; i < osm.nodes.size; i++) {
223
+ const [lon, lat] = osm.nodes.getNodeLonLat({ index: i });
224
+ const longitudeMatches =
225
+ bbox[0] <= bbox[2] ? lon >= bbox[0] && lon <= bbox[2] : lon >= bbox[0] || lon <= bbox[2];
226
+ if (longitudeMatches && lat >= bbox[1] && lat <= bbox[3]) indexes.push(i);
227
+ }
228
+ return indexes;
229
+ }
230
+
231
+ function naiveRadius(osm: Osm, lon: number, lat: number, radiusKm: number): number[] {
232
+ const matches: { distance: number; index: number }[] = [];
233
+ for (let i = 0; i < osm.nodes.size; i++) {
234
+ const [nodeLon, nodeLat] = osm.nodes.getNodeLonLat({ index: i });
235
+ const distance = haversineDistanceKm(lon, lat, nodeLon, nodeLat);
236
+ if (distance <= radiusKm) matches.push({ distance, index: i });
237
+ }
238
+ matches.sort((a, b) => a.distance - b.distance || a.index - b.index);
239
+ return matches.map(({ index }) => index);
240
+ }
241
+
242
+ function haversineDistanceKm(lon1: number, lat1: number, lon2: number, lat2: number): number {
243
+ const radians = Math.PI / 180;
244
+ const latDelta = (lat2 - lat1) * radians;
245
+ const lonDelta = (lon2 - lon1) * radians;
246
+ const a =
247
+ Math.sin(latDelta / 2) ** 2 +
248
+ Math.sin(lonDelta / 2) ** 2 * Math.cos(lat1 * radians) * Math.cos(lat2 * radians);
249
+ return 2 * EARTH_RADIUS_KM * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0, 1 - a)));
250
+ }
251
+
252
+ function mulberry32(seed: number): () => number {
253
+ let state = seed;
254
+ return () => {
255
+ state |= 0;
256
+ state = (state + 0x6d2b79f5) | 0;
257
+ let value = Math.imul(state ^ (state >>> 15), 1 | state);
258
+ value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
259
+ return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
260
+ };
261
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { Osm } from "../src/osm";
4
+
5
+ describe("Osm.bbox", () => {
6
+ it("is null for a dataset without nodes", () => {
7
+ const osm = new Osm();
8
+ osm.buildIndexes();
9
+
10
+ expect(osm.bbox()).toBeNull();
11
+ expect(osm.info().bbox).toBeNull();
12
+ });
13
+
14
+ it("covers every node once nodes are added", () => {
15
+ const osm = new Osm();
16
+ osm.nodes.addNode({ id: 1, lon: -9.2, lat: 38.7 });
17
+ osm.nodes.addNode({ id: 2, lon: -8.6, lat: 41.1 });
18
+ osm.buildIndexes();
19
+
20
+ expect(osm.bbox()).toEqual([-9.2, 38.7, -8.6, 41.1]);
21
+ expect(osm.info().bbox).toEqual([-9.2, 38.7, -8.6, 41.1]);
22
+ });
23
+
24
+ it("stays null after an empty dataset is transferred and reconstructed", () => {
25
+ const empty = new Osm();
26
+ empty.buildIndexes();
27
+
28
+ expect(new Osm(empty.transferables()).bbox()).toBeNull();
29
+ });
30
+ });
@@ -1,9 +1,11 @@
1
- import { bench, describe } from "vitest";
1
+ import { describe, test } from "vitest";
2
2
 
3
3
  import { Osm } from "../src/osm";
4
4
 
5
5
  function createManyMemberRelations() {
6
6
  const osm = new Osm({ id: "relations-benchmark" });
7
+ osm.nodes.addNode({ id: 1, lon: 0, lat: 0 });
8
+ for (let id = 0; id < 10_000; id++) osm.ways.addWay({ id, refs: [1] });
7
9
  for (let relationId = 0; relationId < 100; relationId++) {
8
10
  osm.relations.addRelation({
9
11
  id: relationId,
@@ -14,14 +16,17 @@ function createManyMemberRelations() {
14
16
  })),
15
17
  });
16
18
  }
19
+ osm.buildIndexes();
17
20
  return osm;
18
21
  }
19
22
 
20
23
  const osm = createManyMemberRelations();
21
- osm.relations.getWayMemberIds();
24
+ osm.relations.isWayMember(0);
22
25
 
23
26
  describe("relation way membership cache", () => {
24
- bench("1000 repeated cached lookups", () => {
25
- for (let i = 0; i < 1000; i++) osm.relations.getWayMemberIds();
27
+ test("1000 repeated cached lookups", async ({ bench }) => {
28
+ await bench("1000 repeated cached lookups", () => {
29
+ for (let i = 0; i < 1000; i++) osm.relations.isWayMember(i);
30
+ }).run();
26
31
  });
27
32
  });
@@ -28,60 +28,89 @@ function createOsmWithRelations(): OsmType {
28
28
  return osm;
29
29
  }
30
30
 
31
- describe("relation way membership cache", () => {
32
- it("returns an empty cached set for empty relations", () => {
33
- const osm = new Osm();
31
+ /** Way membership for every way in `osm`, as sorted way IDs. */
32
+ function wayMemberIds(osm: OsmType): number[] {
33
+ const ids: number[] = [];
34
+ for (let i = 0; i < osm.ways.size; i++) {
35
+ if (osm.relations.isWayMember(i)) ids.push(osm.ways.ids.at(i));
36
+ }
37
+ return ids.sort((a, b) => a - b);
38
+ }
34
39
 
35
- const first = osm.relations.getWayMemberIds();
36
- const second = osm.relations.getWayMemberIds();
40
+ /** Osm with ways 10, 20 and 30 and no relations. */
41
+ function createOsmWithWays(): OsmType {
42
+ const osm = new Osm();
43
+ osm.nodes.addNode({ id: 1, lon: 0, lat: 0 });
44
+ for (const id of [10, 20, 30]) osm.ways.addWay({ id, refs: [1] });
45
+ return osm;
46
+ }
37
47
 
38
- expect(first).toBe(second);
39
- expect(first).toEqual(new Set());
48
+ describe("relation way membership cache", () => {
49
+ it("reports no members when there are no relations", () => {
50
+ const osm = createOsmWithWays();
51
+ osm.buildIndexes();
52
+
53
+ expect(wayMemberIds(osm)).toEqual([]);
40
54
  });
41
55
 
42
56
  it("collects unique direct and nested way members", () => {
43
- const wayIds = createOsmWithRelations().relations.getWayMemberIds();
44
-
45
- expect(wayIds).toEqual(new Set([10, 20]));
57
+ expect(wayMemberIds(createOsmWithRelations())).toEqual([10, 20]);
46
58
  });
47
59
 
48
60
  it("does not traverse relations again while the cache is warm", () => {
49
61
  const osm = createOsmWithRelations();
50
62
  const getByIndex = vi.spyOn(osm.relations, "getByIndex");
51
63
 
52
- const first = osm.relations.getWayMemberIds();
64
+ osm.relations.isWayMember(0);
53
65
  const callsAfterFirstLookup = getByIndex.mock.calls.length;
54
- const second = osm.relations.getWayMemberIds();
66
+ osm.relations.isWayMember(1);
55
67
 
56
- expect(second).toBe(first);
57
68
  expect(callsAfterFirstLookup).toBeGreaterThan(0);
58
69
  expect(getByIndex).toHaveBeenCalledTimes(callsAfterFirstLookup);
59
70
  });
60
71
 
72
+ // Relations cannot be added after their ID index is built, so these tests only index nodes and
73
+ // ways. Membership resolution without nested relations never consults the relation ID index.
61
74
  it("invalidates the cache when a relation is added", () => {
62
- const osm = new Osm();
75
+ const osm = createOsmWithWays();
76
+ osm.nodes.buildIndex();
77
+ osm.ways.buildIndex();
63
78
  osm.relations.addRelation({ id: 1, members: [{ type: "way", ref: 10, role: "" }] });
64
- const first = osm.relations.getWayMemberIds();
79
+ expect(wayMemberIds(osm)).toEqual([10]);
65
80
 
66
81
  osm.relations.addRelation({ id: 2, members: [{ type: "way", ref: 20, role: "" }] });
67
- const second = osm.relations.getWayMemberIds();
68
82
 
69
- expect(second).not.toBe(first);
70
- expect(second).toEqual(new Set([10, 20]));
83
+ expect(wayMemberIds(osm)).toEqual([10, 20]);
71
84
  });
72
85
 
73
86
  it("invalidates the cache when relations are added in bulk", () => {
74
- const osm = new Osm();
87
+ const osm = createOsmWithWays();
75
88
  osm.stringTable.add("");
89
+ osm.nodes.buildIndex();
90
+ osm.ways.buildIndex();
76
91
  osm.relations.addRelation({ id: 1, members: [{ type: "way", ref: 10, role: "" }] });
77
- osm.relations.getWayMemberIds();
92
+ expect(wayMemberIds(osm)).toEqual([10]);
78
93
 
79
94
  osm.relations.addRelations(
80
95
  [{ id: 2, keys: [], vals: [], memids: [20], roles_sid: [0], types: [1] }],
81
96
  new Uint32Array([0]),
82
97
  );
83
98
 
84
- expect(osm.relations.getWayMemberIds()).toEqual(new Set([10, 20]));
99
+ expect(wayMemberIds(osm)).toEqual([10, 20]);
100
+ });
101
+
102
+ it("ignores way members that are not in the dataset", () => {
103
+ const osm = createOsmWithWays();
104
+ osm.relations.addRelation({
105
+ id: 1,
106
+ members: [
107
+ { type: "way", ref: 30, role: "" },
108
+ { type: "way", ref: 99, role: "" },
109
+ ],
110
+ });
111
+ osm.buildIndexes();
112
+
113
+ expect(wayMemberIds(osm)).toEqual([30]);
85
114
  });
86
115
 
87
116
  it("starts with an empty cache after transfer and reconstruction", () => {
@@ -89,13 +118,7 @@ describe("relation way membership cache", () => {
89
118
  const reconstructed = new Osm(source);
90
119
  const getByIndex = vi.spyOn(reconstructed.relations, "getByIndex");
91
120
 
92
- const first = reconstructed.relations.getWayMemberIds();
93
- const callsAfterFirstLookup = getByIndex.mock.calls.length;
94
- const second = reconstructed.relations.getWayMemberIds();
95
-
96
- expect(first).toEqual(new Set([10, 20]));
97
- expect(second).toBe(first);
98
- expect(callsAfterFirstLookup).toBeGreaterThan(0);
99
- expect(getByIndex).toHaveBeenCalledTimes(callsAfterFirstLookup);
121
+ expect(wayMemberIds(reconstructed)).toEqual([10, 20]);
122
+ expect(getByIndex.mock.calls.length).toBeGreaterThan(0);
100
123
  });
101
124
  });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { Osm } from "../src/osm.ts";
4
+ import { renumberNegativeIds } from "../src/renumber.ts";
5
+
6
+ function mixedIds() {
7
+ const osm = new Osm({ id: "mixed" });
8
+ osm.nodes.addNode({ id: 5, lon: 0, lat: 0 });
9
+ osm.nodes.addNode({ id: -1, lon: 0.001, lat: 0, tags: { name: "New entrance" } });
10
+ osm.nodes.addNode({ id: -2, lon: 0.002, lat: 0 });
11
+ osm.ways.addWay({ id: 7, refs: [5, -1], tags: { highway: "footway" } });
12
+ osm.ways.addWay({ id: -3, refs: [-1, -2], tags: { highway: "footway" } });
13
+ osm.relations.addRelation({
14
+ id: -1,
15
+ members: [
16
+ { type: "node", ref: -2, role: "stop" },
17
+ { type: "way", ref: -3, role: "" },
18
+ { type: "way", ref: 7, role: "" },
19
+ ],
20
+ tags: { type: "route" },
21
+ });
22
+ osm.buildIndexes();
23
+ osm.buildSpatialIndexes();
24
+ return osm;
25
+ }
26
+
27
+ describe("renumberNegativeIds", () => {
28
+ it("gives each type's negative IDs new ones after its highest ID and follows every reference", () => {
29
+ const input = mixedIds();
30
+ const { osm, idMap } = renumberNegativeIds(input);
31
+ expect(idMap).toEqual({
32
+ nodes: { "-1": 6, "-2": 7 },
33
+ ways: { "-3": 8 },
34
+ relations: { "-1": 1 },
35
+ });
36
+ expect(osm.nodes.getById(6)?.tags).toEqual({ name: "New entrance" });
37
+ expect(osm.ways.getById(7)?.refs).toEqual([5, 6]);
38
+ expect(osm.ways.getById(8)?.refs).toEqual([6, 7]);
39
+ expect(osm.relations.getById(1)?.members.map((member) => member.ref)).toEqual([7, 8, 7]);
40
+ expect([...osm.nodes].every((node) => node.id > 0)).toBe(true);
41
+ // The input keeps its IDs.
42
+ expect(input.nodes.getById(-1)).not.toBeNull();
43
+ });
44
+
45
+ it("is deterministic and a no-op without negative IDs", () => {
46
+ expect(renumberNegativeIds(mixedIds()).idMap).toEqual(renumberNegativeIds(mixedIds()).idMap);
47
+ const positive = new Osm({ id: "positive" });
48
+ positive.nodes.addNode({ id: 1, lon: 0, lat: 0 });
49
+ positive.buildIndexes();
50
+ const { osm, idMap } = renumberNegativeIds(positive);
51
+ expect(idMap).toEqual({ nodes: {}, ways: {}, relations: {} });
52
+ expect([...osm.nodes]).toEqual([...positive.nodes]);
53
+ });
54
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { Osm } from "../src/osm.ts";
4
+
5
+ function buildIndexedOsm() {
6
+ const osm = new Osm();
7
+ osm.nodes.addNode({ id: 1, lon: 0, lat: 0 });
8
+ osm.nodes.addNode({ id: 2, lon: 1, lat: 1 });
9
+ osm.ways.addWay({ id: 10, refs: [1, 2], tags: { highway: "path" } });
10
+ osm.relations.addRelation({
11
+ id: 100,
12
+ members: [{ type: "way", ref: 10, role: "" }],
13
+ tags: { type: "route" },
14
+ });
15
+ osm.buildIndexes();
16
+ osm.buildSpatialIndexes();
17
+ return osm;
18
+ }
19
+
20
+ describe("spatial index rebuilds", () => {
21
+ it("reuses built way and relation indexes", () => {
22
+ const osm = buildIndexedOsm();
23
+ const waysIndex = osm.ways.buildSpatialIndex();
24
+ const relationsIndex = osm.relations.buildSpatialIndex();
25
+
26
+ osm.buildSpatialIndexes();
27
+
28
+ expect(osm.ways.buildSpatialIndex()).toBe(waysIndex);
29
+ expect(osm.relations.buildSpatialIndex()).toBe(relationsIndex);
30
+ expect(osm.ways.intersects([-1, -1, 2, 2])).toEqual([0]);
31
+ expect(osm.relations.intersects([-1, -1, 2, 2])).toEqual([0]);
32
+ });
33
+ });