@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.
- package/.eslintrc.js +5 -0
- package/README.md +35 -0
- package/android/build.gradle +18 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/expo/modules/geoparser/ExpoGeoParserModule.kt +134 -0
- package/android/src/main/java/expo/modules/geoparser/GeoParserError.kt +9 -0
- package/android/src/main/java/expo/modules/geoparser/parsers/GeoJSONParser.kt +100 -0
- package/android/src/main/java/expo/modules/geoparser/parsers/KMLParser.kt +283 -0
- package/build/ExpoGeoParser.types.d.ts +2 -0
- package/build/ExpoGeoParser.types.d.ts.map +1 -0
- package/build/ExpoGeoParser.types.js +2 -0
- package/build/ExpoGeoParser.types.js.map +1 -0
- package/build/ExpoGeoParserModule.d.ts +41 -0
- package/build/ExpoGeoParserModule.d.ts.map +1 -0
- package/build/ExpoGeoParserModule.js +3 -0
- package/build/ExpoGeoParserModule.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +23 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoGeoParser.podspec +30 -0
- package/ios/ExpoGeoParserModule.swift +164 -0
- package/ios/Parsers/GeoJSONParser.swift +59 -0
- package/ios/Parsers/KMLParser.swift +281 -0
- package/package.json +43 -0
- package/src/ExpoGeoParser.types.ts +1 -0
- package/src/ExpoGeoParserModule.ts +48 -0
- package/src/index.ts +28 -0
- package/tsconfig.json +9 -0
package/build/index.d.ts
ADDED
|
@@ -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,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");
|