@superrouteca/smap-react-native 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +116 -2
- package/SMapReactNative.podspec +23 -0
- package/android/build.gradle +46 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/ca/superroute/smap/reactnative/SMapPackage.kt +17 -0
- package/android/src/main/java/ca/superroute/smap/reactnative/SMapReactView.kt +314 -0
- package/android/src/main/java/ca/superroute/smap/reactnative/SMapViewManager.kt +71 -0
- package/ios/RNSMapHost.swift +243 -0
- package/ios/RNSMapNativeView.mm +161 -0
- package/ios/smap_react_native.rb +96 -0
- package/package.json +67 -4
- package/src/SMap.tsx +226 -0
- package/src/SMapNativeViewNativeComponent.ts +47 -0
- package/src/geojson.ts +66 -0
- package/src/index.tsx +6 -0
- package/src/layers.tsx +114 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
- `<SMap>` (a New Architecture native view over the sMap Android and iOS SDKs) with access, style, language, camera and
|
|
6
|
+
3D building props; the ref and `useSMap()` give the map.
|
|
7
|
+
- `<Points>`, `<Lines>` and `<Polygons>` added, updated and removed with the components, with `onTap`.
|
|
8
|
+
- Tokens from `getToken`, renewed before they expire; map keys (`apiKey`).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Goodneo Solutions
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,117 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @superrouteca/smap-react-native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A React Native map view for Superroute's sMap service, over the sMap Android and iOS SDKs: five colour schemes,
|
|
4
|
+
labels in any language, your points, lines and areas drawn on the GPU (clustered or not), 3D buildings and areas, and
|
|
5
|
+
tokens that renew themselves. The same components as [`@superrouteca/smap-react`](../react): `<SMap>`, `<Points>`,
|
|
6
|
+
`<Lines>`, `<Polygons>`, `useSMap()`.
|
|
7
|
+
|
|
8
|
+
- Version 1.0.0, MIT licence (see `LICENSE` at the repository root)
|
|
9
|
+
- React Native 0.81 or later with the New Architecture (a Fabric component); built and tested with 0.88
|
|
10
|
+
- Android 7.0 (API 24) and later, iOS 15.1 and later
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @superrouteca/smap-react-native
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Android.** The sMap Android SDK comes from JitPack (`com.github.Goodneo-Solutions:smap-sdk:1.0.0`); the package
|
|
19
|
+
adds the JitPack repository itself. Set `smapAndroidVersion` in the root `ext { }` of `android/build.gradle` to use
|
|
20
|
+
another release tag.
|
|
21
|
+
|
|
22
|
+
**iOS.** The sMap iOS SDK is a Swift package, added to the pod by CocoaPods. Add two lines to `ios/Podfile`, then run
|
|
23
|
+
`pod install`:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require Pod::Executable.execute_command('node', ['-p',
|
|
27
|
+
'require.resolve("@superrouteca/smap-react-native/ios/smap_react_native.rb", {paths: [process.argv[1]]})',
|
|
28
|
+
__dir__]).strip
|
|
29
|
+
|
|
30
|
+
# ... inside the target:
|
|
31
|
+
post_install do |installer|
|
|
32
|
+
react_native_post_install(installer, config[:reactNativePath], :mac_catalyst_enabled => false)
|
|
33
|
+
SMapReactNative.post_install(installer) # embeds the map framework in the app
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## A minimal map
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { useRef } from 'react';
|
|
41
|
+
import { SMap, Points, Lines, Polygons, type SMapHandle } from '@superrouteca/smap-react-native';
|
|
42
|
+
|
|
43
|
+
export function Deliveries({ stops, route, zones }) {
|
|
44
|
+
const map = useRef<SMapHandle>(null);
|
|
45
|
+
return (
|
|
46
|
+
<SMap ref={map} apiKey="smk_…" style="light" language="en" center={[-79.38, 43.65]} zoom={11}
|
|
47
|
+
containerStyle={{ flex: 1 }} onReady={(m) => m.fitLayer('stops')}>
|
|
48
|
+
<Points id="stops" data={stops} options={{ cluster: true, label: 'name' }}
|
|
49
|
+
onTap={(properties) => console.log(properties.name)} />
|
|
50
|
+
<Lines id="route" data={route} options={{ color: '#16a34a', width: 5 }} />
|
|
51
|
+
<Polygons id="zones" data={zones} options={{ fill: '#f97316', extrude: { height: 'height' } }} />
|
|
52
|
+
</SMap>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### `<SMap>` props
|
|
58
|
+
|
|
59
|
+
| Prop | |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `apiKey`, `token`, `getToken` | access, see below |
|
|
62
|
+
| `style` | `light`, `white`, `grayscale`, `dark`, `black`; a change keeps the layers (a style object here is taken as `containerStyle`) |
|
|
63
|
+
| `language` | label language (`en`, `fr`, `zh-Hans`, …), the device language by default; a change keeps the layers |
|
|
64
|
+
| `center`, `zoom`, `bearing`, `pitch` | the camera; it follows a change |
|
|
65
|
+
| `buildings3D` | buildings in 3D |
|
|
66
|
+
| `nodes`, `baseUrl`, `nearest` | the service (default `https://tile.srsr.ca`) |
|
|
67
|
+
| `containerStyle` | the layout of the map view (default `flex: 1`) |
|
|
68
|
+
| `onReady(map)`, `onError(error)` | |
|
|
69
|
+
|
|
70
|
+
`apiKey`, `token`, `getToken`, `baseUrl`, `nodes` and `nearest` are read when the map is created; changing one creates
|
|
71
|
+
the map again (its layers are added again).
|
|
72
|
+
|
|
73
|
+
The ref (and `useSMap()` inside `<SMap>`) is the map once it is ready, null before: `addPoints`, `addLines`,
|
|
74
|
+
`addPolygons`, `setData`, `setVisible`, `removeLayer`, `layers()`, `fitLayer(id, {padding, maxZoom})`,
|
|
75
|
+
`flyTo(center, zoom?)`, `onTap(id, handler)`, `setStyle`, `setLanguage`, `setBuildings3D`.
|
|
76
|
+
|
|
77
|
+
### Layers
|
|
78
|
+
|
|
79
|
+
`<Points>`, `<Lines>` and `<Polygons>` take `id`, `data` (GeoJSON, or an array of `{lng, lat, …properties}`),
|
|
80
|
+
`options`, `onTap(properties, {lat, lng})` and `visible`; they are added when the map is ready and removed on unmount.
|
|
81
|
+
|
|
82
|
+
- Points: `cluster` (`true` or `{radius, maxZoom}`), `color`, `radius`, `strokeColor`, `icon` (an image registered
|
|
83
|
+
with the native map), `iconSize`, `label` (a property), `minZoom`, `maxZoom`, `zIndex`
|
|
84
|
+
- Lines: `color`, `width`, `dash`, `opacity`, `minZoom`, `maxZoom`, `zIndex`
|
|
85
|
+
- Polygons: `fill`, `opacity`, `outline`, `outlineWidth`, `extrude` (`{height, base}`: numbers or property names),
|
|
86
|
+
`minZoom`, `maxZoom`, `zIndex`
|
|
87
|
+
|
|
88
|
+
Higher `zIndex` draws on top, then the order added.
|
|
89
|
+
|
|
90
|
+
## Map keys and tokens
|
|
91
|
+
|
|
92
|
+
- **Map key** (`apiKey="smk_…"`): created in the Superroute developer center with the apps it may be used from
|
|
93
|
+
(Android: package name and signing certificate; iOS: bundle id). Safe to ship inside the app. The SDK sends the
|
|
94
|
+
app's identity, read from the system, with every request to the map service.
|
|
95
|
+
- **Token** (`token`, or `getToken={async () => …}`): signed by your own server for a limited time; with `getToken` a
|
|
96
|
+
new token is asked for two minutes before the current one expires.
|
|
97
|
+
- With both, both are sent and the token is checked first.
|
|
98
|
+
|
|
99
|
+
The identity reaches the map's own requests through an app-wide hook of the native map; an app that sets its own
|
|
100
|
+
HTTP client (Android) or network delegate (iOS) for the map afterwards replaces it, and those requests then go
|
|
101
|
+
without the identity.
|
|
102
|
+
|
|
103
|
+
## Data attribution
|
|
104
|
+
|
|
105
|
+
The map data credit stays visible on every map, as the data licence requires.
|
|
106
|
+
|
|
107
|
+
## Example and tests
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
npm install && npm test # the JS layer
|
|
111
|
+
cd example && npm install
|
|
112
|
+
npx react-native run-android # or: cd ios && pod install && cd .. && npx react-native run-ios
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The example builds against the Android and iOS SDKs in this repository (`example/android/settings.gradle` includes
|
|
116
|
+
`../../android/smap`; the podspec uses `../ios` when it exists, or `SMAP_IOS_PACKAGE_PATH`). Put a map key that allows
|
|
117
|
+
`ca.superroute.smap.rnexample` in `SMAP_KEY` in `example/App.tsx`.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "ios/smap_react_native"
|
|
3
|
+
|
|
4
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
5
|
+
|
|
6
|
+
Pod::Spec.new do |s|
|
|
7
|
+
s.name = "SMapReactNative"
|
|
8
|
+
s.version = package["version"]
|
|
9
|
+
s.summary = package["description"]
|
|
10
|
+
s.homepage = package["homepage"]
|
|
11
|
+
s.license = { :type => package["license"] }
|
|
12
|
+
s.authors = package["author"]
|
|
13
|
+
s.platforms = { :ios => "15.1" }
|
|
14
|
+
s.source = { :git => "https://github.com/Goodneo-Solutions/smap-sdk.git", :tag => s.version.to_s }
|
|
15
|
+
s.source_files = "ios/**/*.{h,m,mm,swift}"
|
|
16
|
+
s.swift_version = "5.9"
|
|
17
|
+
s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" }
|
|
18
|
+
|
|
19
|
+
install_modules_dependencies(s)
|
|
20
|
+
|
|
21
|
+
# The sMap iOS SDK (Swift package); see ios/smap_react_native.rb for which copy and for the app's Podfile line.
|
|
22
|
+
SMapReactNative.add_to_pod(s)
|
|
23
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The React Native module over the sMap Android SDK (ca.superroute.smap).
|
|
2
|
+
//
|
|
3
|
+
// The SDK comes from JitPack (com.github.Goodneo-Solutions:smap-sdk:<version>, the tag of this release) unless
|
|
4
|
+
// the app includes the SDK's source as the project ':smap-android' (the example does, so it builds against the
|
|
5
|
+
// SDK in this repository). Set `smapAndroidVersion` in the app's root `ext` to pick another tag.
|
|
6
|
+
def safeExtGet(prop, fallback) {
|
|
7
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
apply plugin: "com.android.library"
|
|
11
|
+
apply plugin: "org.jetbrains.kotlin.android"
|
|
12
|
+
apply plugin: "com.facebook.react"
|
|
13
|
+
|
|
14
|
+
android {
|
|
15
|
+
namespace "ca.superroute.smap.reactnative"
|
|
16
|
+
compileSdk safeExtGet("compileSdkVersion", 36)
|
|
17
|
+
|
|
18
|
+
defaultConfig {
|
|
19
|
+
minSdk safeExtGet("minSdkVersion", 24)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
compileOptions {
|
|
23
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
24
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
kotlin {
|
|
29
|
+
jvmToolchain(17)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
repositories {
|
|
33
|
+
google()
|
|
34
|
+
mavenCentral()
|
|
35
|
+
maven { url "https://jitpack.io" }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
dependencies {
|
|
39
|
+
implementation "com.facebook.react:react-android"
|
|
40
|
+
def localSdk = rootProject.findProject(":smap-android")
|
|
41
|
+
if (localSdk != null) {
|
|
42
|
+
api localSdk
|
|
43
|
+
} else {
|
|
44
|
+
api "com.github.Goodneo-Solutions:smap-sdk:${safeExtGet("smapAndroidVersion", "1.0.0")}"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package ca.superroute.smap.reactnative
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
7
|
+
import com.facebook.react.uimanager.ViewManager
|
|
8
|
+
|
|
9
|
+
/** Registers the map view with React Native (found by autolinking). */
|
|
10
|
+
class SMapPackage : BaseReactPackage() {
|
|
11
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
|
12
|
+
listOf(SMapViewManager())
|
|
13
|
+
|
|
14
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = null
|
|
15
|
+
|
|
16
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider { emptyMap() }
|
|
17
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
package ca.superroute.smap.reactnative
|
|
2
|
+
|
|
3
|
+
import android.net.Uri
|
|
4
|
+
import android.widget.FrameLayout
|
|
5
|
+
import ca.superroute.smap.SMap
|
|
6
|
+
import ca.superroute.smap.SMapCluster
|
|
7
|
+
import ca.superroute.smap.SMapExtrude
|
|
8
|
+
import ca.superroute.smap.SMapFlavor
|
|
9
|
+
import ca.superroute.smap.SMapGeoJson
|
|
10
|
+
import ca.superroute.smap.SMapLineOptions
|
|
11
|
+
import ca.superroute.smap.SMapOptions
|
|
12
|
+
import ca.superroute.smap.SMapPointOptions
|
|
13
|
+
import ca.superroute.smap.SMapPolygonOptions
|
|
14
|
+
import ca.superroute.smap.SMapValue
|
|
15
|
+
import com.facebook.react.bridge.Arguments
|
|
16
|
+
import com.facebook.react.bridge.LifecycleEventListener
|
|
17
|
+
import com.facebook.react.bridge.WritableMap
|
|
18
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
19
|
+
import com.facebook.react.uimanager.UIManagerHelper
|
|
20
|
+
import com.facebook.react.uimanager.events.Event
|
|
21
|
+
import kotlinx.coroutines.CompletableDeferred
|
|
22
|
+
import org.json.JSONArray
|
|
23
|
+
import org.json.JSONObject
|
|
24
|
+
import org.maplibre.android.camera.CameraPosition
|
|
25
|
+
import org.maplibre.android.camera.CameraUpdateFactory
|
|
26
|
+
import org.maplibre.android.geometry.LatLng
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The view behind `<SMap>`: holds one [SMap], created when the first props arrive (the `config` prop is read then,
|
|
30
|
+
* and a new config arrives as a new view). Props switch the style, the language, the camera and 3D buildings;
|
|
31
|
+
* commands add and change layers; events report readiness, errors, taps and token requests.
|
|
32
|
+
*/
|
|
33
|
+
class SMapReactView(private val reactContext: ThemedReactContext) : FrameLayout(reactContext), LifecycleEventListener {
|
|
34
|
+
var config: String? = null
|
|
35
|
+
private var flavor: String? = null
|
|
36
|
+
private var language: String? = null
|
|
37
|
+
private var camera: String? = null
|
|
38
|
+
private var buildings = false
|
|
39
|
+
|
|
40
|
+
private var map: SMap? = null
|
|
41
|
+
private var appliedFlavor: String? = null
|
|
42
|
+
private var appliedLanguage: String? = null
|
|
43
|
+
private var appliedCamera: String? = null
|
|
44
|
+
private var appliedBuildings = false
|
|
45
|
+
private var destroyed = false
|
|
46
|
+
private var nextTokenRequest = 0
|
|
47
|
+
private val tokenRequests = HashMap<Int, CompletableDeferred<String>>()
|
|
48
|
+
|
|
49
|
+
init {
|
|
50
|
+
reactContext.addLifecycleEventListener(this)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ------------------------------------------------------------------ props
|
|
54
|
+
|
|
55
|
+
fun setFlavor(value: String?) { flavor = value }
|
|
56
|
+
fun setLanguageTag(value: String?) { language = value }
|
|
57
|
+
fun setCamera(value: String?) { camera = value }
|
|
58
|
+
fun setBuildings(value: Boolean) { buildings = value }
|
|
59
|
+
|
|
60
|
+
/** Called once all props of an update are set: creates the map on the first update, then applies changes. */
|
|
61
|
+
fun commitProps() {
|
|
62
|
+
if (destroyed) return
|
|
63
|
+
val m = map ?: run { create(); return }
|
|
64
|
+
if (flavor != appliedFlavor) {
|
|
65
|
+
appliedFlavor = flavor
|
|
66
|
+
m.setStyle(SMapFlavor.fromId(flavor))
|
|
67
|
+
}
|
|
68
|
+
val tag = language?.takeIf { it.isNotEmpty() }
|
|
69
|
+
if (language != appliedLanguage) {
|
|
70
|
+
appliedLanguage = language
|
|
71
|
+
if (tag != null) m.setLanguage(tag)
|
|
72
|
+
}
|
|
73
|
+
if (camera != appliedCamera) {
|
|
74
|
+
appliedCamera = camera
|
|
75
|
+
moveCamera(m, camera)
|
|
76
|
+
}
|
|
77
|
+
if (buildings != appliedBuildings) {
|
|
78
|
+
appliedBuildings = buildings
|
|
79
|
+
m.setBuildings3D(buildings)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private fun create() {
|
|
84
|
+
val c = try { JSONObject(config ?: "{}") } catch (e: Exception) { JSONObject() }
|
|
85
|
+
val cam = parseObject(camera)
|
|
86
|
+
val wantsToken = c.optBoolean("getToken", false)
|
|
87
|
+
val options = SMapOptions(
|
|
88
|
+
baseUrl = c.optStringOrNull("baseUrl") ?: SMapOptions.DEFAULT_BASE_URL,
|
|
89
|
+
nodes = c.optJSONArray("nodes")?.let { a -> (0 until a.length()).map { a.getString(it) } } ?: emptyList(),
|
|
90
|
+
token = c.optStringOrNull("token"),
|
|
91
|
+
getToken = if (wantsToken) ({ requestToken() }) else null,
|
|
92
|
+
apiKey = c.optStringOrNull("apiKey"),
|
|
93
|
+
nearest = c.optBoolean("nearest", true),
|
|
94
|
+
style = SMapFlavor.fromId(flavor),
|
|
95
|
+
language = language?.takeIf { it.isNotEmpty() },
|
|
96
|
+
center = if (cam.has("lat") && cam.has("lng")) LatLng(cam.getDouble("lat"), cam.getDouble("lng")) else null,
|
|
97
|
+
zoom = cam.optDoubleOrNull("zoom"),
|
|
98
|
+
bearing = cam.optDoubleOrNull("bearing"),
|
|
99
|
+
pitch = cam.optDoubleOrNull("pitch"),
|
|
100
|
+
onError = { e -> emit("mapError") { putString("message", e.message ?: e.toString()) } },
|
|
101
|
+
)
|
|
102
|
+
appliedFlavor = flavor
|
|
103
|
+
appliedLanguage = language
|
|
104
|
+
appliedCamera = camera
|
|
105
|
+
appliedBuildings = buildings
|
|
106
|
+
val m = SMap(context, options)
|
|
107
|
+
map = m
|
|
108
|
+
addView(m, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
109
|
+
m.onCreate(null)
|
|
110
|
+
m.onStart()
|
|
111
|
+
m.onResume()
|
|
112
|
+
if (buildings) m.setBuildings3D(true)
|
|
113
|
+
m.ready { ready ->
|
|
114
|
+
if (destroyed) return@ready
|
|
115
|
+
val node = Uri.parse(ready.styleUrl).let { "${it.scheme}://${it.authority}" }
|
|
116
|
+
emit("mapReady") { putString("node", node) }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private fun moveCamera(m: SMap, json: String?) {
|
|
121
|
+
val engine = m.engine ?: return
|
|
122
|
+
val cam = parseObject(json)
|
|
123
|
+
val b = CameraPosition.Builder(engine.cameraPosition)
|
|
124
|
+
if (cam.has("lat") && cam.has("lng")) b.target(LatLng(cam.getDouble("lat"), cam.getDouble("lng")))
|
|
125
|
+
cam.optDoubleOrNull("zoom")?.let { b.zoom(it) }
|
|
126
|
+
cam.optDoubleOrNull("bearing")?.let { b.bearing(it) }
|
|
127
|
+
cam.optDoubleOrNull("pitch")?.let { b.tilt(it) }
|
|
128
|
+
engine.moveCamera(CameraUpdateFactory.newCameraPosition(b.build()))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ------------------------------------------------------------------ commands
|
|
132
|
+
|
|
133
|
+
fun addLayer(kind: String, id: String, data: String, options: String) {
|
|
134
|
+
val m = map ?: return
|
|
135
|
+
val o = parseObject(options)
|
|
136
|
+
try {
|
|
137
|
+
val fc = SMapGeoJson.toFeatureCollection(data)
|
|
138
|
+
when (kind) {
|
|
139
|
+
"points" -> m.addPoints(id, fc, pointOptions(o))
|
|
140
|
+
"lines" -> m.addLines(id, fc, lineOptions(o))
|
|
141
|
+
"polygons" -> m.addPolygons(id, fc, polygonOptions(o))
|
|
142
|
+
else -> throw IllegalArgumentException("sMap: unknown layer kind $kind")
|
|
143
|
+
}
|
|
144
|
+
m.onTap(id) { properties, latLng ->
|
|
145
|
+
emit("layerTap") {
|
|
146
|
+
putString("layerId", id)
|
|
147
|
+
putString("properties", JSONObject(jsonSafe(properties)).toString())
|
|
148
|
+
putDouble("lat", latLng.latitude)
|
|
149
|
+
putDouble("lng", latLng.longitude)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (e: Exception) {
|
|
153
|
+
emit("mapError") { putString("message", e.message ?: e.toString()) }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fun setLayerData(id: String, data: String) {
|
|
158
|
+
try {
|
|
159
|
+
map?.setData(id, data)
|
|
160
|
+
} catch (e: Exception) {
|
|
161
|
+
emit("mapError") { putString("message", e.message ?: e.toString()) }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fun setLayerVisible(id: String, visible: Boolean) { map?.setVisible(id, visible) }
|
|
166
|
+
|
|
167
|
+
fun removeLayer(id: String) { map?.removeLayer(id) }
|
|
168
|
+
|
|
169
|
+
fun fitLayer(id: String, padding: Double, maxZoom: Double) { map?.fitLayer(id, padding.toInt(), maxZoom) }
|
|
170
|
+
|
|
171
|
+
fun flyTo(lat: Double, lng: Double, zoom: Double) { map?.flyTo(LatLng(lat, lng), if (zoom < 0) null else zoom) }
|
|
172
|
+
|
|
173
|
+
fun resolveToken(requestId: Int, token: String, error: String) {
|
|
174
|
+
val d = tokenRequests.remove(requestId) ?: return
|
|
175
|
+
if (token.isNotEmpty()) d.complete(token) else d.completeExceptionally(IllegalStateException(error.ifEmpty { "sMap: no token" }))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private suspend fun requestToken(): String {
|
|
179
|
+
val id = ++nextTokenRequest
|
|
180
|
+
val d = CompletableDeferred<String>()
|
|
181
|
+
tokenRequests[id] = d
|
|
182
|
+
emit("tokenRequest") { putInt("requestId", id) }
|
|
183
|
+
return d.await()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ------------------------------------------------------------------ lifecycle and layout
|
|
187
|
+
|
|
188
|
+
override fun onHostResume() { map?.onResume() }
|
|
189
|
+
override fun onHostPause() { map?.onPause() }
|
|
190
|
+
override fun onHostDestroy() { destroy() }
|
|
191
|
+
|
|
192
|
+
fun destroy() {
|
|
193
|
+
if (destroyed) return
|
|
194
|
+
destroyed = true
|
|
195
|
+
reactContext.removeLifecycleEventListener(this)
|
|
196
|
+
tokenRequests.values.forEach { it.cancel() }
|
|
197
|
+
tokenRequests.clear()
|
|
198
|
+
map?.let {
|
|
199
|
+
it.onPause()
|
|
200
|
+
it.onStop()
|
|
201
|
+
it.onDestroy()
|
|
202
|
+
removeView(it)
|
|
203
|
+
}
|
|
204
|
+
map = null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// React Native lays out its own views only; the map's native views are measured and placed here.
|
|
208
|
+
// Nullable: requestLayout() is also called by the super constructor, before this field is set.
|
|
209
|
+
private val measureAndLayout: Runnable? = Runnable {
|
|
210
|
+
measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY))
|
|
211
|
+
layout(left, top, right, bottom)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
override fun requestLayout() {
|
|
215
|
+
super.requestLayout()
|
|
216
|
+
measureAndLayout?.let { post(it) }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ------------------------------------------------------------------ events
|
|
220
|
+
|
|
221
|
+
private fun emit(name: String, fill: WritableMap.() -> Unit) {
|
|
222
|
+
val data = Arguments.createMap().apply(fill)
|
|
223
|
+
val surfaceId = UIManagerHelper.getSurfaceId(this)
|
|
224
|
+
UIManagerHelper.getEventDispatcher(reactContext)?.dispatchEvent(SMapEvent(surfaceId, id, name, data))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private class SMapEvent(surfaceId: Int, viewTag: Int, private val name: String, private val data: WritableMap) :
|
|
228
|
+
Event<SMapEvent>(surfaceId, viewTag) {
|
|
229
|
+
override fun getEventName(): String = "top" + name.replaceFirstChar(Char::uppercaseChar)
|
|
230
|
+
override fun getEventData(): WritableMap = data
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
companion object {
|
|
234
|
+
val EVENTS = listOf("mapReady", "mapError", "layerTap", "tokenRequest")
|
|
235
|
+
|
|
236
|
+
private fun parseObject(json: String?): JSONObject =
|
|
237
|
+
try { if (json.isNullOrEmpty()) JSONObject() else JSONObject(json) } catch (e: Exception) { JSONObject() }
|
|
238
|
+
|
|
239
|
+
private fun JSONObject.optStringOrNull(name: String): String? =
|
|
240
|
+
if (has(name) && !isNull(name)) optString(name).takeIf { it.isNotEmpty() } else null
|
|
241
|
+
|
|
242
|
+
private fun JSONObject.optDoubleOrNull(name: String): Double? =
|
|
243
|
+
if (has(name) && !isNull(name)) optDouble(name).takeIf { !it.isNaN() } else null
|
|
244
|
+
|
|
245
|
+
private fun JSONObject.optFloat(name: String, alt: String? = null): Float? =
|
|
246
|
+
(optDoubleOrNull(name) ?: alt?.let { optDoubleOrNull(it) })?.toFloat()
|
|
247
|
+
|
|
248
|
+
private fun value(o: JSONObject, name: String, fallback: Double): SMapValue = when {
|
|
249
|
+
!o.has(name) || o.isNull(name) -> SMapValue.Fixed(fallback)
|
|
250
|
+
o.get(name) is String -> SMapValue.Property(o.getString(name))
|
|
251
|
+
else -> SMapValue.Fixed(o.optDouble(name, fallback))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Point options from JSON; names as in every sMap SDK (`minzoom` / `maxzoom` as in the Web SDK too). */
|
|
255
|
+
fun pointOptions(o: JSONObject): SMapPointOptions {
|
|
256
|
+
val d = SMapPointOptions()
|
|
257
|
+
val cluster = when (val c = o.opt("cluster")) {
|
|
258
|
+
is JSONObject -> SMapCluster(radius = c.optInt("radius", 50), maxZoom = c.optInt("maxZoom", 14))
|
|
259
|
+
true -> SMapCluster()
|
|
260
|
+
else -> null
|
|
261
|
+
}
|
|
262
|
+
return SMapPointOptions(
|
|
263
|
+
cluster = cluster,
|
|
264
|
+
color = o.optStringOrNull("color") ?: d.color,
|
|
265
|
+
radius = o.optFloat("radius") ?: d.radius,
|
|
266
|
+
strokeColor = o.optStringOrNull("strokeColor") ?: d.strokeColor,
|
|
267
|
+
icon = o.optStringOrNull("icon"),
|
|
268
|
+
iconSize = o.optFloat("iconSize") ?: d.iconSize,
|
|
269
|
+
label = o.optStringOrNull("label"),
|
|
270
|
+
minZoom = o.optFloat("minZoom", "minzoom"),
|
|
271
|
+
maxZoom = o.optFloat("maxZoom", "maxzoom"),
|
|
272
|
+
zIndex = o.optInt("zIndex", 0),
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fun lineOptions(o: JSONObject): SMapLineOptions {
|
|
277
|
+
val d = SMapLineOptions()
|
|
278
|
+
val dash = o.optJSONArray("dash")?.let { a -> (0 until a.length()).map { a.getDouble(it).toFloat() } }
|
|
279
|
+
return SMapLineOptions(
|
|
280
|
+
color = o.optStringOrNull("color") ?: d.color,
|
|
281
|
+
width = o.optFloat("width") ?: d.width,
|
|
282
|
+
dash = dash,
|
|
283
|
+
opacity = o.optFloat("opacity") ?: d.opacity,
|
|
284
|
+
minZoom = o.optFloat("minZoom", "minzoom"),
|
|
285
|
+
maxZoom = o.optFloat("maxZoom", "maxzoom"),
|
|
286
|
+
zIndex = o.optInt("zIndex", 0),
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
fun polygonOptions(o: JSONObject): SMapPolygonOptions {
|
|
291
|
+
val d = SMapPolygonOptions()
|
|
292
|
+
val extrude = o.optJSONObject("extrude")?.let { e -> SMapExtrude(height = value(e, "height", 20.0), base = value(e, "base", 0.0)) }
|
|
293
|
+
return SMapPolygonOptions(
|
|
294
|
+
fill = o.optStringOrNull("fill") ?: o.optStringOrNull("color") ?: d.fill,
|
|
295
|
+
opacity = o.optFloat("opacity") ?: d.opacity,
|
|
296
|
+
outline = o.optStringOrNull("outline"),
|
|
297
|
+
outlineWidth = o.optFloat("outlineWidth") ?: d.outlineWidth,
|
|
298
|
+
extrude = extrude,
|
|
299
|
+
minZoom = o.optFloat("minZoom", "minzoom"),
|
|
300
|
+
maxZoom = o.optFloat("maxZoom", "maxzoom"),
|
|
301
|
+
zIndex = o.optInt("zIndex", 0),
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private fun jsonSafe(value: Map<String, Any?>): Map<String, Any?> = value.mapValues { (_, v) -> toJsonValue(v) }
|
|
306
|
+
|
|
307
|
+
private fun toJsonValue(v: Any?): Any? = when (v) {
|
|
308
|
+
null -> JSONObject.NULL
|
|
309
|
+
is Map<*, *> -> JSONObject(v.entries.associate { it.key.toString() to toJsonValue(it.value) })
|
|
310
|
+
is List<*> -> JSONArray(v.map { toJsonValue(it) })
|
|
311
|
+
else -> v
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
package ca.superroute.smap.reactnative
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
4
|
+
import com.facebook.react.uimanager.SimpleViewManager
|
|
5
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
6
|
+
import com.facebook.react.uimanager.ViewManagerDelegate
|
|
7
|
+
import com.facebook.react.uimanager.annotations.ReactProp
|
|
8
|
+
import com.facebook.react.viewmanagers.SMapNativeViewManagerDelegate
|
|
9
|
+
import com.facebook.react.viewmanagers.SMapNativeViewManagerInterface
|
|
10
|
+
|
|
11
|
+
/** The Fabric view manager of `SMapNativeView`; props and commands are routed by the generated delegate. */
|
|
12
|
+
@ReactModule(name = SMapViewManager.NAME)
|
|
13
|
+
class SMapViewManager : SimpleViewManager<SMapReactView>(), SMapNativeViewManagerInterface<SMapReactView> {
|
|
14
|
+
private val delegate: ViewManagerDelegate<SMapReactView> = SMapNativeViewManagerDelegate(this)
|
|
15
|
+
|
|
16
|
+
override fun getDelegate(): ViewManagerDelegate<SMapReactView> = delegate
|
|
17
|
+
|
|
18
|
+
override fun getName(): String = NAME
|
|
19
|
+
|
|
20
|
+
override fun createViewInstance(context: ThemedReactContext): SMapReactView = SMapReactView(context)
|
|
21
|
+
|
|
22
|
+
override fun onAfterUpdateTransaction(view: SMapReactView) {
|
|
23
|
+
super.onAfterUpdateTransaction(view)
|
|
24
|
+
view.commitProps()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override fun onDropViewInstance(view: SMapReactView) {
|
|
28
|
+
view.destroy()
|
|
29
|
+
super.onDropViewInstance(view)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> = SMapReactView.EVENTS.associate {
|
|
33
|
+
"top" + it.replaceFirstChar(Char::uppercaseChar) to mapOf("registrationName" to "on" + it.replaceFirstChar(Char::uppercaseChar))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@ReactProp(name = "config")
|
|
37
|
+
override fun setConfig(view: SMapReactView, value: String?) { view.config = value }
|
|
38
|
+
|
|
39
|
+
@ReactProp(name = "mapStyle")
|
|
40
|
+
override fun setMapStyle(view: SMapReactView, value: String?) { view.setFlavor(value) }
|
|
41
|
+
|
|
42
|
+
@ReactProp(name = "language")
|
|
43
|
+
override fun setLanguage(view: SMapReactView, value: String?) { view.setLanguageTag(value) }
|
|
44
|
+
|
|
45
|
+
@ReactProp(name = "camera")
|
|
46
|
+
override fun setCamera(view: SMapReactView, value: String?) { view.setCamera(value) }
|
|
47
|
+
|
|
48
|
+
@ReactProp(name = "buildings3D")
|
|
49
|
+
override fun setBuildings3D(view: SMapReactView, value: Boolean) { view.setBuildings(value) }
|
|
50
|
+
|
|
51
|
+
override fun addLayer(view: SMapReactView, kind: String, id: String, data: String, options: String) =
|
|
52
|
+
view.addLayer(kind, id, data, options)
|
|
53
|
+
|
|
54
|
+
override fun setData(view: SMapReactView, id: String, data: String) = view.setLayerData(id, data)
|
|
55
|
+
|
|
56
|
+
override fun setVisible(view: SMapReactView, id: String, visible: Boolean) = view.setLayerVisible(id, visible)
|
|
57
|
+
|
|
58
|
+
override fun removeLayer(view: SMapReactView, id: String) = view.removeLayer(id)
|
|
59
|
+
|
|
60
|
+
override fun fitLayer(view: SMapReactView, id: String, padding: Double, maxZoom: Double) =
|
|
61
|
+
view.fitLayer(id, padding, maxZoom)
|
|
62
|
+
|
|
63
|
+
override fun flyTo(view: SMapReactView, lat: Double, lng: Double, zoom: Double) = view.flyTo(lat, lng, zoom)
|
|
64
|
+
|
|
65
|
+
override fun resolveToken(view: SMapReactView, requestId: Int, token: String, error: String) =
|
|
66
|
+
view.resolveToken(requestId, token, error)
|
|
67
|
+
|
|
68
|
+
companion object {
|
|
69
|
+
const val NAME = "SMapNativeView"
|
|
70
|
+
}
|
|
71
|
+
}
|