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>
This commit is contained in:
James Rich
2026-05-18 14:43:57 -05:00
parent db4bbec336
commit 7119714a64
7 changed files with 71 additions and 14 deletions
+1
View File
@@ -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
@@ -667,6 +667,7 @@
<string name="map_style_light">Light</string>
<string name="map_style_osm">OpenStreetMap</string>
<string name="map_style_road_map">Road Map</string>
<string name="map_style_satellite">Satellite</string>
<string name="map_style_selection">Map style selection</string>
<string name="map_style_terrain">Terrain</string>
<string name="map_subDescription">bearing: %1$d° distance: %2$s</string>
@@ -73,18 +73,16 @@ class MapViewModel(
bearing = mapCameraPrefs.cameraBearing.value.toDouble(),
)
/** Active map base style. */
val baseStyle: StateFlow<BaseStyle> =
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<MapStyle> =
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<BaseStyle> =
selectedMapStyle.map { it.toBaseStyle() }.stateInWhileSubscribed(MapStyle.OpenStreetMap.toBaseStyle())
/** Persist camera position to DataStore. */
fun saveCameraPosition(position: CameraPosition) {
mapCameraPrefs.setCameraLat(position.target.latitude)
@@ -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",
@@ -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"}]}"""
@@ -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()
}
}
@@ -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.Uri>(baseStyle)
assertEquals(style.styleUri, baseStyle.uri)
}
}
@Test
fun toBaseStyle_returnsJsonForSatellite() {
val baseStyle = MapStyle.Satellite.toBaseStyle()
assertIs<BaseStyle.Json>(baseStyle)
assertTrue(baseStyle.json.contains("esri-satellite"))
}
@Test
fun allStyles_haveNonBlankUri() {
for (style in MapStyle.entries) {