@osmix/shapefile 0.0.2

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.
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Shapefile-to-OSM conversion utilities.
3
+ *
4
+ * Imports Shapefiles into Osm indexes by first parsing them to GeoJSON
5
+ * using shpjs, then converting the GeoJSON to OSM entities.
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 { OsmRelationMember, OsmTags } from "@osmix/shared/types"
17
+ import { rewindFeature } from "@placemarkio/geojson-rewind"
18
+ import type {
19
+ Feature,
20
+ FeatureCollection,
21
+ LineString,
22
+ MultiPolygon,
23
+ Point,
24
+ Polygon,
25
+ } from "geojson"
26
+ import type { ReadShapefileDataTypes } from "./types"
27
+ import { parseShapefile } from "./utils"
28
+
29
+ /**
30
+ * Create an Osm index from Shapefile data.
31
+ *
32
+ * Parses Shapefiles using shpjs (which returns GeoJSON) and converts
33
+ * the features to OSM entities:
34
+ * - Point → Node
35
+ * - LineString/MultiLineString → Way(s) with nodes
36
+ * - Polygon → Way (simple) or Relation (with holes)
37
+ * - MultiPolygon → Relation
38
+ *
39
+ * Feature properties become OSM tags.
40
+ *
41
+ * @param data - Shapefile data (URL string or ArrayBuffer of ZIP).
42
+ * @param options - Osm index options (id, header).
43
+ * @param onProgress - Progress callback for UI feedback.
44
+ * @returns Populated Osm index with built indexes.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * import { fromShapefile } from "@osmix/shapefile"
49
+ *
50
+ * // From file
51
+ * const zipBuffer = await Bun.file('./buildings.zip').arrayBuffer()
52
+ * const osm = await fromShapefile(zipBuffer, { id: "buildings" })
53
+ *
54
+ * // From URL
55
+ * const osm = await fromShapefile('https://example.com/data.zip')
56
+ *
57
+ * // Query the imported data
58
+ * const buildings = osm.ways.search("building")
59
+ * ```
60
+ */
61
+ export async function fromShapefile(
62
+ data: ReadShapefileDataTypes,
63
+ options: Partial<OsmOptions> = {},
64
+ onProgress: (progress: ProgressEvent) => void = logProgress,
65
+ ): Promise<Osm> {
66
+ const osm = new Osm(options)
67
+
68
+ onProgress(progressEvent("Parsing Shapefile..."))
69
+ const collections = await parseShapefile(data)
70
+
71
+ for (const collection of collections) {
72
+ const name = collection.fileName ?? "shapefile"
73
+ for (const update of startCreateOsmFromShapefile(osm, collection, name)) {
74
+ onProgress(update)
75
+ }
76
+ }
77
+
78
+ // Build indexes after all collections are processed
79
+ onProgress(progressEvent("Building indexes..."))
80
+ osm.buildIndexes()
81
+ osm.buildSpatialIndexes()
82
+
83
+ return osm
84
+ }
85
+
86
+ /**
87
+ * Generator that converts GeoJSON features from a shapefile to OSM entities.
88
+ *
89
+ * This is the core conversion logic, yielding progress events as features
90
+ * are processed.
91
+ *
92
+ * Geometry mapping:
93
+ * - **Point**: Creates a single node with feature properties as tags.
94
+ * - **MultiPoint**: Creates multiple nodes.
95
+ * - **LineString**: Creates nodes for each coordinate, then a way referencing them.
96
+ * - **MultiLineString**: Creates multiple ways.
97
+ * - **Polygon**: Creates a way for simple polygons; for polygons with holes,
98
+ * creates separate ways for outer and inner rings plus a multipolygon relation.
99
+ * - **MultiPolygon**: Creates a multipolygon relation with all rings as way members.
100
+ *
101
+ * @param osm - Target Osm index to populate.
102
+ * @param geojson - Parsed GeoJSON FeatureCollection from shapefile.
103
+ * @param name - Name for progress reporting.
104
+ * @yields Progress events during conversion.
105
+ */
106
+ export function* startCreateOsmFromShapefile(
107
+ osm: Osm,
108
+ geojson: FeatureCollection,
109
+ name: string,
110
+ ): Generator<ProgressEvent> {
111
+ yield progressEvent(`Converting "${name}" to Osmix...`)
112
+
113
+ // Map to track nodes by coordinate string for reuse when creating ways and relations
114
+ const nodeMap = new Map<string, number>()
115
+ let nextNodeId = osm.nodes.size > 0 ? -osm.nodes.size - 1 : -1
116
+ let nextWayId = osm.ways.size > 0 ? -osm.ways.size - 1 : -1
117
+ let nextRelationId = osm.relations.size > 0 ? -osm.relations.size - 1 : -1
118
+
119
+ // Helper to get or create a node for a coordinate
120
+ const getOrCreateNode = (lon: number, lat: number): number => {
121
+ const coordKey = `${lon},${lat}`
122
+ const existingNodeId = nodeMap.get(coordKey)
123
+ if (existingNodeId !== undefined) {
124
+ return existingNodeId
125
+ }
126
+
127
+ const nodeId = nextNodeId--
128
+ nodeMap.set(coordKey, nodeId)
129
+ osm.nodes.addNode({
130
+ id: nodeId,
131
+ lon,
132
+ lat,
133
+ })
134
+ return nodeId
135
+ }
136
+
137
+ // Process each feature
138
+ let count = 0
139
+ for (const feature of geojson.features) {
140
+ const geometry = feature.geometry
141
+ if (!geometry) continue
142
+
143
+ // Normalize winding order for polygons
144
+ const normalizedFeature =
145
+ geometry.type === "Polygon" || geometry.type === "MultiPolygon"
146
+ ? rewindFeature(feature as Feature<Polygon | MultiPolygon>)
147
+ : feature
148
+
149
+ const tags = propertiesToTags(normalizedFeature.properties)
150
+ const featureId = extractFeatureId(normalizedFeature.id)
151
+
152
+ const geomType = normalizedFeature.geometry?.type
153
+
154
+ if (geomType === "Point") {
155
+ const coords = (normalizedFeature.geometry as Point).coordinates
156
+ const [lon, lat] = coords
157
+ if (lon === undefined || lat === undefined) continue
158
+
159
+ const nodeId = featureId ?? nextNodeId--
160
+ osm.nodes.addNode({
161
+ id: nodeId,
162
+ lon,
163
+ lat,
164
+ tags,
165
+ })
166
+ nodeMap.set(`${lon},${lat}`, nodeId)
167
+ } else if (geomType === "MultiPoint") {
168
+ const coords = (normalizedFeature.geometry as GeoJSON.MultiPoint)
169
+ .coordinates
170
+ for (const [lon, lat] of coords) {
171
+ if (lon === undefined || lat === undefined) continue
172
+ const nodeId = nextNodeId--
173
+ osm.nodes.addNode({
174
+ id: nodeId,
175
+ lon,
176
+ lat,
177
+ tags,
178
+ })
179
+ nodeMap.set(`${lon},${lat}`, nodeId)
180
+ }
181
+ } else if (geomType === "LineString") {
182
+ const coords = (normalizedFeature.geometry as LineString).coordinates
183
+ if (coords.length < 2) continue
184
+
185
+ const nodeRefs: number[] = []
186
+ for (const [lon, lat] of coords) {
187
+ if (lon === undefined || lat === undefined) continue
188
+ const nodeId = getOrCreateNode(lon, lat)
189
+ nodeRefs.push(nodeId)
190
+ }
191
+
192
+ if (nodeRefs.length >= 2) {
193
+ const wayId = featureId ?? nextWayId--
194
+ osm.ways.addWay({
195
+ id: wayId,
196
+ refs: nodeRefs,
197
+ tags,
198
+ })
199
+ }
200
+ } else if (geomType === "MultiLineString") {
201
+ const coords = (normalizedFeature.geometry as GeoJSON.MultiLineString)
202
+ .coordinates
203
+ for (const line of coords) {
204
+ if (line.length < 2) continue
205
+
206
+ const nodeRefs: number[] = []
207
+ for (const [lon, lat] of line) {
208
+ if (lon === undefined || lat === undefined) continue
209
+ const nodeId = getOrCreateNode(lon, lat)
210
+ nodeRefs.push(nodeId)
211
+ }
212
+
213
+ if (nodeRefs.length >= 2) {
214
+ const wayId = nextWayId--
215
+ osm.ways.addWay({
216
+ id: wayId,
217
+ refs: nodeRefs,
218
+ tags,
219
+ })
220
+ }
221
+ }
222
+ } else if (geomType === "Polygon") {
223
+ const coords = (normalizedFeature.geometry as Polygon).coordinates
224
+ processPolygonRings(
225
+ osm,
226
+ coords,
227
+ tags,
228
+ featureId,
229
+ () => nextWayId--,
230
+ () => nextRelationId--,
231
+ getOrCreateNode,
232
+ )
233
+ } else if (geomType === "MultiPolygon") {
234
+ const coords = (normalizedFeature.geometry as MultiPolygon).coordinates
235
+ const relationMembers: OsmRelationMember[] = []
236
+
237
+ for (const polygon of coords) {
238
+ const { outerWayId, holeWayIds } = processPolygonRings(
239
+ osm,
240
+ polygon,
241
+ undefined, // Tags go on relation
242
+ undefined,
243
+ () => nextWayId--,
244
+ () => nextRelationId--,
245
+ getOrCreateNode,
246
+ )
247
+
248
+ if (outerWayId !== undefined) {
249
+ relationMembers.push({ type: "way", ref: outerWayId, role: "outer" })
250
+ for (const holeId of holeWayIds) {
251
+ relationMembers.push({ type: "way", ref: holeId, role: "inner" })
252
+ }
253
+ }
254
+ }
255
+
256
+ if (relationMembers.length > 0) {
257
+ osm.relations.addRelation({
258
+ id: featureId ?? nextRelationId--,
259
+ members: relationMembers,
260
+ tags: { type: "multipolygon", ...tags },
261
+ })
262
+ }
263
+ }
264
+
265
+ if (++count % 1000 === 0) {
266
+ yield progressEvent(`Processed ${count} features from "${name}"...`)
267
+ }
268
+ }
269
+
270
+ yield progressEvent(`Finished converting "${name}" to Osmix...`)
271
+ }
272
+
273
+ /**
274
+ * Process polygon rings and add to OSM index.
275
+ * Returns the created way IDs for use in multipolygon relations.
276
+ */
277
+ function processPolygonRings(
278
+ osm: Osm,
279
+ coordinates: number[][][],
280
+ tags: OsmTags | undefined,
281
+ featureId: number | undefined,
282
+ getNextWayId: () => number,
283
+ getNextRelationId: () => number,
284
+ getOrCreateNode: (lon: number, lat: number) => number,
285
+ ): { outerWayId: number | undefined; holeWayIds: number[] } {
286
+ if (coordinates.length === 0) {
287
+ return { outerWayId: undefined, holeWayIds: [] }
288
+ }
289
+
290
+ const outerRing = coordinates[0]
291
+ if (!outerRing || outerRing.length < 3) {
292
+ return { outerWayId: undefined, holeWayIds: [] }
293
+ }
294
+
295
+ const createRelation = coordinates.length > 1
296
+
297
+ // Create nodes for outer ring
298
+ const outerNodeRefs: number[] = []
299
+ for (const coord of outerRing) {
300
+ const [lon, lat] = coord
301
+ if (lon === undefined || lat === undefined) continue
302
+ const nodeId = getOrCreateNode(lon, lat)
303
+ outerNodeRefs.push(nodeId)
304
+ }
305
+
306
+ if (outerNodeRefs.length < 3) {
307
+ return { outerWayId: undefined, holeWayIds: [] }
308
+ }
309
+
310
+ // Ensure ring is closed
311
+ if (outerNodeRefs[0] !== outerNodeRefs[outerNodeRefs.length - 1]) {
312
+ outerNodeRefs.push(outerNodeRefs[0]!)
313
+ }
314
+
315
+ // Create way for outer ring
316
+ const outerWayId = createRelation
317
+ ? getNextWayId()
318
+ : (featureId ?? getNextWayId())
319
+ osm.ways.addWay({
320
+ id: outerWayId,
321
+ refs: outerNodeRefs,
322
+ tags: createRelation ? { area: "yes" } : { area: "yes", ...tags },
323
+ })
324
+
325
+ // Create separate ways for holes
326
+ const holeWayIds: number[] = []
327
+ for (let i = 1; i < coordinates.length; i++) {
328
+ const holeRing = coordinates[i]
329
+ if (!holeRing || holeRing.length < 3) continue
330
+
331
+ const holeNodeRefs: number[] = []
332
+ for (const coord of holeRing) {
333
+ const [lon, lat] = coord
334
+ if (lon === undefined || lat === undefined) continue
335
+ const nodeId = getOrCreateNode(lon, lat)
336
+ holeNodeRefs.push(nodeId)
337
+ }
338
+
339
+ if (holeNodeRefs.length < 3) continue
340
+
341
+ // Ensure hole ring is closed
342
+ if (holeNodeRefs[0] !== holeNodeRefs[holeNodeRefs.length - 1]) {
343
+ holeNodeRefs.push(holeNodeRefs[0]!)
344
+ }
345
+
346
+ const holeWayId = getNextWayId()
347
+ osm.ways.addWay({
348
+ id: holeWayId,
349
+ refs: holeNodeRefs,
350
+ tags: { area: "yes" },
351
+ })
352
+ holeWayIds.push(holeWayId)
353
+ }
354
+
355
+ if (createRelation) {
356
+ osm.relations.addRelation({
357
+ id: featureId ?? getNextRelationId(),
358
+ members: [
359
+ { type: "way", ref: outerWayId, role: "outer" },
360
+ ...holeWayIds.map(
361
+ (id) =>
362
+ ({ type: "way", ref: id, role: "inner" }) as OsmRelationMember,
363
+ ),
364
+ ],
365
+ tags: {
366
+ type: "multipolygon",
367
+ ...tags,
368
+ },
369
+ })
370
+ }
371
+
372
+ return { outerWayId, holeWayIds }
373
+ }
374
+
375
+ /**
376
+ * Convert GeoJSON properties to OSM tags.
377
+ */
378
+ function propertiesToTags(
379
+ properties: Record<string, unknown> | null,
380
+ ): OsmTags | undefined {
381
+ if (!properties || Object.keys(properties).length === 0) {
382
+ return undefined
383
+ }
384
+
385
+ const tags: OsmTags = {}
386
+ for (const [key, value] of Object.entries(properties)) {
387
+ if (typeof value === "string" || typeof value === "number") {
388
+ tags[key] = value
389
+ } else if (value != null) {
390
+ tags[key] = String(value)
391
+ }
392
+ }
393
+ return Object.keys(tags).length > 0 ? tags : undefined
394
+ }
395
+
396
+ /**
397
+ * Extract numeric ID from feature.
398
+ */
399
+ function extractFeatureId(
400
+ featureId: string | number | undefined,
401
+ ): number | undefined {
402
+ if (featureId === undefined) return undefined
403
+ if (typeof featureId === "number") return featureId
404
+ if (typeof featureId === "string") {
405
+ const numId = Number.parseInt(featureId, 10)
406
+ if (!Number.isNaN(numId)) return numId
407
+ }
408
+ return undefined
409
+ }
package/src/shpjs.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ declare module "shpjs" {
2
+ import type { FeatureCollection } from "geojson"
3
+
4
+ export interface ShpjsFeatureCollection extends FeatureCollection {
5
+ fileName?: string
6
+ }
7
+
8
+ export type ShpjsResult = ShpjsFeatureCollection | ShpjsFeatureCollection[]
9
+
10
+ export interface ShpjsInput {
11
+ shp: ArrayBuffer | Buffer
12
+ dbf?: ArrayBuffer | Buffer
13
+ prj?: ArrayBuffer | Buffer | string
14
+ cpg?: ArrayBuffer | Buffer | string
15
+ }
16
+
17
+ /**
18
+ * Parse a shapefile from various sources.
19
+ * @param input - URL string, ArrayBuffer of ZIP, or object with shp/dbf/prj/cpg buffers
20
+ * @returns GeoJSON FeatureCollection(s) with WGS84 projection
21
+ */
22
+ function shp(
23
+ input: string | ArrayBufferLike | ShpjsInput,
24
+ ): Promise<ShpjsResult>
25
+
26
+ export default shp
27
+ }
package/src/types.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Type definitions for Shapefile import.
3
+ * @module
4
+ */
5
+
6
+ import type { FeatureCollection } from "geojson"
7
+
8
+ /**
9
+ * Input types accepted by `fromShapefile`.
10
+ *
11
+ * Supports multiple formats:
12
+ * - `ArrayBufferLike` - Binary ZIP data containing shapefile components
13
+ * - `ReadableStream` - Stream of ZIP data (will be consumed to ArrayBuffer)
14
+ * - `string` - URL to a shapefile or ZIP file
15
+ */
16
+ export type ReadShapefileDataTypes = ArrayBufferLike | ReadableStream | string
17
+
18
+ /**
19
+ * Result from shpjs parsing.
20
+ * Can be a single FeatureCollection or an array if the ZIP contains multiple shapefiles.
21
+ */
22
+ export type ShpjsResult =
23
+ | (FeatureCollection & { fileName?: string })
24
+ | (FeatureCollection & { fileName?: string })[]
package/src/utils.ts ADDED
@@ -0,0 +1,82 @@
1
+ /// <reference path="./shpjs.d.ts" />
2
+ /**
3
+ * Utility functions for Shapefile data handling.
4
+ * @module
5
+ */
6
+
7
+ import type { FeatureCollection } from "geojson"
8
+ import shp from "shpjs"
9
+ import type { ReadShapefileDataTypes, ShpjsResult } from "./types"
10
+
11
+ /**
12
+ * Parse Shapefile data and return GeoJSON FeatureCollection(s).
13
+ *
14
+ * Uses shpjs to parse Shapefiles and automatically project to WGS84.
15
+ *
16
+ * @param data - Shapefile data (URL string, ArrayBuffer/ReadableStream of ZIP).
17
+ * @returns Array of GeoJSON FeatureCollections with optional fileName.
18
+ * @throws If data is null or parsing fails.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * // From ArrayBuffer
23
+ * const collections = await parseShapefile(zipBuffer)
24
+ *
25
+ * // From URL
26
+ * const collections = await parseShapefile('https://example.com/data.zip')
27
+ * ```
28
+ */
29
+ export async function parseShapefile(
30
+ data: ReadShapefileDataTypes,
31
+ ): Promise<(FeatureCollection & { fileName?: string })[]> {
32
+ if (data == null) throw new Error("Data is null")
33
+
34
+ let input: ArrayBufferLike | string
35
+
36
+ // Convert ReadableStream to ArrayBuffer
37
+ if (data instanceof ReadableStream) {
38
+ input = await streamToArrayBuffer(data)
39
+ } else if (data instanceof SharedArrayBuffer) {
40
+ // shpjs expects ArrayBuffer, not SharedArrayBuffer
41
+ const copy = new ArrayBuffer(data.byteLength)
42
+ new Uint8Array(copy).set(new Uint8Array(data))
43
+ input = copy
44
+ } else {
45
+ input = data
46
+ }
47
+
48
+ const result: ShpjsResult = await shp(input)
49
+
50
+ // Normalize to array
51
+ if (Array.isArray(result)) {
52
+ return result
53
+ }
54
+ return [result]
55
+ }
56
+
57
+ /**
58
+ * Convert a ReadableStream to an ArrayBuffer.
59
+ */
60
+ async function streamToArrayBuffer(
61
+ stream: ReadableStream,
62
+ ): Promise<ArrayBuffer> {
63
+ const reader = stream.getReader()
64
+ const chunks: Uint8Array[] = []
65
+ let totalLength = 0
66
+
67
+ while (true) {
68
+ const { done, value } = await reader.read()
69
+ if (done) break
70
+ chunks.push(value)
71
+ totalLength += value.byteLength
72
+ }
73
+
74
+ const result = new Uint8Array(totalLength)
75
+ let offset = 0
76
+ for (const chunk of chunks) {
77
+ result.set(chunk, offset)
78
+ offset += chunk.byteLength
79
+ }
80
+
81
+ return result.buffer
82
+ }