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 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, the language, the camera and 3D buildings; * commands add and change layers; events report readiness, errors, taps and token requests. */ 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 map: SMap? = null private var appliedFlavor: String? = null private var appliedLanguage: String? = null private var appliedCamera: String? = null private var appliedBuildings = false private var destroyed = false 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 } /** 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 m.setStyle(SMapFlavor.fromId(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) } } 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 = 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()) } }, ) appliedFlavor = flavor appliedLanguage = language appliedCamera = camera appliedBuildings = buildings 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) 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") } m.onTap(id) { properties, latLng -> emit("layerTap") { putString("layerId", id) putString("properties", JSONObject(jsonSafe(properties)).toString()) putDouble("lat", latLng.latitude) putDouble("lng", latLng.longitude) } } } catch (e: Exception) { emit("mapError") { putString("message", e.message ?: e.toString()) } } } 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) } 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) 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") private 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), ) } 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), ) } 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), ) } 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 } } }