@osmix/geoparquet 0.1.5 → 0.1.6
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 +74 -0
- package/package.json +11 -34
- package/test/download-monaco-highways.sh +40 -0
- package/test/from-geoparquet.test.ts +520 -0
- package/test/monaco-parquet.test.ts +249 -0
- package/test/wkb.test.ts +296 -0
- package/tsconfig.build.json +5 -0
- package/tsconfig.json +9 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# @osmix/geoparquet
|
|
2
|
+
|
|
3
|
+
## 0.1.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2a634cb: Fix publishing
|
|
8
|
+
- Updated dependencies [2a634cb]
|
|
9
|
+
- @osmix/shared@0.0.12
|
|
10
|
+
- @osmix/core@0.1.7
|
|
11
|
+
|
|
12
|
+
## 0.1.5
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 3c8ee95: Fix and simplify package exports
|
|
17
|
+
- Updated dependencies [3c8ee95]
|
|
18
|
+
- @osmix/core@0.1.6
|
|
19
|
+
- @osmix/shared@0.0.11
|
|
20
|
+
|
|
21
|
+
## 0.1.4
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- Updated dependencies [12728ed]
|
|
26
|
+
- @osmix/shared@0.0.10
|
|
27
|
+
- @osmix/core@0.1.5
|
|
28
|
+
|
|
29
|
+
## 0.1.3
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- f32e4ee: General cleanup
|
|
34
|
+
- Updated dependencies [f32e4ee]
|
|
35
|
+
- @osmix/core@0.1.4
|
|
36
|
+
- @osmix/shared@0.0.9
|
|
37
|
+
|
|
38
|
+
## 0.1.2
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- f468db5: Fix publishing (2)
|
|
43
|
+
- Updated dependencies [f468db5]
|
|
44
|
+
- @osmix/core@0.1.3
|
|
45
|
+
- @osmix/shared@0.0.8
|
|
46
|
+
|
|
47
|
+
## 0.1.1
|
|
48
|
+
|
|
49
|
+
### Patch Changes
|
|
50
|
+
|
|
51
|
+
- 68d6bd8: Fix publishing for packages.
|
|
52
|
+
- Updated dependencies [68d6bd8]
|
|
53
|
+
- @osmix/core@0.1.2
|
|
54
|
+
- @osmix/shared@0.0.7
|
|
55
|
+
|
|
56
|
+
## 0.1.0
|
|
57
|
+
|
|
58
|
+
### Minor Changes
|
|
59
|
+
|
|
60
|
+
- 31fa333: Add `@osmix/geoparquet` package for importing OSM data from OpenStreetMap US Layercake GeoParquet files.
|
|
61
|
+
|
|
62
|
+
Features:
|
|
63
|
+
|
|
64
|
+
- `fromGeoParquet()` function to create Osm indexes from GeoParquet files
|
|
65
|
+
- WKB geometry parsing for Point, LineString, Polygon, and MultiPolygon
|
|
66
|
+
- Support for file paths, URLs, and ArrayBuffer inputs
|
|
67
|
+
- Customizable column mapping for different parquet schemas
|
|
68
|
+
|
|
69
|
+
### Patch Changes
|
|
70
|
+
|
|
71
|
+
- 54fe002: Add fromGeoParquet to OsmixWorker and OsmixRemote
|
|
72
|
+
- 31fa333: Import GeoParquet
|
|
73
|
+
- Updated dependencies [d4f4b1f]
|
|
74
|
+
- @osmix/core@0.1.1
|
package/package.json
CHANGED
|
@@ -1,54 +1,31 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$schema": "https://json.schemastore.org/package",
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@osmix/geoparquet",
|
|
4
4
|
"description": "Import OSM data from GeoParquet files.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.6",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"types": "./dist/index.d.ts",
|
|
8
|
-
"publishConfig": {
|
|
9
|
-
"access": "public"
|
|
10
|
-
},
|
|
11
7
|
"license": "MIT",
|
|
12
8
|
"repository": {
|
|
13
9
|
"type": "git",
|
|
14
10
|
"url": "git+https://github.com/conveyal/osmix.git"
|
|
15
11
|
},
|
|
16
|
-
"
|
|
17
|
-
"bugs": {
|
|
18
|
-
"url": "https://github.com/conveyal/osmix/issues"
|
|
19
|
-
},
|
|
20
|
-
"sideEffects": false,
|
|
12
|
+
"main": "./dist/index.js",
|
|
21
13
|
"scripts": {
|
|
22
14
|
"build": "tsc -p tsconfig.build.json",
|
|
23
15
|
"bench": "bun test --bench",
|
|
24
|
-
"prepare": "bun run build",
|
|
25
|
-
"release": "bun publish",
|
|
26
16
|
"test": "bun test",
|
|
27
17
|
"typecheck": "tsgo --noEmit"
|
|
28
18
|
},
|
|
29
19
|
"devDependencies": {
|
|
30
|
-
"@types/bun": "
|
|
31
|
-
"@types/geojson": "
|
|
32
|
-
"typescript": "
|
|
20
|
+
"@types/bun": "^1.3.9",
|
|
21
|
+
"@types/geojson": "^7946.0.16",
|
|
22
|
+
"typescript": "^5.9.0"
|
|
33
23
|
},
|
|
34
24
|
"dependencies": {
|
|
35
|
-
"@osmix/core": "
|
|
36
|
-
"@osmix/shared": "
|
|
37
|
-
"@placemarkio/geojson-rewind": "
|
|
25
|
+
"@osmix/core": "0.1.7",
|
|
26
|
+
"@osmix/shared": "0.0.12",
|
|
27
|
+
"@placemarkio/geojson-rewind": "^1.0.3",
|
|
38
28
|
"hyparquet": "^1.24.1"
|
|
39
29
|
},
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
"types": "./src/index.ts",
|
|
43
|
-
"bun": "./src/index.ts",
|
|
44
|
-
"development|production": "./src/index.ts",
|
|
45
|
-
"default": "./dist/index.js"
|
|
46
|
-
}
|
|
47
|
-
},
|
|
48
|
-
"files": [
|
|
49
|
-
"dist",
|
|
50
|
-
"src",
|
|
51
|
-
"README.md",
|
|
52
|
-
"LICENSE"
|
|
53
|
-
]
|
|
54
|
-
}
|
|
30
|
+
"types": "./dist/index.d.ts"
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Download Monaco highway data from LayerCake (OSM US) using DuckDB
|
|
4
|
+
# Monaco bounding box coordinates from MapTiler:
|
|
5
|
+
# - xmin: 7.409205
|
|
6
|
+
# - xmax: 7.448637
|
|
7
|
+
# - ymin: 43.72335
|
|
8
|
+
# - ymax: 43.75169
|
|
9
|
+
|
|
10
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
+
OUTPUT_DIR="$SCRIPT_DIR/../../../fixtures"
|
|
12
|
+
OUTPUT_FILE="$OUTPUT_DIR/monaco-highways.parquet"
|
|
13
|
+
|
|
14
|
+
echo "Downloading Monaco highway data from LayerCake..."
|
|
15
|
+
echo "Output: $OUTPUT_FILE"
|
|
16
|
+
|
|
17
|
+
duckdb <<EOF
|
|
18
|
+
INSTALL spatial;
|
|
19
|
+
LOAD spatial;
|
|
20
|
+
|
|
21
|
+
COPY (
|
|
22
|
+
FROM 'https://data.openstreetmap.us/layercake/highways.parquet'
|
|
23
|
+
SELECT
|
|
24
|
+
id,
|
|
25
|
+
tags,
|
|
26
|
+
geometry
|
|
27
|
+
WHERE
|
|
28
|
+
bbox.xmin >= 7.409205 AND bbox.xmax <= 7.448637 AND
|
|
29
|
+
bbox.ymin >= 43.72335 AND bbox.ymax <= 43.75169
|
|
30
|
+
) TO '$OUTPUT_FILE' WITH (FORMAT PARQUET);
|
|
31
|
+
EOF
|
|
32
|
+
|
|
33
|
+
if [ -f "$OUTPUT_FILE" ]; then
|
|
34
|
+
echo "Successfully downloaded Monaco highways to $OUTPUT_FILE"
|
|
35
|
+
echo "File size: $(ls -lh "$OUTPUT_FILE" | awk '{print $5}')"
|
|
36
|
+
else
|
|
37
|
+
echo "Error: Failed to download file"
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { GeoParquetOsmBuilder } from "../src/from-geoparquet"
|
|
3
|
+
import type { GeoParquetRow } from "../src/types"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create WKB Point geometry.
|
|
7
|
+
*/
|
|
8
|
+
function createPointWkb(lon: number, lat: number): Uint8Array {
|
|
9
|
+
const buffer = new ArrayBuffer(21)
|
|
10
|
+
const view = new DataView(buffer)
|
|
11
|
+
let offset = 0
|
|
12
|
+
|
|
13
|
+
view.setUint8(offset, 1) // little endian
|
|
14
|
+
offset += 1
|
|
15
|
+
view.setUint32(offset, 1, true) // Point type
|
|
16
|
+
offset += 4
|
|
17
|
+
view.setFloat64(offset, lon, true)
|
|
18
|
+
offset += 8
|
|
19
|
+
view.setFloat64(offset, lat, true)
|
|
20
|
+
|
|
21
|
+
return new Uint8Array(buffer)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create WKB LineString geometry.
|
|
26
|
+
*/
|
|
27
|
+
function createLineStringWkb(coords: [number, number][]): Uint8Array {
|
|
28
|
+
const buffer = new ArrayBuffer(1 + 4 + 4 + coords.length * 16)
|
|
29
|
+
const view = new DataView(buffer)
|
|
30
|
+
let offset = 0
|
|
31
|
+
|
|
32
|
+
view.setUint8(offset, 1) // little endian
|
|
33
|
+
offset += 1
|
|
34
|
+
view.setUint32(offset, 2, true) // LineString type
|
|
35
|
+
offset += 4
|
|
36
|
+
view.setUint32(offset, coords.length, true) // num points
|
|
37
|
+
offset += 4
|
|
38
|
+
|
|
39
|
+
for (const [lon, lat] of coords) {
|
|
40
|
+
view.setFloat64(offset, lon, true)
|
|
41
|
+
offset += 8
|
|
42
|
+
view.setFloat64(offset, lat, true)
|
|
43
|
+
offset += 8
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return new Uint8Array(buffer)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create WKB Polygon geometry.
|
|
51
|
+
*/
|
|
52
|
+
function createPolygonWkb(rings: [number, number][][]): Uint8Array {
|
|
53
|
+
const totalPoints = rings.reduce((sum, ring) => sum + ring.length, 0)
|
|
54
|
+
const buffer = new ArrayBuffer(
|
|
55
|
+
1 + 4 + 4 + rings.length * 4 + totalPoints * 16,
|
|
56
|
+
)
|
|
57
|
+
const view = new DataView(buffer)
|
|
58
|
+
let offset = 0
|
|
59
|
+
|
|
60
|
+
view.setUint8(offset, 1) // little endian
|
|
61
|
+
offset += 1
|
|
62
|
+
view.setUint32(offset, 3, true) // Polygon type
|
|
63
|
+
offset += 4
|
|
64
|
+
view.setUint32(offset, rings.length, true) // num rings
|
|
65
|
+
offset += 4
|
|
66
|
+
|
|
67
|
+
for (const ring of rings) {
|
|
68
|
+
view.setUint32(offset, ring.length, true)
|
|
69
|
+
offset += 4
|
|
70
|
+
for (const [lon, lat] of ring) {
|
|
71
|
+
view.setFloat64(offset, lon, true)
|
|
72
|
+
offset += 8
|
|
73
|
+
view.setFloat64(offset, lat, true)
|
|
74
|
+
offset += 8
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return new Uint8Array(buffer)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Helper function to process rows using the builder.
|
|
83
|
+
*/
|
|
84
|
+
function processRows(
|
|
85
|
+
rows: GeoParquetRow[],
|
|
86
|
+
options?: { idColumn?: string; geometryColumn?: string; tagsColumn?: string },
|
|
87
|
+
) {
|
|
88
|
+
const builder = new GeoParquetOsmBuilder({}, options, () => {})
|
|
89
|
+
builder.processGeoParquetRows(rows as unknown as Record<string, unknown>[])
|
|
90
|
+
return builder.buildOsm()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("@osmix/geoparquet: GeoParquetOsmBuilder", () => {
|
|
94
|
+
it("should convert Point features to Nodes", () => {
|
|
95
|
+
const rows: GeoParquetRow[] = [
|
|
96
|
+
{
|
|
97
|
+
type: "node",
|
|
98
|
+
id: 100n,
|
|
99
|
+
geometry: createPointWkb(-122.4194, 37.7749),
|
|
100
|
+
tags: { name: "San Francisco", population: "873965" },
|
|
101
|
+
bbox: [-122.4194, 37.7749, -122.4194, 37.7749],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
type: "node",
|
|
105
|
+
id: 200n,
|
|
106
|
+
geometry: createPointWkb(-122.4094, 37.7849),
|
|
107
|
+
tags: { name: "Another Point" },
|
|
108
|
+
bbox: [-122.4094, 37.7849, -122.4094, 37.7849],
|
|
109
|
+
},
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
const osm = processRows(rows)
|
|
113
|
+
|
|
114
|
+
expect(osm.nodes.size).toBe(2)
|
|
115
|
+
expect(osm.ways.size).toBe(0)
|
|
116
|
+
|
|
117
|
+
// Get nodes by index to check values
|
|
118
|
+
const node1 = osm.nodes.getByIndex(0)
|
|
119
|
+
const node2 = osm.nodes.getByIndex(1)
|
|
120
|
+
|
|
121
|
+
expect(node1).toBeDefined()
|
|
122
|
+
expect(node1?.lon).toBeCloseTo(-122.4194, 4)
|
|
123
|
+
expect(node1?.lat).toBeCloseTo(37.7749, 4)
|
|
124
|
+
expect(node1?.tags?.["name"]).toBe("San Francisco")
|
|
125
|
+
expect(node1?.tags?.["population"]).toBe("873965")
|
|
126
|
+
|
|
127
|
+
expect(node2).toBeDefined()
|
|
128
|
+
expect(node2?.lon).toBeCloseTo(-122.4094, 4)
|
|
129
|
+
expect(node2?.lat).toBeCloseTo(37.7849, 4)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("should convert Point features to Nodes with auto-generated IDs", () => {
|
|
133
|
+
const rows: GeoParquetRow[] = [
|
|
134
|
+
{
|
|
135
|
+
type: "node",
|
|
136
|
+
id: undefined as unknown as bigint, // No ID provided
|
|
137
|
+
geometry: createPointWkb(-122.4194, 37.7749),
|
|
138
|
+
tags: { name: "San Francisco" },
|
|
139
|
+
bbox: [-122.4194, 37.7749, -122.4194, 37.7749],
|
|
140
|
+
},
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
const osm = processRows(rows)
|
|
144
|
+
|
|
145
|
+
expect(osm.nodes.size).toBe(1)
|
|
146
|
+
|
|
147
|
+
// Auto-generated IDs start at -1
|
|
148
|
+
const node = osm.nodes.getById(-1)
|
|
149
|
+
expect(node).toBeDefined()
|
|
150
|
+
expect(node?.lon).toBeCloseTo(-122.4194, 4)
|
|
151
|
+
expect(node?.lat).toBeCloseTo(37.7749, 4)
|
|
152
|
+
expect(node?.tags?.["name"]).toBe("San Francisco")
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("should convert LineString features to Ways with Nodes", () => {
|
|
156
|
+
const rows: GeoParquetRow[] = [
|
|
157
|
+
{
|
|
158
|
+
type: "way",
|
|
159
|
+
id: undefined as unknown as bigint, // Auto-generate IDs
|
|
160
|
+
geometry: createLineStringWkb([
|
|
161
|
+
[-122.4194, 37.7749],
|
|
162
|
+
[-122.4094, 37.7849],
|
|
163
|
+
[-122.3994, 37.7949],
|
|
164
|
+
]),
|
|
165
|
+
tags: { highway: "primary", name: "Main Street" },
|
|
166
|
+
bbox: [-122.4194, 37.7749, -122.3994, 37.7949],
|
|
167
|
+
},
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
const osm = processRows(rows)
|
|
171
|
+
|
|
172
|
+
expect(osm.nodes.size).toBe(3)
|
|
173
|
+
expect(osm.ways.size).toBe(1)
|
|
174
|
+
|
|
175
|
+
const way = osm.ways.getById(-1)
|
|
176
|
+
expect(way).toBeDefined()
|
|
177
|
+
expect(way?.refs).toHaveLength(3)
|
|
178
|
+
expect(way?.tags?.["highway"]).toBe("primary")
|
|
179
|
+
expect(way?.tags?.["name"]).toBe("Main Street")
|
|
180
|
+
|
|
181
|
+
// Verify nodes were created with auto-generated IDs
|
|
182
|
+
const node1 = osm.nodes.getById(way!.refs[0]!)
|
|
183
|
+
const node2 = osm.nodes.getById(way!.refs[1]!)
|
|
184
|
+
const node3 = osm.nodes.getById(way!.refs[2]!)
|
|
185
|
+
|
|
186
|
+
expect(node1?.lon).toBeCloseTo(-122.4194, 4)
|
|
187
|
+
expect(node1?.lat).toBeCloseTo(37.7749, 4)
|
|
188
|
+
expect(node2?.lon).toBeCloseTo(-122.4094, 4)
|
|
189
|
+
expect(node2?.lat).toBeCloseTo(37.7849, 4)
|
|
190
|
+
expect(node3?.lon).toBeCloseTo(-122.3994, 4)
|
|
191
|
+
expect(node3?.lat).toBeCloseTo(37.7949, 4)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("should convert Polygon features to Ways with area tags", () => {
|
|
195
|
+
const rows: GeoParquetRow[] = [
|
|
196
|
+
{
|
|
197
|
+
type: "way",
|
|
198
|
+
id: undefined as unknown as bigint, // Auto-generate IDs
|
|
199
|
+
geometry: createPolygonWkb([
|
|
200
|
+
[
|
|
201
|
+
[-122.4194, 37.7749],
|
|
202
|
+
[-122.4094, 37.7749],
|
|
203
|
+
[-122.4094, 37.7849],
|
|
204
|
+
[-122.4194, 37.7849],
|
|
205
|
+
[-122.4194, 37.7749], // closed
|
|
206
|
+
],
|
|
207
|
+
]),
|
|
208
|
+
tags: { building: "yes", name: "Test Building" },
|
|
209
|
+
bbox: [-122.4194, 37.7749, -122.4094, 37.7849],
|
|
210
|
+
},
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
const osm = processRows(rows)
|
|
214
|
+
|
|
215
|
+
expect(osm.nodes.size).toBe(4) // 4 unique nodes
|
|
216
|
+
expect(osm.ways.size).toBe(1)
|
|
217
|
+
expect(osm.relations.size).toBe(0)
|
|
218
|
+
|
|
219
|
+
const way = osm.ways.getById(-1)
|
|
220
|
+
expect(way).toBeDefined()
|
|
221
|
+
expect(way?.tags?.["building"]).toBe("yes")
|
|
222
|
+
expect(way?.tags?.["name"]).toBe("Test Building")
|
|
223
|
+
expect(way?.tags?.["area"]).toBe("yes")
|
|
224
|
+
expect(way?.refs).toHaveLength(5) // 4 unique + closing
|
|
225
|
+
expect(way?.refs[0]).toBe(way?.refs[4]) // Ring is closed
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it("should convert Polygon with holes to relation with multiple Ways", () => {
|
|
229
|
+
const rows: GeoParquetRow[] = [
|
|
230
|
+
{
|
|
231
|
+
type: "relation",
|
|
232
|
+
id: undefined as unknown as bigint, // Auto-generate IDs
|
|
233
|
+
geometry: createPolygonWkb([
|
|
234
|
+
// Outer ring
|
|
235
|
+
[
|
|
236
|
+
[-122.4194, 37.7749],
|
|
237
|
+
[-122.4094, 37.7749],
|
|
238
|
+
[-122.4094, 37.7849],
|
|
239
|
+
[-122.4194, 37.7849],
|
|
240
|
+
[-122.4194, 37.7749],
|
|
241
|
+
],
|
|
242
|
+
// Hole
|
|
243
|
+
[
|
|
244
|
+
[-122.4164, 37.7779],
|
|
245
|
+
[-122.4144, 37.7779],
|
|
246
|
+
[-122.4144, 37.7799],
|
|
247
|
+
[-122.4164, 37.7799],
|
|
248
|
+
[-122.4164, 37.7779],
|
|
249
|
+
],
|
|
250
|
+
]),
|
|
251
|
+
tags: { building: "yes" },
|
|
252
|
+
bbox: [-122.4194, 37.7749, -122.4094, 37.7849],
|
|
253
|
+
},
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
const osm = processRows(rows)
|
|
257
|
+
|
|
258
|
+
expect(osm.ways.size).toBe(2)
|
|
259
|
+
expect(osm.relations.size).toBe(1)
|
|
260
|
+
|
|
261
|
+
// Get ways by index since IDs are auto-generated
|
|
262
|
+
const outerWay = osm.ways.getByIndex(0)
|
|
263
|
+
expect(outerWay).toBeDefined()
|
|
264
|
+
expect(outerWay?.tags?.["area"]).toBe("yes")
|
|
265
|
+
expect(outerWay?.tags?.["building"]).toBeUndefined() // Tags go on relation
|
|
266
|
+
|
|
267
|
+
const holeWay = osm.ways.getByIndex(1)
|
|
268
|
+
expect(holeWay).toBeDefined()
|
|
269
|
+
expect(holeWay?.tags?.["area"]).toBe("yes")
|
|
270
|
+
|
|
271
|
+
const relation = osm.relations.getById(-1)
|
|
272
|
+
expect(relation).toBeDefined()
|
|
273
|
+
expect(relation?.tags?.["type"]).toBe("multipolygon")
|
|
274
|
+
expect(relation?.tags?.["building"]).toBe("yes")
|
|
275
|
+
expect(relation?.members).toHaveLength(2)
|
|
276
|
+
expect(relation?.members[0]?.type).toBe("way")
|
|
277
|
+
expect(relation?.members[0]?.role).toBe("outer")
|
|
278
|
+
expect(relation?.members[1]?.type).toBe("way")
|
|
279
|
+
expect(relation?.members[1]?.role).toBe("inner")
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it("should handle JSON string tags", () => {
|
|
283
|
+
const rows: GeoParquetRow[] = [
|
|
284
|
+
{
|
|
285
|
+
type: "node",
|
|
286
|
+
id: undefined as unknown as bigint,
|
|
287
|
+
geometry: createPointWkb(-122.4194, 37.7749),
|
|
288
|
+
tags: '{"name":"Test","highway":"primary"}',
|
|
289
|
+
bbox: [-122.4194, 37.7749, -122.4194, 37.7749],
|
|
290
|
+
},
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
const osm = processRows(rows)
|
|
294
|
+
|
|
295
|
+
const node = osm.nodes.getById(-1)
|
|
296
|
+
expect(node?.tags?.["name"]).toBe("Test")
|
|
297
|
+
expect(node?.tags?.["highway"]).toBe("primary")
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it("should handle null tags", () => {
|
|
301
|
+
const rows: GeoParquetRow[] = [
|
|
302
|
+
{
|
|
303
|
+
type: "node",
|
|
304
|
+
id: undefined as unknown as bigint,
|
|
305
|
+
geometry: createPointWkb(-122.4194, 37.7749),
|
|
306
|
+
tags: null as unknown as string,
|
|
307
|
+
bbox: [-122.4194, 37.7749, -122.4194, 37.7749],
|
|
308
|
+
},
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
const osm = processRows(rows)
|
|
312
|
+
|
|
313
|
+
const node = osm.nodes.getById(-1)
|
|
314
|
+
expect(node?.tags).toBeUndefined()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it("should skip rows with missing geometry", () => {
|
|
318
|
+
const rows: GeoParquetRow[] = [
|
|
319
|
+
{
|
|
320
|
+
type: "node",
|
|
321
|
+
id: undefined as unknown as bigint,
|
|
322
|
+
geometry: undefined as unknown as Uint8Array,
|
|
323
|
+
tags: { name: "Missing geometry" },
|
|
324
|
+
bbox: [0, 0, 0, 0],
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
type: "node",
|
|
328
|
+
id: undefined as unknown as bigint,
|
|
329
|
+
geometry: createPointWkb(-122.4194, 37.7749),
|
|
330
|
+
tags: { name: "Valid point" },
|
|
331
|
+
bbox: [-122.4194, 37.7749, -122.4194, 37.7749],
|
|
332
|
+
},
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
const osm = processRows(rows)
|
|
336
|
+
|
|
337
|
+
expect(osm.nodes.size).toBe(1)
|
|
338
|
+
const node = osm.nodes.getById(-1)
|
|
339
|
+
expect(node?.tags?.["name"]).toBe("Valid point")
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it("should reuse nodes when features share coordinates", () => {
|
|
343
|
+
const rows: GeoParquetRow[] = [
|
|
344
|
+
{
|
|
345
|
+
type: "way",
|
|
346
|
+
id: undefined as unknown as bigint,
|
|
347
|
+
geometry: createLineStringWkb([
|
|
348
|
+
[-122.4194, 37.7749],
|
|
349
|
+
[-122.4094, 37.7849],
|
|
350
|
+
]),
|
|
351
|
+
tags: { highway: "primary" },
|
|
352
|
+
bbox: [-122.4194, 37.7749, -122.4094, 37.7849],
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
type: "way",
|
|
356
|
+
id: undefined as unknown as bigint,
|
|
357
|
+
geometry: createLineStringWkb([
|
|
358
|
+
[-122.4094, 37.7849], // Shared coordinate
|
|
359
|
+
[-122.3994, 37.7949],
|
|
360
|
+
]),
|
|
361
|
+
tags: { highway: "secondary" },
|
|
362
|
+
bbox: [-122.4094, 37.7849, -122.3994, 37.7949],
|
|
363
|
+
},
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
const osm = processRows(rows)
|
|
367
|
+
|
|
368
|
+
// Should have 3 nodes (shared coordinate is reused)
|
|
369
|
+
expect(osm.nodes.size).toBe(3)
|
|
370
|
+
expect(osm.ways.size).toBe(2)
|
|
371
|
+
|
|
372
|
+
const way1 = osm.ways.getById(-1)
|
|
373
|
+
const way2 = osm.ways.getById(-2)
|
|
374
|
+
|
|
375
|
+
expect(way1?.refs).toHaveLength(2)
|
|
376
|
+
expect(way2?.refs).toHaveLength(2)
|
|
377
|
+
// Shared node
|
|
378
|
+
expect(way1?.refs[1]).toBe(way2?.refs[0])
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it("should build indexes after processing", () => {
|
|
382
|
+
const rows: GeoParquetRow[] = [
|
|
383
|
+
{
|
|
384
|
+
type: "node",
|
|
385
|
+
id: undefined as unknown as bigint,
|
|
386
|
+
geometry: createPointWkb(-122.4194, 37.7749),
|
|
387
|
+
tags: { name: "Test" },
|
|
388
|
+
bbox: [-122.4194, 37.7749, -122.4194, 37.7749],
|
|
389
|
+
},
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
const osm = processRows(rows)
|
|
393
|
+
|
|
394
|
+
expect(osm.isReady()).toBe(true)
|
|
395
|
+
expect(osm.nodes.isReady()).toBe(true)
|
|
396
|
+
expect(osm.ways.isReady()).toBe(true)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it("should handle object tags", () => {
|
|
400
|
+
const rows: GeoParquetRow[] = [
|
|
401
|
+
{
|
|
402
|
+
type: "node",
|
|
403
|
+
id: undefined as unknown as bigint,
|
|
404
|
+
geometry: createPointWkb(-122.4194, 37.7749),
|
|
405
|
+
tags: { name: "Test", highway: "primary" },
|
|
406
|
+
bbox: [-122.4194, 37.7749, -122.4194, 37.7749],
|
|
407
|
+
},
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
const osm = processRows(rows)
|
|
411
|
+
|
|
412
|
+
const node = osm.nodes.getById(-1)
|
|
413
|
+
expect(node?.tags?.["name"]).toBe("Test")
|
|
414
|
+
expect(node?.tags?.["highway"]).toBe("primary")
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it("should handle custom column names", () => {
|
|
418
|
+
const rows = [
|
|
419
|
+
{
|
|
420
|
+
type: "node",
|
|
421
|
+
osm_id: undefined,
|
|
422
|
+
geom: createPointWkb(-122.4194, 37.7749),
|
|
423
|
+
properties: { name: "Custom columns" },
|
|
424
|
+
bbox: [-122.4194, 37.7749, -122.4194, 37.7749],
|
|
425
|
+
},
|
|
426
|
+
] as unknown as GeoParquetRow[]
|
|
427
|
+
|
|
428
|
+
const osm = processRows(rows, {
|
|
429
|
+
idColumn: "osm_id",
|
|
430
|
+
geometryColumn: "geom",
|
|
431
|
+
tagsColumn: "properties",
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
expect(osm.nodes.size).toBe(1)
|
|
435
|
+
const node = osm.nodes.getById(-1)
|
|
436
|
+
expect(node?.tags?.["name"]).toBe("Custom columns")
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it("should infer type from geometry when type column is missing", () => {
|
|
440
|
+
// Simple polygon without holes - should only create a way, not a relation
|
|
441
|
+
const simplePolygonRows: GeoParquetRow[] = [
|
|
442
|
+
{
|
|
443
|
+
type: undefined as unknown as "way", // Missing type
|
|
444
|
+
id: 123n,
|
|
445
|
+
geometry: createPolygonWkb([
|
|
446
|
+
[
|
|
447
|
+
[-122.4194, 37.7749],
|
|
448
|
+
[-122.4094, 37.7749],
|
|
449
|
+
[-122.4094, 37.7849],
|
|
450
|
+
[-122.4194, 37.7849],
|
|
451
|
+
[-122.4194, 37.7749],
|
|
452
|
+
],
|
|
453
|
+
]),
|
|
454
|
+
tags: { building: "yes" },
|
|
455
|
+
bbox: [-122.4194, 37.7749, -122.4094, 37.7849],
|
|
456
|
+
},
|
|
457
|
+
]
|
|
458
|
+
|
|
459
|
+
const osm1 = processRows(simplePolygonRows)
|
|
460
|
+
|
|
461
|
+
// Should create just a way, no relation
|
|
462
|
+
expect(osm1.ways.size).toBe(1)
|
|
463
|
+
expect(osm1.relations.size).toBe(0)
|
|
464
|
+
|
|
465
|
+
// Way should have the feature ID and tags
|
|
466
|
+
const way = osm1.ways.getById(123)
|
|
467
|
+
expect(way).toBeDefined()
|
|
468
|
+
expect(way?.tags?.["building"]).toBe("yes")
|
|
469
|
+
expect(way?.tags?.["area"]).toBe("yes")
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it("should infer relation type for polygon with holes when type is missing", () => {
|
|
473
|
+
// Polygon with hole - should create a relation
|
|
474
|
+
const polygonWithHoleRows: GeoParquetRow[] = [
|
|
475
|
+
{
|
|
476
|
+
type: undefined as unknown as "relation", // Missing type
|
|
477
|
+
id: 456n,
|
|
478
|
+
geometry: createPolygonWkb([
|
|
479
|
+
// Outer ring
|
|
480
|
+
[
|
|
481
|
+
[-122.4194, 37.7749],
|
|
482
|
+
[-122.4094, 37.7749],
|
|
483
|
+
[-122.4094, 37.7849],
|
|
484
|
+
[-122.4194, 37.7849],
|
|
485
|
+
[-122.4194, 37.7749],
|
|
486
|
+
],
|
|
487
|
+
// Hole
|
|
488
|
+
[
|
|
489
|
+
[-122.4164, 37.7779],
|
|
490
|
+
[-122.4144, 37.7779],
|
|
491
|
+
[-122.4144, 37.7799],
|
|
492
|
+
[-122.4164, 37.7799],
|
|
493
|
+
[-122.4164, 37.7779],
|
|
494
|
+
],
|
|
495
|
+
]),
|
|
496
|
+
tags: { building: "yes" },
|
|
497
|
+
bbox: [-122.4194, 37.7749, -122.4094, 37.7849],
|
|
498
|
+
},
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
const osm2 = processRows(polygonWithHoleRows)
|
|
502
|
+
|
|
503
|
+
// Should create 2 ways (outer + hole) and 1 relation
|
|
504
|
+
expect(osm2.ways.size).toBe(2)
|
|
505
|
+
expect(osm2.relations.size).toBe(1)
|
|
506
|
+
|
|
507
|
+
// Relation should have the feature ID and tags
|
|
508
|
+
const relation = osm2.relations.getById(456)
|
|
509
|
+
expect(relation).toBeDefined()
|
|
510
|
+
expect(relation?.tags?.["type"]).toBe("multipolygon")
|
|
511
|
+
expect(relation?.tags?.["building"]).toBe("yes")
|
|
512
|
+
expect(relation?.members).toHaveLength(2)
|
|
513
|
+
expect(relation?.members[0]?.role).toBe("outer")
|
|
514
|
+
expect(relation?.members[1]?.role).toBe("inner")
|
|
515
|
+
|
|
516
|
+
// Ways should not have the feature ID (456 goes to relation)
|
|
517
|
+
const outerWay = osm2.ways.getByIndex(0)
|
|
518
|
+
expect(outerWay?.id).not.toBe(456)
|
|
519
|
+
})
|
|
520
|
+
})
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { getFixturePath } from "@osmix/shared/fixtures"
|
|
3
|
+
import { fromGeoParquet, GeoParquetOsmBuilder } from "../src"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Integration tests using the Monaco highways GeoParquet fixture.
|
|
7
|
+
*
|
|
8
|
+
* The fixture was downloaded from LayerCake (https://openstreetmap.us/our-work/layercake/)
|
|
9
|
+
* using DuckDB with a bounding box filter for Monaco. The fixture has a flattened
|
|
10
|
+
* structure where each tag column is separate (highway, name, surface, etc.) rather
|
|
11
|
+
* than nested in a tags struct.
|
|
12
|
+
*
|
|
13
|
+
* Note: hyparquet automatically parses GeoParquet WKB geometry into GeoJSON objects.
|
|
14
|
+
*
|
|
15
|
+
* Monaco bounding box:
|
|
16
|
+
* - xmin: 7.409205, xmax: 7.448637
|
|
17
|
+
* - ymin: 43.72335, ymax: 43.75169
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
describe("@osmix/geoparquet: Monaco highways fixture", () => {
|
|
21
|
+
const fixtureFile = () => Bun.file(getFixturePath("monaco.parquet"))
|
|
22
|
+
const getOsm = async () => fromGeoParquet(await fixtureFile().arrayBuffer())
|
|
23
|
+
|
|
24
|
+
it("should load the monaco.parquet fixture", async () => {
|
|
25
|
+
const file = fixtureFile()
|
|
26
|
+
expect(await file.exists()).toBe(true)
|
|
27
|
+
|
|
28
|
+
const size = file.size
|
|
29
|
+
expect(size).toBeGreaterThan(0)
|
|
30
|
+
expect(size).toBeLessThan(1_000_000) // Should be a small fixture
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("should read parquet rows with correct structure", async () => {
|
|
34
|
+
const builder = new GeoParquetOsmBuilder({ id: "monaco" }, { rowEnd: 5 })
|
|
35
|
+
const rows = await builder.readParquetRows(
|
|
36
|
+
await fixtureFile().arrayBuffer(),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
expect(rows.length).toBe(5)
|
|
40
|
+
|
|
41
|
+
// Verify row structure
|
|
42
|
+
const firstRow = rows[0]!
|
|
43
|
+
expect(firstRow["id"]).toBeDefined()
|
|
44
|
+
expect(firstRow["geometry"]).toBeDefined()
|
|
45
|
+
// expect(firstRow.osm_type).toBeDefined()
|
|
46
|
+
|
|
47
|
+
// hyparquet parses GeoParquet WKB into GeoJSON automatically
|
|
48
|
+
expect(firstRow["geometry"].type).toBeDefined()
|
|
49
|
+
// Verify it's a geometry with coordinates (not a GeometryCollection)
|
|
50
|
+
expect(["Point", "LineString", "Polygon", "MultiPolygon"]).toContain(
|
|
51
|
+
firstRow["geometry"].type,
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("should convert highway features to OSM entities", async () => {
|
|
56
|
+
const osm = await getOsm()
|
|
57
|
+
|
|
58
|
+
// Should have created nodes and ways from the highway features
|
|
59
|
+
expect(osm.nodes.size).toBeGreaterThan(0)
|
|
60
|
+
expect(osm.ways.size).toBeGreaterThan(0)
|
|
61
|
+
|
|
62
|
+
// Monaco is small, so we expect reasonable numbers
|
|
63
|
+
// The highway extract should have a few hundred to a few thousand ways
|
|
64
|
+
expect(osm.ways.size).toBeGreaterThan(100)
|
|
65
|
+
expect(osm.ways.size).toBeLessThan(10_000)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("should preserve highway tags from GeoParquet columns", async () => {
|
|
69
|
+
const osm = await getOsm()
|
|
70
|
+
|
|
71
|
+
// Find ways with highway tags
|
|
72
|
+
let highwayCount = 0
|
|
73
|
+
for (let i = 0; i < osm.ways.size; i++) {
|
|
74
|
+
const way = osm.ways.getByIndex(i)
|
|
75
|
+
if (way?.tags?.["highway"]) {
|
|
76
|
+
highwayCount++
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Most features should have highway tags since this is the highways layer
|
|
81
|
+
expect(highwayCount).toBeGreaterThan(0)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("should have valid node coordinates within Monaco bounds", async () => {
|
|
85
|
+
const osm = await getOsm()
|
|
86
|
+
|
|
87
|
+
// Monaco bounding box (slightly expanded for node positions)
|
|
88
|
+
const minLon = 7.4
|
|
89
|
+
const maxLon = 7.45
|
|
90
|
+
const minLat = 43.72
|
|
91
|
+
const maxLat = 43.76
|
|
92
|
+
|
|
93
|
+
// Check that all nodes are within Monaco bounds
|
|
94
|
+
let validNodes = 0
|
|
95
|
+
for (let i = 0; i < osm.nodes.size; i++) {
|
|
96
|
+
const node = osm.nodes.getByIndex(i)
|
|
97
|
+
if (node) {
|
|
98
|
+
expect(node.lon).toBeGreaterThanOrEqual(minLon)
|
|
99
|
+
expect(node.lon).toBeLessThanOrEqual(maxLon)
|
|
100
|
+
expect(node.lat).toBeGreaterThanOrEqual(minLat)
|
|
101
|
+
expect(node.lat).toBeLessThanOrEqual(maxLat)
|
|
102
|
+
validNodes++
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
expect(validNodes).toBeGreaterThan(0)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("should build spatial indexes after loading", async () => {
|
|
110
|
+
const osm = await getOsm()
|
|
111
|
+
|
|
112
|
+
// Indexes should be built
|
|
113
|
+
expect(osm.isReady()).toBe(true)
|
|
114
|
+
expect(osm.nodes.isReady()).toBe(true)
|
|
115
|
+
expect(osm.ways.isReady()).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("should handle ways with multiple nodes", async () => {
|
|
119
|
+
const osm = await getOsm()
|
|
120
|
+
// Count ways with enough nodes
|
|
121
|
+
let validWays = 0
|
|
122
|
+
for (let i = 0; i < osm.ways.size; i++) {
|
|
123
|
+
const way = osm.ways.getByIndex(i)
|
|
124
|
+
if (way && way.refs.length >= 2) {
|
|
125
|
+
validWays++
|
|
126
|
+
// All node refs should resolve to actual nodes
|
|
127
|
+
for (const ref of way.refs) {
|
|
128
|
+
const node = osm.nodes.getById(ref)
|
|
129
|
+
expect(node).toBeDefined()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Most ways should have at least 2 nodes (minimum for a LineString)
|
|
135
|
+
expect(validWays).toBeGreaterThan(0)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("should handle maxRows option to limit features", async () => {
|
|
139
|
+
const builder = new GeoParquetOsmBuilder({ id: "monaco" }, { rowEnd: 10 })
|
|
140
|
+
const rows = await builder.readParquetRows(
|
|
141
|
+
await fixtureFile().arrayBuffer(),
|
|
142
|
+
)
|
|
143
|
+
builder.processGeoParquetRows(rows)
|
|
144
|
+
const osm = builder.buildOsm()
|
|
145
|
+
|
|
146
|
+
// Should only have processed 10 features
|
|
147
|
+
expect(rows.length).toBe(10)
|
|
148
|
+
// Note: Some rows may be Point geometries (crossings) which don't create ways
|
|
149
|
+
expect(osm.nodes.size + osm.ways.size).toBeGreaterThan(0)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("should find common highway types in Monaco", async () => {
|
|
153
|
+
const osm = await getOsm()
|
|
154
|
+
|
|
155
|
+
const highwayTypes = new Map<string, number>()
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < osm.ways.size; i++) {
|
|
158
|
+
const way = osm.ways.getByIndex(i)
|
|
159
|
+
const highway = way?.tags?.["highway"]
|
|
160
|
+
if (highway && typeof highway === "string") {
|
|
161
|
+
highwayTypes.set(highway, (highwayTypes.get(highway) ?? 0) + 1)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Monaco should have some common highway types
|
|
166
|
+
const allTypes = Array.from(highwayTypes.keys())
|
|
167
|
+
expect(allTypes.length).toBeGreaterThan(0)
|
|
168
|
+
|
|
169
|
+
// Common types that should exist in Monaco's dense urban area
|
|
170
|
+
const commonTypes = ["residential", "footway", "service", "path", "steps"]
|
|
171
|
+
const foundCommonTypes = commonTypes.filter((t) => highwayTypes.has(t))
|
|
172
|
+
|
|
173
|
+
// At least some common types should be present
|
|
174
|
+
expect(foundCommonTypes.length).toBeGreaterThan(0)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("should handle surface and other highway attributes", async () => {
|
|
178
|
+
const osm = await getOsm()
|
|
179
|
+
|
|
180
|
+
// Check that some ways have additional attributes beyond just highway
|
|
181
|
+
let waysWithSurface = 0
|
|
182
|
+
let waysWithName = 0
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < osm.ways.size; i++) {
|
|
185
|
+
const way = osm.ways.getByIndex(i)
|
|
186
|
+
if (way?.tags) {
|
|
187
|
+
if (way.tags["surface"]) waysWithSurface++
|
|
188
|
+
if (way.tags["name"]) waysWithName++
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Some highways should have surface or name attributes
|
|
193
|
+
// (not all will, so we just check that at least some do)
|
|
194
|
+
expect(waysWithSurface + waysWithName).toBeGreaterThan(0)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("should reuse nodes for connected highways", async () => {
|
|
198
|
+
const osm = await getOsm()
|
|
199
|
+
|
|
200
|
+
// Count how many ways reference the same nodes
|
|
201
|
+
const nodeRefCounts = new Map<number, number>()
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < osm.ways.size; i++) {
|
|
204
|
+
const way = osm.ways.getByIndex(i)
|
|
205
|
+
if (way) {
|
|
206
|
+
for (const ref of way.refs) {
|
|
207
|
+
nodeRefCounts.set(ref, (nodeRefCounts.get(ref) ?? 0) + 1)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Some nodes should be shared between multiple ways (intersections)
|
|
213
|
+
const sharedNodes = Array.from(nodeRefCounts.values()).filter((c) => c > 1)
|
|
214
|
+
expect(sharedNodes.length).toBeGreaterThan(0)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("should handle Point geometries (crossings)", async () => {
|
|
218
|
+
const osm = await getOsm()
|
|
219
|
+
|
|
220
|
+
// Find nodes with crossing tags (Point features become Nodes with tags)
|
|
221
|
+
let crossingCount = 0
|
|
222
|
+
for (let i = 0; i < osm.nodes.size; i++) {
|
|
223
|
+
const node = osm.nodes.getByIndex(i)
|
|
224
|
+
if (node?.tags?.["highway"] === "crossing") {
|
|
225
|
+
crossingCount++
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Monaco should have some pedestrian crossings
|
|
230
|
+
expect(crossingCount).toBeGreaterThan(0)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("should count geometry types in the fixture", async () => {
|
|
234
|
+
const builder = new GeoParquetOsmBuilder({ id: "monaco" })
|
|
235
|
+
const rows = await builder.readParquetRows(
|
|
236
|
+
await fixtureFile().arrayBuffer(),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
const typeCounts = new Map<string, number>()
|
|
240
|
+
for (const row of rows) {
|
|
241
|
+
const type = row["geometry"]?.type ?? "unknown"
|
|
242
|
+
typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Highways layer should have mostly LineStrings (roads) and some Points (crossings)
|
|
246
|
+
expect(typeCounts.get("LineString") ?? 0).toBeGreaterThan(0)
|
|
247
|
+
expect(typeCounts.get("Point") ?? 0).toBeGreaterThan(0)
|
|
248
|
+
})
|
|
249
|
+
})
|
package/test/wkb.test.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { parseWkb } from "../src/wkb"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helper to create a WKB Point with properly encoded float64 coordinates.
|
|
6
|
+
*/
|
|
7
|
+
function createPointWkb(
|
|
8
|
+
lon: number,
|
|
9
|
+
lat: number,
|
|
10
|
+
littleEndian = true,
|
|
11
|
+
): Uint8Array {
|
|
12
|
+
const buffer = new ArrayBuffer(21)
|
|
13
|
+
const view = new DataView(buffer)
|
|
14
|
+
let offset = 0
|
|
15
|
+
|
|
16
|
+
view.setUint8(offset, littleEndian ? 1 : 0)
|
|
17
|
+
offset += 1
|
|
18
|
+
view.setUint32(offset, 1, littleEndian) // Point type
|
|
19
|
+
offset += 4
|
|
20
|
+
view.setFloat64(offset, lon, littleEndian)
|
|
21
|
+
offset += 8
|
|
22
|
+
view.setFloat64(offset, lat, littleEndian)
|
|
23
|
+
|
|
24
|
+
return new Uint8Array(buffer)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("@osmix/geoparquet: WKB Parser", () => {
|
|
28
|
+
it("should parse a Point geometry (little endian)", () => {
|
|
29
|
+
const wkb = createPointWkb(-122.4194, 37.7749, true)
|
|
30
|
+
|
|
31
|
+
const geometry = parseWkb(wkb)
|
|
32
|
+
|
|
33
|
+
expect(geometry.type).toBe("Point")
|
|
34
|
+
if (geometry.type === "Point") {
|
|
35
|
+
expect(geometry.coordinates[0]).toBeCloseTo(-122.4194, 4)
|
|
36
|
+
expect(geometry.coordinates[1]).toBeCloseTo(37.7749, 4)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("should parse a Point geometry (big endian)", () => {
|
|
41
|
+
const wkb = createPointWkb(-122.4194, 37.7749, false)
|
|
42
|
+
|
|
43
|
+
const geometry = parseWkb(wkb)
|
|
44
|
+
|
|
45
|
+
expect(geometry.type).toBe("Point")
|
|
46
|
+
if (geometry.type === "Point") {
|
|
47
|
+
expect(geometry.coordinates[0]).toBeCloseTo(-122.4194, 4)
|
|
48
|
+
expect(geometry.coordinates[1]).toBeCloseTo(37.7749, 4)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("should parse a LineString geometry", () => {
|
|
53
|
+
// WKB LineString with 3 points
|
|
54
|
+
const buffer = new ArrayBuffer(1 + 4 + 4 + 3 * 16)
|
|
55
|
+
const view = new DataView(buffer)
|
|
56
|
+
let offset = 0
|
|
57
|
+
|
|
58
|
+
// Byte order: little endian
|
|
59
|
+
view.setUint8(offset, 1)
|
|
60
|
+
offset += 1
|
|
61
|
+
|
|
62
|
+
// Geometry type: LineString (2)
|
|
63
|
+
view.setUint32(offset, 2, true)
|
|
64
|
+
offset += 4
|
|
65
|
+
|
|
66
|
+
// Number of points: 3
|
|
67
|
+
view.setUint32(offset, 3, true)
|
|
68
|
+
offset += 4
|
|
69
|
+
|
|
70
|
+
// Point 1: -122.4194, 37.7749
|
|
71
|
+
view.setFloat64(offset, -122.4194, true)
|
|
72
|
+
offset += 8
|
|
73
|
+
view.setFloat64(offset, 37.7749, true)
|
|
74
|
+
offset += 8
|
|
75
|
+
|
|
76
|
+
// Point 2: -122.4094, 37.7849
|
|
77
|
+
view.setFloat64(offset, -122.4094, true)
|
|
78
|
+
offset += 8
|
|
79
|
+
view.setFloat64(offset, 37.7849, true)
|
|
80
|
+
offset += 8
|
|
81
|
+
|
|
82
|
+
// Point 3: -122.3994, 37.7949
|
|
83
|
+
view.setFloat64(offset, -122.3994, true)
|
|
84
|
+
offset += 8
|
|
85
|
+
view.setFloat64(offset, 37.7949, true)
|
|
86
|
+
|
|
87
|
+
const wkb = new Uint8Array(buffer)
|
|
88
|
+
const geometry = parseWkb(wkb)
|
|
89
|
+
|
|
90
|
+
expect(geometry.type).toBe("LineString")
|
|
91
|
+
if (geometry.type === "LineString") {
|
|
92
|
+
expect(geometry.coordinates).toHaveLength(3)
|
|
93
|
+
expect(geometry.coordinates[0]?.[0]).toBeCloseTo(-122.4194, 4)
|
|
94
|
+
expect(geometry.coordinates[0]?.[1]).toBeCloseTo(37.7749, 4)
|
|
95
|
+
expect(geometry.coordinates[1]?.[0]).toBeCloseTo(-122.4094, 4)
|
|
96
|
+
expect(geometry.coordinates[1]?.[1]).toBeCloseTo(37.7849, 4)
|
|
97
|
+
expect(geometry.coordinates[2]?.[0]).toBeCloseTo(-122.3994, 4)
|
|
98
|
+
expect(geometry.coordinates[2]?.[1]).toBeCloseTo(37.7949, 4)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("should parse a Polygon geometry", () => {
|
|
103
|
+
// WKB Polygon with 1 ring of 5 points (closed)
|
|
104
|
+
const buffer = new ArrayBuffer(1 + 4 + 4 + 4 + 5 * 16)
|
|
105
|
+
const view = new DataView(buffer)
|
|
106
|
+
let offset = 0
|
|
107
|
+
|
|
108
|
+
// Byte order: little endian
|
|
109
|
+
view.setUint8(offset, 1)
|
|
110
|
+
offset += 1
|
|
111
|
+
|
|
112
|
+
// Geometry type: Polygon (3)
|
|
113
|
+
view.setUint32(offset, 3, true)
|
|
114
|
+
offset += 4
|
|
115
|
+
|
|
116
|
+
// Number of rings: 1
|
|
117
|
+
view.setUint32(offset, 1, true)
|
|
118
|
+
offset += 4
|
|
119
|
+
|
|
120
|
+
// Number of points in ring: 5
|
|
121
|
+
view.setUint32(offset, 5, true)
|
|
122
|
+
offset += 4
|
|
123
|
+
|
|
124
|
+
// Points (closed ring)
|
|
125
|
+
const coords = [
|
|
126
|
+
[-122.4194, 37.7749],
|
|
127
|
+
[-122.4094, 37.7749],
|
|
128
|
+
[-122.4094, 37.7849],
|
|
129
|
+
[-122.4194, 37.7849],
|
|
130
|
+
[-122.4194, 37.7749], // closed
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
for (const [lon, lat] of coords) {
|
|
134
|
+
view.setFloat64(offset, lon!, true)
|
|
135
|
+
offset += 8
|
|
136
|
+
view.setFloat64(offset, lat!, true)
|
|
137
|
+
offset += 8
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const wkb = new Uint8Array(buffer)
|
|
141
|
+
const geometry = parseWkb(wkb)
|
|
142
|
+
|
|
143
|
+
expect(geometry.type).toBe("Polygon")
|
|
144
|
+
if (geometry.type === "Polygon") {
|
|
145
|
+
expect(geometry.coordinates).toHaveLength(1)
|
|
146
|
+
expect(geometry.coordinates[0]).toHaveLength(5)
|
|
147
|
+
expect(geometry.coordinates[0]?.[0]?.[0]).toBeCloseTo(-122.4194, 4)
|
|
148
|
+
expect(geometry.coordinates[0]?.[0]?.[1]).toBeCloseTo(37.7749, 4)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("should parse a MultiPolygon geometry", () => {
|
|
153
|
+
// Helper to create polygon WKB
|
|
154
|
+
const createPolygonWkb = (
|
|
155
|
+
coords: [number, number][][],
|
|
156
|
+
): [ArrayBuffer, number] => {
|
|
157
|
+
const totalPoints = coords.reduce((sum, ring) => sum + ring.length, 0)
|
|
158
|
+
const size = 1 + 4 + 4 + coords.length * 4 + totalPoints * 16
|
|
159
|
+
const buffer = new ArrayBuffer(size)
|
|
160
|
+
const view = new DataView(buffer)
|
|
161
|
+
let offset = 0
|
|
162
|
+
|
|
163
|
+
// Byte order
|
|
164
|
+
view.setUint8(offset, 1)
|
|
165
|
+
offset += 1
|
|
166
|
+
|
|
167
|
+
// Polygon type
|
|
168
|
+
view.setUint32(offset, 3, true)
|
|
169
|
+
offset += 4
|
|
170
|
+
|
|
171
|
+
// Number of rings
|
|
172
|
+
view.setUint32(offset, coords.length, true)
|
|
173
|
+
offset += 4
|
|
174
|
+
|
|
175
|
+
for (const ring of coords) {
|
|
176
|
+
view.setUint32(offset, ring.length, true)
|
|
177
|
+
offset += 4
|
|
178
|
+
for (const [lon, lat] of ring) {
|
|
179
|
+
view.setFloat64(offset, lon, true)
|
|
180
|
+
offset += 8
|
|
181
|
+
view.setFloat64(offset, lat, true)
|
|
182
|
+
offset += 8
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return [buffer, size]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Create MultiPolygon with 2 polygons
|
|
190
|
+
const poly1Coords: [number, number][][] = [
|
|
191
|
+
[
|
|
192
|
+
[-122.4194, 37.7749],
|
|
193
|
+
[-122.4094, 37.7749],
|
|
194
|
+
[-122.4094, 37.7849],
|
|
195
|
+
[-122.4194, 37.7849],
|
|
196
|
+
[-122.4194, 37.7749],
|
|
197
|
+
],
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
const poly2Coords: [number, number][][] = [
|
|
201
|
+
[
|
|
202
|
+
[-122.3994, 37.7649],
|
|
203
|
+
[-122.3894, 37.7649],
|
|
204
|
+
[-122.3894, 37.7749],
|
|
205
|
+
[-122.3994, 37.7749],
|
|
206
|
+
[-122.3994, 37.7649],
|
|
207
|
+
],
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
const [poly1Buffer, poly1Size] = createPolygonWkb(poly1Coords)
|
|
211
|
+
const [poly2Buffer, poly2Size] = createPolygonWkb(poly2Coords)
|
|
212
|
+
|
|
213
|
+
// MultiPolygon header + 2 polygons
|
|
214
|
+
const multiBuffer = new ArrayBuffer(1 + 4 + 4 + poly1Size + poly2Size)
|
|
215
|
+
const multiView = new DataView(multiBuffer)
|
|
216
|
+
let offset = 0
|
|
217
|
+
|
|
218
|
+
// Byte order
|
|
219
|
+
multiView.setUint8(offset, 1)
|
|
220
|
+
offset += 1
|
|
221
|
+
|
|
222
|
+
// MultiPolygon type (6)
|
|
223
|
+
multiView.setUint32(offset, 6, true)
|
|
224
|
+
offset += 4
|
|
225
|
+
|
|
226
|
+
// Number of geometries
|
|
227
|
+
multiView.setUint32(offset, 2, true)
|
|
228
|
+
offset += 4
|
|
229
|
+
|
|
230
|
+
// Copy polygon 1
|
|
231
|
+
new Uint8Array(multiBuffer, offset, poly1Size).set(
|
|
232
|
+
new Uint8Array(poly1Buffer),
|
|
233
|
+
)
|
|
234
|
+
offset += poly1Size
|
|
235
|
+
|
|
236
|
+
// Copy polygon 2
|
|
237
|
+
new Uint8Array(multiBuffer, offset, poly2Size).set(
|
|
238
|
+
new Uint8Array(poly2Buffer),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
const wkb = new Uint8Array(multiBuffer)
|
|
242
|
+
const geometry = parseWkb(wkb)
|
|
243
|
+
|
|
244
|
+
expect(geometry.type).toBe("MultiPolygon")
|
|
245
|
+
if (geometry.type === "MultiPolygon") {
|
|
246
|
+
expect(geometry.coordinates).toHaveLength(2)
|
|
247
|
+
expect(geometry.coordinates[0]).toHaveLength(1)
|
|
248
|
+
expect(geometry.coordinates[1]).toHaveLength(1)
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("should handle EWKB with SRID flag", () => {
|
|
253
|
+
// EWKB Point with SRID (SRID flag: 0x20000000)
|
|
254
|
+
const buffer = new ArrayBuffer(1 + 4 + 4 + 16)
|
|
255
|
+
const view = new DataView(buffer)
|
|
256
|
+
let offset = 0
|
|
257
|
+
|
|
258
|
+
// Byte order: little endian
|
|
259
|
+
view.setUint8(offset, 1)
|
|
260
|
+
offset += 1
|
|
261
|
+
|
|
262
|
+
// Geometry type: Point (1) with SRID flag (0x20000001)
|
|
263
|
+
view.setUint32(offset, 0x20000001, true)
|
|
264
|
+
offset += 4
|
|
265
|
+
|
|
266
|
+
// SRID: 4326
|
|
267
|
+
view.setUint32(offset, 4326, true)
|
|
268
|
+
offset += 4
|
|
269
|
+
|
|
270
|
+
// Point: -122.4194, 37.7749
|
|
271
|
+
view.setFloat64(offset, -122.4194, true)
|
|
272
|
+
offset += 8
|
|
273
|
+
view.setFloat64(offset, 37.7749, true)
|
|
274
|
+
|
|
275
|
+
const wkb = new Uint8Array(buffer)
|
|
276
|
+
const geometry = parseWkb(wkb)
|
|
277
|
+
|
|
278
|
+
expect(geometry.type).toBe("Point")
|
|
279
|
+
if (geometry.type === "Point") {
|
|
280
|
+
expect(geometry.coordinates[0]).toBeCloseTo(-122.4194, 4)
|
|
281
|
+
expect(geometry.coordinates[1]).toBeCloseTo(37.7749, 4)
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it("should throw on unsupported geometry type", () => {
|
|
286
|
+
const wkb = new Uint8Array([
|
|
287
|
+
0x01, // little endian
|
|
288
|
+
0x64,
|
|
289
|
+
0x00,
|
|
290
|
+
0x00,
|
|
291
|
+
0x00, // Invalid type (100)
|
|
292
|
+
])
|
|
293
|
+
|
|
294
|
+
expect(() => parseWkb(wkb)).toThrow("Unsupported WKB geometry type: 100")
|
|
295
|
+
})
|
|
296
|
+
})
|