| @@ -0,0 +1,354 @@ |
| 1 | +/** A key-free native Android map. STX geometry never enters a WebView. */ |
| 2 | +internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback { |
| 3 | + private val mapView: MapView |
| 4 | + private var map: MapLibreMap? = null |
| 5 | + private var destroyed = false |
| 6 | + private var lifecycleStarted = false |
| 7 | + private var pendingProps = JSONObject() |
| 8 | + private var pendingChildren = JSONArray() |
| 9 | + private var styleKey: String? = null |
| 10 | + private var styleVersion = 0 |
| 11 | + private var readyStyle: Style? = null |
| 12 | + private var initialCameraKey: String? = null |
| 13 | + private var userChangingCamera = false |
| 14 | + private var geometryPoints = emptyList<LatLng>() |
| 15 | + private val markerHandlers = mutableMapOf<String, String>() |
| 16 | + private var onMarkerTap: ((String) -> Unit)? = null |
| 17 | + var onMapPress: ((LatLng) -> Unit)? = null |
| 18 | + var onRegionChangeComplete: ((JSONObject) -> Unit)? = null |
| 19 | + private val attribution = TextView(context).apply { |
| 20 | + textSize = 9f |
| 21 | + setTextColor(Color.DKGRAY) |
| 22 | + setBackgroundColor(Color.argb(230, 255, 255, 255)) |
| 23 | + maxLines = 2 |
| 24 | + } |
| 25 | + private val scale = LinearLayout(context).apply { |
| 26 | + orientation = LinearLayout.VERTICAL |
| 27 | + setPadding(4, 2, 4, 2) |
| 28 | + setBackgroundColor(Color.argb(230, 255, 255, 255)) |
| 29 | + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO |
| 30 | + visibility = View.GONE |
| 31 | + } |
| 32 | + private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) } |
| 33 | + private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) } |
| 34 | + |
| 35 | + init { |
| 36 | + MapLibre.getInstance(context) |
| 37 | + mapView = MapView(context) |
| 38 | + addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) |
| 39 | + scale.addView(scaleLabel) |
| 40 | + scale.addView(scaleLine, LinearLayout.LayoutParams(1, 2)) |
| 41 | + addView(scale, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply { |
| 42 | + leftMargin = (8 * resources.displayMetrics.density).roundToInt() |
| 43 | + bottomMargin = (24 * resources.displayMetrics.density).roundToInt() |
| 44 | + }) |
| 45 | + addView(attribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply { |
| 46 | + rightMargin = (8 * resources.displayMetrics.density).roundToInt() |
| 47 | + bottomMargin = (8 * resources.displayMetrics.density).roundToInt() |
| 48 | + }) |
| 49 | + mapView.onCreate(null) |
| 50 | + mapView.getMapAsync(this) |
| 51 | + } |
| 52 | + |
| 53 | + override fun onMapReady(nativeMap: MapLibreMap) { |
| 54 | + if (destroyed) return |
| 55 | + map = nativeMap |
| 56 | + nativeMap.addOnMapClickListener { point -> |
| 57 | + val screen = nativeMap.projection.toScreenLocation(point) |
| 58 | + val radius = 18 * resources.displayMetrics.density |
| 59 | + val hit = nativeMap.queryRenderedFeatures(android.graphics.RectF(screen.x - radius, screen.y - radius, screen.x + radius, screen.y + radius), "craft-map-markers", "craft-map-directions").firstOrNull() |
| 60 | + val id = hit?.takeIf { it.hasProperty("craftMarkerId") }?.getStringProperty("craftMarkerId") |
| 61 | + if (id != null) onMarkerTap?.invoke(id) else onMapPress?.invoke(point) |
| 62 | + true |
| 63 | + } |
| 64 | + nativeMap.addOnCameraMoveStartedListener { reason -> |
| 65 | + if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) userChangingCamera = true |
| 66 | + } |
| 67 | + nativeMap.addOnCameraMoveListener { updateScale() } |
| 68 | + nativeMap.addOnCameraIdleListener { |
| 69 | + updateScale() |
| 70 | + if (userChangingCamera) { |
| 71 | + userChangingCamera = false |
| 72 | + val bounds = nativeMap.projection.visibleRegion.latLngBounds |
| 73 | + val camera = nativeMap.cameraPosition |
| 74 | + val center = camera.target |
| 75 | + if (center != null) onRegionChangeComplete?.invoke(JSONObject() |
| 76 | + .put("center", JSONObject().put("latitude", center.latitude).put("longitude", center.longitude)) |
| 77 | + .put("span", JSONObject().put("latitudeDelta", bounds.latitudeNorth - bounds.latitudeSouth).put("longitudeDelta", bounds.longitudeEast - bounds.longitudeWest)) |
| 78 | + .put("heading", camera.bearing).put("pitch", camera.tilt)) |
| 79 | + } |
| 80 | + } |
| 81 | + applyPending() |
| 82 | + } |
| 83 | + |
| 84 | + fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) { |
| 85 | + pendingProps = props |
| 86 | + pendingChildren = children |
| 87 | + onMarkerTap = markerHandler |
| 88 | + applyPending() |
| 89 | + } |
| 90 | + |
| 91 | + fun markerHandler(id: String): String? = markerHandlers[id] |
| 92 | + |
| 93 | + private fun applyPending() { |
| 94 | + val nativeMap = map ?: return |
| 95 | + if (destroyed) return |
| 96 | + nativeMap.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass")) |
| 97 | + nativeMap.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true) |
| 98 | + nativeMap.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true) |
| 99 | + nativeMap.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled")) |
| 100 | + nativeMap.uiSettings.isTiltGesturesEnabled = true |
| 101 | + // The authored attribution is kept visible rather than hidden in a menu. |
| 102 | + nativeMap.uiSettings.isAttributionEnabled = false |
| 103 | + nativeMap.uiSettings.isLogoEnabled = false |
| 104 | + applyPadding() |
| 105 | + val nextStyle = basemapStyle() |
| 106 | + if (styleKey != nextStyle) { |
| 107 | + styleKey = nextStyle |
| 108 | + readyStyle = null |
| 109 | + val version = ++styleVersion |
| 110 | + nativeMap.setStyle(Style.Builder().fromJson(nextStyle)) { loaded -> |
| 111 | + if (!destroyed && version == styleVersion) { |
| 112 | + readyStyle = loaded |
| 113 | + installOverlays(loaded) |
| 114 | + applyPending() |
| 115 | + } |
| 116 | + } |
| 117 | + return |
| 118 | + } |
| 119 | + val loaded = readyStyle ?: return |
| 120 | + updateOverlays(loaded) |
| 121 | + applyLocation(nativeMap, loaded) |
| 122 | + val explicit = region(pendingProps.optJSONObject("region")) |
| 123 | + val initial = region(pendingProps.optJSONObject("initialRegion")) |
| 124 | + if (explicit != null) { |
| 125 | + moveCamera(explicit, 0) |
| 126 | + initialCameraKey = null |
| 127 | + } else if (initial != null) { |
| 128 | + val key = "region:${pendingProps.optJSONObject("initialRegion")}" |
| 129 | + if (initialCameraKey != key) { moveCamera(initial, 0); initialCameraKey = key } |
| 130 | + } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) { |
| 131 | + val key = geometryPoints.joinToString(";") { "${it.latitude},${it.longitude}" } |
| 132 | + if (initialCameraKey != key && geometryPoints.isNotEmpty() && width > 0 && height > 0) { |
| 133 | + if (geometryPoints.size == 1) nativeMap.moveCamera(CameraUpdateFactory.newLatLngZoom(geometryPoints.first(), 15.0)) |
| 134 | + else moveCamera(LatLngBounds.Builder().includes(geometryPoints).build(), if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt()) |
| 135 | + initialCameraKey = key |
| 136 | + } |
| 137 | + } else initialCameraKey = null |
| 138 | + applyOrientation() |
| 139 | + updateScale() |
| 140 | + } |
| 141 | + |
| 142 | + private fun basemapStyle(): String { |
| 143 | + val custom = pendingProps.optJSONObject("tileOverlay") |
| 144 | + val template = custom?.optString("urlTemplate").orEmpty() |
| 145 | + val credit = custom?.optString("attribution").orEmpty() |
| 146 | + val customValid = template.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { template.contains(it) } && credit.isNotBlank() |
| 147 | + val type = pendingProps.optString("mapType", "standard").lowercase() |
| 148 | + val satellite = type == "satellite" || type == "hybrid" |
| 149 | + val url = when { |
| 150 | + customValid -> template |
| 151 | + satellite -> "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" |
| 152 | + type == "terrain" -> "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}" |
| 153 | + else -> "https://tile.openstreetmap.org/{z}/{x}/{y}.png" |
| 154 | + } |
| 155 | + val attributionText = when { |
| 156 | + customValid -> credit |
| 157 | + satellite -> "Imagery © Esri, Maxar, Earthstar Geographics, and the GIS User Community" |
| 158 | + type == "terrain" -> "Topography: Esri, USGS, NOAA, OpenStreetMap contributors" |
| 159 | + else -> "© OpenStreetMap contributors" |
| 160 | + } |
| 161 | + attribution.text = attributionText |
| 162 | + val sources = JSONObject().put("basemap", rasterSource(url, attributionText)) |
| 163 | + val layers = JSONArray().put(JSONObject().put("id", "basemap").put("type", "raster").put("source", "basemap")) |
| 164 | + if (!customValid && type == "hybrid") { |
| 165 | + sources.put("labels", rasterSource("https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", "Labels © Esri")) |
| 166 | + layers.put(JSONObject().put("id", "labels").put("type", "raster").put("source", "labels")) |
| 167 | + attribution.text = "$attributionText · Labels © Esri" |
| 168 | + } |
| 169 | + return JSONObject().put("version", 8).put("sources", sources).put("layers", layers).toString() |
| 170 | + } |
| 171 | + |
| 172 | + private fun rasterSource(url: String, credit: String): JSONObject = JSONObject() |
| 173 | + .put("type", "raster").put("tiles", JSONArray().put(url)).put("tileSize", 256).put("maxzoom", 19).put("attribution", credit) |
| 174 | + |
| 175 | + private fun installOverlays(style: Style) { |
| 176 | + style.addSource(GeoJsonSource("craft-map-overlays")) |
| 177 | + style.addImage("craft-map-direction", directionBitmap()) |
| 178 | + style.addLayer(LineLayer("craft-map-routes", "craft-map-overlays") |
| 179 | + .withFilter(Expression.eq(Expression.get("kind"), "line")) |
| 180 | + .withProperties(lineColor(Expression.toColor(Expression.get("color"))), lineWidth(Expression.get("width")), lineCap("round"), lineJoin("round"))) |
| 181 | + style.addLayer(CircleLayer("craft-map-markers", "craft-map-overlays") |
| 182 | + .withFilter(Expression.eq(Expression.get("kind"), "marker")) |
| 183 | + .withProperties(circleColor(Expression.toColor(Expression.get("color"))), circleRadius(7f), circleStrokeColor(Color.WHITE), circleStrokeWidth(2f))) |
| 184 | + style.addLayer(SymbolLayer("craft-map-directions", "craft-map-overlays") |
| 185 | + .withFilter(Expression.eq(Expression.get("kind"), "direction")) |
| 186 | + .withProperties(iconImage("craft-map-direction"), iconRotate(Expression.get("bearing")), iconRotationAlignment("map"), iconAllowOverlap(true), iconIgnorePlacement(true), iconAnchor("center"))) |
| 187 | + } |
| 188 | + |
| 189 | + private fun updateOverlays(style: Style) { |
| 190 | + val features = JSONArray() |
| 191 | + val pointsForFit = mutableListOf<LatLng>() |
| 192 | + markerHandlers.clear() |
| 193 | + for (index in 0 until pendingChildren.length()) { |
| 194 | + val child = pendingChildren.optJSONObject(index) ?: continue |
| 195 | + val type = child.optString("type").lowercase() |
| 196 | + val props = child.optJSONObject("props") ?: JSONObject() |
| 197 | + val events = child.optJSONObject("events") ?: JSONObject() |
| 198 | + val id = craftNativeOptionalString(props.opt("id")) ?: craftNativeOptionalString(child.opt("id")) ?: "overlay-$index" |
| 199 | + val points = coordinates(props.optJSONArray("geometry")) |
| 200 | + val properties = JSONObject() |
| 201 | + val geometry = JSONObject() |
| 202 | + if ((type == "mapmarker" || type == "marker") && points.isNotEmpty()) { |
| 203 | + val point = points.first() |
| 204 | + val bearing = finiteNumber(props.opt("bearing")) |
| 205 | + properties.put("kind", if (bearing == null) "marker" else "direction").put("craftMarkerId", id) |
| 206 | + properties.put("bearing", (bearing ?: 0.0) % 360).put("color", cssColor(props.opt("pinColor"), Color.rgb(36, 114, 75))) |
| 207 | + geometry.put("type", "Point").put("coordinates", JSONArray().put(point.longitude).put(point.latitude)) |
| 208 | + listOf(events.opt("onPress"), events.opt("onMarkerPress")).firstNotNullOfOrNull(::craftNativeOptionalString)?.let { markerHandlers[id] = it } |
| 209 | + pointsForFit.add(point) |
| 210 | + } else if ((type == "mappolyline" || type == "polyline") && points.size > 1) { |
| 211 | + val authoredWidth = props.opt("strokeWidth") |
| 212 | + val width = if (authoredWidth == null || authoredWidth == JSONObject.NULL) 3.0 else finiteNumber(authoredWidth) ?: continue |
| 213 | + if (width <= 0 || !width.toFloat().isFinite()) continue |
| 214 | + properties.put("kind", "line").put("color", cssColor(props.opt("strokeColor"), Color.BLUE)).put("width", width) |
| 215 | + geometry.put("type", "LineString").put("coordinates", JSONArray().apply { points.forEach { put(JSONArray().put(it.longitude).put(it.latitude)) } }) |
| 216 | + pointsForFit.addAll(points) |
| 217 | + } else continue |
| 218 | + features.put(JSONObject().put("type", "Feature").put("properties", properties).put("geometry", geometry)) |
| 219 | + } |
| 220 | + geometryPoints = pointsForFit |
| 221 | + style.getSourceAs<GeoJsonSource>("craft-map-overlays")?.setGeoJson(JSONObject().put("type", "FeatureCollection").put("features", features).toString()) |
| 222 | + } |
| 223 | + |
| 224 | + private fun cssColor(value: Any?, fallback: Int): String { |
| 225 | + val color = (value as? String)?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback |
| 226 | + return "rgba(${Color.red(color)},${Color.green(color)},${Color.blue(color)},${Color.alpha(color) / 255.0})" |
| 227 | + } |
| 228 | + |
| 229 | + private fun directionBitmap(): Bitmap { |
| 230 | + val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24) |
| 231 | + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) |
| 232 | + bitmap.density = resources.displayMetrics.densityDpi |
| 233 | + val canvas = Canvas(bitmap) |
| 234 | + val paint = Paint(Paint.ANTI_ALIAS_FLAG) |
| 235 | + paint.color = Color.WHITE |
| 236 | + canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) |
| 237 | + paint.color = Color.rgb(36, 114, 75) |
| 238 | + canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint) |
| 239 | + paint.color = Color.WHITE |
| 240 | + paint.textSize = size * 0.76f |
| 241 | + paint.textAlign = Paint.Align.CENTER |
| 242 | + paint.typeface = Typeface.DEFAULT_BOLD |
| 243 | + canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint) |
| 244 | + return bitmap |
| 245 | + } |
| 246 | + |
| 247 | + private fun applyLocation(nativeMap: MapLibreMap, style: Style) { |
| 248 | + val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || |
| 249 | + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED |
| 250 | + val enabled = permitted && craftNativeBoolean(pendingProps.opt("showsUserLocation")) |
| 251 | + val component = nativeMap.locationComponent |
| 252 | + if (!enabled && !component.isLocationComponentActivated) return |
| 253 | + try { |
| 254 | + if (!component.isLocationComponentActivated) component.activateLocationComponent(LocationComponentActivationOptions.builder(context, style).useDefaultLocationEngine(true).build()) |
| 255 | + component.isLocationComponentEnabled = enabled |
| 256 | + } catch (error: SecurityException) { |
| 257 | + // Permission may be revoked between the check and native activation. |
| 258 | + if (component.isLocationComponentActivated) component.isLocationComponentEnabled = false |
| 259 | + } |
| 260 | + } |
| 261 | + |
| 262 | + private fun applyPadding() { |
| 263 | + val value = pendingProps.optJSONObject("edgePadding") |
| 264 | + fun inset(key: String): Double = (finiteNumber(value?.opt(key)) ?: 0.0).coerceIn(0.0, 10_000.0) * resources.displayMetrics.density |
| 265 | + var left = inset("left"); var right = inset("right"); var top = inset("top"); var bottom = inset("bottom") |
| 266 | + val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f) |
| 267 | + val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f) |
| 268 | + val horizontal = left + right; val vertical = top + bottom |
| 269 | + if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal } |
| 270 | + if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical } |
| 271 | + map?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt()) |
| 272 | + } |
| 273 | + |
| 274 | + private fun moveCamera(bounds: LatLngBounds, padding: Int) { |
| 275 | + post { if (!destroyed && width > 0 && height > 0) { map?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding)); applyOrientation() } } |
| 276 | + } |
| 277 | + |
| 278 | + private fun applyOrientation() { |
| 279 | + val nativeMap = map ?: return |
| 280 | + val camera = nativeMap.cameraPosition |
| 281 | + val pitch = finiteNumber(pendingProps.opt("cameraPitch"))?.coerceIn(0.0, 60.0) ?: camera.tilt |
| 282 | + val heading = finiteNumber(pendingProps.opt("cameraHeading")) ?: camera.bearing |
| 283 | + if (abs(camera.tilt - pitch) > 0.01 || abs(camera.bearing - heading) > 0.01) { |
| 284 | + nativeMap.moveCamera(CameraUpdateFactory.newCameraPosition(CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build())) |
| 285 | + } |
| 286 | + } |
| 287 | + |
| 288 | + private fun updateScale() { |
| 289 | + val nativeMap = map ?: return |
| 290 | + if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scale.visibility = View.GONE; return } |
| 291 | + val pixels = 80 * resources.displayMetrics.density |
| 292 | + val start = nativeMap.projection.fromScreenLocation(android.graphics.PointF(width / 2f, height / 2f)) |
| 293 | + val end = nativeMap.projection.fromScreenLocation(android.graphics.PointF(width / 2f + pixels, height / 2f)) |
| 294 | + val measured = FloatArray(1) |
| 295 | + android.location.Location.distanceBetween(start.latitude, start.longitude, end.latitude, end.longitude, measured) |
| 296 | + val meters = measured[0].toDouble() |
| 297 | + if (!meters.isFinite() || meters <= 0) { scale.visibility = View.GONE; return } |
| 298 | + val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(meters))) |
| 299 | + val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= meters }?.times(magnitude) ?: magnitude |
| 300 | + scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m" |
| 301 | + scaleLine.layoutParams = LinearLayout.LayoutParams((pixels * length / meters).roundToInt().coerceAtLeast(1), 2) |
| 302 | + scale.visibility = View.VISIBLE |
| 303 | + } |
| 304 | + |
| 305 | + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { |
| 306 | + super.onSizeChanged(width, height, oldWidth, oldHeight) |
| 307 | + if (width > 0 && height > 0) { applyPadding(); updateScale(); if (initialCameraKey == null) applyPending() } |
| 308 | + } |
| 309 | + |
| 310 | + override fun onAttachedToWindow() { |
| 311 | + super.onAttachedToWindow() |
| 312 | + if (!destroyed && !lifecycleStarted) { mapView.onStart(); mapView.onResume(); lifecycleStarted = true } |
| 313 | + } |
| 314 | + |
| 315 | + override fun onDetachedFromWindow() { |
| 316 | + if (lifecycleStarted) { mapView.onPause(); mapView.onStop(); lifecycleStarted = false } |
| 317 | + super.onDetachedFromWindow() |
| 318 | + } |
| 319 | + |
| 320 | + fun lowMemory() { if (!destroyed) mapView.onLowMemory() } |
| 321 | + |
| 322 | + fun destroy() { |
| 323 | + if (destroyed) return |
| 324 | + destroyed = true |
| 325 | + styleVersion++ |
| 326 | + onMarkerTap = null; onMapPress = null; onRegionChangeComplete = null |
| 327 | + markerHandlers.clear(); geometryPoints = emptyList() |
| 328 | + if (lifecycleStarted) { mapView.onPause(); mapView.onStop(); lifecycleStarted = false } |
| 329 | + mapView.onDestroy() |
| 330 | + readyStyle = null; map = null |
| 331 | + } |
| 332 | + |
| 333 | + private fun coordinates(value: JSONArray?): List<LatLng> { |
| 334 | + if (value == null) return emptyList() |
| 335 | + return (0 until value.length() - 1 step 2).mapNotNull { index -> |
| 336 | + val latitude = finiteNumber(value.opt(index)) ?: return@mapNotNull null |
| 337 | + val longitude = finiteNumber(value.opt(index + 1)) ?: return@mapNotNull null |
| 338 | + if (latitude in -90.0..90.0 && longitude in -180.0..180.0) LatLng(latitude, longitude) else null |
| 339 | + } |
| 340 | + } |
| 341 | + |
| 342 | + private fun region(value: JSONObject?): LatLngBounds? { |
| 343 | + val center = value?.optJSONObject("center") ?: return null |
| 344 | + val span = value.optJSONObject("span") ?: return null |
| 345 | + val latitude = finiteNumber(center.opt("latitude")) ?: return null |
| 346 | + val longitude = finiteNumber(center.opt("longitude")) ?: return null |
| 347 | + val height = finiteNumber(span.opt("latitudeDelta")) ?: finiteNumber(span.opt("latitude_delta")) ?: return null |
| 348 | + val width = finiteNumber(span.opt("longitudeDelta")) ?: finiteNumber(span.opt("longitude_delta")) ?: return null |
| 349 | + if (latitude !in -90.0..90.0 || longitude !in -180.0..180.0 || height <= 0 || height > 180 || width <= 0 || width > 360 || latitude - height / 2 < -90 || latitude + height / 2 > 90) return null |
| 350 | + return LatLngBounds.from(latitude + height / 2, longitude + width / 2, latitude - height / 2, longitude - width / 2) |
| 351 | + } |
| 352 | + |
| 353 | + private fun finiteNumber(value: Any?): Double? = (value as? Number)?.toDouble()?.takeIf { it.isFinite() } |
| 354 | +} |