@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +47 -0
  3. package/dist/src/from-geoparquet.d.ts +68 -0
  4. package/dist/src/from-geoparquet.d.ts.map +1 -0
  5. package/dist/src/from-geoparquet.js +455 -0
  6. package/dist/src/from-geoparquet.js.map +1 -0
  7. package/dist/src/index.d.ts +27 -0
  8. package/dist/src/index.d.ts.map +1 -0
  9. package/dist/src/index.js +27 -0
  10. package/dist/src/index.js.map +1 -0
  11. package/dist/src/types.d.ts +47 -0
  12. package/dist/src/types.d.ts.map +1 -0
  13. package/dist/src/types.js +6 -0
  14. package/dist/src/types.js.map +1 -0
  15. package/dist/src/wkb.d.ts +22 -0
  16. package/dist/src/wkb.d.ts.map +1 -0
  17. package/dist/src/wkb.js +181 -0
  18. package/dist/src/wkb.js.map +1 -0
  19. package/dist/test/from-geoparquet.test.d.ts +2 -0
  20. package/dist/test/from-geoparquet.test.d.ts.map +1 -0
  21. package/dist/test/from-geoparquet.test.js +445 -0
  22. package/dist/test/from-geoparquet.test.js.map +1 -0
  23. package/dist/test/monaco-parquet.test.d.ts +2 -0
  24. package/dist/test/monaco-parquet.test.d.ts.map +1 -0
  25. package/dist/test/monaco-parquet.test.js +200 -0
  26. package/dist/test/monaco-parquet.test.js.map +1 -0
  27. package/dist/test/wkb.test.d.ts +2 -0
  28. package/dist/test/wkb.test.d.ts.map +1 -0
  29. package/dist/test/wkb.test.js +234 -0
  30. package/dist/test/wkb.test.js.map +1 -0
  31. package/package.json +53 -0
  32. package/src/from-geoparquet.ts +565 -0
  33. package/src/index.ts +27 -0
  34. package/src/types.ts +51 -0
  35. package/src/wkb.ts +218 -0
  36. package/test/download-monaco-highways.sh +40 -0
  37. package/test/from-geoparquet.test.ts +520 -0
  38. package/test/monaco-parquet.test.ts +249 -0
  39. package/test/wkb.test.ts +296 -0
  40. package/tsconfig.json +9 -0
@@ -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
+ })