mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 00:28:20 -04:00
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:
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user