feat(map): online status indicators, zoom-to-fit-all, GeoJSON enrichment

- Add is_online and battery_level properties to node GeoJSON features
- Node marker strokes now show green (online) or gray (offline) using
  switch/condition expressions on the is_online boolean property
- Node labels display a colored status dot (●) via format/span rich text
- Add 'Zoom to Fit All Nodes' action in filter dropdown menu, computing
  bounding box from filteredNodes and animating camera with animateTo()
- Add 4 new GeoJSON converter tests for is_online and battery_level
This commit is contained in:
James Rich
2026-04-13 21:55:48 -05:00
parent 4be30d229f
commit 144622461f
6 changed files with 104 additions and 4 deletions

View File

@@ -836,6 +836,7 @@
<string name="only_favorites">Only Favorites</string>
<string name="show_waypoints">Show Waypoints</string>
<string name="show_precision_circle">Show Precision Circles</string>
<string name="zoom_to_fit_all">Zoom to Fit All Nodes</string>
<string name="client_notification">Client Notification</string>
<string name="key_verification_title">Key Verification</string>
<string name="key_verification_request_title">Key Verification Request</string>

View File

@@ -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 = {

View File

@@ -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),
)
},
)
}
}

View File

@@ -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),

View File

@@ -45,6 +45,8 @@ internal fun nodesToFeatureCollection(nodes: List<Node>, 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())

View File

@@ -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 =