mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-01 22:19:18 +02:00
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:
Generated
+1
@@ -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)
|
||||
|
||||
+32
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user