From 144622461fbabecb0872baeb39d9d4e35d50999f Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 21:55:48 -0500 Subject: [PATCH] feat(map): online status indicators, zoom-to-fit-all, GeoJSON enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../composeResources/values/strings.xml | 1 + .../org/meshtastic/feature/map/MapScreen.kt | 11 +++++ .../map/component/MapFilterDropdown.kt | 17 +++++++ .../map/component/MaplibreMapContent.kt | 30 ++++++++++-- .../feature/map/util/GeoJsonConverters.kt | 2 + .../feature/map/util/GeoJsonConvertersTest.kt | 47 +++++++++++++++++++ 6 files changed, 104 insertions(+), 4 deletions(-) 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 =