Skip to content

Commit 7119714

Browse files
jamesarichCopilot
andcommitted
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>
1 parent db4bbec commit 7119714

7 files changed

Lines changed: 71 additions & 14 deletions

File tree

.skills/compose-ui/strings-index.txt

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/resources/src/commonMain/composeResources/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,7 @@
667667
<string name="map_style_light">Light</string>
668668
<string name="map_style_osm">OpenStreetMap</string>
669669
<string name="map_style_road_map">Road Map</string>
670+
<string name="map_style_satellite">Satellite</string>
670671
<string name="map_style_selection">Map style selection</string>
671672
<string name="map_style_terrain">Terrain</string>
672673
<string name="map_subDescription">bearing: %1$d° distance: %2$s</string>

feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapViewModel.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,18 +73,16 @@ class MapViewModel(
7373
bearing = mapCameraPrefs.cameraBearing.value.toDouble(),
7474
)
7575

76-
/** Active map base style. */
77-
val baseStyle: StateFlow<BaseStyle> =
78-
mapCameraPrefs.selectedStyleUri
79-
.map { uri -> if (uri.isBlank()) MapStyle.OpenStreetMap.toBaseStyle() else BaseStyle.Uri(uri) }
80-
.stateInWhileSubscribed(MapStyle.OpenStreetMap.toBaseStyle())
81-
8276
/** Currently selected map style enum index. */
8377
val selectedMapStyle: StateFlow<MapStyle> =
8478
mapCameraPrefs.selectedStyleUri
8579
.map { uri -> MapStyle.entries.find { it.styleUri == uri } ?: MapStyle.OpenStreetMap }
8680
.stateInWhileSubscribed(MapStyle.OpenStreetMap)
8781

82+
/** Active map base style derived from the selected [MapStyle]. */
83+
val baseStyle: StateFlow<BaseStyle> =
84+
selectedMapStyle.map { it.toBaseStyle() }.stateInWhileSubscribed(MapStyle.OpenStreetMap.toBaseStyle())
85+
8886
/** Persist camera position to DataStore. */
8987
fun saveCameraPosition(position: CameraPosition) {
9088
mapCameraPrefs.setCameraLat(position.target.latitude)

feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,16 @@
1616
*/
1717
package org.meshtastic.feature.map.component
1818

19+
import androidx.compose.animation.core.LinearEasing
20+
import androidx.compose.animation.core.RepeatMode
21+
import androidx.compose.animation.core.animateFloat
22+
import androidx.compose.animation.core.infiniteRepeatable
23+
import androidx.compose.animation.core.rememberInfiniteTransition
24+
import androidx.compose.animation.core.tween
1925
import androidx.compose.material3.MaterialTheme
2026
import androidx.compose.runtime.Composable
2127
import androidx.compose.runtime.LaunchedEffect
28+
import androidx.compose.runtime.getValue
2229
import androidx.compose.runtime.remember
2330
import androidx.compose.runtime.rememberCoroutineScope
2431
import androidx.compose.runtime.rememberUpdatedState
@@ -97,6 +104,9 @@ private const val CLUSTER_OPACITY = 0.85f
97104
private const val LABEL_OFFSET_EM = 1.5f
98105
private const val CLUSTER_ZOOM_INCREMENT = 2.0
99106
private const val HILLSHADE_EXAGGERATION = 0.5f
107+
private const val PULSE_DURATION_MS = 1500
108+
private const val PULSE_MAX_RADIUS_DP = 14f
109+
private const val PULSE_START_OPACITY = 0.5f
100110

101111
/** Free Terrain Tiles (Terrarium encoding) hosted on AWS. No API key required. */
102112
private val TERRAIN_TILES = listOf("https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png")
@@ -199,6 +209,16 @@ private fun NodeMarkerLayers(
199209
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
200210
val clusterLabelColor = MaterialTheme.colorScheme.onPrimary
201211

212+
// Pulsing ring animation for online nodes
213+
val pulseTransition = rememberInfiniteTransition(label = "node-pulse")
214+
val pulseProgress by
215+
pulseTransition.animateFloat(
216+
initialValue = 0f,
217+
targetValue = 1f,
218+
animationSpec = infiniteRepeatable(tween(PULSE_DURATION_MS, easing = LinearEasing), RepeatMode.Restart),
219+
label = "pulse-progress",
220+
)
221+
202222
val nodesSource =
203223
rememberGeoJsonSource(
204224
data = GeoJsonData.Features(featureCollection),
@@ -241,6 +261,18 @@ private fun NodeMarkerLayers(
241261
textSize = const(1.2f.em),
242262
)
243263

264+
// Pulsing ring behind online nodes — animated radius expanding outward with fading opacity
265+
val pulseRadius = (NODE_MARKER_RADIUS.value + (PULSE_MAX_RADIUS_DP - NODE_MARKER_RADIUS.value) * pulseProgress).dp
266+
val pulseOpacity = PULSE_START_OPACITY * (1f - pulseProgress)
267+
CircleLayer(
268+
id = "node-pulse-ring",
269+
source = nodesSource,
270+
filter = feature["is_online"].convertToBoolean(),
271+
radius = const(pulseRadius),
272+
color = const(OnlineStrokeColor),
273+
opacity = const(pulseOpacity),
274+
)
275+
244276
// Individual node markers with per-node background color and online-status stroke
245277
CircleLayer(
246278
id = "node-markers",

feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/model/MapStyle.kt

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@ import org.meshtastic.core.resources.map_style_dark
2323
import org.meshtastic.core.resources.map_style_light
2424
import org.meshtastic.core.resources.map_style_osm
2525
import org.meshtastic.core.resources.map_style_road_map
26+
import org.meshtastic.core.resources.map_style_satellite
2627
import org.meshtastic.core.resources.map_style_terrain
2728

2829
/**
2930
* Predefined map tile styles available in the app.
3031
*
31-
* Uses free tile sources that do not require API keys. All styles are vector-based and work across platforms.
32+
* Uses free tile sources that do not require API keys.
3233
*/
3334
enum class MapStyle(val label: StringResource, val styleUri: String) {
3435
/** OpenStreetMap default tiles via OpenFreeMap Liberty style. */
@@ -45,7 +46,25 @@ enum class MapStyle(val label: StringResource, val styleUri: String) {
4546

4647
/** Dark mode style via OpenFreeMap Fiord. */
4748
Dark(label = Res.string.map_style_dark, styleUri = "https://tiles.openfreemap.org/styles/fiord"),
49+
50+
/** Satellite imagery via Esri World Imagery (free for non-commercial use). */
51+
Satellite(label = Res.string.map_style_satellite, styleUri = SATELLITE_STYLE_URI),
4852
;
4953

50-
fun toBaseStyle(): BaseStyle = BaseStyle.Uri(styleUri)
54+
fun toBaseStyle(): BaseStyle = when (this) {
55+
Satellite -> BaseStyle.Json(SATELLITE_STYLE_JSON)
56+
else -> BaseStyle.Uri(styleUri)
57+
}
5158
}
59+
60+
/** Stable URI used as persistence key for satellite style selection. */
61+
private const val SATELLITE_STYLE_URI = "satellite://esri-world-imagery"
62+
63+
/**
64+
* Inline MapLibre style JSON for raster satellite imagery.
65+
*
66+
* Uses Esri World Imagery tiles which are free for non-commercial and educational use.
67+
*/
68+
@Suppress("MaxLineLength")
69+
private const val SATELLITE_STYLE_JSON: String =
70+
"""{"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"}]}"""

feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
2828
import kotlinx.coroutines.test.resetMain
2929
import kotlinx.coroutines.test.runTest
3030
import kotlinx.coroutines.test.setMain
31-
import org.maplibre.compose.style.BaseStyle
3231
import org.meshtastic.core.repository.PacketRepository
3332
import org.meshtastic.core.testing.FakeMapCameraPrefs
3433
import org.meshtastic.core.testing.FakeMapPrefs
@@ -141,7 +140,7 @@ class MapViewModelTest {
141140
fun baseStyleDefaultsToOpenStreetMap() = runTest(testDispatcher) {
142141
viewModel.baseStyle.test {
143142
val style = awaitItem()
144-
assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style)
143+
assertEquals(MapStyle.OpenStreetMap.toBaseStyle(), style)
145144
cancelAndIgnoreRemainingEvents()
146145
}
147146
}
@@ -166,7 +165,7 @@ class MapViewModelTest {
166165

167166
viewModel.selectMapStyle(MapStyle.Dark)
168167
val darkStyle = awaitItem()
169-
assertEquals(BaseStyle.Uri(MapStyle.Dark.styleUri), darkStyle)
168+
assertEquals(MapStyle.Dark.toBaseStyle(), darkStyle)
170169

171170
cancelAndIgnoreRemainingEvents()
172171
}
@@ -177,7 +176,7 @@ class MapViewModelTest {
177176
// selectedStyleUri defaults to "" in FakeMapCameraPrefs
178177
viewModel.baseStyle.test {
179178
val style = awaitItem()
180-
assertEquals(BaseStyle.Uri(MapStyle.OpenStreetMap.styleUri), style)
179+
assertEquals(MapStyle.OpenStreetMap.toBaseStyle(), style)
181180
cancelAndIgnoreRemainingEvents()
182181
}
183182
}

feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/model/MapStyleTest.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,21 @@ import kotlin.test.assertTrue
2525
class MapStyleTest {
2626

2727
@Test
28-
fun toBaseStyle_returnsUriWithCorrectStyleUri() {
29-
for (style in MapStyle.entries) {
28+
fun toBaseStyle_returnsUriForVectorStyles() {
29+
for (style in MapStyle.entries.filter { it != MapStyle.Satellite }) {
3030
val baseStyle = style.toBaseStyle()
3131
assertIs<BaseStyle.Uri>(baseStyle)
3232
assertEquals(style.styleUri, baseStyle.uri)
3333
}
3434
}
3535

36+
@Test
37+
fun toBaseStyle_returnsJsonForSatellite() {
38+
val baseStyle = MapStyle.Satellite.toBaseStyle()
39+
assertIs<BaseStyle.Json>(baseStyle)
40+
assertTrue(baseStyle.json.contains("esri-satellite"))
41+
}
42+
3643
@Test
3744
fun allStyles_haveNonBlankUri() {
3845
for (style in MapStyle.entries) {

0 commit comments

Comments
 (0)