@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,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GeoParquet to OSM conversion utilities.
|
|
3
|
+
*
|
|
4
|
+
* Imports GeoParquet files into Osm indexes, mapping geometry
|
|
5
|
+
* types to appropriate OSM entity structures.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Osm, type OsmOptions } from "@osmix/core"
|
|
11
|
+
import {
|
|
12
|
+
logProgress,
|
|
13
|
+
type ProgressEvent,
|
|
14
|
+
progressEvent,
|
|
15
|
+
} from "@osmix/shared/progress"
|
|
16
|
+
import type { GeoBbox2D, OsmRelationMember, OsmTags } from "@osmix/shared/types"
|
|
17
|
+
import { rewindFeature } from "@placemarkio/geojson-rewind"
|
|
18
|
+
import type {
|
|
19
|
+
Geometry,
|
|
20
|
+
LineString,
|
|
21
|
+
MultiLineString,
|
|
22
|
+
MultiPolygon,
|
|
23
|
+
Point,
|
|
24
|
+
Polygon,
|
|
25
|
+
} from "geojson"
|
|
26
|
+
import {
|
|
27
|
+
type AsyncBuffer,
|
|
28
|
+
asyncBufferFromUrl,
|
|
29
|
+
type ParquetReadOptions,
|
|
30
|
+
parquetReadObjects,
|
|
31
|
+
} from "hyparquet"
|
|
32
|
+
import type { GeoParquetReadOptions, GeoParquetSource } from "./types"
|
|
33
|
+
import { parseWkb } from "./wkb"
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create an Osm index from GeoParquet data.
|
|
37
|
+
*
|
|
38
|
+
* Accepts various input formats (file path, URL, or buffer) and converts
|
|
39
|
+
* features to OSM entities:
|
|
40
|
+
* - Point → Node
|
|
41
|
+
* - LineString → Way with nodes
|
|
42
|
+
* - Polygon → Way (simple) or Relation (with holes)
|
|
43
|
+
* - MultiPolygon → Relation
|
|
44
|
+
*
|
|
45
|
+
* Feature IDs are preserved if available; otherwise sequential negative IDs
|
|
46
|
+
* are assigned. Feature tags become OSM tags.
|
|
47
|
+
*
|
|
48
|
+
* @param source - GeoParquet data source (file path, URL, or buffer)
|
|
49
|
+
* @param options - Osm index options (id, header)
|
|
50
|
+
* @param readOptions - Options for reading the parquet file
|
|
51
|
+
* @param onProgress - Progress callback for UI feedback
|
|
52
|
+
* @returns Populated Osm index with built indexes
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* import { fromGeoParquet } from "@osmix/geoparquet"
|
|
57
|
+
*
|
|
58
|
+
* // From file
|
|
59
|
+
* const osm = await fromGeoParquet("./roads.parquet")
|
|
60
|
+
*
|
|
61
|
+
* // Query the imported data
|
|
62
|
+
* const highways = osm.ways.search("highway")
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export async function fromGeoParquet(
|
|
66
|
+
source: GeoParquetSource,
|
|
67
|
+
options: Partial<OsmOptions> = {},
|
|
68
|
+
readOptions: GeoParquetReadOptions = {},
|
|
69
|
+
onProgress: (progress: ProgressEvent) => void = logProgress,
|
|
70
|
+
): Promise<Osm> {
|
|
71
|
+
const builder = new GeoParquetOsmBuilder(options, readOptions, onProgress)
|
|
72
|
+
|
|
73
|
+
onProgress(progressEvent("Loading GeoParquet file..."))
|
|
74
|
+
|
|
75
|
+
// Read rows from parquet file
|
|
76
|
+
const rows = await builder.readParquetRows(source)
|
|
77
|
+
|
|
78
|
+
onProgress(progressEvent(`Processing ${rows.length} features...`))
|
|
79
|
+
|
|
80
|
+
// Convert to OSM entities
|
|
81
|
+
builder.processGeoParquetRows(rows)
|
|
82
|
+
|
|
83
|
+
return builder.buildOsm()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class GeoParquetOsmBuilder {
|
|
87
|
+
private osm: Osm
|
|
88
|
+
private readOptions: GeoParquetReadOptions
|
|
89
|
+
private onProgress: (progress: ProgressEvent) => void
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
osmOptions: Partial<OsmOptions> = {},
|
|
93
|
+
readOptions: GeoParquetReadOptions = {},
|
|
94
|
+
onProgress: (progress: ProgressEvent) => void = logProgress,
|
|
95
|
+
) {
|
|
96
|
+
this.osm = new Osm(osmOptions)
|
|
97
|
+
this.readOptions = readOptions
|
|
98
|
+
this.onProgress = onProgress
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async readParquetRows(source: GeoParquetSource) {
|
|
102
|
+
let file: AsyncBuffer
|
|
103
|
+
if (typeof source === "string") {
|
|
104
|
+
// String sources are treated as URLs
|
|
105
|
+
this.onProgress(progressEvent(`Fetching from URL: ${source}`))
|
|
106
|
+
file = await asyncBufferFromUrl({ url: source })
|
|
107
|
+
} else if (source instanceof URL) {
|
|
108
|
+
this.onProgress(progressEvent(`Fetching from URL: ${source.href}`))
|
|
109
|
+
file = await asyncBufferFromUrl({ url: source.href })
|
|
110
|
+
} else if (source instanceof ArrayBuffer) {
|
|
111
|
+
// Wrap ArrayBuffer as AsyncBuffer
|
|
112
|
+
file = {
|
|
113
|
+
byteLength: source.byteLength,
|
|
114
|
+
slice: (start: number, end?: number) => source.slice(start, end),
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
// Assume it's already an AsyncBuffer
|
|
118
|
+
file = source
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const readConfig: Omit<ParquetReadOptions, "onComplete"> = {
|
|
122
|
+
file,
|
|
123
|
+
...this.readOptions,
|
|
124
|
+
columns: [
|
|
125
|
+
this.readOptions.typeColumn ?? "type",
|
|
126
|
+
this.readOptions.idColumn ?? "id",
|
|
127
|
+
this.readOptions.geometryColumn ?? "geometry",
|
|
128
|
+
this.readOptions.tagsColumn ?? "tags",
|
|
129
|
+
this.readOptions.bboxColumn ?? "bbox",
|
|
130
|
+
],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.onProgress(progressEvent("Reading parquet data..."))
|
|
134
|
+
return parquetReadObjects(readConfig)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Converts GeoParquet rows to OSM entities.
|
|
139
|
+
*/
|
|
140
|
+
processGeoParquetRows(rows: Record<string, unknown>[]) {
|
|
141
|
+
this.onProgress(progressEvent("Converting GeoParquet to Osmix..."))
|
|
142
|
+
|
|
143
|
+
const idColumn = this.readOptions.idColumn ?? "id"
|
|
144
|
+
const typeColumn = this.readOptions.typeColumn ?? "type"
|
|
145
|
+
const geometryColumn = this.readOptions.geometryColumn ?? "geometry"
|
|
146
|
+
const tagsColumn = this.readOptions.tagsColumn ?? "tags"
|
|
147
|
+
const bboxColumn = this.readOptions.bboxColumn ?? "bbox"
|
|
148
|
+
|
|
149
|
+
// Process each row
|
|
150
|
+
let count = 0
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
// Extract values using column names
|
|
153
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic column access
|
|
154
|
+
const rowAny = row as any
|
|
155
|
+
const id = rowAny[idColumn] as bigint | number
|
|
156
|
+
const type = rowAny[typeColumn] as "node" | "way" | "relation"
|
|
157
|
+
const geometryData = rowAny[geometryColumn] as
|
|
158
|
+
| Uint8Array
|
|
159
|
+
| GeoJSON.Geometry
|
|
160
|
+
| string
|
|
161
|
+
const tagsData = rowAny[tagsColumn] as
|
|
162
|
+
| Record<string, string | number>
|
|
163
|
+
| string
|
|
164
|
+
// bboxData is read but currently unused - reserved for future optimization
|
|
165
|
+
void (rowAny[bboxColumn] as GeoBbox2D)
|
|
166
|
+
|
|
167
|
+
if (!geometryData) {
|
|
168
|
+
count++
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Parse WKB geometry
|
|
173
|
+
let geometry: Geometry
|
|
174
|
+
try {
|
|
175
|
+
if (geometryData instanceof Uint8Array) {
|
|
176
|
+
geometry = parseWkb(geometryData)
|
|
177
|
+
} else if (typeof geometryData === "string") {
|
|
178
|
+
geometry = JSON.parse(geometryData) as Geometry
|
|
179
|
+
} else {
|
|
180
|
+
geometry = geometryData
|
|
181
|
+
}
|
|
182
|
+
} catch (_e) {
|
|
183
|
+
// Skip invalid geometries
|
|
184
|
+
count++
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Parse tags
|
|
189
|
+
const tags = parseTags(tagsData)
|
|
190
|
+
|
|
191
|
+
// Get numeric ID from bigint or generate one
|
|
192
|
+
const numericId =
|
|
193
|
+
id !== undefined
|
|
194
|
+
? typeof id === "bigint"
|
|
195
|
+
? Number(id)
|
|
196
|
+
: id
|
|
197
|
+
: undefined
|
|
198
|
+
|
|
199
|
+
// Normalize geometry winding order
|
|
200
|
+
const normalizedGeometry = normalizeGeometry(geometry)
|
|
201
|
+
|
|
202
|
+
if (normalizedGeometry.type === "Point") {
|
|
203
|
+
this.processPoint(normalizedGeometry, numericId, tags)
|
|
204
|
+
} else if (normalizedGeometry.type === "LineString") {
|
|
205
|
+
this.processLineString(normalizedGeometry, numericId, tags)
|
|
206
|
+
} else if (normalizedGeometry.type === "Polygon") {
|
|
207
|
+
if (type === "node")
|
|
208
|
+
throw Error(
|
|
209
|
+
`ID: ${numericId} has type 'node' but geometry is a polygon`,
|
|
210
|
+
)
|
|
211
|
+
// Infer type from geometry if missing: relation if has holes, way otherwise
|
|
212
|
+
const polygonType =
|
|
213
|
+
type ??
|
|
214
|
+
(normalizedGeometry.coordinates.length > 1 ? "relation" : "way")
|
|
215
|
+
this.processPolygon(normalizedGeometry, polygonType, numericId, tags)
|
|
216
|
+
} else if (normalizedGeometry.type === "MultiPolygon") {
|
|
217
|
+
this.processMultiPolygon(normalizedGeometry, numericId, tags)
|
|
218
|
+
} else if (normalizedGeometry.type === "MultiLineString") {
|
|
219
|
+
this.processMultiLineString(normalizedGeometry, numericId, tags)
|
|
220
|
+
} else {
|
|
221
|
+
throw Error(`Unsupported geometry type: ${normalizedGeometry.type}`)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
count++
|
|
225
|
+
if (count % 10000 === 0) {
|
|
226
|
+
this.onProgress(progressEvent(`Processed ${count} features...`))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.onProgress(progressEvent(`Imported ${count} features`))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
buildOsm() {
|
|
234
|
+
this.onProgress(
|
|
235
|
+
progressEvent(
|
|
236
|
+
"Finished converting GeoParquet to Osmix, building indexes...",
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
this.osm.buildIndexes()
|
|
240
|
+
this.osm.buildSpatialIndexes()
|
|
241
|
+
return this.osm
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private nextNodeId = -1
|
|
245
|
+
private nextWayId = -1
|
|
246
|
+
private nextRelationId = -1
|
|
247
|
+
getNextRelationId() {
|
|
248
|
+
return this.nextRelationId--
|
|
249
|
+
}
|
|
250
|
+
getNextWayId() {
|
|
251
|
+
return this.nextWayId--
|
|
252
|
+
}
|
|
253
|
+
getNextNodeId() {
|
|
254
|
+
return this.nextNodeId--
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Map to track nodes by coordinate string for reuse when creating ways
|
|
258
|
+
private nodeMap = new Map<string, number>()
|
|
259
|
+
|
|
260
|
+
// Helper to get or create a node for a coordinate
|
|
261
|
+
private getOrCreateNode(lon: number, lat: number): number {
|
|
262
|
+
const coordKey = `${lon},${lat}`
|
|
263
|
+
const existingNodeId = this.nodeMap.get(coordKey)
|
|
264
|
+
if (existingNodeId !== undefined) {
|
|
265
|
+
return existingNodeId
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const nodeId = this.getNextNodeId()
|
|
269
|
+
this.nodeMap.set(coordKey, nodeId)
|
|
270
|
+
this.osm.nodes.addNode({
|
|
271
|
+
id: nodeId,
|
|
272
|
+
lon,
|
|
273
|
+
lat,
|
|
274
|
+
})
|
|
275
|
+
return nodeId
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private processPoint(
|
|
279
|
+
geometry: Point,
|
|
280
|
+
featureId?: number,
|
|
281
|
+
tags?: OsmTags,
|
|
282
|
+
): number {
|
|
283
|
+
const [lon, lat] = geometry.coordinates
|
|
284
|
+
if (lon === undefined || lat === undefined)
|
|
285
|
+
throw Error("Point must have lon and lat coordinates")
|
|
286
|
+
|
|
287
|
+
const nodeId = featureId ?? this.getNextNodeId()
|
|
288
|
+
this.osm.nodes.addNode({
|
|
289
|
+
id: nodeId,
|
|
290
|
+
lon,
|
|
291
|
+
lat,
|
|
292
|
+
tags,
|
|
293
|
+
})
|
|
294
|
+
this.nodeMap.set(`${lon},${lat}`, nodeId)
|
|
295
|
+
return nodeId
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private processLineString(
|
|
299
|
+
geometry: LineString,
|
|
300
|
+
featureId?: number,
|
|
301
|
+
tags?: OsmTags,
|
|
302
|
+
): number {
|
|
303
|
+
const coordinates = geometry.coordinates
|
|
304
|
+
if (coordinates.length < 2)
|
|
305
|
+
throw Error("LineString must have at least 2 coordinates")
|
|
306
|
+
|
|
307
|
+
const nodeRefs: number[] = []
|
|
308
|
+
for (const [lon, lat] of coordinates) {
|
|
309
|
+
if (lon === undefined || lat === undefined)
|
|
310
|
+
throw Error("LineString coordinates must have lon and lat")
|
|
311
|
+
nodeRefs.push(this.getOrCreateNode(lon, lat))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (nodeRefs.length < 2)
|
|
315
|
+
throw Error("LineString must have at least 2 coordinates")
|
|
316
|
+
|
|
317
|
+
const wayId = featureId ?? this.getNextWayId()
|
|
318
|
+
this.osm.ways.addWay({
|
|
319
|
+
id: wayId,
|
|
320
|
+
refs: nodeRefs,
|
|
321
|
+
tags,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
return wayId
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private processMultiLineString(
|
|
328
|
+
geometry: MultiLineString,
|
|
329
|
+
featureId?: number,
|
|
330
|
+
tags?: OsmTags,
|
|
331
|
+
) {
|
|
332
|
+
const coordinates = geometry.coordinates
|
|
333
|
+
if (coordinates.length === 0)
|
|
334
|
+
throw Error("MultiLineString must have at least one LineString")
|
|
335
|
+
|
|
336
|
+
const wayIds: number[] = []
|
|
337
|
+
for (const line of coordinates) {
|
|
338
|
+
if (line.length < 2)
|
|
339
|
+
throw Error("LineString must have at least 2 coordinates")
|
|
340
|
+
const wayId = this.processLineString(
|
|
341
|
+
{ type: "LineString", coordinates: line },
|
|
342
|
+
this.getNextWayId(),
|
|
343
|
+
)
|
|
344
|
+
wayIds.push(wayId)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
this.osm.relations.addRelation({
|
|
348
|
+
id: featureId ?? this.getNextRelationId(),
|
|
349
|
+
members: wayIds.map((id) => ({ type: "way", ref: id })),
|
|
350
|
+
tags: { type: "multilinestring", ...tags },
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private processPolygon(
|
|
355
|
+
geometry: Polygon,
|
|
356
|
+
type: "way" | "relation",
|
|
357
|
+
featureId: number | undefined,
|
|
358
|
+
tags: OsmTags | undefined,
|
|
359
|
+
) {
|
|
360
|
+
const coordinates = geometry.coordinates
|
|
361
|
+
if (coordinates.length === 0) return
|
|
362
|
+
|
|
363
|
+
const outerRing = coordinates[0]
|
|
364
|
+
if (!outerRing || outerRing.length < 3) return
|
|
365
|
+
|
|
366
|
+
// Create nodes for outer ring
|
|
367
|
+
const outerNodeRefs: number[] = []
|
|
368
|
+
for (const [lon, lat] of outerRing) {
|
|
369
|
+
if (lon === undefined || lat === undefined) continue
|
|
370
|
+
const nodeId = this.getOrCreateNode(lon, lat)
|
|
371
|
+
outerNodeRefs.push(nodeId)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (outerNodeRefs.length < 3) return
|
|
375
|
+
|
|
376
|
+
// Ensure the outer ring is closed
|
|
377
|
+
if (outerNodeRefs[0] !== outerNodeRefs[outerNodeRefs.length - 1]) {
|
|
378
|
+
outerNodeRefs.push(outerNodeRefs[0]!)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const outerWayId =
|
|
382
|
+
type === "relation"
|
|
383
|
+
? this.getNextWayId()
|
|
384
|
+
: (featureId ?? this.getNextWayId())
|
|
385
|
+
this.osm.ways.addWay({
|
|
386
|
+
id: outerWayId,
|
|
387
|
+
refs: outerNodeRefs,
|
|
388
|
+
tags: type === "relation" ? { area: "yes" } : { area: "yes", ...tags },
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
if (type === "way") return
|
|
392
|
+
|
|
393
|
+
// Create separate ways for holes
|
|
394
|
+
const holeWayIds: number[] = []
|
|
395
|
+
for (let i = 1; i < coordinates.length; i++) {
|
|
396
|
+
const holeRing = coordinates[i]
|
|
397
|
+
if (!holeRing || holeRing.length < 3) continue
|
|
398
|
+
|
|
399
|
+
const holeNodeRefs: number[] = []
|
|
400
|
+
for (const [lon, lat] of holeRing) {
|
|
401
|
+
if (lon === undefined || lat === undefined) continue
|
|
402
|
+
const nodeId = this.getOrCreateNode(lon, lat)
|
|
403
|
+
holeNodeRefs.push(nodeId)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (holeNodeRefs.length < 3) continue
|
|
407
|
+
|
|
408
|
+
// Ensure the ring is closed
|
|
409
|
+
if (holeNodeRefs[0] !== holeNodeRefs[holeNodeRefs.length - 1]) {
|
|
410
|
+
holeNodeRefs.push(holeNodeRefs[0]!)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const holeWayId = this.getNextWayId()
|
|
414
|
+
this.osm.ways.addWay({
|
|
415
|
+
id: holeWayId,
|
|
416
|
+
refs: holeNodeRefs,
|
|
417
|
+
tags: { area: "yes" },
|
|
418
|
+
})
|
|
419
|
+
holeWayIds.push(holeWayId)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
this.osm.relations.addRelation({
|
|
423
|
+
id: featureId ?? this.getNextRelationId(),
|
|
424
|
+
members: [
|
|
425
|
+
{ type: "way", ref: outerWayId, role: "outer" },
|
|
426
|
+
...holeWayIds.map(
|
|
427
|
+
(id) =>
|
|
428
|
+
({ type: "way", ref: id, role: "inner" }) as OsmRelationMember,
|
|
429
|
+
),
|
|
430
|
+
],
|
|
431
|
+
tags: {
|
|
432
|
+
type: "multipolygon",
|
|
433
|
+
...tags,
|
|
434
|
+
},
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private processMultiPolygon(
|
|
439
|
+
geometry: MultiPolygon,
|
|
440
|
+
featureId: number | undefined,
|
|
441
|
+
tags: OsmTags | undefined,
|
|
442
|
+
) {
|
|
443
|
+
const coordinates = geometry.coordinates
|
|
444
|
+
if (coordinates.length === 0) return
|
|
445
|
+
|
|
446
|
+
const relationMembers: OsmRelationMember[] = []
|
|
447
|
+
|
|
448
|
+
for (const polygon of coordinates) {
|
|
449
|
+
if (polygon.length === 0) continue
|
|
450
|
+
|
|
451
|
+
const outerRing = polygon[0]
|
|
452
|
+
if (!outerRing || outerRing.length < 3) continue
|
|
453
|
+
|
|
454
|
+
// Create nodes for outer ring
|
|
455
|
+
const outerNodeRefs: number[] = []
|
|
456
|
+
for (const [lon, lat] of outerRing) {
|
|
457
|
+
if (lon === undefined || lat === undefined) continue
|
|
458
|
+
const nodeId = this.getOrCreateNode(lon, lat)
|
|
459
|
+
outerNodeRefs.push(nodeId)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (outerNodeRefs.length < 3) continue
|
|
463
|
+
|
|
464
|
+
// Ensure the ring is closed
|
|
465
|
+
if (outerNodeRefs[0] !== outerNodeRefs[outerNodeRefs.length - 1]) {
|
|
466
|
+
outerNodeRefs.push(outerNodeRefs[0]!)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const outerWayId = this.getNextWayId()
|
|
470
|
+
this.osm.ways.addWay({
|
|
471
|
+
id: outerWayId,
|
|
472
|
+
refs: outerNodeRefs,
|
|
473
|
+
tags: { area: "yes" },
|
|
474
|
+
})
|
|
475
|
+
relationMembers.push({ type: "way", ref: outerWayId, role: "outer" })
|
|
476
|
+
|
|
477
|
+
// Create separate ways for holes
|
|
478
|
+
for (let i = 1; i < polygon.length; i++) {
|
|
479
|
+
const holeRing = polygon[i]
|
|
480
|
+
if (!holeRing || holeRing.length < 3) continue
|
|
481
|
+
|
|
482
|
+
const holeNodeRefs: number[] = []
|
|
483
|
+
for (const [lon, lat] of holeRing) {
|
|
484
|
+
if (lon === undefined || lat === undefined) continue
|
|
485
|
+
const nodeId = this.getOrCreateNode(lon, lat)
|
|
486
|
+
holeNodeRefs.push(nodeId)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (holeNodeRefs.length < 3) continue
|
|
490
|
+
|
|
491
|
+
// Ensure the ring is closed
|
|
492
|
+
if (holeNodeRefs[0] !== holeNodeRefs[holeNodeRefs.length - 1]) {
|
|
493
|
+
holeNodeRefs.push(holeNodeRefs[0]!)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const holeWayId = this.getNextWayId()
|
|
497
|
+
this.osm.ways.addWay({
|
|
498
|
+
id: holeWayId,
|
|
499
|
+
refs: holeNodeRefs,
|
|
500
|
+
tags: { area: "yes" },
|
|
501
|
+
})
|
|
502
|
+
relationMembers.push({ type: "way", ref: holeWayId, role: "inner" })
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (relationMembers.length > 0) {
|
|
507
|
+
this.osm.relations.addRelation({
|
|
508
|
+
id: featureId ?? this.getNextRelationId(),
|
|
509
|
+
members: relationMembers,
|
|
510
|
+
tags: { type: "multipolygon", ...tags },
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Parse tags from various formats.
|
|
518
|
+
*/
|
|
519
|
+
function parseTags(
|
|
520
|
+
tagsData: Record<string, string | number> | string | null | undefined,
|
|
521
|
+
): OsmTags | undefined {
|
|
522
|
+
if (!tagsData) return undefined
|
|
523
|
+
|
|
524
|
+
if (typeof tagsData === "string") {
|
|
525
|
+
try {
|
|
526
|
+
const parsed = JSON.parse(tagsData)
|
|
527
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
528
|
+
return parsed as OsmTags
|
|
529
|
+
}
|
|
530
|
+
} catch {
|
|
531
|
+
return undefined
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (typeof tagsData === "object") {
|
|
536
|
+
const tags: OsmTags = {}
|
|
537
|
+
for (const [key, value] of Object.entries(tagsData)) {
|
|
538
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
539
|
+
tags[key] = value
|
|
540
|
+
} else if (value != null) {
|
|
541
|
+
tags[key] = String(value)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return Object.keys(tags).length > 0 ? tags : undefined
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return undefined
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Normalize geometry winding order for OSM conventions.
|
|
552
|
+
*/
|
|
553
|
+
function normalizeGeometry(geometry: Geometry): Geometry {
|
|
554
|
+
if (geometry.type === "Polygon" || geometry.type === "MultiPolygon") {
|
|
555
|
+
// Use rewind to ensure correct winding order
|
|
556
|
+
const feature = {
|
|
557
|
+
type: "Feature" as const,
|
|
558
|
+
geometry,
|
|
559
|
+
properties: {},
|
|
560
|
+
}
|
|
561
|
+
const rewound = rewindFeature(feature)
|
|
562
|
+
return rewound.geometry ?? geometry
|
|
563
|
+
}
|
|
564
|
+
return geometry
|
|
565
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @osmix/geoparquet - Import OSM data from GeoParquet files.
|
|
3
|
+
*
|
|
4
|
+
* Provides import functionality for GeoParquet files including OpenStreetMap US Layercake,
|
|
5
|
+
* converting geometry data to Osmix's in-memory format.
|
|
6
|
+
*
|
|
7
|
+
* Handles geometry mapping:
|
|
8
|
+
* - Point → Node
|
|
9
|
+
* - LineString → Way with nodes
|
|
10
|
+
* - Polygon → Way (simple) or Relation (with holes)
|
|
11
|
+
* - MultiPolygon → Multipolygon relation
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* // Import GeoParquet data to Osm index
|
|
16
|
+
* import { fromGeoParquet } from "@osmix/geoparquet"
|
|
17
|
+
*
|
|
18
|
+
* const osm = await fromGeoParquet("./data.parquet")
|
|
19
|
+
* const highways = osm.ways.search("highway")
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @module @osmix/geoparquet
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export * from "./from-geoparquet"
|
|
26
|
+
export * from "./types"
|
|
27
|
+
export { parseWkb } from "./wkb"
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for GeoParquet data.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { GeoBbox2D, OsmTags } from "@osmix/shared/types"
|
|
8
|
+
import type { AsyncBuffer, ParquetReadOptions } from "hyparquet"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Source types that can be used to read GeoParquet data by hyparquet.
|
|
12
|
+
* - string: File path (Node.js/Bun) or URL (browser)
|
|
13
|
+
* - URL: URL object
|
|
14
|
+
* - ArrayBuffer: Raw parquet data
|
|
15
|
+
* - AsyncBuffer: hyparquet async buffer for streaming
|
|
16
|
+
*/
|
|
17
|
+
export type GeoParquetSource = string | URL | ArrayBuffer | AsyncBuffer
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Raw row from GeoParquet file.
|
|
21
|
+
* The geometry field is WKB-encoded.
|
|
22
|
+
*/
|
|
23
|
+
export interface GeoParquetRow {
|
|
24
|
+
/** OSM entity type */
|
|
25
|
+
type: "node" | "way" | "relation"
|
|
26
|
+
/** OSM entity ID */
|
|
27
|
+
id: bigint | number
|
|
28
|
+
/** OSM tags as string or key-value pairs */
|
|
29
|
+
tags: OsmTags | string
|
|
30
|
+
/** the xmin, ymin, xmax, and ymax of the element’s geometry */
|
|
31
|
+
bbox: GeoBbox2D
|
|
32
|
+
/** WKB-encoded geometry or GeoJSON */
|
|
33
|
+
geometry: Uint8Array | GeoJSON.Geometry | string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Options for reading GeoParquet files.
|
|
38
|
+
*/
|
|
39
|
+
export interface GeoParquetReadOptions
|
|
40
|
+
extends Omit<ParquetReadOptions, "onComplete" | "file" | "columns"> {
|
|
41
|
+
/** Column name for the entity type (default: "type") */
|
|
42
|
+
typeColumn?: string
|
|
43
|
+
/** Column name for the entity ID (default: "id") */
|
|
44
|
+
idColumn?: string
|
|
45
|
+
/** Column name for the entity tags (default: "tags") */
|
|
46
|
+
tagsColumn?: string
|
|
47
|
+
/** Column name for the entity bbox (default: "bbox") */
|
|
48
|
+
bboxColumn?: string
|
|
49
|
+
/** Column name for the entity geometry (default: "geometry") */
|
|
50
|
+
geometryColumn?: string
|
|
51
|
+
}
|