@dunguel/expo-geo-parser 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.
@@ -0,0 +1,5 @@
1
+ import type { GeoJSONFeatureCollection } from "./ExpoGeoParserModule";
2
+ export { default } from "./ExpoGeoParserModule";
3
+ export * from "./ExpoGeoParserModule";
4
+ export declare function parseFile(uri: string): Promise<GeoJSONFeatureCollection>;
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAkB,wBAAwB,EAAsB,MAAM,uBAAuB,CAAC;AAE1G,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAChD,cAAc,uBAAuB,CAAC;AAEtC,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAqB9E"}
package/build/index.js ADDED
@@ -0,0 +1,23 @@
1
+ import NativeModule from "./ExpoGeoParserModule";
2
+ export { default } from "./ExpoGeoParserModule";
3
+ export * from "./ExpoGeoParserModule";
4
+ export async function parseFile(uri) {
5
+ const features = [];
6
+ let resolveDone;
7
+ const allReceived = new Promise(r => { resolveDone = r; });
8
+ const sub = NativeModule.addListener("onParseFeatures", (e) => {
9
+ for (const f of e.features)
10
+ features.push(f);
11
+ if (e.isLast)
12
+ resolveDone();
13
+ });
14
+ try {
15
+ const meta = await NativeModule.parseFile(uri);
16
+ await allReceived;
17
+ return { ...meta, features };
18
+ }
19
+ finally {
20
+ sub.remove();
21
+ }
22
+ }
23
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,uBAAuB,CAAC;AAGjD,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAChD,cAAc,uBAAuB,CAAC;AAEtC,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,GAAW;IACzC,MAAM,QAAQ,GAAqB,EAAE,CAAC;IAEtC,IAAI,WAAwB,CAAC;IAC7B,MAAM,WAAW,GAAG,IAAI,OAAO,CAAO,CAAC,CAAC,EAAE,GAAG,WAAW,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAEjE,MAAM,GAAG,GAAG,YAAY,CAAC,WAAW,CAClC,iBAAiB,EACjB,CAAC,CAAqB,EAAE,EAAE;QACxB,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,QAAQ;YAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAC7C,IAAI,CAAC,CAAC,MAAM;YAAE,WAAW,EAAE,CAAC;IAC9B,CAAC,CACF,CAAC;IAEF,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,YAAY,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QAC/C,MAAM,WAAW,CAAC;QAClB,OAAO,EAAE,GAAG,IAAI,EAAE,QAAQ,EAAE,CAAC;IAC/B,CAAC;YAAS,CAAC;QACT,GAAG,CAAC,MAAM,EAAE,CAAC;IACf,CAAC;AACH,CAAC","sourcesContent":["import NativeModule from \"./ExpoGeoParserModule\";\nimport type { GeoJSONFeature, GeoJSONFeatureCollection, ParseFeaturesEvent } from \"./ExpoGeoParserModule\";\n\nexport { default } from \"./ExpoGeoParserModule\";\nexport * from \"./ExpoGeoParserModule\";\n\nexport async function parseFile(uri: string): Promise<GeoJSONFeatureCollection> {\n const features: GeoJSONFeature[] = [];\n\n let resolveDone!: () => void;\n const allReceived = new Promise<void>(r => { resolveDone = r; });\n\n const sub = NativeModule.addListener(\n \"onParseFeatures\",\n (e: ParseFeaturesEvent) => {\n for (const f of e.features) features.push(f);\n if (e.isLast) resolveDone();\n }\n );\n\n try {\n const meta = await NativeModule.parseFile(uri);\n await allReceived;\n return { ...meta, features };\n } finally {\n sub.remove();\n }\n}\n"]}
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["ExpoGeoParserModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.geoparser.ExpoGeoParserModule"]
8
+ }
9
+ }
@@ -0,0 +1,30 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ExpoGeoParser'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['homepage']
13
+ s.platforms = {
14
+ :ios => '15.1',
15
+ :tvos => '15.1'
16
+ }
17
+ s.swift_version = '5.9'
18
+ s.source = { git: 'https://github.com/guilhermedunguel/expo-geo-parser' }
19
+ s.static_framework = true
20
+
21
+ s.dependency 'ExpoModulesCore'
22
+ s.dependency 'SSZipArchive'
23
+
24
+ # Swift/Objective-C compatibility
25
+ s.pod_target_xcconfig = {
26
+ 'DEFINES_MODULE' => 'YES',
27
+ }
28
+
29
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
30
+ end
@@ -0,0 +1,164 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+ import UniformTypeIdentifiers
4
+ import SSZipArchive
5
+
6
+ public class ExpoGeoParserModule: Module {
7
+ public func definition() -> ModuleDefinition {
8
+ Name("ExpoGeoParser")
9
+ Events("onParseFeatures")
10
+
11
+ Function("detectFileType") { (uri: String) -> [String: Any?] in
12
+ let info = Self.detectFileType(from: uri)
13
+ return [
14
+ "uri": uri,
15
+ "fileName": info.fileName,
16
+ "extension": info.fileExtension,
17
+ "type": info.type,
18
+ "uti": info.utiIdentifier
19
+ ]
20
+ }
21
+
22
+ AsyncFunction("parseFile") { (uri: String) throws -> [String: Any] in
23
+ var result = try Self.parseGeoFile(from: uri)
24
+ let features = result.removeValue(forKey: "features") as? [[String: Any]] ?? []
25
+
26
+ let batchSize = 200
27
+ var idx = 0
28
+ repeat {
29
+ let end = min(idx + batchSize, features.count)
30
+ self.sendEvent("onParseFeatures", [
31
+ "features": Array(features[idx..<end]),
32
+ "isLast": end >= features.count
33
+ ])
34
+ idx = end
35
+ } while idx < features.count
36
+
37
+ return result
38
+ }
39
+ }
40
+ }
41
+
42
+ // MARK: - File-type detection
43
+
44
+ private extension ExpoGeoParserModule {
45
+ struct DetectedFileInfo {
46
+ let fileName: String?
47
+ let fileExtension: String?
48
+ let type: String
49
+ let utiIdentifier: String?
50
+ }
51
+
52
+ static func detectFileType(from uri: String) -> DetectedFileInfo {
53
+ guard let url = URL(string: uri) else {
54
+ return DetectedFileInfo(fileName: nil, fileExtension: nil, type: "unknown", utiIdentifier: nil)
55
+ }
56
+ return detectFileType(from: url)
57
+ }
58
+
59
+ static func detectFileType(from url: URL) -> DetectedFileInfo {
60
+ let fileName = url.lastPathComponent.isEmpty ? nil : url.lastPathComponent
61
+ let rawExt = url.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
62
+ let ext = rawExt.isEmpty ? nil : rawExt.lowercased()
63
+
64
+ let type: String
65
+ switch ext {
66
+ case "kml": type = "kml"
67
+ case "kmz": type = "kmz"
68
+ case "zip": type = "zip"
69
+ case "geojson": type = "geojson"
70
+ case "json": type = "json"
71
+ default: type = "unknown"
72
+ }
73
+
74
+ let uti = ext.flatMap { UTType(filenameExtension: $0)?.identifier }
75
+ return DetectedFileInfo(fileName: fileName, fileExtension: ext, type: type, utiIdentifier: uti)
76
+ }
77
+ }
78
+
79
+ // MARK: - Parser dispatcher
80
+
81
+ private extension ExpoGeoParserModule {
82
+ static func parseGeoFile(from uri: String) throws -> [String: Any] {
83
+ guard let url = URL(string: uri) else {
84
+ throw GeoParserError.invalidURI(uri)
85
+ }
86
+
87
+ let info = detectFileType(from: url)
88
+
89
+ if info.type == "kmz" || info.type == "zip" {
90
+ return try parseArchive(at: url, sourceType: info.type)
91
+ }
92
+
93
+ let data = try Data(contentsOf: url)
94
+
95
+ switch info.type {
96
+ case "kml": return try KMLParser.parse(data: data, sourceType: "kml")
97
+ case "geojson", "json": return try GeoJSONParser.parse(data: data, sourceType: info.type)
98
+ default: return try sniffAndParse(data: data, sourceType: "unknown")
99
+ }
100
+ }
101
+
102
+ static func parseArchive(at url: URL, sourceType: String) throws -> [String: Any] {
103
+ let tmpDir = FileManager.default.temporaryDirectory
104
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
105
+ try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
106
+ defer { try? FileManager.default.removeItem(at: tmpDir) }
107
+
108
+ guard SSZipArchive.unzipFile(atPath: url.path, toDestination: tmpDir.path) else {
109
+ throw GeoParserError.extractionFailed
110
+ }
111
+
112
+ guard let enumerator = FileManager.default.enumerator(at: tmpDir, includingPropertiesForKeys: nil) else {
113
+ throw GeoParserError.noGeoDataFound
114
+ }
115
+
116
+ var kmlFiles: [URL] = []
117
+ var geojsonFiles: [URL] = []
118
+ for case let fileURL as URL in enumerator {
119
+ let ext = fileURL.pathExtension.lowercased()
120
+ if ext == "kml" { kmlFiles.append(fileURL) }
121
+ else if ext == "geojson" || ext == "json" { geojsonFiles.append(fileURL) }
122
+ }
123
+
124
+ if let kmlURL = kmlFiles.first(where: { $0.lastPathComponent.lowercased() == "doc.kml" }) ?? kmlFiles.first {
125
+ return try KMLParser.parse(data: Data(contentsOf: kmlURL), sourceType: sourceType)
126
+ }
127
+ if let gjURL = geojsonFiles.first {
128
+ return try GeoJSONParser.parse(data: Data(contentsOf: gjURL), sourceType: sourceType)
129
+ }
130
+
131
+ throw GeoParserError.noGeoDataFound
132
+ }
133
+
134
+ static func sniffAndParse(data: Data, sourceType: String) throws -> [String: Any] {
135
+ if data.first == UInt8(ascii: "{") {
136
+ return try GeoJSONParser.parse(data: data, sourceType: sourceType)
137
+ }
138
+ if let prefix = String(data: data.prefix(256), encoding: .utf8),
139
+ prefix.contains("<kml") || prefix.contains("<Placemark") || prefix.contains("<Document") {
140
+ return try KMLParser.parse(data: data, sourceType: sourceType)
141
+ }
142
+ throw GeoParserError.unsupportedFormat
143
+ }
144
+ }
145
+
146
+ // MARK: - Errors
147
+
148
+ enum GeoParserError: LocalizedError {
149
+ case invalidURI(String)
150
+ case extractionFailed
151
+ case noGeoDataFound
152
+ case unsupportedFormat
153
+ case parseError(String)
154
+
155
+ var errorDescription: String? {
156
+ switch self {
157
+ case .invalidURI(let uri): return "Invalid URI: \(uri)"
158
+ case .extractionFailed: return "Failed to extract archive"
159
+ case .noGeoDataFound: return "No supported geo data found in archive"
160
+ case .unsupportedFormat: return "Unsupported or unrecognised file format"
161
+ case .parseError(let msg): return "Parse error: \(msg)"
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,59 @@
1
+ import Foundation
2
+
3
+ struct GeoJSONParser {
4
+
5
+ static func parse(data: Data, sourceType: String) throws -> [String: Any] {
6
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
7
+ throw GeoParserError.parseError("Invalid JSON")
8
+ }
9
+
10
+ let features = extractFeatures(from: json)
11
+
12
+ var result: [String: Any] = ["type": "FeatureCollection", "sourceType": sourceType, "features": features]
13
+ if let name = json["name"] as? String { result["name"] = name }
14
+ if let props = json["properties"] as? [String: Any] {
15
+ if let name = props["name"] as? String { result["name"] = name }
16
+ if let desc = props["description"] as? String { result["description"] = desc }
17
+ }
18
+ return result
19
+ }
20
+
21
+ private static func extractFeatures(from json: [String: Any]) -> [[String: Any]] {
22
+ guard let type = json["type"] as? String else { return [] }
23
+
24
+ switch type {
25
+ case "FeatureCollection":
26
+ return (json["features"] as? [[String: Any]] ?? []).compactMap { parseFeature($0) }
27
+ case "Feature":
28
+ return parseFeature(json).map { [$0] } ?? []
29
+ default:
30
+ if isGeometryType(type), let feature = bareGeometryToFeature(json) { return [feature] }
31
+ return []
32
+ }
33
+ }
34
+
35
+ private static func parseFeature(_ json: [String: Any]) -> [String: Any]? {
36
+ guard json["type"] as? String == "Feature",
37
+ let geometry = json["geometry"] as? [String: Any]
38
+ else { return nil }
39
+
40
+ let properties = json["properties"] as? [String: Any] ?? [:]
41
+ var feature: [String: Any] = ["type": "Feature", "geometry": geometry, "properties": properties]
42
+ if let id = json["id"] { feature["id"] = "\(id)" }
43
+ return feature
44
+ }
45
+
46
+ private static func bareGeometryToFeature(_ json: [String: Any]) -> [String: Any]? {
47
+ guard json["type"] as? String != nil else { return nil }
48
+ return ["type": "Feature", "geometry": json, "properties": [:] as [String: Any]]
49
+ }
50
+
51
+ private static func isGeometryType(_ type: String) -> Bool {
52
+ let types: Set<String> = [
53
+ "Point", "LineString", "Polygon",
54
+ "MultiPoint", "MultiLineString", "MultiPolygon",
55
+ "GeometryCollection"
56
+ ]
57
+ return types.contains(type)
58
+ }
59
+ }
@@ -0,0 +1,281 @@
1
+ import Foundation
2
+
3
+ final class KMLParser: NSObject, XMLParserDelegate {
4
+
5
+ private var features: [[String: Any]] = []
6
+ private var documentName = ""
7
+ private var documentDescription = ""
8
+ private var styles: [String: StyleInfo] = [:]
9
+ private var styleMaps: [String: String] = [:]
10
+
11
+ private var elementStack: [String] = []
12
+ private var textBuffer = ""
13
+
14
+ private var documentDepth = 0
15
+ private var documentMetaCaptured = false
16
+
17
+ private var inPlacemark = false
18
+ private var currentFeatureId: String?
19
+ private var currentFeatureName = ""
20
+ private var currentFeatureDescription = ""
21
+ private var currentFeatureStyleUrl = ""
22
+ private var currentGeometryType: String?
23
+
24
+ private var pointCoord: [Double] = []
25
+ private var lineCoords: [[Double]] = []
26
+ private var currentRing: [[Double]] = []
27
+ private var outerRing: [[Double]] = []
28
+ private var innerRings: [[[Double]]] = []
29
+ private var inOuterBoundary = false
30
+ private var inInnerBoundary = false
31
+
32
+ private var inMultiGeometry = false
33
+ private var multiGeometries: [[String: Any]] = []
34
+
35
+ private var currentStyleId: String?
36
+ private var buildingStyle = StyleInfo()
37
+ private var inLineStyle = false
38
+ private var inPolyStyle = false
39
+ private var inIconStyle = false
40
+ private var inIconHref = false
41
+
42
+ private var inStyleMap = false
43
+ private var currentStyleMapId: String?
44
+ private var inPair = false
45
+ private var currentPairKey = ""
46
+ private var currentPairStyleUrl = ""
47
+
48
+ static func parse(data: Data, sourceType: String) throws -> [String: Any] {
49
+ let delegate = KMLParser()
50
+ let xmlParser = XMLParser(data: data)
51
+ xmlParser.delegate = delegate
52
+ xmlParser.shouldProcessNamespaces = true
53
+ xmlParser.shouldReportNamespacePrefixes = false
54
+
55
+ guard xmlParser.parse() else {
56
+ throw GeoParserError.parseError(xmlParser.parserError?.localizedDescription ?? "XML parse error")
57
+ }
58
+
59
+ var result: [String: Any] = [
60
+ "type": "FeatureCollection",
61
+ "sourceType": sourceType,
62
+ "features": delegate.features
63
+ ]
64
+ if !delegate.documentName.isEmpty { result["name"] = delegate.documentName }
65
+ if !delegate.documentDescription.isEmpty { result["description"] = delegate.documentDescription }
66
+ return result
67
+ }
68
+
69
+ func parser(
70
+ _ parser: XMLParser,
71
+ didStartElement elementName: String,
72
+ namespaceURI: String?,
73
+ qualifiedName _: String?,
74
+ attributes attributeDict: [String: String]
75
+ ) {
76
+ let name = stripped(elementName)
77
+ elementStack.append(name)
78
+ textBuffer = ""
79
+
80
+ switch name {
81
+ case "Document": documentDepth += 1
82
+ case "Style": currentStyleId = attributeDict["id"]; buildingStyle = StyleInfo()
83
+ case "StyleMap": inStyleMap = true; currentStyleMapId = attributeDict["id"]
84
+ case "Pair": if inStyleMap { inPair = true; currentPairKey = ""; currentPairStyleUrl = "" }
85
+ case "LineStyle": inLineStyle = true
86
+ case "PolyStyle": inPolyStyle = true
87
+ case "IconStyle": inIconStyle = true
88
+ case "Icon": if inIconStyle { inIconHref = true }
89
+ case "Placemark":
90
+ inPlacemark = true
91
+ currentFeatureId = attributeDict["id"]
92
+ currentFeatureName = ""
93
+ currentFeatureDescription = ""
94
+ currentFeatureStyleUrl = ""
95
+ currentGeometryType = nil
96
+ multiGeometries = []
97
+ case "Point": currentGeometryType = "Point"; pointCoord = []
98
+ case "LineString": currentGeometryType = "LineString"; lineCoords = []
99
+ case "LinearRing": currentRing = []
100
+ case "Polygon":
101
+ currentGeometryType = "Polygon"
102
+ outerRing = []; innerRings = []
103
+ inOuterBoundary = false; inInnerBoundary = false
104
+ case "MultiGeometry": inMultiGeometry = true; multiGeometries = []
105
+ case "outerBoundaryIs": inOuterBoundary = true; inInnerBoundary = false
106
+ case "innerBoundaryIs": inInnerBoundary = true; inOuterBoundary = false
107
+ default: break
108
+ }
109
+ }
110
+
111
+ func parser(_ parser: XMLParser, foundCharacters string: String) {
112
+ textBuffer += string
113
+ }
114
+
115
+ func parser(_ parser: XMLParser, foundCDATA block: Data) {
116
+ if let s = String(data: block, encoding: .utf8) { textBuffer += s }
117
+ }
118
+
119
+ func parser(
120
+ _ parser: XMLParser,
121
+ didEndElement elementName: String,
122
+ namespaceURI: String?,
123
+ qualifiedName _: String?
124
+ ) {
125
+ let name = stripped(elementName)
126
+ let text = textBuffer.trimmingCharacters(in: .whitespacesAndNewlines)
127
+ defer { textBuffer = ""; if !elementStack.isEmpty { elementStack.removeLast() } }
128
+
129
+ switch name {
130
+ case "Document": documentDepth -= 1
131
+ case "name":
132
+ if inPlacemark { currentFeatureName = text }
133
+ else if documentDepth > 0 && !documentMetaCaptured { documentName = text; documentMetaCaptured = true }
134
+ case "description":
135
+ if inPlacemark { currentFeatureDescription = text }
136
+ else if documentDepth > 0 && documentDescription.isEmpty { documentDescription = text }
137
+ case "Style":
138
+ if let id = currentStyleId { styles[id] = buildingStyle }
139
+ currentStyleId = nil
140
+ case "StyleMap": inStyleMap = false; currentStyleMapId = nil
141
+ case "Pair":
142
+ if inStyleMap && currentPairKey == "normal", let mapId = currentStyleMapId {
143
+ styleMaps[mapId] = currentPairStyleUrl
144
+ }
145
+ inPair = false
146
+ case "key": if inStyleMap && inPair { currentPairKey = text }
147
+ case "styleUrl":
148
+ if inPlacemark { currentFeatureStyleUrl = text }
149
+ else if inStyleMap && inPair { currentPairStyleUrl = text }
150
+ case "LineStyle": inLineStyle = false
151
+ case "PolyStyle": inPolyStyle = false
152
+ case "IconStyle": inIconStyle = false
153
+ case "Icon": inIconHref = false
154
+ case "color":
155
+ let hex = kmlColorToCSS(text)
156
+ if inLineStyle { buildingStyle.strokeColor = hex }
157
+ else if inPolyStyle { buildingStyle.fillColor = hex }
158
+ case "width": if inLineStyle, let w = Double(text) { buildingStyle.strokeWidth = w }
159
+ case "fill": if inPolyStyle { buildingStyle.fillEnabled = (text != "0") }
160
+ case "href": if inIconHref { buildingStyle.iconUrl = text }
161
+ case "scale": if inIconStyle, let s = Double(text) { buildingStyle.iconScale = s }
162
+ case "coordinates":
163
+ let coords = parseCoordinates(text)
164
+ let parent = elementStack.dropLast().last ?? ""
165
+ switch parent {
166
+ case "Point": pointCoord = coords.first ?? []
167
+ case "LineString": lineCoords = coords
168
+ case "LinearRing": currentRing = coords
169
+ default: break
170
+ }
171
+ case "LinearRing":
172
+ if inOuterBoundary { outerRing = currentRing }
173
+ else if inInnerBoundary { innerRings.append(currentRing) }
174
+ case "outerBoundaryIs": inOuterBoundary = false
175
+ case "innerBoundaryIs": inInnerBoundary = false
176
+ case "Point", "LineString", "Polygon":
177
+ if inMultiGeometry, let geom = buildGeometry(type: name) {
178
+ multiGeometries.append(geom)
179
+ pointCoord = []; lineCoords = []; outerRing = []; innerRings = []
180
+ }
181
+ case "MultiGeometry": inMultiGeometry = false; currentGeometryType = "MultiGeometry"
182
+ case "Placemark": finalizeFeature(); inPlacemark = false
183
+ default: break
184
+ }
185
+ }
186
+
187
+ private func stripped(_ name: String) -> String {
188
+ if let r = name.range(of: ":", options: .backwards) { return String(name[r.upperBound...]) }
189
+ return name
190
+ }
191
+
192
+ private func kmlColorToCSS(_ kml: String) -> String {
193
+ let s = kml.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
194
+ guard s.count == 8 else { return "#000000" }
195
+ return "#\(s.dropFirst(6).prefix(2))\(s.dropFirst(4).prefix(2))\(s.dropFirst(2).prefix(2))"
196
+ }
197
+
198
+ private func parseCoordinates(_ text: String) -> [[Double]] {
199
+ text
200
+ .components(separatedBy: .whitespacesAndNewlines)
201
+ .filter { !$0.isEmpty }
202
+ .compactMap { tuple -> [Double]? in
203
+ let parts = tuple.split(separator: ",").compactMap { Double($0) }
204
+ return parts.count >= 2 ? Array(parts) : nil
205
+ }
206
+ }
207
+
208
+ private func buildGeometry(type: String) -> [String: Any]? {
209
+ switch type {
210
+ case "Point":
211
+ guard !pointCoord.isEmpty else { return nil }
212
+ return ["type": "Point", "coordinates": pointCoord]
213
+ case "LineString":
214
+ guard !lineCoords.isEmpty else { return nil }
215
+ return ["type": "LineString", "coordinates": lineCoords]
216
+ case "Polygon":
217
+ guard !outerRing.isEmpty else { return nil }
218
+ return ["type": "Polygon", "coordinates": [outerRing] + innerRings]
219
+ default:
220
+ return nil
221
+ }
222
+ }
223
+
224
+ private func resolveStyle(url: String) -> StyleInfo? {
225
+ let id = url.hasPrefix("#") ? String(url.dropFirst()) : url
226
+ if let style = styles[id] { return style }
227
+ if let normalUrl = styleMaps[id] {
228
+ let nid = normalUrl.hasPrefix("#") ? String(normalUrl.dropFirst()) : normalUrl
229
+ return styles[nid]
230
+ }
231
+ return nil
232
+ }
233
+
234
+ private func finalizeFeature() {
235
+ let geometry: [String: Any]
236
+ if currentGeometryType == "MultiGeometry" {
237
+ guard !multiGeometries.isEmpty else { return }
238
+ geometry = ["type": "GeometryCollection", "geometries": multiGeometries]
239
+ } else if let gType = currentGeometryType, let geom = buildGeometry(type: gType) {
240
+ geometry = geom
241
+ } else {
242
+ return
243
+ }
244
+
245
+ var properties: [String: Any] = [:]
246
+ if !currentFeatureName.isEmpty { properties["name"] = currentFeatureName }
247
+ if !currentFeatureDescription.isEmpty { properties["description"] = currentFeatureDescription }
248
+ if !currentFeatureStyleUrl.isEmpty {
249
+ properties["styleId"] = currentFeatureStyleUrl
250
+ if let style = resolveStyle(url: currentFeatureStyleUrl), !style.isEmpty {
251
+ properties["style"] = style.asDictionary()
252
+ }
253
+ }
254
+
255
+ var feature: [String: Any] = ["type": "Feature", "geometry": geometry, "properties": properties]
256
+ if let id = currentFeatureId, !id.isEmpty { feature["id"] = id }
257
+ features.append(feature)
258
+ }
259
+ }
260
+
261
+ private struct StyleInfo {
262
+ var strokeColor: String?
263
+ var strokeWidth: Double?
264
+ var fillColor: String?
265
+ var fillEnabled: Bool = true
266
+ var iconUrl: String?
267
+ var iconScale: Double?
268
+
269
+ var isEmpty: Bool { strokeColor == nil && fillColor == nil && iconUrl == nil }
270
+
271
+ func asDictionary() -> [String: Any] {
272
+ var d: [String: Any] = [:]
273
+ if let v = strokeColor { d["strokeColor"] = v }
274
+ if let v = strokeWidth { d["strokeWidth"] = v }
275
+ if let v = fillColor { d["fillColor"] = v }
276
+ if !fillEnabled { d["fillEnabled"] = false }
277
+ if let v = iconUrl { d["iconUrl"] = v }
278
+ if let v = iconScale { d["iconScale"] = v }
279
+ return d
280
+ }
281
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@dunguel/expo-geo-parser",
3
+ "version": "0.1.0",
4
+ "description": "A high-performance Expo native module for parsing geospatial files like KML, KMZ, and other formats, extracting polygons, markers, and spatial data efficiently on iOS and Android.",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "scripts": {
8
+ "build": "expo-module build",
9
+ "clean": "expo-module clean",
10
+ "lint": "expo-module lint",
11
+ "test": "expo-module test",
12
+ "prepare": "expo-module prepare",
13
+ "prepublishOnly": "expo-module prepublishOnly",
14
+ "expo-module": "expo-module",
15
+ "open:ios": "xed example/ios",
16
+ "open:android": "open -a \"Android Studio\" example/android"
17
+ },
18
+ "keywords": [
19
+ "react-native",
20
+ "expo",
21
+ "expo-geo-parser",
22
+ "ExpoGeoParser"
23
+ ],
24
+ "repository": "https://github.com/guilhermedunguel/expo-geo-parser",
25
+ "bugs": {
26
+ "url": "https://github.com/guilhermedunguel/expo-geo-parser/issues"
27
+ },
28
+ "author": "Guilherme Dunguel <guilhermedunguel@gmail.com> (https://github.com/guilhermedunguel)",
29
+ "license": "MIT",
30
+ "homepage": "https://github.com/guilhermedunguel/expo-geo-parser#readme",
31
+ "dependencies": {},
32
+ "devDependencies": {
33
+ "@types/react": "~19.1.1",
34
+ "expo-module-scripts": "^55.0.2",
35
+ "expo": "^55.0.5",
36
+ "react-native": "0.82.1"
37
+ },
38
+ "peerDependencies": {
39
+ "expo": "*",
40
+ "react": "*",
41
+ "react-native": "*"
42
+ }
43
+ }
@@ -0,0 +1 @@
1
+ export type ExpoGeoParserEvents = {};
@@ -0,0 +1,48 @@
1
+ import { NativeModule, requireNativeModule } from "expo";
2
+
3
+ export type GeoFileType = "kml" | "kmz" | "zip" | "geojson" | "json" | "unknown";
4
+
5
+ export type DetectedFileType = {
6
+ uri: string;
7
+ fileName?: string | null;
8
+ extension?: string | null;
9
+ type: GeoFileType;
10
+ uti?: string | null;
11
+ };
12
+
13
+ export type GeoJSONGeometry = {
14
+ type: string;
15
+ coordinates?: unknown;
16
+ geometries?: GeoJSONGeometry[];
17
+ };
18
+
19
+ export type GeoJSONFeature = {
20
+ type: "Feature";
21
+ id?: string | number;
22
+ geometry: GeoJSONGeometry;
23
+ properties: Record<string, unknown>;
24
+ };
25
+
26
+ export type GeoJSONFeatureCollection = {
27
+ type: "FeatureCollection";
28
+ name?: string;
29
+ description?: string;
30
+ sourceType?: GeoFileType;
31
+ features: GeoJSONFeature[];
32
+ };
33
+
34
+ export type ParseFeaturesEvent = {
35
+ features: GeoJSONFeature[];
36
+ isLast: boolean;
37
+ };
38
+
39
+ declare class ExpoGeoParserModule extends NativeModule {
40
+ detectFileType(uri: string): DetectedFileType;
41
+ parseFile(uri: string): Promise<Omit<GeoJSONFeatureCollection, "features">>;
42
+ addListener(
43
+ event: "onParseFeatures",
44
+ listener: (event: ParseFeaturesEvent) => void
45
+ ): { remove: () => void };
46
+ }
47
+
48
+ export default requireNativeModule<ExpoGeoParserModule>("ExpoGeoParser");