From 7119714a648d971229ea7a7226e224e702351133 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 18 May 2026 14:43:57 -0500 Subject: [PATCH] feat(map): add pulsing online indicator and satellite tile style - Add animated pulsing ring behind online nodes using Compose InfiniteTransition (expanding radius + fading opacity) - Add Satellite map style using free Esri World Imagery raster tiles - Use BaseStyle.Json for inline raster style definition - Derive baseStyle from selectedMapStyle (single source of truth) - Update MapStyleTest to verify both Uri and Json style variants - Update MapViewModelTest to use toBaseStyle() assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .skills/compose-ui/strings-index.txt | 1 + .../composeResources/values/strings.xml | 1 + .../meshtastic/feature/map/MapViewModel.kt | 10 +++--- .../map/component/MaplibreMapContent.kt | 32 +++++++++++++++++++ .../meshtastic/feature/map/model/MapStyle.kt | 23 +++++++++++-- .../feature/map/MapViewModelTest.kt | 7 ++-- .../feature/map/model/MapStyleTest.kt | 11 +++++-- 7 files changed, 71 insertions(+), 14 deletions(-) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 9dfa4c187..913689a56 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -637,6 +637,7 @@ map_style_dark map_style_light map_style_osm map_style_road_map +map_style_satellite map_style_selection map_style_terrain map_subDescription diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 388728867..4cfc67b56 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -667,6 +667,7 @@ Light OpenStreetMap Road Map + Satellite Map style selection Terrain bearing: %1$d° distance: %2$s diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt index 616d953b0..b9b55d0dc 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt @@ -73,18 +73,16 @@ class MapViewModel( bearing = mapCameraPrefs.cameraBearing.value.toDouble(), ) - /** Active map base style. */ - val baseStyle: StateFlow = - mapCameraPrefs.selectedStyleUri - .map { uri -> if (uri.isBlank()) MapStyle.OpenStreetMap.toBaseStyle() else BaseStyle.Uri(uri) } - .stateInWhileSubscribed(MapStyle.OpenStreetMap.toBaseStyle()) - /** Currently selected map style enum index. */ val selectedMapStyle: StateFlow = mapCameraPrefs.selectedStyleUri .map { uri -> MapStyle.entries.find { it.styleUri == uri } ?: MapStyle.OpenStreetMap } .stateInWhileSubscribed(MapStyle.OpenStreetMap) + /** Active map base style derived from the selected [MapStyle]. */ + val baseStyle: StateFlow = + selectedMapStyle.map { it.toBaseStyle() }.stateInWhileSubscribed(MapStyle.OpenStreetMap.toBaseStyle()) + /** Persist camera position to DataStore. */ fun saveCameraPosition(position: CameraPosition) { mapCameraPrefs.setCameraLat(position.target.latitude) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index fb6f4b85f..aae47f898 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -16,9 +16,16 @@ */ package org.meshtastic.feature.map.component +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -97,6 +104,9 @@ private const val CLUSTER_OPACITY = 0.85f private const val LABEL_OFFSET_EM = 1.5f private const val CLUSTER_ZOOM_INCREMENT = 2.0 private const val HILLSHADE_EXAGGERATION = 0.5f +private const val PULSE_DURATION_MS = 1500 +private const val PULSE_MAX_RADIUS_DP = 14f +private const val PULSE_START_OPACITY = 0.5f /** Free Terrain Tiles (Terrarium encoding) hosted on AWS. No API key required. */ private val TERRAIN_TILES = listOf("https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png") @@ -199,6 +209,16 @@ private fun NodeMarkerLayers( val labelColor = MaterialTheme.colorScheme.onSurfaceVariant val clusterLabelColor = MaterialTheme.colorScheme.onPrimary + // Pulsing ring animation for online nodes + val pulseTransition = rememberInfiniteTransition(label = "node-pulse") + val pulseProgress by + pulseTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(PULSE_DURATION_MS, easing = LinearEasing), RepeatMode.Restart), + label = "pulse-progress", + ) + val nodesSource = rememberGeoJsonSource( data = GeoJsonData.Features(featureCollection), @@ -241,6 +261,18 @@ private fun NodeMarkerLayers( textSize = const(1.2f.em), ) + // Pulsing ring behind online nodes — animated radius expanding outward with fading opacity + val pulseRadius = (NODE_MARKER_RADIUS.value + (PULSE_MAX_RADIUS_DP - NODE_MARKER_RADIUS.value) * pulseProgress).dp + val pulseOpacity = PULSE_START_OPACITY * (1f - pulseProgress) + CircleLayer( + id = "node-pulse-ring", + source = nodesSource, + filter = feature["is_online"].convertToBoolean(), + radius = const(pulseRadius), + color = const(OnlineStrokeColor), + opacity = const(pulseOpacity), + ) + // Individual node markers with per-node background color and online-status stroke CircleLayer( id = "node-markers", diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt index 339b69e50..3cf5a2add 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt @@ -23,12 +23,13 @@ import org.meshtastic.core.resources.map_style_dark import org.meshtastic.core.resources.map_style_light import org.meshtastic.core.resources.map_style_osm import org.meshtastic.core.resources.map_style_road_map +import org.meshtastic.core.resources.map_style_satellite import org.meshtastic.core.resources.map_style_terrain /** * Predefined map tile styles available in the app. * - * Uses free tile sources that do not require API keys. All styles are vector-based and work across platforms. + * Uses free tile sources that do not require API keys. */ enum class MapStyle(val label: StringResource, val styleUri: String) { /** OpenStreetMap default tiles via OpenFreeMap Liberty style. */ @@ -45,7 +46,25 @@ enum class MapStyle(val label: StringResource, val styleUri: String) { /** Dark mode style via OpenFreeMap Fiord. */ Dark(label = Res.string.map_style_dark, styleUri = "https://tiles.openfreemap.org/styles/fiord"), + + /** Satellite imagery via Esri World Imagery (free for non-commercial use). */ + Satellite(label = Res.string.map_style_satellite, styleUri = SATELLITE_STYLE_URI), ; - fun toBaseStyle(): BaseStyle = BaseStyle.Uri(styleUri) + fun toBaseStyle(): BaseStyle = when (this) { + Satellite -> BaseStyle.Json(SATELLITE_STYLE_JSON) + else -> BaseStyle.Uri(styleUri) + } } + +/** Stable URI used as persistence key for satellite style selection. */ +private const val SATELLITE_STYLE_URI = "satellite://esri-world-imagery" + +/** + * Inline MapLibre style JSON for raster satellite imagery. + * + * Uses Esri World Imagery tiles which are free for non-commercial and educational use. + */ +@Suppress("MaxLineLength") +private const val SATELLITE_STYLE_JSON: String = + """{"version":8,"name":"Satellite","sources":{"esri-satellite":{"type":"raster","tiles":["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"],"tileSize":256,"maxzoom":18,"attribution":"Esri, Maxar, Earthstar Geographics"}},"layers":[{"id":"satellite","type":"raster","source":"esri-satellite"}]}""" diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt index 12321cc52..3b73c3736 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import org.maplibre.compose.style.BaseStyle import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.testing.FakeMapCameraPrefs import org.meshtastic.core.testing.FakeMapPrefs @@ -141,7 +140,7 @@ class MapViewModelTest { fun baseStyleDefaultsToOpenStreetMap() = runTest(testDispatcher) { viewModel.baseStyle.test { val style = awaitItem() - assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style) + assertEquals(MapStyle.OpenStreetMap.toBaseStyle(), style) cancelAndIgnoreRemainingEvents() } } @@ -166,7 +165,7 @@ class MapViewModelTest { viewModel.selectMapStyle(MapStyle.Dark) val darkStyle = awaitItem() - assertEquals(BaseStyle.Uri(MapStyle.Dark.styleUri), darkStyle) + assertEquals(MapStyle.Dark.toBaseStyle(), darkStyle) cancelAndIgnoreRemainingEvents() } @@ -177,7 +176,7 @@ class MapViewModelTest { // selectedStyleUri defaults to "" in FakeMapCameraPrefs viewModel.baseStyle.test { val style = awaitItem() - assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style) + assertEquals(MapStyle.OpenStreetMap.toBaseStyle(), style) cancelAndIgnoreRemainingEvents() } } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt index e63009765..a4e2ed728 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt @@ -25,14 +25,21 @@ import kotlin.test.assertTrue class MapStyleTest { @Test - fun toBaseStyle_returnsUriWithCorrectStyleUri() { - for (style in MapStyle.entries) { + fun toBaseStyle_returnsUriForVectorStyles() { + for (style in MapStyle.entries.filter { it != MapStyle.Satellite }) { val baseStyle = style.toBaseStyle() assertIs(baseStyle) assertEquals(style.styleUri, baseStyle.uri) } } + @Test + fun toBaseStyle_returnsJsonForSatellite() { + val baseStyle = MapStyle.Satellite.toBaseStyle() + assertIs(baseStyle) + assertTrue(baseStyle.json.contains("esri-satellite")) + } + @Test fun allStyles_haveNonBlankUri() { for (style in MapStyle.entries) {