1616 */
1717package 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
1925import androidx.compose.runtime.Composable
2026import androidx.compose.runtime.LaunchedEffect
27+ import androidx.compose.runtime.getValue
28+ import androidx.compose.runtime.mutableStateOf
2129import androidx.compose.runtime.remember
2230import androidx.compose.runtime.rememberCoroutineScope
31+ import androidx.compose.runtime.setValue
2332import androidx.compose.ui.Modifier
2433import androidx.compose.ui.graphics.Color
34+ import androidx.compose.ui.unit.Dp
2535import androidx.compose.ui.unit.dp
2636import androidx.compose.ui.unit.em
37+ import kotlinx.coroutines.Job
38+ import kotlinx.coroutines.delay
2739import kotlinx.coroutines.launch
2840import org.maplibre.compose.camera.CameraPosition
2941import org.maplibre.compose.camera.CameraState
@@ -61,11 +73,11 @@ import org.maplibre.compose.style.StyleState
6173import org.maplibre.compose.style.rememberStyleState
6274import org.maplibre.compose.util.ClickResult
6375import org.maplibre.spatialk.geojson.Point
76+ import org.meshtastic.core.common.util.nowSeconds
6477import org.meshtastic.core.model.DataPacket
6578import org.meshtastic.core.model.Node
6679import org.meshtastic.feature.map.model.MapLayerItem
6780import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH
68- import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS
6981import org.meshtastic.feature.map.util.PRECISION_CIRCLE_STROKE_ALPHA
7082import org.meshtastic.feature.map.util.nodesToFeatureCollection
7183import org.meshtastic.feature.map.util.waypointsToFeatureCollection
@@ -89,10 +101,21 @@ private const val PRECISION_SCALE_MIN = 1f / EQUATORIAL_METERS_PER_PIXEL_ZOOM0
89101@Suppress(" MagicNumber" )
90102private const val PRECISION_SCALE_MAX = 16_777_216f / EQUATORIAL_METERS_PER_PIXEL_ZOOM0 // 2^24
91103private const val CLUSTER_OPACITY = 0.85f
92- private const val LABEL_OFFSET_EM = 1.5f
93104private const val CLUSTER_ZOOM_INCREMENT = 2.0
94105private const val HILLSHADE_EXAGGERATION = 0.5f
95106
107+ /* * Node chip marker: larger circle with short name text centered inside. */
108+ private val NODE_CHIP_RADIUS : Dp = 14 .dp
109+ private const val NODE_CHIP_TEXT_SIZE = 0.7f
110+ private val NODE_CHIP_HALO_WIDTH : Dp = 1 .dp
111+
112+ /* * Pulse animation: ripple ring triggered when a node is heard. Matches original PulsingNodeChip behavior. */
113+ private const val PULSE_RECENTLY_HEARD_SECONDS = 5
114+ private const val PULSE_DURATION_MS = 5000L
115+ private const val PULSE_CYCLE_MS = 1000
116+ private const val PULSE_MAX_EXPANSION_DP = 10f
117+ private const val PULSE_MAX_OPACITY = 0.3f
118+
96119/* * Free Terrain Tiles (Terrarium encoding) hosted on AWS. No API key required. */
97120private val TERRAIN_TILES = listOf (" https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png" )
98121
@@ -230,6 +253,60 @@ private fun NodeMarkerLayers(
230253 GeoJsonOptions (cluster = true , clusterRadius = CLUSTER_RADIUS , clusterMinPoints = CLUSTER_MIN_POINTS ),
231254 )
232255
256+ // --- Pulse detection: track lastHeard changes to trigger ripple animation ---
257+ val prevLastHeard = remember { mutableMapOf<Int , Int >() }
258+ var pulsingNodeNums by remember { mutableStateOf(emptySet<Int >()) }
259+ val removalJobs = remember { mutableMapOf<Int , Job >() }
260+
261+ val currentLastHeard = remember(nodes) { nodes.associate { it.num to it.lastHeard } }
262+ LaunchedEffect (currentLastHeard) {
263+ val now = nowSeconds
264+ for ((num, lastHeard) in currentLastHeard) {
265+ val prev = prevLastHeard[num]
266+ val recentlyHeard = lastHeard > 0 && (now - lastHeard) <= PULSE_RECENTLY_HEARD_SECONDS
267+ if (recentlyHeard && (prev == null || prev < lastHeard)) {
268+ removalJobs[num]?.cancel()
269+ pulsingNodeNums = pulsingNodeNums + num
270+ removalJobs[num] =
271+ coroutineScope.launch {
272+ delay(PULSE_DURATION_MS )
273+ pulsingNodeNums = pulsingNodeNums - num
274+ removalJobs.remove(num)
275+ }
276+ }
277+ }
278+ prevLastHeard.clear()
279+ prevLastHeard.putAll(currentLastHeard)
280+ }
281+
282+ // Pulse halo layer — expanding ripple ring behind markers for recently-heard nodes
283+ if (pulsingNodeNums.isNotEmpty()) {
284+ val pulseTransition = rememberInfiniteTransition()
285+ val pulseProgress by
286+ pulseTransition.animateFloat(
287+ initialValue = 0f ,
288+ targetValue = 1f ,
289+ animationSpec =
290+ infiniteRepeatable(
291+ animation = tween(PULSE_CYCLE_MS , easing = LinearEasing ),
292+ repeatMode = RepeatMode .Restart ,
293+ ),
294+ )
295+ val pulsingFeatures =
296+ remember(nodes, pulsingNodeNums) {
297+ val pulsingSet = pulsingNodeNums
298+ nodesToFeatureCollection(nodes.filter { it.num in pulsingSet }, myNodeNum)
299+ }
300+ val pulsingSource = rememberGeoJsonSource(data = GeoJsonData .Features (pulsingFeatures))
301+ CircleLayer (
302+ id = " node-pulse-halo" ,
303+ source = pulsingSource,
304+ radius = const(NODE_CHIP_RADIUS + (pulseProgress * PULSE_MAX_EXPANSION_DP ).dp),
305+ color = feature[" background_color" ].convertToColor(const(NodeMarkerColor )),
306+ opacity = const((1f - pulseProgress) * PULSE_MAX_OPACITY ),
307+ )
308+ }
309+
233310 // Cluster circles — tap to zoom in toward expansion
234311 CircleLayer (
235312 id = " node-clusters" ,
@@ -265,12 +342,12 @@ private fun NodeMarkerLayers(
265342 textSize = const(1.2f .em),
266343 )
267344
268- // Individual node markers with per-node background color
345+ // Individual node markers — colored chip-style circles with short name
269346 CircleLayer (
270347 id = " node-markers" ,
271348 source = nodesSource,
272349 filter = ! feature.has(" cluster" ),
273- radius = const(NODE_MARKER_RADIUS ),
350+ radius = const(NODE_CHIP_RADIUS ),
274351 color = feature[" background_color" ].convertToColor(const(NodeMarkerColor )),
275352 strokeWidth = const(MARKER_STROKE_WIDTH ),
276353 strokeColor = const(Color .White ),
@@ -285,15 +362,16 @@ private fun NodeMarkerLayers(
285362 },
286363 )
287364
288- // Short name labels below node markers
365+ // Short name centered on the node marker chip
289366 SymbolLayer (
290367 id = " node-labels" ,
291368 source = nodesSource,
292369 filter = ! feature.has(" cluster" ),
293370 textField = feature[" short_name" ].asString(),
294- textSize = const(0.9f .em),
295- textOffset = offset(0f .em, LABEL_OFFSET_EM .em),
296- textColor = const(Color .DarkGray ),
371+ textSize = const(NODE_CHIP_TEXT_SIZE .em),
372+ textColor = feature[" foreground_color" ].convertToColor(const(Color .White )),
373+ textHaloColor = feature[" background_color" ].convertToColor(const(NodeMarkerColor )),
374+ textHaloWidth = const(NODE_CHIP_HALO_WIDTH ),
297375 textAllowOverlap = const(true ),
298376 iconAllowOverlap = const(true ),
299377 )
0 commit comments