From 034005b4fa5c8b34761c4ed5d85f0d3ebd4345d8 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Sun, 5 Jul 2026 10:28:27 -0700 Subject: [PATCH] Style GeoJSON overlays from simplestyle-spec (fill/stroke) (#6088) Co-authored-by: James Rich Co-authored-by: Claude Opus 4.8 --- .../kotlin/org/meshtastic/app/map/MapView.kt | 90 ++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 7dad8d2fb..7eafc1637 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -90,7 +90,10 @@ import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberUpdatedMarkerState import com.google.maps.android.compose.widgets.ScaleBar import com.google.maps.android.data.Layer +import com.google.maps.android.data.geojson.GeoJsonFeature import com.google.maps.android.data.geojson.GeoJsonLayer +import com.google.maps.android.data.geojson.GeoJsonLineStringStyle +import com.google.maps.android.data.geojson.GeoJsonPolygonStyle import com.google.maps.android.data.kml.KmlLayer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -154,6 +157,8 @@ import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint import kotlin.math.abs import kotlin.math.max +import kotlin.math.roundToInt +import android.graphics.Color as AndroidColor // region --- Map Mode --- @@ -192,6 +197,11 @@ private val GEOFENCE_OVERLAY_COLOR = Color(0xFFFF9800) private const val GEOFENCE_FILL_ALPHA = 0.12f private const val GEOFENCE_STROKE_WIDTH = 2f +// simplestyle-spec fallbacks for imported GeoJSON overlays that omit these keys; tune here. +// 0.35 lets stacked contour bands read as a gradient. +private const val DEFAULT_GEOJSON_FILL_OPACITY = 0.35f +private const val DEFAULT_GEOJSON_STROKE_WIDTH = 2f + // Minimum lat/lon delta (~11 m) between the two box-authoring corner taps; below this the box would be degenerate // (zero-area) so the second tap is ignored. private const val BOX_AUTHORING_MIN_CORNER_DELTA = 1e-4 @@ -1207,7 +1217,9 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) LayerType.KML -> KmlLayer(map, inputStream, context) LayerType.GEOJSON -> - GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })) + GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() })).also { + it.applySimpleStyleSpec() + } } } catch (e: Exception) { Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" } @@ -1248,6 +1260,82 @@ private fun Layer.safeAddLayerToMap() { } } +/** + * Apply simplestyle-spec (https://github.com/mapbox/simplestyle-spec) properties to a GeoJSON layer. + * + * Google's [GeoJsonLayer] otherwise applies one default style to every feature, so exports that carry per-feature + * colors render unstyled. In particular, Meshtastic Site Planner coverage contours set `fill`/`stroke` (plus a legacy + * `color`) and `fill-opacity`; read those and style each polygon/line so the coverage draws in its dBm colors instead + * of the default black outline. + */ +private fun GeoJsonLayer.applySimpleStyleSpec() { + for (feature in features) { + val fill = feature.cssColor("fill") ?: feature.cssColor("color") + val stroke = feature.cssColor("stroke") ?: feature.cssColor("color") + val fillOpacity = feature.getProperty("fill-opacity")?.toFloatOrNull() + val strokeWidth = feature.getProperty("stroke-width")?.toFloatOrNull() ?: DEFAULT_GEOJSON_STROKE_WIDTH + when (feature.geometry?.geometryType) { + "Polygon", + "MultiPolygon", + -> + feature.polygonStyle = + GeoJsonPolygonStyle().apply { + fill?.let { fillColor = it.resolveFillAlpha(fillOpacity) } + stroke?.let { strokeColor = it } + this.strokeWidth = strokeWidth + } + + "LineString", + "MultiLineString", + -> + feature.lineStringStyle = + GeoJsonLineStringStyle().apply { + stroke?.let { color = it } + width = strokeWidth + } + + else -> Unit // Points keep the default marker. + } + } +} + +private fun GeoJsonFeature.cssColor(key: String): Int? = getProperty(key)?.let { parseCssColor(it) } + +/** + * Resolve a polygon fill's alpha: `fill-opacity` wins when present; otherwise keep any alpha the color already carries + * (`rgba()`/`#AARRGGBB`), falling back to [DEFAULT_GEOJSON_FILL_OPACITY] for opaque fills. + */ +private fun Int.resolveFillAlpha(fillOpacity: Float?): Int = when { + fillOpacity != null -> withAlpha(fillOpacity) + AndroidColor.alpha(this) < 255 -> this + else -> withAlpha(DEFAULT_GEOJSON_FILL_OPACITY) +} + +/** Parse a hex (`#RRGGBB`/`#AARRGGBB`), `rgb()/rgba()`, or named color to an ARGB int; null if invalid. */ +private fun parseCssColor(raw: String): Int? { + val value = raw.trim() + return try { + if (value.startsWith("rgb", ignoreCase = true)) { + val parts = value.substringAfter('(').substringBefore(')').split(',').map { it.trim() } + if (parts.size < 3) return null + val alpha = if (parts.size >= 4) (parts[3].toFloat() * 255f).roundToInt() else 255 + AndroidColor.argb(alpha, parts[0].toInt(), parts[1].toInt(), parts[2].toInt()) + } else { + AndroidColor.parseColor(value) // #hex or named color + } + } catch (e: IllegalArgumentException) { + Logger.withTag("MapView").w(e) { "Unparseable GeoJSON color: $raw" } + null + } +} + +private fun Int.withAlpha(opacity: Float): Int = AndroidColor.argb( + (opacity.coerceIn(0f, 1f) * 255f).roundToInt(), + AndroidColor.red(this), + AndroidColor.green(this), + AndroidColor.blue(this), +) + // endregion // region --- Utilities ---