Skip to content

Commit dcd7ddd

Browse files
jamesarichCopilot
andcommitted
fix(map): pulse indicator fires on new packet, not permanent online status
Change the pulsing ring from showing for all online nodes to only nodes heard within the last 5 seconds. This correctly indicates when a new packet arrives rather than acting as a static online badge. - Add 'recently_heard' boolean property to GeoJSON features - Use Clock.System.now().epochSeconds with periodic tick (1s) to expire stale pulse states - Filter pulse layer on 'recently_heard' instead of 'is_online' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7119714 commit dcd7ddd

2 files changed

Lines changed: 32 additions & 5 deletions

File tree

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,18 @@ import androidx.compose.material3.MaterialTheme
2626
import androidx.compose.runtime.Composable
2727
import androidx.compose.runtime.LaunchedEffect
2828
import androidx.compose.runtime.getValue
29+
import androidx.compose.runtime.mutableStateOf
2930
import androidx.compose.runtime.remember
3031
import androidx.compose.runtime.rememberCoroutineScope
3132
import androidx.compose.runtime.rememberUpdatedState
33+
import androidx.compose.runtime.setValue
3234
import androidx.compose.ui.Modifier
3335
import androidx.compose.ui.graphics.Color
3436
import androidx.compose.ui.unit.dp
3537
import androidx.compose.ui.unit.em
38+
import kotlinx.coroutines.delay
3639
import kotlinx.coroutines.launch
40+
import kotlinx.datetime.Clock
3741
import org.maplibre.compose.camera.CameraPosition
3842
import org.maplibre.compose.camera.CameraState
3943
import org.maplibre.compose.expressions.dsl.asString
@@ -107,6 +111,8 @@ private const val HILLSHADE_EXAGGERATION = 0.5f
107111
private const val PULSE_DURATION_MS = 1500
108112
private const val PULSE_MAX_RADIUS_DP = 14f
109113
private const val PULSE_START_OPACITY = 0.5f
114+
private const val PULSE_WINDOW_SECONDS = 5L
115+
private const val PULSE_TICK_INTERVAL_MS = 1000L
110116

111117
/** Free Terrain Tiles (Terrarium encoding) hosted on AWS. No API key required. */
112118
private val TERRAIN_TILES = listOf("https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png")
@@ -202,14 +208,25 @@ private fun NodeMarkerLayers(
202208
onNodeClick: (Int) -> Unit,
203209
) {
204210
val coroutineScope = rememberCoroutineScope()
205-
val featureCollection = remember(nodes, myNodeNum) { nodesToFeatureCollection(nodes, myNodeNum) }
211+
212+
// Tick current time to expire pulse animations after PULSE_WINDOW_SECONDS
213+
var nowEpochSeconds by remember { mutableStateOf(Clock.System.now().epochSeconds) }
214+
LaunchedEffect(Unit) {
215+
while (true) {
216+
delay(PULSE_TICK_INTERVAL_MS)
217+
nowEpochSeconds = Clock.System.now().epochSeconds
218+
}
219+
}
220+
221+
val featureCollection =
222+
remember(nodes, myNodeNum, nowEpochSeconds) { nodesToFeatureCollection(nodes, myNodeNum, nowEpochSeconds) }
206223

207224
// Read M3 semantic colors for map layers (recomposes on theme change)
208225
val clusterColor = MaterialTheme.colorScheme.primary
209226
val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
210227
val clusterLabelColor = MaterialTheme.colorScheme.onPrimary
211228

212-
// Pulsing ring animation for online nodes
229+
// Pulsing ring animation for recently-heard nodes
213230
val pulseTransition = rememberInfiniteTransition(label = "node-pulse")
214231
val pulseProgress by
215232
pulseTransition.animateFloat(
@@ -261,13 +278,13 @@ private fun NodeMarkerLayers(
261278
textSize = const(1.2f.em),
262279
)
263280

264-
// Pulsing ring behind online nodes — animated radius expanding outward with fading opacity
281+
// Pulsing ring behind recently-heard nodes — indicates new packet received
265282
val pulseRadius = (NODE_MARKER_RADIUS.value + (PULSE_MAX_RADIUS_DP - NODE_MARKER_RADIUS.value) * pulseProgress).dp
266283
val pulseOpacity = PULSE_START_OPACITY * (1f - pulseProgress)
267284
CircleLayer(
268285
id = "node-pulse-ring",
269286
source = nodesSource,
270-
filter = feature["is_online"].convertToBoolean(),
287+
filter = feature["recently_heard"].convertToBoolean(),
271288
radius = const(pulseRadius),
272289
color = const(OnlineStrokeColor),
273290
opacity = const(pulseOpacity),

feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,24 @@ import org.meshtastic.core.model.Node
2929

3030
private const val MIN_PRECISION_BITS = 10
3131
private const val MAX_PRECISION_BITS = 19
32+
private const val RECENTLY_HEARD_WINDOW_SECONDS = 5
3233

3334
/** Convert a list of nodes to a GeoJSON [FeatureCollection] for map rendering. */
34-
internal fun nodesToFeatureCollection(nodes: List<Node>, myNodeNum: Int? = null): FeatureCollection<Point, JsonObject> {
35+
internal fun nodesToFeatureCollection(
36+
nodes: List<Node>,
37+
myNodeNum: Int? = null,
38+
nowEpochSeconds: Long = 0L,
39+
): FeatureCollection<Point, JsonObject> {
3540
val features =
3641
nodes.mapNotNull { node ->
3742
val pos = node.validPosition ?: return@mapNotNull null
3843
val geoPos = toGeoPositionOrNull(pos.latitude_i, pos.longitude_i) ?: return@mapNotNull null
3944

4045
val colors = node.colors
46+
val recentlyHeard =
47+
nowEpochSeconds > 0L &&
48+
node.lastHeard > 0 &&
49+
(nowEpochSeconds - node.lastHeard) <= RECENTLY_HEARD_WINDOW_SECONDS
4150
val props = buildJsonObject {
4251
put("node_num", node.num)
4352
put("short_name", node.user.short_name)
@@ -46,6 +55,7 @@ internal fun nodesToFeatureCollection(nodes: List<Node>, myNodeNum: Int? = null)
4655
put("is_favorite", node.isFavorite)
4756
put("is_my_node", node.num == myNodeNum)
4857
put("is_online", node.isOnline)
58+
put("recently_heard", recentlyHeard)
4959
put("battery_level", node.batteryLevel ?: -1)
5060
put("hops_away", node.hopsAway)
5161
put("via_mqtt", node.viaMqtt)

0 commit comments

Comments
 (0)