diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 4ed865783..a61e48cf4 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -836,6 +836,7 @@
Only Favorites
Show Waypoints
Show Precision Circles
+ Zoom to Fit All Nodes
Client Notification
Key Verification
Key Verification Request
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
index 17a0b50c2..594272bf9 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt
@@ -53,6 +53,7 @@ import org.meshtastic.feature.map.component.MapFilterDropdown
import org.meshtastic.feature.map.component.MapStyleSelector
import org.meshtastic.feature.map.component.MaplibreMapContent
import org.meshtastic.feature.map.model.MapStyle
+import org.meshtastic.feature.map.util.computeBoundingBox
import org.meshtastic.feature.map.util.toGeoPositionOrNull
import org.maplibre.spatialk.geojson.Position as GeoPosition
@@ -205,6 +206,16 @@ fun MapScreen(
onToggleWaypoints = viewModel::toggleShowWaypointsOnMap,
onTogglePrecisionCircle = viewModel::toggleShowPrecisionCircleOnMap,
onSetLastHeardFilter = viewModel::setLastHeardFilter,
+ onZoomToFitAll = {
+ val positions =
+ filteredNodes.mapNotNull { node ->
+ node.validPosition?.let { pos ->
+ toGeoPositionOrNull(pos.latitude_i, pos.longitude_i)
+ }
+ }
+ val bbox = computeBoundingBox(positions) ?: return@MapFilterDropdown
+ scope.launch { cameraState.animateTo(bbox) }
+ },
)
},
mapTypeContent = {
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt
index 53f3d5dc2..5b8cb5993 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterDropdown.kt
@@ -39,10 +39,12 @@ import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
+import org.meshtastic.core.resources.zoom_to_fit_all
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PinDrop
+import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
import kotlin.math.roundToInt
@@ -62,6 +64,7 @@ internal fun MapFilterDropdown(
onToggleWaypoints: () -> Unit,
onTogglePrecisionCircle: () -> Unit,
onSetLastHeardFilter: (LastHeardFilter) -> Unit,
+ onZoomToFitAll: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
DropdownMenuItem(
@@ -101,6 +104,20 @@ internal fun MapFilterDropdown(
)
HorizontalDivider()
LastHeardSlider(filterState.lastHeardFilter, onSetLastHeardFilter)
+ HorizontalDivider()
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.zoom_to_fit_all)) },
+ onClick = {
+ onZoomToFitAll()
+ onDismissRequest()
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = MeshtasticIcons.SelectAll,
+ contentDescription = stringResource(Res.string.zoom_to_fit_all),
+ )
+ },
+ )
}
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt
index 2f23615e6..63ad11aa7 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt
@@ -28,15 +28,20 @@ import kotlinx.coroutines.launch
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.expressions.dsl.asString
+import org.maplibre.compose.expressions.dsl.condition
import org.maplibre.compose.expressions.dsl.const
+import org.maplibre.compose.expressions.dsl.convertToBoolean
import org.maplibre.compose.expressions.dsl.convertToColor
import org.maplibre.compose.expressions.dsl.convertToNumber
import org.maplibre.compose.expressions.dsl.dp
import org.maplibre.compose.expressions.dsl.exponential
import org.maplibre.compose.expressions.dsl.feature
+import org.maplibre.compose.expressions.dsl.format
import org.maplibre.compose.expressions.dsl.interpolate
import org.maplibre.compose.expressions.dsl.not
import org.maplibre.compose.expressions.dsl.offset
+import org.maplibre.compose.expressions.dsl.span
+import org.maplibre.compose.expressions.dsl.switch
import org.maplibre.compose.expressions.dsl.times
import org.maplibre.compose.expressions.dsl.zoom
import org.maplibre.compose.layers.CircleLayer
@@ -69,6 +74,8 @@ import org.meshtastic.feature.map.util.waypointsToFeatureCollection
import org.maplibre.spatialk.geojson.Position as GeoPosition
private val NodeMarkerColor = Color(0xFF6750A4)
+private val OnlineStrokeColor = Color(0xFF4CAF50) // Green — node heard within online threshold
+private val OfflineStrokeColor = Color(0xFF9E9E9E) // Gray — node not heard recently
private const val CLUSTER_RADIUS = 50
private const val CLUSTER_MIN_POINTS = 10
private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f
@@ -227,7 +234,7 @@ private fun NodeMarkerLayers(
textSize = const(1.2f.em),
)
- // Individual node markers with per-node background color
+ // Individual node markers with per-node background color and online-status stroke
CircleLayer(
id = "node-markers",
source = nodesSource,
@@ -235,7 +242,11 @@ private fun NodeMarkerLayers(
radius = const(NODE_MARKER_RADIUS),
color = feature["background_color"].convertToColor(const(NodeMarkerColor)),
strokeWidth = const(MARKER_STROKE_WIDTH),
- strokeColor = const(Color.White),
+ strokeColor =
+ switch(
+ condition(feature["is_online"].convertToBoolean(), const(OnlineStrokeColor)),
+ fallback = const(OfflineStrokeColor),
+ ),
onClick = { features ->
val nodeNum = features.firstOrNull()?.properties?.get("node_num")?.toString()?.toIntOrNull()
if (nodeNum != null) {
@@ -247,12 +258,23 @@ private fun NodeMarkerLayers(
},
)
- // Short name labels below node markers
+ // Short name labels with online status dot below node markers
SymbolLayer(
id = "node-labels",
source = nodesSource,
filter = !feature.has("cluster"),
- textField = feature["short_name"].asString(),
+ textField =
+ format(
+ span(feature["short_name"].asString()),
+ span(
+ const(" \u25CF"), // U+25CF Black Circle
+ textColor =
+ switch(
+ condition(feature["is_online"].convertToBoolean(), const(OnlineStrokeColor)),
+ fallback = const(OfflineStrokeColor),
+ ),
+ ),
+ ),
textSize = const(0.9f.em),
textOffset = offset(0f.em, LABEL_OFFSET_EM.em),
textColor = const(Color.DarkGray),
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
index e3ea84423..e4e5afdd4 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/util/GeoJsonConverters.kt
@@ -45,6 +45,8 @@ internal fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null)
put("last_heard", node.lastHeard)
put("is_favorite", node.isFavorite)
put("is_my_node", node.num == myNodeNum)
+ put("is_online", node.isOnline)
+ put("battery_level", node.batteryLevel ?: -1)
put("hops_away", node.hopsAway)
put("via_mqtt", node.viaMqtt)
put("snr", node.snr.toDouble())
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt
index 1b5502f80..d52da3a8f 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/util/GeoJsonConvertersTest.kt
@@ -18,8 +18,10 @@ package org.meshtastic.feature.map.util
import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.Point
+import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
+import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
@@ -92,6 +94,51 @@ class GeoJsonConvertersTest {
assertEquals("false", props["is_my_node"].toString())
}
+ @Test
+ fun nodesToFeatureCollection_isOnline_offlineByDefault() {
+ // lastHeard defaults to 0 (epoch 1970), always older than the 2-hour online threshold
+ val node = Node(num = 1, position = Position(latitude_i = 400000000, longitude_i = -740000000))
+ val result = nodesToFeatureCollection(listOf(node))
+ val props = result.features.first().properties
+ assertEquals("false", props["is_online"].toString())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_isOnline_trueWhenRecentlyHeard() {
+ val recentTimestamp = nowSeconds.toInt()
+ val node =
+ Node(
+ num = 1,
+ position = Position(latitude_i = 400000000, longitude_i = -740000000),
+ lastHeard = recentTimestamp,
+ )
+ val result = nodesToFeatureCollection(listOf(node))
+ val props = result.features.first().properties
+ assertEquals("true", props["is_online"].toString())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_batteryLevel_withKnownBattery() {
+ val node =
+ Node(
+ num = 1,
+ position = Position(latitude_i = 400000000, longitude_i = -740000000),
+ deviceMetrics = DeviceMetrics(battery_level = 75),
+ )
+ val result = nodesToFeatureCollection(listOf(node))
+ val props = result.features.first().properties
+ assertEquals(75, props["battery_level"]?.toString()?.toIntOrNull())
+ }
+
+ @Test
+ fun nodesToFeatureCollection_batteryLevel_nullDefaultsToNegativeOne() {
+ // Default DeviceMetrics has null battery_level — should map to -1 sentinel
+ val node = Node(num = 1, position = Position(latitude_i = 400000000, longitude_i = -740000000))
+ val result = nodesToFeatureCollection(listOf(node))
+ val props = result.features.first().properties
+ assertEquals(-1, props["battery_level"]?.toString()?.toIntOrNull())
+ }
+
@Test
fun nodesToFeatureCollection_multipleNodes() {
val nodes =