@osmix/geoparquet 0.1.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 +21 -0
- package/README.md +47 -0
- package/dist/src/from-geoparquet.d.ts +68 -0
- package/dist/src/from-geoparquet.d.ts.map +1 -0
- package/dist/src/from-geoparquet.js +455 -0
- package/dist/src/from-geoparquet.js.map +1 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +27 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/types.d.ts +47 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +6 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/wkb.d.ts +22 -0
- package/dist/src/wkb.d.ts.map +1 -0
- package/dist/src/wkb.js +181 -0
- package/dist/src/wkb.js.map +1 -0
- package/dist/test/from-geoparquet.test.d.ts +2 -0
- package/dist/test/from-geoparquet.test.d.ts.map +1 -0
- package/dist/test/from-geoparquet.test.js +445 -0
- package/dist/test/from-geoparquet.test.js.map +1 -0
- package/dist/test/monaco-parquet.test.d.ts +2 -0
- package/dist/test/monaco-parquet.test.d.ts.map +1 -0
- package/dist/test/monaco-parquet.test.js +200 -0
- package/dist/test/monaco-parquet.test.js.map +1 -0
- package/dist/test/wkb.test.d.ts +2 -0
- package/dist/test/wkb.test.d.ts.map +1 -0
- package/dist/test/wkb.test.js +234 -0
- package/dist/test/wkb.test.js.map +1 -0
- package/package.json +53 -0
- package/src/from-geoparquet.ts +565 -0
- package/src/index.ts +27 -0
- package/src/types.ts +51 -0
- package/src/wkb.ts +218 -0
- 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.json +9 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { getFixturePath } from "@osmix/shared/test/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
|
+
})
|