Style GeoJSON overlays from simplestyle-spec (fill/stroke) (#6088)

Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Garth Vander Houwen
2026-07-05 10:28:27 -07:00
committed by GitHub
parent bf5f86daae
commit 034005b4fa

View File

@@ -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 ---