@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.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +136 -72
- package/dist/contracts.d.ts +27 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +1 -0
- package/dist/contracts.js.map +1 -0
- package/dist/entities.d.ts +22 -2
- package/dist/entities.d.ts.map +1 -1
- package/dist/entities.js +54 -10
- package/dist/entities.js.map +1 -1
- package/dist/ids.d.ts +20 -5
- package/dist/ids.d.ts.map +1 -1
- package/dist/ids.js +87 -29
- package/dist/ids.js.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/indirect-kd-index.d.ts +28 -0
- package/dist/indirect-kd-index.d.ts.map +1 -0
- package/dist/indirect-kd-index.js +163 -0
- package/dist/indirect-kd-index.js.map +1 -0
- package/dist/limits.d.ts +30 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +51 -0
- package/dist/limits.js.map +1 -0
- package/dist/mocks.d.ts.map +1 -1
- package/dist/mocks.js.map +1 -1
- package/dist/nodes.d.ts +36 -15
- package/dist/nodes.d.ts.map +1 -1
- package/dist/nodes.js +185 -62
- package/dist/nodes.js.map +1 -1
- package/dist/osm.d.ts +60 -4
- package/dist/osm.d.ts.map +1 -1
- package/dist/osm.js +32 -6
- package/dist/osm.js.map +1 -1
- package/dist/relations.d.ts +11 -4
- package/dist/relations.d.ts.map +1 -1
- package/dist/relations.js +73 -29
- package/dist/relations.js.map +1 -1
- package/dist/renumber.d.ts +23 -0
- package/dist/renumber.d.ts.map +1 -0
- package/dist/renumber.js +64 -0
- package/dist/renumber.js.map +1 -0
- package/dist/stringtable.d.ts +2 -1
- package/dist/stringtable.d.ts.map +1 -1
- package/dist/stringtable.js +9 -14
- package/dist/stringtable.js.map +1 -1
- package/dist/tags.d.ts +45 -12
- package/dist/tags.d.ts.map +1 -1
- package/dist/tags.js +153 -27
- package/dist/tags.js.map +1 -1
- package/dist/typed-arrays.d.ts +63 -48
- package/dist/typed-arrays.d.ts.map +1 -1
- package/dist/typed-arrays.js +184 -107
- package/dist/typed-arrays.js.map +1 -1
- package/dist/ways.d.ts +52 -3
- package/dist/ways.d.ts.map +1 -1
- package/dist/ways.js +223 -46
- package/dist/ways.js.map +1 -1
- package/package.json +45 -31
- package/src/contracts.ts +29 -0
- package/src/entities.ts +242 -194
- package/src/ids.ts +278 -235
- package/src/index.ts +22 -13
- package/src/indirect-kd-index.ts +212 -0
- package/src/limits.ts +73 -0
- package/src/mocks.ts +114 -113
- package/src/nodes.ts +538 -397
- package/src/osm.ts +282 -207
- package/src/relations.ts +580 -558
- package/src/renumber.ts +80 -0
- package/src/stringtable.ts +189 -184
- package/src/tags.ts +406 -272
- package/src/typed-arrays.ts +307 -205
- package/src/types/geoflatbush.d.ts +28 -33
- package/src/ways.ts +577 -406
- package/test/entity-index-errors.test.ts +41 -0
- package/test/ids.bench.ts +33 -0
- package/test/ids.test.ts +122 -0
- package/test/limits.test.ts +83 -0
- package/test/memory.test.ts +29 -30
- package/test/no-shared-array-buffer.test.ts +41 -0
- package/test/nodes-spatial-index.test.ts +261 -0
- package/test/osm-bbox.test.ts +30 -0
- package/test/relations.bench.ts +32 -0
- package/test/relations.test.ts +124 -0
- package/test/renumber.test.ts +54 -0
- package/test/spatial-index-rebuild.test.ts +33 -0
- package/test/storage-compaction.test.ts +269 -0
- package/test/tags-search.test.ts +89 -56
- package/test/typed-arrays.test.ts +252 -132
- package/tsconfig.build.json +6 -6
- package/tsconfig.json +7 -7
- package/src/types/geokdbush.d.ts +0 -37
- package/src/types/kdbush.d.ts +0 -47
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { Osm } from "../src/osm.ts";
|
|
4
|
+
import { TypedBufferAllocationError } from "../src/typed-arrays.ts";
|
|
5
|
+
|
|
6
|
+
describe("entity index errors", () => {
|
|
7
|
+
it("adds entity and component context to typed-buffer failures", () => {
|
|
8
|
+
const osm = new Osm({ id: "allocation-error" });
|
|
9
|
+
const allocationError = new TypedBufferAllocationError(
|
|
10
|
+
{
|
|
11
|
+
operation: "compact",
|
|
12
|
+
typedArray: "Float64Array",
|
|
13
|
+
bufferType: "shared-array-buffer",
|
|
14
|
+
elementCount: 273_574_591,
|
|
15
|
+
bytesPerElement: 8,
|
|
16
|
+
requiredBytes: 2_188_596_728,
|
|
17
|
+
},
|
|
18
|
+
new RangeError("Array buffer allocation failed"),
|
|
19
|
+
);
|
|
20
|
+
vi.spyOn(osm.nodes.ids, "buildIndex").mockImplementation(() => {
|
|
21
|
+
throw allocationError;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(() => osm.nodes.buildIndex()).toThrowError(
|
|
25
|
+
expect.objectContaining({
|
|
26
|
+
name: "OsmEntityIndexBuildError",
|
|
27
|
+
code: "OSM_ENTITY_INDEX_BUILD_FAILED",
|
|
28
|
+
stage: "entity-index-finalization",
|
|
29
|
+
entityType: "node",
|
|
30
|
+
component: "ids",
|
|
31
|
+
operation: "compact",
|
|
32
|
+
typedArray: "Float64Array",
|
|
33
|
+
bufferType: "shared-array-buffer",
|
|
34
|
+
elementCount: 273_574_591,
|
|
35
|
+
bytesPerElement: 8,
|
|
36
|
+
requiredBytes: 2_188_596_728,
|
|
37
|
+
cause: allocationError,
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { Ids } from "../src/ids";
|
|
4
|
+
import { Osm } from "../src/osm";
|
|
5
|
+
|
|
6
|
+
const values = Array.from({ length: 20_000 }, (_, index) => (index * 7919) % 100_000);
|
|
7
|
+
|
|
8
|
+
function buildIds() {
|
|
9
|
+
const ids = new Ids();
|
|
10
|
+
for (const value of values) ids.add(value);
|
|
11
|
+
ids.buildIndex();
|
|
12
|
+
return ids;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const osm = new Osm();
|
|
16
|
+
for (const value of values) osm.nodes.addNode({ id: value, lon: value, lat: value });
|
|
17
|
+
osm.buildIndexes();
|
|
18
|
+
|
|
19
|
+
describe("sorted ID iteration", () => {
|
|
20
|
+
test("build shuffled sorted index", async ({ bench }) => {
|
|
21
|
+
await bench("build shuffled sorted index", () => {
|
|
22
|
+
buildIds();
|
|
23
|
+
}).run();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("iterate all sorted entities", async ({ bench }) => {
|
|
27
|
+
await bench("iterate all sorted entities", () => {
|
|
28
|
+
let checksum = 0;
|
|
29
|
+
for (const node of osm.nodes.sorted()) checksum += node.id + node.lon;
|
|
30
|
+
if (checksum === 0) throw Error("Unexpected empty benchmark");
|
|
31
|
+
}).run();
|
|
32
|
+
});
|
|
33
|
+
});
|
package/test/ids.test.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { Ids } from "../src/ids";
|
|
4
|
+
import { Osm } from "../src/osm";
|
|
5
|
+
|
|
6
|
+
function buildIds(values: number[]) {
|
|
7
|
+
const ids = new Ids();
|
|
8
|
+
for (const value of values) ids.add(value);
|
|
9
|
+
ids.buildIndex();
|
|
10
|
+
return ids;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("Ids sorted entries", () => {
|
|
14
|
+
it.each([
|
|
15
|
+
{ name: "empty", values: [], entries: [] },
|
|
16
|
+
{
|
|
17
|
+
name: "ascending",
|
|
18
|
+
values: [1, 2, 3],
|
|
19
|
+
entries: [
|
|
20
|
+
[1, 0],
|
|
21
|
+
[2, 1],
|
|
22
|
+
[3, 2],
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "reverse",
|
|
27
|
+
values: [3, 2, 1],
|
|
28
|
+
entries: [
|
|
29
|
+
[1, 2],
|
|
30
|
+
[2, 1],
|
|
31
|
+
[3, 0],
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "sparse",
|
|
36
|
+
values: [100, 2, 50],
|
|
37
|
+
entries: [
|
|
38
|
+
[2, 1],
|
|
39
|
+
[50, 2],
|
|
40
|
+
[100, 0],
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "duplicates",
|
|
45
|
+
values: [5, 2, 5, 2],
|
|
46
|
+
entries: [
|
|
47
|
+
[2, 1],
|
|
48
|
+
[2, 3],
|
|
49
|
+
[5, 0],
|
|
50
|
+
[5, 2],
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
])("preserves sorted ID and original-position order for $name IDs", ({ values, entries }) => {
|
|
54
|
+
expect(Array.from(buildIds(values).sortedEntries())).toEqual(entries);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("preserves sorted entries through transfer reconstruction", () => {
|
|
58
|
+
const source = buildIds([9, 3, 9, 1]);
|
|
59
|
+
const reconstructed = new Ids(source.transferables());
|
|
60
|
+
|
|
61
|
+
expect(Array.from(reconstructed.sortedEntries())).toEqual([
|
|
62
|
+
[1, 3],
|
|
63
|
+
[3, 1],
|
|
64
|
+
[9, 0],
|
|
65
|
+
[9, 2],
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("orders negative IDs canonically for OSM serialization", () => {
|
|
70
|
+
expect(Array.from(buildIds([3, -3, -1, 2, -2, 1, -2]).osmSortedEntries())).toEqual([
|
|
71
|
+
[-1, 2],
|
|
72
|
+
[-2, 4],
|
|
73
|
+
[-2, 6],
|
|
74
|
+
[-3, 1],
|
|
75
|
+
[1, 5],
|
|
76
|
+
[2, 3],
|
|
77
|
+
[3, 0],
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("omits redundant sorted buffers for ascending IDs", () => {
|
|
82
|
+
const transferables = buildIds([1, 2, 3]).transferables();
|
|
83
|
+
|
|
84
|
+
expect(transferables.idsAreSorted).toBe(true);
|
|
85
|
+
expect(transferables.sortedIds).toBeUndefined();
|
|
86
|
+
expect(transferables.sortedIdPositionToIndex).toBeUndefined();
|
|
87
|
+
expect(Ids.getBytesRequired(3)).toBe(
|
|
88
|
+
3 * Float64Array.BYTES_PER_ELEMENT + Float64Array.BYTES_PER_ELEMENT,
|
|
89
|
+
);
|
|
90
|
+
expect(Array.from(new Ids(transferables).sortedEntries())).toEqual([
|
|
91
|
+
[1, 0],
|
|
92
|
+
[2, 1],
|
|
93
|
+
[3, 2],
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("retains derived lookup buffers for unsorted IDs", () => {
|
|
98
|
+
const transferables = buildIds([3, 1, 2]).transferables();
|
|
99
|
+
|
|
100
|
+
expect(transferables.idsAreSorted).toBe(false);
|
|
101
|
+
expect(transferables.sortedIds?.byteLength).toBe(3 * Float64Array.BYTES_PER_ELEMENT);
|
|
102
|
+
expect(transferables.sortedIdPositionToIndex?.byteLength).toBe(
|
|
103
|
+
3 * Uint32Array.BYTES_PER_ELEMENT,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("streams sorted entities without looking up each ID again", () => {
|
|
108
|
+
const osm = new Osm();
|
|
109
|
+
osm.nodes.addNode({ id: 2, lon: 20, lat: 20 });
|
|
110
|
+
osm.nodes.addNode({ id: 1, lon: 10, lat: 10 });
|
|
111
|
+
osm.nodes.addNode({ id: 1, lon: 11, lat: 11 });
|
|
112
|
+
osm.buildIndexes();
|
|
113
|
+
const getIndexFromId = vi.spyOn(osm.nodes.ids, "getIndexFromId");
|
|
114
|
+
|
|
115
|
+
expect([...osm.nodes.sorted()].map((node) => [node.id, node.lon])).toEqual([
|
|
116
|
+
[1, 10],
|
|
117
|
+
[1, 11],
|
|
118
|
+
[2, 20],
|
|
119
|
+
]);
|
|
120
|
+
expect(getIndexFromId).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
MAX_RELATION_MEMBERS,
|
|
5
|
+
MAX_STRING_BYTES,
|
|
6
|
+
MAX_UINT32_OFFSET,
|
|
7
|
+
MAX_WAY_REFS,
|
|
8
|
+
Osm,
|
|
9
|
+
OsmCapacityError,
|
|
10
|
+
Relations,
|
|
11
|
+
Tags,
|
|
12
|
+
assertCapacity,
|
|
13
|
+
} from "../src";
|
|
14
|
+
import StringTable from "../src/stringtable.ts";
|
|
15
|
+
|
|
16
|
+
describe("storage capacity limits", () => {
|
|
17
|
+
it("stores a way with exactly MAX_WAY_REFS refs", () => {
|
|
18
|
+
const osm = new Osm({ id: "max-way" });
|
|
19
|
+
const refs = Array.from({ length: MAX_WAY_REFS }, (_, i) => i + 1);
|
|
20
|
+
osm.ways.addWay({ id: 1, refs });
|
|
21
|
+
osm.buildIndexes();
|
|
22
|
+
expect(osm.ways.getById(1)?.refs).toHaveLength(MAX_WAY_REFS);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects a way with more than MAX_WAY_REFS refs instead of wrapping", () => {
|
|
26
|
+
const osm = new Osm({ id: "long-way" });
|
|
27
|
+
const refs = Array.from({ length: MAX_WAY_REFS + 1 }, (_, i) => i + 1);
|
|
28
|
+
const add = () => osm.ways.addWay({ id: 7, refs });
|
|
29
|
+
expect(add).toThrow(OsmCapacityError);
|
|
30
|
+
expect(add).toThrow("Way 7 has 65,536 node refs; Osmix stores at most 65,535.");
|
|
31
|
+
expect(osm.ways.size).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects a relation with more than MAX_RELATION_MEMBERS members", () => {
|
|
35
|
+
const osm = new Osm({ id: "big-relation" });
|
|
36
|
+
const members = Array.from({ length: MAX_RELATION_MEMBERS + 1 }, (_, i) => ({
|
|
37
|
+
type: "node" as const,
|
|
38
|
+
ref: i + 1,
|
|
39
|
+
}));
|
|
40
|
+
try {
|
|
41
|
+
osm.relations.addRelation({ id: 9, members });
|
|
42
|
+
expect.unreachable();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
expect(error).toBeInstanceOf(OsmCapacityError);
|
|
45
|
+
expect(error).toMatchObject({
|
|
46
|
+
code: "OSM_CAPACITY_EXCEEDED",
|
|
47
|
+
limit: "relation-members",
|
|
48
|
+
value: MAX_RELATION_MEMBERS + 1,
|
|
49
|
+
max: MAX_RELATION_MEMBERS,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
expect(osm.relations.size).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects a string longer than MAX_STRING_BYTES UTF-8 bytes", () => {
|
|
56
|
+
const table = new StringTable();
|
|
57
|
+
const fits = "a".repeat(MAX_STRING_BYTES);
|
|
58
|
+
expect(table.get(table.add(fits))).toBe(fits);
|
|
59
|
+
// "é" is two UTF-8 bytes, so this string is short in characters but too long in bytes.
|
|
60
|
+
const tooLong = "é".repeat((MAX_STRING_BYTES + 1) / 2);
|
|
61
|
+
expect(() => table.add(tooLong)).toThrow(OsmCapacityError);
|
|
62
|
+
expect(() => table.add(tooLong)).toThrow(/65,536 UTF-8 bytes/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("reports dataset-wide offset overflow", () => {
|
|
66
|
+
expect(() =>
|
|
67
|
+
assertCapacity("total-way-refs", MAX_UINT32_OFFSET + 1, MAX_UINT32_OFFSET, () => "Data"),
|
|
68
|
+
).toThrow("Data has 4,294,967,296 node refs; Osmix stores at most 4,294,967,295.");
|
|
69
|
+
expect(() =>
|
|
70
|
+
assertCapacity("total-way-refs", MAX_UINT32_OFFSET, MAX_UINT32_OFFSET, () => "Data"),
|
|
71
|
+
).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("byte estimators", () => {
|
|
76
|
+
it("counts tag pairs at 12 bytes each", () => {
|
|
77
|
+
expect(Tags.getBytesRequired(10, 10, 5) - Tags.getBytesRequired(10, 10)).toBe(60);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("counts relation members at 13 bytes each", () => {
|
|
81
|
+
expect(Relations.getBytesRequired(1, 4) - Relations.getBytesRequired(1)).toBe(52);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/test/memory.test.ts
CHANGED
|
@@ -1,38 +1,37 @@
|
|
|
1
|
-
import { describe, expect, it } from "
|
|
2
|
-
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { Nodes, Relations, Ways } from "../src";
|
|
3
4
|
|
|
4
5
|
export function getOsmCoreMemoryRequirements(
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
nodeCount: number,
|
|
7
|
+
wayCount: number,
|
|
8
|
+
relationCount: number,
|
|
8
9
|
) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
const nodesBytes = Nodes.getBytesRequired(nodeCount);
|
|
11
|
+
const waysBytes = Ways.getBytesRequired(wayCount);
|
|
12
|
+
const relationsBytes = Relations.getBytesRequired(relationCount);
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
return {
|
|
15
|
+
nodes: nodesBytes,
|
|
16
|
+
ways: waysBytes,
|
|
17
|
+
relations: relationsBytes,
|
|
18
|
+
total: nodesBytes + waysBytes + relationsBytes,
|
|
19
|
+
};
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
describe("Memory Requirements", () => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
)
|
|
30
|
-
})
|
|
23
|
+
it("computes memory requirements for small datasets", () => {
|
|
24
|
+
const breakdown = getOsmCoreMemoryRequirements(10, 2, 1);
|
|
25
|
+
expect(breakdown.nodes).toBeGreaterThan(0);
|
|
26
|
+
expect(breakdown.ways).toBeGreaterThan(0);
|
|
27
|
+
expect(breakdown.relations).toBeGreaterThan(0);
|
|
28
|
+
expect(breakdown.total).toBe(breakdown.nodes + breakdown.ways + breakdown.relations);
|
|
29
|
+
});
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
})
|
|
31
|
+
it("computes memory requirements for large datasets", () => {
|
|
32
|
+
const breakdown = getOsmCoreMemoryRequirements(70000, 20000, 0);
|
|
33
|
+
expect(breakdown.nodes).toBeGreaterThan(0);
|
|
34
|
+
expect(breakdown.ways).toBeGreaterThan(0);
|
|
35
|
+
expect(breakdown.total).toBe(breakdown.nodes + breakdown.ways);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
describe("without SharedArrayBuffer", () => {
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.unstubAllGlobals();
|
|
6
|
+
vi.resetModules();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("builds indexes and manages ArrayBuffer-backed typed arrays", async () => {
|
|
10
|
+
vi.stubGlobal("SharedArrayBuffer", undefined);
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
|
|
13
|
+
const { BufferConstructor, ResizeableTypedArray } = await import("../src/typed-arrays.ts");
|
|
14
|
+
const { Osm } = await import("../src/osm.ts");
|
|
15
|
+
|
|
16
|
+
expect(BufferConstructor).toBe(ArrayBuffer);
|
|
17
|
+
|
|
18
|
+
const osm = new Osm({ id: "array-buffer-only" });
|
|
19
|
+
osm.nodes.addNode({ id: 1, lon: 115.2167, lat: -8.65, tags: { place: "city" } });
|
|
20
|
+
osm.nodes.addNode({ id: 2, lon: 115.22, lat: -8.66 });
|
|
21
|
+
osm.ways.addWay({ id: 1, refs: [1, 2], tags: { highway: "residential" } });
|
|
22
|
+
osm.buildIndexes();
|
|
23
|
+
|
|
24
|
+
expect(osm.isReady()).toBe(true);
|
|
25
|
+
expect(osm.nodes.getById(1)?.tags?.["place"]).toBe("city");
|
|
26
|
+
|
|
27
|
+
const restored = ResizeableTypedArray.from(Uint8Array, new ArrayBuffer(4));
|
|
28
|
+
restored.push(9);
|
|
29
|
+
expect(restored.buffer).toBeInstanceOf(ArrayBuffer);
|
|
30
|
+
|
|
31
|
+
const growing = new ResizeableTypedArray(Uint8Array);
|
|
32
|
+
growing.pushMany(new Uint8Array(growing.capacity));
|
|
33
|
+
growing.push(7);
|
|
34
|
+
expect(growing.buffer).toBeInstanceOf(ArrayBuffer);
|
|
35
|
+
expect(growing.at(-1)).toBe(7);
|
|
36
|
+
|
|
37
|
+
const compacted = growing.compact();
|
|
38
|
+
expect(compacted.buffer).toBeInstanceOf(ArrayBuffer);
|
|
39
|
+
expect(compacted.length).toBe(growing.length);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -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
|
+
});
|