@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/.eslintrc.js
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# expo-geo-parser
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
# API documentation
|
|
6
|
+
|
|
7
|
+
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/geo-parser/)
|
|
8
|
+
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/geo-parser/)
|
|
9
|
+
|
|
10
|
+
# Installation in managed Expo projects
|
|
11
|
+
|
|
12
|
+
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
|
|
13
|
+
|
|
14
|
+
# Installation in bare React Native projects
|
|
15
|
+
|
|
16
|
+
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
|
|
17
|
+
|
|
18
|
+
### Add the package to your npm dependencies
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
npm install expo-geo-parser
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Configure for Android
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Configure for iOS
|
|
30
|
+
|
|
31
|
+
Run `npx pod-install` after installing the npm package.
|
|
32
|
+
|
|
33
|
+
# Contributing
|
|
34
|
+
|
|
35
|
+
Contributions are very welcome! Please refer to guidelines described in the [contributing guide]( https://github.com/expo/expo#contributing).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
plugins {
|
|
2
|
+
id 'com.android.library'
|
|
3
|
+
id 'kotlin-android'
|
|
4
|
+
id 'expo-module-gradle-plugin'
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
group = 'expo.modules.geoparser'
|
|
8
|
+
version = '0.1.0'
|
|
9
|
+
|
|
10
|
+
android {
|
|
11
|
+
namespace "expo.modules.geoparser"
|
|
12
|
+
defaultConfig {
|
|
13
|
+
versionCode 1
|
|
14
|
+
versionName "0.1.0"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
dependencies {}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
package expo.modules.geoparser
|
|
2
|
+
|
|
3
|
+
import android.net.Uri
|
|
4
|
+
import expo.modules.geoparser.parsers.GeoJSONParser
|
|
5
|
+
import expo.modules.geoparser.parsers.KMLParser
|
|
6
|
+
import expo.modules.kotlin.modules.Module
|
|
7
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
8
|
+
import java.io.File
|
|
9
|
+
import java.io.InputStream
|
|
10
|
+
import java.util.UUID
|
|
11
|
+
import java.util.zip.ZipInputStream
|
|
12
|
+
|
|
13
|
+
class ExpoGeoParserModule : Module() {
|
|
14
|
+
override fun definition() = ModuleDefinition {
|
|
15
|
+
Name("ExpoGeoParser")
|
|
16
|
+
Events("onParseFeatures")
|
|
17
|
+
|
|
18
|
+
Function("detectFileType") { uri: String ->
|
|
19
|
+
detectFileType(uri)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
AsyncFunction("parseFile") { uri: String ->
|
|
23
|
+
parseGeoFile(uri) { batch, isLast ->
|
|
24
|
+
sendEvent("onParseFeatures", mapOf(
|
|
25
|
+
"features" to batch,
|
|
26
|
+
"isLast" to isLast
|
|
27
|
+
))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private fun detectFileType(uri: String): Map<String, Any?> {
|
|
33
|
+
val parsed = Uri.parse(uri)
|
|
34
|
+
val fileName = parsed.lastPathSegment
|
|
35
|
+
val ext = fileName?.substringAfterLast('.', "")?.lowercase()?.takeIf { it.isNotEmpty() }
|
|
36
|
+
val type = when (ext) {
|
|
37
|
+
"kml" -> "kml"
|
|
38
|
+
"kmz" -> "kmz"
|
|
39
|
+
"zip" -> "zip"
|
|
40
|
+
"geojson" -> "geojson"
|
|
41
|
+
"json" -> "json"
|
|
42
|
+
else -> "unknown"
|
|
43
|
+
}
|
|
44
|
+
return mapOf("uri" to uri, "fileName" to fileName, "extension" to ext, "type" to type, "uti" to null)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private fun openStream(uri: String): InputStream {
|
|
48
|
+
val parsed = Uri.parse(uri)
|
|
49
|
+
if (parsed.scheme == "file") {
|
|
50
|
+
return File(parsed.path ?: throw GeoParserError.InvalidURI(uri)).inputStream()
|
|
51
|
+
}
|
|
52
|
+
val context = appContext.reactContext ?: throw GeoParserError.InvalidURI(uri)
|
|
53
|
+
return context.contentResolver.openInputStream(parsed) ?: throw GeoParserError.InvalidURI(uri)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private fun parseGeoFile(uri: String, onFeatures: (List<Map<String, Any?>>, Boolean) -> Unit): Map<String, Any?> {
|
|
57
|
+
val parsed = Uri.parse(uri)
|
|
58
|
+
val ext = parsed.lastPathSegment?.substringAfterLast('.', "")?.lowercase() ?: ""
|
|
59
|
+
|
|
60
|
+
if (ext == "kmz" || ext == "zip") return parseArchive(uri, ext, onFeatures)
|
|
61
|
+
|
|
62
|
+
return openStream(uri).use { stream ->
|
|
63
|
+
when (ext) {
|
|
64
|
+
"kml" -> KMLParser.parse(stream, "kml", onFeatures)
|
|
65
|
+
"geojson", "json" -> GeoJSONParser.parse(stream, ext, onFeatures)
|
|
66
|
+
else -> sniffAndParse(stream, "unknown", onFeatures)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private fun parseArchive(uri: String, sourceType: String, onFeatures: (List<Map<String, Any?>>, Boolean) -> Unit): Map<String, Any?> {
|
|
72
|
+
val context = appContext.reactContext ?: throw GeoParserError.InvalidURI(uri)
|
|
73
|
+
val tmpDir = File(context.cacheDir, UUID.randomUUID().toString()).also { it.mkdirs() }
|
|
74
|
+
try {
|
|
75
|
+
var bestKmlFile: File? = null
|
|
76
|
+
var firstGeoJsonFile: File? = null
|
|
77
|
+
|
|
78
|
+
openStream(uri).use { raw ->
|
|
79
|
+
ZipInputStream(raw).use { zip ->
|
|
80
|
+
var entry = zip.nextEntry
|
|
81
|
+
while (entry != null) {
|
|
82
|
+
val entryExt = entry.name.substringAfterLast('.', "").lowercase()
|
|
83
|
+
if (!entry.isDirectory) {
|
|
84
|
+
when (entryExt) {
|
|
85
|
+
"kml" -> {
|
|
86
|
+
val outFile = File(tmpDir, entry.name.substringAfterLast('/'))
|
|
87
|
+
outFile.parentFile?.mkdirs()
|
|
88
|
+
outFile.outputStream().use { zip.copyTo(it) }
|
|
89
|
+
if (bestKmlFile == null || entry.name.lowercase().endsWith("doc.kml")) {
|
|
90
|
+
bestKmlFile = outFile
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
"geojson", "json" -> {
|
|
94
|
+
if (firstGeoJsonFile == null) {
|
|
95
|
+
val outFile = File(tmpDir, entry.name.substringAfterLast('/'))
|
|
96
|
+
outFile.outputStream().use { zip.copyTo(it) }
|
|
97
|
+
firstGeoJsonFile = outFile
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
zip.closeEntry()
|
|
103
|
+
entry = zip.nextEntry
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
bestKmlFile?.let { file ->
|
|
109
|
+
return file.inputStream().use { KMLParser.parse(it, sourceType, onFeatures) }
|
|
110
|
+
}
|
|
111
|
+
firstGeoJsonFile?.let { file ->
|
|
112
|
+
return file.inputStream().use { GeoJSONParser.parse(it, sourceType, onFeatures) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
throw GeoParserError.NoGeoDataFound
|
|
116
|
+
} finally {
|
|
117
|
+
tmpDir.deleteRecursively()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private fun sniffAndParse(stream: InputStream, sourceType: String, onFeatures: (List<Map<String, Any?>>, Boolean) -> Unit): Map<String, Any?> {
|
|
122
|
+
val buffered = stream.buffered()
|
|
123
|
+
buffered.mark(512)
|
|
124
|
+
val peek = ByteArray(256)
|
|
125
|
+
val n = buffered.read(peek)
|
|
126
|
+
buffered.reset()
|
|
127
|
+
val prefix = if (n > 0) String(peek, 0, n, Charsets.UTF_8) else ""
|
|
128
|
+
return when {
|
|
129
|
+
prefix.trimStart().startsWith("{") -> GeoJSONParser.parse(buffered, sourceType, onFeatures)
|
|
130
|
+
"<kml" in prefix || "<Placemark" in prefix || "<Document" in prefix -> KMLParser.parse(buffered, sourceType, onFeatures)
|
|
131
|
+
else -> throw GeoParserError.UnsupportedFormat
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
package expo.modules.geoparser
|
|
2
|
+
|
|
3
|
+
sealed class GeoParserError(message: String) : Exception(message) {
|
|
4
|
+
class InvalidURI(uri: String) : GeoParserError("Invalid URI: $uri")
|
|
5
|
+
object ExtractionFailed : GeoParserError("Failed to extract archive")
|
|
6
|
+
object NoGeoDataFound : GeoParserError("No supported geo data found in archive")
|
|
7
|
+
object UnsupportedFormat : GeoParserError("Unsupported or unrecognised file format")
|
|
8
|
+
class ParseError(msg: String) : GeoParserError("Parse error: $msg")
|
|
9
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
package expo.modules.geoparser.parsers
|
|
2
|
+
|
|
3
|
+
import expo.modules.geoparser.GeoParserError
|
|
4
|
+
import org.json.JSONArray
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
import java.io.InputStream
|
|
7
|
+
|
|
8
|
+
object GeoJSONParser {
|
|
9
|
+
|
|
10
|
+
fun parse(input: InputStream, sourceType: String, onFeatures: (List<Map<String, Any?>>, isLast: Boolean) -> Unit): Map<String, Any?> {
|
|
11
|
+
val json = try {
|
|
12
|
+
JSONObject(input.reader(Charsets.UTF_8).readText())
|
|
13
|
+
} catch (e: Exception) {
|
|
14
|
+
throw GeoParserError.ParseError("Invalid JSON")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
val features = extractFeatures(json)
|
|
18
|
+
val batchSize = 200
|
|
19
|
+
if (features.isEmpty()) {
|
|
20
|
+
onFeatures(emptyList(), true)
|
|
21
|
+
} else {
|
|
22
|
+
var idx = 0
|
|
23
|
+
while (idx < features.size) {
|
|
24
|
+
val end = minOf(idx + batchSize, features.size)
|
|
25
|
+
onFeatures(features.subList(idx, end), end >= features.size)
|
|
26
|
+
idx = end
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
val result = mutableMapOf<String, Any?>(
|
|
31
|
+
"type" to "FeatureCollection",
|
|
32
|
+
"sourceType" to sourceType
|
|
33
|
+
)
|
|
34
|
+
json.optString("name").takeIf { it.isNotEmpty() }?.let { result["name"] = it }
|
|
35
|
+
json.optJSONObject("properties")?.let { props ->
|
|
36
|
+
props.optString("name").takeIf { it.isNotEmpty() }?.let { result["name"] = it }
|
|
37
|
+
props.optString("description").takeIf { it.isNotEmpty() }?.let { result["description"] = it }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return result
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private fun extractFeatures(json: JSONObject): List<Map<String, Any?>> {
|
|
44
|
+
return when (val type = json.optString("type")) {
|
|
45
|
+
"FeatureCollection" -> {
|
|
46
|
+
val arr = json.optJSONArray("features") ?: return emptyList()
|
|
47
|
+
(0 until arr.length()).mapNotNull { parseFeature(arr.getJSONObject(it)) }
|
|
48
|
+
}
|
|
49
|
+
"Feature" -> listOfNotNull(parseFeature(json))
|
|
50
|
+
else -> {
|
|
51
|
+
if (isGeometryType(type)) listOfNotNull(bareGeometryToFeature(json))
|
|
52
|
+
else emptyList()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private fun parseFeature(json: JSONObject): Map<String, Any?>? {
|
|
58
|
+
if (json.optString("type") != "Feature") return null
|
|
59
|
+
val geometry = json.optJSONObject("geometry") ?: return null
|
|
60
|
+
val properties = json.optJSONObject("properties") ?: JSONObject()
|
|
61
|
+
val feature = mutableMapOf<String, Any?>(
|
|
62
|
+
"type" to "Feature",
|
|
63
|
+
"geometry" to toMap(geometry),
|
|
64
|
+
"properties" to toMap(properties)
|
|
65
|
+
)
|
|
66
|
+
json.opt("id")?.takeIf { it != JSONObject.NULL }?.let { feature["id"] = it.toString() }
|
|
67
|
+
return feature
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private fun bareGeometryToFeature(json: JSONObject): Map<String, Any?>? {
|
|
71
|
+
if (json.optString("type").isEmpty()) return null
|
|
72
|
+
return mapOf(
|
|
73
|
+
"type" to "Feature",
|
|
74
|
+
"geometry" to toMap(json),
|
|
75
|
+
"properties" to emptyMap<String, Any?>()
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private fun isGeometryType(type: String): Boolean = type in setOf(
|
|
80
|
+
"Point", "LineString", "Polygon",
|
|
81
|
+
"MultiPoint", "MultiLineString", "MultiPolygon",
|
|
82
|
+
"GeometryCollection"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
fun toMap(obj: JSONObject): Map<String, Any?> {
|
|
86
|
+
val map = mutableMapOf<String, Any?>()
|
|
87
|
+
for (key in obj.keys()) map[key] = toKotlin(obj.get(key))
|
|
88
|
+
return map
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private fun toList(arr: JSONArray): List<Any?> =
|
|
92
|
+
(0 until arr.length()).map { toKotlin(arr.get(it)) }
|
|
93
|
+
|
|
94
|
+
private fun toKotlin(value: Any?): Any? = when (value) {
|
|
95
|
+
is JSONObject -> toMap(value)
|
|
96
|
+
is JSONArray -> toList(value)
|
|
97
|
+
JSONObject.NULL -> null
|
|
98
|
+
else -> value
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
package expo.modules.geoparser.parsers
|
|
2
|
+
|
|
3
|
+
import expo.modules.geoparser.GeoParserError
|
|
4
|
+
import org.xml.sax.Attributes
|
|
5
|
+
import org.xml.sax.helpers.DefaultHandler
|
|
6
|
+
import java.io.InputStream
|
|
7
|
+
import javax.xml.parsers.SAXParserFactory
|
|
8
|
+
|
|
9
|
+
class KMLParser(
|
|
10
|
+
private val batchSize: Int = 200,
|
|
11
|
+
private val onFeatures: (List<Map<String, Any?>>, isLast: Boolean) -> Unit
|
|
12
|
+
) : DefaultHandler() {
|
|
13
|
+
|
|
14
|
+
private val featureBuffer = mutableListOf<Map<String, Any?>>()
|
|
15
|
+
var documentName = ""
|
|
16
|
+
var documentDescription = ""
|
|
17
|
+
private val styles = mutableMapOf<String, StyleInfo>()
|
|
18
|
+
private val styleMaps = mutableMapOf<String, String>()
|
|
19
|
+
|
|
20
|
+
private val elementStack = mutableListOf<String>()
|
|
21
|
+
private val textBuffer = StringBuilder()
|
|
22
|
+
|
|
23
|
+
private var documentDepth = 0
|
|
24
|
+
private var documentMetaCaptured = false
|
|
25
|
+
|
|
26
|
+
private var inPlacemark = false
|
|
27
|
+
private var currentFeatureId: String? = null
|
|
28
|
+
private var currentFeatureName = ""
|
|
29
|
+
private var currentFeatureDescription = ""
|
|
30
|
+
private var currentFeatureStyleUrl = ""
|
|
31
|
+
private var currentGeometryType: String? = null
|
|
32
|
+
|
|
33
|
+
private var pointCoord = listOf<Double>()
|
|
34
|
+
private var lineCoords = listOf<List<Double>>()
|
|
35
|
+
private var currentRing = listOf<List<Double>>()
|
|
36
|
+
private var outerRing = listOf<List<Double>>()
|
|
37
|
+
private var innerRings = mutableListOf<List<List<Double>>>()
|
|
38
|
+
private var inOuterBoundary = false
|
|
39
|
+
private var inInnerBoundary = false
|
|
40
|
+
|
|
41
|
+
private var inMultiGeometry = false
|
|
42
|
+
private var multiGeometries = mutableListOf<Map<String, Any?>>()
|
|
43
|
+
|
|
44
|
+
private var currentStyleId: String? = null
|
|
45
|
+
private var buildingStyle = StyleInfo()
|
|
46
|
+
private var inLineStyle = false
|
|
47
|
+
private var inPolyStyle = false
|
|
48
|
+
private var inIconStyle = false
|
|
49
|
+
private var inIconHref = false
|
|
50
|
+
|
|
51
|
+
private var inStyleMap = false
|
|
52
|
+
private var currentStyleMapId: String? = null
|
|
53
|
+
private var inPair = false
|
|
54
|
+
private var currentPairKey = ""
|
|
55
|
+
private var currentPairStyleUrl = ""
|
|
56
|
+
|
|
57
|
+
companion object {
|
|
58
|
+
fun parse(input: InputStream, sourceType: String, onFeatures: (List<Map<String, Any?>>, Boolean) -> Unit): Map<String, Any?> {
|
|
59
|
+
val handler = KMLParser(onFeatures = onFeatures)
|
|
60
|
+
val factory = SAXParserFactory.newInstance().apply { isNamespaceAware = true }
|
|
61
|
+
try {
|
|
62
|
+
factory.newSAXParser().parse(input, handler)
|
|
63
|
+
} catch (e: Exception) {
|
|
64
|
+
throw GeoParserError.ParseError(e.message ?: "XML parse error")
|
|
65
|
+
}
|
|
66
|
+
val result = mutableMapOf<String, Any?>(
|
|
67
|
+
"type" to "FeatureCollection",
|
|
68
|
+
"sourceType" to sourceType
|
|
69
|
+
)
|
|
70
|
+
if (handler.documentName.isNotEmpty()) result["name"] = handler.documentName
|
|
71
|
+
if (handler.documentDescription.isNotEmpty()) result["description"] = handler.documentDescription
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override fun startElement(uri: String?, localName: String?, qName: String?, attrs: Attributes?) {
|
|
77
|
+
val name = stripped(localName ?: qName ?: "")
|
|
78
|
+
elementStack.add(name)
|
|
79
|
+
textBuffer.clear()
|
|
80
|
+
|
|
81
|
+
when (name) {
|
|
82
|
+
"Document" -> documentDepth++
|
|
83
|
+
"Style" -> { currentStyleId = attrs?.getValue("id"); buildingStyle = StyleInfo() }
|
|
84
|
+
"StyleMap" -> { inStyleMap = true; currentStyleMapId = attrs?.getValue("id") }
|
|
85
|
+
"Pair" -> if (inStyleMap) { inPair = true; currentPairKey = ""; currentPairStyleUrl = "" }
|
|
86
|
+
"LineStyle" -> inLineStyle = true
|
|
87
|
+
"PolyStyle" -> inPolyStyle = true
|
|
88
|
+
"IconStyle" -> inIconStyle = true
|
|
89
|
+
"Icon" -> if (inIconStyle) inIconHref = true
|
|
90
|
+
"Placemark" -> {
|
|
91
|
+
inPlacemark = true
|
|
92
|
+
currentFeatureId = attrs?.getValue("id")
|
|
93
|
+
currentFeatureName = ""
|
|
94
|
+
currentFeatureDescription = ""
|
|
95
|
+
currentFeatureStyleUrl = ""
|
|
96
|
+
currentGeometryType = null
|
|
97
|
+
multiGeometries = mutableListOf()
|
|
98
|
+
}
|
|
99
|
+
"Point" -> { currentGeometryType = "Point"; pointCoord = listOf() }
|
|
100
|
+
"LineString" -> { currentGeometryType = "LineString"; lineCoords = listOf() }
|
|
101
|
+
"LinearRing" -> currentRing = listOf()
|
|
102
|
+
"Polygon" -> {
|
|
103
|
+
currentGeometryType = "Polygon"
|
|
104
|
+
outerRing = listOf(); innerRings = mutableListOf()
|
|
105
|
+
inOuterBoundary = false; inInnerBoundary = false
|
|
106
|
+
}
|
|
107
|
+
"MultiGeometry" -> { inMultiGeometry = true; multiGeometries = mutableListOf() }
|
|
108
|
+
"outerBoundaryIs" -> { inOuterBoundary = true; inInnerBoundary = false }
|
|
109
|
+
"innerBoundaryIs" -> { inInnerBoundary = true; inOuterBoundary = false }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
override fun characters(ch: CharArray?, start: Int, length: Int) {
|
|
114
|
+
if (ch != null) textBuffer.append(ch, start, length)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
override fun endElement(uri: String?, localName: String?, qName: String?) {
|
|
118
|
+
val name = stripped(localName ?: qName ?: "")
|
|
119
|
+
val text = textBuffer.toString().trim()
|
|
120
|
+
textBuffer.clear()
|
|
121
|
+
if (elementStack.isNotEmpty()) elementStack.removeAt(elementStack.lastIndex)
|
|
122
|
+
|
|
123
|
+
when (name) {
|
|
124
|
+
"Document" -> documentDepth--
|
|
125
|
+
"name" -> when {
|
|
126
|
+
inPlacemark -> currentFeatureName = text
|
|
127
|
+
documentDepth > 0 && !documentMetaCaptured -> { documentName = text; documentMetaCaptured = true }
|
|
128
|
+
}
|
|
129
|
+
"description" -> when {
|
|
130
|
+
inPlacemark -> currentFeatureDescription = text
|
|
131
|
+
documentDepth > 0 && documentDescription.isEmpty() -> documentDescription = text
|
|
132
|
+
}
|
|
133
|
+
"Style" -> { currentStyleId?.let { styles[it] = buildingStyle }; currentStyleId = null }
|
|
134
|
+
"StyleMap" -> { inStyleMap = false; currentStyleMapId = null }
|
|
135
|
+
"Pair" -> {
|
|
136
|
+
if (inStyleMap && currentPairKey == "normal") currentStyleMapId?.let { styleMaps[it] = currentPairStyleUrl }
|
|
137
|
+
inPair = false
|
|
138
|
+
}
|
|
139
|
+
"key" -> if (inStyleMap && inPair) currentPairKey = text
|
|
140
|
+
"styleUrl" -> when {
|
|
141
|
+
inPlacemark -> currentFeatureStyleUrl = text
|
|
142
|
+
inStyleMap && inPair -> currentPairStyleUrl = text
|
|
143
|
+
}
|
|
144
|
+
"LineStyle" -> inLineStyle = false
|
|
145
|
+
"PolyStyle" -> inPolyStyle = false
|
|
146
|
+
"IconStyle" -> inIconStyle = false
|
|
147
|
+
"Icon" -> inIconHref = false
|
|
148
|
+
"color" -> {
|
|
149
|
+
val hex = kmlColorToCSS(text)
|
|
150
|
+
when {
|
|
151
|
+
inLineStyle -> buildingStyle.strokeColor = hex
|
|
152
|
+
inPolyStyle -> buildingStyle.fillColor = hex
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
"width" -> if (inLineStyle) text.toDoubleOrNull()?.let { buildingStyle.strokeWidth = it }
|
|
156
|
+
"fill" -> if (inPolyStyle) buildingStyle.fillEnabled = text != "0"
|
|
157
|
+
"href" -> if (inIconHref) buildingStyle.iconUrl = text
|
|
158
|
+
"scale" -> if (inIconStyle) text.toDoubleOrNull()?.let { buildingStyle.iconScale = it }
|
|
159
|
+
"coordinates" -> {
|
|
160
|
+
val coords = parseCoordinates(text)
|
|
161
|
+
val parent = elementStack.lastOrNull() ?: ""
|
|
162
|
+
when (parent) {
|
|
163
|
+
"Point" -> pointCoord = coords.firstOrNull() ?: listOf()
|
|
164
|
+
"LineString" -> lineCoords = coords
|
|
165
|
+
"LinearRing" -> currentRing = coords
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
"LinearRing" -> when {
|
|
169
|
+
inOuterBoundary -> outerRing = currentRing
|
|
170
|
+
inInnerBoundary -> innerRings.add(currentRing)
|
|
171
|
+
}
|
|
172
|
+
"outerBoundaryIs" -> inOuterBoundary = false
|
|
173
|
+
"innerBoundaryIs" -> inInnerBoundary = false
|
|
174
|
+
"Point", "LineString", "Polygon" -> {
|
|
175
|
+
if (inMultiGeometry) {
|
|
176
|
+
buildGeometry(name)?.let {
|
|
177
|
+
multiGeometries.add(it)
|
|
178
|
+
pointCoord = listOf(); lineCoords = listOf()
|
|
179
|
+
outerRing = listOf(); innerRings = mutableListOf()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
"MultiGeometry" -> { inMultiGeometry = false; currentGeometryType = "MultiGeometry" }
|
|
184
|
+
"Placemark" -> { finalizeFeature(); inPlacemark = false }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
override fun endDocument() {
|
|
189
|
+
onFeatures(featureBuffer.toList(), true)
|
|
190
|
+
featureBuffer.clear()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private fun stripped(name: String): String {
|
|
194
|
+
val idx = name.lastIndexOf(':')
|
|
195
|
+
return if (idx >= 0) name.substring(idx + 1) else name
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private fun kmlColorToCSS(kml: String): String {
|
|
199
|
+
val s = kml.trim().lowercase()
|
|
200
|
+
if (s.length != 8) return "#000000"
|
|
201
|
+
return "#${s.substring(6, 8)}${s.substring(4, 6)}${s.substring(2, 4)}"
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private fun parseCoordinates(text: String): List<List<Double>> =
|
|
205
|
+
text.trim().split(Regex("\\s+"))
|
|
206
|
+
.filter { it.isNotEmpty() }
|
|
207
|
+
.mapNotNull { tuple ->
|
|
208
|
+
val parts = tuple.split(",").mapNotNull { it.toDoubleOrNull() }
|
|
209
|
+
if (parts.size >= 2) parts else null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private fun buildGeometry(type: String): Map<String, Any?>? = when (type) {
|
|
213
|
+
"Point" -> if (pointCoord.isNotEmpty()) mapOf("type" to "Point", "coordinates" to pointCoord) else null
|
|
214
|
+
"LineString" -> if (lineCoords.isNotEmpty()) mapOf("type" to "LineString", "coordinates" to lineCoords) else null
|
|
215
|
+
"Polygon" -> if (outerRing.isNotEmpty()) mapOf("type" to "Polygon", "coordinates" to (listOf(outerRing) + innerRings)) else null
|
|
216
|
+
else -> null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private fun resolveStyle(url: String): StyleInfo? {
|
|
220
|
+
val id = if (url.startsWith("#")) url.drop(1) else url
|
|
221
|
+
styles[id]?.let { return it }
|
|
222
|
+
styleMaps[id]?.let { normalUrl ->
|
|
223
|
+
val nid = if (normalUrl.startsWith("#")) normalUrl.drop(1) else normalUrl
|
|
224
|
+
return styles[nid]
|
|
225
|
+
}
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private fun finalizeFeature() {
|
|
230
|
+
val geometry: Map<String, Any?> = if (currentGeometryType == "MultiGeometry") {
|
|
231
|
+
if (multiGeometries.isEmpty()) return
|
|
232
|
+
mapOf("type" to "GeometryCollection", "geometries" to multiGeometries.toList())
|
|
233
|
+
} else {
|
|
234
|
+
val gType = currentGeometryType ?: return
|
|
235
|
+
buildGeometry(gType) ?: return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
val properties = mutableMapOf<String, Any?>()
|
|
239
|
+
if (currentFeatureName.isNotEmpty()) properties["name"] = currentFeatureName
|
|
240
|
+
if (currentFeatureDescription.isNotEmpty()) properties["description"] = currentFeatureDescription
|
|
241
|
+
if (currentFeatureStyleUrl.isNotEmpty()) {
|
|
242
|
+
properties["styleId"] = currentFeatureStyleUrl
|
|
243
|
+
resolveStyle(currentFeatureStyleUrl)?.takeIf { !it.isEmpty }?.let {
|
|
244
|
+
properties["style"] = it.asDictionary()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
val feature = mutableMapOf<String, Any?>(
|
|
249
|
+
"type" to "Feature",
|
|
250
|
+
"geometry" to geometry,
|
|
251
|
+
"properties" to properties
|
|
252
|
+
)
|
|
253
|
+
currentFeatureId?.takeIf { it.isNotEmpty() }?.let { feature["id"] = it }
|
|
254
|
+
|
|
255
|
+
featureBuffer.add(feature)
|
|
256
|
+
if (featureBuffer.size >= batchSize) {
|
|
257
|
+
onFeatures(featureBuffer.toList(), false)
|
|
258
|
+
featureBuffer.clear()
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private data class StyleInfo(
|
|
264
|
+
var strokeColor: String? = null,
|
|
265
|
+
var strokeWidth: Double? = null,
|
|
266
|
+
var fillColor: String? = null,
|
|
267
|
+
var fillEnabled: Boolean = true,
|
|
268
|
+
var iconUrl: String? = null,
|
|
269
|
+
var iconScale: Double? = null
|
|
270
|
+
) {
|
|
271
|
+
val isEmpty: Boolean get() = strokeColor == null && fillColor == null && iconUrl == null
|
|
272
|
+
|
|
273
|
+
fun asDictionary(): Map<String, Any?> {
|
|
274
|
+
val d = mutableMapOf<String, Any?>()
|
|
275
|
+
strokeColor?.let { d["strokeColor"] = it }
|
|
276
|
+
strokeWidth?.let { d["strokeWidth"] = it }
|
|
277
|
+
fillColor?.let { d["fillColor"] = it }
|
|
278
|
+
if (!fillEnabled) d["fillEnabled"] = false
|
|
279
|
+
iconUrl?.let { d["iconUrl"] = it }
|
|
280
|
+
iconScale?.let { d["iconScale"] = it }
|
|
281
|
+
return d
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoGeoParser.types.d.ts","sourceRoot":"","sources":["../src/ExpoGeoParser.types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,mBAAmB,GAAG,EAAE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoGeoParser.types.js","sourceRoot":"","sources":["../src/ExpoGeoParser.types.ts"],"names":[],"mappings":"","sourcesContent":["export type ExpoGeoParserEvents = {};\n"]}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { NativeModule } from "expo";
|
|
2
|
+
export type GeoFileType = "kml" | "kmz" | "zip" | "geojson" | "json" | "unknown";
|
|
3
|
+
export type DetectedFileType = {
|
|
4
|
+
uri: string;
|
|
5
|
+
fileName?: string | null;
|
|
6
|
+
extension?: string | null;
|
|
7
|
+
type: GeoFileType;
|
|
8
|
+
uti?: string | null;
|
|
9
|
+
};
|
|
10
|
+
export type GeoJSONGeometry = {
|
|
11
|
+
type: string;
|
|
12
|
+
coordinates?: unknown;
|
|
13
|
+
geometries?: GeoJSONGeometry[];
|
|
14
|
+
};
|
|
15
|
+
export type GeoJSONFeature = {
|
|
16
|
+
type: "Feature";
|
|
17
|
+
id?: string | number;
|
|
18
|
+
geometry: GeoJSONGeometry;
|
|
19
|
+
properties: Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
export type GeoJSONFeatureCollection = {
|
|
22
|
+
type: "FeatureCollection";
|
|
23
|
+
name?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
sourceType?: GeoFileType;
|
|
26
|
+
features: GeoJSONFeature[];
|
|
27
|
+
};
|
|
28
|
+
export type ParseFeaturesEvent = {
|
|
29
|
+
features: GeoJSONFeature[];
|
|
30
|
+
isLast: boolean;
|
|
31
|
+
};
|
|
32
|
+
declare class ExpoGeoParserModule extends NativeModule {
|
|
33
|
+
detectFileType(uri: string): DetectedFileType;
|
|
34
|
+
parseFile(uri: string): Promise<Omit<GeoJSONFeatureCollection, "features">>;
|
|
35
|
+
addListener(event: "onParseFeatures", listener: (event: ParseFeaturesEvent) => void): {
|
|
36
|
+
remove: () => void;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
declare const _default: ExpoGeoParserModule;
|
|
40
|
+
export default _default;
|
|
41
|
+
//# sourceMappingURL=ExpoGeoParserModule.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoGeoParserModule.d.ts","sourceRoot":"","sources":["../src/ExpoGeoParserModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAAC;AAEjF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,IAAI,EAAE,WAAW,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,IAAI,EAAE,SAAS,CAAC;IAChB,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACrB,QAAQ,EAAE,eAAe,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACrC,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,IAAI,EAAE,mBAAmB,CAAC;IAC1B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,WAAW,CAAC;IACzB,QAAQ,EAAE,cAAc,EAAE,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,OAAO,OAAO,mBAAoB,SAAQ,YAAY;IACpD,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB;IAC7C,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,wBAAwB,EAAE,UAAU,CAAC,CAAC;IAC3E,WAAW,CACT,KAAK,EAAE,iBAAiB,EACxB,QAAQ,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,GAC5C;QAAE,MAAM,EAAE,MAAM,IAAI,CAAA;KAAE;CAC1B;;AAED,wBAAyE"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoGeoParserModule.js","sourceRoot":"","sources":["../src/ExpoGeoParserModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AA+CzD,eAAe,mBAAmB,CAAsB,eAAe,CAAC,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from \"expo\";\n\nexport type GeoFileType = \"kml\" | \"kmz\" | \"zip\" | \"geojson\" | \"json\" | \"unknown\";\n\nexport type DetectedFileType = {\n uri: string;\n fileName?: string | null;\n extension?: string | null;\n type: GeoFileType;\n uti?: string | null;\n};\n\nexport type GeoJSONGeometry = {\n type: string;\n coordinates?: unknown;\n geometries?: GeoJSONGeometry[];\n};\n\nexport type GeoJSONFeature = {\n type: \"Feature\";\n id?: string | number;\n geometry: GeoJSONGeometry;\n properties: Record<string, unknown>;\n};\n\nexport type GeoJSONFeatureCollection = {\n type: \"FeatureCollection\";\n name?: string;\n description?: string;\n sourceType?: GeoFileType;\n features: GeoJSONFeature[];\n};\n\nexport type ParseFeaturesEvent = {\n features: GeoJSONFeature[];\n isLast: boolean;\n};\n\ndeclare class ExpoGeoParserModule extends NativeModule {\n detectFileType(uri: string): DetectedFileType;\n parseFile(uri: string): Promise<Omit<GeoJSONFeatureCollection, \"features\">>;\n addListener(\n event: \"onParseFeatures\",\n listener: (event: ParseFeaturesEvent) => void\n ): { remove: () => void };\n}\n\nexport default requireNativeModule<ExpoGeoParserModule>(\"ExpoGeoParser\");\n"]}
|