Skip to content

Commit 32e2173

Browse files
jamesarichCopilot
andcommitted
feat(map): chip markers, node info sheet, zoom buttons, and pulse animation
- Chip-style node markers displaying short names via SymbolLayer - Node info bottom sheet on marker tap with details - Pulse-on-heard ripple animation for recently active nodes - Zoom in/out buttons overlay Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3cdfbc4 commit 32e2173

7 files changed

Lines changed: 281 additions & 9 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="960"
5+
android:viewportHeight="960">
6+
<path
7+
android:fillColor="#FFFFFFFF"
8+
android:pathData="M240,520Q223,520 211.5,508.5Q200,497 200,480Q200,463 211.5,451.5Q223,440 240,440L720,440Q737,440 748.5,451.5Q760,463 760,480Q760,497 748.5,508.5Q737,520 720,520L240,520Z"/>
9+
</vector>

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,9 @@
835835
<string name="owm_api_key_hint">OpenWeatherMap API Key (global)</string>
836836
<string name="owm_info_title">Global Weather Radar</string>
837837
<string name="owm_info_message">Without an API key, weather radar shows US-only NOAA NEXRAD data.\n\nFor global coverage, enter a free OpenWeatherMap API key. Sign up at openweathermap.org/api — the free tier includes 1,000 tile requests per day.</string>
838+
<string name="zoom_in">Zoom in</string>
839+
<string name="zoom_out">Zoom out</string>
840+
<string name="view_details">View Details</string>
838841
<string name="client_notification">Client Notification</string>
839842
<string name="key_verification_title">Key Verification</string>
840843
<string name="key_verification_request_title">Key Verification Request</string>

core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import org.meshtastic.core.resources.ic_qr_code
4646
import org.meshtastic.core.resources.ic_qr_code_2
4747
import org.meshtastic.core.resources.ic_qr_code_scanner
4848
import org.meshtastic.core.resources.ic_refresh
49+
import org.meshtastic.core.resources.ic_remove
4950
import org.meshtastic.core.resources.ic_reply
5051
import org.meshtastic.core.resources.ic_restart_alt
5152
import org.meshtastic.core.resources.ic_restore
@@ -63,6 +64,8 @@ val MeshtasticIcons.Add: ImageVector
6364
@Composable get() = vectorResource(Res.drawable.ic_add)
6465
val MeshtasticIcons.AddReaction: ImageVector
6566
@Composable get() = vectorResource(Res.drawable.ic_add_reaction)
67+
val MeshtasticIcons.Remove: ImageVector
68+
@Composable get() = vectorResource(Res.drawable.ic_remove)
6669
val MeshtasticIcons.Close: ImageVector
6770
@Composable get() = vectorResource(Res.drawable.ic_close)
6871
val MeshtasticIcons.Copy: ImageVector

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import org.meshtastic.feature.map.component.MapControlsOverlay
5454
import org.meshtastic.feature.map.component.MapFilterDropdown
5555
import org.meshtastic.feature.map.component.MapStyleSelector
5656
import org.meshtastic.feature.map.component.MaplibreMapContent
57+
import org.meshtastic.feature.map.component.NodeInfoSheet
58+
import org.meshtastic.feature.map.component.ZoomButtons
5759
import org.meshtastic.feature.map.model.MapStyle
5860
import org.meshtastic.feature.map.util.toGeoPositionOrNull
5961
import org.maplibre.spatialk.geojson.Position as GeoPosition
@@ -93,6 +95,9 @@ fun MapScreen(
9395

9496
var filterMenuExpanded by remember { mutableStateOf(false) }
9597

98+
// Node info sheet state
99+
var selectedNodeNum by remember { mutableStateOf<Int?>(null) }
100+
96101
// Waypoint dialog state
97102
var showWaypointDialog by remember { mutableStateOf(false) }
98103
var longPressPosition by remember { mutableStateOf<GeoPosition?>(null) }
@@ -169,7 +174,7 @@ fun MapScreen(
169174
showHillshade = selectedMapStyle == MapStyle.Terrain,
170175
showWeatherRadar = filterState.showWeatherRadar,
171176
owmApiKey = filterState.owmApiKey,
172-
onNodeClick = { nodeNum -> navigateToNodeDetails(nodeNum) },
177+
onNodeClick = { nodeNum -> selectedNodeNum = nodeNum },
173178
onMapLongClick = { position ->
174179
longPressPosition = position
175180
editingWaypointId = null
@@ -263,6 +268,21 @@ fun MapScreen(
263268
},
264269
)
265270

271+
// Zoom controls
272+
ZoomButtons(
273+
onZoomIn = {
274+
scope.launch {
275+
cameraState.animateTo(cameraState.position.copy(zoom = cameraState.position.zoom + 1.0))
276+
}
277+
},
278+
onZoomOut = {
279+
scope.launch {
280+
cameraState.animateTo(cameraState.position.copy(zoom = cameraState.position.zoom - 1.0))
281+
}
282+
},
283+
modifier = Modifier.align(Alignment.CenterEnd).padding(MAP_OVERLAY_PADDING),
284+
)
285+
266286
// Scale bar — auto-shows on zoom change, hides after 3 seconds
267287
DisappearingScaleBar(
268288
metersPerDp = cameraState.metersPerDpAtTarget,
@@ -310,4 +330,14 @@ fun MapScreen(
310330
position = longPressPosition,
311331
)
312332
}
333+
334+
// Node info bottom sheet
335+
val selectedNode = selectedNodeNum?.let { num -> filteredNodes.find { it.num == num } }
336+
if (selectedNode != null) {
337+
NodeInfoSheet(
338+
node = selectedNode,
339+
onDismiss = { selectedNodeNum = null },
340+
onViewDetails = { navigateToNodeDetails(selectedNode.num) },
341+
)
342+
}
313343
}

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

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

19+
import androidx.compose.foundation.layout.Column
20+
import androidx.compose.foundation.layout.Spacer
21+
import androidx.compose.foundation.layout.height
1922
import androidx.compose.material3.FilledIconButton
2023
import androidx.compose.material3.Icon
2124
import androidx.compose.material3.IconButtonDefaults
2225
import androidx.compose.runtime.Composable
2326
import androidx.compose.ui.Modifier
2427
import androidx.compose.ui.graphics.Color
2528
import androidx.compose.ui.graphics.vector.ImageVector
29+
import androidx.compose.ui.unit.dp
30+
import org.jetbrains.compose.resources.stringResource
31+
import org.meshtastic.core.resources.Res
32+
import org.meshtastic.core.resources.zoom_in
33+
import org.meshtastic.core.resources.zoom_out
34+
import org.meshtastic.core.ui.icon.Add
35+
import org.meshtastic.core.ui.icon.MeshtasticIcons
36+
import org.meshtastic.core.ui.icon.Remove
2637

2738
/** A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance. */
2839
@Composable
@@ -41,3 +52,21 @@ internal fun MapButton(
4152
)
4253
}
4354
}
55+
56+
/** Vertical zoom in/out button pair for the map overlay. */
57+
@Composable
58+
internal fun ZoomButtons(onZoomIn: () -> Unit, onZoomOut: () -> Unit, modifier: Modifier = Modifier) {
59+
Column(modifier = modifier) {
60+
MapButton(
61+
icon = MeshtasticIcons.Add,
62+
contentDescription = stringResource(Res.string.zoom_in),
63+
onClick = onZoomIn,
64+
)
65+
Spacer(Modifier.height(4.dp))
66+
MapButton(
67+
icon = MeshtasticIcons.Remove,
68+
contentDescription = stringResource(Res.string.zoom_out),
69+
onClick = onZoomOut,
70+
)
71+
}
72+
}

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

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,26 @@
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.runtime.Composable
2026
import androidx.compose.runtime.LaunchedEffect
27+
import androidx.compose.runtime.getValue
28+
import androidx.compose.runtime.mutableStateOf
2129
import androidx.compose.runtime.remember
2230
import androidx.compose.runtime.rememberCoroutineScope
31+
import androidx.compose.runtime.setValue
2332
import androidx.compose.ui.Modifier
2433
import androidx.compose.ui.graphics.Color
34+
import androidx.compose.ui.unit.Dp
2535
import androidx.compose.ui.unit.dp
2636
import androidx.compose.ui.unit.em
37+
import kotlinx.coroutines.Job
38+
import kotlinx.coroutines.delay
2739
import kotlinx.coroutines.launch
2840
import org.maplibre.compose.camera.CameraPosition
2941
import org.maplibre.compose.camera.CameraState
@@ -61,11 +73,11 @@ import org.maplibre.compose.style.StyleState
6173
import org.maplibre.compose.style.rememberStyleState
6274
import org.maplibre.compose.util.ClickResult
6375
import org.maplibre.spatialk.geojson.Point
76+
import org.meshtastic.core.common.util.nowSeconds
6477
import org.meshtastic.core.model.DataPacket
6578
import org.meshtastic.core.model.Node
6679
import org.meshtastic.feature.map.model.MapLayerItem
6780
import org.meshtastic.feature.map.util.MARKER_STROKE_WIDTH
68-
import org.meshtastic.feature.map.util.NODE_MARKER_RADIUS
6981
import org.meshtastic.feature.map.util.PRECISION_CIRCLE_STROKE_ALPHA
7082
import org.meshtastic.feature.map.util.nodesToFeatureCollection
7183
import 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")
90102
private const val PRECISION_SCALE_MAX = 16_777_216f / EQUATORIAL_METERS_PER_PIXEL_ZOOM0 // 2^24
91103
private const val CLUSTER_OPACITY = 0.85f
92-
private const val LABEL_OFFSET_EM = 1.5f
93104
private const val CLUSTER_ZOOM_INCREMENT = 2.0
94105
private 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. */
97120
private 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

Comments
 (0)