@@ -26,14 +26,18 @@ import androidx.compose.material3.MaterialTheme
2626import androidx.compose.runtime.Composable
2727import androidx.compose.runtime.LaunchedEffect
2828import androidx.compose.runtime.getValue
29+ import androidx.compose.runtime.mutableStateOf
2930import androidx.compose.runtime.remember
3031import androidx.compose.runtime.rememberCoroutineScope
3132import androidx.compose.runtime.rememberUpdatedState
33+ import androidx.compose.runtime.setValue
3234import androidx.compose.ui.Modifier
3335import androidx.compose.ui.graphics.Color
3436import androidx.compose.ui.unit.dp
3537import androidx.compose.ui.unit.em
38+ import kotlinx.coroutines.delay
3639import kotlinx.coroutines.launch
40+ import kotlinx.datetime.Clock
3741import org.maplibre.compose.camera.CameraPosition
3842import org.maplibre.compose.camera.CameraState
3943import org.maplibre.compose.expressions.dsl.asString
@@ -107,6 +111,8 @@ private const val HILLSHADE_EXAGGERATION = 0.5f
107111private const val PULSE_DURATION_MS = 1500
108112private const val PULSE_MAX_RADIUS_DP = 14f
109113private 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. */
112118private 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),
0 commit comments