package ca.superroute.smap.reactnative import android.net.Uri import android.widget.FrameLayout import ca.superroute.smap.SMap import ca.superroute.smap.SMapCluster import ca.superroute.smap.SMapExtrude import ca.superroute.smap.SMapFlavor import ca.superroute.smap.SMapGeoJson import ca.superroute.smap.SMapLineOptions import ca.superroute.smap.SMapOptions import ca.superroute.smap.SMapPointOptions import ca.superroute.smap.SMapPolygonOptions import ca.superroute.smap.SMapValue import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.WritableMap import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.Event import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject import org.maplibre.android.camera.CameraPosition import org.maplibre.android.camera.CameraUpdateFactory import org.maplibre.android.geometry.LatLng /** * The view behind ``: holds one [SMap], created when the first props arrive (the `config` prop is read then, * and a new config arrives as a new view). Props switch the style (a flavor or `custom:`), the language, the * camera, the basemap overrides and 3D buildings; commands add and change layers, and `call` runs one 1.1.0 call * ([SMapInterop]); events report readiness, errors, taps, token requests and call answers. */ class SMapReactView(private val reactContext: ThemedReactContext) : FrameLayout(reactContext), LifecycleEventListener { var config: String? = null private var flavor: String? = null private var language: String? = null private var camera: String? = null private var buildings = false private var basemap: String? = null private var map: SMap? = null private var appliedFlavor: String? = null private var appliedLanguage: String? = null private var appliedCamera: String? = null private var appliedBuildings = false private var appliedBasemap: String? = null private var destroyed = false private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var nextTokenRequest = 0 private val tokenRequests = HashMap>() init { reactContext.addLifecycleEventListener(this) } // ------------------------------------------------------------------ props fun setFlavor(value: String?) { flavor = value } fun setLanguageTag(value: String?) { language = value } fun setCamera(value: String?) { camera = value } fun setBuildings(value: Boolean) { buildings = value } fun setBasemapOverrides(value: String?) { basemap = value } /** Called once all props of an update are set: creates the map on the first update, then applies changes. */ fun commitProps() { if (destroyed) return val m = map ?: run { create(); return } if (flavor != appliedFlavor) { appliedFlavor = flavor applyStyle(m, flavor) } val tag = language?.takeIf { it.isNotEmpty() } if (language != appliedLanguage) { appliedLanguage = language if (tag != null) m.setLanguage(tag) } if (camera != appliedCamera) { appliedCamera = camera moveCamera(m, camera) } if (buildings != appliedBuildings) { appliedBuildings = buildings m.setBuildings3D(buildings) } if (basemap.orEmpty() != appliedBasemap.orEmpty()) { appliedBasemap = basemap applyBasemap(m, basemap) } } /** A flavor id, or a saved custom style `custom:`. */ private fun applyStyle(m: SMap, style: String?) { try { if (style != null && style.startsWith("custom:")) m.setStyle(style) else m.setStyle(SMapFlavor.fromId(style)) } catch (e: Exception) { emit("mapError") { putString("message", e.message ?: e.toString()) } } } private fun applyBasemap(m: SMap, json: String?) { val args = org.json.JSONArray().put(parseObject(json)) scope.launch { try { SMapInterop.call(m, this@SMapReactView, "setBasemap", args) } catch (e: Exception) { emit("mapError") { putString("message", e.message ?: e.toString()) } } } } private fun create() { val c = try { JSONObject(config ?: "{}") } catch (e: Exception) { JSONObject() } val cam = parseObject(camera) val wantsToken = c.optBoolean("getToken", false) val options = SMapOptions( baseUrl = c.optStringOrNull("baseUrl") ?: SMapOptions.DEFAULT_BASE_URL, nodes = c.optJSONArray("nodes")?.let { a -> (0 until a.length()).map { a.getString(it) } } ?: emptyList(), token = c.optStringOrNull("token"), getToken = if (wantsToken) ({ requestToken() }) else null, apiKey = c.optStringOrNull("apiKey"), nearest = c.optBoolean("nearest", true), style = if (flavor?.startsWith("custom:") == true) SMapFlavor.LIGHT else SMapFlavor.fromId(flavor), language = language?.takeIf { it.isNotEmpty() }, center = if (cam.has("lat") && cam.has("lng")) LatLng(cam.getDouble("lat"), cam.getDouble("lng")) else null, zoom = cam.optDoubleOrNull("zoom"), bearing = cam.optDoubleOrNull("bearing"), pitch = cam.optDoubleOrNull("pitch"), onError = { e -> emit("mapError") { putString("message", e.message ?: e.toString()) } }, offlineRegion = c.optStringOrNull("offlineRegion"), offlineFallback = c.optBoolean("offlineFallback", true), offlineAutoRefresh = c.optBoolean("offlineAutoRefresh", true), ) appliedFlavor = flavor appliedLanguage = language appliedCamera = camera appliedBuildings = buildings appliedBasemap = basemap val m = SMap(context, options) map = m addView(m, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) m.onCreate(null) m.onStart() m.onResume() if (buildings) m.setBuildings3D(true) if (flavor?.startsWith("custom:") == true) applyStyle(m, flavor) if (!basemap.isNullOrEmpty()) applyBasemap(m, basemap) m.ready { ready -> if (destroyed) return@ready val node = Uri.parse(ready.styleUrl).let { "${it.scheme}://${it.authority}" } emit("mapReady") { putString("node", node) } } } private fun moveCamera(m: SMap, json: String?) { val engine = m.engine ?: return val cam = parseObject(json) val b = CameraPosition.Builder(engine.cameraPosition) if (cam.has("lat") && cam.has("lng")) b.target(LatLng(cam.getDouble("lat"), cam.getDouble("lng"))) cam.optDoubleOrNull("zoom")?.let { b.zoom(it) } cam.optDoubleOrNull("bearing")?.let { b.bearing(it) } cam.optDoubleOrNull("pitch")?.let { b.tilt(it) } engine.moveCamera(CameraUpdateFactory.newCameraPosition(b.build())) } // ------------------------------------------------------------------ commands fun addLayer(kind: String, id: String, data: String, options: String) { val m = map ?: return val o = parseObject(options) try { val fc = SMapGeoJson.toFeatureCollection(data) when (kind) { "points" -> m.addPoints(id, fc, pointOptions(o)) "lines" -> m.addLines(id, fc, lineOptions(o)) "polygons" -> m.addPolygons(id, fc, polygonOptions(o)) else -> throw IllegalArgumentException("sMap: unknown layer kind $kind") } listenTaps(m, id) } catch (e: Exception) { emit("mapError") { putString("message", e.message ?: e.toString()) } } } /** Reports taps on the features of layer [id] as `layerTap` events. */ internal fun listenTaps(m: SMap, id: String) { m.onTap(id) { properties, latLng -> emit("layerTap") { putString("layerId", id) putString("properties", JSONObject(jsonSafe(properties)).toString()) putDouble("lat", latLng.latitude) putDouble("lng", latLng.longitude) } } } fun setLayerData(id: String, data: String) { try { map?.setData(id, data) } catch (e: Exception) { emit("mapError") { putString("message", e.message ?: e.toString()) } } } fun setLayerVisible(id: String, visible: Boolean) { map?.setVisible(id, visible) } fun removeLayer(id: String) { map?.removeLayer(id) } fun fitLayer(id: String, padding: Double, maxZoom: Double) { map?.fitLayer(id, padding.toInt(), maxZoom) } fun flyTo(lat: Double, lng: Double, zoom: Double) { map?.flyTo(LatLng(lat, lng), if (zoom < 0) null else zoom) } /** One 1.1.0 call; answered with the `callResult` event of the same [requestId]. */ fun call(requestId: Int, method: String, args: String) { val m = map if (m == null) { answer(requestId, "", "sMap: the map is not created yet") return } scope.launch { try { val parsed = org.json.JSONArray(if (args.isEmpty()) "[]" else args) answer(requestId, SMapInterop.call(m, this@SMapReactView, method, parsed), "") } catch (e: Exception) { answer(requestId, "", e.message ?: e.toString()) } } } private fun answer(requestId: Int, result: String, error: String) { emit("callResult") { putInt("requestId", requestId) putString("result", result) putString("error", error) } } fun resolveToken(requestId: Int, token: String, error: String) { val d = tokenRequests.remove(requestId) ?: return if (token.isNotEmpty()) d.complete(token) else d.completeExceptionally(IllegalStateException(error.ifEmpty { "sMap: no token" })) } private suspend fun requestToken(): String { val id = ++nextTokenRequest val d = CompletableDeferred() tokenRequests[id] = d emit("tokenRequest") { putInt("requestId", id) } return d.await() } // ------------------------------------------------------------------ lifecycle and layout override fun onHostResume() { map?.onResume() } override fun onHostPause() { map?.onPause() } override fun onHostDestroy() { destroy() } fun destroy() { if (destroyed) return destroyed = true reactContext.removeLifecycleEventListener(this) scope.cancel() tokenRequests.values.forEach { it.cancel() } tokenRequests.clear() map?.let { it.onPause() it.onStop() it.onDestroy() removeView(it) } map = null } // React Native lays out its own views only; the map's native views are measured and placed here. // Nullable: requestLayout() is also called by the super constructor, before this field is set. private val measureAndLayout: Runnable? = Runnable { measure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)) layout(left, top, right, bottom) } override fun requestLayout() { super.requestLayout() measureAndLayout?.let { post(it) } } // ------------------------------------------------------------------ events private fun emit(name: String, fill: WritableMap.() -> Unit) { val data = Arguments.createMap().apply(fill) val surfaceId = UIManagerHelper.getSurfaceId(this) UIManagerHelper.getEventDispatcher(reactContext)?.dispatchEvent(SMapEvent(surfaceId, id, name, data)) } private class SMapEvent(surfaceId: Int, viewTag: Int, private val name: String, private val data: WritableMap) : Event(surfaceId, viewTag) { override fun getEventName(): String = "top" + name.replaceFirstChar(Char::uppercaseChar) override fun getEventData(): WritableMap = data } companion object { val EVENTS = listOf("mapReady", "mapError", "layerTap", "tokenRequest", "callResult") fun parseObject(json: String?): JSONObject = try { if (json.isNullOrEmpty()) JSONObject() else JSONObject(json) } catch (e: Exception) { JSONObject() } private fun JSONObject.optStringOrNull(name: String): String? = if (has(name) && !isNull(name)) optString(name).takeIf { it.isNotEmpty() } else null private fun JSONObject.optDoubleOrNull(name: String): Double? = if (has(name) && !isNull(name)) optDouble(name).takeIf { !it.isNaN() } else null private fun JSONObject.optFloat(name: String, alt: String? = null): Float? = (optDoubleOrNull(name) ?: alt?.let { optDoubleOrNull(it) })?.toFloat() private fun value(o: JSONObject, name: String, fallback: Double): SMapValue = when { !o.has(name) || o.isNull(name) -> SMapValue.Fixed(fallback) o.get(name) is String -> SMapValue.Property(o.getString(name)) else -> SMapValue.Fixed(o.optDouble(name, fallback)) } /** Point options from JSON; names as in every sMap SDK (`minzoom` / `maxzoom` as in the Web SDK too). */ fun pointOptions(o: JSONObject): SMapPointOptions { val d = SMapPointOptions() val cluster = when (val c = o.opt("cluster")) { is JSONObject -> SMapCluster(radius = c.optInt("radius", 50), maxZoom = c.optInt("maxZoom", 14)) true -> SMapCluster() else -> null } return SMapPointOptions( cluster = cluster, color = o.optStringOrNull("color") ?: d.color, radius = o.optFloat("radius") ?: d.radius, strokeColor = o.optStringOrNull("strokeColor") ?: d.strokeColor, icon = o.optStringOrNull("icon"), iconSize = o.optFloat("iconSize") ?: d.iconSize, label = o.optStringOrNull("label"), minZoom = o.optFloat("minZoom", "minzoom"), maxZoom = o.optFloat("maxZoom", "maxzoom"), zIndex = o.optInt("zIndex", 0), slot = SMapInterop.slot(o), ) } fun lineOptions(o: JSONObject): SMapLineOptions { val d = SMapLineOptions() val dash = o.optJSONArray("dash")?.let { a -> (0 until a.length()).map { a.getDouble(it).toFloat() } } return SMapLineOptions( color = o.optStringOrNull("color") ?: d.color, width = o.optFloat("width") ?: d.width, dash = dash, opacity = o.optFloat("opacity") ?: d.opacity, minZoom = o.optFloat("minZoom", "minzoom"), maxZoom = o.optFloat("maxZoom", "maxzoom"), zIndex = o.optInt("zIndex", 0), slot = SMapInterop.slot(o), ) } fun polygonOptions(o: JSONObject): SMapPolygonOptions { val d = SMapPolygonOptions() val extrude = o.optJSONObject("extrude")?.let { e -> SMapExtrude(height = value(e, "height", 20.0), base = value(e, "base", 0.0)) } return SMapPolygonOptions( fill = o.optStringOrNull("fill") ?: o.optStringOrNull("color") ?: d.fill, opacity = o.optFloat("opacity") ?: d.opacity, outline = o.optStringOrNull("outline"), outlineWidth = o.optFloat("outlineWidth") ?: d.outlineWidth, extrude = extrude, minZoom = o.optFloat("minZoom", "minzoom"), maxZoom = o.optFloat("maxZoom", "maxzoom"), zIndex = o.optInt("zIndex", 0), slot = SMapInterop.slot(o), ) } private fun jsonSafe(value: Map): Map = value.mapValues { (_, v) -> toJsonValue(v) } private fun toJsonValue(v: Any?): Any? = when (v) { null -> JSONObject.NULL is Map<*, *> -> JSONObject(v.entries.associate { it.key.toString() to toJsonValue(it.value) }) is List<*> -> JSONArray(v.map { toJsonValue(it) }) else -> v } } }