From 4c597f553320cd75bded54162185270a351f80b1 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sat, 17 Oct 2026 13:41:48 -0500
Subject: [PATCH 01/31] refactor(map): move the node map filter rules to
core:model
The map's node filters become a plain NodeMapFilter predicate over Node, so a view outside feature:map can apply the
same rules. MapNodePolicy delegates to it, and MapFilterPrefs converts to it directly.
---
.../meshtastic/core/model/NodeMapFilter.kt | 88 +++++++++++++++++++
.../core/repository/NodeFilterPrefs.kt | 17 ++++
.../feature/map/BaseMapViewModel.kt | 15 ++++
.../meshtastic/feature/map/MapNodePolicy.kt | 66 ++------------
4 files changed, 126 insertions(+), 60 deletions(-)
create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeMapFilter.kt
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeMapFilter.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeMapFilter.kt
new file mode 100644
index 00000000000..448ffc72bda
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeMapFilter.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model
+
+import org.meshtastic.core.model.util.ONLINE_WINDOW_SECONDS
+import org.meshtastic.proto.Config
+
+/**
+ * The node-level map filters, shared by every view that draws nodes over a place: both map renderers and the camera
+ * mesh view. [lastHeardSeconds] of 0 means no limit.
+ */
+data class NodeMapFilter(
+ val onlyFavorites: Boolean = false,
+ val showIgnored: Boolean = false,
+ /** Roles the user has switched off. Excluded rather than included so a role added by future firmware shows. */
+ val excludedRoles: Set = emptySet(),
+ val includeUnknown: Boolean = true,
+ val onlyOnline: Boolean = false,
+ val onlyDirect: Boolean = false,
+ val excludeMqtt: Boolean = false,
+ val lastHeardSeconds: Long = 0,
+ val onlySigned: Boolean = false,
+ val onlyEncrypted: Boolean = false,
+)
+
+/**
+ * One node against every filter.
+ *
+ * The node-level rules match the node list's query exactly, because they carry its labels: direct means `hopsAway == 0`
+ * (a node whose distance was never measured, -1, is not direct) and unknown means a node that has not sent a short
+ * name.
+ *
+ * [NodeMapFilter.showIgnored] is the one place the map deliberately differs. The node list segregates (`isIgnored ==
+ * showIgnored`, so switching it on shows *only* ignored nodes); here it reads literally and adds them, because a map of
+ * nothing but ignored nodes is not a view anyone asked for.
+ */
+fun Node.passesMapFilter(filter: NodeMapFilter, nowSeconds: Long): Boolean {
+ val secondsSinceHeard = nowSeconds - lastHeard
+ return passesIdentityFilters(filter) &&
+ passesSecurityFilters(filter) &&
+ passesReachabilityFilters(filter, secondsSinceHeard)
+}
+
+/**
+ * Resolves persisted role names back to roles, dropping any the current protobufs no longer define.
+ *
+ * Names rather than ordinals, and forgiving of names it does not know: a set written by a newer build (or a role
+ * removed from the protos) must not throw on the way back in.
+ */
+fun decodeExcludedRoles(names: Set): Set =
+ names.mapNotNullTo(mutableSetOf()) { name -> Config.DeviceConfig.Role.entries.firstOrNull { it.name == name } }
+
+/** Who the node is: favourited, ignored, its role, and whether its name has arrived yet. */
+private fun Node.passesIdentityFilters(filter: NodeMapFilter): Boolean = (!filter.onlyFavorites || isFavorite) &&
+ (!isIgnored || filter.showIgnored) &&
+ user.role !in filter.excludedRoles &&
+ (filter.includeUnknown || user.short_name.isNotEmpty())
+
+/** How the node is reachable: online, hop count, bearer, and how recently it was heard. */
+private fun Node.passesReachabilityFilters(filter: NodeMapFilter, secondsSinceHeard: Long): Boolean =
+ (!filter.onlyOnline || secondsSinceHeard <= ONLINE_WINDOW_SECONDS) &&
+ (!filter.onlyDirect || (hopsAway == 0 && !viaMqtt)) &&
+ (!filter.excludeMqtt || !viaMqtt) &&
+ (filter.lastHeardSeconds == 0L || secondsSinceHeard <= filter.lastHeardSeconds)
+
+/**
+ * The two security filters (design#149).
+ *
+ * Signing reads the raw flag rather than the version-gated indicator: the filter answers "has the radio verified this
+ * node's signature", which is what the badge beside it claims. Encrypted excludes a stored key that stopped matching,
+ * since a mismatch is not a key you can safely encrypt to.
+ */
+private fun Node.passesSecurityFilters(filter: NodeMapFilter): Boolean =
+ (!filter.onlySigned || signsPackets) && (!filter.onlyEncrypted || (hasPKC && !mismatchKey))
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeFilterPrefs.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeFilterPrefs.kt
index 026ba7aa623..a1ff64e52bd 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeFilterPrefs.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeFilterPrefs.kt
@@ -16,6 +16,9 @@
*/
package org.meshtastic.core.repository
+import org.meshtastic.core.model.NodeMapFilter
+import org.meshtastic.core.model.decodeExcludedRoles
+
/**
* The stored node-list filters, as one value.
*
@@ -68,3 +71,17 @@ data class MapFilterPrefs(
*/
val excludedRoles: Set = emptySet(),
)
+
+/** The node-level part of these filters, for views that draw nodes without the map's filter sheet. */
+fun MapFilterPrefs.toNodeMapFilter() = NodeMapFilter(
+ onlyFavorites = onlyFavorites,
+ showIgnored = showIgnored,
+ excludedRoles = decodeExcludedRoles(excludedRoles),
+ includeUnknown = includeUnknown,
+ onlyOnline = onlyOnline,
+ onlyDirect = onlyDirect,
+ excludeMqtt = excludeMqtt,
+ lastHeardSeconds = lastHeardSeconds,
+ onlySigned = onlySigned,
+ onlyEncrypted = onlyEncrypted,
+)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
index 0e45c88f16d..01328ac1a7d 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
@@ -30,7 +30,9 @@ import org.meshtastic.core.model.ContactKey
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.core.model.NodeMapFilter
import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.core.model.decodeExcludedRoles
import org.meshtastic.core.model.geofence.activeWaypointPackets
import org.meshtastic.core.model.isFromLocal
import org.meshtastic.core.network.repository.NetworkRepository
@@ -264,6 +266,19 @@ open class BaseMapViewModel(
excludeMqtt ||
!includeUnknown ||
lastHeardFilter != LastHeardFilter.Any
+
+ fun toNodeMapFilter() = NodeMapFilter(
+ onlyFavorites = onlyFavorites,
+ showIgnored = showIgnored,
+ excludedRoles = excludedRoles,
+ includeUnknown = includeUnknown,
+ onlyOnline = onlyOnline,
+ onlyDirect = onlyDirect,
+ excludeMqtt = excludeMqtt,
+ lastHeardSeconds = lastHeardFilter.seconds,
+ onlySigned = onlySigned,
+ onlyEncrypted = onlyEncrypted,
+ )
}
// One flow, one map: the store hands every filter back as a single value, so there is nothing to recombine.
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt
index 59689b0527f..230c21e5bb8 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt
@@ -17,8 +17,7 @@
package org.meshtastic.feature.map
import org.meshtastic.core.model.Node
-import org.meshtastic.core.model.util.ONLINE_WINDOW_SECONDS
-import org.meshtastic.proto.Config
+import org.meshtastic.core.model.passesMapFilter
/**
* Which nodes a map shows, and which of them draws on top.
@@ -62,63 +61,10 @@ object MapNodePolicy {
filterState: BaseMapViewModel.MapFilterState,
nowSeconds: Long,
myNodeNum: Int?,
- ): List = nodes.filter { node ->
- node.validPosition != null && (node.num == myNodeNum || node.passesFilters(filterState, nowSeconds))
+ ): List {
+ val filter = filterState.toNodeMapFilter()
+ return nodes.filter { node ->
+ node.validPosition != null && (node.num == myNodeNum || node.passesMapFilter(filter, nowSeconds))
+ }
}
-
- /**
- * One node against every filter.
- *
- * The node-level rules match the node list's query exactly, because they carry its labels: direct means `hopsAway
- * == 0` — a node whose distance was never measured (-1) is not direct — and unknown means a node that has not sent
- * a short name.
- *
- * [BaseMapViewModel.MapFilterState.showIgnored] is the one place the map deliberately differs. The node list
- * segregates (`isIgnored == showIgnored`, so switching it on shows *only* ignored nodes); here it reads literally
- * and adds them, because a map of nothing but ignored nodes is not a view anyone asked for.
- */
- private fun Node.passesFilters(filterState: BaseMapViewModel.MapFilterState, nowSeconds: Long): Boolean {
- val secondsSinceHeard = nowSeconds - lastHeard
- return passesIdentityFilters(filterState) &&
- passesSecurityFilters(filterState) &&
- passesReachabilityFilters(filterState, secondsSinceHeard)
- }
-
- /** Who the node is: favourited, ignored, its role, and whether its name has arrived yet. */
- private fun Node.passesIdentityFilters(filterState: BaseMapViewModel.MapFilterState): Boolean =
- (!filterState.onlyFavorites || isFavorite) &&
- (!isIgnored || filterState.showIgnored) &&
- user.role !in filterState.excludedRoles &&
- (filterState.includeUnknown || user.short_name.isNotEmpty())
-
- /** How the node is reachable: online, hop count, bearer, and how recently it was heard. */
- private fun Node.passesReachabilityFilters(
- filterState: BaseMapViewModel.MapFilterState,
- secondsSinceHeard: Long,
- ): Boolean {
- val cutoff = filterState.lastHeardFilter.seconds
- return (!filterState.onlyOnline || secondsSinceHeard <= ONLINE_WINDOW_SECONDS) &&
- (!filterState.onlyDirect || (hopsAway == 0 && !viaMqtt)) &&
- (!filterState.excludeMqtt || !viaMqtt) &&
- (cutoff == LastHeardFilter.Any.seconds || secondsSinceHeard <= cutoff)
- }
-
- /**
- * The two security filters (design#149).
- *
- * Signing reads the raw flag rather than the version-gated indicator: the filter answers "has the radio verified
- * this node's signature", which is what the badge beside it claims. Encrypted excludes a stored key that stopped
- * matching — a mismatch is not a key you can safely encrypt to.
- */
- private fun Node.passesSecurityFilters(filterState: BaseMapViewModel.MapFilterState): Boolean =
- (!filterState.onlySigned || signsPackets) && (!filterState.onlyEncrypted || (hasPKC && !mismatchKey))
}
-
-/**
- * Resolves persisted role names back to roles, dropping any the current protobufs no longer define.
- *
- * Names rather than ordinals, and forgiving of names it does not know: a set written by a newer build (or a role
- * removed from the protos) must not throw on the way back in.
- */
-fun decodeExcludedRoles(names: Set): Set =
- names.mapNotNullTo(mutableSetOf()) { name -> Config.DeviceConfig.Role.entries.firstOrNull { it.name == name } }
From a4c06e0107e5c26b5ea5e52fff6c4eb9c2c238b9 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sat, 17 Oct 2026 15:13:00 -0500
Subject: [PATCH 02/31] refactor(core): move the phone location and magnetic
field providers to core
The compass's phone location and declination providers become core:repository interfaces with their Android
implementations in core:data, so a view outside feature:node can read the phone's fix and true north the same way.
---
.../core/data/location}/AndroidMagneticFieldProvider.kt | 3 ++-
.../core/data/location}/AndroidPhoneLocationProvider.kt | 5 ++++-
.../meshtastic/core/repository}/MagneticFieldProvider.kt | 2 +-
.../meshtastic/core/repository}/PhoneLocationProvider.kt | 2 +-
.../kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt | 4 ++--
.../main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt | 6 +++---
.../org/meshtastic/feature/node/compass/CompassViewModel.kt | 3 +++
.../meshtastic/feature/node/compass/CompassViewModelTest.kt | 4 ++++
.../feature/node/detail/NodeDetailCompassLifecycleTest.kt | 6 +++---
9 files changed, 23 insertions(+), 12 deletions(-)
rename {feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass => core/data/src/androidMain/kotlin/org/meshtastic/core/data/location}/AndroidMagneticFieldProvider.kt (91%)
rename {feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass => core/data/src/androidMain/kotlin/org/meshtastic/core/data/location}/AndroidPhoneLocationProvider.kt (97%)
rename {feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass => core/repository/src/commonMain/kotlin/org/meshtastic/core/repository}/MagneticFieldProvider.kt (94%)
rename {feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass => core/repository/src/commonMain/kotlin/org/meshtastic/core/repository}/PhoneLocationProvider.kt (96%)
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidMagneticFieldProvider.kt b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidMagneticFieldProvider.kt
similarity index 91%
rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidMagneticFieldProvider.kt
rename to core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidMagneticFieldProvider.kt
index e6657082e3b..ad0d473ebff 100644
--- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidMagneticFieldProvider.kt
+++ b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidMagneticFieldProvider.kt
@@ -14,10 +14,11 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.node.compass
+package org.meshtastic.core.data.location
import android.hardware.GeomagneticField
import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.MagneticFieldProvider
@Single
class AndroidMagneticFieldProvider : MagneticFieldProvider {
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidPhoneLocationProvider.kt b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
similarity index 97%
rename from feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidPhoneLocationProvider.kt
rename to core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
index f869249960c..63073623c86 100644
--- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/compass/AndroidPhoneLocationProvider.kt
+++ b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.node.compass
+package org.meshtastic.core.data.location
import android.annotation.SuppressLint
import android.content.Context
@@ -31,6 +31,9 @@ import kotlinx.coroutines.flow.flowOn
import org.koin.core.annotation.Single
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.repository.PhoneLocation
+import org.meshtastic.core.repository.PhoneLocationProvider
+import org.meshtastic.core.repository.PhoneLocationState
@Single
class AndroidPhoneLocationProvider(private val context: Context, private val dispatchers: CoroutineDispatchers) :
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/MagneticFieldProvider.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MagneticFieldProvider.kt
similarity index 94%
rename from feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/MagneticFieldProvider.kt
rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MagneticFieldProvider.kt
index fce026b4237..aabe17dfc0c 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/MagneticFieldProvider.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MagneticFieldProvider.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.node.compass
+package org.meshtastic.core.repository
interface MagneticFieldProvider {
fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/PhoneLocationProvider.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PhoneLocationProvider.kt
similarity index 96%
rename from feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/PhoneLocationProvider.kt
rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PhoneLocationProvider.kt
index 76eee099695..2b86842bd00 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/PhoneLocationProvider.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PhoneLocationProvider.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.node.compass
+package org.meshtastic.core.repository
import kotlinx.coroutines.flow.Flow
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt
index 6882a7da060..6dd9042e441 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt
@@ -20,8 +20,10 @@ import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.LocationRepository
+import org.meshtastic.core.repository.MagneticFieldProvider
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshWorkerManager
+import org.meshtastic.core.repository.PhoneLocationProvider
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
@@ -32,8 +34,6 @@ import org.meshtastic.desktop.stub.NoopMeshWorkerManager
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
import org.meshtastic.feature.node.compass.CompassHeadingProvider
-import org.meshtastic.feature.node.compass.MagneticFieldProvider
-import org.meshtastic.feature.node.compass.PhoneLocationProvider
/**
* Stubs for interfaces whose only real implementation needs Android APIs — WorkManager, widgets, location, sensors and
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
index b0761522de4..da7f9d621ac 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
@@ -18,11 +18,11 @@ package org.meshtastic.desktop.stub
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
+import org.meshtastic.core.repository.MagneticFieldProvider
+import org.meshtastic.core.repository.PhoneLocationProvider
+import org.meshtastic.core.repository.PhoneLocationState
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.HeadingState
-import org.meshtastic.feature.node.compass.MagneticFieldProvider
-import org.meshtastic.feature.node.compass.PhoneLocationProvider
-import org.meshtastic.feature.node.compass.PhoneLocationState
/** No-op [CompassHeadingProvider] — desktop has no compass sensor. */
class NoopCompassHeadingProvider : CompassHeadingProvider {
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
index 390109b9e62..edc8223ad04 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
@@ -36,6 +36,9 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.precisionRadiusMetersOrNull
import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.repository.MagneticFieldProvider
+import org.meshtastic.core.repository.PhoneLocationProvider
+import org.meshtastic.core.repository.PhoneLocationState
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.proto.Position
import kotlin.math.abs
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt
index 2ecd57d4125..47c271204ff 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt
@@ -33,6 +33,10 @@ import kotlinx.coroutines.test.setMain
import org.meshtastic.core.common.util.MeasurementSystem
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.MagneticFieldProvider
+import org.meshtastic.core.repository.PhoneLocation
+import org.meshtastic.core.repository.PhoneLocationProvider
+import org.meshtastic.core.repository.PhoneLocationState
import org.meshtastic.proto.User
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
diff --git a/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailCompassLifecycleTest.kt b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailCompassLifecycleTest.kt
index a5949c42aa0..1fc92e28205 100644
--- a/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailCompassLifecycleTest.kt
+++ b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailCompassLifecycleTest.kt
@@ -51,7 +51,10 @@ import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCas
import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.SessionStatus
+import org.meshtastic.core.repository.MagneticFieldProvider
import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.PhoneLocationProvider
+import org.meshtastic.core.repository.PhoneLocationState
import org.meshtastic.core.repository.QueryController
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
@@ -62,9 +65,6 @@ import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.compass.HeadingState
-import org.meshtastic.feature.node.compass.MagneticFieldProvider
-import org.meshtastic.feature.node.compass.PhoneLocationProvider
-import org.meshtastic.feature.node.compass.PhoneLocationState
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.Position
From 1fc0189afc13e2621c4ed3300e0268a619329b10 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sat, 17 Oct 2026 15:13:10 -0500
Subject: [PATCH 03/31] feat(map): AR mesh view
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Point the back camera at the horizon and see the mesh in place: nodes as their chips and waypoints as their emoji,
by bearing and elevation, over a CameraX preview. It opens from the map for everything in range, from node detail and
the compass for one node, from a traceroute's map to draw that route in the air, and from the position log to draw
that node's track.
The pose is the rotation vector's camera axes, smoothed with a 1€ filter, and the field of view comes from the lens
calibration and the stream crop. Elevation compares altitudes in one frame. Precision draws as a fading zone from both
positions' accuracy and the heading error. The phone's own fix is averaged while it is still and shown live, and
targets too close to point at go on a heading-up radar. Off-screen targets pin to the edge to turn or tilt toward, a
focused target gets a reticle, a turn hint and a haptic lock, and dragging the view corrects the compass. Traceroutes
draw as lifted arcs, forward up and return down, weighted by each link's SNR; tracks as breadcrumbs fading with age;
geofences as walls on the ground. It follows the map's filters and a range toggle.
The compass shares the mesh view's position uncertainty and fix time rules.
---
.skills/compose-ui/strings-index.txt | 29 +
.../kotlin/org/meshtastic/app/MainActivity.kt | 4 +-
.../app/ui/NavigationAssemblyTest.kt | 4 +-
.../location/AndroidPhoneLocationProvider.kt | 53 +-
.../core/model/util/PositionPrecision.kt | 30 +
.../org/meshtastic/core/navigation/Routes.kt | 14 +
.../core/navigation/NavigationConfigTest.kt | 1 +
.../core/repository/PhoneLocationProvider.kt | 17 +-
.../drawable/ic_view_in_ar.xml | 10 +
.../composeResources/values/strings.xml | 29 +
core/ui/detekt-baseline.xml | 2 +-
.../core/ui/util/MeshHudSupport.android.kt | 35 ++
.../org/meshtastic/core/ui/icon/Hardware.kt | 7 +
.../ui/util/LocalMapMainScreenProvider.kt | 3 +-
.../meshtastic/core/ui/util/MeshHudSupport.kt | 22 +
.../core/ui/util/MeshHudSupport.ios.kt | 21 +
.../core/ui/util/MeshHudSupport.jvm.kt | 21 +
.../kotlin/org/meshtastic/desktop/Main.kt | 2 +-
.../desktop/di/DesktopStubsModule.kt | 4 +
.../meshtastic/desktop/stub/CompassStubs.kt | 7 +
feature/map/build.gradle.kts | 8 +
.../map/hud/AndroidCameraPoseProvider.kt | 140 +++++
.../map/hud/HudCameraPreview.android.kt | 138 +++++
.../org/meshtastic/feature/map/MapScreen.kt | 18 +-
.../feature/map/hud/CameraPoseProvider.kt | 36 ++
.../meshtastic/feature/map/hud/FixAverager.kt | 81 +++
.../feature/map/hud/HudCameraPreview.kt | 64 ++
.../feature/map/hud/HudChipLayer.kt | 283 +++++++++
.../meshtastic/feature/map/hud/HudChips.kt | 117 ++++
.../meshtastic/feature/map/hud/HudEdges.kt | 77 +++
.../meshtastic/feature/map/hud/HudGeometry.kt | 166 +++++
.../meshtastic/feature/map/hud/HudOverlay.kt | 245 ++++++++
.../meshtastic/feature/map/hud/HudPaths.kt | 193 ++++++
.../feature/map/hud/HudPathsDraw.kt | 162 +++++
.../meshtastic/feature/map/hud/HudRadar.kt | 192 ++++++
.../meshtastic/feature/map/hud/HudTargets.kt | 331 ++++++++++
.../feature/map/hud/MeshHudScreen.kt | 570 ++++++++++++++++++
.../feature/map/hud/MeshHudViewModel.kt | 359 +++++++++++
.../map/hud/OneEuroQuaternionFilter.kt | 75 +++
.../feature/map/navigation/MapNavigation.kt | 16 +
.../feature/map/hud/FixAveragerTest.kt | 74 +++
.../feature/map/hud/HudGeometryTest.kt | 214 +++++++
.../feature/map/hud/HudPathsTest.kt | 124 ++++
.../feature/map/hud/HudRadarTest.kt | 56 ++
.../feature/map/hud/HudTargetsTest.kt | 169 ++++++
.../map/hud/OneEuroQuaternionFilterTest.kt | 69 +++
.../feature/map/hud/HudCameraPreview.ios.kt | 22 +
.../feature/map/hud/HudCameraPreview.jvm.kt | 22 +
.../feature/node/compass/CompassViewModel.kt | 38 +-
.../node/component/CompassBottomSheet.kt | 13 +
.../feature/node/component/PositionSection.kt | 20 +
.../feature/node/detail/NodeDetailScreens.kt | 13 +
.../node/metrics/PositionLogScreens.kt | 17 +-
.../node/metrics/TracerouteMapScreen.kt | 18 +-
.../node/navigation/NodesNavigation.kt | 36 +-
55 files changed, 4443 insertions(+), 48 deletions(-)
create mode 100644 core/resources/src/commonMain/composeResources/drawable/ic_view_in_ar.xml
create mode 100644 core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.android.kt
create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.kt
create mode 100644 core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.ios.kt
create mode 100644 core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.jvm.kt
create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.android.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/FixAverager.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudEdges.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPaths.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilter.kt
create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/FixAveragerTest.kt
create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudGeometryTest.kt
create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt
create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudRadarTest.kt
create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt
create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilterTest.kt
create mode 100644 feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.ios.kt
create mode 100644 feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.jvm.kt
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 4015a3c5fff..0eeb6ec5371 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1096,6 +1096,35 @@ mesh_beacon_target_channel_index
mesh_beacon_target_default
mesh_beacon_target_remove
mesh_beacon_targets
+mesh_hud_aligned
+mesh_hud_camera_rationale
+mesh_hud_cardinal_east
+mesh_hud_cardinal_north
+mesh_hud_cardinal_south
+mesh_hud_cardinal_west
+mesh_hud_distance_no_altitude
+mesh_hud_drag_to_align
+mesh_hud_elevation_approximate
+mesh_hud_find
+mesh_hud_heading
+mesh_hud_heading_accuracy
+mesh_hud_heading_low_accuracy
+mesh_hud_inside_geofence
+mesh_hud_inside_unnamed_geofence
+mesh_hud_nearby
+mesh_hud_no_nodes
+mesh_hud_on_target
+mesh_hud_open
+mesh_hud_open_node
+mesh_hud_own_accuracy
+mesh_hud_range_all
+mesh_hud_range_within
+mesh_hud_range_within_hidden
+mesh_hud_tilt_down
+mesh_hud_tilt_up
+mesh_hud_turn_left
+mesh_hud_turn_right
+mesh_hud_waiting_sensor
mesh_map_location
mesh_map_location_description
### MESHTASTIC ###
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 827e1125935..e2e1fdbb9dc 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -103,6 +103,7 @@ import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.core.ui.util.accentColorOrNull
import org.meshtastic.core.ui.util.brandHighlightOrNull
import org.meshtastic.core.ui.util.brandPalette
+import org.meshtastic.core.ui.util.rememberMeshHudSupported
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.intro.AppIntroductionScreen
@@ -296,7 +297,7 @@ class MainActivity : AppCompatActivity() {
org.meshtastic.app.map.discovery.DiscoveryMap(userLat, userLon, nodes, modifier)
},
LocalMapMainScreenProvider provides
- { onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum ->
+ { onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum, onOpenMeshHud ->
val viewModel = koinViewModel()
MapScreen(
viewModel = viewModel,
@@ -304,6 +305,7 @@ class MainActivity : AppCompatActivity() {
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
sitePlannerNodeNum = sitePlannerNodeNum,
+ onOpenMeshHud = onOpenMeshHud.takeIf { rememberMeshHudSupported() },
)
},
content = content,
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
index 5f402a445ba..fbcadccf2b9 100644
--- a/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
@@ -81,7 +81,7 @@ class NavigationAssemblyTest {
val backStack = rememberNavBackStack(route)
CompositionLocalProvider(
LocalMapMainScreenProvider provides
- { _, _, waypointId, sitePlannerNodeNum ->
+ { _, _, waypointId, sitePlannerNodeNum, _ ->
SideEffect {
receivedWaypointId = waypointId
receivedSitePlannerNodeNum = sitePlannerNodeNum
@@ -113,7 +113,7 @@ class NavigationAssemblyTest {
backStack = rememberNavBackStack(route)
CompositionLocalProvider(
LocalMapMainScreenProvider provides
- { _, _, _, sitePlannerNodeNum ->
+ { _, _, _, sitePlannerNodeNum, _ ->
LaunchedEffect(sitePlannerNodeNum) { effectStarts += 1 }
DisposableEffect(Unit) { onDispose { disposals += 1 } }
},
diff --git a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
index 63073623c86..3b0abfee8e7 100644
--- a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
+++ b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
@@ -20,14 +20,18 @@ import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
+import android.location.altitude.AltitudeConverter
+import android.os.Build
import android.os.Looper
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
+import co.touchlab.kermit.Logger
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.di.CoroutineDispatchers
@@ -124,10 +128,55 @@ class AndroidPhoneLocationProvider(private val context: Context, private val dis
awaitClose { LocationManagerCompat.removeUpdates(locationManager, listener) }
}
+ .map { it.withMslAltitude() }
.flowOn(dispatchers.io)
- private fun Location.toPhoneLocation() =
- PhoneLocation(latitude = latitude, longitude = longitude, altitude = altitude, timeMillis = time)
+ private val altitudeConverter by lazy {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) AltitudeConverter() else null
+ }
+
+ /**
+ * Fills the sea-level height a provider left out, from the platform's geoid model (API 34+). It loads model data on
+ * first use and may block, which is why it runs here, upstream of the IO dispatcher, rather than in the callback.
+ */
+ @Suppress("ReturnCount")
+ private fun PhoneLocationState.withMslAltitude(): PhoneLocationState {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return this
+ val fix = location ?: return this
+ val ellipsoid = fix.altitude
+ val converter = altitudeConverter
+ if (fix.mslAltitude != null || ellipsoid == null || converter == null) return this
+ val probe =
+ Location("mesh-view").apply {
+ latitude = fix.latitude
+ longitude = fix.longitude
+ altitude = ellipsoid
+ }
+ @Suppress("TooGenericExceptionCaught")
+ return try {
+ converter.addMslAltitudeToLocation(context, probe)
+ copy(location = fix.copy(mslAltitude = probe.mslAltitudeMeters))
+ } catch (e: Exception) {
+ Logger.w(e) { "MSL altitude conversion failed" }
+ this
+ }
+ }
+
+ private fun Location.toPhoneLocation() = PhoneLocation(
+ latitude = latitude,
+ longitude = longitude,
+ altitude = if (hasAltitude()) altitude else null,
+ timeMillis = time,
+ mslAltitude =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && hasMslAltitude()) {
+ mslAltitudeMeters
+ } else {
+ null
+ },
+ accuracyM = if (hasAccuracy()) accuracy else null,
+ verticalAccuracyM = if (hasVerticalAccuracy()) verticalAccuracyMeters else null,
+ speedMps = if (hasSpeed()) speed else null,
+ )
companion object {
private const val MIN_UPDATE_INTERVAL_MS = 1_000L
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/PositionPrecision.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/PositionPrecision.kt
index ea2307e886a..e9d432c2bd3 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/PositionPrecision.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/PositionPrecision.kt
@@ -16,6 +16,10 @@
*/
package org.meshtastic.core.model.util
+import org.meshtastic.proto.Position
+import kotlin.math.pow
+import kotlin.math.sqrt
+
/**
* The bit depths firmware degrades a shared position to.
*
@@ -53,3 +57,29 @@ private val PRECISION_RADIUS_METERS =
18 to 91.182212,
19 to 45.58554,
)
+
+private const val DOP_SCALE = 100.0
+private const val MILLIMETERS_PER_METER = 1000.0
+
+/**
+ * Radius, in meters, within which a reported position lies: the receiver's accuracy scaled by its dilution of precision
+ * when both are reported, else the precision-bits circle, else null.
+ */
+fun Position.uncertaintyRadiusMetersOrNull(): Double? {
+ val dop =
+ when {
+ PDOP > 0 -> PDOP / DOP_SCALE
+ HDOP > 0 && VDOP > 0 -> sqrt((HDOP / DOP_SCALE).pow(2) + (VDOP / DOP_SCALE).pow(2))
+ HDOP > 0 -> HDOP / DOP_SCALE
+ else -> null
+ }
+ if (gps_accuracy > 0 && dop != null) return gps_accuracy / MILLIMETERS_PER_METER * dop
+ return precisionRadiusMetersOrNull(precision_bits)
+}
+
+/** Radius, in meters, of a reported altitude's uncertainty: the receiver's accuracy scaled by its vertical DOP. */
+fun Position.verticalUncertaintyMetersOrNull(): Double? =
+ if (gps_accuracy > 0 && VDOP > 0) gps_accuracy / MILLIMETERS_PER_METER * (VDOP / DOP_SCALE) else null
+
+/** When the fix was taken, in epoch seconds: the GPS timestamp when set, else the time the position was sent. */
+fun Position.fixTimeSecondsOrNull(): Long? = timestamp.takeIf { it > 0 }?.toLong() ?: time.takeIf { it > 0 }?.toLong()
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
index 0af0502add2..3c2430e03f1 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -55,6 +55,20 @@ sealed interface ContactsRoute : Route {
@Serializable
sealed interface MapRoute : Route {
@Serializable data class Map(val waypointId: Int? = null, val sitePlannerNodeNum: Int? = null) : MapRoute
+
+ /**
+ * The camera mesh view. [focusNodeNum] points the finder at one node. A traceroute ([tracerouteDestNum] with
+ * [tracerouteRequestId], and [tracerouteLogUuid] when it was logged) draws that route in the air, and
+ * [trackNodeNum] draws that node's position track.
+ */
+ @Serializable
+ data class MeshHud(
+ val focusNodeNum: Int? = null,
+ val tracerouteDestNum: Int? = null,
+ val tracerouteRequestId: Int? = null,
+ val tracerouteLogUuid: String? = null,
+ val trackNodeNum: Int? = null,
+ ) : MapRoute
}
@Serializable
diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt
index 5772e4a4ee5..fa6da61d9e1 100644
--- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt
+++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/NavigationConfigTest.kt
@@ -50,6 +50,7 @@ class NavigationConfigTest {
ContactsRoute.QuickChat,
// MapRoute
MapRoute.Map(),
+ MapRoute.MeshHud(focusNodeNum = 100, tracerouteDestNum = 100, tracerouteRequestId = 7, trackNodeNum = 100),
MapRoute.Map(waypointId = 42),
// NodesRoute
NodesRoute.Nodes,
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PhoneLocationProvider.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PhoneLocationProvider.kt
index 2b86842bd00..e587417a9f0 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PhoneLocationProvider.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PhoneLocationProvider.kt
@@ -18,7 +18,22 @@ package org.meshtastic.core.repository
import kotlinx.coroutines.flow.Flow
-data class PhoneLocation(val latitude: Double, val longitude: Double, val altitude: Double, val timeMillis: Long)
+/**
+ * [altitude] is height above the WGS84 ellipsoid, as Android reports it, and null when the fix has none (a network
+ * fix). [mslAltitude] is height above mean sea level, null when the platform cannot convert. [accuracyM] and
+ * [verticalAccuracyM] are the fix's accuracy radii, null when the platform reports none. [speedMps] is ground speed,
+ * null when unknown.
+ */
+data class PhoneLocation(
+ val latitude: Double,
+ val longitude: Double,
+ val altitude: Double?,
+ val timeMillis: Long,
+ val mslAltitude: Double? = null,
+ val accuracyM: Float? = null,
+ val verticalAccuracyM: Float? = null,
+ val speedMps: Float? = null,
+)
data class PhoneLocationState(
val permissionGranted: Boolean,
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_view_in_ar.xml b/core/resources/src/commonMain/composeResources/drawable/ic_view_in_ar.xml
new file mode 100644
index 00000000000..d2d37ed3317
--- /dev/null
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_view_in_ar.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index f13aa213829..37e179a6723 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1138,6 +1138,35 @@
Default
Remove target
Broadcast targets
+ Aligned %1$s · reset
+ Allow camera access to see nodes over the camera view.
+ E
+ N
+ S
+ W
+ %1$s · no altitude
+ Drag sideways to line a chip up with what you see
+ Elevation is approximate
+ Find
+ Heading %1$s
+ Heading %1$s ± %2$s
+ Low heading accuracy. Move the phone in a figure eight to calibrate.
+ Inside %1$s
+ Inside a geofence
+ Nearby
+ No nodes with a position
+ On target
+ Open mesh view
+ Open node
+ You ± %1$s
+ All distances
+ Within %1$s
+ Within %1$s · %2$d farther
+ Tilt down %1$d°
+ Tilt up %1$d°
+ Turn left %1$d°
+ Turn right %1$d°
+ Waiting for the orientation sensor
Mesh Map Location
Enables the blue location dot for your phone in the mesh map.
diff --git a/core/ui/detekt-baseline.xml b/core/ui/detekt-baseline.xml
index 8ce27952e14..91ee21b266d 100644
--- a/core/ui/detekt-baseline.xml
+++ b/core/ui/detekt-baseline.xml
@@ -18,7 +18,7 @@
CompositionLocalAllowlist:LocalBarcodeScannerProvider.kt:val LocalBarcodeScannerProvider = compositionLocalOf<@Composable (onResult: (String?) -> Unit) -> BarcodeScanner> { { object : BarcodeScanner { override fun startScan() { // Default NO-OP } } } }
CompositionLocalAllowlist:LocalBarcodeScannerProvider.kt:val LocalBarcodeScannerSupported = compositionLocalOf { false }
CompositionLocalAllowlist:LocalInlineMapProvider.kt:val LocalInlineMapProvider = compositionLocalOf<@Composable (node: Node, modifier: Modifier) -> Unit> { { _, _ -> } }
- CompositionLocalAllowlist:LocalMapMainScreenProvider.kt:/** * Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it * falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalMapMainScreenProvider = compositionLocalOf< @Composable ( onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?, sitePlannerNodeNum: Int?, ) -> Unit, > { { _, _, _, _ -> PlaceholderScreen("Map") } }
+ CompositionLocalAllowlist:LocalMapMainScreenProvider.kt:/** * Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it * falls back to a [PlaceholderScreen]. */ @Suppress("Wrapping") val LocalMapMainScreenProvider = compositionLocalOf< @Composable ( onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?, sitePlannerNodeNum: Int?, onOpenMeshHud: () -> Unit, ) -> Unit, > { { _, _, _, _, _ -> PlaceholderScreen("Map") } }
CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerProvider = compositionLocalOf<@Composable (onResult: (String?) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _ -> } }
CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcScannerSupported = compositionLocalOf { false }
CompositionLocalAllowlist:LocalNfcScannerProvider.kt:val LocalNfcWriterProvider = compositionLocalOf<@Composable (url: String, onResult: (Boolean) -> Unit, onNfcDisabled: () -> Unit) -> Unit> { { _, _, _ -> } }
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.android.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.android.kt
new file mode 100644
index 00000000000..7da31e9687f
--- /dev/null
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.android.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.util
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.hardware.Sensor
+import android.hardware.SensorManager
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+actual fun rememberMeshHudSupported(): Boolean {
+ val context = LocalContext.current
+ return remember(context) {
+ val sensors = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) &&
+ sensors?.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null
+ }
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt
index fb9634e63d9..4c664ea21ab 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt
@@ -34,15 +34,22 @@ import org.meshtastic.core.resources.ic_speaker_phone
import org.meshtastic.core.resources.ic_terminal
import org.meshtastic.core.resources.ic_usb
import org.meshtastic.core.resources.ic_usb_off
+import org.meshtastic.core.resources.ic_view_in_ar
import org.meshtastic.core.resources.ic_wifi
/** The maker hardware mark from meshtastic/design#160: a wrench, Material Symbols "build". */
val MeshtasticIcons.Wrench: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_build)
+
+/** Material Symbols "view_in_ar". */
+val MeshtasticIcons.ViewInAr: ImageVector
+ @Composable get() = vectorResource(Res.drawable.ic_view_in_ar)
+
val MeshtasticIcons.BluetoothConnected: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_bluetooth_connected)
val MeshtasticIcons.BluetoothSearching: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_bluetooth_searching)
+
val MeshtasticIcons.UsbOff: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_usb_off)
val MeshtasticIcons.Antenna: ImageVector
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt
index 70d02f4426f..4fe92da3a33 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalMapMainScreenProvider.kt
@@ -32,7 +32,8 @@ val LocalMapMainScreenProvider =
navigateToNodeDetails: (Int) -> Unit,
waypointId: Int?,
sitePlannerNodeNum: Int?,
+ onOpenMeshHud: () -> Unit,
) -> Unit,
> {
- { _, _, _, _ -> PlaceholderScreen("Map") }
+ { _, _, _, _, _ -> PlaceholderScreen("Map") }
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.kt
new file mode 100644
index 00000000000..2157c00639d
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.util
+
+import androidx.compose.runtime.Composable
+
+/** Whether this device can show the camera mesh view: it needs a back camera and an orientation sensor. */
+@Composable expect fun rememberMeshHudSupported(): Boolean
diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.ios.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.ios.kt
new file mode 100644
index 00000000000..2fff4f68cb5
--- /dev/null
+++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.ios.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.util
+
+import androidx.compose.runtime.Composable
+
+@Composable actual fun rememberMeshHudSupported(): Boolean = false
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.jvm.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.jvm.kt
new file mode 100644
index 00000000000..2fff4f68cb5
--- /dev/null
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.jvm.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.util
+
+import androidx.compose.runtime.Composable
+
+@Composable actual fun rememberMeshHudSupported(): Boolean = false
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 83b4a5f01bb..21d5d2020ff 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -477,7 +477,7 @@ private fun ApplicationScope.MeshtasticWindow(
// mapGraph renders the Map tab through this seam, not through LocalMapViewProvider
// directly; without it the tab falls back to the "Map" placeholder.
LocalMapMainScreenProvider provides
- { onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum ->
+ { onClickNodeChip, navigateToNodeDetails, waypointId, sitePlannerNodeNum, _ ->
MapScreen(
viewModel = koinViewModel(),
onClickNodeChip = onClickNodeChip,
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt
index 6dd9042e441..772b2aa9717 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt
@@ -26,6 +26,7 @@ import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.PhoneLocationProvider
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
+import org.meshtastic.desktop.stub.NoopCameraPoseProvider
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
import org.meshtastic.desktop.stub.NoopLocationRepository
import org.meshtastic.desktop.stub.NoopMagneticFieldProvider
@@ -33,6 +34,7 @@ import org.meshtastic.desktop.stub.NoopMeshLocationManager
import org.meshtastic.desktop.stub.NoopMeshWorkerManager
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
+import org.meshtastic.feature.map.hud.CameraPoseProvider
import org.meshtastic.feature.node.compass.CompassHeadingProvider
/**
@@ -57,4 +59,6 @@ class DesktopStubsModule {
@Single fun phoneLocationProvider(): PhoneLocationProvider = NoopPhoneLocationProvider()
@Single fun magneticFieldProvider(): MagneticFieldProvider = NoopMagneticFieldProvider()
+
+ @Single fun cameraPoseProvider(): CameraPoseProvider = NoopCameraPoseProvider()
}
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
index da7f9d621ac..546af73b0db 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt
@@ -21,6 +21,8 @@ import kotlinx.coroutines.flow.flowOf
import org.meshtastic.core.repository.MagneticFieldProvider
import org.meshtastic.core.repository.PhoneLocationProvider
import org.meshtastic.core.repository.PhoneLocationState
+import org.meshtastic.feature.map.hud.CameraPoseProvider
+import org.meshtastic.feature.map.hud.CameraPoseState
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.HeadingState
@@ -35,6 +37,11 @@ class NoopPhoneLocationProvider : PhoneLocationProvider {
flowOf(PhoneLocationState(permissionGranted = false, providerEnabled = false))
}
+/** No-op [CameraPoseProvider] — desktop has no orientation sensor or back camera. */
+class NoopCameraPoseProvider : CameraPoseProvider {
+ override fun poseUpdates(): Flow = flowOf(CameraPoseState(hasSensor = false))
+}
+
/** No-op [MagneticFieldProvider] — always returns zero declination. */
class NoopMagneticFieldProvider : MagneticFieldProvider {
override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f
diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts
index e9544ebebd6..f1c322244ef 100644
--- a/feature/map/build.gradle.kts
+++ b/feature/map/build.gradle.kts
@@ -45,6 +45,14 @@ kotlin {
implementation(libs.ktor.client.core)
}
+ // The camera mesh view's preview and lens intrinsics.
+ androidMain.dependencies {
+ implementation(libs.androidx.camera.camera2)
+ implementation(libs.androidx.camera.compose)
+ implementation(libs.androidx.camera.core)
+ implementation(libs.androidx.camera.lifecycle)
+ }
+
// Compose UI tests live in jvmTest, not commonTest: this module enables Android host tests, and
// `runComposeUiTest` has no Robolectric host there — the same tests NPE the moment they run under it.
jvmTest.dependencies {
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
new file mode 100644
index 00000000000..6edbbfce036
--- /dev/null
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import android.content.Context
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.hardware.display.DisplayManager
+import android.os.Handler
+import android.os.HandlerThread
+import android.view.Display
+import android.view.Surface
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import org.koin.core.annotation.Single
+import kotlin.math.max
+import kotlin.math.sqrt
+
+private const val QUATERNION_SIZE = 4
+private const val ROTATION_MATRIX_SIZE = 9
+
+private const val HEADING_ACCURACY_INDEX = 4
+private const val NINETY_FIVE_PERCENT_SIGMAS = 1.96
+
+@Single
+class AndroidCameraPoseProvider(private val context: Context) : CameraPoseProvider {
+
+ override fun poseUpdates(): Flow = callbackFlow {
+ val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
+ val rotationSensor = sensorManager?.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
+ if (sensorManager == null || rotationSensor == null) {
+ trySend(CameraPoseState(hasSensor = false))
+ awaitClose {}
+ return@callbackFlow
+ }
+ val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
+
+ val smoothed = FloatArray(QUATERNION_SIZE)
+ val sample = FloatArray(QUATERNION_SIZE)
+ val matrix = FloatArray(ROTATION_MATRIX_SIZE)
+ val filter = OneEuroQuaternionFilter()
+ // Some fusions (measured on a Pixel 9 Pro) report a fixed pi/6 here rather than an estimate, so the value is
+ // trusted only once it has moved.
+ var firstAccuracy: Float? = null
+ var accuracyIsLive = false
+ // Sensor callbacks get their own thread, away from the main thread that draws the overlay.
+ val thread = HandlerThread("mesh-view-pose").apply { start() }
+
+ val listener =
+ object : SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ event.toQuaternion(sample)
+ filter.filter(sample, event.timestamp, smoothed)
+ SensorManager.getRotationMatrixFromVector(matrix, smoothed)
+ val rotation = displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
+ trySend(
+ CameraPoseState(
+ pose = matrix.toCameraPose(rotation),
+ calibration = event.accuracy,
+ headingAccuracyDeg =
+ event.headingAccuracyRad()?.let { raw ->
+ if (firstAccuracy == null) firstAccuracy = raw
+ if (raw != firstAccuracy) accuracyIsLive = true
+ // The sensor's bound holds 95% of the time; scale it to the one-sigma figure
+ // every other accuracy on screen uses.
+ raw.takeIf { accuracyIsLive }
+ ?.let { Math.toDegrees(it.toDouble()) / NINETY_FIVE_PERCENT_SIGMAS }
+ },
+ ),
+ )
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
+ }
+
+ sensorManager.registerListener(
+ listener,
+ rotationSensor,
+ SensorManager.SENSOR_DELAY_GAME,
+ Handler(thread.looper),
+ )
+ awaitClose {
+ sensorManager.unregisterListener(listener)
+ thread.quitSafely()
+ }
+ }
+}
+
+/** Rotation-vector values as (x, y, z, w); older sensors omit w, which is recovered from the unit norm. */
+@Suppress("MagicNumber")
+private fun SensorEvent.toQuaternion(out: FloatArray) {
+ val x = values[0]
+ val y = values[1]
+ val z = values[2]
+ out[0] = x
+ out[1] = y
+ out[2] = z
+ out[3] = if (values.size > 3) values[3] else sqrt(max(0f, 1f - x * x - y * y - z * z))
+}
+
+/** The rotation vector's heading error estimate, which it reports in radians and as -1 when it has none. */
+private fun SensorEvent.headingAccuracyRad(): Float? = values.getOrNull(HEADING_ACCURACY_INDEX)?.takeIf { it >= 0f }
+
+/**
+ * The matrix maps device coordinates into east-north-up, so its columns are the device axes in that frame. The back
+ * camera looks down device -Z, and which device axis is the screen's right depends on the display rotation.
+ */
+@Suppress("MagicNumber")
+private fun FloatArray.toCameraPose(displayRotation: Int): CameraPose {
+ val deviceX = Vec3(this[0].toDouble(), this[3].toDouble(), this[6].toDouble())
+ val deviceY = Vec3(this[1].toDouble(), this[4].toDouble(), this[7].toDouble())
+ val forward = Vec3(-this[2].toDouble(), -this[5].toDouble(), -this[8].toDouble())
+ val (right, up) =
+ when (displayRotation) {
+ Surface.ROTATION_90 -> -deviceY to deviceX
+ Surface.ROTATION_180 -> -deviceX to -deviceY
+ Surface.ROTATION_270 -> deviceY to -deviceX
+ else -> deviceX to deviceY
+ }
+ return CameraPose(right = right, up = up, forward = forward)
+}
+
+private operator fun Vec3.unaryMinus() = Vec3(-x, -y, -z)
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.android.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.android.kt
new file mode 100644
index 00000000000..5f1f21c1a05
--- /dev/null
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.android.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import android.hardware.camera2.CameraCharacteristics
+import androidx.camera.camera2.interop.Camera2CameraInfo
+import androidx.camera.camera2.interop.ExperimentalCamera2Interop
+import androidx.camera.compose.CameraXViewfinder
+import androidx.camera.core.Camera
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.Preview
+import androidx.camera.core.SurfaceRequest
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import co.touchlab.kermit.Logger
+import kotlin.math.max
+import kotlin.math.min
+
+@Composable
+actual fun HudCameraPreview(onIntrinsics: (CameraIntrinsics?) -> Unit, modifier: Modifier) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val currentOnIntrinsics by rememberUpdatedState(onIntrinsics)
+ var surfaceRequest by remember { mutableStateOf(null) }
+
+ DisposableEffect(lifecycleOwner) {
+ var disposed = false
+ var provider: ProcessCameraProvider? = null
+ var sensorIntrinsics: CameraIntrinsics? = null
+ val preview = Preview.Builder().build()
+
+ // The stream CameraX picked, and its crop, decide how much of the sensor the view shows.
+ fun report() {
+ val info = preview.resolutionInfo
+ currentOnIntrinsics(
+ sensorIntrinsics?.let { base ->
+ info?.let {
+ base.forStream(
+ streamLong = max(it.resolution.width, it.resolution.height),
+ streamShort = min(it.resolution.width, it.resolution.height),
+ cropLong = max(it.cropRect.width(), it.cropRect.height()),
+ cropShort = min(it.cropRect.width(), it.cropRect.height()),
+ )
+ } ?: base
+ },
+ )
+ }
+ preview.setSurfaceProvider { request ->
+ if (disposed) {
+ request.willNotProvideSurface()
+ } else {
+ surfaceRequest = request
+ report()
+ }
+ }
+
+ val future = ProcessCameraProvider.getInstance(context)
+ future.addListener(
+ {
+ if (disposed) return@addListener
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ val bound = future.get()
+ provider = bound
+ val camera = bound.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview)
+ sensorIntrinsics = camera.intrinsics()
+ report()
+ } catch (e: Exception) {
+ Logger.w(e) { "Mesh view camera bind failed" }
+ currentOnIntrinsics(null)
+ }
+ },
+ ContextCompat.getMainExecutor(context),
+ )
+
+ onDispose {
+ disposed = true
+ surfaceRequest?.willNotProvideSurface()
+ surfaceRequest = null
+ provider?.unbind(preview)
+ }
+ }
+
+ // Crop, not fit: CameraIntrinsics.focalPx assumes the preview fills the view and overflows the long side.
+ surfaceRequest?.let {
+ CameraXViewfinder(surfaceRequest = it, modifier = modifier, contentScale = ContentScale.Crop)
+ }
+}
+
+/** Focal length and the active sensor area in millimetres, or null when the camera does not report them. */
+@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
+private fun Camera.intrinsics(): CameraIntrinsics? {
+ val info = Camera2CameraInfo.from(cameraInfo)
+ val focal = info.getCameraCharacteristic(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)?.firstOrNull()
+ val physical = info.getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE)
+ val pixels = info.getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE)
+ val active = info.getCameraCharacteristic(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)
+ if (focal == null || physical == null || pixels == null || active == null) return null
+ // The physical size covers the whole pixel array; the preview streams only the active area.
+ val width = physical.width * active.width() / pixels.width
+ val height = physical.height * active.height() / pixels.height
+ // A calibrated focal length, in pre-correction active-array pixels, beats the nominal one when the camera has it:
+ // on a logical multi-camera the first listed focal length need not be the lens in use.
+ val calibratedFx = info.getCameraCharacteristic(CameraCharacteristics.LENS_INTRINSIC_CALIBRATION)?.firstOrNull()
+ // Pixels to millimetres by the sensor's pixel pitch.
+ val nominalOrCalibrated = calibratedFx?.takeIf { it > 0f }?.let { it * physical.width / pixels.width } ?: focal
+ val zoom = cameraInfo.zoomState.value?.zoomRatio ?: 1f
+ return CameraIntrinsics(
+ focalLengthMm = nominalOrCalibrated * zoom,
+ sensorLongMm = max(width, height),
+ sensorShortMm = min(width, height),
+ )
+}
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 950d2716339..90ef447d761 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
@@ -18,6 +18,8 @@ package org.meshtastic.feature.map
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -26,7 +28,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map
+import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.ViewInAr
import org.meshtastic.core.ui.util.LocalMapViewProvider
@Composable
@@ -37,6 +42,8 @@ fun MapScreen(
viewModel: SharedMapViewModel,
waypointId: Int? = null,
sitePlannerNodeNum: Int? = null,
+ /** Null hides the mesh view button, for platforms with no camera. */
+ onOpenMeshHud: (() -> Unit)? = null,
) {
val ourNodeInfo by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
@@ -51,7 +58,16 @@ fun MapScreen(
showNodeChip = ourNodeInfo != null && isConnected,
canNavigateUp = false,
onNavigateUp = {},
- actions = {},
+ actions = {
+ onOpenMeshHud?.let { onClick ->
+ IconButton(onClick = onClick) {
+ Icon(
+ MeshtasticIcons.ViewInAr,
+ contentDescription = stringResource(Res.string.mesh_hud_open),
+ )
+ }
+ }
+ },
onClickChip = { onClickNodeChip(it.num) },
)
},
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt
new file mode 100644
index 00000000000..bcc48b8f051
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * [calibration] is the platform's sensor status, 3 high down to 0 unreliable, and -1 before the first reading.
+ * [headingAccuracyDeg] is the sensor's own estimate of its heading error, null when it gives none.
+ */
+data class CameraPoseState(
+ val pose: CameraPose? = null,
+ val hasSensor: Boolean = true,
+ val calibration: Int = CALIBRATION_UNKNOWN,
+ val headingAccuracyDeg: Double? = null,
+)
+
+const val CALIBRATION_UNKNOWN = -1
+
+interface CameraPoseProvider {
+ fun poseUpdates(): Flow
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/FixAverager.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/FixAverager.kt
new file mode 100644
index 00000000000..2e128b69be4
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/FixAverager.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import org.meshtastic.core.common.util.latLongToMeter
+import org.meshtastic.core.repository.PhoneLocation
+
+private const val WINDOW_MS = 30_000L
+
+/** Above this ground speed the user is walking, and old fixes describe somewhere they no longer are. */
+private const val MOVING_MPS = 0.7f
+
+/** A fix this many accuracy radii from the running mean is a real move, not noise. */
+private const val JUMP_RADII = 3.0
+
+/** Weight for a fix that reports no accuracy, as if it were this many meters. */
+private const val UNKNOWN_ACCURACY_M = 20f
+
+/**
+ * Steadies the phone's own position while it is held still: a mean of the last [WINDOW_MS] of fixes, each weighted by
+ * the inverse square of its accuracy, restarted whenever the phone moves. Bearings to nodes a few tens of meters away
+ * swing with every meter of GPS wander, so this is what keeps nearby chips from jumping.
+ *
+ * The reported accuracy is the best single fix's, not divided by the count: GPS error is correlated over this window,
+ * so averaging steadies the position without proving it is that much closer to the truth.
+ */
+internal class FixAverager {
+ private val fixes = ArrayDeque()
+
+ /** How many fixes the last result averaged. */
+ var count: Int = 0
+ private set
+
+ fun add(fix: PhoneLocation): PhoneLocation {
+ if (fixes.lastOrNull()?.timeMillis == fix.timeMillis && fixes.lastOrNull() == fix) return mean()
+ val moving = (fix.speedMps ?: 0f) > MOVING_MPS
+ val current = fixes.takeIf { it.isNotEmpty() }?.let { mean() }
+ val jumped =
+ current != null &&
+ latLongToMeter(current.latitude, current.longitude, fix.latitude, fix.longitude) >
+ JUMP_RADII * (fix.accuracyM ?: UNKNOWN_ACCURACY_M)
+ if (moving || jumped) fixes.clear()
+ fixes.addLast(fix)
+ while (fixes.size > 1 && fix.timeMillis - fixes.first().timeMillis > WINDOW_MS) fixes.removeFirst()
+ return mean()
+ }
+
+ private fun mean(): PhoneLocation {
+ val newest = fixes.last()
+ var weightSum = 0.0
+ var lat = 0.0
+ var lon = 0.0
+ fixes.forEach { f ->
+ val accuracy = (f.accuracyM ?: UNKNOWN_ACCURACY_M).toDouble().coerceAtLeast(1.0)
+ val w = 1 / (accuracy * accuracy)
+ weightSum += w
+ lat += w * f.latitude
+ lon += w * f.longitude
+ }
+ count = fixes.size
+ return newest.copy(
+ latitude = lat / weightSum,
+ longitude = lon / weightSum,
+ accuracyM = fixes.mapNotNull { it.accuracyM }.minOrNull(),
+ )
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.kt
new file mode 100644
index 00000000000..3dbdeb73777
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import kotlin.math.max
+import kotlin.math.tan
+
+/** Fallback when the camera reports no intrinsics: a typical phone main camera's horizontal field of view. */
+private const val FALLBACK_HORIZONTAL_FOV_DEG = 66.0
+
+/** The back camera's focal length and the size of the sensor area the preview shows, both in millimetres. */
+data class CameraIntrinsics(val focalLengthMm: Float, val sensorLongMm: Float, val sensorShortMm: Float) {
+ /**
+ * The sensor area a preview stream of [streamLong] by [streamShort] pixels shows after its [cropLong] by
+ * [cropShort] crop. The camera fills a stream whose aspect differs from the sensor's by cropping the sensor to that
+ * aspect about its center, so a 16:9 stream from a 4:3 sensor loses sensor height before any crop is applied.
+ */
+ fun forStream(streamLong: Int, streamShort: Int, cropLong: Int, cropShort: Int): CameraIntrinsics {
+ if (streamLong <= 0 || streamShort <= 0) return this
+ val streamAspect = streamLong.toFloat() / streamShort
+ val (coveredLong, coveredShort) =
+ if (streamAspect >= sensorLongMm / sensorShortMm) {
+ sensorLongMm to sensorLongMm / streamAspect
+ } else {
+ sensorShortMm * streamAspect to sensorShortMm
+ }
+ return copy(
+ sensorLongMm = coveredLong * cropLong.coerceIn(1, streamLong) / streamLong,
+ sensorShortMm = coveredShort * cropShort.coerceIn(1, streamShort) / streamShort,
+ )
+ }
+
+ /**
+ * Focal length in view pixels. The preview fills the view and crops the overflow, so the scale is the larger of the
+ * two axis ratios; the sensor's long side lies along the view's long side whatever the display rotation.
+ */
+ fun focalPx(viewWidth: Float, viewHeight: Float): Float {
+ val viewLong = max(viewWidth, viewHeight)
+ val viewShort = minOf(viewWidth, viewHeight)
+ return focalLengthMm * max(viewLong / sensorLongMm, viewShort / sensorShortMm)
+ }
+}
+
+fun fallbackFocalPx(viewWidth: Float, viewHeight: Float): Float =
+ (max(viewWidth, viewHeight) / 2 / tan(FALLBACK_HORIZONTAL_FOV_DEG.toRadians() / 2)).toFloat()
+
+/** The back camera's live preview, filling [modifier]; reports intrinsics once the camera is bound. */
+@Composable expect fun HudCameraPreview(onIntrinsics: (CameraIntrinsics?) -> Unit, modifier: Modifier = Modifier)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
new file mode 100644
index 00000000000..134d1acbea2
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
@@ -0,0 +1,283 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.text.TextMeasurer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.dp
+import kotlin.math.abs
+
+private const val OFFSCREEN_ALPHA = 0.7f
+private const val STEM_ALPHA = 0.8f
+private const val NEAR_SCALE_M = 1_000.0
+private const val MID_SCALE_M = 10_000.0
+private const val MID_SCALE = 0.9f
+private const val FAR_SCALE = 0.8f
+private val ChipGap = 4.dp
+
+/** Chips scale about their foot, so the stem stays on the true point. */
+private val BottomCenter = TransformOrigin(pivotFractionX = 0.5f, pivotFractionY = 1f)
+private val EdgeGutter = 28.dp
+private val ArrowSize = 10.dp
+private val AnchorRadius = 4.dp
+private val StemWidth = 2.dp
+private val StemEdge = Color.Black.copy(alpha = 0.4f)
+private val OverflowPadding = 6.dp
+private val OverflowBand = 28.dp
+private val OverflowScrim = Color.Black.copy(alpha = 0.55f)
+
+/** Where a chip went, so the draw pass can join it to its true point or point it toward the turn. */
+private class PlacedChip(val rect: IntRect, val anchor: Offset?, val edge: PinEdge?, val color: Color)
+
+/** A column of chips cut off by the top limit: its center and how many did not fit. */
+private class Overflow(val centerX: Int, var count: Int)
+
+/** Written by the placement pass and read by the draw pass of the same frame. */
+private class ChipPlacement {
+ val chips = mutableListOf()
+ val overflow = mutableListOf()
+
+ /** The edge each off-screen target was last pinned to; kept across frames, unlike the rest. */
+ val lastEdge = mutableMapOf()
+
+ fun clear() {
+ chips.clear()
+ overflow.clear()
+ }
+
+ fun addOverflow(centerX: Int, width: Int) {
+ overflow.firstOrNull { abs(it.centerX - centerX) < width }?.let { it.count++ }
+ ?: overflow.add(Overflow(centerX, 1))
+ }
+}
+
+/** Nearer chips draw larger, so depth reads at a glance without reading the distance label. */
+private fun distanceScale(distanceM: Double): Float = when {
+ distanceM < NEAR_SCALE_M -> 1f
+ distanceM < MID_SCALE_M -> MID_SCALE
+ else -> FAR_SCALE
+}
+
+/**
+ * Places each chip with its bottom center on the target's projected point, nearest first, lifting a chip above any
+ * earlier one it would overlap. The lift comes from [stackLevels], which works in view angles and so does not change as
+ * the phone moves. A lifted chip keeps a stem down to its true point. Chips that would leave the band between
+ * [topLimitPx] and [bottomLimitPx] are dropped into a "+N" count for their column. Targets out of view are pinned to
+ * the edge they lie beyond (see [pinEdge]) with an arrow toward them.
+ *
+ * The pose is read in the placement and draw blocks, so sensor updates relayout and redraw without recomposing.
+ */
+@Suppress("LongMethod", "CyclomaticComplexMethod")
+@Composable
+internal fun HudChipLayer(
+ targets: List,
+ currentPose: () -> CameraPose?,
+ focalPx: Float,
+ topLimitPx: () -> Int,
+ bottomLimitPx: () -> Int,
+ modifier: Modifier = Modifier,
+ chip: @Composable (HudTarget) -> Unit,
+) {
+ val placement = remember { ChipPlacement() }
+ val textMeasurer = rememberTextMeasurer()
+ val overflowStyle = MaterialTheme.typography.labelMedium.copy(color = Color.White)
+ Layout(
+ content = { targets.forEach { key(it.subject.key) { chip(it) } } },
+ modifier =
+ modifier.drawBehind {
+ if (currentPose() == null) return@drawBehind
+ placement.chips.forEach { drawStemOrArrow(it) }
+ placement.overflow.forEach { drawOverflow(it, topLimitPx(), textMeasurer, overflowStyle) }
+ },
+ ) { measurables, constraints ->
+ val placeables = measurables.map { it.measure(Constraints()) }
+ val width = constraints.maxWidth
+ val height = constraints.maxHeight
+ val gap = ChipGap.roundToPx()
+ val gutter = EdgeGutter.roundToPx()
+ val overflowBand = OverflowBand.roundToPx()
+ val keys = targets.mapTo(HashSet()) { it.subject.key }
+ val levels =
+ stackLevels(
+ targets.mapIndexed { i, target ->
+ AngularBox(
+ bearingDeg = target.bearingDeg,
+ elevationDeg = target.elevationDeg,
+ halfWidthDeg = pxToDeg(placeables[i].width, focalPx) / 2,
+ heightDeg = pxToDeg(placeables[i].height, focalPx),
+ )
+ },
+ gapDeg = pxToDeg(gap, focalPx),
+ )
+ layout(width, height) {
+ placement.clear()
+ placement.lastEdge.keys.retainAll(keys)
+ val pose = currentPose() ?: return@layout
+ // The overflow badges get their own row under the limit, so no chip is placed over them.
+ val topLimit = topLimitPx() + overflowBand
+ val bottomLimit = bottomLimitPx().takeIf { it > topLimit } ?: height
+ // Only the edge-pinned chips stack on screen; they sit at fixed places, so that holds still too.
+ val edgeOccupied = mutableListOf()
+ placeables.forEachIndexed { index, placeable ->
+ val target = targets[index]
+ val direction = directionEnu(target.bearingDeg, target.elevationDeg)
+ val projected = project(direction, pose, focalPx, width.toFloat(), height.toFloat())
+ val point = projected?.takeIf { it.x in 0f..width.toFloat() && it.y in 0f..height.toFloat() }
+ val key = target.subject.key
+ val yaw = signedTurnDeg(pose.azimuthDeg, target.bearingDeg)
+ val pitchDelta = target.elevationDeg - pose.pitchDeg
+ val edge =
+ if (point != null) {
+ null
+ } else {
+ pinEdge(projected, width.toFloat(), yaw, pitchDelta, placement.lastEdge[key]).also {
+ placement.lastEdge[key] = it
+ }
+ }
+ val rowHeight = placeable.height + gap
+ // Pinned chips spread along their edge by where the target lies, rather than piling in one place.
+ val along = edge?.let { edgeOffsetPx(it, yaw, pitchDelta, focalPx) } ?: 0f
+ val alongX =
+ (width / 2 + along.toInt() - placeable.width / 2).coerceIn(
+ gutter,
+ maxOf(gutter, width - placeable.width - gutter),
+ )
+ val alongY =
+ (height / 2 - along.toInt() - placeable.height / 2).coerceIn(
+ topLimit,
+ maxOf(topLimit, bottomLimit - placeable.height),
+ )
+ val left =
+ when {
+ point != null -> point.x.toInt() - placeable.width / 2
+ edge == PinEdge.RIGHT -> width - placeable.width - gutter
+ edge == PinEdge.LEFT -> gutter
+ else -> alongX
+ }
+ var top =
+ when {
+ point != null -> point.y.toInt() - placeable.height - levels[index] * rowHeight
+ edge == PinEdge.TOP -> topLimit + gutter
+ edge == PinEdge.BOTTOM -> bottomLimit - placeable.height - gutter
+ else -> alongY
+ }
+ // Top-pinned chips stack downward, away from the edge; every other kind stacks upward.
+ val step = if (edge == PinEdge.TOP) rowHeight else -rowHeight
+ var rect = IntRect(left, top, left + placeable.width, top + placeable.height)
+ while (edge != null && edgeOccupied.any { it.overlaps(rect) }) {
+ top += step
+ rect = IntRect(left, top, left + placeable.width, top + placeable.height)
+ }
+ if (rect.top < topLimit || rect.bottom > bottomLimit) {
+ placement.addOverflow(rect.center.x, placeable.width)
+ return@forEachIndexed
+ }
+ if (edge != null) edgeOccupied += rect
+ placement.chips +=
+ PlacedChip(rect, point?.let { Offset(it.x, it.y) }, edge, Color(target.subject.color))
+ val scale = distanceScale(target.distanceM)
+ placeable.placeWithLayer(left, top) {
+ alpha = if (edge == null) 1f else OFFSCREEN_ALPHA
+ scaleX = scale
+ scaleY = scale
+ transformOrigin = BottomCenter
+ }
+ }
+ }
+ }
+}
+
+private fun DrawScope.drawStemOrArrow(chip: PlacedChip) {
+ chip.anchor?.let { anchor ->
+ val foot = Offset(chip.rect.center.x.toFloat(), chip.rect.bottom.toFloat())
+ // In the node's color, over a dark edge so it holds up against a bright sky.
+ drawLine(StemEdge, foot, anchor, strokeWidth = StemWidth.toPx() + 2.dp.toPx())
+ drawLine(chip.color.copy(alpha = STEM_ALPHA), foot, anchor, strokeWidth = StemWidth.toPx())
+ drawCircle(Color.White, radius = AnchorRadius.toPx() + 1.dp.toPx(), center = anchor)
+ drawCircle(chip.color, radius = AnchorRadius.toPx(), center = anchor)
+ }
+ chip.edge?.let { drawEdgeArrow(chip.rect, it) }
+}
+
+/** A triangle just outside [rect], pointing past the edge the target lies beyond. */
+private fun DrawScope.drawEdgeArrow(rect: IntRect, edge: PinEdge) {
+ val size = ArrowSize.toPx()
+ val gap = 2.dp.toPx()
+ val cx = rect.center.x.toFloat()
+ val cy = rect.center.y.toFloat()
+ val arrow =
+ Path().apply {
+ when (edge) {
+ PinEdge.LEFT -> {
+ val base = rect.left - gap
+ moveTo(base - size, cy)
+ lineTo(base, cy - size)
+ lineTo(base, cy + size)
+ }
+
+ PinEdge.RIGHT -> {
+ val base = rect.right + gap
+ moveTo(base + size, cy)
+ lineTo(base, cy - size)
+ lineTo(base, cy + size)
+ }
+
+ PinEdge.TOP -> {
+ val base = rect.top - gap
+ moveTo(cx, base - size)
+ lineTo(cx - size, base)
+ lineTo(cx + size, base)
+ }
+
+ PinEdge.BOTTOM -> {
+ val base = rect.bottom + gap
+ moveTo(cx, base + size)
+ lineTo(cx - size, base)
+ lineTo(cx + size, base)
+ }
+ }
+ close()
+ }
+ drawPath(arrow, Color.White)
+}
+
+private fun DrawScope.drawOverflow(overflow: Overflow, topLimit: Int, textMeasurer: TextMeasurer, style: TextStyle) {
+ val layout = textMeasurer.measure("+${overflow.count}", style)
+ val pad = OverflowPadding.toPx()
+ val boxSize = Size(layout.size.width + 2 * pad, layout.size.height + pad)
+ val topLeft = Offset(overflow.centerX - boxSize.width / 2, topLimit.toFloat())
+ drawRoundRect(OverflowScrim, topLeft, boxSize, CornerRadius(pad))
+ drawText(layout, topLeft = topLeft + Offset(pad, pad / 2))
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
new file mode 100644
index 00000000000..d63e8e5fbee
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.minimumInteractiveComponentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.util.toCodePointString
+import org.meshtastic.core.model.util.waypointIconOrDefault
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.mesh_hud_distance_no_altitude
+import org.meshtastic.core.resources.mesh_hud_find
+import org.meshtastic.core.resources.mesh_hud_open_node
+import org.meshtastic.core.ui.component.NodeChip
+import org.meshtastic.proto.Waypoint
+
+private const val FRESH_POSITION_SEC = 15 * 60L
+private const val OLD_POSITION_SEC = 6 * 60 * 60L
+private const val OLDEST_ALPHA = 0.45f
+private const val WAYPOINT_SHADOW_ALPHA = 0.8f
+private const val WAYPOINT_SHADOW_BLUR = 8f
+
+/** Fully opaque while fresh, fading toward [OLDEST_ALPHA] as the position ages, so stale fixes read as stale. */
+private fun ageAlpha(ageSec: Long?): Float {
+ val age = ageSec ?: 0L
+ val t = ((age - FRESH_POSITION_SEC).toFloat() / (OLD_POSITION_SEC - FRESH_POSITION_SEC)).coerceIn(0f, 1f)
+ return 1f - t * (1f - OLDEST_ALPHA)
+}
+
+/** Tap opens a node's detail; long press makes it, or a waypoint, the finder's target. */
+@Composable
+internal fun HudChip(target: HudTarget, onOpen: () -> Unit, onToggleFocus: () -> Unit) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier =
+ Modifier.alpha(ageAlpha(target.positionAgeSec))
+ .clip(MaterialTheme.shapes.small)
+ .minimumInteractiveComponentSize()
+ .combinedClickable(
+ onClickLabel = stringResource(Res.string.mesh_hud_open_node),
+ onLongClickLabel = stringResource(Res.string.mesh_hud_find),
+ onClick = onOpen,
+ onLongClick = onToggleFocus,
+ )
+ .semantics(mergeDescendants = true) {},
+ ) {
+ val chipModifier =
+ if (target.focused) Modifier.border(2.dp, Color.White, MaterialTheme.shapes.small) else Modifier
+ SubjectChip(target.subject, chipModifier)
+ val showsMissingAltitude = target.subject is HudSubject.NodeSubject && !target.elevationKnown
+ HudLabel(
+ if (showsMissingAltitude) {
+ stringResource(Res.string.mesh_hud_distance_no_altitude, target.distanceText)
+ } else {
+ target.distanceText
+ },
+ )
+ }
+}
+
+@Composable
+internal fun SubjectChip(subject: HudSubject, modifier: Modifier = Modifier) {
+ when (subject) {
+ is HudSubject.NodeSubject -> NodeChip(modifier = modifier, node = subject.node)
+ is HudSubject.WaypointSubject -> WaypointMarker(subject.waypoint, modifier)
+ }
+}
+
+/**
+ * A waypoint as a bare emoji over its name, with no chip behind it, so a place reads apart from the nodes. The shadow
+ * keeps it legible over sky and ground alike.
+ */
+@Composable
+private fun WaypointMarker(waypoint: Waypoint, modifier: Modifier = Modifier) {
+ val shadow = Shadow(color = Color.Black.copy(alpha = WAYPOINT_SHADOW_ALPHA), blurRadius = WAYPOINT_SHADOW_BLUR)
+ Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
+ Text(
+ waypoint.icon.waypointIconOrDefault().toCodePointString(),
+ style = MaterialTheme.typography.headlineMedium.copy(shadow = shadow),
+ )
+ if (waypoint.name.isNotBlank()) {
+ Text(
+ waypoint.name,
+ color = Color.White,
+ style = MaterialTheme.typography.labelMedium.copy(shadow = shadow),
+ maxLines = 1,
+ )
+ }
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudEdges.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudEdges.kt
new file mode 100644
index 00000000000..1ecc2487774
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudEdges.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import kotlin.math.abs
+import kotlin.math.tan
+
+private const val HALF_CIRCLE = 180.0
+
+/** The screen edge an out-of-view target pins to. */
+enum class PinEdge {
+ LEFT,
+ RIGHT,
+ TOP,
+ BOTTOM,
+}
+
+/** A target further than this to either side pins to a side edge: turning toward it beats tilting. */
+private const val SIDE_YAW_DEG = 35.0
+
+/** Hysteresis on [SIDE_YAW_DEG], and the window near straight behind where a side edge holds. */
+private const val EDGE_HOLD_DEG = 12.0
+private const val MAX_EDGE_ANGLE_DEG = 80.0
+
+/**
+ * Where to pin a target that falls outside the view, chosen the way someone finds it: a target well to the side, or
+ * behind, pins to the side to turn toward ([yawDeg], positive clockwise), and one roughly ahead pins top or bottom to
+ * tilt toward ([pitchDeltaDeg], positive up). Choosing on the screen's own axes instead puts a node behind the user on
+ * the bottom edge while the camera points at the ground, which reads as inverted. [previous] holds through small
+ * wobbles: near [SIDE_YAW_DEG] and near straight behind, where the side would otherwise flip.
+ */
+fun pinEdge(projected: ScreenPoint?, width: Float, yawDeg: Double, pitchDeltaDeg: Double, previous: PinEdge?): PinEdge {
+ val wasSide = previous == PinEdge.LEFT || previous == PinEdge.RIGHT
+ val sideYaw = if (wasSide) SIDE_YAW_DEG - EDGE_HOLD_DEG else SIDE_YAW_DEG + EDGE_HOLD_DEG
+ val ahead = projected != null && projected.x in 0f..width
+ if (ahead || abs(yawDeg) < sideYaw) return if (pitchDeltaDeg > 0) PinEdge.TOP else PinEdge.BOTTOM
+ val nearBehind = abs(yawDeg) > HALF_CIRCLE - EDGE_HOLD_DEG
+ return if (wasSide && nearBehind) {
+ requireNotNull(previous)
+ } else if (yawDeg > 0) {
+ PinEdge.RIGHT
+ } else {
+ PinEdge.LEFT
+ }
+}
+
+/**
+ * Where along its pinned [edge] a target sits, in pixels from the view center: across the top and bottom by how far it
+ * is to the side, up and down the sides by how far it is above or below. Positive is rightward and upward.
+ */
+fun edgeOffsetPx(edge: PinEdge, yawDeg: Double, pitchDeltaDeg: Double, focalPx: Float): Float {
+ val angle =
+ when (edge) {
+ PinEdge.TOP,
+ PinEdge.BOTTOM,
+ -> yawDeg
+
+ PinEdge.LEFT,
+ PinEdge.RIGHT,
+ -> pitchDeltaDeg
+ }
+ return (tan(angle.coerceIn(-MAX_EDGE_ANGLE_DEG, MAX_EDGE_ANGLE_DEG).toRadians()) * focalPx).toFloat()
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt
new file mode 100644
index 00000000000..817f4feb4c9
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.asin
+import kotlin.math.atan
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.max
+import kotlin.math.sin
+
+private const val DEGREES_PER_RADIAN = 180.0 / PI
+
+/** Earth radius scaled by 4/3, the standard allowance for atmospheric refraction near the horizon. */
+private const val EFFECTIVE_EARTH_RADIUS_M = 6_371_000.0 * 4.0 / 3.0
+
+/** Points nearer than this to the camera plane are treated as behind it, so a projection never divides by ~0. */
+private const val MIN_FORWARD_COMPONENT = 0.05
+
+/** A vector in the local east-north-up frame. */
+data class Vec3(val x: Double, val y: Double, val z: Double) {
+ infix fun dot(o: Vec3): Double = x * o.x + y * o.y + z * o.z
+}
+
+/**
+ * Where the back camera points, as the screen's right, up and forward axes in east-north-up, referenced to magnetic
+ * north.
+ */
+data class CameraPose(val right: Vec3, val up: Vec3, val forward: Vec3) {
+ /**
+ * Magnetic azimuth the camera faces, 0..360. Steeply down or up, the line of sight has no usable horizontal part,
+ * so the heading follows the screen's top edge instead, which faces the same way when the phone is tipped forward
+ * and the opposite way when it is tipped back.
+ */
+ val azimuthDeg: Double
+ get() {
+ val facing =
+ when {
+ forward.z < -STEEP_Z -> up
+ forward.z > STEEP_Z -> Vec3(-up.x, -up.y, -up.z)
+ else -> forward
+ }
+ return (atan2(facing.x, facing.y) * DEGREES_PER_RADIAN + FULL_CIRCLE) % FULL_CIRCLE
+ }
+}
+
+/** This pose turned clockwise about the vertical by [deg], as a manual heading correction applies it. */
+fun CameraPose.rotatedAboutVertical(deg: Double): CameraPose {
+ if (deg == 0.0) return this
+ val c = cos(deg.toRadians())
+ val s = sin(deg.toRadians())
+ fun Vec3.turn() = Vec3(x = x * c + y * s, y = -x * s + y * c, z = z)
+ return CameraPose(right = right.turn(), up = up.turn(), forward = forward.turn())
+}
+
+data class ScreenPoint(val x: Float, val y: Float)
+
+private const val FULL_CIRCLE = 360.0
+
+/** sin(60°): a camera pitched further than this has its heading read from the screen's top edge. */
+private const val STEEP_Z = 0.87
+private const val HALF_CIRCLE = 180.0
+
+internal fun Double.toRadians(): Double = this / DEGREES_PER_RADIAN
+
+/** Unit vector toward a bearing (degrees from north, clockwise) at an elevation above the horizontal. */
+fun directionEnu(bearingDeg: Double, elevationDeg: Double): Vec3 {
+ val b = bearingDeg.toRadians()
+ val e = elevationDeg.toRadians()
+ return Vec3(x = sin(b) * cos(e), y = cos(b) * cos(e), z = sin(e))
+}
+
+/**
+ * Angle above the observer's horizontal to a target [distanceM] away and [riseM] above, including the curvature drop at
+ * that range. A null [riseM] places the target level with the observer.
+ */
+fun elevationDeg(distanceM: Double, riseM: Double?): Double {
+ if (distanceM <= 0.0) return 0.0
+ val drop = distanceM * distanceM / (2 * EFFECTIVE_EARTH_RADIUS_M)
+ return atan2((riseM ?: 0.0) - drop, distanceM) * DEGREES_PER_RADIAN
+}
+
+/** A target with no shared altitude frame could sit anywhere in a band this tall either side of level. */
+private const val UNKNOWN_ELEVATION_SPREAD_DEG = 4.0
+
+/**
+ * Half the vertical angle a target [distanceM] away could lie within, from a vertical uncertainty of [verticalM]. When
+ * the target has no [elevationKnown], its level placement is a guess, so the band is at least
+ * [UNKNOWN_ELEVATION_SPREAD_DEG] tall.
+ */
+fun elevationSpreadDeg(distanceM: Double, verticalM: Double, elevationKnown: Boolean): Double {
+ val measured = atan(verticalM / max(distanceM, 1.0)) * DEGREES_PER_RADIAN
+ return if (elevationKnown) measured else max(measured, UNKNOWN_ELEVATION_SPREAD_DEG)
+}
+
+/** Half the angle a circle of [radiusM] subtends at [distanceM], or null when the observer is inside it. */
+fun precisionHalfAngleDeg(radiusM: Double, distanceM: Double): Double? =
+ if (radiusM >= distanceM) null else atan(radiusM / max(distanceM, 1.0)) * DEGREES_PER_RADIAN
+
+/**
+ * Pinhole projection of [direction] onto a [width] x [height] view with focal length [focalPx], or null when it is
+ * behind the camera.
+ */
+fun project(direction: Vec3, pose: CameraPose, focalPx: Float, width: Float, height: Float): ScreenPoint? {
+ val z = direction dot pose.forward
+ if (z < MIN_FORWARD_COMPONENT) return null
+ val x = direction dot pose.right
+ val y = direction dot pose.up
+ return ScreenPoint(x = width / 2 + (focalPx * x / z).toFloat(), y = height / 2 - (focalPx * y / z).toFloat())
+}
+
+/** A chip's footprint in view angles: centered on [bearingDeg], rising [heightDeg] from [elevationDeg]. */
+data class AngularBox(val bearingDeg: Double, val elevationDeg: Double, val halfWidthDeg: Double, val heightDeg: Double)
+
+private const val MAX_STACK_LEVEL = 64
+
+/**
+ * How many rows each chip is lifted so none overlaps an earlier one, in list order. Computed from view angles rather
+ * than screen positions, so it holds still while the phone moves and changes only when the targets do.
+ */
+fun stackLevels(boxes: List, gapDeg: Double): IntArray {
+ val levels = IntArray(boxes.size)
+ boxes.forEachIndexed { i, box ->
+ fun bottom(index: Int, level: Int) = boxes[index].elevationDeg + level * (boxes[index].heightDeg + gapDeg)
+ fun clashes(level: Int) = (0 until i).any { j ->
+ val other = boxes[j]
+ val apart = abs(signedTurnDeg(other.bearingDeg, box.bearingDeg))
+ val myBottom = bottom(i, level)
+ val theirBottom = bottom(j, levels[j])
+ apart < box.halfWidthDeg + other.halfWidthDeg &&
+ myBottom < theirBottom + other.heightDeg &&
+ theirBottom < myBottom + box.heightDeg
+ }
+ var level = 0
+ while (level < MAX_STACK_LEVEL && clashes(level)) level++
+ levels[i] = level
+ }
+ return levels
+}
+
+/** View angle, in degrees, that [px] spans at the center of a view with focal length [focalPx]. */
+fun pxToDeg(px: Int, focalPx: Float): Double = px / focalPx.toDouble() * DEGREES_PER_RADIAN
+
+/** The camera's tilt above the horizontal, in degrees; negative when it points at the ground. */
+val CameraPose.pitchDeg: Double
+ get() = asin(forward.z.coerceIn(-1.0, 1.0)) * DEGREES_PER_RADIAN
+
+/** The shortest signed turn from [fromDeg] to [toDeg], in -180..180; positive is clockwise. */
+fun signedTurnDeg(fromDeg: Double, toDeg: Double): Double =
+ ((toDeg - fromDeg) % FULL_CIRCLE + HALF_CIRCLE + FULL_CIRCLE) % FULL_CIRCLE - HALF_CIRCLE
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
new file mode 100644
index 00000000000..afba375305d
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
@@ -0,0 +1,245 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.withTransform
+import androidx.compose.ui.text.TextMeasurer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.unit.dp
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.ceil
+import kotlin.math.floor
+
+private const val HORIZON_SPAN_DEG = 90
+private const val HORIZON_STEP_DEG = 5
+private const val HORIZON_ALPHA = 0.4f
+private const val ZONE_ALPHA = 0.3f
+private const val TAPE_MINOR_DEG = 5
+private const val TAPE_MAJOR_DEG = 15
+private const val TAPE_LABEL_DEG = 30
+private const val QUARTER_TURN = 90
+private const val HALF_TURN = 180
+private const val THREE_QUARTER_TURN = 270
+private const val LOCKED_FILL_ALPHA = 0.35f
+private const val FULL_TURN = 360
+private val ZoneMinRadius = 6.dp
+private val TapeHeight = 30.dp
+private val TapeMinorTick = 5.dp
+private val TapeMajorTick = 9.dp
+private val TapeMarker = 5.dp
+private val ReticleRadius = 36.dp
+private val TapeScrim = Color.Black.copy(alpha = 0.4f)
+
+/** The four cardinal letters, localized, for the compass tape. */
+internal data class Cardinals(val north: String, val east: String, val south: String, val west: String) {
+ fun at(deg: Int): String? = when (deg) {
+ 0 -> north
+ QUARTER_TURN -> east
+ HALF_TURN -> south
+ THREE_QUARTER_TURN -> west
+ else -> null
+ }
+}
+
+/** The true horizontal (elevation 0) across the view, as a check that the overlay sits level with the world. */
+internal fun DrawScope.drawHorizon(pose: CameraPose, focalPx: Float) {
+ val points =
+ (-HORIZON_SPAN_DEG..HORIZON_SPAN_DEG step HORIZON_STEP_DEG).mapNotNull { offset ->
+ project(directionEnu(pose.azimuthDeg + offset, 0.0), pose, focalPx, size.width, size.height)
+ }
+ points.zipWithNext { a, b ->
+ drawLine(Color.White.copy(alpha = HORIZON_ALPHA), Offset(a.x, a.y), Offset(b.x, b.y), 1.dp.toPx())
+ }
+}
+
+/**
+ * The zone the target could lie in, drawn as a soft oval in its color, strongest at its best-estimate point and fading
+ * to clear at the edge. Its width is the bearing uncertainty, widened by [headingAccuracyDeg] since a heading error
+ * moves every target sideways; its height is the elevation uncertainty; it lies along the horizon even when the phone
+ * rolls.
+ */
+internal fun DrawScope.drawPrecisionZone(
+ target: HudTarget,
+ pose: CameraPose,
+ focalPx: Float,
+ headingAccuracyDeg: Double?,
+) {
+ val across = (target.precisionHalfAngleDeg ?: 0.0) + (headingAccuracyDeg ?: 0.0)
+ val tall = target.elevationSpreadDeg
+ fun at(bearingOffset: Double, elevationOffset: Double) = project(
+ directionEnu(target.bearingDeg + bearingOffset, target.elevationDeg + elevationOffset),
+ pose,
+ focalPx,
+ size.width,
+ size.height,
+ )
+ ?.let { Offset(it.x, it.y) }
+ val c = at(0.0, 0.0) ?: return
+ val left = at(-across, 0.0)
+ val right = at(across, 0.0)
+ val top = at(0.0, tall)
+ val limit = size.maxDimension
+ val minRadius = ZoneMinRadius.toPx()
+ val rx = (if (left != null && right != null) (right - left).getDistance() / 2 else limit).coerceIn(minRadius, limit)
+ val ry = (top?.let { (it - c).getDistance() } ?: limit).coerceIn(minRadius, limit)
+ val degreesPerRad = (HALF_TURN / PI).toFloat()
+ val angle = if (left != null && right != null) atan2(right.y - left.y, right.x - left.x) * degreesPerRad else 0f
+ val color = Color(target.subject.color)
+ withTransform({
+ rotate(angle, pivot = c)
+ scale(scaleX = 1f, scaleY = ry / rx, pivot = c)
+ }) {
+ drawCircle(
+ brush =
+ Brush.radialGradient(
+ colors =
+ listOf(color.copy(alpha = ZONE_ALPHA), color.copy(alpha = ZONE_ALPHA / 2), Color.Transparent),
+ center = c,
+ radius = rx,
+ ),
+ radius = rx,
+ center = c,
+ )
+ }
+}
+
+/**
+ * A heading tape across the top at [topPx], scaled to the camera so its ticks sit over the directions they name.
+ * [trueHeadingDeg] is at the center; each target shows as a marker at its true bearing, larger when focused, so
+ * everything around the user is visible without turning.
+ */
+@Suppress("LongParameterList")
+internal fun DrawScope.drawCompassTape(
+ trueHeadingDeg: Double,
+ declinationDeg: Float,
+ targets: List,
+ focalPx: Float,
+ topPx: Float,
+ cardinals: Cardinals,
+ textMeasurer: TextMeasurer,
+ labelStyle: TextStyle,
+) {
+ val pxPerDeg = focalPx * (PI / HALF_TURN).toFloat()
+ val height = TapeHeight.toPx()
+ val centerX = size.width / 2
+ drawRect(TapeScrim, Offset(0f, topPx), Size(size.width, height))
+ val halfSpanDeg = centerX / pxPerDeg
+ val first = ceil((trueHeadingDeg - halfSpanDeg) / TAPE_MINOR_DEG).toInt() * TAPE_MINOR_DEG
+ val last = floor((trueHeadingDeg + halfSpanDeg) / TAPE_MINOR_DEG).toInt() * TAPE_MINOR_DEG
+ for (deg in first..last step TAPE_MINOR_DEG) {
+ val x = centerX + ((deg - trueHeadingDeg) * pxPerDeg).toFloat()
+ val bearing = ((deg % FULL_TURN) + FULL_TURN) % FULL_TURN
+ val major = bearing % TAPE_MAJOR_DEG == 0
+ val tick = if (major) TapeMajorTick.toPx() else TapeMinorTick.toPx()
+ drawLine(Color.White, Offset(x, topPx + height - tick), Offset(x, topPx + height), 1.dp.toPx())
+ val label = cardinals.at(bearing) ?: bearing.takeIf { it % TAPE_LABEL_DEG == 0 }?.toString()
+ if (label != null) {
+ val layout = textMeasurer.measure(label, labelStyle)
+ drawText(layout, topLeft = Offset(x - layout.size.width / 2f, topPx + 2.dp.toPx()))
+ }
+ }
+ targets.forEach { target ->
+ val trueBearing = target.bearingDeg + declinationDeg
+ val offset = signedTurnDeg(trueHeadingDeg, trueBearing)
+ if (abs(offset) > halfSpanDeg) return@forEach
+ val x = centerX + (offset * pxPerDeg).toFloat()
+ val r = if (target.focused) TapeMarker.toPx() * 2 else TapeMarker.toPx()
+ drawCircle(Color(target.subject.color), radius = r, center = Offset(x, topPx + height - r))
+ }
+ val caret =
+ Path().apply {
+ moveTo(centerX, topPx + height + 6.dp.toPx())
+ lineTo(centerX - 6.dp.toPx(), topPx + height + 14.dp.toPx())
+ lineTo(centerX + 6.dp.toPx(), topPx + height + 14.dp.toPx())
+ close()
+ }
+ drawPath(caret, Color.White)
+}
+
+/** The finder's center ring, filled with the target's color once it is on target. */
+internal fun DrawScope.drawReticle(color: Color, locked: Boolean) {
+ val radius = ReticleRadius.toPx()
+ if (locked) drawCircle(color.copy(alpha = LOCKED_FILL_ALPHA), radius = radius)
+ drawCircle(if (locked) color else Color.White, radius = radius, style = Stroke(width = 2.dp.toPx()))
+ val tick = 8.dp.toPx()
+ val c = center
+ listOf(Offset(1f, 0f), Offset(-1f, 0f), Offset(0f, 1f), Offset(0f, -1f)).forEach { d ->
+ drawLine(Color.White, c + d * radius, c + d * (radius + tick), 2.dp.toPx())
+ }
+ drawCircle(Color.White, radius = 2.dp.toPx(), center = c)
+}
+
+/** Where the phone's camera sits above the ground it stands on, for drawing things that lie on that ground. */
+private const val EYE_HEIGHT_M = 1.5
+
+/** How tall a geofence's wall stands, so the fence reads as a boundary rather than a line on the grass. */
+private const val FENCE_WALL_M = 3.0
+private const val FENCE_WALL_ALPHA = 0.3f
+private const val FENCE_LINE_ALPHA = 0.85f
+private val FenceColor = Color(0xFFFFB300)
+private val FenceLineWidth = 2.dp
+
+/**
+ * A geofence as a wall standing on the ground along its edge, solid at the foot and fading upward, so the boundary
+ * reads in depth from inside or outside. Segments that fall behind the camera are left out.
+ */
+internal fun DrawScope.drawGeofence(fence: HudFence, pose: CameraPose, focalPx: Float) {
+ fun at(point: FencePoint, riseM: Double) = project(
+ directionEnu(point.bearingDeg, elevationDeg(point.distanceM, riseM)),
+ pose,
+ focalPx,
+ size.width,
+ size.height,
+ )
+ ?.let { Offset(it.x, it.y) }
+ val edge = fence.edge
+ edge.indices.forEach { i ->
+ val p = edge[i]
+ val q = edge[(i + 1) % edge.size]
+ val footP = at(p, -EYE_HEIGHT_M) ?: return@forEach
+ val footQ = at(q, -EYE_HEIGHT_M) ?: return@forEach
+ val topP = at(p, FENCE_WALL_M - EYE_HEIGHT_M) ?: return@forEach
+ val topQ = at(q, FENCE_WALL_M - EYE_HEIGHT_M) ?: return@forEach
+ val wall =
+ Path().apply {
+ moveTo(footP.x, footP.y)
+ lineTo(footQ.x, footQ.y)
+ lineTo(topQ.x, topQ.y)
+ lineTo(topP.x, topP.y)
+ close()
+ }
+ drawPath(
+ wall,
+ Brush.linearGradient(
+ colors = listOf(FenceColor.copy(alpha = FENCE_WALL_ALPHA), Color.Transparent),
+ start = (footP + footQ) / 2f,
+ end = (topP + topQ) / 2f,
+ ),
+ )
+ drawLine(FenceColor.copy(alpha = FENCE_LINE_ALPHA), footP, footQ, FenceLineWidth.toPx())
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPaths.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPaths.kt
new file mode 100644
index 00000000000..2a3a07b7694
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPaths.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import org.meshtastic.core.common.util.bearing
+import org.meshtastic.core.common.util.latLongToMeter
+import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.hasFix
+import org.meshtastic.core.model.util.fixTimeSecondsOrNull
+import org.meshtastic.core.model.util.uncertaintyRadiusMetersOrNull
+import org.meshtastic.core.repository.PhoneLocation
+import kotlin.math.PI
+import kotlin.math.hypot
+import kotlin.math.sin
+import org.meshtastic.proto.Position as ProtoPosition
+
+private const val FULL_CIRCLE = 360.0
+private const val SNR_UNKNOWN = -128
+private const val QUARTER_DB = 4f
+private const val ARC_SAMPLES = 24
+private const val ARC_LIFT_FRACTION = 0.15
+private const val ARC_MIN_LIFT_DEG = 2.0
+private const val ARC_MAX_LIFT_DEG = 15.0
+
+/** Points this close are drawn from the viewer: the phone's own fix cannot tell them apart from where it stands. */
+private const val MIN_VIEWER_RADIUS_M = 5.0
+
+/** The most breadcrumbs a track draws, as the Google track map caps them. */
+private const val MAX_TRACK_POINTS = 500
+
+/**
+ * A point on a path through the air, seen from the phone. [bearingDeg] is magnetic, like the camera's. [atViewer] is
+ * true when the point is inside the phone's own accuracy, so the path starts from the viewer rather than a direction.
+ */
+data class PathPoint(val bearingDeg: Double, val elevationDeg: Double, val distanceM: Double, val atViewer: Boolean)
+
+/**
+ * One direction of a traceroute. [points] runs hop by hop, null for a hop with no position; [snrDb] holds the signal of
+ * the link into each hop after the first, null where it was not reported.
+ */
+data class RouteLeg(
+ val nodeNums: List,
+ val points: List,
+ val snrDb: List,
+ val forward: Boolean,
+)
+
+/** A traceroute to draw: its legs, and how many of its known nodes have a position to draw them at. */
+data class HudRoute(val forward: RouteLeg, val back: RouteLeg?, val mappable: Int, val total: Int) {
+ /** Node nums the route passes through, the relays the user wants to see whatever the map filters say. */
+ val nodeNums: Set
+ get() = (forward.nodeNums + back?.nodeNums.orEmpty()).filterTo(mutableSetOf()) { it != UNKNOWN_HOP }
+}
+
+data class TrackPoint(val point: PathPoint, val ageSec: Long?, val altitudeKnown: Boolean)
+
+/** A node's position history as breadcrumbs, oldest first, in the node's [color]. */
+data class HudTrack(val nodeNum: Int, val color: Int, val points: List)
+
+/** The node num a traceroute uses for a relay it could not identify. */
+internal const val UNKNOWN_HOP = NodeAddress.NODENUM_BROADCAST
+
+/** Where [position] is from here, or null when it has no fix. */
+private fun PhoneLocation.pathPoint(position: ProtoPosition, declination: Float): PathPoint? {
+ if (!position.hasFix()) return null
+ val lat = Position.degD(position.latitude_i ?: 0)
+ val lon = Position.degD(position.longitude_i ?: 0)
+ val distance = latLongToMeter(latitude, longitude, lat, lon)
+ val reach =
+ maxOf(MIN_VIEWER_RADIUS_M, hypot(position.uncertaintyRadiusMetersOrNull() ?: 0.0, accuracyM?.toDouble() ?: 0.0))
+ return PathPoint(
+ bearingDeg = (bearing(latitude, longitude, lat, lon) - declination + FULL_CIRCLE) % FULL_CIRCLE,
+ elevationDeg = elevationDeg(distance, riseTo(position)),
+ distanceM = distance,
+ atViewer = distance <= reach,
+ )
+}
+
+/** Quarter-dB link SNRs as dB, valid only when there is one per link of [route]; unknowns become null. */
+private fun snrFor(route: List, quarterDb: List?): List =
+ if (quarterDb != null && quarterDb.size == route.size - 1) {
+ quarterDb.map { q -> q.takeIf { it != SNR_UNKNOWN }?.let { it / QUARTER_DB } }
+ } else {
+ List((route.size - 1).coerceAtLeast(0)) { null }
+ }
+
+/**
+ * A traceroute as legs of points from here. Each hop takes [positionOf]'s answer, which should prefer the position the
+ * route was recorded with and fall back to the live one, so a hop is only blank when neither is known.
+ */
+@Suppress("LongParameterList")
+internal fun buildHudRoute(
+ here: PhoneLocation,
+ declination: Float,
+ forward: List,
+ back: List,
+ snrTowards: List?,
+ snrBack: List?,
+ positionOf: (Int) -> ProtoPosition?,
+): HudRoute {
+ fun leg(route: List, snr: List?, isForward: Boolean) = RouteLeg(
+ nodeNums = route,
+ points = route.map { num -> positionOf(num)?.let { here.pathPoint(it, declination) } },
+ snrDb = snrFor(route, snr),
+ forward = isForward,
+ )
+ val known = (forward + back).filter { it != UNKNOWN_HOP }.distinct()
+ return HudRoute(
+ forward = leg(forward, snrTowards, isForward = true),
+ back = back.takeIf { it.isNotEmpty() }?.let { leg(it, snrBack, isForward = false) },
+ mappable = known.count { num -> positionOf(num)?.hasFix() == true },
+ total = known.size,
+ )
+}
+
+/** A node's positions as breadcrumbs from here, oldest first, keeping the newest [MAX_TRACK_POINTS] with a fix. */
+internal fun buildHudTrack(
+ here: PhoneLocation,
+ declination: Float,
+ nodeNum: Int,
+ color: Int,
+ positions: List,
+ nowSeconds: Long,
+): HudTrack {
+ val points =
+ positions
+ .filter { it.hasFix() }
+ .sortedBy { it.fixTimeSecondsOrNull() ?: 0 }
+ .takeLast(MAX_TRACK_POINTS)
+ .mapNotNull { position ->
+ here.pathPoint(position, declination)?.let { point ->
+ TrackPoint(
+ point = point,
+ ageSec = position.fixTimeSecondsOrNull()?.let { maxOf(0, nowSeconds - it) },
+ altitudeKnown = position.altitude != null || position.altitude_hae != null,
+ )
+ }
+ }
+ return HudTrack(nodeNum, color, points)
+}
+
+/**
+ * The directions along a lifted arc from [a] to [b], so a link reads as a span through the air rather than a line on
+ * the ground. [liftSign] is 1 for the forward leg and -1 for the return, so the two directions of a route part.
+ */
+internal fun arcDirections(a: PathPoint, b: PathPoint, liftSign: Int, samples: Int = ARC_SAMPLES): List {
+ val turn = signedTurnDeg(a.bearingDeg, b.bearingDeg)
+ val rise = b.elevationDeg - a.elevationDeg
+ val lift = (hypot(turn, rise) * ARC_LIFT_FRACTION).coerceIn(ARC_MIN_LIFT_DEG, ARC_MAX_LIFT_DEG) * liftSign
+ return (0..samples).map { i ->
+ val t = i.toDouble() / samples
+ directionEnu(a.bearingDeg + turn * t, a.elevationDeg + rise * t + lift * sin(PI * t))
+ }
+}
+
+/**
+ * Clips the segment [a]-[b] to the part in front of the camera, where [project] can place it. A segment wholly behind
+ * gives null; one crossing the near plane has its behind end moved onto the plane, which projects far off screen in the
+ * right direction, so a line through the viewer's side still reaches the edge instead of wrapping around.
+ */
+internal fun clipToNearPlane(a: Vec3, b: Vec3, pose: CameraPose): Pair? {
+ val za = a dot pose.forward
+ val zb = b dot pose.forward
+ return when {
+ za >= NEAR_PLANE && zb >= NEAR_PLANE -> a to b
+
+ za < NEAR_PLANE && zb < NEAR_PLANE -> null
+
+ else -> {
+ val t = (NEAR_PLANE - za) / (zb - za)
+ val q = Vec3(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t)
+ if (za < NEAR_PLANE) q to b else a to q
+ }
+ }
+}
+
+/** Just inside [project]'s own cutoff, so a point clipped onto it still projects despite rounding. */
+internal const val NEAR_PLANE = 0.051
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
new file mode 100644
index 00000000000..2f4a0a0229a
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.text.TextMeasurer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.unit.dp
+import org.meshtastic.core.common.util.formatString
+
+private const val SNR_FLOOR_DB = -20f
+private const val SNR_SPAN_DB = 30f
+private const val UNKNOWN_SNR_QUALITY = 0.5f
+private const val ARC_MIN_ALPHA = 0.55f
+private const val TRACK_LEVELS = 8
+private const val OLDEST_TRACK_ALPHA = 0.25f
+private val ArcMinWidth = 2.dp
+private val ArcExtraWidth = 4.dp
+private val TrackWidth = 3.dp
+private val TrackRing = 4.dp
+private val LabelPadding = 4.dp
+private val LabelScrim = Color.Black.copy(alpha = 0.55f)
+private val Dashes = floatArrayOf(18f, 12f)
+
+/** Projects [direction], or null when it is behind the camera. */
+private fun DrawScope.screen(direction: Vec3, pose: CameraPose, focalPx: Float): Offset? =
+ project(direction, pose, focalPx, size.width, size.height)?.let { Offset(it.x, it.y) }
+
+/** Draws the polyline through [directions], clipping each piece at the near plane so nothing wraps around the view. */
+private fun DrawScope.drawPolyline(
+ directions: List,
+ pose: CameraPose,
+ focalPx: Float,
+ color: Color,
+ width: Float,
+ dashed: Boolean,
+) {
+ val effect = if (dashed) PathEffect.dashPathEffect(Dashes) else null
+ directions.zipWithNext { a, b ->
+ val (ca, cb) = clipToNearPlane(a, b, pose) ?: return@zipWithNext
+ val pa = screen(ca, pose, focalPx) ?: return@zipWithNext
+ val pb = screen(cb, pose, focalPx) ?: return@zipWithNext
+ drawLine(color, pa, pb, strokeWidth = width, cap = StrokeCap.Round, pathEffect = effect)
+ }
+}
+
+/** A small label on a dark plate, centered on [at]. */
+private fun DrawScope.drawTag(text: String, at: Offset, textMeasurer: TextMeasurer, style: TextStyle) {
+ val layout = textMeasurer.measure(text, style)
+ val pad = LabelPadding.toPx()
+ val box = Size(layout.size.width + 2 * pad, layout.size.height + pad)
+ val topLeft = at - Offset(box.width / 2, box.height / 2)
+ drawRoundRect(LabelScrim, topLeft, box, CornerRadius(pad))
+ drawText(layout, topLeft = topLeft + Offset(pad, pad / 2))
+}
+
+/**
+ * A traceroute in the air: each link a lifted arc between its hops, the forward leg arching up and the return leg down,
+ * in the traceroute map's two colors. A link's reported SNR sets its width and strength and labels its apex. Hops with
+ * no position break the chain: the arc jumps them dashed, tagged with how many it skipped. A leg starting at the phone,
+ * the usual case for a handheld radio, rises from the bottom of the view.
+ */
+@Suppress("LongParameterList", "CyclomaticComplexMethod")
+internal fun DrawScope.drawRoute(
+ route: HudRoute,
+ pose: CameraPose,
+ focalPx: Float,
+ forwardColor: Color,
+ backColor: Color,
+ textMeasurer: TextMeasurer,
+ labelStyle: TextStyle,
+) {
+ listOfNotNull(route.forward, route.back).forEach { leg ->
+ val color = if (leg.forward) forwardColor else backColor
+ val liftSign = if (leg.forward) 1 else -1
+ var from: Int? = null
+ leg.points.forEachIndexed { index, point ->
+ if (point == null) return@forEachIndexed
+ val previous = from
+ val start = previous?.let { leg.points[it] }
+ if (previous != null && start != null) {
+ val skipped = index - previous - 1
+ // One reported SNR per link, so a link across skipped hops has none.
+ val snr = if (skipped == 0) leg.snrDb.getOrNull(index - 1) else null
+ val quality = snr?.let { ((it - SNR_FLOOR_DB) / SNR_SPAN_DB).coerceIn(0f, 1f) } ?: UNKNOWN_SNR_QUALITY
+ val width = ArcMinWidth.toPx() + ArcExtraWidth.toPx() * quality
+ val stroke = color.copy(alpha = ARC_MIN_ALPHA + (1 - ARC_MIN_ALPHA) * quality)
+ if (start.atViewer && !point.atViewer) {
+ val end = screen(directionEnu(point.bearingDeg, point.elevationDeg), pose, focalPx)
+ if (end != null) drawLine(stroke, Offset(size.width / 2, size.height), end, width, StrokeCap.Round)
+ } else if (!start.atViewer && !point.atViewer) {
+ val arc = arcDirections(start, point, liftSign)
+ drawPolyline(arc, pose, focalPx, stroke, width, dashed = skipped > 0)
+ val apex = screen(arc[arc.size / 2], pose, focalPx)
+ if (apex != null) {
+ val tag =
+ when {
+ skipped > 0 -> "+$skipped"
+ snr != null -> formatString("%.1f dB", snr)
+ else -> null
+ }
+ tag?.let { drawTag(it, apex, textMeasurer, labelStyle) }
+ }
+ }
+ }
+ from = index
+ }
+ }
+}
+
+/**
+ * A node's track as breadcrumbs in its color, fading with age in the same eight steps as the Google track map so the
+ * two views agree. A leg into a point with no altitude draws dashed, since that point only sits level as a guess.
+ */
+internal fun DrawScope.drawTrack(track: HudTrack, pose: CameraPose, focalPx: Float) {
+ val points = track.points
+ if (points.isEmpty()) return
+ val base = Color(track.color)
+ fun alphaAt(index: Int): Float {
+ val level = (index * TRACK_LEVELS / points.size).coerceAtMost(TRACK_LEVELS - 1)
+ return OLDEST_TRACK_ALPHA + (1 - OLDEST_TRACK_ALPHA) * level / (TRACK_LEVELS - 1)
+ }
+ fun TrackPoint.direction() = directionEnu(point.bearingDeg, point.elevationDeg)
+ points.zipWithNext().forEachIndexed { i, (a, b) ->
+ drawPolyline(
+ listOf(a.direction(), b.direction()),
+ pose,
+ focalPx,
+ base.copy(alpha = alphaAt(i + 1)),
+ TrackWidth.toPx(),
+ dashed = !b.altitudeKnown,
+ )
+ }
+ points.forEachIndexed { i, p ->
+ if (p.point.atViewer) return@forEachIndexed
+ screen(p.direction(), pose, focalPx)?.let { at ->
+ drawCircle(base.copy(alpha = alphaAt(i)), TrackRing.toPx(), at, style = Stroke(2.dp.toPx()))
+ }
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
new file mode 100644
index 00000000000..218d3009316
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
@@ -0,0 +1,192 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.minimumInteractiveComponentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.dp
+import org.meshtastic.core.common.util.MeasurementSystem
+import org.meshtastic.core.model.util.toCodePointString
+import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.model.util.waypointIconOrDefault
+import kotlin.math.cos
+import kotlin.math.floor
+import kotlin.math.log10
+import kotlin.math.pow
+import kotlin.math.sin
+
+private const val MIN_SCALE_M = 10.0
+private const val SCALE_HEADROOM = 1.15
+private const val OWN_RING_SCALE = 1.5
+private const val NICE_STEP_TWO = 2.0
+private const val NICE_STEP_FIVE = 5.0
+private const val TEN = 10.0
+private const val RING_ALPHA = 0.5f
+private const val OWN_FILL_ALPHA = 0.18f
+private const val NODE_RING_ALPHA = 0.25f
+private val CompactRadar = 168.dp
+private val ExpandedRadar = 300.dp
+private val RadarDot = 30.dp
+private val RadarScrim = Color.Black.copy(alpha = 0.55f)
+
+/** The smallest of 1, 2 or 5 times a power of ten that is at least [meters]. */
+internal fun niceScaleM(meters: Double): Double {
+ val magnitude = TEN.pow(floor(log10(meters)))
+ return listOf(1.0, NICE_STEP_TWO, NICE_STEP_FIVE, TEN).map { it * magnitude }.first { it >= meters }
+}
+
+/**
+ * Where a nearby target sits on a heading-up radar of [radiusPx], [scaleM] to the rim, as an offset from its center:
+ * the camera's heading [headingDeg] points up the screen, like the view behind it.
+ */
+internal fun radarOffset(target: NearbyTarget, headingDeg: Double, scaleM: Double, radiusPx: Float): Offset {
+ val rel = (target.bearingDeg - headingDeg).toRadians()
+ val r = (target.distanceM / scaleM).coerceAtMost(1.0) * radiusPx
+ return Offset((r * sin(rel)).toFloat(), (-r * cos(rel)).toFloat())
+}
+
+/**
+ * A heading-up plan view of the targets too close to point at, with the phone at the center inside its own accuracy
+ * ring and each target inside its precision ring. Tilting the phone toward the ground [expanded]s it, since that is the
+ * posture of someone looking for something close by. The heading is read in layout and draw, so it turns with the
+ * camera without recomposing.
+ */
+@Suppress("LongMethod")
+@Composable
+internal fun NearbyRadar(
+ nearby: List,
+ ownAccuracyM: Float?,
+ units: MeasurementSystem,
+ northLabel: String,
+ declinationDeg: Float,
+ currentHeadingDeg: () -> Double?,
+ expanded: Boolean,
+ onOpen: (HudSubject) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val radarSize by animateDpAsState(if (expanded) ExpandedRadar else CompactRadar)
+ val scaleM =
+ remember(nearby, ownAccuracyM) {
+ val farthest = nearby.maxOfOrNull { it.distanceM + it.radiusM } ?: 0.0
+ niceScaleM(maxOf(MIN_SCALE_M, farthest * SCALE_HEADROOM, (ownAccuracyM ?: 0f) * OWN_RING_SCALE))
+ }
+ val scaleText = scaleM.toInt().toDistanceString(units)
+ val textMeasurer = rememberTextMeasurer()
+ val labelStyle = MaterialTheme.typography.labelSmall.copy(color = Color.White, textAlign = TextAlign.Center)
+ Layout(
+ content = { nearby.forEach { key(it.subject.key) { RadarDot(it.subject) { onOpen(it.subject) } } } },
+ modifier =
+ modifier.size(radarSize).clip(CircleShape).background(RadarScrim).drawBehind {
+ val heading = currentHeadingDeg() ?: 0.0
+ val radius = this.size.minDimension / 2
+ val pxPerM = radius / scaleM.toFloat()
+ drawCircle(Color.White.copy(alpha = RING_ALPHA), radius - 1.dp.toPx(), style = Stroke(1.dp.toPx()))
+ drawCircle(Color.White.copy(alpha = RING_ALPHA), radius / 2, style = Stroke(1.dp.toPx()))
+ ownAccuracyM?.let { drawCircle(Color.White.copy(alpha = OWN_FILL_ALPHA), it * pxPerM) }
+ drawCircle(Color.White, 3.dp.toPx())
+ nearby.forEach { target ->
+ if (target.radiusM > 0) {
+ drawCircle(
+ Color(target.subject.color).copy(alpha = NODE_RING_ALPHA),
+ (target.radiusM * pxPerM).toFloat(),
+ center + radarOffset(target, heading, scaleM, radius),
+ )
+ }
+ }
+ // True north on the rim, matching the tape and heading, so the plan view reads against a map. The radar
+ // turns with the magnetic camera heading, and true north lies the declination west of magnetic.
+ val north = textMeasurer.measure(northLabel, labelStyle)
+ val northRad = (-declinationDeg - heading).toRadians()
+ val northR = radius - north.size.height
+ val northAt = center + Offset((northR * sin(northRad)).toFloat(), (-northR * cos(northRad)).toFloat())
+ drawText(north, topLeft = northAt - Offset(north.size.width / 2f, north.size.height / 2f))
+ val scale = textMeasurer.measure(scaleText, labelStyle)
+ drawText(
+ scale,
+ topLeft = Offset(center.x - scale.size.width / 2f, this.size.height - scale.size.height - 2f),
+ )
+ },
+ ) { measurables, constraints ->
+ val placeables = measurables.map { it.measure(Constraints()) }
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ val heading = currentHeadingDeg() ?: 0.0
+ val radius = minOf(constraints.maxWidth, constraints.maxHeight) / 2f
+ placeables.forEachIndexed { i, placeable ->
+ val at = radarOffset(nearby[i], heading, scaleM, radius)
+ placeable.place(
+ (radius + at.x - placeable.width / 2f).toInt(),
+ (radius + at.y - placeable.height / 2f).toInt(),
+ )
+ }
+ }
+ }
+}
+
+/** A compact marker for the radar: the node's color and short name, or the waypoint's emoji. */
+@Composable
+private fun RadarDot(subject: HudSubject, onClick: () -> Unit) {
+ val label =
+ when (subject) {
+ is HudSubject.NodeSubject -> subject.node.user.short_name.ifEmpty { "?" }
+ is HudSubject.WaypointSubject -> subject.waypoint.icon.waypointIconOrDefault().toCodePointString()
+ }
+ val textColor =
+ when (subject) {
+ is HudSubject.NodeSubject -> Color(subject.node.colors.first)
+ is HudSubject.WaypointSubject -> Color.Black
+ }
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.minimumInteractiveComponentSize().clickable(onClick = onClick),
+ ) {
+ when (subject) {
+ is HudSubject.NodeSubject ->
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.size(RadarDot).clip(CircleShape).background(Color(subject.color)),
+ ) {
+ Text(label, color = textColor, style = MaterialTheme.typography.labelSmall, maxLines = 1)
+ }
+
+ // A place, not a node: the bare emoji, as in the camera view.
+ is HudSubject.WaypointSubject -> Text(label, style = MaterialTheme.typography.titleLarge)
+ }
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
new file mode 100644
index 00000000000..142af9546fe
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
@@ -0,0 +1,331 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import org.meshtastic.core.common.util.MeasurementSystem
+import org.meshtastic.core.common.util.bearing
+import org.meshtastic.core.common.util.latLongToMeter
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.geofence.Geofence
+import org.meshtastic.core.model.geofence.toGeofence
+import org.meshtastic.core.model.util.fixTimeSecondsOrNull
+import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.model.util.uncertaintyRadiusMetersOrNull
+import org.meshtastic.core.model.util.verticalUncertaintyMetersOrNull
+import org.meshtastic.core.repository.PhoneLocation
+import org.meshtastic.core.repository.PhoneLocationState
+import org.meshtastic.proto.Waypoint
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.hypot
+import kotlin.math.sin
+import org.meshtastic.proto.Position as ProtoPosition
+
+private const val FULL_CIRCLE = 360.0
+private const val WAYPOINT_COLOR = 0xFFFFFFFF.toInt()
+
+/** What a chip stands for. [key] is stable across updates and unique across both kinds. */
+sealed interface HudSubject {
+ val key: String
+
+ /** ARGB fill for the chip's anchor dot and precision bar. */
+ val color: Int
+
+ data class NodeSubject(val node: Node) : HudSubject {
+ override val key: String
+ get() = "node-${node.num}"
+
+ override val color: Int
+ get() = node.colors.second
+ }
+
+ data class WaypointSubject(val waypoint: Waypoint) : HudSubject {
+ override val key: String
+ get() = "waypoint-${waypoint.id}"
+
+ override val color: Int
+ get() = WAYPOINT_COLOR
+ }
+}
+
+/**
+ * A positioned node or waypoint as the HUD draws it. [bearingDeg] is magnetic, to match the camera pose, and
+ * [precisionHalfAngleDeg] spans the target's uncertainty circle widened by the phone's own fix accuracy, or is null
+ * when neither is known. [elevationKnown] is false when the target and the phone share no altitude frame, so the target
+ * sits level with the phone.
+ */
+data class HudTarget(
+ val subject: HudSubject,
+ val bearingDeg: Double,
+ val elevationDeg: Double,
+ val distanceM: Double,
+ val distanceText: String,
+ val precisionHalfAngleDeg: Double?,
+ /** Half the vertical angle the target could lie within, from both altitudes' accuracy. */
+ val elevationSpreadDeg: Double,
+ val elevationKnown: Boolean,
+ val positionAgeSec: Long?,
+ val focused: Boolean,
+)
+
+/**
+ * A target too close to point at: the phone sits inside its uncertainty circle. The radar plots it by offset instead,
+ * [eastM] and [northM] from the phone, inside a ring of [radiusM]. [bearingDeg] is magnetic, like the camera's.
+ */
+data class NearbyTarget(
+ val subject: HudSubject,
+ val eastM: Double,
+ val northM: Double,
+ val bearingDeg: Double,
+ val distanceM: Double,
+ val radiusM: Double,
+)
+
+/** A point on a geofence's edge as seen from the phone. [bearingDeg] is magnetic, like the camera's. */
+data class FencePoint(val bearingDeg: Double, val distanceM: Double)
+
+/** A waypoint's geofence outline, sampled around its edge, and whether the phone stands inside it. */
+data class HudFence(val key: String, val name: String, val edge: List, val inside: Boolean)
+
+enum class HudWarning {
+ NO_LOCATION_PERMISSION,
+ LOCATION_DISABLED,
+ NO_LOCATION_FIX,
+}
+
+data class MeshHudTargets(
+ /** Sorted nearest first, so the declutter pass stacks farther chips above nearer ones. */
+ val targets: List = emptyList(),
+ /** Nodes and waypoints whose uncertainty circle contains the phone, so their direction means nothing. */
+ val directionUnknown: List = emptyList(),
+ val fences: List = emptyList(),
+ /** The phone's own fix accuracy after averaging, in meters, and how many fixes went into it. */
+ val ownAccuracyM: Float? = null,
+ val averagedFixes: Int = 0,
+ /** True when the phone's fix has no altitude, so every target sits level with it. */
+ val elevationApproximate: Boolean = false,
+ /** Added to the camera's magnetic azimuth to give a true heading. */
+ val declinationDeg: Float = 0f,
+ val units: MeasurementSystem = MeasurementSystem.METRIC,
+ val rangeLimited: Boolean = true,
+ val nearRangeText: String = "",
+ /** Positioned targets left out by the range limit. */
+ val hiddenByRange: Int = 0,
+ val warnings: List = emptyList(),
+)
+
+/**
+ * The HUD's view of [nodes] and [waypoints] from where the phone is. Pure, so the rules are testable without sensors:
+ * targets inside their uncertainty circle go to [MeshHudTargets.directionUnknown], and the rest are sorted nearest
+ * first.
+ */
+@Suppress("LongParameterList")
+internal fun buildHudTargets(
+ locationState: PhoneLocationState,
+ nodes: List,
+ waypoints: List,
+ units: MeasurementSystem,
+ focusKey: String?,
+ nowSeconds: Long,
+ declinationOf: (PhoneLocation) -> Float,
+): MeshHudTargets {
+ val warnings = locationWarnings(locationState)
+ val here = locationState.location?.takeIf { warnings.isEmpty() } ?: return MeshHudTargets(warnings = warnings)
+ val declination = declinationOf(here)
+ val placed = mutableListOf()
+ val unknown = mutableListOf()
+ nodes.forEach { node ->
+ val subject = HudSubject.NodeSubject(node)
+ val target =
+ here.targetAt(
+ subject = subject,
+ latitude = node.latitude,
+ longitude = node.longitude,
+ targetRadiusM = node.position.uncertaintyRadiusMetersOrNull(),
+ riseM = here.riseTo(node.position),
+ declination = declination,
+ units = units,
+ targetVerticalM = node.position.verticalUncertaintyMetersOrNull(),
+ )
+ if (target == null) {
+ unknown +=
+ here.nearby(
+ subject,
+ node.latitude,
+ node.longitude,
+ node.position.uncertaintyRadiusMetersOrNull(),
+ declination,
+ )
+ } else {
+ placed +=
+ target.copy(
+ positionAgeSec = node.position.fixTimeSecondsOrNull()?.let { maxOf(0, nowSeconds - it) },
+ focused = subject.key == focusKey,
+ )
+ }
+ }
+ waypoints.forEach { waypoint ->
+ val lat = waypoint.latitude_i?.takeIf { it != 0 }?.let { Position.degD(it) } ?: return@forEach
+ val lon = waypoint.longitude_i?.takeIf { it != 0 }?.let { Position.degD(it) } ?: return@forEach
+ val subject = HudSubject.WaypointSubject(waypoint)
+ val target = here.targetAt(subject, lat, lon, null, null, declination, units)
+ if (target == null) {
+ unknown += here.nearby(subject, lat, lon, null, declination)
+ } else {
+ placed += target.copy(focused = subject.key == focusKey)
+ }
+ }
+ return MeshHudTargets(
+ targets = placed.sortedBy { it.distanceM },
+ directionUnknown = unknown,
+ fences = waypoints.mapNotNull { here.fenceFor(it, declination) },
+ elevationApproximate = here.altitude == null && here.mslAltitude == null,
+ ownAccuracyM = here.accuracyM,
+ declinationDeg = declination,
+ )
+}
+
+/**
+ * How far above the phone [position] is, in one altitude frame: ellipsoid to ellipsoid when both report it, else sea
+ * level to sea level, else the phone's ellipsoid height moved to sea level by the node's geoid separation (the geoid is
+ * near flat over mesh distances). Null when no frame is shared.
+ */
+internal fun PhoneLocation.riseTo(position: ProtoPosition): Double? {
+ val nodeHae = position.altitude_hae
+ val nodeMsl = position.altitude
+ val separation = position.altitude_geoidal_separation
+ val phoneHae = altitude
+ val phoneMsl = mslAltitude
+ return when {
+ nodeHae != null && phoneHae != null -> nodeHae - phoneHae
+ nodeMsl != null && phoneMsl != null -> nodeMsl - phoneMsl
+ nodeMsl != null && phoneHae != null && separation != null -> nodeMsl - (phoneHae - separation)
+ else -> null
+ }
+}
+
+/** Null when the phone sits inside the target's uncertainty circle widened by its own fix accuracy. */
+@Suppress("LongParameterList")
+private fun PhoneLocation.targetAt(
+ subject: HudSubject,
+ latitude: Double,
+ longitude: Double,
+ targetRadiusM: Double?,
+ riseM: Double?,
+ declination: Float,
+ units: MeasurementSystem,
+ targetVerticalM: Double? = null,
+): HudTarget? {
+ val distance = latLongToMeter(this.latitude, this.longitude, latitude, longitude)
+ // The two errors are independent, so they combine as a root sum of squares, not a sum.
+ val radius = hypot(targetRadiusM ?: 0.0, accuracyM?.toDouble() ?: 0.0)
+ val halfAngle =
+ if (radius > 0.0) {
+ precisionHalfAngleDeg(radius, distance) ?: return null
+ } else {
+ null
+ }
+ val trueBearing = bearing(this.latitude, this.longitude, latitude, longitude)
+ val verticalM = hypot(targetVerticalM ?: 0.0, verticalAccuracyM?.toDouble() ?: 0.0)
+ return HudTarget(
+ subject = subject,
+ bearingDeg = (trueBearing - declination + FULL_CIRCLE) % FULL_CIRCLE,
+ elevationDeg = elevationDeg(distance, riseM),
+ distanceM = distance,
+ distanceText = distance.toInt().toDistanceString(units),
+ precisionHalfAngleDeg = halfAngle,
+ elevationSpreadDeg = elevationSpreadDeg(distance, verticalM, riseM != null),
+ elevationKnown = riseM != null,
+ positionAgeSec = null,
+ focused = false,
+ )
+}
+
+private fun PhoneLocation.nearby(
+ subject: HudSubject,
+ latitude: Double,
+ longitude: Double,
+ targetRadiusM: Double?,
+ declination: Float,
+): NearbyTarget {
+ val distance = latLongToMeter(this.latitude, this.longitude, latitude, longitude)
+ val trueBearing = bearing(this.latitude, this.longitude, latitude, longitude)
+ val rad = trueBearing.toRadians()
+ return NearbyTarget(
+ subject = subject,
+ eastM = distance * sin(rad),
+ northM = distance * cos(rad),
+ bearingDeg = (trueBearing - declination + FULL_CIRCLE) % FULL_CIRCLE,
+ distanceM = distance,
+ radiusM = targetRadiusM ?: 0.0,
+ )
+}
+
+private const val CIRCLE_SAMPLES = 64
+private const val BOX_EDGE_SAMPLES = 16
+private const val METERS_PER_DEGREE_LAT = 111_320.0
+
+/** The waypoint's geofence as edge points around the phone, or null when it has none. */
+private fun PhoneLocation.fenceFor(waypoint: Waypoint, declination: Float): HudFence? {
+ val fence = waypoint.toGeofence()
+ return fence?.outline()?.let { outline ->
+ HudFence(
+ key = "fence-${waypoint.id}",
+ name = waypoint.name,
+ edge =
+ outline.map { (lat, lon) ->
+ FencePoint(
+ bearingDeg = (bearing(latitude, longitude, lat, lon) - declination + FULL_CIRCLE) % FULL_CIRCLE,
+ distanceM = latLongToMeter(latitude, longitude, lat, lon),
+ )
+ },
+ inside = fence.contains(latitude, longitude),
+ )
+ }
+}
+
+/**
+ * Points around the fence's edge, the circle when it has one, else the box. Fences are a few hundred meters to a few
+ * kilometers across, where a flat-earth offset is well within GPS error.
+ */
+private fun Geofence.outline(): List>? = circle?.let { c ->
+ (0 until CIRCLE_SAMPLES).map { i ->
+ val theta = 2 * PI * i / CIRCLE_SAMPLES
+ val dLat = c.radiusMeters * cos(theta) / METERS_PER_DEGREE_LAT
+ val dLon = c.radiusMeters * sin(theta) / (METERS_PER_DEGREE_LAT * cos(c.centerLat.toRadians()))
+ (c.centerLat + dLat) to (c.centerLon + dLon)
+ }
+}
+ ?: box?.let { b ->
+ val corners = listOf(b.south to b.west, b.north to b.west, b.north to b.east, b.south to b.east)
+ corners.indices.flatMap { i ->
+ val (lat0, lon0) = corners[i]
+ val (lat1, lon1) = corners[(i + 1) % corners.size]
+ (0 until BOX_EDGE_SAMPLES).map { k ->
+ val t = k.toDouble() / BOX_EDGE_SAMPLES
+ (lat0 + (lat1 - lat0) * t) to (lon0 + (lon1 - lon0) * t)
+ }
+ }
+ }
+
+private fun locationWarnings(state: PhoneLocationState): List = when {
+ !state.permissionGranted -> listOf(HudWarning.NO_LOCATION_PERMISSION)
+ !state.providerEnabled -> listOf(HudWarning.LOCATION_DISABLED)
+ !state.hasFix -> listOf(HudWarning.NO_LOCATION_FIX)
+ else -> emptyList()
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
new file mode 100644
index 00000000000..21e2f7555d0
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
@@ -0,0 +1,570 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onPlaced
+import androidx.compose.ui.layout.positionInParent
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.delay
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.compass_location_disabled
+import org.meshtastic.core.resources.compass_no_location_fix
+import org.meshtastic.core.resources.compass_no_location_permission
+import org.meshtastic.core.resources.compass_no_magnetometer
+import org.meshtastic.core.resources.mesh_hud_aligned
+import org.meshtastic.core.resources.mesh_hud_camera_rationale
+import org.meshtastic.core.resources.mesh_hud_cardinal_east
+import org.meshtastic.core.resources.mesh_hud_cardinal_north
+import org.meshtastic.core.resources.mesh_hud_cardinal_south
+import org.meshtastic.core.resources.mesh_hud_cardinal_west
+import org.meshtastic.core.resources.mesh_hud_drag_to_align
+import org.meshtastic.core.resources.mesh_hud_elevation_approximate
+import org.meshtastic.core.resources.mesh_hud_heading
+import org.meshtastic.core.resources.mesh_hud_heading_accuracy
+import org.meshtastic.core.resources.mesh_hud_heading_low_accuracy
+import org.meshtastic.core.resources.mesh_hud_inside_geofence
+import org.meshtastic.core.resources.mesh_hud_inside_unnamed_geofence
+import org.meshtastic.core.resources.mesh_hud_nearby
+import org.meshtastic.core.resources.mesh_hud_no_nodes
+import org.meshtastic.core.resources.mesh_hud_on_target
+import org.meshtastic.core.resources.mesh_hud_own_accuracy
+import org.meshtastic.core.resources.mesh_hud_range_all
+import org.meshtastic.core.resources.mesh_hud_range_within
+import org.meshtastic.core.resources.mesh_hud_range_within_hidden
+import org.meshtastic.core.resources.mesh_hud_tilt_down
+import org.meshtastic.core.resources.mesh_hud_tilt_up
+import org.meshtastic.core.resources.mesh_hud_turn_left
+import org.meshtastic.core.resources.mesh_hud_turn_right
+import org.meshtastic.core.resources.mesh_hud_waiting_sensor
+import org.meshtastic.core.resources.navigate_back
+import org.meshtastic.core.resources.traceroute_outgoing_route
+import org.meshtastic.core.resources.traceroute_return_route
+import org.meshtastic.core.resources.traceroute_showing_nodes
+import org.meshtastic.core.ui.component.PermissionRecoveryCard
+import org.meshtastic.core.ui.icon.ArrowBack
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.theme.TracerouteColors
+import org.meshtastic.core.ui.util.KeepScreenOn
+import org.meshtastic.core.ui.util.PermissionStatus
+import org.meshtastic.core.ui.util.rememberCameraPermissionState
+import org.meshtastic.core.ui.util.rememberLocationPermissionState
+import org.meshtastic.feature.map.component.WaypointInfoDialog
+import org.meshtastic.proto.Waypoint
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.roundToInt
+
+/** Calibration status at or below this (SENSOR_STATUS_ACCURACY_LOW) asks the user to calibrate. */
+private const val LOW_CALIBRATION = 1
+
+/** A heading error estimate above this asks the user to calibrate too. */
+private const val LOW_ACCURACY_DEG = 20.0
+private const val FULL_CIRCLE_DEG = 360.0
+
+/** How long the sensor may stay silent before the screen says it is waiting for it. */
+private const val SENSOR_WAIT_MS = 2_000L
+
+/** The finder counts as on target within this many degrees, or the target's own spread when that is wider. */
+private const val ON_TARGET_DEG = 5.0
+
+/** Turn hints move in steps this coarse, so the label does not churn with every sensor sample. */
+private const val HINT_STEP_DEG = 5
+private val HudScrim = Color.Black.copy(alpha = 0.55f)
+private val TapeGap = 4.dp
+private const val HALF_TURN_DEG = 180.0
+
+/** A heading correction smaller than this is not worth a reset control. */
+private const val MIN_SHOWN_OFFSET_DEG = 0.5
+
+/** Pitched further down than this, the user is looking for something close by, so the radar grows. */
+private const val POINTING_DOWN_DEG = -45.0
+
+/** The band under the top bar the compass tape takes, which chips stay out of. */
+private val TapeClearance = 52.dp
+
+/** Where the finder's target is from the center of the view, rounded to [HINT_STEP_DEG]. */
+private sealed interface FinderHint {
+ data object OnTarget : FinderHint
+
+ data class Turn(val degrees: Int, val right: Boolean) : FinderHint
+
+ data class Tilt(val degrees: Int, val up: Boolean) : FinderHint
+}
+
+@Suppress("LongMethod", "UnnecessaryLaunchedEffect")
+@Composable
+fun MeshHudScreen(
+ focusNodeNum: Int?,
+ onNavigateUp: () -> Unit,
+ onOpenNode: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+ tracerouteDestNum: Int? = null,
+ tracerouteRequestId: Int? = null,
+ tracerouteLogUuid: String? = null,
+ trackNodeNum: Int? = null,
+ viewModel: MeshHudViewModel = koinViewModel(),
+) {
+ LaunchedEffect(tracerouteDestNum, tracerouteRequestId, tracerouteLogUuid) {
+ viewModel.showTraceroute(tracerouteDestNum, tracerouteRequestId, tracerouteLogUuid)
+ }
+ LaunchedEffect(trackNodeNum) { viewModel.showTrack(trackNodeNum) }
+ val route by viewModel.route.collectAsStateWithLifecycle()
+ val alertOptIns by viewModel.geofenceAlertOptIns.collectAsStateWithLifecycle()
+ var infoWaypoint by remember { mutableStateOf(null) }
+ val track by viewModel.track.collectAsStateWithLifecycle()
+ KeepScreenOn(true)
+ // Keyed, not a SideEffect: the user's own focus choices must survive recomposition.
+ LaunchedEffect(focusNodeNum) { viewModel.setFocusNode(focusNodeNum) }
+ val camera = rememberCameraPermissionState()
+ val location = rememberLocationPermissionState()
+ // One system dialog at a time, and only ever asked once: a recreated screen must not prompt again. These stay
+ // keyed effects because each must fire once per key change, which SideEffect does not do.
+ LaunchedEffect(Unit) { if (camera.status == PermissionStatus.NOT_REQUESTED) camera.request() }
+ LaunchedEffect(camera.status) {
+ if (camera.status != PermissionStatus.NOT_REQUESTED && location.status == PermissionStatus.NOT_REQUESTED) {
+ location.request()
+ }
+ }
+ SideEffect { viewModel.setLocationPermissionGranted(location.isGranted) }
+ val targets by viewModel.targets.collectAsStateWithLifecycle()
+ val poseState = viewModel.pose.collectAsStateWithLifecycle()
+ var intrinsics by remember { mutableStateOf(null) }
+ var topBarBottomPx by remember { mutableIntStateOf(0) }
+ var bottomPanelTopPx by remember { mutableIntStateOf(0) }
+ var sensorSilent by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ delay(SENSOR_WAIT_MS)
+ sensorSilent = true
+ }
+
+ val currentTargets by rememberUpdatedState(targets)
+ val focused by remember { derivedStateOf { currentTargets.targets.firstOrNull { it.focused } } }
+ // Derived, so the labels recompose on a whole-degree change rather than at the sensor rate.
+ val headingDeg by remember {
+ derivedStateOf {
+ poseState.value.pose?.let {
+ ((it.azimuthDeg + currentTargets.declinationDeg + FULL_CIRCLE_DEG) % FULL_CIRCLE_DEG).roundToInt()
+ }
+ }
+ }
+ val headingAccuracyDeg by remember { derivedStateOf { poseState.value.headingAccuracyDeg?.roundToInt() } }
+ val needsCalibration by remember {
+ derivedStateOf {
+ val pose = poseState.value
+ pose.pose != null &&
+ (pose.calibration in 0..LOW_CALIBRATION || (pose.headingAccuracyDeg ?: 0.0) > LOW_ACCURACY_DEG)
+ }
+ }
+ val hasSensor by remember { derivedStateOf { poseState.value.hasSensor } }
+ val headingOffsetDeg by viewModel.headingOffsetDeg.collectAsStateWithLifecycle()
+ val waitingForSensor by remember {
+ derivedStateOf { sensorSilent && poseState.value.hasSensor && poseState.value.pose == null }
+ }
+ val hint by remember { derivedStateOf { focused?.let { finderHint(it, poseState.value.pose) } } }
+ val pointingDown by remember { derivedStateOf { (poseState.value.pose?.pitchDeg ?: 0.0) < POINTING_DOWN_DEG } }
+ val northLabel = stringResource(Res.string.mesh_hud_cardinal_north)
+ FinderHaptics(locked = hint == FinderHint.OnTarget)
+
+ val tapeClearancePx = with(LocalDensity.current) { TapeClearance.roundToPx() }
+ val textMeasurer = rememberTextMeasurer()
+ val tapeStyle = MaterialTheme.typography.labelSmall.copy(color = Color.White)
+ val cardinals =
+ Cardinals(
+ north = stringResource(Res.string.mesh_hud_cardinal_north),
+ east = stringResource(Res.string.mesh_hud_cardinal_east),
+ south = stringResource(Res.string.mesh_hud_cardinal_south),
+ west = stringResource(Res.string.mesh_hud_cardinal_west),
+ )
+
+ BoxWithConstraints(modifier = modifier.fillMaxSize().background(Color.Black)) {
+ val width = constraints.maxWidth.toFloat()
+ val height = constraints.maxHeight.toFloat()
+ val focalPx = intrinsics?.focalPx(width, height) ?: fallbackFocalPx(width, height)
+
+ if (camera.isGranted) HudCameraPreview(onIntrinsics = { intrinsics = it }, modifier = Modifier.fillMaxSize())
+
+ val pxPerDeg = focalPx * (PI / HALF_TURN_DEG).toFloat()
+ // Dragging the empty view sideways lines the overlay up with a landmark, correcting a compass that metal or a
+ // fixed accuracy report leaves off. Chips sit above and take their own taps.
+ Canvas(
+ Modifier.fillMaxSize().pointerInput(pxPerDeg) {
+ detectHorizontalDragGestures { change, dragAmount ->
+ change.consume()
+ viewModel.nudgeHeading(-dragAmount / pxPerDeg.toDouble())
+ }
+ },
+ ) {
+ val state = poseState.value
+ val pose = state.pose ?: return@Canvas
+ drawHorizon(pose, focalPx)
+ targets.fences.forEach { drawGeofence(it, pose, focalPx) }
+ track?.let { drawTrack(it, pose, focalPx) }
+ route?.let {
+ drawRoute(
+ it,
+ pose,
+ focalPx,
+ TracerouteColors.OutgoingRoute,
+ TracerouteColors.ReturnRoute,
+ textMeasurer,
+ tapeStyle,
+ )
+ }
+ targets.targets.forEach { drawPrecisionZone(it, pose, focalPx, state.headingAccuracyDeg) }
+ focused?.let { drawReticle(Color(it.subject.color), locked = finderHint(it, pose) == FinderHint.OnTarget) }
+ drawCompassTape(
+ trueHeadingDeg = (pose.azimuthDeg + targets.declinationDeg + FULL_CIRCLE_DEG) % FULL_CIRCLE_DEG,
+ declinationDeg = targets.declinationDeg,
+ targets = targets.targets,
+ focalPx = focalPx,
+ topPx = topBarBottomPx + TapeGap.toPx(),
+ cardinals = cardinals,
+ textMeasurer = textMeasurer,
+ labelStyle = tapeStyle,
+ )
+ }
+ HudChipLayer(
+ targets = targets.targets,
+ currentPose = { poseState.value.pose },
+ focalPx = focalPx,
+ topLimitPx = { topBarBottomPx + tapeClearancePx },
+ bottomLimitPx = { bottomPanelTopPx },
+ modifier = Modifier.fillMaxSize(),
+ ) { target ->
+ HudChip(
+ target = target,
+ // A node opens its detail and a waypoint its info, as on the map; long press focuses either.
+ onOpen = {
+ when (val subject = target.subject) {
+ is HudSubject.NodeSubject -> onOpenNode(subject.node.num)
+ is HudSubject.WaypointSubject -> infoWaypoint = subject.waypoint
+ }
+ },
+ onToggleFocus = { viewModel.toggleFocus(target.subject) },
+ )
+ }
+
+ HudTopBar(
+ headingDeg = headingDeg,
+ headingAccuracyDeg = headingAccuracyDeg,
+ hasSensor = hasSensor,
+ headingOffsetDeg = headingOffsetDeg,
+ onResetHeading = viewModel::resetHeadingOffset,
+ needsCalibration = needsCalibration,
+ waitingForSensor = waitingForSensor,
+ targets = targets,
+ onNavigateUp = onNavigateUp,
+ onToggleRange = viewModel::toggleRangeLimit,
+ modifier =
+ Modifier.align(Alignment.TopStart).onPlaced {
+ topBarBottomPx = (it.positionInParent().y + it.size.height).toInt()
+ },
+ )
+ HudBottomPanel(
+ targets = targets,
+ hint = hint,
+ route = route,
+ radar = {
+ NearbyRadar(
+ nearby = targets.directionUnknown,
+ ownAccuracyM = targets.ownAccuracyM,
+ units = targets.units,
+ northLabel = northLabel,
+ declinationDeg = targets.declinationDeg,
+ currentHeadingDeg = { poseState.value.pose?.azimuthDeg },
+ expanded = pointingDown,
+ onOpen = { subject -> (subject as? HudSubject.NodeSubject)?.let { onOpenNode(it.node.num) } },
+ )
+ },
+ cameraContent = {
+ if (!camera.isGranted) {
+ PermissionRecoveryCard(camera, stringResource(Res.string.mesh_hud_camera_rationale))
+ }
+ },
+ modifier =
+ Modifier.align(Alignment.BottomStart).onPlaced { bottomPanelTopPx = it.positionInParent().y.toInt() },
+ )
+ }
+
+ // Read-only here: editing a waypoint re-broadcasts it, which belongs on the map with its pickers.
+ infoWaypoint?.let { waypoint ->
+ WaypointInfoDialog(
+ waypoint = waypoint,
+ displayUnits = targets.units,
+ alertsEnabled = waypoint.id in alertOptIns,
+ onToggleAlerts = { viewModel.setGeofenceAlertOptIn(waypoint.id, it) },
+ onDismissRequest = { infoWaypoint = null },
+ )
+ }
+}
+
+/** Where [target] is from the center of the view, or null before the first pose. */
+private fun finderHint(target: HudTarget, pose: CameraPose?): FinderHint? {
+ pose ?: return null
+ val yaw = signedTurnDeg(pose.azimuthDeg, target.bearingDeg)
+ val pitch = target.elevationDeg - pose.pitchDeg
+ val tolerance = maxOf(ON_TARGET_DEG, target.precisionHalfAngleDeg ?: 0.0)
+ fun step(deg: Double) = (abs(deg) / HINT_STEP_DEG).roundToInt().coerceAtLeast(1) * HINT_STEP_DEG
+ return when {
+ abs(yaw) > tolerance -> FinderHint.Turn(step(yaw), right = yaw > 0)
+ abs(pitch) > ON_TARGET_DEG -> FinderHint.Tilt(step(pitch), up = pitch > 0)
+ else -> FinderHint.OnTarget
+ }
+}
+
+/** One firm pulse as the finder locks on, so the user can stop sweeping without looking at the screen. */
+@Suppress("UnnecessaryLaunchedEffect") // One pulse per lock, not one per recomposition.
+@Composable
+private fun FinderHaptics(locked: Boolean) {
+ val haptics = LocalHapticFeedback.current
+ LaunchedEffect(locked) { if (locked) haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }
+}
+
+@Suppress("LongParameterList")
+@Composable
+private fun HudTopBar(
+ headingDeg: Int?,
+ headingAccuracyDeg: Int?,
+ hasSensor: Boolean,
+ headingOffsetDeg: Double,
+ onResetHeading: () -> Unit,
+ needsCalibration: Boolean,
+ waitingForSensor: Boolean,
+ targets: MeshHudTargets,
+ onNavigateUp: () -> Unit,
+ onToggleRange: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier.fillMaxWidth().safeDrawingPadding()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = onNavigateUp) {
+ Icon(MeshtasticIcons.ArrowBack, stringResource(Res.string.navigate_back), tint = Color.White)
+ }
+ headingDeg?.let { heading ->
+ val headingText = formatString("%d°", heading)
+ HudLabel(
+ headingAccuracyDeg?.let {
+ stringResource(Res.string.mesh_hud_heading_accuracy, headingText, formatString("%d°", it))
+ } ?: stringResource(Res.string.mesh_hud_heading, headingText),
+ )
+ }
+ Spacer(Modifier.width(8.dp))
+ HudRangeToggle(targets, onToggleRange)
+ }
+ targets.ownAccuracyM?.let { accuracy ->
+ HudLabel(
+ stringResource(
+ Res.string.mesh_hud_own_accuracy,
+ accuracy.toInt().coerceAtLeast(1).toDistanceString(targets.units),
+ ),
+ )
+ }
+ if (abs(headingOffsetDeg) >= MIN_SHOWN_OFFSET_DEG) {
+ HudLabel(
+ text =
+ stringResource(
+ Res.string.mesh_hud_aligned,
+ (if (headingOffsetDeg >= 0) "+" else "-") + formatString("%.0f°", abs(headingOffsetDeg)),
+ ),
+ modifier = Modifier.clip(MaterialTheme.shapes.small).clickable(onClick = onResetHeading),
+ )
+ }
+ when {
+ !hasSensor -> HudLabel(stringResource(Res.string.compass_no_magnetometer))
+
+ waitingForSensor -> HudLabel(stringResource(Res.string.mesh_hud_waiting_sensor))
+
+ needsCalibration -> {
+ HudLabel(stringResource(Res.string.mesh_hud_heading_low_accuracy))
+ HudLabel(stringResource(Res.string.mesh_hud_drag_to_align))
+ }
+ }
+ }
+}
+
+@Composable
+private fun HudBottomPanel(
+ targets: MeshHudTargets,
+ hint: FinderHint?,
+ route: HudRoute?,
+ radar: @Composable () -> Unit,
+ cameraContent: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier.fillMaxWidth().safeDrawingPadding().padding(8.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ hint?.let { FinderHintLabel(it, Modifier.align(Alignment.CenterHorizontally)) }
+ route?.let { RouteLegend(it) }
+ cameraContent()
+ targets.warnings.forEach { HudLabel(stringResource(it.label())) }
+ targets.fences
+ .filter { it.inside }
+ .forEach { fence ->
+ HudLabel(
+ if (fence.name.isNotBlank()) {
+ stringResource(Res.string.mesh_hud_inside_geofence, fence.name)
+ } else {
+ stringResource(Res.string.mesh_hud_inside_unnamed_geofence)
+ },
+ )
+ }
+ if (targets.warnings.isEmpty() && targets.targets.isEmpty() && targets.directionUnknown.isEmpty()) {
+ HudLabel(stringResource(Res.string.mesh_hud_no_nodes))
+ }
+ if (targets.elevationApproximate && targets.targets.isNotEmpty()) {
+ HudLabel(stringResource(Res.string.mesh_hud_elevation_approximate))
+ }
+ if (targets.directionUnknown.isNotEmpty()) {
+ HudLabel(stringResource(Res.string.mesh_hud_nearby))
+ radar()
+ }
+ }
+}
+
+/** The traceroute's two colors, as on the map, and how many of its nodes have a position to draw. */
+@Composable
+private fun RouteLegend(route: HudRoute) {
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ HudLabel(stringResource(Res.string.traceroute_showing_nodes, route.mappable, route.total))
+ HudLabel(
+ stringResource(Res.string.traceroute_outgoing_route),
+ Modifier.border(2.dp, TracerouteColors.OutgoingRoute, MaterialTheme.shapes.small),
+ )
+ if (route.back != null) {
+ HudLabel(
+ stringResource(Res.string.traceroute_return_route),
+ Modifier.border(2.dp, TracerouteColors.ReturnRoute, MaterialTheme.shapes.small),
+ )
+ }
+ }
+}
+
+@Composable
+private fun FinderHintLabel(hint: FinderHint, modifier: Modifier = Modifier) {
+ val text =
+ when (hint) {
+ FinderHint.OnTarget -> stringResource(Res.string.mesh_hud_on_target)
+
+ is FinderHint.Turn ->
+ stringResource(
+ if (hint.right) Res.string.mesh_hud_turn_right else Res.string.mesh_hud_turn_left,
+ hint.degrees,
+ )
+
+ is FinderHint.Tilt ->
+ stringResource(
+ if (hint.up) Res.string.mesh_hud_tilt_up else Res.string.mesh_hud_tilt_down,
+ hint.degrees,
+ )
+ }
+ Text(
+ text = text,
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium,
+ modifier =
+ modifier
+ .semantics { liveRegion = LiveRegionMode.Polite }
+ .background(HudScrim, MaterialTheme.shapes.medium)
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ )
+}
+
+private fun HudWarning.label() = when (this) {
+ HudWarning.NO_LOCATION_PERMISSION -> Res.string.compass_no_location_permission
+ HudWarning.LOCATION_DISABLED -> Res.string.compass_location_disabled
+ HudWarning.NO_LOCATION_FIX -> Res.string.compass_no_location_fix
+}
+
+@Composable
+private fun HudRangeToggle(targets: MeshHudTargets, onToggle: () -> Unit) {
+ val text =
+ when {
+ !targets.rangeLimited -> stringResource(Res.string.mesh_hud_range_all)
+
+ targets.hiddenByRange > 0 ->
+ stringResource(Res.string.mesh_hud_range_within_hidden, targets.nearRangeText, targets.hiddenByRange)
+
+ else -> stringResource(Res.string.mesh_hud_range_within, targets.nearRangeText)
+ }
+ HudLabel(
+ text = text,
+ modifier =
+ Modifier.clip(MaterialTheme.shapes.small)
+ .toggleable(value = targets.rangeLimited, role = Role.Switch, onValueChange = { onToggle() }),
+ )
+}
+
+@Composable
+internal fun HudLabel(text: String, modifier: Modifier = Modifier) {
+ Text(
+ text = text,
+ color = Color.White,
+ style = MaterialTheme.typography.labelMedium,
+ modifier =
+ modifier.background(HudScrim, MaterialTheme.shapes.small).padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
new file mode 100644
index 00000000000..571dfc5e2da
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
@@ -0,0 +1,359 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.stateIn
+import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.common.util.LocaleUnitsProvider
+import org.meshtastic.core.common.util.MeasurementSystem
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.nowSeconds
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.MeshLog
+import org.meshtastic.core.model.fullRouteDiscovery
+import org.meshtastic.core.model.geofence.activeWaypointPackets
+import org.meshtastic.core.model.passesMapFilter
+import org.meshtastic.core.model.service.TracerouteResponse
+import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.repository.MagneticFieldProvider
+import org.meshtastic.core.repository.MapFilterPrefs
+import org.meshtastic.core.repository.MapPrefs
+import org.meshtastic.core.repository.MeshLogRepository
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.NotificationPrefs
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.PhoneLocation
+import org.meshtastic.core.repository.PhoneLocationProvider
+import org.meshtastic.core.repository.TracerouteResponseProvider
+import org.meshtastic.core.repository.TracerouteSnapshotRepository
+import org.meshtastic.core.repository.toNodeMapFilter
+import org.meshtastic.core.ui.util.toPosition
+import org.meshtastic.feature.map.mergeStationaryRuns
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.Position as ProtoPosition
+
+private const val STOP_TIMEOUT_MS = 5_000L
+
+private data class HudSettings(
+ val units: MeasurementSystem,
+ val focusKey: String?,
+ val rangeLimited: Boolean,
+ val filters: MapFilterPrefs,
+ /** Nodes a traceroute or track names, shown whatever the filters and range say. */
+ val pinned: Set,
+)
+
+/** A traceroute to draw: its node nums each way, link SNRs in quarter dB, and the positions it was recorded with. */
+data class RouteSource(
+ val forward: List,
+ val back: List,
+ val snrTowards: List?,
+ val snrBack: List?,
+ val snapshot: Map,
+)
+
+/** A node's position history to draw as breadcrumbs. */
+data class TrackSource(val nodeNum: Int, val positions: List)
+
+/** A traceroute the user opened, before it is matched to its logged response. */
+private data class RouteRequest(val destNum: Int, val requestId: Int, val logUuid: String?) {
+ fun resolve(logs: List, snapshot: Map, response: TracerouteResponse?): RouteSource? {
+ val logged =
+ if (logUuid != null) {
+ logs.find { it.uuid == logUuid }
+ } else {
+ logs.find { it.fromRadio.packet?.decoded?.request_id == requestId }
+ }
+ val discovery = logged?.fromRadio?.packet?.fullRouteDiscovery
+ val live = response?.takeIf { it.requestId == requestId && it.hasOverlay }
+ val forward = discovery?.route ?: live?.forwardRoute
+ val back = discovery?.route_back ?: live?.returnRoute
+ return if (forward.isNullOrEmpty() && back.isNullOrEmpty()) {
+ null
+ } else {
+ RouteSource(forward.orEmpty(), back.orEmpty(), discovery?.snr_towards, discovery?.snr_back, snapshot)
+ }
+ }
+}
+
+private const val TRACK_FALLBACK_COLOR = 0xFFFFFFFF.toInt()
+
+/** Default reach of the view, a round number in each unit system; farther targets hide unless focused. */
+private const val NEAR_RANGE_METRIC_M = 50_000
+private const val NEAR_RANGE_IMPERIAL_M = 48_280 // 30 mi
+
+private fun nearRangeM(units: MeasurementSystem) =
+ if (units == MeasurementSystem.IMPERIAL) NEAR_RANGE_IMPERIAL_M else NEAR_RANGE_METRIC_M
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@Suppress("LongParameterList")
+@KoinViewModel
+class MeshHudViewModel(
+ poseProvider: CameraPoseProvider,
+ phoneLocationProvider: PhoneLocationProvider,
+ private val magneticFieldProvider: MagneticFieldProvider,
+ nodeRepository: NodeRepository,
+ packetRepository: PacketRepository,
+ meshLogRepository: MeshLogRepository,
+ tracerouteResponseProvider: TracerouteResponseProvider,
+ tracerouteSnapshotRepository: TracerouteSnapshotRepository,
+ mapPrefs: MapPrefs,
+ private val notificationPrefs: NotificationPrefs,
+ localeUnitsProvider: LocaleUnitsProvider,
+ dispatchers: CoroutineDispatchers,
+) : ViewModel() {
+
+ private val focusKey = MutableStateFlow(null)
+
+ // The provider checks the permission once per collection, so a grant made while the screen is open needs a new one.
+ private val locationPermissionGranted = MutableStateFlow(false)
+ private val rangeLimited = MutableStateFlow(true)
+ private val fixAverager = FixAverager()
+ private val routeRequest = MutableStateFlow(null)
+ private val trackNodeNum = MutableStateFlow(null)
+
+ /**
+ * The opened traceroute, resolved as the traceroute map resolves it: the logged response first (by its uuid, else
+ * its request id), then the service's latest response, with the hop positions recorded alongside it.
+ */
+ private val routeSource = routeRequest.flatMapLatest { request ->
+ if (request == null) {
+ flowOf(null)
+ } else {
+ combine(
+ nodeRepository
+ .effectiveLogNodeId(request.destNum)
+ .flatMapLatest { meshLogRepository.getLogsFrom(it, PortNum.TRACEROUTE_APP.value) }
+ .onStart { emit(emptyList()) },
+ request.logUuid?.let(tracerouteSnapshotRepository::getSnapshotPositions) ?: flowOf(emptyMap()),
+ tracerouteResponseProvider.tracerouteResponse,
+ ) { logs, snapshot, response ->
+ request.resolve(logs, snapshot, response)
+ }
+ }
+ }
+
+ /** The opened node's position history, trimmed by the map's track filter and with stationary runs merged. */
+ private val trackSource = trackNodeNum.flatMapLatest { num ->
+ if (num == null) {
+ flowOf(null)
+ } else {
+ combine(
+ nodeRepository.effectiveLogNodeId(num).flatMapLatest {
+ meshLogRepository.getMeshPacketsFrom(it, PortNum.POSITION_APP.value)
+ },
+ mapPrefs.mapFilters,
+ ) { packets, filters ->
+ val cutoff = filters.lastHeardTrackSeconds.takeIf { it > 0 }?.let { nowSeconds - it }
+ val positions =
+ packets
+ .mapNotNull { it.toPosition() }
+ .filter { cutoff == null || it.time >= cutoff }
+ .sortedBy { it.time }
+ TrackSource(num, mergeStationaryRuns(positions).map { it.position })
+ }
+ }
+ }
+ private val pinned =
+ combine(routeSource, trackSource) { route, track ->
+ buildSet {
+ route?.let { addAll(it.forward + it.back) }
+ track?.let { add(it.nodeNum) }
+ remove(UNKNOWN_HOP)
+ }
+ }
+
+ /** The phone's averaged fix, shared by the targets, the route and the track. */
+ private val location =
+ locationPermissionGranted
+ .flatMapLatest { phoneLocationProvider.locationUpdates() }
+ .map { state -> state.location?.let { state.copy(location = fixAverager.add(it)) } ?: state }
+ .shareIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), replay = 1)
+
+ private val _headingOffsetDeg = MutableStateFlow(0.0)
+
+ /** The user's drag correction to the compass, added to the camera's heading. */
+ val headingOffsetDeg: StateFlow = _headingOffsetDeg
+
+ val pose: StateFlow =
+ combine(poseProvider.poseUpdates().conflate(), _headingOffsetDeg) { state, offset ->
+ state.copy(pose = state.pose?.rotatedAboutVertical(offset))
+ }
+ .flowOn(dispatchers.default)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), CameraPoseState())
+
+ val targets: StateFlow =
+ combine(
+ location,
+ nodeRepository.nodeDBbyNum,
+ nodeRepository.myNodeInfo,
+ combine(
+ localeUnitsProvider.measurementSystem,
+ focusKey,
+ rangeLimited,
+ mapPrefs.mapFilters,
+ pinned,
+ ::HudSettings,
+ ),
+ packetRepository.getWaypoints(),
+ ) { location, nodes, myNode, settings, waypointPackets ->
+ val now = nowSeconds
+ val filter = settings.filters.toNodeMapFilter()
+ val shownNodes =
+ nodes.values.filter { node ->
+ node.num != myNode?.myNodeNum &&
+ node.validPosition != null &&
+ (
+ HudSubject.NodeSubject(node).key == settings.focusKey ||
+ node.num in settings.pinned ||
+ node.passesMapFilter(filter, now)
+ )
+ }
+ val waypoints =
+ if (settings.filters.showWaypoints) {
+ waypointPackets.activeWaypointPackets(now).values.mapNotNull { it.waypoint }
+ } else {
+ emptyList()
+ }
+ buildHudTargets(
+ location,
+ shownNodes,
+ waypoints,
+ settings.units,
+ settings.focusKey,
+ now,
+ ::declinationAt,
+ )
+ .limitedTo(settings.rangeLimited, settings.units, settings.pinned)
+ .copy(averagedFixes = fixAverager.count)
+ }
+ .flowOn(dispatchers.default)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), MeshHudTargets())
+
+ /** The traceroute in view, if one was opened, as legs of points from here. */
+ val route: StateFlow =
+ combine(location, routeSource, nodeRepository.nodeDBbyNum) { state, source, nodes ->
+ val here = state.location
+ if (source == null || here == null) {
+ null
+ } else {
+ buildHudRoute(
+ here,
+ declinationAt(here),
+ source.forward,
+ source.back,
+ source.snrTowards,
+ source.snrBack,
+ ) { num ->
+ source.snapshot[num] ?: nodes[num]?.validPosition
+ }
+ }
+ }
+ .flowOn(dispatchers.default)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null)
+
+ /** The position track in view, if one was opened, as breadcrumbs from here. */
+ val track: StateFlow =
+ combine(location, trackSource, nodeRepository.nodeDBbyNum) { state, source, nodes ->
+ val here = state.location
+ if (source == null || here == null) {
+ null
+ } else {
+ val color = nodes[source.nodeNum]?.colors?.second ?: TRACK_FALLBACK_COLOR
+ buildHudTrack(here, declinationAt(here), source.nodeNum, color, source.positions, nowSeconds)
+ }
+ }
+ .flowOn(dispatchers.default)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null)
+
+ private fun declinationAt(here: PhoneLocation): Float =
+ magneticFieldProvider.getDeclination(here.latitude, here.longitude, here.altitude ?: 0.0, nowMillis)
+
+ /** Waypoint ids of foreign geofences the user opted in to crossing alerts for, as the map's dialog shows them. */
+ val geofenceAlertOptIns: StateFlow> = notificationPrefs.geofenceAlertOptIns
+
+ fun setGeofenceAlertOptIn(waypointId: Int, enabled: Boolean) =
+ notificationPrefs.setGeofenceAlertOptIn(waypointId, enabled)
+
+ /** Draws traceroute [requestId] to [destNum] in the air; [logUuid] names its logged response when known. */
+ fun showTraceroute(destNum: Int?, requestId: Int?, logUuid: String?) {
+ routeRequest.value =
+ if (destNum != null && requestId != null) RouteRequest(destNum, requestId, logUuid) else null
+ }
+
+ /** Draws [nodeNum]'s position track, or clears it when null. */
+ fun showTrack(nodeNum: Int?) {
+ trackNodeNum.value = nodeNum
+ }
+
+ /** Focuses the finder on one node, as the compass sheet opens it. */
+ fun setFocusNode(nodeNum: Int?) {
+ focusKey.value = nodeNum?.let { "node-$it" }
+ }
+
+ /** Focuses the finder on [subject], or clears the focus when it is already the one focused. */
+ fun toggleFocus(subject: HudSubject) {
+ focusKey.value = subject.key.takeIf { it != focusKey.value }
+ }
+
+ fun setLocationPermissionGranted(granted: Boolean) {
+ locationPermissionGranted.value = granted
+ }
+
+ /** Nudges the heading correction by [deltaDeg], kept within one turn either way. */
+ fun nudgeHeading(deltaDeg: Double) {
+ _headingOffsetDeg.value = signedTurnDeg(0.0, _headingOffsetDeg.value + deltaDeg)
+ }
+
+ fun resetHeadingOffset() {
+ _headingOffsetDeg.value = 0.0
+ }
+
+ fun toggleRangeLimit() {
+ rangeLimited.value = !rangeLimited.value
+ }
+
+ private fun MeshHudTargets.limitedTo(limited: Boolean, units: MeasurementSystem, pinned: Set): MeshHudTargets {
+ val range = nearRangeM(units)
+ fun HudTarget.isPinned() = (subject as? HudSubject.NodeSubject)?.node?.num in pinned
+ val kept = if (limited) targets.filter { it.distanceM <= range || it.focused || it.isPinned() } else targets
+ val keptFences =
+ if (limited) fences.filter { f -> f.inside || f.edge.any { it.distanceM <= range } } else fences
+ return copy(
+ targets = kept,
+ fences = keptFences,
+ rangeLimited = limited,
+ units = units,
+ nearRangeText = range.toDistanceString(units),
+ hiddenByRange = targets.size - kept.size,
+ )
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilter.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilter.kt
new file mode 100644
index 00000000000..54fe4c0f65a
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilter.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.acos
+import kotlin.math.sqrt
+
+private const val NANOS_PER_SECOND = 1e9
+private const val QUATERNION_SIZE = 4
+
+/** A gap longer than this (the app was backgrounded) restarts the filter instead of sweeping across it. */
+private const val MAX_GAP_S = 0.5
+
+/**
+ * Speed-adaptive smoothing for an orientation quaternion, the "1€ filter" (Casiez et al., CHI 2012). Held still it
+ * filters hard, so chips do not shimmer; panning raises the cutoff with angular speed, so the overlay does not trail
+ * the camera image. Quaternions are (x, y, z, w), the rotation vector's order.
+ */
+internal class OneEuroQuaternionFilter(
+ private val minCutoffHz: Double = 0.6,
+ private val beta: Double = 0.8,
+ private val speedCutoffHz: Double = 1.0,
+) {
+ private val state = DoubleArray(QUATERNION_SIZE)
+ private var lastTimestampNs = 0L
+ private var speedRadPerS = 0.0
+
+ /** Filters [sample] taken at [timestampNs] and writes the smoothed quaternion into [out]. */
+ fun filter(sample: FloatArray, timestampNs: Long, out: FloatArray) {
+ val dt = (timestampNs - lastTimestampNs) / NANOS_PER_SECOND
+ if (lastTimestampNs == 0L || dt <= 0.0 || dt > MAX_GAP_S || state.any { it.isNaN() }) {
+ for (i in 0 until QUATERNION_SIZE) state[i] = sample[i].toDouble()
+ speedRadPerS = 0.0
+ } else {
+ var dot = 0.0
+ for (i in 0 until QUATERNION_SIZE) dot += state[i] * sample[i]
+ // q and -q are the same rotation; blend toward whichever is nearer.
+ val sign = if (dot < 0) -1.0 else 1.0
+ val angle = 2 * acos(abs(dot).coerceAtMost(1.0))
+ speedRadPerS += smoothing(speedCutoffHz, dt) * (angle / dt - speedRadPerS)
+ val alpha = smoothing(minCutoffHz + beta * speedRadPerS, dt)
+ var norm = 0.0
+ for (i in 0 until QUATERNION_SIZE) {
+ state[i] += alpha * (sign * sample[i] - state[i])
+ norm += state[i] * state[i]
+ }
+ val inv = 1 / sqrt(norm)
+ for (i in 0 until QUATERNION_SIZE) state[i] *= inv
+ }
+ lastTimestampNs = timestampNs
+ for (i in 0 until QUATERNION_SIZE) out[i] = state[i].toFloat()
+ }
+
+ /** The low-pass weight for a step of [dt] seconds at [cutoffHz]. */
+ private fun smoothing(cutoffHz: Double, dt: Double): Double {
+ val tau = 1 / (2 * PI * cutoffHz)
+ return 1 / (1 + tau / dt)
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
index 54b049834d9..9ae28088063 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
@@ -16,11 +16,13 @@
*/
package org.meshtastic.feature.map.navigation
+import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.navigation.NodesRoute
+import org.meshtastic.feature.map.hud.MeshHudScreen
fun EntryProviderScope.mapGraph(backStack: NavBackStack) {
entry { args ->
@@ -30,6 +32,20 @@ fun EntryProviderScope.mapGraph(backStack: NavBackStack) {
{ id -> backStack.add(NodesRoute.NodeDetail(id)) }, // navigateToNodeDetails
args.waypointId,
args.sitePlannerNodeNum,
+ { backStack.add(MapRoute.MeshHud()) }, // onOpenMeshHud
+ )
+ }
+
+ // No pane metadata: the camera view takes the whole window.
+ entry { args ->
+ MeshHudScreen(
+ focusNodeNum = args.focusNodeNum,
+ onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
+ onOpenNode = { num -> backStack.add(NodesRoute.NodeDetail(num)) },
+ tracerouteDestNum = args.tracerouteDestNum,
+ tracerouteRequestId = args.tracerouteRequestId,
+ tracerouteLogUuid = args.tracerouteLogUuid,
+ trackNodeNum = args.trackNodeNum,
)
}
}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/FixAveragerTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/FixAveragerTest.kt
new file mode 100644
index 00000000000..4dadd00f23d
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/FixAveragerTest.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import org.meshtastic.core.repository.PhoneLocation
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/** About 1.1 m of latitude. */
+private const val METRE_DEG = 0.00001
+
+private fun fix(lat: Double, t: Long, accuracy: Float = 5f, speed: Float? = 0f) =
+ PhoneLocation(40.0 + lat, -105.0, 100.0, t, accuracyM = accuracy, speedMps = speed)
+
+class FixAveragerTest {
+
+ @Test
+ fun `a still phone averages its wander`() {
+ val averager = FixAverager()
+ averager.add(fix(METRE_DEG, 1_000))
+ val mean = averager.add(fix(-METRE_DEG, 2_000))
+ assertEquals(40.0, mean.latitude, 1e-9)
+ assertEquals(2, averager.count)
+ }
+
+ @Test
+ fun `better fixes count for more`() {
+ val averager = FixAverager()
+ averager.add(fix(0.0, 1_000, accuracy = 2f))
+ val mean = averager.add(fix(4 * METRE_DEG, 2_000, accuracy = 8f))
+ // Weights 1/4 and 1/64: the mean sits close to the precise fix.
+ assertEquals(40.0 + 4 * METRE_DEG / 17, mean.latitude, 1e-9)
+ assertEquals(2f, mean.accuracyM)
+ }
+
+ @Test
+ fun `walking restarts the average`() {
+ val averager = FixAverager()
+ averager.add(fix(0.0, 1_000))
+ val mean = averager.add(fix(METRE_DEG, 2_000, speed = 1.4f))
+ assertEquals(40.0 + METRE_DEG, mean.latitude, 1e-12)
+ assertEquals(1, averager.count)
+ }
+
+ @Test
+ fun `a jump far beyond the accuracy restarts the average`() {
+ val averager = FixAverager()
+ averager.add(fix(0.0, 1_000))
+ averager.add(fix(100 * METRE_DEG, 2_000))
+ assertEquals(1, averager.count)
+ }
+
+ @Test
+ fun `old fixes leave the window`() {
+ val averager = FixAverager()
+ averager.add(fix(0.0, 1_000))
+ averager.add(fix(0.0, 40_000))
+ assertEquals(1, averager.count)
+ }
+}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudGeometryTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudGeometryTest.kt
new file mode 100644
index 00000000000..3749f437627
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudGeometryTest.kt
@@ -0,0 +1,214 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+private const val W = 1000f
+private const val H = 2000f
+private const val FOCAL = 800f
+
+/** Phone upright in portrait, back camera facing north, level. */
+private val facingNorth =
+ CameraPose(right = Vec3(1.0, 0.0, 0.0), up = Vec3(0.0, 0.0, 1.0), forward = Vec3(0.0, 1.0, 0.0))
+
+/** Facing east: right is south, forward is east. */
+private val facingEast =
+ CameraPose(right = Vec3(0.0, -1.0, 0.0), up = Vec3(0.0, 0.0, 1.0), forward = Vec3(1.0, 0.0, 0.0))
+
+class HudGeometryTest {
+
+ @Test
+ fun `a target straight ahead lands at the view center`() {
+ val point = assertNotNull(project(directionEnu(0.0, 0.0), facingNorth, FOCAL, W, H))
+ assertEquals(W / 2, point.x, 0.01f)
+ assertEquals(H / 2, point.y, 0.01f)
+ }
+
+ @Test
+ fun `a target east of the line of sight lands right of center`() {
+ val point = assertNotNull(project(directionEnu(20.0, 0.0), facingNorth, FOCAL, W, H))
+ assertTrue(point.x > W / 2)
+ assertEquals(H / 2, point.y, 0.01f)
+ }
+
+ @Test
+ fun `a target above the horizon lands above center`() {
+ val point = assertNotNull(project(directionEnu(0.0, 10.0), facingNorth, FOCAL, W, H))
+ assertTrue(point.y < H / 2)
+ }
+
+ @Test
+ fun `a target behind the camera does not project`() {
+ assertNull(project(directionEnu(180.0, 0.0), facingNorth, FOCAL, W, H))
+ }
+
+ @Test
+ fun `the camera azimuth follows its forward axis`() {
+ assertEquals(0.0, facingNorth.azimuthDeg, 1e-9)
+ assertEquals(90.0, facingEast.azimuthDeg, 1e-9)
+ }
+
+ @Test
+ fun `a node ahead and above the view pins to the top`() {
+ assertEquals(PinEdge.TOP, pinEdge(null, W, yawDeg = 5.0, pitchDeltaDeg = 49.0, previous = null))
+ assertEquals(PinEdge.BOTTOM, pinEdge(ScreenPoint(W / 2, H + 300f), W, 0.0, -30.0, previous = null))
+ }
+
+ @Test
+ fun `a node behind pins to a side even with the camera pointing down`() {
+ assertEquals(PinEdge.RIGHT, pinEdge(null, W, yawDeg = 150.0, pitchDeltaDeg = 49.0, previous = null))
+ assertEquals(PinEdge.LEFT, pinEdge(null, W, yawDeg = -150.0, pitchDeltaDeg = 49.0, previous = null))
+ }
+
+ @Test
+ fun `a node near straight behind keeps its side`() {
+ assertEquals(PinEdge.LEFT, pinEdge(null, W, yawDeg = 175.0, pitchDeltaDeg = 0.0, previous = PinEdge.LEFT))
+ assertEquals(PinEdge.RIGHT, pinEdge(null, W, yawDeg = 150.0, pitchDeltaDeg = 0.0, previous = PinEdge.LEFT))
+ }
+
+ @Test
+ fun `the side threshold holds through small wobbles`() {
+ assertEquals(PinEdge.RIGHT, pinEdge(null, W, yawDeg = 30.0, pitchDeltaDeg = 40.0, previous = PinEdge.RIGHT))
+ assertEquals(PinEdge.TOP, pinEdge(null, W, yawDeg = 40.0, pitchDeltaDeg = 40.0, previous = PinEdge.TOP))
+ }
+
+ @Test
+ fun `a pinned node sits along its edge by its angle`() {
+ assertEquals(800f, edgeOffsetPx(PinEdge.TOP, yawDeg = 45.0, pitchDeltaDeg = 0.0, focalPx = 800f), 0.5f)
+ assertEquals(-800f, edgeOffsetPx(PinEdge.LEFT, yawDeg = 0.0, pitchDeltaDeg = -45.0, focalPx = 800f), 0.5f)
+ }
+
+ @Test
+ fun `a camera pointing straight down takes its heading from the screen top`() {
+ val down = CameraPose(right = Vec3(1.0, 0.0, 0.0), up = Vec3(0.0, 1.0, 0.0), forward = Vec3(0.0, 0.0, -1.0))
+ assertEquals(0.0, down.azimuthDeg, 1e-9)
+ }
+
+ @Test
+ fun `a heading correction turns the pose clockwise`() {
+ assertEquals(90.0, facingNorth.rotatedAboutVertical(90.0).azimuthDeg, 1e-9)
+ assertEquals(350.0, facingNorth.rotatedAboutVertical(-10.0).azimuthDeg, 1e-9)
+ }
+
+ @Test
+ fun `a known altitude gives a band from its accuracy`() {
+ // 10 m of vertical uncertainty at 1 km is about 0.57 degrees.
+ assertEquals(0.573, elevationSpreadDeg(1_000.0, verticalM = 10.0, elevationKnown = true), 0.001)
+ }
+
+ @Test
+ fun `an unknown altitude gives a tall band`() {
+ assertEquals(4.0, elevationSpreadDeg(1_000.0, verticalM = 0.0, elevationKnown = false), 1e-9)
+ }
+
+ @Test
+ fun `turns take the short way round`() {
+ assertEquals(20.0, signedTurnDeg(350.0, 10.0), 1e-9)
+ assertEquals(-20.0, signedTurnDeg(10.0, 350.0), 1e-9)
+ }
+
+ @Test
+ fun `a level camera has no pitch`() {
+ assertEquals(0.0, facingNorth.pitchDeg, 1e-9)
+ }
+
+ @Test
+ fun `curvature puts a distant level target below the horizon`() {
+ // Dip at 10 km with 4/3 Earth radius is about 5.9 m, so about -0.034 degrees.
+ val elevation = elevationDeg(distanceM = 10_000.0, riseM = 0.0)
+ assertTrue(elevation < 0)
+ assertTrue(abs(elevation + 0.0337) < 0.001)
+ }
+
+ @Test
+ fun `a missing altitude places the target level with the observer`() {
+ val elevation = elevationDeg(distanceM = 100.0, riseM = null)
+ assertTrue(abs(elevation) < 0.001)
+ }
+
+ @Test
+ fun `a higher target nearby sits well above the horizon`() {
+ val elevation = elevationDeg(distanceM = 1_000.0, riseM = 100.0)
+ assertEquals(5.71, elevation, 0.01)
+ }
+
+ @Test
+ fun `precision spans the angle its radius subtends`() {
+ // 13 bits is about 2918 m; at 10 km that is about 16.3 degrees each side.
+ assertEquals(16.27, assertNotNull(precisionHalfAngleDeg(2918.0, 10_000.0)), 0.01)
+ }
+
+ @Test
+ fun `inside the precision circle the direction is unknown`() {
+ assertNull(precisionHalfAngleDeg(2918.0, 2_000.0))
+ }
+
+ @Test
+ fun `chips on the same bearing stack in list order`() {
+ val box = AngularBox(bearingDeg = 90.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0)
+ assertEquals(listOf(0, 1, 2), stackLevels(listOf(box, box, box), gapDeg = 0.1).toList())
+ }
+
+ @Test
+ fun `chips far apart in bearing do not stack`() {
+ val a = AngularBox(bearingDeg = 10.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0)
+ val b = a.copy(bearingDeg = 20.0)
+ assertEquals(listOf(0, 0), stackLevels(listOf(a, b), gapDeg = 0.1).toList())
+ }
+
+ @Test
+ fun `stacking sees across north`() {
+ val a = AngularBox(bearingDeg = 359.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0)
+ val b = a.copy(bearingDeg = 1.0)
+ assertEquals(listOf(0, 1), stackLevels(listOf(a, b), gapDeg = 0.1).toList())
+ }
+
+ @Test
+ fun `a chip fills the lowest free row`() {
+ val low = AngularBox(bearingDeg = 0.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0)
+ val high = low.copy(elevationDeg = 5.0)
+ assertEquals(listOf(0, 0, 1), stackLevels(listOf(low, high, low), gapDeg = 0.1).toList())
+ }
+
+ @Test
+ fun `a wide stream from a squarer sensor loses sensor height`() {
+ // A 16:9 stream from a 4 by 3 mm sensor covers the full 4 mm but only 2.25 mm of the height.
+ val streamed = CameraIntrinsics(4f, 4f, 3f).forStream(1920, 1080, cropLong = 1920, cropShort = 1080)
+ assertEquals(4f, streamed.sensorLongMm, 0.001f)
+ assertEquals(2.25f, streamed.sensorShortMm, 0.001f)
+ }
+
+ @Test
+ fun `a crop inside the stream narrows the sensor area further`() {
+ val streamed = CameraIntrinsics(4f, 4f, 3f).forStream(1600, 1200, cropLong = 800, cropShort = 1200)
+ assertEquals(2f, streamed.sensorLongMm, 0.001f)
+ assertEquals(3f, streamed.sensorShortMm, 0.001f)
+ }
+
+ @Test
+ fun `intrinsics scale by the axis that fills the view`() {
+ // A 4 by 3 mm sensor behind a 4 mm lens, filling a 1000 by 2000 portrait view: the long axis governs.
+ val focal = CameraIntrinsics(focalLengthMm = 4f, sensorLongMm = 4f, sensorShortMm = 3f).focalPx(W, H)
+ assertEquals(2000f, focal, 0.01f)
+ }
+}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt
new file mode 100644
index 00000000000..b43acac3f4a
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt
@@ -0,0 +1,124 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import org.meshtastic.core.repository.PhoneLocation
+import org.meshtastic.proto.Position
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+private val here = PhoneLocation(40.0, -105.0, 100.0, 0L, accuracyM = 5f)
+private val northPose = CameraPose(right = Vec3(1.0, 0.0, 0.0), up = Vec3(0.0, 0.0, 1.0), forward = Vec3(0.0, 1.0, 0.0))
+
+private fun at(lat: Double, time: Int = 0, altitude: Int? = 100) = Position.Builder()
+ .also { b ->
+ b.latitude_i = (lat * 1e7).toInt()
+ b.longitude_i = (-105.0 * 1e7).toInt()
+ b.altitude = altitude
+ b.time = time
+ }
+ .build()
+
+class HudPathsTest {
+
+ @Test
+ fun `a segment wholly in front is left alone`() {
+ val a = Vec3(0.0, 1.0, 0.0)
+ val b = Vec3(0.5, 1.0, 0.0)
+ assertEquals(a to b, clipToNearPlane(a, b, northPose))
+ }
+
+ @Test
+ fun `a segment wholly behind is dropped`() {
+ assertNull(clipToNearPlane(Vec3(0.0, -1.0, 0.0), Vec3(0.5, -1.0, 0.0), northPose))
+ }
+
+ @Test
+ fun `a segment crossing the near plane is cut at it`() {
+ val (a, b) = assertNotNull(clipToNearPlane(Vec3(0.0, -1.0, 0.0), Vec3(0.0, 1.0, 0.0), northPose))
+ assertEquals(NEAR_PLANE, a.y, 1e-9)
+ assertEquals(1.0, b.y, 1e-9)
+ assertNotNull(project(a, northPose, 800f, 1000f, 2000f))
+ }
+
+ @Test
+ fun `an arc starts and ends at its hops and lifts between them`() {
+ val a = PathPoint(bearingDeg = 0.0, elevationDeg = 0.0, distanceM = 1000.0, atViewer = false)
+ val b = PathPoint(bearingDeg = 40.0, elevationDeg = 0.0, distanceM = 1000.0, atViewer = false)
+ val arc = arcDirections(a, b, liftSign = 1)
+ assertEquals(directionEnu(0.0, 0.0).x, arc.first().x, 1e-9)
+ assertEquals(directionEnu(40.0, 0.0).x, arc.last().x, 1e-9)
+ assertTrue(arc[arc.size / 2].z > 0)
+ assertTrue(arcDirections(a, b, liftSign = -1)[arc.size / 2].z < 0)
+ }
+
+ @Test
+ fun `a route counts unknown relays apart and reads SNR in dB`() {
+ val positions = mapOf(1 to at(40.0), 3 to at(40.01))
+ val route =
+ buildHudRoute(
+ here,
+ declination = 0f,
+ forward = listOf(1, UNKNOWN_HOP, 3),
+ back = emptyList(),
+ snrTowards = listOf(20, -128),
+ snrBack = null,
+ positionOf = positions::get,
+ )
+ assertEquals(2, route.total)
+ assertEquals(2, route.mappable)
+ assertNull(route.forward.points[1])
+ assertEquals(listOf(5f, null), route.forward.snrDb)
+ assertNull(route.back)
+ assertEquals(setOf(1, 3), route.nodeNums)
+ }
+
+ @Test
+ fun `SNR is ignored when it does not match the route`() {
+ val route = buildHudRoute(here, 0f, listOf(1, 2, 3), emptyList(), listOf(12), null) { at(40.01) }
+ assertEquals(listOf(null, null), route.forward.snrDb)
+ }
+
+ @Test
+ fun `a hop at the phone starts the route from the viewer`() {
+ val route =
+ buildHudRoute(here, 0f, listOf(1, 2), emptyList(), null, null) { num ->
+ if (num == 1) at(40.0) else at(40.01)
+ }
+ assertTrue(assertNotNull(route.forward.points[0]).atViewer)
+ assertFalse(assertNotNull(route.forward.points[1]).atViewer)
+ }
+
+ @Test
+ fun `a track runs oldest first and flags points with no altitude`() {
+ val track =
+ buildHudTrack(
+ here,
+ declination = 0f,
+ nodeNum = 7,
+ color = 0,
+ positions = listOf(at(40.02, time = 300), at(40.01, time = 100, altitude = null)),
+ nowSeconds = 400,
+ )
+ assertEquals(listOf(300L, 100L), track.points.map { it.ageSec })
+ assertFalse(track.points.first().altitudeKnown)
+ }
+}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudRadarTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudRadarTest.kt
new file mode 100644
index 00000000000..66e089f8208
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudRadarTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import org.meshtastic.core.model.Node
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+private fun nearby(bearing: Double, distance: Double) =
+ NearbyTarget(HudSubject.NodeSubject(Node(num = 1)), 0.0, 0.0, bearing, distance, 0.0)
+
+class HudRadarTest {
+
+ @Test
+ fun `the scale rounds up to one two or five`() {
+ assertEquals(20.0, niceScaleM(13.0))
+ assertEquals(50.0, niceScaleM(21.0))
+ assertEquals(100.0, niceScaleM(51.0))
+ assertEquals(10.0, niceScaleM(10.0))
+ }
+
+ @Test
+ fun `a target straight ahead plots straight up`() {
+ val at = radarOffset(nearby(bearing = 90.0, distance = 10.0), headingDeg = 90.0, scaleM = 20.0, radiusPx = 100f)
+ assertEquals(0f, at.x, 0.01f)
+ assertEquals(-50f, at.y, 0.01f)
+ }
+
+ @Test
+ fun `a target to the right of the heading plots to the right`() {
+ val at =
+ radarOffset(nearby(bearing = 180.0, distance = 20.0), headingDeg = 90.0, scaleM = 20.0, radiusPx = 100f)
+ assertEquals(100f, at.x, 0.01f)
+ assertEquals(0f, at.y, 0.01f)
+ }
+
+ @Test
+ fun `a target beyond the scale stays on the rim`() {
+ val at = radarOffset(nearby(bearing = 0.0, distance = 500.0), headingDeg = 0.0, scaleM = 20.0, radiusPx = 100f)
+ assertEquals(-100f, at.y, 0.01f)
+ }
+}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt
new file mode 100644
index 00000000000..1840dd85c2c
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import org.meshtastic.core.common.util.MeasurementSystem
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.PhoneLocation
+import org.meshtastic.core.repository.PhoneLocationState
+import org.meshtastic.proto.Position
+import org.meshtastic.proto.Waypoint
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+private const val LAT = 40.0
+private const val LON = -105.0
+
+/** About 1.1 km north of the phone. */
+private const val NORTH_LAT = 40.01
+
+private fun phone(hae: Double? = 100.0, msl: Double? = null, accuracyM: Float? = null) = PhoneLocationState(
+ permissionGranted = true,
+ providerEnabled = true,
+ location = PhoneLocation(LAT, LON, hae, 0L, mslAltitude = msl, accuracyM = accuracyM),
+)
+
+private fun node(num: Int, lat: Double = NORTH_LAT, hae: Int? = null, msl: Int? = null, separation: Int? = null) = Node(
+ num = num,
+ position =
+ Position.Builder()
+ .also { b ->
+ b.latitude_i = (lat * 1e7).toInt()
+ b.longitude_i = (LON * 1e7).toInt()
+ b.altitude_hae = hae
+ b.altitude = msl
+ b.altitude_geoidal_separation = separation
+ }
+ .build(),
+)
+
+private fun waypoint(id: Int, lat: Double) = Waypoint.Builder()
+ .also { b ->
+ b.id = id
+ b.latitude_i = (lat * 1e7).toInt()
+ b.longitude_i = (LON * 1e7).toInt()
+ }
+ .build()
+
+private fun build(
+ location: PhoneLocationState = phone(),
+ nodes: List = emptyList(),
+ waypoints: List = emptyList(),
+ focus: String? = null,
+ declination: Float = 0f,
+) = buildHudTargets(location, nodes, waypoints, MeasurementSystem.METRIC, focus, nowSeconds = 0L) { declination }
+
+class HudTargetsTest {
+
+ @Test
+ fun `ellipsoid heights are compared to each other first`() {
+ val target = build(nodes = listOf(node(1, hae = 200, msl = 5))).targets.single()
+ assertTrue(target.elevationKnown)
+ // 100 m up over about 1112 m.
+ assertEquals(5.1, target.elevationDeg, 0.1)
+ }
+
+ @Test
+ fun `sea level heights are compared when either side lacks the ellipsoid`() {
+ val target = build(phone(hae = null, msl = 100.0), listOf(node(1, msl = 200))).targets.single()
+ assertEquals(5.1, target.elevationDeg, 0.1)
+ }
+
+ @Test
+ fun `the node geoid separation bridges a sea level node to an ellipsoid phone`() {
+ // Phone HAE 100 with separation -20 is 120 MSL, so a node at 220 MSL is 100 m up.
+ val target = build(phone(hae = 100.0), listOf(node(1, msl = 220, separation = -20))).targets.single()
+ assertEquals(5.1, target.elevationDeg, 0.1)
+ }
+
+ @Test
+ fun `with no shared frame the target sits level and says so`() {
+ val target = build(phone(hae = null), listOf(node(1, msl = 300))).targets.single()
+ assertFalse(target.elevationKnown)
+ assertTrue(target.elevationDeg < 0 && target.elevationDeg > -0.1)
+ }
+
+ @Test
+ fun `a node closer than the phone fix accuracy has no direction`() {
+ val result = build(phone(accuracyM = 2_000f), listOf(node(1)))
+ assertTrue(result.targets.isEmpty())
+ assertEquals("node-1", result.directionUnknown.single().subject.key)
+ }
+
+ @Test
+ fun `independent errors combine as a root sum of squares`() {
+ // 800 m and 600 m make 1000 m: a node 1.1 km off stays pointable, where adding them would not.
+ val node =
+ Node(
+ num = 1,
+ position =
+ Position.Builder()
+ .also { b ->
+ b.latitude_i = (NORTH_LAT * 1e7).toInt()
+ b.longitude_i = (LON * 1e7).toInt()
+ b.gps_accuracy = 800_000
+ b.PDOP = 100
+ }
+ .build(),
+ )
+ assertEquals(1, build(phone(accuracyM = 600f), listOf(node)).targets.size)
+ }
+
+ @Test
+ fun `a waypoint closer than the phone fix accuracy has no direction either`() {
+ val result = build(phone(accuracyM = 2_000f), waypoints = listOf(waypoint(7, NORTH_LAT)))
+ assertEquals("waypoint-7", result.directionUnknown.single().subject.key)
+ }
+
+ @Test
+ fun `a circular geofence is traced around its edge`() {
+ val fenced = waypoint(9, NORTH_LAT).newBuilder().also { it.geofence_radius = 100 }.build()
+ val fence = build(waypoints = listOf(fenced)).fences.single()
+ assertEquals(64, fence.edge.size)
+ assertFalse(fence.inside)
+ // The near edge is about 1012 m off, the far edge about 1212 m.
+ assertEquals(1012.0, fence.edge.minOf { it.distanceM }, 5.0)
+ }
+
+ @Test
+ fun `standing inside a geofence is reported`() {
+ val fenced = waypoint(9, LAT).newBuilder().also { it.geofence_radius = 100 }.build()
+ assertTrue(build(waypoints = listOf(fenced)).fences.single().inside)
+ }
+
+ @Test
+ fun `bearings are magnetic so they match the camera`() {
+ val target = build(nodes = listOf(node(1)), declination = 10f).targets.single()
+ assertEquals(350.0, target.bearingDeg, 0.01)
+ }
+
+ @Test
+ fun `targets are sorted nearest first and the focused one is marked`() {
+ val result = build(nodes = listOf(node(1, lat = 40.02), node(2)), focus = "node-1")
+ assertEquals(listOf("node-2", "node-1"), result.targets.map { it.subject.key })
+ assertTrue(result.targets.last().focused)
+ }
+
+ @Test
+ fun `without a fix there are warnings and no targets`() {
+ val result = build(PhoneLocationState(permissionGranted = false, providerEnabled = true), listOf(node(1)))
+ assertEquals(listOf(HudWarning.NO_LOCATION_PERMISSION), result.warnings)
+ assertTrue(result.targets.isEmpty())
+ }
+}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilterTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilterTest.kt
new file mode 100644
index 00000000000..81df0a0cb4b
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/OneEuroQuaternionFilterTest.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+private const val STEP_NS = 20_000_000L
+
+/** A rotation of [deg] about the vertical, as (x, y, z, w). */
+private fun yaw(deg: Double): FloatArray {
+ val half = deg * kotlin.math.PI / 360
+ return floatArrayOf(0f, 0f, sin(half).toFloat(), cos(half).toFloat())
+}
+
+class OneEuroQuaternionFilterTest {
+
+ @Test
+ fun `the first sample passes straight through`() {
+ val out = FloatArray(4)
+ OneEuroQuaternionFilter().filter(yaw(30.0), STEP_NS, out)
+ assertEquals(yaw(30.0).toList(), out.toList())
+ }
+
+ @Test
+ fun `a jittery still phone is smoothed toward its mean`() {
+ val filter = OneEuroQuaternionFilter()
+ val out = FloatArray(4)
+ repeat(200) { i -> filter.filter(yaw(if (i % 2 == 0) 1.0 else -1.0), (i + 1) * STEP_NS, out) }
+ // The raw samples swing two degrees; the output should hold within a fraction of one.
+ assertTrue(kotlin.math.abs(out[2]) < sin(0.5 * kotlin.math.PI / 360))
+ }
+
+ @Test
+ fun `a fast pan is followed without long lag`() {
+ val filter = OneEuroQuaternionFilter()
+ val out = FloatArray(4)
+ filter.filter(yaw(0.0), STEP_NS, out)
+ repeat(25) { i -> filter.filter(yaw(90.0), (i + 2) * STEP_NS, out) }
+ // Half a second after a 90 degree turn the output is most of the way there.
+ assertTrue(out[2] > sin(60.0 * kotlin.math.PI / 360))
+ }
+
+ @Test
+ fun `q and minus q are the same rotation`() {
+ val filter = OneEuroQuaternionFilter()
+ val out = FloatArray(4)
+ filter.filter(yaw(10.0), STEP_NS, out)
+ filter.filter(yaw(10.0).map { -it }.toFloatArray(), 2 * STEP_NS, out)
+ assertEquals(yaw(10.0)[2], out[2], 1e-4f)
+ }
+}
diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.ios.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.ios.kt
new file mode 100644
index 00000000000..83b00d3cbb8
--- /dev/null
+++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.ios.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable actual fun HudCameraPreview(onIntrinsics: (CameraIntrinsics?) -> Unit, modifier: Modifier) = Unit
diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.jvm.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.jvm.kt
new file mode 100644
index 00000000000..83b00d3cbb8
--- /dev/null
+++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.jvm.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable actual fun HudCameraPreview(onIntrinsics: (CameraIntrinsics?) -> Unit, modifier: Modifier) = Unit
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
index edc8223ad04..c9ca4509dd4 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
@@ -34,8 +34,8 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.Node
-import org.meshtastic.core.model.util.precisionRadiusMetersOrNull
import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.model.util.uncertaintyRadiusMetersOrNull
import org.meshtastic.core.repository.MagneticFieldProvider
import org.meshtastic.core.repository.PhoneLocationProvider
import org.meshtastic.core.repository.PhoneLocationState
@@ -44,14 +44,10 @@ import org.meshtastic.proto.Position
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.min
-import kotlin.math.pow
-import kotlin.math.sqrt
private const val ALIGNMENT_TOLERANCE_DEGREES = 5f
private const val FULL_CIRCLE_DEGREES = 360f
private const val BEARING_FORMAT = "%.0f°"
-private const val HUNDRED = 100f
-private const val MILLIMETERS_PER_METER = 1000f
@Suppress("TooManyFunctions")
@KoinViewModel
@@ -194,7 +190,8 @@ class CompassViewModel(
private fun applyTrueNorthCorrection(heading: Float?, locationState: PhoneLocationState): Float? {
val loc = locationState.location ?: return heading
val baseHeading = heading ?: return null
- val declination = magneticFieldProvider.getDeclination(loc.latitude, loc.longitude, loc.altitude, nowMillis)
+ val declination =
+ magneticFieldProvider.getDeclination(loc.latitude, loc.longitude, loc.altitude ?: 0.0, nowMillis)
return (baseHeading + declination + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
}
@@ -204,34 +201,7 @@ class CompassViewModel(
val positionTime = targetPositionTimeSec
if (positionTime == null || positionTime <= 0) return null
- val gpsAccuracyMm = position.gps_accuracy.toFloat()
- val pdop = position.PDOP
- val hdop = position.HDOP
- val vdop = position.VDOP
- val dop: Float? =
- when {
- pdop > 0 -> pdop / HUNDRED
-
- hdop > 0 && vdop > 0 ->
- sqrt((hdop / HUNDRED).toDouble().pow(2.0) + (vdop / HUNDRED).toDouble().pow(2.0)).toFloat()
-
- hdop > 0 -> hdop / HUNDRED
-
- else -> null
- }
-
- if (gpsAccuracyMm > 0f && dop != null) {
- return (gpsAccuracyMm / MILLIMETERS_PER_METER) * dop
- }
-
- // Fallback: infer radius from precision bits if provided
- // Only a degraded position implies a radius; `0` and `32` both mean "not degraded", and the old formula
- // answered 23,905 km and 5 mm for them respectively.
- precisionRadiusMetersOrNull(position.precision_bits)?.let {
- return it.toFloat()
- }
-
- return null
+ return position.uncertaintyRadiusMetersOrNull()?.toFloat()
}
@Suppress("ReturnCount")
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
index f92d01b842e..aca1739f356 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
+import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -69,10 +70,12 @@ import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.formatDuration
import org.meshtastic.core.resources.last_position_update
+import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.ui.component.ElevationInfo
import org.meshtastic.core.ui.icon.ErrorOutline
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
+import org.meshtastic.core.ui.icon.ViewInAr
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassWarning
import kotlin.math.PI
@@ -89,6 +92,8 @@ fun CompassSheetContent(
onOpenLocationSettings: () -> Unit,
onRequestPosition: () -> Unit,
modifier: Modifier = Modifier,
+ /** Null hides the mesh view button, for platforms with no camera. */
+ onOpenMeshHud: (() -> Unit)? = null,
) {
val haptics = LocalHapticFeedback.current
@@ -166,6 +171,14 @@ fun CompassSheetContent(
}
}
+ onOpenMeshHud?.let { onClick ->
+ FilledTonalButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
+ Icon(imageVector = MeshtasticIcons.ViewInAr, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = stringResource(Res.string.mesh_hud_open))
+ }
+ }
+
if (uiState.warnings.isNotEmpty()) {
WarningList(
warnings = uiState.warnings,
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index 950b36ffa09..aec5c56baad 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -41,14 +41,17 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.site_planner_estimate
import org.meshtastic.core.ui.icon.CellTower
import org.meshtastic.core.ui.icon.Compass
import org.meshtastic.core.ui.icon.Distance
import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.ViewInAr
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalSitePlannerAvailable
+import org.meshtastic.core.ui.util.rememberMeshHudSupported
import org.meshtastic.feature.node.model.NodeDetailAction
private const val MAP_HEIGHT_DP = 200
@@ -83,6 +86,23 @@ internal fun PositionInlineContent(
overflow = TextOverflow.Ellipsis,
)
}
+ if (rememberMeshHudSupported() && node.validPosition != null) {
+ Spacer(Modifier.height(8.dp))
+ FilledTonalButton(
+ onClick = { onAction(NodeDetailAction.Navigate(MapRoute.MeshHud(focusNodeNum = node.num))) },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ ) {
+ Icon(MeshtasticIcons.ViewInAr, null, Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(
+ text = stringResource(Res.string.mesh_hud_open),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
// Estimate RF coverage for this node in the Site Planner (Google flavor, position-gated).
if (LocalSitePlannerAvailable.current && node.validPosition != null) {
Spacer(Modifier.height(8.dp))
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
index c5ba3964370..fde44a67b35 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
@@ -38,12 +38,14 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.MeasurementSystem
import org.meshtastic.core.model.FirmwareRelease
import org.meshtastic.core.model.Node
+import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.details
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.ShareContactDialog
import org.meshtastic.core.ui.util.ActiveWhileStarted
+import org.meshtastic.core.ui.util.rememberMeshHudSupported
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.CompassSheetContent
@@ -153,6 +155,15 @@ private fun NodeDetailScaffold(
compassUiState,
actualCompassViewModel,
{ activeOverlay = null },
+ onOpenMeshHud =
+ if (rememberMeshHudSupported()) {
+ { num ->
+ activeOverlay = null
+ onNavigate(MapRoute.MeshHud(num))
+ }
+ } else {
+ null
+ },
) {
viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it))
}
@@ -167,6 +178,7 @@ private fun NodeDetailOverlays(
compassUiState: CompassUiState,
compassViewModel: CompassViewModel?,
onDismiss: () -> Unit,
+ onOpenMeshHud: ((Int) -> Unit)?,
onRequestPosition: (Node) -> Unit,
) {
val locationPermission = org.meshtastic.core.ui.util.rememberLocationPermissionState()
@@ -213,6 +225,7 @@ private fun NodeDetailOverlays(
onOpenLocationSettings = { openLocationSettings() },
onRequestPosition = { node?.let { onRequestPosition(it) } },
modifier = Modifier.padding(bottom = 24.dp),
+ onOpenMeshHud = onOpenMeshHud?.let { open -> { open(overlay.nodeNum) } },
)
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
index 89d90b6d487..ad1623045b8 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
@@ -36,17 +36,24 @@ import org.meshtastic.core.resources.clear_position_track_message
import org.meshtastic.core.resources.clear_position_track_title
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.export_gpx
+import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.FileDownload
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
+import org.meshtastic.core.ui.icon.ViewInAr
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.rememberSaveFileLauncher
@Composable
-fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
+fun PositionLogScreen(
+ viewModel: MetricsViewModel,
+ onNavigateUp: () -> Unit,
+ /** Null hides the mesh view button, for platforms with no camera. */
+ onOpenMeshHud: (() -> Unit)? = null,
+) {
val state by viewModel.state.collectAsStateWithLifecycle()
val positions = state.positionLogs
@@ -67,6 +74,14 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
timeProvider = { it.time.toDouble() },
onExportCsv = { exportPositionLauncher("position.csv", "text/csv") },
extraActions = {
+ if (hasTrackPoint && onOpenMeshHud != null) {
+ IconButton(onClick = onOpenMeshHud) {
+ Icon(
+ imageVector = MeshtasticIcons.ViewInAr,
+ contentDescription = stringResource(Res.string.mesh_hud_open),
+ )
+ }
+ }
if (hasTrackPoint) {
IconButton(onClick = { exportGpxLauncher("track.gpx", "application/gpx+xml") }) {
Icon(
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
index 470bcf69308..90a6c558fc9 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -44,6 +45,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.traceroute_outgoing_route
import org.meshtastic.core.resources.traceroute_return_route
@@ -51,6 +53,7 @@ import org.meshtastic.core.resources.traceroute_showing_nodes
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Route
+import org.meshtastic.core.ui.icon.ViewInAr
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
@@ -61,6 +64,8 @@ fun TracerouteMapScreen(
metricsViewModel: MetricsViewModel,
requestId: Int,
logUuid: String? = null,
+ /** Null hides the mesh view button, for platforms with no camera. */
+ onOpenMeshHud: (() -> Unit)? = null,
onNavigateUp: () -> Unit,
) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
@@ -89,6 +94,7 @@ fun TracerouteMapScreen(
overlay = overlay,
snapshotPositions = snapshotPositions,
onNavigateUp = onNavigateUp,
+ onOpenMeshHud = onOpenMeshHud,
)
}
@@ -98,6 +104,7 @@ private fun TracerouteMapScaffold(
overlay: TracerouteOverlay?,
snapshotPositions: Map,
onNavigateUp: () -> Unit,
+ onOpenMeshHud: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
var tracerouteNodesShown by remember { mutableStateOf(0) }
@@ -111,7 +118,16 @@ private fun TracerouteMapScaffold(
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
- actions = {},
+ actions = {
+ onOpenMeshHud?.let { onClick ->
+ IconButton(onClick = onClick) {
+ Icon(
+ MeshtasticIcons.ViewInAr,
+ contentDescription = stringResource(Res.string.mesh_hud_open),
+ )
+ }
+ }
+ },
onClickChip = {},
)
},
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
index 11eba202cde..e54b15003f2 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
@@ -30,6 +30,7 @@ import org.jetbrains.compose.resources.StringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.core.navigation.ContactsRoute
+import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.navigation.NodeDetailRoute
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.core.navigation.Route
@@ -57,6 +58,7 @@ import org.meshtastic.core.resources.remote_shell
import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
+import org.meshtastic.core.ui.util.rememberMeshHudSupported
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.detail.NodeDetailScreen
import org.meshtastic.feature.node.detail.NodeDetailViewModel
@@ -141,6 +143,21 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack)
requestId = args.requestId,
logUuid = args.logUuid,
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
+ onOpenMeshHud =
+ if (rememberMeshHudSupported()) {
+ {
+ backStack.add(
+ MapRoute.MeshHud(
+ focusNodeNum = args.destNum,
+ tracerouteDestNum = args.destNum,
+ tracerouteRequestId = args.requestId,
+ tracerouteLogUuid = args.logUuid,
+ ),
+ )
+ }
+ } else {
+ null
+ },
)
}
@@ -154,13 +171,28 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack)
)
}
+ // No pane metadata: the camera view takes the whole window.
+ entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
+ val metricsViewModel = koinViewModel { parametersOf(args.destNum) }
+ metricsViewModel.setNodeId(args.destNum)
+ PositionLogScreen(
+ viewModel = metricsViewModel,
+ onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
+ onOpenMeshHud =
+ if (rememberMeshHudSupported()) {
+ { backStack.add(MapRoute.MeshHud(focusNodeNum = args.destNum, trackNodeNum = args.destNum)) }
+ } else {
+ null
+ },
+ )
+ }
+
NodeDetailScreen.entries.forEach { routeInfo ->
when (routeInfo.routeClass) {
NodeDetailRoute.DeviceMetrics::class ->
addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum }
- NodeDetailRoute.PositionLog::class ->
- addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum }
+ // NodeDetailRoute.PositionLog has its own entry above, for its mesh view action.
NodeDetailRoute.EnvironmentMetrics::class ->
addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum }
From 3b2110fc34c441fb3ed8e7f5ce3e31749d1ffe3a Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sat, 17 Oct 2026 18:15:52 -0500
Subject: [PATCH 04/31] fix(map): mesh view opened for a purpose shows only
that
From node detail or the compass the view shows that node alone; from the position log, that node and its track; from
a traceroute, its destination and hops. Waypoints, geofences, other nodes and the range toggle stay out. Opened from
the map it still shows everything the filters allow.
---
.../meshtastic/feature/map/hud/HudTargets.kt | 2 ++
.../feature/map/hud/MeshHudScreen.kt | 10 +++---
.../feature/map/hud/MeshHudViewModel.kt | 32 +++++++++++++------
3 files changed, 31 insertions(+), 13 deletions(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
index 142af9546fe..16dd9f8b1ec 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
@@ -123,6 +123,8 @@ data class MeshHudTargets(
val declinationDeg: Float = 0f,
val units: MeasurementSystem = MeasurementSystem.METRIC,
val rangeLimited: Boolean = true,
+ /** True when the view was opened for one purpose, so it shows only that and has no range to toggle. */
+ val scoped: Boolean = false,
val nearRangeText: String = "",
/** Positioned targets left out by the range limit. */
val hiddenByRange: Int = 0,
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
index 21e2f7555d0..d7b650f5925 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
@@ -170,13 +170,13 @@ fun MeshHudScreen(
viewModel.showTraceroute(tracerouteDestNum, tracerouteRequestId, tracerouteLogUuid)
}
LaunchedEffect(trackNodeNum) { viewModel.showTrack(trackNodeNum) }
+ // Opened for a purpose (a node, its track, a traceroute) the view shows only that; from the map, the whole mesh.
+ LaunchedEffect(focusNodeNum) { viewModel.openFor(focusNodeNum) }
val route by viewModel.route.collectAsStateWithLifecycle()
val alertOptIns by viewModel.geofenceAlertOptIns.collectAsStateWithLifecycle()
var infoWaypoint by remember { mutableStateOf(null) }
val track by viewModel.track.collectAsStateWithLifecycle()
KeepScreenOn(true)
- // Keyed, not a SideEffect: the user's own focus choices must survive recomposition.
- LaunchedEffect(focusNodeNum) { viewModel.setFocusNode(focusNodeNum) }
val camera = rememberCameraPermissionState()
val location = rememberLocationPermissionState()
// One system dialog at a time, and only ever asked once: a recreated screen must not prompt again. These stay
@@ -410,8 +410,10 @@ private fun HudTopBar(
} ?: stringResource(Res.string.mesh_hud_heading, headingText),
)
}
- Spacer(Modifier.width(8.dp))
- HudRangeToggle(targets, onToggleRange)
+ if (!targets.scoped) {
+ Spacer(Modifier.width(8.dp))
+ HudRangeToggle(targets, onToggleRange)
+ }
}
targets.ownAccuracyM?.let { accuracy ->
HudLabel(
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
index 571dfc5e2da..450b1e79cdd 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
@@ -69,6 +69,11 @@ private data class HudSettings(
val filters: MapFilterPrefs,
/** Nodes a traceroute or track names, shown whatever the filters and range say. */
val pinned: Set,
+ /**
+ * The node the view was opened for, from its detail, compass, track or traceroute. Then only it and the nodes its
+ * track or route names are shown.
+ */
+ val solo: Int?,
)
/** A traceroute to draw: its node nums each way, link SNRs in quarter dB, and the positions it was recorded with. */
@@ -138,6 +143,7 @@ class MeshHudViewModel(
private val rangeLimited = MutableStateFlow(true)
private val fixAverager = FixAverager()
private val routeRequest = MutableStateFlow(null)
+ private val soloNodeNum = MutableStateFlow(null)
private val trackNodeNum = MutableStateFlow(null)
/**
@@ -220,25 +226,29 @@ class MeshHudViewModel(
focusKey,
rangeLimited,
mapPrefs.mapFilters,
- pinned,
- ::HudSettings,
- ),
+ combine(pinned, soloNodeNum, ::Pair),
+ ) { units, focus, limited, filters, (pinnedNums, solo) ->
+ HudSettings(units, focus, limited, filters, pinnedNums, solo)
+ },
packetRepository.getWaypoints(),
) { location, nodes, myNode, settings, waypointPackets ->
val now = nowSeconds
val filter = settings.filters.toNodeMapFilter()
+ val solo = settings.solo
val shownNodes =
nodes.values.filter { node ->
node.num != myNode?.myNodeNum &&
node.validPosition != null &&
- (
+ if (solo != null) {
+ node.num == solo || node.num in settings.pinned
+ } else {
HudSubject.NodeSubject(node).key == settings.focusKey ||
node.num in settings.pinned ||
node.passesMapFilter(filter, now)
- )
+ }
}
val waypoints =
- if (settings.filters.showWaypoints) {
+ if (solo == null && settings.filters.showWaypoints) {
waypointPackets.activeWaypointPackets(now).values.mapNotNull { it.waypoint }
} else {
emptyList()
@@ -253,7 +263,7 @@ class MeshHudViewModel(
::declinationAt,
)
.limitedTo(settings.rangeLimited, settings.units, settings.pinned)
- .copy(averagedFixes = fixAverager.count)
+ .copy(averagedFixes = fixAverager.count, scoped = solo != null)
}
.flowOn(dispatchers.default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), MeshHudTargets())
@@ -314,9 +324,13 @@ class MeshHudViewModel(
trackNodeNum.value = nodeNum
}
- /** Focuses the finder on one node, as the compass sheet opens it. */
- fun setFocusNode(nodeNum: Int?) {
+ /**
+ * Opens the view for [nodeNum], from that node's detail, compass, track or traceroute: the finder points at it, and
+ * only it and the nodes its track or route names are shown. Null opens the whole mesh, as from the map.
+ */
+ fun openFor(nodeNum: Int?) {
focusKey.value = nodeNum?.let { "node-$it" }
+ soloNodeNum.value = nodeNum
}
/** Focuses the finder on [subject], or clears the focus when it is already the one focused. */
From 670b6bbe397739d23b628c80cc43d7ce92f4664c Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sat, 17 Oct 2026 18:48:20 -0500
Subject: [PATCH 05/31] fix(map): mesh view draws routes and tracks on the
radar
A scoped view whose nodes all sit inside their own uncertainty showed only radar dots, so a traceroute or track looked
empty. The radar now draws both in plan view, and a node with no live position takes the one its route or track
recorded. The view no longer reports an empty mesh before its first update.
Also: the return leg's stem from the viewer, scoped views ignore the range limit, AltitudeConverterCompat for sea
level altitude, the geofence foot line clipped at the near plane, and an "Open waypoint" click label.
---
.skills/compose-ui/strings-index.txt | 1 +
.../location/AndroidPhoneLocationProvider.kt | 29 ++---
.../composeResources/values/strings.xml | 1 +
.../meshtastic/feature/map/hud/HudChips.kt | 10 +-
.../meshtastic/feature/map/hud/HudOverlay.kt | 17 ++-
.../feature/map/hud/HudPathsDraw.kt | 11 +-
.../meshtastic/feature/map/hud/HudRadar.kt | 5 +-
.../feature/map/hud/HudRadarPaths.kt | 100 ++++++++++++++++++
.../meshtastic/feature/map/hud/HudTargets.kt | 2 +
.../feature/map/hud/MeshHudScreen.kt | 12 ++-
.../feature/map/hud/MeshHudViewModel.kt | 35 ++++--
.../feature/map/hud/HudRadarTest.kt | 7 ++
12 files changed, 194 insertions(+), 36 deletions(-)
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadarPaths.kt
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 0eeb6ec5371..e2b3c2cf04e 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1116,6 +1116,7 @@ mesh_hud_no_nodes
mesh_hud_on_target
mesh_hud_open
mesh_hud_open_node
+mesh_hud_open_waypoint
mesh_hud_own_accuracy
mesh_hud_range_all
mesh_hud_range_within
diff --git a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
index 3b0abfee8e7..4c0949bb559 100644
--- a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
+++ b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
@@ -20,9 +20,9 @@ import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationManager
-import android.location.altitude.AltitudeConverter
-import android.os.Build
import android.os.Looper
+import androidx.core.location.LocationCompat
+import androidx.core.location.altitude.AltitudeConverterCompat
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
@@ -131,21 +131,16 @@ class AndroidPhoneLocationProvider(private val context: Context, private val dis
.map { it.withMslAltitude() }
.flowOn(dispatchers.io)
- private val altitudeConverter by lazy {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) AltitudeConverter() else null
- }
-
/**
- * Fills the sea-level height a provider left out, from the platform's geoid model (API 34+). It loads model data on
- * first use and may block, which is why it runs here, upstream of the IO dispatcher, rather than in the callback.
+ * Fills the sea-level height a provider left out, from the geoid model, on every API level through the compat
+ * converter (as `LocationRepositoryImpl` does). It loads model data on first use and may block, which is why it
+ * runs here, upstream of the IO dispatcher, rather than in the callback.
*/
@Suppress("ReturnCount")
private fun PhoneLocationState.withMslAltitude(): PhoneLocationState {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return this
val fix = location ?: return this
val ellipsoid = fix.altitude
- val converter = altitudeConverter
- if (fix.mslAltitude != null || ellipsoid == null || converter == null) return this
+ if (fix.mslAltitude != null || ellipsoid == null) return this
val probe =
Location("mesh-view").apply {
latitude = fix.latitude
@@ -154,8 +149,9 @@ class AndroidPhoneLocationProvider(private val context: Context, private val dis
}
@Suppress("TooGenericExceptionCaught")
return try {
- converter.addMslAltitudeToLocation(context, probe)
- copy(location = fix.copy(mslAltitude = probe.mslAltitudeMeters))
+ AltitudeConverterCompat.addMslAltitudeToLocation(context, probe)
+ val msl = if (LocationCompat.hasMslAltitude(probe)) LocationCompat.getMslAltitudeMeters(probe) else null
+ copy(location = fix.copy(mslAltitude = msl))
} catch (e: Exception) {
Logger.w(e) { "MSL altitude conversion failed" }
this
@@ -167,12 +163,7 @@ class AndroidPhoneLocationProvider(private val context: Context, private val dis
longitude = longitude,
altitude = if (hasAltitude()) altitude else null,
timeMillis = time,
- mslAltitude =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && hasMslAltitude()) {
- mslAltitudeMeters
- } else {
- null
- },
+ mslAltitude = if (LocationCompat.hasMslAltitude(this)) LocationCompat.getMslAltitudeMeters(this) else null,
accuracyM = if (hasAccuracy()) accuracy else null,
verticalAccuracyM = if (hasVerticalAccuracy()) verticalAccuracyMeters else null,
speedMps = if (hasSpeed()) speed else null,
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 37e179a6723..ef99020c37c 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1158,6 +1158,7 @@
On target
Open mesh view
Open node
+ Open waypoint
You ± %1$s
All distances
Within %1$s
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
index d63e8e5fbee..0403aeb4898 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
@@ -38,6 +38,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.mesh_hud_distance_no_altitude
import org.meshtastic.core.resources.mesh_hud_find
import org.meshtastic.core.resources.mesh_hud_open_node
+import org.meshtastic.core.resources.mesh_hud_open_waypoint
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.proto.Waypoint
@@ -64,7 +65,14 @@ internal fun HudChip(target: HudTarget, onOpen: () -> Unit, onToggleFocus: () ->
.clip(MaterialTheme.shapes.small)
.minimumInteractiveComponentSize()
.combinedClickable(
- onClickLabel = stringResource(Res.string.mesh_hud_open_node),
+ onClickLabel =
+ stringResource(
+ if (target.subject is HudSubject.WaypointSubject) {
+ Res.string.mesh_hud_open_waypoint
+ } else {
+ Res.string.mesh_hud_open_node
+ },
+ ),
onLongClickLabel = stringResource(Res.string.mesh_hud_find),
onClick = onOpen,
onLongClick = onToggleFocus,
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
index afba375305d..730ef90ccb1 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
@@ -203,13 +203,17 @@ private const val FENCE_LINE_ALPHA = 0.85f
private val FenceColor = Color(0xFFFFB300)
private val FenceLineWidth = 2.dp
+/** The direction to a fence point [riseM] above the phone's height. */
+private fun groundDirection(point: FencePoint, riseM: Double) =
+ directionEnu(point.bearingDeg, elevationDeg(point.distanceM, riseM))
+
/**
* A geofence as a wall standing on the ground along its edge, solid at the foot and fading upward, so the boundary
* reads in depth from inside or outside. Segments that fall behind the camera are left out.
*/
internal fun DrawScope.drawGeofence(fence: HudFence, pose: CameraPose, focalPx: Float) {
fun at(point: FencePoint, riseM: Double) = project(
- directionEnu(point.bearingDeg, elevationDeg(point.distanceM, riseM)),
+ groundDirection(point, riseM),
pose,
focalPx,
size.width,
@@ -220,6 +224,16 @@ internal fun DrawScope.drawGeofence(fence: HudFence, pose: CameraPose, focalPx:
edge.indices.forEach { i ->
val p = edge[i]
val q = edge[(i + 1) % edge.size]
+ // The foot line is clipped at the near plane, so the boundary runs to the view's edge from inside the fence.
+ val clippedFoot = clipToNearPlane(groundDirection(p, -EYE_HEIGHT_M), groundDirection(q, -EYE_HEIGHT_M), pose)
+ clippedFoot?.let { (a, b) ->
+ val fa = project(a, pose, focalPx, size.width, size.height)
+ val fb = project(b, pose, focalPx, size.width, size.height)
+ if (fa != null && fb != null) {
+ drawLine(FenceColor.copy(alpha = FENCE_LINE_ALPHA), Offset(fa.x, fa.y), Offset(fb.x, fb.y), FenceLineWidth.toPx())
+ }
+ }
+ // The wall is decoration: drawn only where its whole segment is in front of the camera.
val footP = at(p, -EYE_HEIGHT_M) ?: return@forEach
val footQ = at(q, -EYE_HEIGHT_M) ?: return@forEach
val topP = at(p, FENCE_WALL_M - EYE_HEIGHT_M) ?: return@forEach
@@ -240,6 +254,5 @@ internal fun DrawScope.drawGeofence(fence: HudFence, pose: CameraPose, focalPx:
end = (topP + topQ) / 2f,
),
)
- drawLine(FenceColor.copy(alpha = FENCE_LINE_ALPHA), footP, footQ, FenceLineWidth.toPx())
}
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
index 2f4a0a0229a..379114272fe 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
@@ -107,10 +107,13 @@ internal fun DrawScope.drawRoute(
val quality = snr?.let { ((it - SNR_FLOOR_DB) / SNR_SPAN_DB).coerceIn(0f, 1f) } ?: UNKNOWN_SNR_QUALITY
val width = ArcMinWidth.toPx() + ArcExtraWidth.toPx() * quality
val stroke = color.copy(alpha = ARC_MIN_ALPHA + (1 - ARC_MIN_ALPHA) * quality)
- if (start.atViewer && !point.atViewer) {
- val end = screen(directionEnu(point.bearingDeg, point.elevationDeg), pose, focalPx)
- if (end != null) drawLine(stroke, Offset(size.width / 2, size.height), end, width, StrokeCap.Round)
- } else if (!start.atViewer && !point.atViewer) {
+ val viewer = Offset(size.width / 2, size.height)
+ if (start.atViewer != point.atViewer) {
+ // A link to or from the phone itself: the forward leg leaves the viewer, the return leg comes home.
+ val far = if (start.atViewer) point else start
+ val end = screen(directionEnu(far.bearingDeg, far.elevationDeg), pose, focalPx)
+ if (end != null) drawLine(stroke, viewer, end, width, StrokeCap.Round)
+ } else if (!start.atViewer) {
val arc = arcDirections(start, point, liftSign)
drawPolyline(arc, pose, focalPx, stroke, width, dashed = skipped > 0)
val apex = screen(arc[arc.size / 2], pose, focalPx)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
index 218d3009316..0ffe41849ed 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
@@ -85,7 +85,7 @@ internal fun radarOffset(target: NearbyTarget, headingDeg: Double, scaleM: Doubl
/**
* A heading-up plan view of the targets too close to point at, with the phone at the center inside its own accuracy
* ring and each target inside its precision ring. Tilting the phone toward the ground [expanded]s it, since that is the
- * posture of someone looking for something close by. The heading is read in layout and draw, so it turns with the
+ * posture of someone looking for something close by. The open [route] and [track] are drawn under the dots. The heading is read in layout and draw, so it turns with the
* camera without recomposing.
*/
@Suppress("LongMethod")
@@ -100,6 +100,8 @@ internal fun NearbyRadar(
expanded: Boolean,
onOpen: (HudSubject) -> Unit,
modifier: Modifier = Modifier,
+ route: HudRoute? = null,
+ track: HudTrack? = null,
) {
val radarSize by animateDpAsState(if (expanded) ExpandedRadar else CompactRadar)
val scaleM =
@@ -120,6 +122,7 @@ internal fun NearbyRadar(
drawCircle(Color.White.copy(alpha = RING_ALPHA), radius - 1.dp.toPx(), style = Stroke(1.dp.toPx()))
drawCircle(Color.White.copy(alpha = RING_ALPHA), radius / 2, style = Stroke(1.dp.toPx()))
ownAccuracyM?.let { drawCircle(Color.White.copy(alpha = OWN_FILL_ALPHA), it * pxPerM) }
+ drawRadarPaths(route, track, heading, pxPerM)
drawCircle(Color.White, 3.dp.toPx())
nearby.forEach { target ->
if (target.radiusM > 0) {
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadarPaths.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadarPaths.kt
new file mode 100644
index 00000000000..d2f9a42f05f
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadarPaths.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.unit.dp
+import org.meshtastic.core.ui.theme.TracerouteColors
+import kotlin.math.cos
+import kotlin.math.sin
+
+private const val RADAR_TRACK_MIN_ALPHA = 0.3f
+private val RadarPathWidth = 2.dp
+private val RadarReturnShift = 3.dp
+private val RadarTrackRing = 3.dp
+private val RadarDashes = floatArrayOf(10f, 8f)
+
+/**
+ * Where a point [distanceM] away on magnetic [bearingDeg] sits on a heading-up radar, as an offset from its center at
+ * [pxPerM]. Unlike [radarOffset] it does not stop at the rim, so a path toward a farther point leaves the radar heading
+ * the right way.
+ */
+internal fun radarPathOffset(bearingDeg: Double, distanceM: Double, headingDeg: Double, pxPerM: Float): Offset {
+ val rel = (bearingDeg - headingDeg).toRadians()
+ val r = distanceM * pxPerM
+ return Offset((r * sin(rel)).toFloat(), (-r * cos(rel)).toFloat())
+}
+
+/**
+ * The open traceroute and track in plan view, under the radar's dots: a scoped view whose nodes are all too close to
+ * point at still shows what it was opened for. The return leg is shifted aside so it does not hide the forward one, and
+ * a hop with no position is bridged dashed, as in the camera view.
+ */
+internal fun DrawScope.drawRadarPaths(route: HudRoute?, track: HudTrack?, headingDeg: Double, pxPerM: Float) {
+ fun at(p: PathPoint) = center + radarPathOffset(p.bearingDeg, p.distanceM, headingDeg, pxPerM)
+ track?.let { drawRadarTrack(it, ::at) }
+ route?.let {
+ drawRadarLeg(it.forward, TracerouteColors.OutgoingRoute, Offset.Zero, ::at)
+ it.back?.let { back ->
+ val shift = RadarReturnShift.toPx()
+ drawRadarLeg(back, TracerouteColors.ReturnRoute, Offset(shift, shift), ::at)
+ }
+ }
+}
+
+private fun DrawScope.drawRadarLeg(leg: RouteLeg, color: Color, shift: Offset, at: (PathPoint) -> Offset) {
+ var previous: Offset? = null
+ var skipped = false
+ leg.points.forEach { point ->
+ if (point == null) {
+ skipped = previous != null
+ return@forEach
+ }
+ val here = at(point) + shift
+ previous?.let { from ->
+ drawLine(
+ color,
+ from,
+ here,
+ RadarPathWidth.toPx(),
+ StrokeCap.Round,
+ pathEffect = if (skipped) PathEffect.dashPathEffect(RadarDashes) else null,
+ )
+ }
+ previous = here
+ skipped = false
+ }
+}
+
+private fun DrawScope.drawRadarTrack(track: HudTrack, at: (PathPoint) -> Offset) {
+ val points = track.points.map { at(it.point) }
+ if (points.isEmpty()) return
+ val base = Color(track.color)
+ fun alphaAt(index: Int) =
+ RADAR_TRACK_MIN_ALPHA + (1 - RADAR_TRACK_MIN_ALPHA) * (index + 1) / points.size.toFloat()
+ points.zipWithNext().forEachIndexed { i, (a, b) ->
+ drawLine(base.copy(alpha = alphaAt(i + 1)), a, b, RadarPathWidth.toPx(), StrokeCap.Round)
+ }
+ points.forEachIndexed { i, p ->
+ drawCircle(base.copy(alpha = alphaAt(i)), RadarTrackRing.toPx(), p, style = Stroke(1.dp.toPx()))
+ }
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
index 16dd9f8b1ec..c9b09aa7546 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
@@ -129,6 +129,8 @@ data class MeshHudTargets(
/** Positioned targets left out by the range limit. */
val hiddenByRange: Int = 0,
val warnings: List = emptyList(),
+ /** False until the first computation, so the screen does not report an empty mesh before it has looked. */
+ val ready: Boolean = false,
)
/**
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
index d7b650f5925..febed2dddf8 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
@@ -336,6 +336,8 @@ fun MeshHudScreen(
currentHeadingDeg = { poseState.value.pose?.azimuthDeg },
expanded = pointingDown,
onOpen = { subject -> (subject as? HudSubject.NodeSubject)?.let { onOpenNode(it.node.num) } },
+ route = route,
+ track = track,
)
},
cameraContent = {
@@ -410,7 +412,7 @@ private fun HudTopBar(
} ?: stringResource(Res.string.mesh_hud_heading, headingText),
)
}
- if (!targets.scoped) {
+ if (targets.ready && !targets.scoped) {
Spacer(Modifier.width(8.dp))
HudRangeToggle(targets, onToggleRange)
}
@@ -474,7 +476,13 @@ private fun HudBottomPanel(
},
)
}
- if (targets.warnings.isEmpty() && targets.targets.isEmpty() && targets.directionUnknown.isEmpty()) {
+ if (
+ targets.ready &&
+ route == null &&
+ targets.warnings.isEmpty() &&
+ targets.targets.isEmpty() &&
+ targets.directionUnknown.isEmpty()
+ ) {
HudLabel(stringResource(Res.string.mesh_hud_no_nodes))
}
if (targets.elevationApproximate && targets.targets.isNotEmpty()) {
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
index 450b1e79cdd..75e74858fb2 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
@@ -74,6 +74,8 @@ private data class HudSettings(
* track or route names are shown.
*/
val solo: Int?,
+ /** Positions to place nodes at when the database has none live (see `fallbackPositions`). */
+ val fallbacks: Map,
)
/** A traceroute to draw: its node nums each way, link SNRs in quarter dB, and the positions it was recorded with. */
@@ -197,6 +199,19 @@ class MeshHudViewModel(
}
}
+ /**
+ * Where to place a node the database has no live position for: the position recorded with the open route, else
+ * the newest point of its open track. A remote node, or one last heard while disconnected, often has only these,
+ * and without them its chip, and the edge arrow leading to it, would never appear.
+ */
+ private val fallbackPositions =
+ combine(routeSource, trackSource) { route, track ->
+ buildMap {
+ track?.positions?.lastOrNull()?.let { put(track.nodeNum, it) }
+ route?.snapshot?.let { putAll(it) }
+ }
+ }
+
/** The phone's averaged fix, shared by the targets, the route and the track. */
private val location =
locationPermissionGranted
@@ -226,9 +241,9 @@ class MeshHudViewModel(
focusKey,
rangeLimited,
mapPrefs.mapFilters,
- combine(pinned, soloNodeNum, ::Pair),
- ) { units, focus, limited, filters, (pinnedNums, solo) ->
- HudSettings(units, focus, limited, filters, pinnedNums, solo)
+ combine(pinned, soloNodeNum, fallbackPositions, ::Triple),
+ ) { units, focus, limited, filters, (pinnedNums, solo, fallbacks) ->
+ HudSettings(units, focus, limited, filters, pinnedNums, solo, fallbacks)
},
packetRepository.getWaypoints(),
) { location, nodes, myNode, settings, waypointPackets ->
@@ -236,9 +251,14 @@ class MeshHudViewModel(
val filter = settings.filters.toNodeMapFilter()
val solo = settings.solo
val shownNodes =
- nodes.values.filter { node ->
+ nodes.values.mapNotNull { node ->
+ if (node.validPosition != null) {
+ node
+ } else {
+ settings.fallbacks[node.num]?.let { node.copy(position = it) }?.takeIf { it.validPosition != null }
+ }
+ }.filter { node ->
node.num != myNode?.myNodeNum &&
- node.validPosition != null &&
if (solo != null) {
node.num == solo || node.num in settings.pinned
} else {
@@ -262,8 +282,9 @@ class MeshHudViewModel(
now,
::declinationAt,
)
- .limitedTo(settings.rangeLimited, settings.units, settings.pinned)
- .copy(averagedFixes = fixAverager.count, scoped = solo != null)
+ // A scoped view has no range toggle, so it must not drop its own node for being far.
+ .limitedTo(settings.rangeLimited && solo == null, settings.units, settings.pinned)
+ .copy(averagedFixes = fixAverager.count, scoped = solo != null, ready = true)
}
.flowOn(dispatchers.default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), MeshHudTargets())
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudRadarTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudRadarTest.kt
index 66e089f8208..dadd47fee72 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudRadarTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudRadarTest.kt
@@ -53,4 +53,11 @@ class HudRadarTest {
val at = radarOffset(nearby(bearing = 0.0, distance = 500.0), headingDeg = 0.0, scaleM = 20.0, radiusPx = 100f)
assertEquals(-100f, at.y, 0.01f)
}
+
+ @Test
+ fun `a path point beyond the scale runs past the rim the same way`() {
+ val at = radarPathOffset(bearingDeg = 180.0, distanceM = 500.0, headingDeg = 90.0, pxPerM = 1f)
+ assertEquals(500f, at.x, 0.01f)
+ assertEquals(0f, at.y, 0.01f)
+ }
}
From 2fdbe94d8bc920efb3efaf7deecaa612e7fe1066 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 19 Oct 2026 17:38:13 -0500
Subject: [PATCH 06/31] refactor(map): mesh view drops drag-to-align and takes
true-north poses
---
.skills/compose-ui/strings-index.txt | 2 -
.../location/AndroidPhoneLocationProvider.kt | 2 +-
.../composeResources/values/strings.xml | 2 -
.../feature/map/hud/CameraPoseProvider.kt | 4 +-
.../meshtastic/feature/map/hud/HudGeometry.kt | 2 +-
.../meshtastic/feature/map/hud/HudOverlay.kt | 7 +-
.../meshtastic/feature/map/hud/HudRadar.kt | 4 +-
.../feature/map/hud/HudRadarPaths.kt | 3 +-
.../feature/map/hud/MeshHudScreen.kt | 42 +-----------
.../feature/map/hud/MeshHudViewModel.kt | 67 ++++++++++---------
10 files changed, 51 insertions(+), 84 deletions(-)
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index e2b3c2cf04e..d62fdec82a6 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1096,14 +1096,12 @@ mesh_beacon_target_channel_index
mesh_beacon_target_default
mesh_beacon_target_remove
mesh_beacon_targets
-mesh_hud_aligned
mesh_hud_camera_rationale
mesh_hud_cardinal_east
mesh_hud_cardinal_north
mesh_hud_cardinal_south
mesh_hud_cardinal_west
mesh_hud_distance_no_altitude
-mesh_hud_drag_to_align
mesh_hud_elevation_approximate
mesh_hud_find
mesh_hud_heading
diff --git a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
index 4c0949bb559..3d2307080aa 100644
--- a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
+++ b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/location/AndroidPhoneLocationProvider.kt
@@ -22,10 +22,10 @@ import android.location.Location
import android.location.LocationManager
import android.os.Looper
import androidx.core.location.LocationCompat
-import androidx.core.location.altitude.AltitudeConverterCompat
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
+import androidx.core.location.altitude.AltitudeConverterCompat
import co.touchlab.kermit.Logger
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index ef99020c37c..12f74934d2d 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1138,14 +1138,12 @@
Default
Remove target
Broadcast targets
- Aligned %1$s · reset
Allow camera access to see nodes over the camera view.
E
N
S
W
%1$s · no altitude
- Drag sideways to line a chip up with what you see
Elevation is approximate
Find
Heading %1$s
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt
index bcc48b8f051..b21b78cb424 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt
@@ -20,13 +20,15 @@ import kotlinx.coroutines.flow.Flow
/**
* [calibration] is the platform's sensor status, 3 high down to 0 unreliable, and -1 before the first reading.
- * [headingAccuracyDeg] is the sensor's own estimate of its heading error, null when it gives none.
+ * [headingAccuracyDeg] is the sensor's own estimate of its heading error, null when it gives none. [trueNorth] is true
+ * when the source already applied the declination, so the pose is referenced to true north rather than magnetic.
*/
data class CameraPoseState(
val pose: CameraPose? = null,
val hasSensor: Boolean = true,
val calibration: Int = CALIBRATION_UNKNOWN,
val headingAccuracyDeg: Double? = null,
+ val trueNorth: Boolean = false,
)
const val CALIBRATION_UNKNOWN = -1
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt
index 817f4feb4c9..fc43c17eaaf 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt
@@ -60,7 +60,7 @@ data class CameraPose(val right: Vec3, val up: Vec3, val forward: Vec3) {
}
}
-/** This pose turned clockwise about the vertical by [deg], as a manual heading correction applies it. */
+/** This pose turned clockwise about the vertical by [deg]. */
fun CameraPose.rotatedAboutVertical(deg: Double): CameraPose {
if (deg == 0.0) return this
val c = cos(deg.toRadians())
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
index 730ef90ccb1..418ad29f1d6 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
@@ -230,7 +230,12 @@ internal fun DrawScope.drawGeofence(fence: HudFence, pose: CameraPose, focalPx:
val fa = project(a, pose, focalPx, size.width, size.height)
val fb = project(b, pose, focalPx, size.width, size.height)
if (fa != null && fb != null) {
- drawLine(FenceColor.copy(alpha = FENCE_LINE_ALPHA), Offset(fa.x, fa.y), Offset(fb.x, fb.y), FenceLineWidth.toPx())
+ drawLine(
+ FenceColor.copy(alpha = FENCE_LINE_ALPHA),
+ Offset(fa.x, fa.y),
+ Offset(fb.x, fb.y),
+ FenceLineWidth.toPx(),
+ )
}
}
// The wall is decoration: drawn only where its whole segment is in front of the camera.
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
index 0ffe41849ed..c04ec2a8b5d 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
@@ -85,8 +85,8 @@ internal fun radarOffset(target: NearbyTarget, headingDeg: Double, scaleM: Doubl
/**
* A heading-up plan view of the targets too close to point at, with the phone at the center inside its own accuracy
* ring and each target inside its precision ring. Tilting the phone toward the ground [expanded]s it, since that is the
- * posture of someone looking for something close by. The open [route] and [track] are drawn under the dots. The heading is read in layout and draw, so it turns with the
- * camera without recomposing.
+ * posture of someone looking for something close by. The open [route] and [track] are drawn under the dots. The heading
+ * is read in layout and draw, so it turns with the camera without recomposing.
*/
@Suppress("LongMethod")
@Composable
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadarPaths.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadarPaths.kt
index d2f9a42f05f..f2e2919e8a3 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadarPaths.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadarPaths.kt
@@ -89,8 +89,7 @@ private fun DrawScope.drawRadarTrack(track: HudTrack, at: (PathPoint) -> Offset)
val points = track.points.map { at(it.point) }
if (points.isEmpty()) return
val base = Color(track.color)
- fun alphaAt(index: Int) =
- RADAR_TRACK_MIN_ALPHA + (1 - RADAR_TRACK_MIN_ALPHA) * (index + 1) / points.size.toFloat()
+ fun alphaAt(index: Int) = RADAR_TRACK_MIN_ALPHA + (1 - RADAR_TRACK_MIN_ALPHA) * (index + 1) / points.size.toFloat()
points.zipWithNext().forEachIndexed { i, (a, b) ->
drawLine(base.copy(alpha = alphaAt(i + 1)), a, b, RadarPathWidth.toPx(), StrokeCap.Round)
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
index febed2dddf8..096547f42b8 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
@@ -19,8 +19,6 @@ package org.meshtastic.feature.map.hud
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -51,7 +49,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
@@ -73,13 +70,11 @@ import org.meshtastic.core.resources.compass_location_disabled
import org.meshtastic.core.resources.compass_no_location_fix
import org.meshtastic.core.resources.compass_no_location_permission
import org.meshtastic.core.resources.compass_no_magnetometer
-import org.meshtastic.core.resources.mesh_hud_aligned
import org.meshtastic.core.resources.mesh_hud_camera_rationale
import org.meshtastic.core.resources.mesh_hud_cardinal_east
import org.meshtastic.core.resources.mesh_hud_cardinal_north
import org.meshtastic.core.resources.mesh_hud_cardinal_south
import org.meshtastic.core.resources.mesh_hud_cardinal_west
-import org.meshtastic.core.resources.mesh_hud_drag_to_align
import org.meshtastic.core.resources.mesh_hud_elevation_approximate
import org.meshtastic.core.resources.mesh_hud_heading
import org.meshtastic.core.resources.mesh_hud_heading_accuracy
@@ -112,7 +107,6 @@ import org.meshtastic.core.ui.util.rememberCameraPermissionState
import org.meshtastic.core.ui.util.rememberLocationPermissionState
import org.meshtastic.feature.map.component.WaypointInfoDialog
import org.meshtastic.proto.Waypoint
-import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -133,10 +127,6 @@ private const val ON_TARGET_DEG = 5.0
private const val HINT_STEP_DEG = 5
private val HudScrim = Color.Black.copy(alpha = 0.55f)
private val TapeGap = 4.dp
-private const val HALF_TURN_DEG = 180.0
-
-/** A heading correction smaller than this is not worth a reset control. */
-private const val MIN_SHOWN_OFFSET_DEG = 0.5
/** Pitched further down than this, the user is looking for something close by, so the radar grows. */
private const val POINTING_DOWN_DEG = -45.0
@@ -218,7 +208,6 @@ fun MeshHudScreen(
}
}
val hasSensor by remember { derivedStateOf { poseState.value.hasSensor } }
- val headingOffsetDeg by viewModel.headingOffsetDeg.collectAsStateWithLifecycle()
val waitingForSensor by remember {
derivedStateOf { sensorSilent && poseState.value.hasSensor && poseState.value.pose == null }
}
@@ -245,17 +234,7 @@ fun MeshHudScreen(
if (camera.isGranted) HudCameraPreview(onIntrinsics = { intrinsics = it }, modifier = Modifier.fillMaxSize())
- val pxPerDeg = focalPx * (PI / HALF_TURN_DEG).toFloat()
- // Dragging the empty view sideways lines the overlay up with a landmark, correcting a compass that metal or a
- // fixed accuracy report leaves off. Chips sit above and take their own taps.
- Canvas(
- Modifier.fillMaxSize().pointerInput(pxPerDeg) {
- detectHorizontalDragGestures { change, dragAmount ->
- change.consume()
- viewModel.nudgeHeading(-dragAmount / pxPerDeg.toDouble())
- }
- },
- ) {
+ Canvas(Modifier.fillMaxSize()) {
val state = poseState.value
val pose = state.pose ?: return@Canvas
drawHorizon(pose, focalPx)
@@ -310,8 +289,6 @@ fun MeshHudScreen(
headingDeg = headingDeg,
headingAccuracyDeg = headingAccuracyDeg,
hasSensor = hasSensor,
- headingOffsetDeg = headingOffsetDeg,
- onResetHeading = viewModel::resetHeadingOffset,
needsCalibration = needsCalibration,
waitingForSensor = waitingForSensor,
targets = targets,
@@ -390,8 +367,6 @@ private fun HudTopBar(
headingDeg: Int?,
headingAccuracyDeg: Int?,
hasSensor: Boolean,
- headingOffsetDeg: Double,
- onResetHeading: () -> Unit,
needsCalibration: Boolean,
waitingForSensor: Boolean,
targets: MeshHudTargets,
@@ -425,25 +400,12 @@ private fun HudTopBar(
),
)
}
- if (abs(headingOffsetDeg) >= MIN_SHOWN_OFFSET_DEG) {
- HudLabel(
- text =
- stringResource(
- Res.string.mesh_hud_aligned,
- (if (headingOffsetDeg >= 0) "+" else "-") + formatString("%.0f°", abs(headingOffsetDeg)),
- ),
- modifier = Modifier.clip(MaterialTheme.shapes.small).clickable(onClick = onResetHeading),
- )
- }
when {
!hasSensor -> HudLabel(stringResource(Res.string.compass_no_magnetometer))
waitingForSensor -> HudLabel(stringResource(Res.string.mesh_hud_waiting_sensor))
- needsCalibration -> {
- HudLabel(stringResource(Res.string.mesh_hud_heading_low_accuracy))
- HudLabel(stringResource(Res.string.mesh_hud_drag_to_align))
- }
+ needsCalibration -> HudLabel(stringResource(Res.string.mesh_hud_heading_low_accuracy))
}
}
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
index 75e74858fb2..32af7d0263d 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -200,9 +201,9 @@ class MeshHudViewModel(
}
/**
- * Where to place a node the database has no live position for: the position recorded with the open route, else
- * the newest point of its open track. A remote node, or one last heard while disconnected, often has only these,
- * and without them its chip, and the edge arrow leading to it, would never appear.
+ * Where to place a node the database has no live position for: the position recorded with the open route, else the
+ * newest point of its open track. A remote node, or one last heard while disconnected, often has only these, and
+ * without them its chip, and the edge arrow leading to it, would never appear.
*/
private val fallbackPositions =
combine(routeSource, trackSource) { route, track ->
@@ -219,14 +220,21 @@ class MeshHudViewModel(
.map { state -> state.location?.let { state.copy(location = fixAverager.add(it)) } ?: state }
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), replay = 1)
- private val _headingOffsetDeg = MutableStateFlow(0.0)
-
- /** The user's drag correction to the compass, added to the camera's heading. */
- val headingOffsetDeg: StateFlow = _headingOffsetDeg
+ /** The magnetic declination where the phone is, zero until it has a fix. */
+ private val declinationDeg =
+ location.map { state -> state.location?.let(::declinationAt) ?: 0f }.onStart { emit(0f) }.distinctUntilChanged()
+ /**
+ * The camera's pose, always referenced to magnetic north like the rotation vector: a source that already gives true
+ * north is turned back by the declination, which every target, the tape and the radar then add once.
+ */
val pose: StateFlow =
- combine(poseProvider.poseUpdates().conflate(), _headingOffsetDeg) { state, offset ->
- state.copy(pose = state.pose?.rotatedAboutVertical(offset))
+ combine(poseProvider.poseUpdates().conflate(), declinationDeg) { state, declination ->
+ if (state.trueNorth) {
+ state.copy(pose = state.pose?.rotatedAboutVertical(-declination.toDouble()), trueNorth = false)
+ } else {
+ state
+ }
}
.flowOn(dispatchers.default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), CameraPoseState())
@@ -251,22 +259,26 @@ class MeshHudViewModel(
val filter = settings.filters.toNodeMapFilter()
val solo = settings.solo
val shownNodes =
- nodes.values.mapNotNull { node ->
- if (node.validPosition != null) {
- node
- } else {
- settings.fallbacks[node.num]?.let { node.copy(position = it) }?.takeIf { it.validPosition != null }
- }
- }.filter { node ->
- node.num != myNode?.myNodeNum &&
- if (solo != null) {
- node.num == solo || node.num in settings.pinned
+ nodes.values
+ .mapNotNull { node ->
+ if (node.validPosition != null) {
+ node
} else {
- HudSubject.NodeSubject(node).key == settings.focusKey ||
- node.num in settings.pinned ||
- node.passesMapFilter(filter, now)
+ settings.fallbacks[node.num]
+ ?.let { node.copy(position = it) }
+ ?.takeIf { it.validPosition != null }
}
- }
+ }
+ .filter { node ->
+ node.num != myNode?.myNodeNum &&
+ if (solo != null) {
+ node.num == solo || node.num in settings.pinned
+ } else {
+ HudSubject.NodeSubject(node).key == settings.focusKey ||
+ node.num in settings.pinned ||
+ node.passesMapFilter(filter, now)
+ }
+ }
val waypoints =
if (solo == null && settings.filters.showWaypoints) {
waypointPackets.activeWaypointPackets(now).values.mapNotNull { it.waypoint }
@@ -363,15 +375,6 @@ class MeshHudViewModel(
locationPermissionGranted.value = granted
}
- /** Nudges the heading correction by [deltaDeg], kept within one turn either way. */
- fun nudgeHeading(deltaDeg: Double) {
- _headingOffsetDeg.value = signedTurnDeg(0.0, _headingOffsetDeg.value + deltaDeg)
- }
-
- fun resetHeadingOffset() {
- _headingOffsetDeg.value = 0.0
- }
-
fun toggleRangeLimit() {
rangeLimited.value = !rangeLimited.value
}
From 95e4d50145c52a717166384f9e99f9a2c15ae991 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 19 Oct 2026 17:39:25 -0500
Subject: [PATCH 07/31] feat(map): mesh view entry points use the
center_focus_strong icon
---
.../drawable/ic_center_focus_strong.xml | 12 ++++++++++++
.../composeResources/drawable/ic_view_in_ar.xml | 10 ----------
.../kotlin/org/meshtastic/core/ui/icon/Hardware.kt | 8 ++++----
.../kotlin/org/meshtastic/feature/map/MapScreen.kt | 4 ++--
.../feature/node/component/CompassBottomSheet.kt | 4 ++--
.../feature/node/component/PositionSection.kt | 4 ++--
.../feature/node/metrics/PositionLogScreens.kt | 4 ++--
.../feature/node/metrics/TracerouteMapScreen.kt | 4 ++--
8 files changed, 26 insertions(+), 24 deletions(-)
create mode 100644 core/resources/src/commonMain/composeResources/drawable/ic_center_focus_strong.xml
delete mode 100644 core/resources/src/commonMain/composeResources/drawable/ic_view_in_ar.xml
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_center_focus_strong.xml b/core/resources/src/commonMain/composeResources/drawable/ic_center_focus_strong.xml
new file mode 100644
index 00000000000..b16af13b13b
--- /dev/null
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_center_focus_strong.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_view_in_ar.xml b/core/resources/src/commonMain/composeResources/drawable/ic_view_in_ar.xml
deleted file mode 100644
index d2d37ed3317..00000000000
--- a/core/resources/src/commonMain/composeResources/drawable/ic_view_in_ar.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt
index 4c664ea21ab..3761776142c 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt
@@ -25,6 +25,7 @@ import org.meshtastic.core.resources.ic_bluetooth_connected
import org.meshtastic.core.resources.ic_bluetooth_searching
import org.meshtastic.core.resources.ic_build
import org.meshtastic.core.resources.ic_cached
+import org.meshtastic.core.resources.ic_center_focus_strong
import org.meshtastic.core.resources.ic_display_settings
import org.meshtastic.core.resources.ic_memory
import org.meshtastic.core.resources.ic_mesh_radio
@@ -34,16 +35,15 @@ import org.meshtastic.core.resources.ic_speaker_phone
import org.meshtastic.core.resources.ic_terminal
import org.meshtastic.core.resources.ic_usb
import org.meshtastic.core.resources.ic_usb_off
-import org.meshtastic.core.resources.ic_view_in_ar
import org.meshtastic.core.resources.ic_wifi
/** The maker hardware mark from meshtastic/design#160: a wrench, Material Symbols "build". */
val MeshtasticIcons.Wrench: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_build)
-/** Material Symbols "view_in_ar". */
-val MeshtasticIcons.ViewInAr: ImageVector
- @Composable get() = vectorResource(Res.drawable.ic_view_in_ar)
+/** Material Symbols "center_focus_strong", a viewfinder: the camera mesh view. */
+val MeshtasticIcons.CenterFocusStrong: ImageVector
+ @Composable get() = vectorResource(Res.drawable.ic_center_focus_strong)
val MeshtasticIcons.BluetoothConnected: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_bluetooth_connected)
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 90ef447d761..a504d19e216 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
@@ -31,7 +31,7 @@ import org.meshtastic.core.resources.map
import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.ViewInAr
+import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.util.LocalMapViewProvider
@Composable
@@ -62,7 +62,7 @@ fun MapScreen(
onOpenMeshHud?.let { onClick ->
IconButton(onClick = onClick) {
Icon(
- MeshtasticIcons.ViewInAr,
+ MeshtasticIcons.CenterFocusStrong,
contentDescription = stringResource(Res.string.mesh_hud_open),
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
index aca1739f356..5fb8d8487df 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
@@ -75,7 +75,7 @@ import org.meshtastic.core.ui.component.ElevationInfo
import org.meshtastic.core.ui.icon.ErrorOutline
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
-import org.meshtastic.core.ui.icon.ViewInAr
+import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassWarning
import kotlin.math.PI
@@ -173,7 +173,7 @@ fun CompassSheetContent(
onOpenMeshHud?.let { onClick ->
FilledTonalButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
- Icon(imageVector = MeshtasticIcons.ViewInAr, contentDescription = null)
+ Icon(imageVector = MeshtasticIcons.CenterFocusStrong, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(Res.string.mesh_hud_open))
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index aec5c56baad..3917a9af8c0 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -48,7 +48,7 @@ import org.meshtastic.core.ui.icon.CellTower
import org.meshtastic.core.ui.icon.Compass
import org.meshtastic.core.ui.icon.Distance
import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.ViewInAr
+import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalSitePlannerAvailable
import org.meshtastic.core.ui.util.rememberMeshHudSupported
@@ -93,7 +93,7 @@ internal fun PositionInlineContent(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
) {
- Icon(MeshtasticIcons.ViewInAr, null, Modifier.size(18.dp))
+ Icon(MeshtasticIcons.CenterFocusStrong, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(Res.string.mesh_hud_open),
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
index ad1623045b8..9ec04f84438 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
@@ -43,7 +43,7 @@ import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.FileDownload
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
-import org.meshtastic.core.ui.icon.ViewInAr
+import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.rememberSaveFileLauncher
@@ -77,7 +77,7 @@ fun PositionLogScreen(
if (hasTrackPoint && onOpenMeshHud != null) {
IconButton(onClick = onOpenMeshHud) {
Icon(
- imageVector = MeshtasticIcons.ViewInAr,
+ imageVector = MeshtasticIcons.CenterFocusStrong,
contentDescription = stringResource(Res.string.mesh_hud_open),
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
index 90a6c558fc9..6fc086ce734 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
@@ -53,7 +53,7 @@ import org.meshtastic.core.resources.traceroute_showing_nodes
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Route
-import org.meshtastic.core.ui.icon.ViewInAr
+import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
@@ -122,7 +122,7 @@ private fun TracerouteMapScaffold(
onOpenMeshHud?.let { onClick ->
IconButton(onClick = onClick) {
Icon(
- MeshtasticIcons.ViewInAr,
+ MeshtasticIcons.CenterFocusStrong,
contentDescription = stringResource(Res.string.mesh_hud_open),
)
}
From f5376241e46c7afc226ae6febebfcc52d3ddd69e Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 19 Oct 2026 17:39:45 -0500
Subject: [PATCH 08/31] feat(map): mesh view pose carries the phone's thermal
state
---
.../org/meshtastic/feature/map/hud/CameraPoseProvider.kt | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt
index b21b78cb424..b55b5035602 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/CameraPoseProvider.kt
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.Flow
* [calibration] is the platform's sensor status, 3 high down to 0 unreliable, and -1 before the first reading.
* [headingAccuracyDeg] is the sensor's own estimate of its heading error, null when it gives none. [trueNorth] is true
* when the source already applied the declination, so the pose is referenced to true north rather than magnetic.
+ * [thermal] is how hot the phone runs, so the view can shed work before the system throttles it.
*/
data class CameraPoseState(
val pose: CameraPose? = null,
@@ -29,8 +30,16 @@ data class CameraPoseState(
val calibration: Int = CALIBRATION_UNKNOWN,
val headingAccuracyDeg: Double? = null,
val trueNorth: Boolean = false,
+ val thermal: HudThermal = HudThermal.NORMAL,
)
+/** [HOT] slows the sensors and says so; [CRITICAL] also releases the camera, leaving the overlay as a compass. */
+enum class HudThermal {
+ NORMAL,
+ HOT,
+ CRITICAL,
+}
+
const val CALIBRATION_UNKNOWN = -1
interface CameraPoseProvider {
From 97d619c17ec09ff1b7c5420c1bd4a703e9c1194f Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 19 Oct 2026 17:47:37 -0500
Subject: [PATCH 09/31] feat(node): node list menu opens the mesh view, and the
mesh view never stacks on itself
---
.../feature/node/component/NodeContextMenu.kt | 21 +++-
.../feature/node/list/NodeListScreen.kt | 2 +
.../node/navigation/AdaptiveNodeListScreen.kt | 2 +
.../node/navigation/MeshHudBackStack.kt | 43 ++++++++
.../node/navigation/NodesNavigation.kt | 10 +-
.../node/navigation/MeshHudBackStackTest.kt | 100 ++++++++++++++++++
6 files changed, 174 insertions(+), 4 deletions(-)
create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStack.kt
create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStackTest.kt
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt
index 51fecd6bbb7..a6c695b8f2d 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt
@@ -38,6 +38,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_favorite
import org.meshtastic.core.resources.ignore
+import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.resources.message
import org.meshtastic.core.resources.mute_notifications
import org.meshtastic.core.resources.remove
@@ -46,6 +47,7 @@ import org.meshtastic.core.resources.remove_ignored
import org.meshtastic.core.resources.trace_route
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.resources.update_status
+import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.icon.DeleteNode
import org.meshtastic.core.ui.icon.DoDisturb
import org.meshtastic.core.ui.icon.Favorite
@@ -57,9 +59,10 @@ import org.meshtastic.core.ui.icon.Route
import org.meshtastic.core.ui.icon.VolumeOff
import org.meshtastic.core.ui.icon.VolumeUp
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
+import org.meshtastic.core.ui.util.rememberMeshHudSupported
/**
- * Shared context menu for node actions (favorite, mute, message, trace route, ignore, remove).
+ * Shared context menu for node actions (favorite, mute, message, trace route, mesh view, ignore, remove).
*
* Used by both Android and Desktop adaptive node list screens.
*/
@@ -75,6 +78,7 @@ fun NodeContextMenu(
onIgnore: () -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
+ onOpenMeshView: () -> Unit = {},
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
@@ -84,6 +88,9 @@ fun NodeContextMenu(
}
MessageMenuItem(node, onMessage, onDismiss)
TraceRouteMenuItem(node, lastTracerouteTime, onTraceRoute, onDismiss)
+ if (node.validPosition != null && rememberMeshHudSupported()) {
+ MeshViewMenuItem(onOpenMeshView, onDismiss)
+ }
}
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
IgnoreMenuItem(node, onIgnore, onDismiss)
@@ -212,6 +219,18 @@ private fun TraceRouteMenuItem(
private const val COOLDOWN_TICK_MS = 500L
+@Composable
+private fun MeshViewMenuItem(onOpenMeshView: () -> Unit, onDismiss: () -> Unit) {
+ DropdownMenuItem(
+ onClick = {
+ onOpenMeshView()
+ onDismiss()
+ },
+ leadingIcon = { Icon(imageVector = MeshtasticIcons.CenterFocusStrong, contentDescription = null) },
+ text = { Text(text = stringResource(Res.string.mesh_hud_open)) },
+ )
+}
+
@Composable
private fun RemoveMenuItem(node: Node, onRemove: () -> Unit, onDismiss: () -> Unit) {
DropdownMenuItem(
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index b6cd2b0f3f3..4217aaa4ea8 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -130,6 +130,7 @@ fun NodeListScreen(
onHandleDeepLink: (org.meshtastic.core.common.util.CommonUri, onInvalid: () -> Unit) -> Unit = { _, _ -> },
onNavigateToConnections: () -> Unit = {},
onEditStatusMessage: () -> Unit = {},
+ navigateToMeshView: (Int) -> Unit = {},
) {
val showToast = org.meshtastic.core.ui.util.rememberShowToastResource()
val scope = rememberCoroutineScope()
@@ -274,6 +275,7 @@ fun NodeListScreen(
onIgnore = { viewModel.ignoreNode(node) },
onRemove = { viewModel.removeNodes(listOf(node)) },
onDismiss = { expanded = false },
+ onOpenMeshView = { navigateToMeshView(node.num) },
)
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
index 081980a6382..4fcc6fe7afc 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
@@ -22,6 +22,7 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ContactsRoute
+import org.meshtastic.core.navigation.MapRoute
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.ui.component.ScrollToTopEvent
@@ -47,5 +48,6 @@ fun AdaptiveNodeListScreen(
onNavigateToConnections = onNavigateToConnections,
// A bare radio-config route on this tab's stack resolves to the local session, the same as Connections -> LoRa.
onEditStatusMessage = { backStack.add(SettingsRoute.UserStatusMessage) },
+ navigateToMeshView = { num -> backStack.add(MapRoute.MeshHud(focusNodeNum = num)) },
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStack.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStack.kt
new file mode 100644
index 00000000000..b1045babf03
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStack.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.navigation
+
+import androidx.navigation3.runtime.NavKey
+import org.meshtastic.core.navigation.MapRoute
+import org.meshtastic.core.navigation.NodeDetailRoute
+import org.meshtastic.core.navigation.NodesRoute
+
+/**
+ * Pushes [route] from a node detail screen. A mesh view opened from node screens that were themselves opened from a
+ * mesh view replaces that mesh view rather than stacking a second one.
+ */
+internal fun MutableList.navigateFromNodeDetail(route: NavKey) {
+ val meshHudIndex = if (route is MapRoute.MeshHud) indexOfMeshHudUnderNodeDetail() else -1
+ // Add before removing so the back stack is never momentarily empty.
+ add(route)
+ if (meshHudIndex >= 0) {
+ repeat(lastIndex - meshHudIndex) { removeAt(meshHudIndex) }
+ }
+}
+
+private fun List.indexOfMeshHudUnderNodeDetail(): Int {
+ var index = lastIndex
+ while (index >= 0 && this[index].isNodeScreen()) index--
+ return if (index in 0 until lastIndex && this[index] is MapRoute.MeshHud) index else -1
+}
+
+private fun NavKey.isNodeScreen(): Boolean = this is NodesRoute.NodeDetail || this is NodeDetailRoute
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
index e54b15003f2..f6a7a628737 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
@@ -110,7 +110,7 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack)
viewModel = nodeDetailViewModel,
compassViewModel = compassViewModel,
navigateToMessages = { key -> backStack.add(ContactsRoute.Messages(key)) },
- onNavigate = { route -> backStack.add(route) },
+ onNavigate = { route -> backStack.navigateFromNodeDetail(route) },
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
)
}
@@ -146,7 +146,7 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack)
onOpenMeshHud =
if (rememberMeshHudSupported()) {
{
- backStack.add(
+ backStack.navigateFromNodeDetail(
MapRoute.MeshHud(
focusNodeNum = args.destNum,
tracerouteDestNum = args.destNum,
@@ -180,7 +180,11 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack)
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
onOpenMeshHud =
if (rememberMeshHudSupported()) {
- { backStack.add(MapRoute.MeshHud(focusNodeNum = args.destNum, trackNodeNum = args.destNum)) }
+ {
+ backStack.navigateFromNodeDetail(
+ MapRoute.MeshHud(focusNodeNum = args.destNum, trackNodeNum = args.destNum),
+ )
+ }
} else {
null
},
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStackTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStackTest.kt
new file mode 100644
index 00000000000..67c08d0079c
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStackTest.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.navigation
+
+import androidx.navigation3.runtime.NavKey
+import org.meshtastic.core.navigation.ContactsRoute
+import org.meshtastic.core.navigation.MapRoute
+import org.meshtastic.core.navigation.NodeDetailRoute
+import org.meshtastic.core.navigation.NodesRoute
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class MeshHudBackStackTest {
+
+ @Test
+ fun `mesh view from a detail opened by a mesh view replaces that mesh view`() {
+ val stack = mutableListOf(NodesRoute.Nodes, MapRoute.MeshHud(1), NodesRoute.NodeDetail(2))
+
+ stack.navigateFromNodeDetail(MapRoute.MeshHud(focusNodeNum = 2))
+
+ assertEquals(listOf(NodesRoute.Nodes, MapRoute.MeshHud(focusNodeNum = 2)), stack)
+ }
+
+ @Test
+ fun `mesh view from a position log under a mesh view pops through the detail screens`() {
+ val stack =
+ mutableListOf(
+ NodesRoute.Nodes,
+ MapRoute.MeshHud(1),
+ NodesRoute.NodeDetail(2),
+ NodeDetailRoute.PositionLog(2),
+ )
+ val track = MapRoute.MeshHud(focusNodeNum = 2, trackNodeNum = 2)
+
+ stack.navigateFromNodeDetail(track)
+
+ assertEquals(listOf(NodesRoute.Nodes, track), stack)
+ }
+
+ @Test
+ fun `mesh view from a detail opened by the node list is pushed`() {
+ val stack = mutableListOf(NodesRoute.Nodes, NodesRoute.NodeDetail(2))
+
+ stack.navigateFromNodeDetail(MapRoute.MeshHud(focusNodeNum = 2))
+
+ assertEquals(
+ listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(2), MapRoute.MeshHud(focusNodeNum = 2)),
+ stack,
+ )
+ }
+
+ @Test
+ fun `other routes from a detail above a mesh view are pushed`() {
+ val stack = mutableListOf(NodesRoute.Nodes, MapRoute.MeshHud(1), NodesRoute.NodeDetail(2))
+
+ stack.navigateFromNodeDetail(ContactsRoute.Messages("0!00000002"))
+
+ assertEquals(
+ listOf(
+ NodesRoute.Nodes,
+ MapRoute.MeshHud(1),
+ NodesRoute.NodeDetail(2),
+ ContactsRoute.Messages("0!00000002"),
+ ),
+ stack,
+ )
+ }
+
+ @Test
+ fun `mesh view directly on top of a mesh view is pushed`() {
+ val stack = mutableListOf(NodesRoute.Nodes, MapRoute.MeshHud(1))
+
+ stack.navigateFromNodeDetail(MapRoute.MeshHud(focusNodeNum = 2))
+
+ assertEquals(listOf(NodesRoute.Nodes, MapRoute.MeshHud(1), MapRoute.MeshHud(focusNodeNum = 2)), stack)
+ }
+
+ @Test
+ fun `mesh view onto an empty stack is pushed`() {
+ val stack = mutableListOf()
+
+ stack.navigateFromNodeDetail(MapRoute.MeshHud(focusNodeNum = 2))
+
+ assertEquals(listOf(MapRoute.MeshHud(focusNodeNum = 2)), stack)
+ }
+}
From 1214cf0c782c75ddd83c3097b4b992338d74173e Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 19 Oct 2026 17:59:04 -0500
Subject: [PATCH 10/31] feat(map): mesh view takes the fused orientation
provider on google, with sensor fallbacks and thermal backoff
---
.../org/meshtastic/app/di/FlavorModule.kt | 4 +
.../org/meshtastic/app/di/FlavorModule.kt | 7 +
.../map/hud/GoogleFusedOrientationSource.kt | 65 +++++
.../core/ui/util/MeshHudSupport.android.kt | 7 +-
.../feature/map/hud/DeviceAttitudeTest.kt | 82 ++++++
.../map/hud/AndroidCameraPoseProvider.kt | 251 +++++++++++++-----
.../feature/map/hud/DeviceAttitude.kt | 56 ++++
.../feature/map/hud/FusedOrientationSource.kt | 53 ++++
8 files changed, 451 insertions(+), 74 deletions(-)
create mode 100644 androidApp/src/google/kotlin/org/meshtastic/app/map/hud/GoogleFusedOrientationSource.kt
create mode 100644 feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/hud/DeviceAttitudeTest.kt
create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/DeviceAttitude.kt
create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/FusedOrientationSource.kt
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
index 99661f0bf8a..8398de840c4 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -21,6 +21,7 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.GOOGLE_SERVICES_AVAILABLE
import org.meshtastic.core.ui.theme.EventFontResolver
+import org.meshtastic.feature.map.hud.FusedOrientationSource
@Module(includes = [FdroidAiModule::class])
class FlavorModule {
@@ -32,6 +33,9 @@ class FlavorModule {
/** No Play Services font provider on F-Droid — event fonts stay off; UI falls back to the app typeface. */
@Single fun eventFontResolver(): EventFontResolver = EventFontResolver { null }
+ /** F-Droid has no Play services orientation fusion, so the camera mesh view uses the platform rotation vector. */
+ @Single fun fusedOrientationSource(): FusedOrientationSource = FusedOrientationSource.NONE
+
/** No analytics on F-Droid — the shared Ktor `HttpClient` gets no Datadog interceptor or event listener. */
@Single fun okHttpNetworkInstrumentation(): OkHttpNetworkInstrumentation = OkHttpNetworkInstrumentation.NONE
}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
index e515c1d1b58..a97c7802d9d 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -18,19 +18,26 @@
package org.meshtastic.app.di
+import android.content.Context
import com.datadog.android.okhttp.DatadogEventListener
import com.datadog.android.okhttp.DatadogInterceptor
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
+import org.meshtastic.app.map.hud.GoogleFusedOrientationSource
import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule
import org.meshtastic.app.theme.GoogleFontsEventFontResolver
import org.meshtastic.core.ui.theme.EventFontResolver
+import org.meshtastic.feature.map.hud.FusedOrientationSource
@Module(includes = [GoogleMapsKoinModule::class, GoogleAiModule::class, AppFunctionsModule::class])
class FlavorModule {
/** Downloadable Google Fonts for event branding — Google flavor only. */
@Single fun eventFontResolver(): EventFontResolver = GoogleFontsEventFontResolver()
+ /** Play services' fused orientation for the camera mesh view, which falls back to the rotation vector without it. */
+ @Single
+ fun fusedOrientationSource(context: Context): FusedOrientationSource = GoogleFusedOrientationSource(context)
+
/**
* Datadog network instrumentation for the shared Ktor `HttpClient`. The interceptor emits RUM Resource spans with
* full DNS/connect/SSL/download timing for first-party (`meshtastic.org`) requests, and the event listener supplies
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/hud/GoogleFusedOrientationSource.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/hud/GoogleFusedOrientationSource.kt
new file mode 100644
index 00000000000..244f8cebab1
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/hud/GoogleFusedOrientationSource.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.map.hud
+
+import android.content.Context
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailabilityLight
+import com.google.android.gms.location.DeviceOrientationListener
+import com.google.android.gms.location.DeviceOrientationRequest
+import com.google.android.gms.location.LocationServices
+import org.meshtastic.feature.map.hud.FusedOrientationSource
+import java.util.concurrent.Executor
+
+/** The platform's SENSOR_DELAY_UI period, which the rotation-vector path drops to when the phone runs hot. */
+private const val REDUCED_PERIOD_MICROS = 66_667L
+
+/** Play services' Fused Orientation Provider, the same fusion Google Maps draws its heading from. */
+class GoogleFusedOrientationSource(private val context: Context) : FusedOrientationSource {
+
+ override fun start(
+ reduced: Boolean,
+ executor: Executor,
+ listener: FusedOrientationSource.Listener,
+ onFailure: () -> Unit,
+ ): AutoCloseable? {
+ val availability = GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context)
+ if (availability != ConnectionResult.SUCCESS) return null
+ val client = LocationServices.getFusedOrientationProviderClient(context)
+ val callback = DeviceOrientationListener { orientation ->
+ val headingError =
+ if (orientation.hasConservativeHeadingErrorDegrees()) {
+ orientation.conservativeHeadingErrorDegrees
+ } else {
+ orientation.headingErrorDegrees
+ }
+ // FOP has no per-sample flag: it applies the declination whenever Play services knows the location,
+ // which it does whenever the view has the fix it needs to place anything.
+ listener.onSample(
+ attitude = orientation.attitude,
+ headingErrorDeg = headingError,
+ trueNorth = true,
+ timestampNs = orientation.elapsedRealtimeNs,
+ )
+ }
+ val period = if (reduced) REDUCED_PERIOD_MICROS else DeviceOrientationRequest.OUTPUT_PERIOD_DEFAULT
+ client
+ .requestOrientationUpdates(DeviceOrientationRequest.Builder(period).build(), executor, callback)
+ .addOnFailureListener(executor) { onFailure() }
+ return AutoCloseable { client.removeOrientationUpdates(callback) }
+ }
+}
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.android.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.android.kt
index 7da31e9687f..bed619c1ad9 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.android.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/MeshHudSupport.android.kt
@@ -29,7 +29,10 @@ actual fun rememberMeshHudSupported(): Boolean {
val context = LocalContext.current
return remember(context) {
val sensors = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
- context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) &&
- sensors?.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null
+ val hasOrientation =
+ sensors?.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null ||
+ sensors?.getDefaultSensor(Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR) != null
+ // The view binds the back camera, which FEATURE_CAMERA names; FEATURE_CAMERA_ANY is met by a front one alone.
+ context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA) && hasOrientation
}
}
diff --git a/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/hud/DeviceAttitudeTest.kt b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/hud/DeviceAttitudeTest.kt
new file mode 100644
index 00000000000..8d715636ec7
--- /dev/null
+++ b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/hud/DeviceAttitudeTest.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import android.view.Surface
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.math.sqrt
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+private const val TOLERANCE = 1e-5
+
+private val EAST = Vec3(1.0, 0.0, 0.0)
+private val NORTH = Vec3(0.0, 1.0, 0.0)
+private val SOUTH = Vec3(0.0, -1.0, 0.0)
+private val UP = Vec3(0.0, 0.0, 1.0)
+private val DOWN = Vec3(0.0, 0.0, -1.0)
+
+private fun assertNear(expected: Vec3, actual: Vec3) {
+ assertEquals(expected.x, actual.x, TOLERANCE, "x of $actual")
+ assertEquals(expected.y, actual.y, TOLERANCE, "y of $actual")
+ assertEquals(expected.z, actual.z, TOLERANCE, "z of $actual")
+}
+
+/** Quaternions are (x, y, z, w) device-to-east-north-up, the layout of both the rotation vector and FOP's attitude. */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class DeviceAttitudeTest {
+
+ @Test
+ fun `identity is the phone flat on its back with its top to the north`() {
+ val pose = attitudeToCameraPose(floatArrayOf(0f, 0f, 0f, 1f), Surface.ROTATION_0)
+ assertNear(DOWN, pose.forward)
+ assertNear(EAST, pose.right)
+ assertNear(NORTH, pose.up)
+ assertEquals(0.0, pose.azimuthDeg, TOLERANCE)
+ }
+
+ @Test
+ fun `tilting the top up about device x faces the camera north`() {
+ val half = sqrt(0.5).toFloat()
+ val pose = attitudeToCameraPose(floatArrayOf(half, 0f, 0f, half), Surface.ROTATION_0)
+ assertNear(NORTH, pose.forward)
+ assertNear(EAST, pose.right)
+ assertNear(UP, pose.up)
+ assertEquals(0.0, pose.azimuthDeg, TOLERANCE)
+ }
+
+ @Test
+ fun `an upright portrait phone facing east has south on its right`() {
+ val pose = attitudeToCameraPose(floatArrayOf(0.5f, -0.5f, -0.5f, 0.5f), Surface.ROTATION_0)
+ assertNear(EAST, pose.forward)
+ assertNear(SOUTH, pose.right)
+ assertNear(UP, pose.up)
+ assertEquals(90.0, pose.azimuthDeg, TOLERANCE)
+ }
+
+ @Test
+ fun `a landscape phone with its top to the left takes the screen axes from the display rotation`() {
+ val pose = attitudeToCameraPose(floatArrayOf(0.5f, -0.5f, 0.5f, 0.5f), Surface.ROTATION_90)
+ assertNear(NORTH, pose.forward)
+ assertNear(EAST, pose.right)
+ assertNear(UP, pose.up)
+ assertEquals(0.0, pose.azimuthDeg, TOLERANCE)
+ }
+}
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
index 6edbbfce036..a57ac9ebc58 100644
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
@@ -22,84 +22,193 @@ import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.hardware.display.DisplayManager
+import android.os.Build
import android.os.Handler
import android.os.HandlerThread
+import android.os.PowerManager
import android.view.Display
import android.view.Surface
+import androidx.annotation.RequiresApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.koin.core.annotation.Single
+import java.util.concurrent.Executor
import kotlin.math.max
import kotlin.math.sqrt
private const val QUATERNION_SIZE = 4
-private const val ROTATION_MATRIX_SIZE = 9
private const val HEADING_ACCURACY_INDEX = 4
private const val NINETY_FIVE_PERCENT_SIGMAS = 1.96
+private const val MAX_HEADING_ERROR_DEG = 180f
+/**
+ * Prefers [fusedOrientation] and falls back to the platform rotation vector, or the geomagnetic one on devices without
+ * a gyroscope. Running hot, either source is slowed.
+ */
@Single
-class AndroidCameraPoseProvider(private val context: Context) : CameraPoseProvider {
+class AndroidCameraPoseProvider(
+ private val context: Context,
+ private val fusedOrientation: FusedOrientationSource,
+) : CameraPoseProvider {
override fun poseUpdates(): Flow = callbackFlow {
- val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
- val rotationSensor = sensorManager?.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
- if (sensorManager == null || rotationSensor == null) {
- trySend(CameraPoseState(hasSensor = false))
- awaitClose {}
- return@callbackFlow
- }
- val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
-
- val smoothed = FloatArray(QUATERNION_SIZE)
- val sample = FloatArray(QUATERNION_SIZE)
- val matrix = FloatArray(ROTATION_MATRIX_SIZE)
- val filter = OneEuroQuaternionFilter()
- // Some fusions (measured on a Pixel 9 Pro) report a fixed pi/6 here rather than an estimate, so the value is
- // trusted only once it has moved.
- var firstAccuracy: Float? = null
- var accuracyIsLive = false
- // Sensor callbacks get their own thread, away from the main thread that draws the overlay.
+ // Every callback runs on this one thread, away from the main thread that draws the overlay, so the session
+ // needs no locking.
val thread = HandlerThread("mesh-view-pose").apply { start() }
+ val handler = Handler(thread.looper)
+ val session = PoseSession(context, fusedOrientation, handler) { trySend(it) }
+ handler.post(session::start)
+ awaitClose {
+ handler.post {
+ session.stop()
+ thread.quitSafely()
+ }
+ }
+ }
+}
+
+private class PoseSession(
+ context: Context,
+ private val fused: FusedOrientationSource,
+ private val handler: Handler,
+ private val send: (CameraPoseState) -> Unit,
+) {
+ private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
+ private val rotationSensor =
+ sensorManager?.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
+ ?: sensorManager?.getDefaultSensor(Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR)
+ private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
+ private val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
+ private val executor = Executor { handler.post(it) }
+
+ private val sample = FloatArray(QUATERNION_SIZE)
+ private val smoothed = FloatArray(QUATERNION_SIZE)
+ private val matrix = FloatArray(ROTATION_MATRIX_SIZE)
+ private var filter = OneEuroQuaternionFilter()
+ private var last = CameraPoseState()
+ private var thermal = HudThermal.NORMAL
+ private var fusedHandle: AutoCloseable? = null
+ private var sensorRegistered = false
+ private var stopThermal: (() -> Unit)? = null
+ private var stopped = false
+
+ // Some fusions (measured on a Pixel 9 Pro) report a fixed pi/6 here rather than an estimate, so the value is
+ // trusted only once it has moved.
+ private var firstAccuracy: Float? = null
+ private var accuracyIsLive = false
+
+ private val reduced
+ get() = thermal != HudThermal.NORMAL
- val listener =
- object : SensorEventListener {
- override fun onSensorChanged(event: SensorEvent) {
- event.toQuaternion(sample)
- filter.filter(sample, event.timestamp, smoothed)
- SensorManager.getRotationMatrixFromVector(matrix, smoothed)
- val rotation = displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
- trySend(
- CameraPoseState(
- pose = matrix.toCameraPose(rotation),
- calibration = event.accuracy,
- headingAccuracyDeg =
- event.headingAccuracyRad()?.let { raw ->
- if (firstAccuracy == null) firstAccuracy = raw
- if (raw != firstAccuracy) accuracyIsLive = true
- // The sensor's bound holds 95% of the time; scale it to the one-sigma figure
- // every other accuracy on screen uses.
- raw.takeIf { accuracyIsLive }
- ?.let { Math.toDegrees(it.toDouble()) / NINETY_FIVE_PERCENT_SIGMAS }
- },
- ),
- )
- }
-
- override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
+ private val sensorListener =
+ object : SensorEventListener {
+ override fun onSensorChanged(event: SensorEvent) {
+ event.toQuaternion(sample)
+ publish(sample, event.timestamp, event.accuracy, event.headingAccuracyDeg(), trueNorth = false)
}
- sensorManager.registerListener(
- listener,
- rotationSensor,
- SensorManager.SENSOR_DELAY_GAME,
- Handler(thread.looper),
- )
- awaitClose {
- sensorManager.unregisterListener(listener)
- thread.quitSafely()
+ override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
+ }
+
+ private val fusedListener =
+ FusedOrientationSource.Listener { attitude, headingErrorDeg, trueNorth, timestampNs ->
+ if (!stopped && fusedHandle != null) {
+ val error = headingErrorDeg.toOneSigmaDeg()
+ // An invalid heading error means an uncalibrated compass, so it asks for calibration like status 0.
+ val calibration = if (error == null) SensorManager.SENSOR_STATUS_UNRELIABLE else CALIBRATION_UNKNOWN
+ publish(attitude, timestampNs, calibration, error, trueNorth)
+ }
}
+
+ fun start() {
+ val power = powerManager
+ if (power != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ stopThermal = power.watchThermal(executor, ::onThermal)
+ }
+ startOrientation()
+ }
+
+ fun stop() {
+ stopped = true
+ fusedHandle?.close()
+ fusedHandle = null
+ if (sensorRegistered) sensorManager?.unregisterListener(sensorListener)
+ sensorRegistered = false
+ stopThermal?.invoke()
+ }
+
+ private fun startOrientation() {
+ var handle: AutoCloseable? = null
+ handle = fused.start(reduced, executor, fusedListener) { onFusedFailure(handle) }
+ fusedHandle = handle
+ if (handle == null) startSensor()
+ }
+
+ private fun onFusedFailure(handle: AutoCloseable?) {
+ if (stopped || handle == null || handle !== fusedHandle) return
+ fusedHandle = null
+ filter = OneEuroQuaternionFilter()
+ startSensor()
+ }
+
+ private fun startSensor() {
+ val sensor = rotationSensor
+ val delay = if (reduced) SensorManager.SENSOR_DELAY_UI else SensorManager.SENSOR_DELAY_GAME
+ sensorRegistered =
+ sensor != null && sensorManager?.registerListener(sensorListener, sensor, delay, handler) == true
+ if (!sensorRegistered) emit(CameraPoseState(hasSensor = false))
+ }
+
+ private fun onThermal(next: HudThermal) {
+ if (stopped || next == thermal) return
+ val wasReduced = reduced
+ thermal = next
+ if (reduced != wasReduced) restartOrientation()
+ emit(last)
+ }
+
+ private fun restartOrientation() {
+ val handle = fusedHandle
+ if (handle != null) {
+ fusedHandle = null
+ handle.close()
+ startOrientation()
+ } else if (sensorRegistered) {
+ sensorManager?.unregisterListener(sensorListener)
+ startSensor()
+ }
+ }
+
+ private fun publish(
+ attitude: FloatArray,
+ timestampNs: Long,
+ calibration: Int,
+ headingAccuracyDeg: Double?,
+ trueNorth: Boolean,
+ ) {
+ filter.filter(attitude, timestampNs, smoothed)
+ val rotation = displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
+ emit(
+ CameraPoseState(
+ pose = attitudeToCameraPose(smoothed, rotation, matrix),
+ calibration = calibration,
+ headingAccuracyDeg = headingAccuracyDeg,
+ trueNorth = trueNorth,
+ ),
+ )
+ }
+
+ private fun emit(state: CameraPoseState) {
+ last = state.copy(thermal = thermal)
+ send(last)
+ }
+
+ private fun SensorEvent.headingAccuracyDeg(): Double? = headingAccuracyRad()?.let { raw ->
+ if (firstAccuracy == null) firstAccuracy = raw
+ if (raw != firstAccuracy) accuracyIsLive = true
+ raw.takeIf { accuracyIsLive }?.let { Math.toDegrees(it.toDouble()) / NINETY_FIVE_PERCENT_SIGMAS }
}
}
@@ -118,23 +227,21 @@ private fun SensorEvent.toQuaternion(out: FloatArray) {
/** The rotation vector's heading error estimate, which it reports in radians and as -1 when it has none. */
private fun SensorEvent.headingAccuracyRad(): Float? = values.getOrNull(HEADING_ACCURACY_INDEX)?.takeIf { it >= 0f }
-/**
- * The matrix maps device coordinates into east-north-up, so its columns are the device axes in that frame. The back
- * camera looks down device -Z, and which device axis is the screen's right depends on the display rotation.
- */
-@Suppress("MagicNumber")
-private fun FloatArray.toCameraPose(displayRotation: Int): CameraPose {
- val deviceX = Vec3(this[0].toDouble(), this[3].toDouble(), this[6].toDouble())
- val deviceY = Vec3(this[1].toDouble(), this[4].toDouble(), this[7].toDouble())
- val forward = Vec3(-this[2].toDouble(), -this[5].toDouble(), -this[8].toDouble())
- val (right, up) =
- when (displayRotation) {
- Surface.ROTATION_90 -> -deviceY to deviceX
- Surface.ROTATION_180 -> -deviceX to -deviceY
- Surface.ROTATION_270 -> deviceY to -deviceX
- else -> deviceX to deviceY
- }
- return CameraPose(right = right, up = up, forward = forward)
+/** A 95% heading error bound in degrees, as the one-sigma figure every other accuracy on screen uses. */
+private fun Float.toOneSigmaDeg(): Double? =
+ takeIf { it >= 0f && it < MAX_HEADING_ERROR_DEG }?.let { it / NINETY_FIVE_PERCENT_SIGMAS }
+
+private fun Int.toHudThermal(): HudThermal = when {
+ this >= PowerManager.THERMAL_STATUS_CRITICAL -> HudThermal.CRITICAL
+ this >= PowerManager.THERMAL_STATUS_SEVERE -> HudThermal.HOT
+ else -> HudThermal.NORMAL
}
-private operator fun Vec3.unaryMinus() = Vec3(-x, -y, -z)
+/** Reports the current thermal status, then every change, and returns the call that stops it. */
+@RequiresApi(Build.VERSION_CODES.Q)
+private fun PowerManager.watchThermal(executor: Executor, onChange: (HudThermal) -> Unit): () -> Unit {
+ onChange(currentThermalStatus.toHudThermal())
+ val listener = PowerManager.OnThermalStatusChangedListener { onChange(it.toHudThermal()) }
+ addThermalStatusListener(executor, listener)
+ return { removeThermalStatusListener(listener) }
+}
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/DeviceAttitude.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/DeviceAttitude.kt
new file mode 100644
index 00000000000..5c292a441bd
--- /dev/null
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/DeviceAttitude.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import android.hardware.SensorManager
+import android.view.Surface
+
+internal const val ROTATION_MATRIX_SIZE = 9
+
+/**
+ * The camera pose for a device-to-east-north-up [attitude] quaternion in (x, y, z, w) order, which both the rotation
+ * vector and the Fused Orientation Provider report. [matrix] is scratch space of [ROTATION_MATRIX_SIZE].
+ */
+internal fun attitudeToCameraPose(
+ attitude: FloatArray,
+ displayRotation: Int,
+ matrix: FloatArray = FloatArray(ROTATION_MATRIX_SIZE),
+): CameraPose {
+ SensorManager.getRotationMatrixFromVector(matrix, attitude)
+ return matrix.toCameraPose(displayRotation)
+}
+
+/**
+ * The matrix maps device coordinates into east-north-up, so its columns are the device axes in that frame. The back
+ * camera looks down device -Z, and which device axis is the screen's right depends on the display rotation.
+ */
+@Suppress("MagicNumber")
+private fun FloatArray.toCameraPose(displayRotation: Int): CameraPose {
+ val deviceX = Vec3(this[0].toDouble(), this[3].toDouble(), this[6].toDouble())
+ val deviceY = Vec3(this[1].toDouble(), this[4].toDouble(), this[7].toDouble())
+ val forward = Vec3(-this[2].toDouble(), -this[5].toDouble(), -this[8].toDouble())
+ val (right, up) =
+ when (displayRotation) {
+ Surface.ROTATION_90 -> -deviceY to deviceX
+ Surface.ROTATION_180 -> -deviceX to -deviceY
+ Surface.ROTATION_270 -> deviceY to -deviceX
+ else -> deviceX to deviceY
+ }
+ return CameraPose(right = right, up = up, forward = forward)
+}
+
+private operator fun Vec3.unaryMinus() = Vec3(-x, -y, -z)
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/FusedOrientationSource.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/FusedOrientationSource.kt
new file mode 100644
index 00000000000..c0555c8f18e
--- /dev/null
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/FusedOrientationSource.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import java.util.concurrent.Executor
+
+/**
+ * An orientation fusion outside the platform sensors, such as Play services' Fused Orientation Provider. Each flavor
+ * binds one; [NONE] is the flavor without it, and the camera pose falls back to the rotation vector.
+ */
+interface FusedOrientationSource {
+
+ /**
+ * Starts delivering samples on [executor], slower when [reduced], and returns the handle that stops them, or null
+ * when the source is unavailable. [onFailure] runs on [executor] if the source fails after it was started.
+ */
+ fun start(reduced: Boolean, executor: Executor, listener: Listener, onFailure: () -> Unit): AutoCloseable?
+
+ fun interface Listener {
+ /**
+ * [attitude] is the device-to-east-north-up quaternion as (x, y, z, w), the rotation vector's layout.
+ * [headingErrorDeg] is the 95% half-cone of the heading error, 180 or more when unknown. [trueNorth] says the
+ * attitude is referenced to true north.
+ */
+ fun onSample(attitude: FloatArray, headingErrorDeg: Float, trueNorth: Boolean, timestampNs: Long)
+ }
+
+ companion object {
+ val NONE =
+ object : FusedOrientationSource {
+ override fun start(
+ reduced: Boolean,
+ executor: Executor,
+ listener: Listener,
+ onFailure: () -> Unit,
+ ): AutoCloseable? = null
+ }
+ }
+}
From 83bcaf0fc3147cbc04fcec61202d1852f82d63b4 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 19 Oct 2026 17:59:04 -0500
Subject: [PATCH 11/31] feat(map): mesh view coaching, recovery states,
contrast, TalkBack and map parity
---
.skills/compose-ui/strings-index.txt | 15 +
.../org/meshtastic/core/navigation/Routes.kt | 3 +-
.../meshtastic/core/prefs/map/MapPrefsImpl.kt | 9 +
.../core/repository/AppPreferences.kt | 5 +
.../composeResources/values/strings.xml | 15 +
.../core/testing/FakeAppPreferences.kt | 6 +
.../map/hud/HudCameraPreview.android.kt | 30 +-
.../feature/map/hud/HudCameraPreview.kt | 14 +-
.../feature/map/hud/HudChipLayer.kt | 24 +-
.../meshtastic/feature/map/hud/HudChips.kt | 83 +++-
.../meshtastic/feature/map/hud/HudOverlay.kt | 2 +-
.../meshtastic/feature/map/hud/HudPanels.kt | 360 ++++++++++++++++++
.../feature/map/hud/HudPathsDraw.kt | 10 +-
.../meshtastic/feature/map/hud/HudRadar.kt | 31 +-
.../meshtastic/feature/map/hud/HudTargets.kt | 9 +
.../feature/map/hud/MeshHudScreen.kt | 356 ++++++-----------
.../feature/map/hud/MeshHudViewModel.kt | 27 +-
.../feature/map/navigation/MapNavigation.kt | 2 +
.../feature/map/hud/HudCameraPreview.ios.kt | 7 +-
.../feature/map/hud/HudCameraPreview.jvm.kt | 7 +-
20 files changed, 723 insertions(+), 292 deletions(-)
create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index d62fdec82a6..e0252493561 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1096,19 +1096,29 @@ mesh_beacon_target_channel_index
mesh_beacon_target_default
mesh_beacon_target_remove
mesh_beacon_targets
+mesh_hud_age
mesh_hud_camera_rationale
+mesh_hud_camera_unavailable
mesh_hud_cardinal_east
mesh_hud_cardinal_north
mesh_hud_cardinal_south
mesh_hud_cardinal_west
+mesh_hud_chip_description
+mesh_hud_coach_dismiss
+mesh_hud_coach_find
+mesh_hud_coach_point
+mesh_hud_coach_tap
mesh_hud_distance_no_altitude
mesh_hud_elevation_approximate
mesh_hud_find
+mesh_hud_finding
mesh_hud_heading
mesh_hud_heading_accuracy
mesh_hud_heading_low_accuracy
mesh_hud_inside_geofence
mesh_hud_inside_unnamed_geofence
+mesh_hud_location_rationale
+mesh_hud_more
mesh_hud_nearby
mesh_hud_no_nodes
mesh_hud_on_target
@@ -1116,14 +1126,19 @@ mesh_hud_open
mesh_hud_open_node
mesh_hud_open_waypoint
mesh_hud_own_accuracy
+mesh_hud_radar_description
mesh_hud_range_all
mesh_hud_range_within
mesh_hud_range_within_hidden
+mesh_hud_snr
+mesh_hud_thermal_critical
+mesh_hud_thermal_hot
mesh_hud_tilt_down
mesh_hud_tilt_up
mesh_hud_turn_left
mesh_hud_turn_right
mesh_hud_waiting_sensor
+mesh_hud_watch_step
mesh_map_location
mesh_map_location_description
### MESHTASTIC ###
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
index 3c2430e03f1..2a53af43943 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -75,7 +75,8 @@ sealed interface MapRoute : Route {
sealed interface NodesRoute : Route {
@Serializable data object Nodes : NodesRoute, Graph
- @Serializable data class NodeDetail(val destNum: Int? = null) : NodesRoute
+ /** [openCompass] opens the compass sheet on arrival, for entry points that offer the compass directly. */
+ @Serializable data class NodeDetail(val destNum: Int? = null, val openCompass: Boolean = false) : NodesRoute
}
@Serializable
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt
index 8973b4b45f0..d01e593c1d1 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt
@@ -159,7 +159,16 @@ class MapPrefsImpl(private val dataStore: MapDataStore, dispatchers: CoroutineDi
}
.first()
+ // Starts as seen, so a returning user does not glimpse the coaching before the stored value loads.
+ override val meshHudCoachSeen: StateFlow =
+ dataStore.data.map { it[KEY_MESH_HUD_COACH_SEEN_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, true)
+
+ override fun setMeshHudCoachSeen(seen: Boolean) {
+ scope.launch { dataStore.edit { it[KEY_MESH_HUD_COACH_SEEN_PREF] = seen } }
+ }
+
companion object {
+ val KEY_MESH_HUD_COACH_SEEN_PREF = booleanPreferencesKey("mesh_hud_coach_seen")
val KEY_MAP_STYLE_PREF = intPreferencesKey("map_style_id")
val KEY_SHOW_ONLY_FAVORITES_PREF = booleanPreferencesKey("show_only_favorites")
val KEY_SHOW_WAYPOINTS_PREF = booleanPreferencesKey("show_waypoints")
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index 45a3866a68f..2f71548f6f8 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -340,6 +340,11 @@ interface MapPrefs {
/** Load the complete persisted camera, or null before the map has ever been positioned. */
suspend fun awaitCameraPosition(): MapCameraPosition?
+
+ /** True once the user has dismissed the mesh view's first-run coaching. */
+ val meshHudCoachSeen: StateFlow
+
+ fun setMeshHudCoachSeen(seen: Boolean)
}
data class MapCameraPosition(val latitude: Double, val longitude: Double, val zoom: Double)
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 12f74934d2d..72cb79d0215 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1138,19 +1138,29 @@
Default
Remove target
Broadcast targets
+ %1$s · %2$s
Allow camera access to see nodes over the camera view.
+ Camera unavailable. The view still works as a compass.
E
N
S
W
+ %1$s, %2$s, at %3$d o'clock
+ Got it
+ Long-press a node to find it.
+ Hold the phone up and point it toward the horizon.
+ Tap a node to open it.
%1$s · no altitude
Elevation is approximate
Find
+ Finding %1$s · %2$s
Heading %1$s
Heading %1$s ± %2$s
Low heading accuracy. Move the phone in a figure eight to calibrate.
Inside %1$s
Inside a geofence
+ The mesh view uses your location to place nodes around you.
+ +%1$d
Nearby
No nodes with a position
On target
@@ -1158,14 +1168,19 @@
Open node
Open waypoint
You ± %1$s
+ %1$s, %2$s
All distances
Within %1$s
Within %1$s · %2$d farther
+ %1$s dB
+ Phone is too hot. Camera paused.
+ Phone is hot. Updating less often.
Tilt down %1$d°
Tilt up %1$d°
Turn left %1$d°
Turn right %1$d°
Waiting for the orientation sensor
+ Watch where you walk
Mesh Map Location
Enables the blue location dot for your phone in the mesh map.
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index a0de1823817..0398ed4c1cc 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -301,6 +301,12 @@ class FakeMapPrefs : MapPrefs {
}
override suspend fun awaitCameraPosition(): MapCameraPosition? = cameraPosition
+
+ override val meshHudCoachSeen = MutableStateFlow(false)
+
+ override fun setMeshHudCoachSeen(seen: Boolean) {
+ meshHudCoachSeen.value = seen
+ }
}
class FakeMapConsentPrefs : MapConsentPrefs {
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.android.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.android.kt
index 5f1f21c1a05..36ead24db24 100644
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.android.kt
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.android.kt
@@ -22,6 +22,7 @@ import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
+import androidx.camera.core.CameraState
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.lifecycle.ProcessCameraProvider
@@ -36,22 +37,37 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
+import androidx.lifecycle.Observer
import androidx.lifecycle.compose.LocalLifecycleOwner
import co.touchlab.kermit.Logger
import kotlin.math.max
import kotlin.math.min
@Composable
-actual fun HudCameraPreview(onIntrinsics: (CameraIntrinsics?) -> Unit, modifier: Modifier) {
+actual fun HudCameraPreview(
+ onIntrinsics: (CameraIntrinsics?) -> Unit,
+ onAvailable: (Boolean) -> Unit,
+ modifier: Modifier,
+) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnIntrinsics by rememberUpdatedState(onIntrinsics)
+ val currentOnAvailable by rememberUpdatedState(onAvailable)
var surfaceRequest by remember { mutableStateOf(null) }
DisposableEffect(lifecycleOwner) {
var disposed = false
var provider: ProcessCameraProvider? = null
var sensorIntrinsics: CameraIntrinsics? = null
+ var boundCamera: Camera? = null
+ val stateObserver =
+ Observer { state ->
+ when {
+ state.error != null -> currentOnAvailable(false)
+ state.type == CameraState.Type.OPEN -> currentOnAvailable(true)
+ }
+ }
+ // Stabilization stays off: it crops up to a fifth of the field of view, which the intrinsics would not see.
val preview = Preview.Builder().build()
// The stream CameraX picked, and its crop, decide how much of the sensor the view shows.
@@ -90,9 +106,13 @@ actual fun HudCameraPreview(onIntrinsics: (CameraIntrinsics?) -> Unit, modifier:
val camera = bound.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview)
sensorIntrinsics = camera.intrinsics()
report()
+ // Another app holding the camera, or a HAL fault, arrives here rather than as a bind failure.
+ camera.cameraInfo.cameraState.observe(lifecycleOwner, stateObserver)
+ boundCamera = camera
} catch (e: Exception) {
Logger.w(e) { "Mesh view camera bind failed" }
currentOnIntrinsics(null)
+ currentOnAvailable(false)
}
},
ContextCompat.getMainExecutor(context),
@@ -100,6 +120,7 @@ actual fun HudCameraPreview(onIntrinsics: (CameraIntrinsics?) -> Unit, modifier:
onDispose {
disposed = true
+ boundCamera?.cameraInfo?.cameraState?.removeObserver(stateObserver)
surfaceRequest?.willNotProvideSurface()
surfaceRequest = null
provider?.unbind(preview)
@@ -112,7 +133,10 @@ actual fun HudCameraPreview(onIntrinsics: (CameraIntrinsics?) -> Unit, modifier:
}
}
-/** Focal length and the active sensor area in millimetres, or null when the camera does not report them. */
+/**
+ * Focal length and the active sensor area in millimeters, or null when the camera does not report them. CameraX 1.7
+ * replaces Camera2CameraInfo with CameraInfo.cameraCharacteristics.
+ */
@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
private fun Camera.intrinsics(): CameraIntrinsics? {
val info = Camera2CameraInfo.from(cameraInfo)
@@ -127,7 +151,7 @@ private fun Camera.intrinsics(): CameraIntrinsics? {
// A calibrated focal length, in pre-correction active-array pixels, beats the nominal one when the camera has it:
// on a logical multi-camera the first listed focal length need not be the lens in use.
val calibratedFx = info.getCameraCharacteristic(CameraCharacteristics.LENS_INTRINSIC_CALIBRATION)?.firstOrNull()
- // Pixels to millimetres by the sensor's pixel pitch.
+ // Pixels to millimeters by the sensor's pixel pitch.
val nominalOrCalibrated = calibratedFx?.takeIf { it > 0f }?.let { it * physical.width / pixels.width } ?: focal
val zoom = cameraInfo.zoomState.value?.zoomRatio ?: 1f
return CameraIntrinsics(
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.kt
index 3dbdeb73777..939ec2f0c07 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.kt
@@ -24,7 +24,7 @@ import kotlin.math.tan
/** Fallback when the camera reports no intrinsics: a typical phone main camera's horizontal field of view. */
private const val FALLBACK_HORIZONTAL_FOV_DEG = 66.0
-/** The back camera's focal length and the size of the sensor area the preview shows, both in millimetres. */
+/** The back camera's focal length and the size of the sensor area the preview shows, both in millimeters. */
data class CameraIntrinsics(val focalLengthMm: Float, val sensorLongMm: Float, val sensorShortMm: Float) {
/**
* The sensor area a preview stream of [streamLong] by [streamShort] pixels shows after its [cropLong] by
@@ -60,5 +60,13 @@ data class CameraIntrinsics(val focalLengthMm: Float, val sensorLongMm: Float, v
fun fallbackFocalPx(viewWidth: Float, viewHeight: Float): Float =
(max(viewWidth, viewHeight) / 2 / tan(FALLBACK_HORIZONTAL_FOV_DEG.toRadians() / 2)).toFloat()
-/** The back camera's live preview, filling [modifier]; reports intrinsics once the camera is bound. */
-@Composable expect fun HudCameraPreview(onIntrinsics: (CameraIntrinsics?) -> Unit, modifier: Modifier = Modifier)
+/**
+ * The back camera's live preview, filling [modifier]; reports intrinsics once the camera is bound, and [onAvailable]
+ * false when it cannot bind or the camera fails, true once it opens.
+ */
+@Composable
+expect fun HudCameraPreview(
+ onIntrinsics: (CameraIntrinsics?) -> Unit,
+ onAvailable: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
index 134d1acbea2..6aa85f87fb5 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
@@ -36,7 +36,13 @@ import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.semantics.isTraversalGroup
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.mesh_hud_more
import kotlin.math.abs
private const val OFFSCREEN_ALPHA = 0.7f
@@ -56,7 +62,7 @@ private val StemWidth = 2.dp
private val StemEdge = Color.Black.copy(alpha = 0.4f)
private val OverflowPadding = 6.dp
private val OverflowBand = 28.dp
-private val OverflowScrim = Color.Black.copy(alpha = 0.55f)
+private val OverflowScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA)
/** Where a chip went, so the draw pass can join it to its true point or point it toward the turn. */
private class PlacedChip(val rect: IntRect, val anchor: Offset?, val edge: PinEdge?, val color: Color)
@@ -113,13 +119,15 @@ internal fun HudChipLayer(
val placement = remember { ChipPlacement() }
val textMeasurer = rememberTextMeasurer()
val overflowStyle = MaterialTheme.typography.labelMedium.copy(color = Color.White)
+ val overflowTemplate = stringResource(Res.string.mesh_hud_more)
Layout(
content = { targets.forEach { key(it.subject.key) { chip(it) } } },
+ // A traversal group, so TalkBack reads the chips in the order each sets, nearest the center first.
modifier =
- modifier.drawBehind {
+ modifier.semantics { isTraversalGroup = true }.drawBehind {
if (currentPose() == null) return@drawBehind
placement.chips.forEach { drawStemOrArrow(it) }
- placement.overflow.forEach { drawOverflow(it, topLimitPx(), textMeasurer, overflowStyle) }
+ placement.overflow.forEach { drawOverflow(it, topLimitPx(), textMeasurer, overflowStyle, overflowTemplate) }
},
) { measurables, constraints ->
val placeables = measurables.map { it.measure(Constraints()) }
@@ -273,8 +281,14 @@ private fun DrawScope.drawEdgeArrow(rect: IntRect, edge: PinEdge) {
drawPath(arrow, Color.White)
}
-private fun DrawScope.drawOverflow(overflow: Overflow, topLimit: Int, textMeasurer: TextMeasurer, style: TextStyle) {
- val layout = textMeasurer.measure("+${overflow.count}", style)
+private fun DrawScope.drawOverflow(
+ overflow: Overflow,
+ topLimit: Int,
+ textMeasurer: TextMeasurer,
+ style: TextStyle,
+ template: String,
+) {
+ val layout = textMeasurer.measure(formatString(template, overflow.count), style)
val pad = OverflowPadding.toPx()
val boxSize = Size(layout.size.width + 2 * pad, layout.size.height + pad)
val topLeft = Offset(overflow.centerX - boxSize.width / 2, topLimit.toFloat())
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
index 0403aeb4898..1672dbceb3e 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
@@ -23,24 +23,34 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.toCodePointString
import org.meshtastic.core.model.util.waypointIconOrDefault
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.mesh_hud_age
+import org.meshtastic.core.resources.mesh_hud_chip_description
import org.meshtastic.core.resources.mesh_hud_distance_no_altitude
import org.meshtastic.core.resources.mesh_hud_find
import org.meshtastic.core.resources.mesh_hud_open_node
import org.meshtastic.core.resources.mesh_hud_open_waypoint
import org.meshtastic.core.ui.component.NodeChip
+import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.proto.Waypoint
+import kotlin.math.roundToInt
private const val FRESH_POSITION_SEC = 15 * 60L
private const val OLD_POSITION_SEC = 6 * 60 * 60L
@@ -55,9 +65,39 @@ private fun ageAlpha(ageSec: Long?): Float {
return 1f - t * (1f - OLDEST_ALPHA)
}
-/** Tap opens a node's detail; long press makes it, or a waypoint, the finder's target. */
+private const val HOURS_ON_DIAL = 12
+private const val DEG_PER_HOUR = 30.0
+
+/** Where [bearingDeg] lies on a clock face whose 12 is the camera's heading, 1 to 12. */
+internal fun clockHour(bearingDeg: Double, headingDeg: Double): Int {
+ val hour = (signedTurnDeg(headingDeg, bearingDeg) / DEG_PER_HOUR).roundToInt().mod(HOURS_ON_DIAL)
+ return if (hour == 0) HOURS_ON_DIAL else hour
+}
+
+/** What TalkBack calls a subject: a node's long name, a waypoint's name, falling back to what the chip shows. */
+internal fun HudSubject.spokenName(): String = when (this) {
+ is HudSubject.NodeSubject -> node.user.long_name.ifBlank { node.user.short_name }
+ is HudSubject.WaypointSubject -> waypoint.name.ifBlank { waypoint.icon.waypointIconOrDefault().toCodePointString() }
+}
+
+/**
+ * Tap opens a node's detail; long press makes it, or a waypoint, the finder's target. TalkBack hears its name, distance
+ * and clock position from the camera's heading, and reaches the chips nearest straight ahead first.
+ */
@Composable
-internal fun HudChip(target: HudTarget, onOpen: () -> Unit, onToggleFocus: () -> Unit) {
+internal fun HudChip(
+ target: HudTarget,
+ currentPose: () -> CameraPose?,
+ onOpen: () -> Unit,
+ onToggleFocus: () -> Unit,
+) {
+ // Derived, so the chip recomposes when its clock hour changes rather than at the sensor rate.
+ val hour by remember(target.bearingDeg) {
+ derivedStateOf { currentPose()?.let { clockHour(target.bearingDeg, it.azimuthDeg) } }
+ }
+ val distanceText = chipDistanceText(target)
+ val description =
+ hour?.let { stringResource(Res.string.mesh_hud_chip_description, target.subject.spokenName(), distanceText, it) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
@@ -77,22 +117,32 @@ internal fun HudChip(target: HudTarget, onOpen: () -> Unit, onToggleFocus: () ->
onClick = onOpen,
onLongClick = onToggleFocus,
)
- .semantics(mergeDescendants = true) {},
+ .semantics(mergeDescendants = true) {
+ description?.let { contentDescription = it }
+ hour?.let { traversalIndex = minOf(it, HOURS_ON_DIAL - it).toFloat() }
+ },
) {
val chipModifier =
if (target.focused) Modifier.border(2.dp, Color.White, MaterialTheme.shapes.small) else Modifier
SubjectChip(target.subject, chipModifier)
- val showsMissingAltitude = target.subject is HudSubject.NodeSubject && !target.elevationKnown
- HudLabel(
- if (showsMissingAltitude) {
- stringResource(Res.string.mesh_hud_distance_no_altitude, target.distanceText)
- } else {
- target.distanceText
- },
- )
+ HudLabel(distanceText)
}
}
+/** The distance under a chip, marked when the altitude is missing and dated once the position is no longer fresh. */
+@Composable
+private fun chipDistanceText(target: HudTarget): String {
+ val showsMissingAltitude = target.subject is HudSubject.NodeSubject && !target.elevationKnown
+ val distance =
+ if (showsMissingAltitude) {
+ stringResource(Res.string.mesh_hud_distance_no_altitude, target.distanceText)
+ } else {
+ target.distanceText
+ }
+ val age = target.positionAgeSec?.takeIf { it > FRESH_POSITION_SEC } ?: return distance
+ return stringResource(Res.string.mesh_hud_age, distance, formatAgo((nowSeconds - age).toInt()))
+}
+
@Composable
internal fun SubjectChip(subject: HudSubject, modifier: Modifier = Modifier) {
when (subject) {
@@ -103,7 +153,7 @@ internal fun SubjectChip(subject: HudSubject, modifier: Modifier = Modifier) {
/**
* A waypoint as a bare emoji over its name, with no chip behind it, so a place reads apart from the nodes. The shadow
- * keeps it legible over sky and ground alike.
+ * keeps the emoji legible over sky and ground alike; the name sits on the same plate as every other label.
*/
@Composable
private fun WaypointMarker(waypoint: Waypoint, modifier: Modifier = Modifier) {
@@ -113,13 +163,6 @@ private fun WaypointMarker(waypoint: Waypoint, modifier: Modifier = Modifier) {
waypoint.icon.waypointIconOrDefault().toCodePointString(),
style = MaterialTheme.typography.headlineMedium.copy(shadow = shadow),
)
- if (waypoint.name.isNotBlank()) {
- Text(
- waypoint.name,
- color = Color.White,
- style = MaterialTheme.typography.labelMedium.copy(shadow = shadow),
- maxLines = 1,
- )
- }
+ if (waypoint.name.isNotBlank()) HudLabel(waypoint.name)
}
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
index 418ad29f1d6..9ce5b50614b 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
@@ -52,7 +52,7 @@ private val TapeMinorTick = 5.dp
private val TapeMajorTick = 9.dp
private val TapeMarker = 5.dp
private val ReticleRadius = 36.dp
-private val TapeScrim = Color.Black.copy(alpha = 0.4f)
+private val TapeScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA)
/** The four cardinal letters, localized, for the compass tape. */
internal data class Cardinals(val north: String, val east: String, val south: String, val west: String) {
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
new file mode 100644
index 00000000000..d778aff3694
--- /dev/null
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
@@ -0,0 +1,360 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.formatString
+import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.compass_location_disabled
+import org.meshtastic.core.resources.compass_no_location_fix
+import org.meshtastic.core.resources.compass_no_magnetometer
+import org.meshtastic.core.resources.map_filter_title
+import org.meshtastic.core.resources.mesh_hud_camera_unavailable
+import org.meshtastic.core.resources.mesh_hud_coach_dismiss
+import org.meshtastic.core.resources.mesh_hud_coach_find
+import org.meshtastic.core.resources.mesh_hud_coach_point
+import org.meshtastic.core.resources.mesh_hud_coach_tap
+import org.meshtastic.core.resources.mesh_hud_elevation_approximate
+import org.meshtastic.core.resources.mesh_hud_finding
+import org.meshtastic.core.resources.mesh_hud_heading
+import org.meshtastic.core.resources.mesh_hud_heading_accuracy
+import org.meshtastic.core.resources.mesh_hud_heading_low_accuracy
+import org.meshtastic.core.resources.mesh_hud_inside_geofence
+import org.meshtastic.core.resources.mesh_hud_inside_unnamed_geofence
+import org.meshtastic.core.resources.mesh_hud_location_rationale
+import org.meshtastic.core.resources.mesh_hud_nearby
+import org.meshtastic.core.resources.mesh_hud_no_nodes
+import org.meshtastic.core.resources.mesh_hud_on_target
+import org.meshtastic.core.resources.mesh_hud_own_accuracy
+import org.meshtastic.core.resources.mesh_hud_range_all
+import org.meshtastic.core.resources.mesh_hud_range_within
+import org.meshtastic.core.resources.mesh_hud_range_within_hidden
+import org.meshtastic.core.resources.mesh_hud_thermal_critical
+import org.meshtastic.core.resources.mesh_hud_thermal_hot
+import org.meshtastic.core.resources.mesh_hud_tilt_down
+import org.meshtastic.core.resources.mesh_hud_tilt_up
+import org.meshtastic.core.resources.mesh_hud_turn_left
+import org.meshtastic.core.resources.mesh_hud_turn_right
+import org.meshtastic.core.resources.mesh_hud_waiting_sensor
+import org.meshtastic.core.resources.mesh_hud_watch_step
+import org.meshtastic.core.resources.navigate_back
+import org.meshtastic.core.resources.traceroute_outgoing_route
+import org.meshtastic.core.resources.traceroute_return_route
+import org.meshtastic.core.resources.traceroute_showing_nodes
+import org.meshtastic.core.ui.component.PermissionRecoveryCard
+import org.meshtastic.core.ui.icon.ArrowBack
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.MyLocation
+import org.meshtastic.core.ui.icon.Tune
+import org.meshtastic.core.ui.theme.TracerouteColors
+import org.meshtastic.core.ui.util.PermissionUiState
+
+/** Dark enough that white label text keeps about 4.5:1 over a white sky. */
+internal const val HUD_SCRIM_ALPHA = 0.8f
+private val HudScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA)
+private const val COACH_BACKDROP_ALPHA = 0.5f
+private val CoachMaxWidth = 360.dp
+
+/** Where the finder's target is from the center of the view, in whole hint steps. */
+internal sealed interface FinderHint {
+ data object OnTarget : FinderHint
+
+ data class Turn(val degrees: Int, val right: Boolean) : FinderHint
+
+ data class Tilt(val degrees: Int, val up: Boolean) : FinderHint
+}
+
+/** What the sensors and the phone itself report, for the top bar. */
+internal data class HudSensorStatus(
+ val headingDeg: Int?,
+ val headingAccuracyDeg: Int?,
+ val hasSensor: Boolean,
+ val needsCalibration: Boolean,
+ val waitingForSensor: Boolean,
+ val thermal: HudThermal,
+)
+
+/** [onOpenFilters] is null where filters do not apply, in a view scoped to one purpose. */
+@Composable
+internal fun HudTopBar(
+ status: HudSensorStatus,
+ targets: MeshHudTargets,
+ onNavigateUp: () -> Unit,
+ onToggleRange: () -> Unit,
+ onOpenFilters: (() -> Unit)?,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier.fillMaxWidth()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = onNavigateUp) {
+ Icon(MeshtasticIcons.ArrowBack, stringResource(Res.string.navigate_back), tint = Color.White)
+ }
+ status.headingDeg?.let { heading ->
+ val headingText = formatString("%d°", heading)
+ HudLabel(
+ status.headingAccuracyDeg?.let {
+ stringResource(Res.string.mesh_hud_heading_accuracy, headingText, formatString("%d°", it))
+ } ?: stringResource(Res.string.mesh_hud_heading, headingText),
+ )
+ }
+ if (targets.ready && !targets.scoped) {
+ Spacer(Modifier.width(8.dp))
+ HudRangeToggle(targets, onToggleRange)
+ }
+ Spacer(Modifier.weight(1f))
+ onOpenFilters?.let { open ->
+ IconButton(onClick = open) {
+ Icon(MeshtasticIcons.Tune, stringResource(Res.string.map_filter_title), tint = Color.White)
+ }
+ }
+ }
+ targets.ownAccuracyM?.let { accuracy ->
+ HudLabel(
+ stringResource(
+ Res.string.mesh_hud_own_accuracy,
+ accuracy.toInt().coerceAtLeast(1).toDistanceString(targets.units),
+ ),
+ )
+ }
+ when {
+ !status.hasSensor -> HudLabel(stringResource(Res.string.compass_no_magnetometer))
+
+ status.waitingForSensor -> HudLabel(stringResource(Res.string.mesh_hud_waiting_sensor))
+
+ status.needsCalibration -> HudLabel(stringResource(Res.string.mesh_hud_heading_low_accuracy))
+ }
+ when (status.thermal) {
+ HudThermal.NORMAL -> Unit
+ HudThermal.HOT -> HudLabel(stringResource(Res.string.mesh_hud_thermal_hot))
+ HudThermal.CRITICAL -> HudLabel(stringResource(Res.string.mesh_hud_thermal_critical))
+ }
+ }
+}
+
+/** What the bottom panel reports besides the targets themselves. */
+internal data class HudNotices(val cameraUnavailable: Boolean, val walking: Boolean)
+
+@Suppress("LongParameterList")
+@Composable
+internal fun HudBottomPanel(
+ targets: MeshHudTargets,
+ hint: FinderHint?,
+ route: HudRoute?,
+ notices: HudNotices,
+ location: PermissionUiState,
+ onOpenLocationSettings: () -> Unit,
+ radar: @Composable () -> Unit,
+ cameraContent: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ if (notices.walking) HudLabel(stringResource(Res.string.mesh_hud_watch_step))
+ targets.finding?.let {
+ HudLabel(
+ stringResource(Res.string.mesh_hud_finding, it.subject.spokenName(), it.distanceText),
+ Modifier.align(Alignment.CenterHorizontally),
+ )
+ }
+ hint?.let { FinderHintLabel(it, Modifier.align(Alignment.CenterHorizontally)) }
+ route?.let { RouteLegend(it) }
+ cameraContent()
+ if (notices.cameraUnavailable) HudLabel(stringResource(Res.string.mesh_hud_camera_unavailable))
+ LocationWarnings(targets.warnings, location, onOpenLocationSettings)
+ targets.fences
+ .filter { it.inside }
+ .forEach { fence ->
+ HudLabel(
+ if (fence.name.isNotBlank()) {
+ stringResource(Res.string.mesh_hud_inside_geofence, fence.name)
+ } else {
+ stringResource(Res.string.mesh_hud_inside_unnamed_geofence)
+ },
+ )
+ }
+ if (
+ targets.ready &&
+ route == null &&
+ targets.warnings.isEmpty() &&
+ targets.targets.isEmpty() &&
+ targets.directionUnknown.isEmpty()
+ ) {
+ HudLabel(stringResource(Res.string.mesh_hud_no_nodes))
+ }
+ if (targets.elevationApproximate && targets.targets.isNotEmpty()) {
+ HudLabel(stringResource(Res.string.mesh_hud_elevation_approximate))
+ }
+ if (targets.directionUnknown.isNotEmpty()) {
+ HudLabel(stringResource(Res.string.mesh_hud_nearby))
+ radar()
+ }
+ }
+}
+
+/** Each location warning with the way out of it: the permission card, or the system's location settings. */
+@Composable
+private fun LocationWarnings(
+ warnings: List,
+ location: PermissionUiState,
+ onOpenLocationSettings: () -> Unit,
+) {
+ warnings.forEach { warning ->
+ when (warning) {
+ HudWarning.NO_LOCATION_PERMISSION ->
+ PermissionRecoveryCard(location, stringResource(Res.string.mesh_hud_location_rationale))
+
+ HudWarning.LOCATION_DISABLED ->
+ Button(onClick = onOpenLocationSettings) {
+ Icon(MeshtasticIcons.MyLocation, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(Res.string.compass_location_disabled))
+ }
+
+ HudWarning.NO_LOCATION_FIX -> HudLabel(stringResource(Res.string.compass_no_location_fix))
+ }
+ }
+}
+
+/**
+ * The first open's coaching: how to hold the phone, and what a tap and a long press do. Shown once, over the view, and
+ * gone at the first tap on its button.
+ */
+@Composable
+internal fun HudCoach(onDismiss: () -> Unit, modifier: Modifier = Modifier) {
+ Box(modifier.fillMaxSize().background(Color.Black.copy(alpha = COACH_BACKDROP_ALPHA)), contentAlignment = Alignment.Center) {
+ Column(
+ modifier =
+ Modifier.widthIn(max = CoachMaxWidth)
+ .padding(24.dp)
+ .background(HudScrim, MaterialTheme.shapes.large)
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ listOf(Res.string.mesh_hud_coach_point, Res.string.mesh_hud_coach_tap, Res.string.mesh_hud_coach_find)
+ .forEach { Text(stringResource(it), color = Color.White, style = MaterialTheme.typography.bodyLarge) }
+ Button(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
+ Text(stringResource(Res.string.mesh_hud_coach_dismiss))
+ }
+ }
+ }
+}
+
+/** The traceroute's two colors, as on the map, and how many of its nodes have a position to draw. */
+@Composable
+private fun RouteLegend(route: HudRoute) {
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ HudLabel(stringResource(Res.string.traceroute_showing_nodes, route.mappable, route.total))
+ HudLabel(
+ stringResource(Res.string.traceroute_outgoing_route),
+ Modifier.border(2.dp, TracerouteColors.OutgoingRoute, MaterialTheme.shapes.small),
+ )
+ if (route.back != null) {
+ HudLabel(
+ stringResource(Res.string.traceroute_return_route),
+ Modifier.border(2.dp, TracerouteColors.ReturnRoute, MaterialTheme.shapes.small),
+ )
+ }
+ }
+}
+
+@Composable
+private fun FinderHintLabel(hint: FinderHint, modifier: Modifier = Modifier) {
+ val text =
+ when (hint) {
+ FinderHint.OnTarget -> stringResource(Res.string.mesh_hud_on_target)
+
+ is FinderHint.Turn ->
+ stringResource(
+ if (hint.right) Res.string.mesh_hud_turn_right else Res.string.mesh_hud_turn_left,
+ hint.degrees,
+ )
+
+ is FinderHint.Tilt ->
+ stringResource(
+ if (hint.up) Res.string.mesh_hud_tilt_up else Res.string.mesh_hud_tilt_down,
+ hint.degrees,
+ )
+ }
+ Text(
+ text = text,
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium,
+ modifier =
+ modifier
+ .semantics { liveRegion = LiveRegionMode.Polite }
+ .background(HudScrim, MaterialTheme.shapes.medium)
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ )
+}
+
+@Composable
+private fun HudRangeToggle(targets: MeshHudTargets, onToggle: () -> Unit) {
+ val text =
+ when {
+ !targets.rangeLimited -> stringResource(Res.string.mesh_hud_range_all)
+
+ targets.hiddenByRange > 0 ->
+ stringResource(Res.string.mesh_hud_range_within_hidden, targets.nearRangeText, targets.hiddenByRange)
+
+ else -> stringResource(Res.string.mesh_hud_range_within, targets.nearRangeText)
+ }
+ HudLabel(
+ text = text,
+ modifier =
+ Modifier.clip(MaterialTheme.shapes.small)
+ .toggleable(value = targets.rangeLimited, role = Role.Switch, onValueChange = { onToggle() }),
+ )
+}
+
+@Composable
+internal fun HudLabel(text: String, modifier: Modifier = Modifier) {
+ Text(
+ text = text,
+ color = Color.White,
+ style = MaterialTheme.typography.labelMedium,
+ modifier =
+ modifier.background(HudScrim, MaterialTheme.shapes.small).padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
index 379114272fe..f55aec7f9de 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
@@ -41,7 +41,7 @@ private val ArcExtraWidth = 4.dp
private val TrackWidth = 3.dp
private val TrackRing = 4.dp
private val LabelPadding = 4.dp
-private val LabelScrim = Color.Black.copy(alpha = 0.55f)
+private val LabelScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA)
private val Dashes = floatArrayOf(18f, 12f)
/** Projects [direction], or null when it is behind the camera. */
@@ -66,6 +66,9 @@ private fun DrawScope.drawPolyline(
}
}
+/** The localized patterns for a route's apex tags, read in composition since drawing cannot reach resources. */
+internal class RouteLabels(val skippedTemplate: String, val snrTemplate: String)
+
/** A small label on a dark plate, centered on [at]. */
private fun DrawScope.drawTag(text: String, at: Offset, textMeasurer: TextMeasurer, style: TextStyle) {
val layout = textMeasurer.measure(text, style)
@@ -91,6 +94,7 @@ internal fun DrawScope.drawRoute(
backColor: Color,
textMeasurer: TextMeasurer,
labelStyle: TextStyle,
+ labels: RouteLabels,
) {
listOfNotNull(route.forward, route.back).forEach { leg ->
val color = if (leg.forward) forwardColor else backColor
@@ -120,8 +124,8 @@ internal fun DrawScope.drawRoute(
if (apex != null) {
val tag =
when {
- skipped > 0 -> "+$skipped"
- snr != null -> formatString("%.1f dB", snr)
+ skipped > 0 -> formatString(labels.skippedTemplate, skipped)
+ snr != null -> formatString(labels.snrTemplate, formatString("%.1f", snr))
else -> null
}
tag?.let { drawTag(it, apex, textMeasurer, labelStyle) }
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
index c04ec2a8b5d..245f1149d81 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudRadar.kt
@@ -37,15 +37,22 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.MeasurementSystem
import org.meshtastic.core.model.util.toCodePointString
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.model.util.waypointIconOrDefault
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.mesh_hud_radar_description
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.log10
@@ -64,7 +71,7 @@ private const val NODE_RING_ALPHA = 0.25f
private val CompactRadar = 168.dp
private val ExpandedRadar = 300.dp
private val RadarDot = 30.dp
-private val RadarScrim = Color.Black.copy(alpha = 0.55f)
+private val RadarScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA)
/** The smallest of 1, 2 or 5 times a power of ten that is at least [meters]. */
internal fun niceScaleM(meters: Double): Double {
@@ -113,7 +120,19 @@ internal fun NearbyRadar(
val textMeasurer = rememberTextMeasurer()
val labelStyle = MaterialTheme.typography.labelSmall.copy(color = Color.White, textAlign = TextAlign.Center)
Layout(
- content = { nearby.forEach { key(it.subject.key) { RadarDot(it.subject) { onOpen(it.subject) } } } },
+ content = {
+ nearby.forEach {
+ key(it.subject.key) {
+ val description =
+ stringResource(
+ Res.string.mesh_hud_radar_description,
+ it.subject.spokenName(),
+ it.distanceM.toInt().toDistanceString(units),
+ )
+ RadarDot(it.subject, description) { onOpen(it.subject) }
+ }
+ }
+ },
modifier =
modifier.size(radarSize).clip(CircleShape).background(RadarScrim).drawBehind {
val heading = currentHeadingDeg() ?: 0.0
@@ -164,7 +183,7 @@ internal fun NearbyRadar(
/** A compact marker for the radar: the node's color and short name, or the waypoint's emoji. */
@Composable
-private fun RadarDot(subject: HudSubject, onClick: () -> Unit) {
+private fun RadarDot(subject: HudSubject, description: String, onClick: () -> Unit) {
val label =
when (subject) {
is HudSubject.NodeSubject -> subject.node.user.short_name.ifEmpty { "?" }
@@ -177,7 +196,11 @@ private fun RadarDot(subject: HudSubject, onClick: () -> Unit) {
}
Box(
contentAlignment = Alignment.Center,
- modifier = Modifier.minimumInteractiveComponentSize().clickable(onClick = onClick),
+ modifier =
+ Modifier.minimumInteractiveComponentSize().clickable(onClick = onClick).clearAndSetSemantics {
+ contentDescription = description
+ role = Role.Button
+ },
) {
when (subject) {
is HudSubject.NodeSubject ->
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
index c9b09aa7546..fecf96ade88 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
@@ -131,8 +131,17 @@ data class MeshHudTargets(
val warnings: List = emptyList(),
/** False until the first computation, so the screen does not report an empty mesh before it has looked. */
val ready: Boolean = false,
+ /** True while the phone is carried at walking pace, so the screen can remind the user to look up. */
+ val moving: Boolean = false,
+ /** The map's precision circle setting, which the precision zones follow. */
+ val showPrecision: Boolean = true,
+ /** The target the finder is on, whether placed in the view or on the radar. */
+ val finding: HudFinding? = null,
)
+/** What the finder is looking for and how far away it is. */
+data class HudFinding(val subject: HudSubject, val distanceText: String)
+
/**
* The HUD's view of [nodes] and [waypoints] from where the phone is. Pure, so the rules are testable without sensors:
* targets inside their uncertainty circle go to [MeshHudTargets.directionUnknown], and the rest are sorted nearest
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
index 096547f42b8..46962102746 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
@@ -18,22 +18,9 @@ package org.meshtastic.feature.map.hud
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.safeDrawingPadding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.selection.toggleable
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
@@ -46,66 +33,38 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
-import androidx.compose.ui.semantics.LiveRegionMode
-import androidx.compose.ui.semantics.Role
-import androidx.compose.ui.semantics.liveRegion
-import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
-import org.meshtastic.core.common.util.formatString
-import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.compass_location_disabled
-import org.meshtastic.core.resources.compass_no_location_fix
-import org.meshtastic.core.resources.compass_no_location_permission
-import org.meshtastic.core.resources.compass_no_magnetometer
import org.meshtastic.core.resources.mesh_hud_camera_rationale
import org.meshtastic.core.resources.mesh_hud_cardinal_east
import org.meshtastic.core.resources.mesh_hud_cardinal_north
import org.meshtastic.core.resources.mesh_hud_cardinal_south
import org.meshtastic.core.resources.mesh_hud_cardinal_west
-import org.meshtastic.core.resources.mesh_hud_elevation_approximate
-import org.meshtastic.core.resources.mesh_hud_heading
-import org.meshtastic.core.resources.mesh_hud_heading_accuracy
-import org.meshtastic.core.resources.mesh_hud_heading_low_accuracy
-import org.meshtastic.core.resources.mesh_hud_inside_geofence
-import org.meshtastic.core.resources.mesh_hud_inside_unnamed_geofence
-import org.meshtastic.core.resources.mesh_hud_nearby
-import org.meshtastic.core.resources.mesh_hud_no_nodes
-import org.meshtastic.core.resources.mesh_hud_on_target
-import org.meshtastic.core.resources.mesh_hud_own_accuracy
-import org.meshtastic.core.resources.mesh_hud_range_all
-import org.meshtastic.core.resources.mesh_hud_range_within
-import org.meshtastic.core.resources.mesh_hud_range_within_hidden
-import org.meshtastic.core.resources.mesh_hud_tilt_down
-import org.meshtastic.core.resources.mesh_hud_tilt_up
-import org.meshtastic.core.resources.mesh_hud_turn_left
-import org.meshtastic.core.resources.mesh_hud_turn_right
-import org.meshtastic.core.resources.mesh_hud_waiting_sensor
-import org.meshtastic.core.resources.navigate_back
-import org.meshtastic.core.resources.traceroute_outgoing_route
-import org.meshtastic.core.resources.traceroute_return_route
-import org.meshtastic.core.resources.traceroute_showing_nodes
+import org.meshtastic.core.resources.mesh_hud_more
+import org.meshtastic.core.resources.mesh_hud_snr
import org.meshtastic.core.ui.component.PermissionRecoveryCard
-import org.meshtastic.core.ui.icon.ArrowBack
-import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.KeepScreenOn
import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.rememberCameraPermissionState
import org.meshtastic.core.ui.util.rememberLocationPermissionState
+import org.meshtastic.core.ui.util.rememberOpenLocationSettings
+import org.meshtastic.feature.map.SharedMapViewModel
+import org.meshtastic.feature.map.component.MapFilterSheet
import org.meshtastic.feature.map.component.WaypointInfoDialog
+import org.meshtastic.feature.map.component.mapFilterActions
import org.meshtastic.proto.Waypoint
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -125,7 +84,6 @@ private const val ON_TARGET_DEG = 5.0
/** Turn hints move in steps this coarse, so the label does not churn with every sensor sample. */
private const val HINT_STEP_DEG = 5
-private val HudScrim = Color.Black.copy(alpha = 0.55f)
private val TapeGap = 4.dp
/** Pitched further down than this, the user is looking for something close by, so the radar grows. */
@@ -134,16 +92,16 @@ private const val POINTING_DOWN_DEG = -45.0
/** The band under the top bar the compass tape takes, which chips stay out of. */
private val TapeClearance = 52.dp
-/** Where the finder's target is from the center of the view, rounded to [HINT_STEP_DEG]. */
-private sealed interface FinderHint {
- data object OnTarget : FinderHint
+/** Walking pace held this long brings up the reminder to look around, so a step or two does not. */
+private const val WALK_NOTICE_MS = 3_000L
- data class Turn(val degrees: Int, val right: Boolean) : FinderHint
+/** Neither turned nor carried for this long, the phone has likely been set down, so the screen may sleep. */
+private const val IDLE_SCREEN_MS = 10 * 60_000L
- data class Tilt(val degrees: Int, val up: Boolean) : FinderHint
-}
+/** Turning the view this far counts as the user still looking. */
+private const val ACTIVITY_TURN_DEG = 15
-@Suppress("LongMethod", "UnnecessaryLaunchedEffect")
+@Suppress("LongMethod", "UnnecessaryLaunchedEffect", "CyclomaticComplexMethod")
@Composable
fun MeshHudScreen(
focusNodeNum: Int?,
@@ -154,6 +112,7 @@ fun MeshHudScreen(
tracerouteRequestId: Int? = null,
tracerouteLogUuid: String? = null,
trackNodeNum: Int? = null,
+ onEditWaypoint: ((Int) -> Unit)? = null,
viewModel: MeshHudViewModel = koinViewModel(),
) {
LaunchedEffect(tracerouteDestNum, tracerouteRequestId, tracerouteLogUuid) {
@@ -164,11 +123,14 @@ fun MeshHudScreen(
LaunchedEffect(focusNodeNum) { viewModel.openFor(focusNodeNum) }
val route by viewModel.route.collectAsStateWithLifecycle()
val alertOptIns by viewModel.geofenceAlertOptIns.collectAsStateWithLifecycle()
+ val coachSeen by viewModel.coachSeen.collectAsStateWithLifecycle()
var infoWaypoint by remember { mutableStateOf(null) }
+ var showFilters by remember { mutableStateOf(false) }
+ var cameraAvailable by remember { mutableStateOf(true) }
val track by viewModel.track.collectAsStateWithLifecycle()
- KeepScreenOn(true)
val camera = rememberCameraPermissionState()
val location = rememberLocationPermissionState()
+ val openLocationSettings = rememberOpenLocationSettings()
// One system dialog at a time, and only ever asked once: a recreated screen must not prompt again. These stay
// keyed effects because each must fire once per key change, which SideEffect does not do.
LaunchedEffect(Unit) { if (camera.status == PermissionStatus.NOT_REQUESTED) camera.request() }
@@ -208,17 +170,40 @@ fun MeshHudScreen(
}
}
val hasSensor by remember { derivedStateOf { poseState.value.hasSensor } }
+ val thermal by remember { derivedStateOf { poseState.value.thermal } }
val waitingForSensor by remember {
derivedStateOf { sensorSilent && poseState.value.hasSensor && poseState.value.pose == null }
}
val hint by remember { derivedStateOf { focused?.let { finderHint(it, poseState.value.pose) } } }
val pointingDown by remember { derivedStateOf { (poseState.value.pose?.pitchDeg ?: 0.0) < POINTING_DOWN_DEG } }
val northLabel = stringResource(Res.string.mesh_hud_cardinal_north)
- FinderHaptics(locked = hint == FinderHint.OnTarget)
+ FinderHaptics(hint)
+
+ // The screen stays on while the user is looking: turning the view or walking restarts the clock.
+ val lookBucket by remember { derivedStateOf { headingDeg?.div(ACTIVITY_TURN_DEG) } }
+ var idle by remember { mutableStateOf(false) }
+ LaunchedEffect(lookBucket, targets.moving) {
+ idle = false
+ delay(IDLE_SCREEN_MS)
+ idle = true
+ }
+ KeepScreenOn(!idle)
+ var walking by remember { mutableStateOf(false) }
+ LaunchedEffect(targets.moving) {
+ if (targets.moving) delay(WALK_NOTICE_MS)
+ walking = targets.moving
+ }
+ // A critical thermal state gives the camera back to the system; the overlay carries on as a compass.
+ val showCamera = camera.isGranted && thermal != HudThermal.CRITICAL
val tapeClearancePx = with(LocalDensity.current) { TapeClearance.roundToPx() }
val textMeasurer = rememberTextMeasurer()
val tapeStyle = MaterialTheme.typography.labelSmall.copy(color = Color.White)
+ val routeLabels =
+ RouteLabels(
+ skippedTemplate = stringResource(Res.string.mesh_hud_more),
+ snrTemplate = stringResource(Res.string.mesh_hud_snr),
+ )
val cardinals =
Cardinals(
north = stringResource(Res.string.mesh_hud_cardinal_north),
@@ -232,9 +217,16 @@ fun MeshHudScreen(
val height = constraints.maxHeight.toFloat()
val focalPx = intrinsics?.focalPx(width, height) ?: fallbackFocalPx(width, height)
- if (camera.isGranted) HudCameraPreview(onIntrinsics = { intrinsics = it }, modifier = Modifier.fillMaxSize())
+ if (showCamera) {
+ HudCameraPreview(
+ onIntrinsics = { intrinsics = it },
+ onAvailable = { cameraAvailable = it },
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
- Canvas(Modifier.fillMaxSize()) {
+ // Decoration only: every target it marks is a chip or radar dot with its own semantics.
+ Canvas(Modifier.fillMaxSize().clearAndSetSemantics {}) {
val state = poseState.value
val pose = state.pose ?: return@Canvas
drawHorizon(pose, focalPx)
@@ -249,9 +241,12 @@ fun MeshHudScreen(
TracerouteColors.ReturnRoute,
textMeasurer,
tapeStyle,
+ routeLabels,
)
}
- targets.targets.forEach { drawPrecisionZone(it, pose, focalPx, state.headingAccuracyDeg) }
+ if (targets.showPrecision) {
+ targets.targets.forEach { drawPrecisionZone(it, pose, focalPx, state.headingAccuracyDeg) }
+ }
focused?.let { drawReticle(Color(it.subject.color), locked = finderHint(it, pose) == FinderHint.OnTarget) }
drawCompassTape(
trueHeadingDeg = (pose.azimuthDeg + targets.declinationDeg + FULL_CIRCLE_DEG) % FULL_CIRCLE_DEG,
@@ -274,6 +269,7 @@ fun MeshHudScreen(
) { target ->
HudChip(
target = target,
+ currentPose = { poseState.value.pose },
// A node opens its detail and a waypoint its info, as on the map; long press focuses either.
onOpen = {
when (val subject = target.subject) {
@@ -286,14 +282,19 @@ fun MeshHudScreen(
}
HudTopBar(
- headingDeg = headingDeg,
- headingAccuracyDeg = headingAccuracyDeg,
- hasSensor = hasSensor,
- needsCalibration = needsCalibration,
- waitingForSensor = waitingForSensor,
+ status =
+ HudSensorStatus(
+ headingDeg = headingDeg,
+ headingAccuracyDeg = headingAccuracyDeg,
+ hasSensor = hasSensor,
+ needsCalibration = needsCalibration,
+ waitingForSensor = waitingForSensor,
+ thermal = thermal,
+ ),
targets = targets,
onNavigateUp = onNavigateUp,
onToggleRange = viewModel::toggleRangeLimit,
+ onOpenFilters = if (targets.ready && !targets.scoped) ({ showFilters = true }) else null,
modifier =
Modifier.align(Alignment.TopStart).onPlaced {
topBarBottomPx = (it.positionInParent().y + it.size.height).toInt()
@@ -303,6 +304,9 @@ fun MeshHudScreen(
targets = targets,
hint = hint,
route = route,
+ notices = HudNotices(cameraUnavailable = showCamera && !cameraAvailable, walking = walking),
+ location = location,
+ onOpenLocationSettings = openLocationSettings,
radar = {
NearbyRadar(
nearby = targets.directionUnknown,
@@ -325,9 +329,21 @@ fun MeshHudScreen(
modifier =
Modifier.align(Alignment.BottomStart).onPlaced { bottomPanelTopPx = it.positionInParent().y.toInt() },
)
+ if (!coachSeen && camera.isGranted) HudCoach(onDismiss = viewModel::dismissCoach)
}
- // Read-only here: editing a waypoint re-broadcasts it, which belongs on the map with its pickers.
+ if (showFilters) {
+ // The map's own filters and sheet, so the two views always agree on what they show.
+ val mapViewModel: SharedMapViewModel = koinViewModel()
+ val filterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
+ MapFilterSheet(
+ onDismissRequest = { showFilters = false },
+ filterState = filterState,
+ actions = remember(mapViewModel) { mapViewModel.mapFilterActions() },
+ )
+ }
+
+ // Editing hands off to the map, which has the pickers a waypoint needs.
infoWaypoint?.let { waypoint ->
WaypointInfoDialog(
waypoint = waypoint,
@@ -335,6 +351,13 @@ fun MeshHudScreen(
alertsEnabled = waypoint.id in alertOptIns,
onToggleAlerts = { viewModel.setGeofenceAlertOptIn(waypoint.id, it) },
onDismissRequest = { infoWaypoint = null },
+ onEdit =
+ onEditWaypoint?.let { edit ->
+ {
+ infoWaypoint = null
+ edit(waypoint.id)
+ }
+ },
)
}
}
@@ -353,190 +376,27 @@ private fun finderHint(target: HudTarget, pose: CameraPose?): FinderHint? {
}
}
-/** One firm pulse as the finder locks on, so the user can stop sweeping without looking at the screen. */
-@Suppress("UnnecessaryLaunchedEffect") // One pulse per lock, not one per recomposition.
-@Composable
-private fun FinderHaptics(locked: Boolean) {
- val haptics = LocalHapticFeedback.current
- LaunchedEffect(locked) { if (locked) haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }
-}
+/** True when this turn hint is a step closer than [other] on the same side, so the user is closing in. */
+private fun FinderHint.Turn.closerThan(other: FinderHint?): Boolean =
+ other is FinderHint.Turn && other.right == right && degrees < other.degrees
-@Suppress("LongParameterList")
+/**
+ * A tick for each hint step closer while turning toward the target, silent when sweeping away, and one firm pulse as
+ * the finder locks on, so the user can stop without looking at the screen. Tilt steps stay silent: they change with
+ * every movement of the wrist.
+ */
+@Suppress("UnnecessaryLaunchedEffect") // One haptic per hint change, not one per recomposition.
@Composable
-private fun HudTopBar(
- headingDeg: Int?,
- headingAccuracyDeg: Int?,
- hasSensor: Boolean,
- needsCalibration: Boolean,
- waitingForSensor: Boolean,
- targets: MeshHudTargets,
- onNavigateUp: () -> Unit,
- onToggleRange: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Column(modifier.fillMaxWidth().safeDrawingPadding()) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- IconButton(onClick = onNavigateUp) {
- Icon(MeshtasticIcons.ArrowBack, stringResource(Res.string.navigate_back), tint = Color.White)
- }
- headingDeg?.let { heading ->
- val headingText = formatString("%d°", heading)
- HudLabel(
- headingAccuracyDeg?.let {
- stringResource(Res.string.mesh_hud_heading_accuracy, headingText, formatString("%d°", it))
- } ?: stringResource(Res.string.mesh_hud_heading, headingText),
- )
- }
- if (targets.ready && !targets.scoped) {
- Spacer(Modifier.width(8.dp))
- HudRangeToggle(targets, onToggleRange)
- }
- }
- targets.ownAccuracyM?.let { accuracy ->
- HudLabel(
- stringResource(
- Res.string.mesh_hud_own_accuracy,
- accuracy.toInt().coerceAtLeast(1).toDistanceString(targets.units),
- ),
- )
- }
+private fun FinderHaptics(hint: FinderHint?) {
+ val haptics = LocalHapticFeedback.current
+ var previous by remember { mutableStateOf(null) }
+ LaunchedEffect(hint) {
+ val last = previous
+ previous = hint
when {
- !hasSensor -> HudLabel(stringResource(Res.string.compass_no_magnetometer))
-
- waitingForSensor -> HudLabel(stringResource(Res.string.mesh_hud_waiting_sensor))
-
- needsCalibration -> HudLabel(stringResource(Res.string.mesh_hud_heading_low_accuracy))
+ hint == FinderHint.OnTarget -> haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
+ hint is FinderHint.Turn && hint.closerThan(last) ->
+ haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
}
}
-
-@Composable
-private fun HudBottomPanel(
- targets: MeshHudTargets,
- hint: FinderHint?,
- route: HudRoute?,
- radar: @Composable () -> Unit,
- cameraContent: @Composable () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Column(
- modifier.fillMaxWidth().safeDrawingPadding().padding(8.dp),
- verticalArrangement = Arrangement.spacedBy(6.dp),
- ) {
- hint?.let { FinderHintLabel(it, Modifier.align(Alignment.CenterHorizontally)) }
- route?.let { RouteLegend(it) }
- cameraContent()
- targets.warnings.forEach { HudLabel(stringResource(it.label())) }
- targets.fences
- .filter { it.inside }
- .forEach { fence ->
- HudLabel(
- if (fence.name.isNotBlank()) {
- stringResource(Res.string.mesh_hud_inside_geofence, fence.name)
- } else {
- stringResource(Res.string.mesh_hud_inside_unnamed_geofence)
- },
- )
- }
- if (
- targets.ready &&
- route == null &&
- targets.warnings.isEmpty() &&
- targets.targets.isEmpty() &&
- targets.directionUnknown.isEmpty()
- ) {
- HudLabel(stringResource(Res.string.mesh_hud_no_nodes))
- }
- if (targets.elevationApproximate && targets.targets.isNotEmpty()) {
- HudLabel(stringResource(Res.string.mesh_hud_elevation_approximate))
- }
- if (targets.directionUnknown.isNotEmpty()) {
- HudLabel(stringResource(Res.string.mesh_hud_nearby))
- radar()
- }
- }
-}
-
-/** The traceroute's two colors, as on the map, and how many of its nodes have a position to draw. */
-@Composable
-private fun RouteLegend(route: HudRoute) {
- Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
- HudLabel(stringResource(Res.string.traceroute_showing_nodes, route.mappable, route.total))
- HudLabel(
- stringResource(Res.string.traceroute_outgoing_route),
- Modifier.border(2.dp, TracerouteColors.OutgoingRoute, MaterialTheme.shapes.small),
- )
- if (route.back != null) {
- HudLabel(
- stringResource(Res.string.traceroute_return_route),
- Modifier.border(2.dp, TracerouteColors.ReturnRoute, MaterialTheme.shapes.small),
- )
- }
- }
-}
-
-@Composable
-private fun FinderHintLabel(hint: FinderHint, modifier: Modifier = Modifier) {
- val text =
- when (hint) {
- FinderHint.OnTarget -> stringResource(Res.string.mesh_hud_on_target)
-
- is FinderHint.Turn ->
- stringResource(
- if (hint.right) Res.string.mesh_hud_turn_right else Res.string.mesh_hud_turn_left,
- hint.degrees,
- )
-
- is FinderHint.Tilt ->
- stringResource(
- if (hint.up) Res.string.mesh_hud_tilt_up else Res.string.mesh_hud_tilt_down,
- hint.degrees,
- )
- }
- Text(
- text = text,
- color = Color.White,
- style = MaterialTheme.typography.titleMedium,
- modifier =
- modifier
- .semantics { liveRegion = LiveRegionMode.Polite }
- .background(HudScrim, MaterialTheme.shapes.medium)
- .padding(horizontal = 12.dp, vertical = 6.dp),
- )
-}
-
-private fun HudWarning.label() = when (this) {
- HudWarning.NO_LOCATION_PERMISSION -> Res.string.compass_no_location_permission
- HudWarning.LOCATION_DISABLED -> Res.string.compass_location_disabled
- HudWarning.NO_LOCATION_FIX -> Res.string.compass_no_location_fix
-}
-
-@Composable
-private fun HudRangeToggle(targets: MeshHudTargets, onToggle: () -> Unit) {
- val text =
- when {
- !targets.rangeLimited -> stringResource(Res.string.mesh_hud_range_all)
-
- targets.hiddenByRange > 0 ->
- stringResource(Res.string.mesh_hud_range_within_hidden, targets.nearRangeText, targets.hiddenByRange)
-
- else -> stringResource(Res.string.mesh_hud_range_within, targets.nearRangeText)
- }
- HudLabel(
- text = text,
- modifier =
- Modifier.clip(MaterialTheme.shapes.small)
- .toggleable(value = targets.rangeLimited, role = Role.Switch, onValueChange = { onToggle() }),
- )
-}
-
-@Composable
-internal fun HudLabel(text: String, modifier: Modifier = Modifier) {
- Text(
- text = text,
- color = Color.White,
- style = MaterialTheme.typography.labelMedium,
- modifier =
- modifier.background(HudScrim, MaterialTheme.shapes.small).padding(horizontal = 6.dp, vertical = 2.dp),
- )
-}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
index 32af7d0263d..62bd1663e32 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
@@ -63,6 +63,9 @@ import org.meshtastic.proto.Position as ProtoPosition
private const val STOP_TIMEOUT_MS = 5_000L
+/** Above this ground speed the user is walking with the phone up, which is when to remind them to look around. */
+private const val WALKING_MPS = 1.0f
+
private data class HudSettings(
val units: MeasurementSystem,
val focusKey: String?,
@@ -133,7 +136,7 @@ class MeshHudViewModel(
meshLogRepository: MeshLogRepository,
tracerouteResponseProvider: TracerouteResponseProvider,
tracerouteSnapshotRepository: TracerouteSnapshotRepository,
- mapPrefs: MapPrefs,
+ private val mapPrefs: MapPrefs,
private val notificationPrefs: NotificationPrefs,
localeUnitsProvider: LocaleUnitsProvider,
dispatchers: CoroutineDispatchers,
@@ -296,7 +299,16 @@ class MeshHudViewModel(
)
// A scoped view has no range toggle, so it must not drop its own node for being far.
.limitedTo(settings.rangeLimited && solo == null, settings.units, settings.pinned)
- .copy(averagedFixes = fixAverager.count, scoped = solo != null, ready = true)
+ .let { built ->
+ built.copy(
+ averagedFixes = fixAverager.count,
+ scoped = solo != null,
+ ready = true,
+ moving = (location.location?.speedMps ?: 0f) > WALKING_MPS,
+ showPrecision = settings.filters.showPrecisionCircle,
+ finding = built.findingFor(settings.focusKey),
+ )
+ }
}
.flowOn(dispatchers.default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), MeshHudTargets())
@@ -379,6 +391,17 @@ class MeshHudViewModel(
rangeLimited.value = !rangeLimited.value
}
+ private fun MeshHudTargets.findingFor(focusKey: String?): HudFinding? =
+ targets.firstOrNull { it.focused }?.let { HudFinding(it.subject, it.distanceText) }
+ ?: directionUnknown
+ .firstOrNull { focusKey != null && it.subject.key == focusKey }
+ ?.let { HudFinding(it.subject, it.distanceM.toInt().toDistanceString(units)) }
+
+ /** True once the user has dismissed the first-run coaching. */
+ val coachSeen: StateFlow = mapPrefs.meshHudCoachSeen
+
+ fun dismissCoach() = mapPrefs.setMeshHudCoachSeen(true)
+
private fun MeshHudTargets.limitedTo(limited: Boolean, units: MeasurementSystem, pinned: Set): MeshHudTargets {
val range = nearRangeM(units)
fun HudTarget.isPinned() = (subject as? HudSubject.NodeSubject)?.node?.num in pinned
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
index 9ae28088063..77766f1e0c3 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
@@ -46,6 +46,8 @@ fun EntryProviderScope.mapGraph(backStack: NavBackStack) {
tracerouteRequestId = args.tracerouteRequestId,
tracerouteLogUuid = args.tracerouteLogUuid,
trackNodeNum = args.trackNodeNum,
+ // The map opens the waypoint's own dialog, whose edit has the pickers.
+ onEditWaypoint = { id -> backStack.add(MapRoute.Map(waypointId = id)) },
)
}
}
diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.ios.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.ios.kt
index 83b00d3cbb8..aa3f21d672a 100644
--- a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.ios.kt
+++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.ios.kt
@@ -19,4 +19,9 @@ package org.meshtastic.feature.map.hud
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-@Composable actual fun HudCameraPreview(onIntrinsics: (CameraIntrinsics?) -> Unit, modifier: Modifier) = Unit
+@Composable
+actual fun HudCameraPreview(
+ onIntrinsics: (CameraIntrinsics?) -> Unit,
+ onAvailable: (Boolean) -> Unit,
+ modifier: Modifier,
+) = Unit
diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.jvm.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.jvm.kt
index 83b00d3cbb8..aa3f21d672a 100644
--- a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.jvm.kt
+++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCameraPreview.jvm.kt
@@ -19,4 +19,9 @@ package org.meshtastic.feature.map.hud
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-@Composable actual fun HudCameraPreview(onIntrinsics: (CameraIntrinsics?) -> Unit, modifier: Modifier) = Unit
+@Composable
+actual fun HudCameraPreview(
+ onIntrinsics: (CameraIntrinsics?) -> Unit,
+ onAvailable: (Boolean) -> Unit,
+ modifier: Modifier,
+) = Unit
From 06970650c2c276c111c442fd4de1f3571f5b4b32 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 19 Oct 2026 17:59:04 -0500
Subject: [PATCH 12/31] feat(node): node list menu and node detail route open
the compass
---
.../feature/node/component/NodeContextMenu.kt | 26 ++++++++++++++-----
.../feature/node/detail/NodeDetailScreens.kt | 13 ++++++++++
.../feature/node/list/NodeListScreen.kt | 2 ++
.../node/navigation/AdaptiveNodeListScreen.kt | 1 +
.../node/navigation/NodesNavigation.kt | 1 +
5 files changed, 36 insertions(+), 7 deletions(-)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt
index a6c695b8f2d..58de33179bc 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt
@@ -32,6 +32,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.delay
+import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Node
@@ -40,6 +41,7 @@ import org.meshtastic.core.resources.add_favorite
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.resources.message
+import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.mute_notifications
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_favorite
@@ -48,6 +50,7 @@ import org.meshtastic.core.resources.trace_route
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.resources.update_status
import org.meshtastic.core.ui.icon.CenterFocusStrong
+import org.meshtastic.core.ui.icon.Compass
import org.meshtastic.core.ui.icon.DeleteNode
import org.meshtastic.core.ui.icon.DoDisturb
import org.meshtastic.core.ui.icon.Favorite
@@ -62,7 +65,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.util.rememberMeshHudSupported
/**
- * Shared context menu for node actions (favorite, mute, message, trace route, mesh view, ignore, remove).
+ * Shared context menu for node actions (favorite, mute, message, trace route, compass, mesh view, ignore, remove).
*
* Used by both Android and Desktop adaptive node list screens.
*/
@@ -79,6 +82,7 @@ fun NodeContextMenu(
onRemove: () -> Unit,
onDismiss: () -> Unit,
onOpenMeshView: () -> Unit = {},
+ onOpenCompass: () -> Unit = {},
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
@@ -88,8 +92,16 @@ fun NodeContextMenu(
}
MessageMenuItem(node, onMessage, onDismiss)
TraceRouteMenuItem(node, lastTracerouteTime, onTraceRoute, onDismiss)
- if (node.validPosition != null && rememberMeshHudSupported()) {
- MeshViewMenuItem(onOpenMeshView, onDismiss)
+ if (node.validPosition != null) {
+ MenuItem(MeshtasticIcons.Compass, stringResource(Res.string.open_compass), onOpenCompass, onDismiss)
+ if (rememberMeshHudSupported()) {
+ MenuItem(
+ MeshtasticIcons.CenterFocusStrong,
+ stringResource(Res.string.mesh_hud_open),
+ onOpenMeshView,
+ onDismiss,
+ )
+ }
}
}
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
@@ -220,14 +232,14 @@ private fun TraceRouteMenuItem(
private const val COOLDOWN_TICK_MS = 500L
@Composable
-private fun MeshViewMenuItem(onOpenMeshView: () -> Unit, onDismiss: () -> Unit) {
+private fun MenuItem(icon: ImageVector, text: String, onClick: () -> Unit, onDismiss: () -> Unit) {
DropdownMenuItem(
onClick = {
- onOpenMeshView()
+ onClick()
onDismiss()
},
- leadingIcon = { Icon(imageVector = MeshtasticIcons.CenterFocusStrong, contentDescription = null) },
- text = { Text(text = stringResource(Res.string.mesh_hud_open)) },
+ leadingIcon = { Icon(imageVector = icon, contentDescription = null) },
+ text = { Text(text = text) },
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
index fde44a67b35..348a6841186 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
@@ -30,6 +30,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -70,6 +71,7 @@ fun NodeDetailScreen(
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
compassViewModel: CompassViewModel? = null,
+ openCompass: Boolean = false,
) {
SideEffect(nodeId) { viewModel.start(nodeId) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -83,6 +85,7 @@ fun NodeDetailScreen(
onNavigate = onNavigate,
onNavigateUp = onNavigateUp,
compassViewModel = compassViewModel,
+ openCompass = openCompass,
)
}
@@ -96,8 +99,18 @@ private fun NodeDetailScaffold(
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
compassViewModel: CompassViewModel? = null,
+ openCompass: Boolean = false,
) {
var activeOverlay by remember { mutableStateOf(null) }
+ // Once per arrival: closing the sheet, or coming back to this screen, must not reopen it.
+ var compassOpened by rememberSaveable { mutableStateOf(false) }
+ val compassNode = uiState.node?.takeIf { openCompass && !compassOpened && it.validPosition != null }
+ LaunchedEffect(compassNode?.num) {
+ compassNode?.let {
+ activeOverlay = NodeDetailOverlay.Compass(it.num, uiState.metricsState.displayUnits)
+ compassOpened = true
+ }
+ }
val actualCompassViewModel = compassViewModel
val compassUiState by
actualCompassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 4217aaa4ea8..55442ffc994 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -131,6 +131,7 @@ fun NodeListScreen(
onNavigateToConnections: () -> Unit = {},
onEditStatusMessage: () -> Unit = {},
navigateToMeshView: (Int) -> Unit = {},
+ navigateToCompass: (Int) -> Unit = {},
) {
val showToast = org.meshtastic.core.ui.util.rememberShowToastResource()
val scope = rememberCoroutineScope()
@@ -276,6 +277,7 @@ fun NodeListScreen(
onRemove = { viewModel.removeNodes(listOf(node)) },
onDismiss = { expanded = false },
onOpenMeshView = { navigateToMeshView(node.num) },
+ onOpenCompass = { navigateToCompass(node.num) },
)
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
index 4fcc6fe7afc..605e59f9e5e 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt
@@ -49,5 +49,6 @@ fun AdaptiveNodeListScreen(
// A bare radio-config route on this tab's stack resolves to the local session, the same as Connections -> LoRa.
onEditStatusMessage = { backStack.add(SettingsRoute.UserStatusMessage) },
navigateToMeshView = { num -> backStack.add(MapRoute.MeshHud(focusNodeNum = num)) },
+ navigateToCompass = { num -> backStack.add(NodesRoute.NodeDetail(num, openCompass = true)) },
)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
index f6a7a628737..11cb10d6506 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
@@ -109,6 +109,7 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack)
nodeId = destNum,
viewModel = nodeDetailViewModel,
compassViewModel = compassViewModel,
+ openCompass = args.openCompass,
navigateToMessages = { key -> backStack.add(ContactsRoute.Messages(key)) },
onNavigate = { route -> backStack.navigateFromNodeDetail(route) },
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
From d7ff96577ca17cb0847d2c1d22efa7a041ccdf45 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 19 Oct 2026 18:03:42 -0500
Subject: [PATCH 13/31] test(map): mesh view scoping, edge pinning, clock
positions and fix averaging
---
.../meshtastic/feature/map/hud/FixAverager.kt | 7 +-
.../feature/map/hud/FixAveragerTest.kt | 96 ++++++-
.../feature/map/hud/HudChipsTest.kt | 56 ++++
.../feature/map/hud/HudEdgesTest.kt | 109 ++++++++
.../feature/map/hud/MeshHudViewModelTest.kt | 243 ++++++++++++++++++
5 files changed, 500 insertions(+), 11 deletions(-)
create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudChipsTest.kt
create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudEdgesTest.kt
create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/FixAverager.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/FixAverager.kt
index 2e128b69be4..482b8252813 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/FixAverager.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/FixAverager.kt
@@ -27,6 +27,9 @@ private const val MOVING_MPS = 0.7f
/** A fix this many accuracy radii from the running mean is a real move, not noise. */
private const val JUMP_RADII = 3.0
+/** No fix is trusted below this, so a reported zero neither divides by zero nor makes every wobble a jump. */
+private const val MIN_ACCURACY_M = 1f
+
/** Weight for a fix that reports no accuracy, as if it were this many meters. */
private const val UNKNOWN_ACCURACY_M = 20f
@@ -52,7 +55,7 @@ internal class FixAverager {
val jumped =
current != null &&
latLongToMeter(current.latitude, current.longitude, fix.latitude, fix.longitude) >
- JUMP_RADII * (fix.accuracyM ?: UNKNOWN_ACCURACY_M)
+ JUMP_RADII * (fix.accuracyM ?: UNKNOWN_ACCURACY_M).coerceAtLeast(MIN_ACCURACY_M)
if (moving || jumped) fixes.clear()
fixes.addLast(fix)
while (fixes.size > 1 && fix.timeMillis - fixes.first().timeMillis > WINDOW_MS) fixes.removeFirst()
@@ -65,7 +68,7 @@ internal class FixAverager {
var lat = 0.0
var lon = 0.0
fixes.forEach { f ->
- val accuracy = (f.accuracyM ?: UNKNOWN_ACCURACY_M).toDouble().coerceAtLeast(1.0)
+ val accuracy = (f.accuracyM ?: UNKNOWN_ACCURACY_M).coerceAtLeast(MIN_ACCURACY_M).toDouble()
val w = 1 / (accuracy * accuracy)
weightSum += w
lat += w * f.latitude
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/FixAveragerTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/FixAveragerTest.kt
index 4dadd00f23d..b2c49b1c76a 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/FixAveragerTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/FixAveragerTest.kt
@@ -19,11 +19,13 @@ package org.meshtastic.feature.map.hud
import org.meshtastic.core.repository.PhoneLocation
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
/** About 1.1 m of latitude. */
-private const val METRE_DEG = 0.00001
+private const val METER_DEG = 0.00001
-private fun fix(lat: Double, t: Long, accuracy: Float = 5f, speed: Float? = 0f) =
+private fun fix(lat: Double, t: Long, accuracy: Float? = 5f, speed: Float? = 0f) =
PhoneLocation(40.0 + lat, -105.0, 100.0, t, accuracyM = accuracy, speedMps = speed)
class FixAveragerTest {
@@ -31,8 +33,8 @@ class FixAveragerTest {
@Test
fun `a still phone averages its wander`() {
val averager = FixAverager()
- averager.add(fix(METRE_DEG, 1_000))
- val mean = averager.add(fix(-METRE_DEG, 2_000))
+ averager.add(fix(METER_DEG, 1_000))
+ val mean = averager.add(fix(-METER_DEG, 2_000))
assertEquals(40.0, mean.latitude, 1e-9)
assertEquals(2, averager.count)
}
@@ -41,9 +43,9 @@ class FixAveragerTest {
fun `better fixes count for more`() {
val averager = FixAverager()
averager.add(fix(0.0, 1_000, accuracy = 2f))
- val mean = averager.add(fix(4 * METRE_DEG, 2_000, accuracy = 8f))
+ val mean = averager.add(fix(4 * METER_DEG, 2_000, accuracy = 8f))
// Weights 1/4 and 1/64: the mean sits close to the precise fix.
- assertEquals(40.0 + 4 * METRE_DEG / 17, mean.latitude, 1e-9)
+ assertEquals(40.0 + 4 * METER_DEG / 17, mean.latitude, 1e-9)
assertEquals(2f, mean.accuracyM)
}
@@ -51,8 +53,8 @@ class FixAveragerTest {
fun `walking restarts the average`() {
val averager = FixAverager()
averager.add(fix(0.0, 1_000))
- val mean = averager.add(fix(METRE_DEG, 2_000, speed = 1.4f))
- assertEquals(40.0 + METRE_DEG, mean.latitude, 1e-12)
+ val mean = averager.add(fix(METER_DEG, 2_000, speed = 1.4f))
+ assertEquals(40.0 + METER_DEG, mean.latitude, 1e-12)
assertEquals(1, averager.count)
}
@@ -60,10 +62,86 @@ class FixAveragerTest {
fun `a jump far beyond the accuracy restarts the average`() {
val averager = FixAverager()
averager.add(fix(0.0, 1_000))
- averager.add(fix(100 * METRE_DEG, 2_000))
+ averager.add(fix(100 * METER_DEG, 2_000))
assertEquals(1, averager.count)
}
+ @Test
+ fun `fixes without an accuracy are averaged at an assumed one`() {
+ val averager = FixAverager()
+ averager.add(fix(METER_DEG, 1_000, accuracy = null))
+ val mean = averager.add(fix(-METER_DEG, 2_000, accuracy = null))
+ assertEquals(40.0, mean.latitude, 1e-9)
+ assertNull(mean.accuracyM)
+ assertEquals(2, averager.count)
+ }
+
+ @Test
+ fun `a fix without an accuracy weighs less than a precise one`() {
+ val averager = FixAverager()
+ averager.add(fix(0.0, 1_000, accuracy = 2f))
+ val mean = averager.add(fix(4 * METER_DEG, 2_000, accuracy = null))
+ // Weights 1/4 and 1/400.
+ assertEquals(40.0 + 4 * METER_DEG / 101, mean.latitude, 1e-9)
+ assertEquals(2f, mean.accuracyM)
+ }
+
+ @Test
+ fun `a zero accuracy fix averages as one meter without dividing by zero`() {
+ val averager = FixAverager()
+ averager.add(fix(0.0, 1_000, accuracy = 0f))
+ val mean = averager.add(fix(METER_DEG, 2_000, accuracy = 1f))
+ assertFalse(mean.latitude.isNaN())
+ assertEquals(40.0 + METER_DEG / 2, mean.latitude, 1e-9)
+ assertEquals(0f, mean.accuracyM)
+ assertEquals(2, averager.count)
+ }
+
+ @Test
+ fun `a run of zero accuracy fixes keeps averaging through meter-scale wander`() {
+ val averager = FixAverager()
+ averager.add(fix(0.0, 1_000, accuracy = 0f))
+ averager.add(fix(METER_DEG, 2_000, accuracy = 0f))
+ averager.add(fix(-METER_DEG, 3_000, accuracy = 0f))
+ assertEquals(3, averager.count)
+ }
+
+ @Test
+ fun `a still fix after walking starts a fresh window`() {
+ val averager = FixAverager()
+ averager.add(fix(0.0, 1_000))
+ averager.add(fix(0.0, 2_000))
+ averager.add(fix(2 * METER_DEG, 3_000, speed = 1.4f))
+ val mean = averager.add(fix(4 * METER_DEG, 4_000))
+ assertEquals(40.0 + 3 * METER_DEG, mean.latitude, 1e-9)
+ assertEquals(2, averager.count)
+ }
+
+ @Test
+ fun `a missing speed counts as still`() {
+ val averager = FixAverager()
+ averager.add(fix(0.0, 1_000, speed = null))
+ averager.add(fix(0.0, 2_000, speed = null))
+ assertEquals(2, averager.count)
+ }
+
+ @Test
+ fun `a move within a few accuracy radii is wander not a jump`() {
+ val averager = FixAverager()
+ averager.add(fix(0.0, 1_000))
+ averager.add(fix(8 * METER_DEG, 2_000))
+ assertEquals(2, averager.count)
+ }
+
+ @Test
+ fun `a move past three accuracy radii of the new fix restarts the average`() {
+ val averager = FixAverager()
+ averager.add(fix(0.0, 1_000))
+ val mean = averager.add(fix(20 * METER_DEG, 2_000))
+ assertEquals(1, averager.count)
+ assertEquals(40.0 + 20 * METER_DEG, mean.latitude, 1e-12)
+ }
+
@Test
fun `old fixes leave the window`() {
val averager = FixAverager()
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudChipsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudChipsTest.kt
new file mode 100644
index 00000000000..8eae7d737c3
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudChipsTest.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class HudChipsTest {
+
+ @Test
+ fun `straight ahead is twelve o clock`() {
+ assertEquals(12, clockHour(bearingDeg = 0.0, headingDeg = 0.0))
+ assertEquals(12, clockHour(bearingDeg = 214.0, headingDeg = 214.0))
+ }
+
+ @Test
+ fun `the quarters read three six and nine`() {
+ assertEquals(3, clockHour(bearingDeg = 90.0, headingDeg = 0.0))
+ assertEquals(6, clockHour(bearingDeg = 180.0, headingDeg = 0.0))
+ assertEquals(9, clockHour(bearingDeg = 270.0, headingDeg = 0.0))
+ }
+
+ @Test
+ fun `the hour is relative to the heading`() {
+ assertEquals(3, clockHour(bearingDeg = 180.0, headingDeg = 90.0))
+ assertEquals(9, clockHour(bearingDeg = 0.0, headingDeg = 90.0))
+ }
+
+ @Test
+ fun `bearings wrap across north`() {
+ assertEquals(1, clockHour(bearingDeg = 20.0, headingDeg = 350.0))
+ assertEquals(11, clockHour(bearingDeg = 340.0, headingDeg = 10.0))
+ assertEquals(12, clockHour(bearingDeg = 5.0, headingDeg = 355.0))
+ }
+
+ @Test
+ fun `an angle rounds to the nearest hour`() {
+ assertEquals(12, clockHour(bearingDeg = 14.0, headingDeg = 0.0))
+ assertEquals(1, clockHour(bearingDeg = 16.0, headingDeg = 0.0))
+ assertEquals(11, clockHour(bearingDeg = 344.0, headingDeg = 0.0))
+ }
+}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudEdgesTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudEdgesTest.kt
new file mode 100644
index 00000000000..9569e5d5186
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudEdgesTest.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import kotlin.math.PI
+import kotlin.math.tan
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+private const val W = 1000f
+private const val FOCAL = 800f
+
+private fun pin(yaw: Double, pitch: Double = -10.0, previous: PinEdge? = null, projected: ScreenPoint? = null) =
+ pinEdge(projected, W, yaw, pitch, previous)
+
+class HudEdgesTest {
+
+ @Test
+ fun `a target just past the side threshold keeps its vertical edge inside the hysteresis band`() {
+ assertEquals(PinEdge.BOTTOM, pin(yaw = 40.0))
+ assertEquals(PinEdge.BOTTOM, pin(yaw = 40.0, previous = PinEdge.BOTTOM))
+ assertEquals(PinEdge.TOP, pin(yaw = -40.0, pitch = 10.0, previous = PinEdge.TOP))
+ }
+
+ @Test
+ fun `a target clearing the band to the side moves to that side`() {
+ assertEquals(PinEdge.RIGHT, pin(yaw = 48.0, previous = PinEdge.BOTTOM))
+ assertEquals(PinEdge.LEFT, pin(yaw = -48.0, previous = PinEdge.TOP))
+ }
+
+ @Test
+ fun `a side pinned target holds its side until it clears the band toward ahead`() {
+ assertEquals(PinEdge.RIGHT, pin(yaw = 30.0, previous = PinEdge.RIGHT))
+ assertEquals(PinEdge.LEFT, pin(yaw = -24.0, previous = PinEdge.LEFT))
+ assertEquals(PinEdge.BOTTOM, pin(yaw = 20.0, previous = PinEdge.RIGHT))
+ assertEquals(PinEdge.TOP, pin(yaw = -20.0, pitch = 5.0, previous = PinEdge.LEFT))
+ }
+
+ @Test
+ fun `a target behind pins to the side it is nearer`() {
+ assertEquals(PinEdge.RIGHT, pin(yaw = 170.0))
+ assertEquals(PinEdge.LEFT, pin(yaw = -170.0))
+ assertEquals(PinEdge.RIGHT, pin(yaw = 120.0, pitch = 60.0))
+ }
+
+ @Test
+ fun `a side pinned target near straight behind keeps its side as it crosses over`() {
+ assertEquals(PinEdge.RIGHT, pin(yaw = -175.0, previous = PinEdge.RIGHT))
+ assertEquals(PinEdge.LEFT, pin(yaw = 175.0, previous = PinEdge.LEFT))
+ }
+
+ @Test
+ fun `a target past the behind window switches to its nearer side`() {
+ assertEquals(PinEdge.LEFT, pin(yaw = -160.0, previous = PinEdge.RIGHT))
+ assertEquals(PinEdge.RIGHT, pin(yaw = 160.0, previous = PinEdge.LEFT))
+ }
+
+ @Test
+ fun `a target roughly ahead pins above or below by its pitch`() {
+ assertEquals(PinEdge.TOP, pin(yaw = 10.0, pitch = 30.0))
+ assertEquals(PinEdge.BOTTOM, pin(yaw = 10.0, pitch = -30.0))
+ assertEquals(PinEdge.BOTTOM, pin(yaw = 0.0, pitch = 0.0))
+ }
+
+ @Test
+ fun `a target projected within the view width pins vertically whatever its yaw`() {
+ assertEquals(PinEdge.TOP, pin(yaw = 100.0, pitch = 70.0, projected = ScreenPoint(500f, -50f)))
+ assertEquals(PinEdge.BOTTOM, pin(yaw = -100.0, previous = PinEdge.LEFT, projected = ScreenPoint(0f, 3000f)))
+ }
+
+ @Test
+ fun `a target projected beyond the view width pins by yaw`() {
+ assertEquals(PinEdge.RIGHT, pin(yaw = 60.0, projected = ScreenPoint(W + 1f, 100f)))
+ }
+
+ @Test
+ fun `top and bottom offsets follow yaw`() {
+ assertEquals(FOCAL, edgeOffsetPx(PinEdge.TOP, 45.0, 30.0, FOCAL), 0.01f)
+ assertEquals(-FOCAL, edgeOffsetPx(PinEdge.BOTTOM, -45.0, 30.0, FOCAL), 0.01f)
+ }
+
+ @Test
+ fun `side offsets follow pitch`() {
+ assertEquals(FOCAL, edgeOffsetPx(PinEdge.LEFT, 120.0, 45.0, FOCAL), 0.01f)
+ assertEquals(-FOCAL, edgeOffsetPx(PinEdge.RIGHT, 120.0, -45.0, FOCAL), 0.01f)
+ assertEquals(0f, edgeOffsetPx(PinEdge.RIGHT, 120.0, 0.0, FOCAL), 0.01f)
+ }
+
+ @Test
+ fun `offsets clamp at eighty degrees`() {
+ val max = (tan(80.0 * PI / 180.0) * FOCAL).toFloat()
+ assertEquals(max, edgeOffsetPx(PinEdge.TOP, 89.0, 0.0, FOCAL), 0.1f)
+ assertEquals(-max, edgeOffsetPx(PinEdge.LEFT, 0.0, -170.0, FOCAL), 0.1f)
+ }
+}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
new file mode 100644
index 00000000000..685ce33c1bc
--- /dev/null
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
@@ -0,0 +1,243 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.hud
+
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.service.TracerouteResponse
+import org.meshtastic.core.repository.MagneticFieldProvider
+import org.meshtastic.core.repository.MapFilterPrefs
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.PhoneLocation
+import org.meshtastic.core.repository.PhoneLocationProvider
+import org.meshtastic.core.repository.PhoneLocationState
+import org.meshtastic.core.repository.TracerouteResponseProvider
+import org.meshtastic.core.testing.FakeLocaleUnitsProvider
+import org.meshtastic.core.testing.FakeMapPrefs
+import org.meshtastic.core.testing.FakeMeshLogRepository
+import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.FakeNotificationPrefs
+import org.meshtastic.core.testing.FakeTracerouteSnapshotRepository
+import org.meshtastic.proto.Position
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+private const val LAT = 40.0
+private const val LON = -105.0
+
+/** About 1.1 km north of the phone. */
+private const val NEAR_LAT = 40.01
+
+/** About 111 km north of the phone, past the 50 km range limit. */
+private const val FAR_LAT = 41.0
+
+private fun node(num: Int, lat: Double = NEAR_LAT, favorite: Boolean = false) = Node(
+ num = num,
+ isFavorite = favorite,
+ position =
+ Position.Builder()
+ .also { b ->
+ b.latitude_i = (lat * 1e7).toInt()
+ b.longitude_i = (LON * 1e7).toInt()
+ }
+ .build(),
+)
+
+private class FakePoseProvider : CameraPoseProvider {
+ override fun poseUpdates(): Flow = MutableStateFlow(CameraPoseState())
+}
+
+private class FakePhoneLocationProvider : PhoneLocationProvider {
+ val state =
+ MutableStateFlow(
+ PhoneLocationState(
+ permissionGranted = true,
+ providerEnabled = true,
+ location = PhoneLocation(LAT, LON, 100.0, 0L, accuracyM = 5f, speedMps = 0f),
+ ),
+ )
+
+ override fun locationUpdates(): Flow = state
+}
+
+private class ZeroDeclination : MagneticFieldProvider {
+ override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long) = 0f
+}
+
+private class FakeTracerouteResponses : TracerouteResponseProvider {
+ override val tracerouteResponse = MutableStateFlow(null)
+
+ override fun clearTracerouteResponse() {
+ tracerouteResponse.value = null
+ }
+}
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MeshHudViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val nodeRepository = FakeNodeRepository()
+ private val mapPrefs = FakeMapPrefs()
+ private val traceroutes = FakeTracerouteResponses()
+ private val packetRepository: PacketRepository = mock()
+ private lateinit var viewModel: MeshHudViewModel
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ every { packetRepository.getWaypoints() } returns MutableStateFlow(emptyList())
+ viewModel =
+ MeshHudViewModel(
+ poseProvider = FakePoseProvider(),
+ phoneLocationProvider = FakePhoneLocationProvider(),
+ magneticFieldProvider = ZeroDeclination(),
+ nodeRepository = nodeRepository,
+ packetRepository = packetRepository,
+ meshLogRepository = FakeMeshLogRepository(),
+ tracerouteResponseProvider = traceroutes,
+ tracerouteSnapshotRepository = FakeTracerouteSnapshotRepository(),
+ mapPrefs = mapPrefs,
+ notificationPrefs = FakeNotificationPrefs(),
+ localeUnitsProvider = FakeLocaleUnitsProvider(),
+ dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher),
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun TestScope.collectTargets(): StateFlow {
+ backgroundScope.launch { viewModel.targets.collect {} }
+ runCurrent()
+ return viewModel.targets
+ }
+
+ private val StateFlow.keys
+ get() = value.targets.map { it.subject.key }.toSet()
+
+ @Test
+ fun `targets are not ready before the first computation and ready after`() = runTest(testDispatcher) {
+ nodeRepository.setNodes(listOf(node(1)))
+ assertFalse(viewModel.targets.value.ready)
+ val targets = collectTargets()
+ assertTrue(targets.value.ready)
+ }
+
+ @Test
+ fun `the whole mesh view shows every positioned node passing the map filter`() = runTest(testDispatcher) {
+ nodeRepository.setNodes(listOf(node(1, favorite = true), node(2), Node(num = 3)))
+ mapPrefs.mapFilters.value = MapFilterPrefs(onlyFavorites = true)
+ viewModel.openFor(null)
+ val targets = collectTargets()
+ assertEquals(setOf("node-1"), targets.keys)
+ assertFalse(targets.value.scoped)
+
+ mapPrefs.mapFilters.value = MapFilterPrefs()
+ runCurrent()
+ assertEquals(setOf("node-1", "node-2"), targets.keys)
+ }
+
+ @Test
+ fun `a view opened for a node shows only that node`() = runTest(testDispatcher) {
+ nodeRepository.setNodes(listOf(node(1), node(2), node(3)))
+ viewModel.openFor(2)
+ val targets = collectTargets()
+ assertEquals(setOf("node-2"), targets.keys)
+ assertTrue(targets.value.scoped)
+ assertTrue(targets.value.targets.single().focused)
+ }
+
+ @Test
+ fun `a scoped view ignores the map filter for its node`() = runTest(testDispatcher) {
+ nodeRepository.setNodes(listOf(node(1, favorite = true), node(2)))
+ mapPrefs.mapFilters.value = MapFilterPrefs(onlyFavorites = true)
+ viewModel.openFor(2)
+ assertEquals(setOf("node-2"), collectTargets().keys)
+ }
+
+ @Test
+ fun `a scoped view also shows the node its open track names`() = runTest(testDispatcher) {
+ nodeRepository.setNodes(listOf(node(1), node(2), node(3)))
+ viewModel.openFor(1)
+ viewModel.showTrack(2)
+ assertEquals(setOf("node-1", "node-2"), collectTargets().keys)
+ }
+
+ @Test
+ fun `a scoped view also shows the hops its open traceroute names`() = runTest(testDispatcher) {
+ nodeRepository.setNodes(listOf(node(1), node(2), node(3), node(4), node(5)))
+ traceroutes.tracerouteResponse.value =
+ TracerouteResponse("", 2, requestId = 7, forwardRoute = listOf(3), returnRoute = listOf(4))
+ viewModel.openFor(2)
+ viewModel.showTraceroute(destNum = 2, requestId = 7, logUuid = null)
+ assertEquals(setOf("node-2", "node-3", "node-4"), collectTargets().keys)
+ }
+
+ @Test
+ fun `the whole mesh view hides nodes past the range limit`() = runTest(testDispatcher) {
+ nodeRepository.setNodes(listOf(node(1), node(3, lat = FAR_LAT)))
+ viewModel.openFor(null)
+ val targets = collectTargets()
+ assertEquals(setOf("node-1"), targets.keys)
+ assertEquals(1, targets.value.hiddenByRange)
+ }
+
+ @Test
+ fun `a scoped view keeps its node past the range limit even unfocused`() = runTest(testDispatcher) {
+ val far = node(3, lat = FAR_LAT)
+ nodeRepository.setNodes(listOf(node(1), far))
+ viewModel.openFor(3)
+ val targets = collectTargets()
+ assertEquals(setOf("node-3"), targets.keys)
+
+ viewModel.toggleFocus(HudSubject.NodeSubject(far))
+ runCurrent()
+ assertEquals(setOf("node-3"), targets.keys)
+ assertFalse(targets.value.targets.single().focused)
+ assertEquals(0, targets.value.hiddenByRange)
+ }
+
+ @Test
+ fun `a tracked node past the range limit stays in the whole mesh view`() = runTest(testDispatcher) {
+ nodeRepository.setNodes(listOf(node(1), node(3, lat = FAR_LAT)))
+ viewModel.openFor(null)
+ viewModel.showTrack(3)
+ assertEquals(setOf("node-1", "node-3"), collectTargets().keys)
+ }
+}
From 3e9549a304528b8b370ebf3c1420e54ec54672db Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Mon, 19 Oct 2026 18:09:40 -0500
Subject: [PATCH 14/31] style(map): mesh view lint and formatting
---
.../org/meshtastic/app/di/FlavorModule.kt | 7 ++--
.../map/hud/AndroidCameraPoseProvider.kt | 33 +++++++++----------
.../org/meshtastic/feature/map/MapScreen.kt | 2 +-
.../feature/map/hud/HudChipLayer.kt | 18 ++++++----
.../meshtastic/feature/map/hud/HudChips.kt | 16 +++++----
.../meshtastic/feature/map/hud/HudPanels.kt | 15 +++------
.../meshtastic/feature/map/hud/HudTargets.kt | 4 +++
.../feature/map/hud/MeshHudScreen.kt | 1 +
.../feature/map/hud/MeshHudViewModel.kt | 2 +-
feature/node/detekt-baseline.xml | 2 +-
.../node/component/CompassBottomSheet.kt | 2 +-
.../feature/node/component/NodeContextMenu.kt | 4 +--
.../feature/node/component/PositionSection.kt | 2 +-
.../feature/node/detail/NodeDetailScreens.kt | 25 +++++++++-----
.../node/metrics/PositionLogScreens.kt | 2 +-
.../node/metrics/TracerouteMapScreen.kt | 2 +-
.../node/navigation/MeshHudBackStackTest.kt | 10 +++---
17 files changed, 81 insertions(+), 66 deletions(-)
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
index a97c7802d9d..43169c540be 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
@@ -34,9 +34,10 @@ class FlavorModule {
/** Downloadable Google Fonts for event branding — Google flavor only. */
@Single fun eventFontResolver(): EventFontResolver = GoogleFontsEventFontResolver()
- /** Play services' fused orientation for the camera mesh view, which falls back to the rotation vector without it. */
- @Single
- fun fusedOrientationSource(context: Context): FusedOrientationSource = GoogleFusedOrientationSource(context)
+ /**
+ * Play services' fused orientation for the camera mesh view, which falls back to the rotation vector without it.
+ */
+ @Single fun fusedOrientationSource(context: Context): FusedOrientationSource = GoogleFusedOrientationSource(context)
/**
* Datadog network instrumentation for the shared Ktor `HttpClient`. The interceptor emits RUM Resource spans with
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
index a57ac9ebc58..549e5c24f62 100644
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
@@ -44,21 +44,19 @@ private const val NINETY_FIVE_PERCENT_SIGMAS = 1.96
private const val MAX_HEADING_ERROR_DEG = 180f
/**
- * Prefers [fusedOrientation] and falls back to the platform rotation vector, or the geomagnetic one on devices without
- * a gyroscope. Running hot, either source is slowed.
+ * Prefers [fused] and falls back to the platform rotation vector, or the geomagnetic one on devices without a
+ * gyroscope. Running hot, either source is slowed.
*/
@Single
-class AndroidCameraPoseProvider(
- private val context: Context,
- private val fusedOrientation: FusedOrientationSource,
-) : CameraPoseProvider {
+class AndroidCameraPoseProvider(context: Context, private val fused: FusedOrientationSource) : CameraPoseProvider {
+ private val context = context.applicationContext
override fun poseUpdates(): Flow = callbackFlow {
// Every callback runs on this one thread, away from the main thread that draws the overlay, so the session
// needs no locking.
val thread = HandlerThread("mesh-view-pose").apply { start() }
val handler = Handler(thread.looper)
- val session = PoseSession(context, fusedOrientation, handler) { trySend(it) }
+ val session = PoseSession(context, fused, handler) { trySend(it) }
handler.post(session::start)
awaitClose {
handler.post {
@@ -112,15 +110,14 @@ private class PoseSession(
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
}
- private val fusedListener =
- FusedOrientationSource.Listener { attitude, headingErrorDeg, trueNorth, timestampNs ->
- if (!stopped && fusedHandle != null) {
- val error = headingErrorDeg.toOneSigmaDeg()
- // An invalid heading error means an uncalibrated compass, so it asks for calibration like status 0.
- val calibration = if (error == null) SensorManager.SENSOR_STATUS_UNRELIABLE else CALIBRATION_UNKNOWN
- publish(attitude, timestampNs, calibration, error, trueNorth)
- }
+ private val fusedListener = FusedOrientationSource.Listener { attitude, headingErrorDeg, trueNorth, timestampNs ->
+ if (!stopped && fusedHandle != null) {
+ val error = headingErrorDeg.toOneSigmaDeg()
+ // An invalid heading error means an uncalibrated compass, so it asks for calibration like status 0.
+ val calibration = if (error == null) SensorManager.SENSOR_STATUS_UNRELIABLE else CALIBRATION_UNKNOWN
+ publish(attitude, timestampNs, calibration, error, trueNorth)
}
+ }
fun start() {
val power = powerManager
@@ -228,8 +225,10 @@ private fun SensorEvent.toQuaternion(out: FloatArray) {
private fun SensorEvent.headingAccuracyRad(): Float? = values.getOrNull(HEADING_ACCURACY_INDEX)?.takeIf { it >= 0f }
/** A 95% heading error bound in degrees, as the one-sigma figure every other accuracy on screen uses. */
-private fun Float.toOneSigmaDeg(): Double? =
- takeIf { it >= 0f && it < MAX_HEADING_ERROR_DEG }?.let { it / NINETY_FIVE_PERCENT_SIGMAS }
+private fun Float.toOneSigmaDeg(): Double? = takeIf {
+ it >= 0f && it < MAX_HEADING_ERROR_DEG
+}
+ ?.let { it / NINETY_FIVE_PERCENT_SIGMAS }
private fun Int.toHudThermal(): HudThermal = when {
this >= PowerManager.THERMAL_STATUS_CRITICAL -> HudThermal.CRITICAL
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 a504d19e216..193061886f5 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
@@ -30,8 +30,8 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map
import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.ui.component.MainAppBar
-import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.CenterFocusStrong
+import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.LocalMapViewProvider
@Composable
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
index 6aa85f87fb5..b6666be14e8 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
@@ -30,14 +30,14 @@ import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.semantics.isTraversalGroup
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntRect
-import androidx.compose.ui.semantics.isTraversalGroup
-import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
@@ -124,11 +124,15 @@ internal fun HudChipLayer(
content = { targets.forEach { key(it.subject.key) { chip(it) } } },
// A traversal group, so TalkBack reads the chips in the order each sets, nearest the center first.
modifier =
- modifier.semantics { isTraversalGroup = true }.drawBehind {
- if (currentPose() == null) return@drawBehind
- placement.chips.forEach { drawStemOrArrow(it) }
- placement.overflow.forEach { drawOverflow(it, topLimitPx(), textMeasurer, overflowStyle, overflowTemplate) }
- },
+ modifier
+ .semantics { isTraversalGroup = true }
+ .drawBehind {
+ if (currentPose() == null) return@drawBehind
+ placement.chips.forEach { drawStemOrArrow(it) }
+ placement.overflow.forEach {
+ drawOverflow(it, topLimitPx(), textMeasurer, overflowStyle, overflowTemplate)
+ }
+ },
) { measurables, constraints ->
val placeables = measurables.map { it.measure(Constraints()) }
val width = constraints.maxWidth
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
index 1672dbceb3e..1dc1338e565 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
@@ -77,7 +77,9 @@ internal fun clockHour(bearingDeg: Double, headingDeg: Double): Int {
/** What TalkBack calls a subject: a node's long name, a waypoint's name, falling back to what the chip shows. */
internal fun HudSubject.spokenName(): String = when (this) {
is HudSubject.NodeSubject -> node.user.long_name.ifBlank { node.user.short_name }
- is HudSubject.WaypointSubject -> waypoint.name.ifBlank { waypoint.icon.waypointIconOrDefault().toCodePointString() }
+
+ is HudSubject.WaypointSubject ->
+ waypoint.name.ifBlank { waypoint.icon.waypointIconOrDefault().toCodePointString() }
}
/**
@@ -92,12 +94,14 @@ internal fun HudChip(
onToggleFocus: () -> Unit,
) {
// Derived, so the chip recomposes when its clock hour changes rather than at the sensor rate.
- val hour by remember(target.bearingDeg) {
- derivedStateOf { currentPose()?.let { clockHour(target.bearingDeg, it.azimuthDeg) } }
- }
+ val hour by
+ remember(target.bearingDeg) {
+ derivedStateOf { currentPose()?.let { clockHour(target.bearingDeg, it.azimuthDeg) } }
+ }
val distanceText = chipDistanceText(target)
- val description =
- hour?.let { stringResource(Res.string.mesh_hud_chip_description, target.subject.spokenName(), distanceText, it) }
+ val description = hour?.let {
+ stringResource(Res.string.mesh_hud_chip_description, target.subject.spokenName(), distanceText, it)
+ }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
index d778aff3694..ac84f0dd4e1 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
@@ -161,9 +161,7 @@ internal fun HudTopBar(
}
when {
!status.hasSensor -> HudLabel(stringResource(Res.string.compass_no_magnetometer))
-
status.waitingForSensor -> HudLabel(stringResource(Res.string.mesh_hud_waiting_sensor))
-
status.needsCalibration -> HudLabel(stringResource(Res.string.mesh_hud_heading_low_accuracy))
}
when (status.thermal) {
@@ -214,13 +212,7 @@ internal fun HudBottomPanel(
},
)
}
- if (
- targets.ready &&
- route == null &&
- targets.warnings.isEmpty() &&
- targets.targets.isEmpty() &&
- targets.directionUnknown.isEmpty()
- ) {
+ if (targets.ready && route == null && targets.showsNothing) {
HudLabel(stringResource(Res.string.mesh_hud_no_nodes))
}
if (targets.elevationApproximate && targets.targets.isNotEmpty()) {
@@ -263,7 +255,10 @@ private fun LocationWarnings(
*/
@Composable
internal fun HudCoach(onDismiss: () -> Unit, modifier: Modifier = Modifier) {
- Box(modifier.fillMaxSize().background(Color.Black.copy(alpha = COACH_BACKDROP_ALPHA)), contentAlignment = Alignment.Center) {
+ Box(
+ modifier.fillMaxSize().background(Color.Black.copy(alpha = COACH_BACKDROP_ALPHA)),
+ contentAlignment = Alignment.Center,
+ ) {
Column(
modifier =
Modifier.widthIn(max = CoachMaxWidth)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
index fecf96ade88..b28e8cbf0e1 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
@@ -139,6 +139,10 @@ data class MeshHudTargets(
val finding: HudFinding? = null,
)
+/** True when there is nothing to show and no warning explaining why. */
+internal val MeshHudTargets.showsNothing: Boolean
+ get() = warnings.isEmpty() && targets.isEmpty() && directionUnknown.isEmpty()
+
/** What the finder is looking for and how far away it is. */
data class HudFinding(val subject: HudSubject, val distanceText: String)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
index 46962102746..07d82304671 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
@@ -395,6 +395,7 @@ private fun FinderHaptics(hint: FinderHint?) {
previous = hint
when {
hint == FinderHint.OnTarget -> haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
+
hint is FinderHint.Turn && hint.closerThan(last) ->
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
index 62bd1663e32..04379cd39bb 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
@@ -23,8 +23,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
diff --git a/feature/node/detekt-baseline.xml b/feature/node/detekt-baseline.xml
index 7db65a1758b..6f40e2209e8 100644
--- a/feature/node/detekt-baseline.xml
+++ b/feature/node/detekt-baseline.xml
@@ -112,6 +112,6 @@
UseOrEmpty:PowerMetrics.kt:state.node?.user?.long_name ?: ""
UseOrEmpty:SignalMetrics.kt:state.node?.user?.long_name ?: ""
UseOrEmpty:TracerouteLog.kt:state.node?.user?.long_name ?: ""
- ViewModelForwarding:NodeDetailScreens.kt:NodeDetailScaffold( modifier = modifier, uiState = uiState, viewModel = viewModel, navigateToMessages = navigateToMessages, onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, )
+ ViewModelForwarding:NodeDetailScreens.kt:NodeDetailScaffold( modifier = modifier, uiState = uiState, viewModel = viewModel, navigateToMessages = navigateToMessages, onNavigate = onNavigate, onNavigateUp = onNavigateUp, compassViewModel = compassViewModel, openCompass = openCompass, )
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
index 5fb8d8487df..88d4ce39b70 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
@@ -72,10 +72,10 @@ import org.meshtastic.core.resources.formatDuration
import org.meshtastic.core.resources.last_position_update
import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.ui.component.ElevationInfo
+import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.icon.ErrorOutline
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
-import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassWarning
import kotlin.math.PI
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt
index 58de33179bc..9eb1922b745 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt
@@ -31,8 +31,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
-import kotlinx.coroutines.delay
import androidx.compose.ui.graphics.vector.ImageVector
+import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Node
@@ -41,8 +41,8 @@ import org.meshtastic.core.resources.add_favorite
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.resources.message
-import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.mute_notifications
+import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_favorite
import org.meshtastic.core.resources.remove_ignored
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index 3917a9af8c0..bd5eced47ea 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -45,10 +45,10 @@ import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.site_planner_estimate
import org.meshtastic.core.ui.icon.CellTower
+import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.icon.Compass
import org.meshtastic.core.ui.icon.Distance
import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalSitePlannerAvailable
import org.meshtastic.core.ui.util.rememberMeshHudSupported
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
index 348a6841186..e3b9a23a20a 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
@@ -89,6 +89,21 @@ fun NodeDetailScreen(
)
}
+/** Opens the compass sheet once per arrival: closing it, or coming back to this screen, must not reopen it. */
+@Suppress("UnnecessaryLaunchedEffect") // Fires once per node, not once per recomposition.
+@Composable
+private fun OpenCompassOnArrival(uiState: NodeDetailUiState, open: (NodeDetailOverlay) -> Unit) {
+ var opened by rememberSaveable { mutableStateOf(false) }
+ val currentOpen by rememberUpdatedState(open)
+ val node = uiState.node?.takeIf { !opened && it.validPosition != null }
+ LaunchedEffect(node?.num) {
+ node?.let {
+ currentOpen(NodeDetailOverlay.Compass(it.num, uiState.metricsState.displayUnits))
+ opened = true
+ }
+ }
+}
+
@Composable
@Suppress("LongParameterList")
private fun NodeDetailScaffold(
@@ -102,15 +117,7 @@ private fun NodeDetailScaffold(
openCompass: Boolean = false,
) {
var activeOverlay by remember { mutableStateOf(null) }
- // Once per arrival: closing the sheet, or coming back to this screen, must not reopen it.
- var compassOpened by rememberSaveable { mutableStateOf(false) }
- val compassNode = uiState.node?.takeIf { openCompass && !compassOpened && it.validPosition != null }
- LaunchedEffect(compassNode?.num) {
- compassNode?.let {
- activeOverlay = NodeDetailOverlay.Compass(it.num, uiState.metricsState.displayUnits)
- compassOpened = true
- }
- }
+ if (openCompass) OpenCompassOnArrival(uiState) { activeOverlay = it }
val actualCompassViewModel = compassViewModel
val compassUiState by
actualCompassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
index 9ec04f84438..fbd14d0148c 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
@@ -39,11 +39,11 @@ import org.meshtastic.core.resources.export_gpx
import org.meshtastic.core.resources.mesh_hud_open
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
+import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.FileDownload
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
-import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.rememberSaveFileLauncher
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
index 6fc086ce734..2727874de7b 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt
@@ -51,9 +51,9 @@ import org.meshtastic.core.resources.traceroute_outgoing_route
import org.meshtastic.core.resources.traceroute_return_route
import org.meshtastic.core.resources.traceroute_showing_nodes
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Route
-import org.meshtastic.core.ui.icon.CenterFocusStrong
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStackTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStackTest.kt
index 67c08d0079c..1b9e548f1ea 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStackTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/navigation/MeshHudBackStackTest.kt
@@ -32,7 +32,7 @@ class MeshHudBackStackTest {
stack.navigateFromNodeDetail(MapRoute.MeshHud(focusNodeNum = 2))
- assertEquals(listOf(NodesRoute.Nodes, MapRoute.MeshHud(focusNodeNum = 2)), stack)
+ assertEquals(listOf(NodesRoute.Nodes, MapRoute.MeshHud(focusNodeNum = 2)), stack)
}
@Test
@@ -48,7 +48,7 @@ class MeshHudBackStackTest {
stack.navigateFromNodeDetail(track)
- assertEquals(listOf(NodesRoute.Nodes, track), stack)
+ assertEquals(listOf(NodesRoute.Nodes, track), stack)
}
@Test
@@ -58,7 +58,7 @@ class MeshHudBackStackTest {
stack.navigateFromNodeDetail(MapRoute.MeshHud(focusNodeNum = 2))
assertEquals(
- listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(2), MapRoute.MeshHud(focusNodeNum = 2)),
+ listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(2), MapRoute.MeshHud(focusNodeNum = 2)),
stack,
)
}
@@ -70,7 +70,7 @@ class MeshHudBackStackTest {
stack.navigateFromNodeDetail(ContactsRoute.Messages("0!00000002"))
assertEquals(
- listOf(
+ listOf(
NodesRoute.Nodes,
MapRoute.MeshHud(1),
NodesRoute.NodeDetail(2),
@@ -86,7 +86,7 @@ class MeshHudBackStackTest {
stack.navigateFromNodeDetail(MapRoute.MeshHud(focusNodeNum = 2))
- assertEquals(listOf(NodesRoute.Nodes, MapRoute.MeshHud(1), MapRoute.MeshHud(focusNodeNum = 2)), stack)
+ assertEquals(listOf(NodesRoute.Nodes, MapRoute.MeshHud(1), MapRoute.MeshHud(focusNodeNum = 2)), stack)
}
@Test
From 68b1a9b2bad7ef160239e0b480b535fa91f43c54 Mon Sep 17 00:00:00 2001
From: James Rich
Date: Tue, 20 Oct 2026 10:56:01 -0500
Subject: [PATCH 15/31] fix(map): mesh view keeps 0 coordinates and the user's
focus across recreation
A waypoint at latitude or longitude 0 is a real position, and the nullable
field already carries absence. Reopening the view for the same node, as a
recreated screen does, no longer resets the focus the user chose.
---
.../meshtastic/feature/map/hud/HudTargets.kt | 4 +--
.../feature/map/hud/MeshHudViewModel.kt | 4 +++
.../feature/map/hud/HudTargetsTest.kt | 33 +++++++++++++++++++
.../feature/map/hud/MeshHudViewModelTest.kt | 15 +++++++++
.../node/navigation/NodesNavigation.kt | 1 -
5 files changed, 54 insertions(+), 3 deletions(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
index b28e8cbf0e1..384f467055a 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
@@ -197,8 +197,8 @@ internal fun buildHudTargets(
}
}
waypoints.forEach { waypoint ->
- val lat = waypoint.latitude_i?.takeIf { it != 0 }?.let { Position.degD(it) } ?: return@forEach
- val lon = waypoint.longitude_i?.takeIf { it != 0 }?.let { Position.degD(it) } ?: return@forEach
+ val lat = waypoint.latitude_i?.let { Position.degD(it) } ?: return@forEach
+ val lon = waypoint.longitude_i?.let { Position.degD(it) } ?: return@forEach
val subject = HudSubject.WaypointSubject(waypoint)
val target = here.targetAt(subject, lat, lon, null, null, declination, units)
if (target == null) {
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
index 04379cd39bb..b4972db3b07 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
@@ -150,6 +150,7 @@ class MeshHudViewModel(
private val fixAverager = FixAverager()
private val routeRequest = MutableStateFlow(null)
private val soloNodeNum = MutableStateFlow(null)
+ private var opened = false
private val trackNodeNum = MutableStateFlow(null)
/**
@@ -374,6 +375,9 @@ class MeshHudViewModel(
* only it and the nodes its track or route names are shown. Null opens the whole mesh, as from the map.
*/
fun openFor(nodeNum: Int?) {
+ // A recreated screen opens again for the same node, and must keep the focus the user has chosen since.
+ if (opened && soloNodeNum.value == nodeNum) return
+ opened = true
focusKey.value = nodeNum?.let { "node-$it" }
soloNodeNum.value = nodeNum
}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt
index 1840dd85c2c..fa0b3fb15e9 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt
@@ -99,6 +99,20 @@ class HudTargetsTest {
assertTrue(target.elevationDeg < 0 && target.elevationDeg > -0.1)
}
+ @Test
+ fun `a node at sea level is a reading and not a missing altitude`() {
+ val target = build(phone(hae = null, msl = 100.0), listOf(node(1, msl = 0))).targets.single()
+ assertTrue(target.elevationKnown)
+ assertEquals(-5.1, target.elevationDeg, 0.1)
+ }
+
+ @Test
+ fun `a phone at zero ellipsoid height is a reading and not a missing altitude`() {
+ val target = build(phone(hae = 0.0), listOf(node(1, hae = 100))).targets.single()
+ assertTrue(target.elevationKnown)
+ assertEquals(5.1, target.elevationDeg, 0.1)
+ }
+
@Test
fun `a node closer than the phone fix accuracy has no direction`() {
val result = build(phone(accuracyM = 2_000f), listOf(node(1)))
@@ -131,6 +145,25 @@ class HudTargetsTest {
assertEquals("waypoint-7", result.directionUnknown.single().subject.key)
}
+ @Test
+ fun `a waypoint on the equator and prime meridian is placed`() {
+ val nullIsland =
+ Waypoint.Builder()
+ .also { b ->
+ b.id = 3
+ b.latitude_i = 0
+ b.longitude_i = 0
+ }
+ .build()
+ assertEquals("waypoint-3", build(waypoints = listOf(nullIsland)).targets.single().subject.key)
+ }
+
+ @Test
+ fun `a waypoint without coordinates is left out`() {
+ val unplaced = Waypoint.Builder().also { it.id = 4 }.build()
+ assertTrue(build(waypoints = listOf(unplaced)).targets.isEmpty())
+ }
+
@Test
fun `a circular geofence is traced around its edge`() {
val fenced = waypoint(9, NORTH_LAT).newBuilder().also { it.geofence_radius = 100 }.build()
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
index 685ce33c1bc..9cc04ba8c1d 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
@@ -183,6 +183,21 @@ class MeshHudViewModelTest {
assertTrue(targets.value.targets.single().focused)
}
+ @Test
+ fun `opening again for the same node keeps the focus the user chose`() = runTest(testDispatcher) {
+ nodeRepository.setNodes(listOf(node(1), node(2)))
+ viewModel.openFor(null)
+ val targets = collectTargets()
+ viewModel.toggleFocus(HudSubject.NodeSubject(node(2)))
+ viewModel.openFor(null)
+ runCurrent()
+ assertEquals("node-2", targets.value.targets.single { it.focused }.subject.key)
+
+ viewModel.openFor(1)
+ runCurrent()
+ assertEquals("node-1", targets.value.targets.single { it.focused }.subject.key)
+ }
+
@Test
fun `a scoped view ignores the map filter for its node`() = runTest(testDispatcher) {
nodeRepository.setNodes(listOf(node(1, favorite = true), node(2)))
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
index 11cb10d6506..b327b0167e8 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt
@@ -172,7 +172,6 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack)
)
}
- // No pane metadata: the camera view takes the whole window.
entry(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val metricsViewModel = koinViewModel { parametersOf(args.destNum) }
metricsViewModel.setNodeId(args.destNum)
From 91ce3edf5dc1fb5cde06ce0a77797db9618f5638 Mon Sep 17 00:00:00 2001
From: James Rich
Date: Tue, 20 Oct 2026 10:56:01 -0500
Subject: [PATCH 16/31] docs(map): describe the AR mesh view and the node
list's new menu entries
---
docs/en/user.md | 2 ++
docs/en/user/map-and-waypoints.md | 16 +++++++++++++++-
docs/en/user/nodes.md | 4 +++-
3 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/docs/en/user.md b/docs/en/user.md
index 97bf469302c..a7a73445f21 100644
--- a/docs/en/user.md
+++ b/docs/en/user.md
@@ -17,6 +17,8 @@ Documentation for using the Meshtastic Android and Desktop app.
Keep the last 5–8 entries and archive older ones by removing them.
-->
+**October 2026** — [Map & Waypoints](user/map-and-waypoints) — The AR mesh view draws nodes and waypoints over the phone's camera and turns you toward the one you are finding.
+
**September 2026** — [Notifications](user/notifications) — A new page lists every notification category, what each opens when tapped, when a message stays quiet, and what a message notification lets you do from the shade or a watch.
**September 2026** — [Nodes](user/nodes) — Nodes your node has not heard since its LoRa settings changed now carry an orange marker, a **Hide unheard nodes** filter, and a banner whose **Remove** action deletes them in bulk.
diff --git a/docs/en/user/map-and-waypoints.md b/docs/en/user/map-and-waypoints.md
index c8fc906ad14..8cd19d890f9 100644
--- a/docs/en/user/map-and-waypoints.md
+++ b/docs/en/user/map-and-waypoints.md
@@ -2,7 +2,7 @@
title: Map & Waypoints
parent: User Guide
nav_order: 6
-last_updated: 2026-10-07
+last_updated: 2026-10-20
description: View node positions on the map, create and share waypoints, manage map layers and Site Planner, and control position sharing and privacy.
aliases:
- map
@@ -47,6 +47,20 @@ Tap the filter button in the floating toolbar to open **Filter map**. **Display*
A dot on the filter button means at least one filter is hiding something — check it before concluding the mesh is quiet. Turning **Show Waypoints** off hides every waypoint, including your own. **Show ignored nodes** adds them to the map rather than showing only them — unlike the node list's **Only show ignored Nodes**.
+## AR mesh view
+
+The mesh view draws nodes and waypoints over the phone's camera, each in the direction and at the height where it stands, with its distance and the age of its position. It needs a back camera and an orientation sensor, so it appears only on Android phones that have both.
+
+Tap **Open mesh view** in the map's top bar to see the whole mesh, filtered by the same **Filter map** settings as the map. A node's detail page, its compass sheet, its position log, and a traceroute map open the view too. Opened from a node, it shows only that node, plus the track or route you came from.
+
+- **Tap** a node to open its detail page, or a waypoint to open its info.
+- **Touch & hold** a node or waypoint to find it. The view says how far to turn and tilt, the phone ticks as you turn closer, and it pulses once you are on target.
+- In the whole-mesh view, nodes and waypoints farther than 50 km (30 mi) stay hidden until you tap the range label in the top bar.
+- A node whose position uncertainty covers your own spot has no meaningful direction, so it goes on the radar at the bottom instead. Point the phone down to enlarge the radar.
+- Waypoint geofences are outlined, and the view says when you are inside one.
+
+When your phone's position has no altitude, targets sit level with you and the view says elevation is approximate. Without camera access, or while the phone is too hot, the view carries on as a compass.
+
## Waypoints
Waypoints are shared points of interest, visible to everyone on your mesh.
diff --git a/docs/en/user/nodes.md b/docs/en/user/nodes.md
index 3121885c259..120130508a2 100644
--- a/docs/en/user/nodes.md
+++ b/docs/en/user/nodes.md
@@ -2,7 +2,7 @@
title: Nodes
parent: User Guide
nav_order: 4
-last_updated: 2026-10-02
+last_updated: 2026-10-20
description: Browse, filter, and sort mesh nodes — view details, signal quality, roles, and quick actions.
aliases:
- node-list
@@ -106,6 +106,8 @@ From the node list, you can:
- Mute/unmute notifications
- Send a direct message
- Trace route
+ - Open Compass, for a node with a position
+ - Open mesh view, for a node with a position on a phone that supports the [AR mesh view](map-and-waypoints#ar-mesh-view)
- Ignore/unignore
- Remove
From 0cda7eac99105bffcd820cf1732847390e7b173a Mon Sep 17 00:00:00 2001
From: James Rich
Date: Tue, 20 Oct 2026 13:20:02 -0500
Subject: [PATCH 17/31] fix(map): radar waypoints open their info; SNR tag
through NumberFormatter
---
.../kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt | 3 ++-
.../kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt | 7 ++++++-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
index f55aec7f9de..99d8ef827f7 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
@@ -28,6 +28,7 @@ import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.unit.dp
+import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.formatString
private const val SNR_FLOOR_DB = -20f
@@ -125,7 +126,7 @@ internal fun DrawScope.drawRoute(
val tag =
when {
skipped > 0 -> formatString(labels.skippedTemplate, skipped)
- snr != null -> formatString(labels.snrTemplate, formatString("%.1f", snr))
+ snr != null -> formatString(labels.snrTemplate, NumberFormatter.format(snr, 1))
else -> null
}
tag?.let { drawTag(it, apex, textMeasurer, labelStyle) }
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
index 07d82304671..af76c58868f 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
@@ -316,7 +316,12 @@ fun MeshHudScreen(
declinationDeg = targets.declinationDeg,
currentHeadingDeg = { poseState.value.pose?.azimuthDeg },
expanded = pointingDown,
- onOpen = { subject -> (subject as? HudSubject.NodeSubject)?.let { onOpenNode(it.node.num) } },
+ onOpen = { subject ->
+ when (subject) {
+ is HudSubject.NodeSubject -> onOpenNode(subject.node.num)
+ is HudSubject.WaypointSubject -> infoWaypoint = subject.waypoint
+ }
+ },
route = route,
track = track,
)
From 468a71cd055efec28269d87db321a038c70de47a Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 13:58:30 -0500
Subject: [PATCH 18/31] fix(map): mesh view speaks an unnamed node by its id
---
.../org/meshtastic/feature/map/hud/HudChips.kt | 9 +++++++--
.../org/meshtastic/feature/map/hud/HudChipsTest.kt | 14 ++++++++++++++
2 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
index 1dc1338e565..cf9970bb3ca 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
@@ -38,6 +38,7 @@ import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
+import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.toCodePointString
import org.meshtastic.core.model.util.waypointIconOrDefault
import org.meshtastic.core.resources.Res
@@ -74,9 +75,13 @@ internal fun clockHour(bearingDeg: Double, headingDeg: Double): Int {
return if (hour == 0) HOURS_ON_DIAL else hour
}
-/** What TalkBack calls a subject: a node's long name, a waypoint's name, falling back to what the chip shows. */
+/**
+ * What TalkBack calls a subject: a node's long name, else its short name, else its node id, since a node never heard
+ * from has neither name; a waypoint's name, else its emoji.
+ */
internal fun HudSubject.spokenName(): String = when (this) {
- is HudSubject.NodeSubject -> node.user.long_name.ifBlank { node.user.short_name }
+ is HudSubject.NodeSubject ->
+ node.user.long_name.ifBlank { node.user.short_name.ifBlank { NodeAddress.numToDefaultId(node.num) } }
is HudSubject.WaypointSubject ->
waypoint.name.ifBlank { waypoint.icon.waypointIconOrDefault().toCodePointString() }
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudChipsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudChipsTest.kt
index 8eae7d737c3..ec5fa7dbc78 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudChipsTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudChipsTest.kt
@@ -16,6 +16,8 @@
*/
package org.meshtastic.feature.map.hud
+import org.meshtastic.core.model.Node
+import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -53,4 +55,16 @@ class HudChipsTest {
assertEquals(1, clockHour(bearingDeg = 16.0, headingDeg = 0.0))
assertEquals(11, clockHour(bearingDeg = 344.0, headingDeg = 0.0))
}
+
+ @Test
+ fun `a node with no names is spoken by its node id`() {
+ val node = Node(num = 0x1a2b3c4d, user = User.Builder().build())
+ assertEquals("!1a2b3c4d", HudSubject.NodeSubject(node).spokenName())
+ }
+
+ @Test
+ fun `a node with only a short name is spoken by it`() {
+ val node = Node(num = 1, user = User.Builder().also { it.short_name = "ABCD" }.build())
+ assertEquals("ABCD", HudSubject.NodeSubject(node).spokenName())
+ }
}
From 0b4f201ad04cc15176c3e532f75249d606795945 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 14:03:53 -0500
Subject: [PATCH 19/31] =?UTF-8?q?fix(node):=20compass=20estimated=20area?=
=?UTF-8?q?=20shows=20one=20=C2=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../feature/node/compass/CompassViewModel.kt | 4 ++--
.../feature/node/compass/CompassViewModelTest.kt | 12 ++++++------
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
index c9ca4509dd4..ead4cb7a34f 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
@@ -124,8 +124,8 @@ class CompassViewModel(
val isAligned = isAligned(trueHeading, bearingDegrees)
val lastUpdateAgeSeconds = targetPositionTimeSec?.let { maxOf(0, nowSeconds - it) }
val angularErrorDeg = calculateAngularError(positionalAccuracyMeters, distanceMeters)
- val errorRadiusText =
- positionalAccuracyMeters?.toInt()?.let { "± ${it.toDistanceString(current.displayUnits)}" }
+ // The string resource carries the ±.
+ val errorRadiusText = positionalAccuracyMeters?.toInt()?.toDistanceString(current.displayUnits)
return current.copy(
heading = trueHeading,
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt
index 47c271204ff..1029a1774aa 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/compass/CompassViewModelTest.kt
@@ -172,7 +172,7 @@ class CompassViewModelTest {
.build(),
)
- assertEquals("± 12 m", state.errorRadiusText)
+ assertEquals("12 m", state.errorRadiusText)
}
@Test
@@ -192,7 +192,7 @@ class CompassViewModelTest {
.build(),
)
- assertEquals("± 25 m", state.errorRadiusText)
+ assertEquals("25 m", state.errorRadiusText)
}
@Test
@@ -211,7 +211,7 @@ class CompassViewModelTest {
.build(),
)
- assertEquals("± 8 m", state.errorRadiusText)
+ assertEquals("8 m", state.errorRadiusText)
}
@Test
@@ -229,7 +229,7 @@ class CompassViewModelTest {
.build(),
)
- assertEquals("± 729 m", state.errorRadiusText)
+ assertEquals("729 m", state.errorRadiusText)
}
@Test
@@ -268,7 +268,7 @@ class CompassViewModelTest {
location = PhoneLocation(1.0, 1.0, 0.0, 1000L),
)
- assertEquals("± 12 m", state.errorRadiusText)
+ assertEquals("12 m", state.errorRadiusText)
assertNotNull(state.angularErrorDeg)
assertEquals(180f, state.angularErrorDeg)
}
@@ -290,7 +290,7 @@ class CompassViewModelTest {
location = PhoneLocation(1.0, 1.0, 0.0, 1000L),
)
- assertEquals("± 12 m", state.errorRadiusText)
+ assertEquals("12 m", state.errorRadiusText)
assertNotNull(state.angularErrorDeg)
assertEquals(85.4f, state.angularErrorDeg, 0.5f)
}
From c9cd29147aa88841cbc130b700beb876f6ce52eb Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 14:30:31 -0500
Subject: [PATCH 20/31] fix(map): mesh view places a traceroute's hops where it
recorded them
---
.../feature/map/hud/MeshHudViewModel.kt | 52 +++++++++++--------
.../feature/map/hud/MeshHudViewModelTest.kt | 17 +++++-
2 files changed, 46 insertions(+), 23 deletions(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
index b4972db3b07..abcecefe221 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
@@ -39,6 +39,7 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
+import org.meshtastic.core.model.Node
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.geofence.activeWaypointPackets
import org.meshtastic.core.model.passesMapFilter
@@ -78,10 +79,25 @@ private data class HudSettings(
* track or route names are shown.
*/
val solo: Int?,
- /** Positions to place nodes at when the database has none live (see `fallbackPositions`). */
- val fallbacks: Map,
+ /** Where the open route and track place the nodes they name (see `positionHints`). */
+ val hints: PositionHints,
)
+/**
+ * Where to place the nodes an open route or track names. A route's [recorded] positions win over live ones, as on the
+ * traceroute map, so each chip sits where its arc ends; a track's newest point is only a [fallback] for a node the
+ * database has no live position for.
+ */
+private data class PositionHints(
+ val recorded: Map = emptyMap(),
+ val fallback: Map = emptyMap(),
+)
+
+private fun Node.placedBy(hints: PositionHints): Node? {
+ val position = hints.recorded[num] ?: hints.fallback[num]?.takeIf { validPosition == null }
+ return (position?.let { copy(position = it) } ?: this).takeIf { it.validPosition != null }
+}
+
/** A traceroute to draw: its node nums each way, link SNRs in quarter dB, and the positions it was recorded with. */
data class RouteSource(
val forward: List,
@@ -205,16 +221,16 @@ class MeshHudViewModel(
}
/**
- * Where to place a node the database has no live position for: the position recorded with the open route, else the
- * newest point of its open track. A remote node, or one last heard while disconnected, often has only these, and
- * without them its chip, and the edge arrow leading to it, would never appear.
+ * The open route's recorded hop positions and the open track's newest point. A remote node, or one last heard while
+ * disconnected, often has only these, and without them its chip, and the edge arrow leading to it, would never
+ * appear.
*/
- private val fallbackPositions =
+ private val positionHints =
combine(routeSource, trackSource) { route, track ->
- buildMap {
- track?.positions?.lastOrNull()?.let { put(track.nodeNum, it) }
- route?.snapshot?.let { putAll(it) }
- }
+ PositionHints(
+ recorded = route?.snapshot.orEmpty(),
+ fallback = track?.positions?.lastOrNull()?.let { mapOf(track.nodeNum to it) }.orEmpty(),
+ )
}
/** The phone's averaged fix, shared by the targets, the route and the track. */
@@ -253,9 +269,9 @@ class MeshHudViewModel(
focusKey,
rangeLimited,
mapPrefs.mapFilters,
- combine(pinned, soloNodeNum, fallbackPositions, ::Triple),
- ) { units, focus, limited, filters, (pinnedNums, solo, fallbacks) ->
- HudSettings(units, focus, limited, filters, pinnedNums, solo, fallbacks)
+ combine(pinned, soloNodeNum, positionHints, ::Triple),
+ ) { units, focus, limited, filters, (pinnedNums, solo, hints) ->
+ HudSettings(units, focus, limited, filters, pinnedNums, solo, hints)
},
packetRepository.getWaypoints(),
) { location, nodes, myNode, settings, waypointPackets ->
@@ -264,15 +280,7 @@ class MeshHudViewModel(
val solo = settings.solo
val shownNodes =
nodes.values
- .mapNotNull { node ->
- if (node.validPosition != null) {
- node
- } else {
- settings.fallbacks[node.num]
- ?.let { node.copy(position = it) }
- ?.takeIf { it.validPosition != null }
- }
- }
+ .mapNotNull { node -> node.placedBy(settings.hints) }
.filter { node ->
node.num != myNode?.myNodeNum &&
if (solo != null) {
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
index 9cc04ba8c1d..5799b91a6b5 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
@@ -64,6 +64,7 @@ private const val NEAR_LAT = 40.01
/** About 111 km north of the phone, past the 50 km range limit. */
private const val FAR_LAT = 41.0
+private const val FAR_ENOUGH_M = 100_000.0
private fun node(num: Int, lat: Double = NEAR_LAT, favorite: Boolean = false) = Node(
num = num,
@@ -113,6 +114,7 @@ class MeshHudViewModelTest {
private val nodeRepository = FakeNodeRepository()
private val mapPrefs = FakeMapPrefs()
private val traceroutes = FakeTracerouteResponses()
+ private val snapshots = FakeTracerouteSnapshotRepository()
private val packetRepository: PacketRepository = mock()
private lateinit var viewModel: MeshHudViewModel
@@ -129,7 +131,7 @@ class MeshHudViewModelTest {
packetRepository = packetRepository,
meshLogRepository = FakeMeshLogRepository(),
tracerouteResponseProvider = traceroutes,
- tracerouteSnapshotRepository = FakeTracerouteSnapshotRepository(),
+ tracerouteSnapshotRepository = snapshots,
mapPrefs = mapPrefs,
notificationPrefs = FakeNotificationPrefs(),
localeUnitsProvider = FakeLocaleUnitsProvider(),
@@ -224,6 +226,19 @@ class MeshHudViewModelTest {
assertEquals(setOf("node-2", "node-3", "node-4"), collectTargets().keys)
}
+ @Test
+ fun `a hop is placed where its traceroute recorded it rather than where it is now`() = runTest(testDispatcher) {
+ nodeRepository.setNodes(listOf(node(2), node(3)))
+ val recorded = mapOf(3 to node(3, lat = FAR_LAT).position)
+ snapshots.upsertSnapshotPositions("log-1", requestId = 7, positions = recorded)
+ traceroutes.tracerouteResponse.value =
+ TracerouteResponse("", 2, requestId = 7, forwardRoute = listOf(3), returnRoute = emptyList())
+ viewModel.openFor(2)
+ viewModel.showTraceroute(destNum = 2, requestId = 7, logUuid = "log-1")
+ val hop = collectTargets().value.targets.first { it.subject.key == "node-3" }
+ assertTrue(hop.distanceM > FAR_ENOUGH_M)
+ }
+
@Test
fun `the whole mesh view hides nodes past the range limit`() = runTest(testDispatcher) {
nodeRepository.setNodes(listOf(node(1), node(3, lat = FAR_LAT)))
From 5e4941cb3c9a19bd59c69603ff3835e91c566005 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 14:47:14 -0500
Subject: [PATCH 21/31] fix(map): mesh view draws a traceroute's legs to the
connected radio from the viewer
---
.../org/meshtastic/feature/map/hud/HudPaths.kt | 12 ++++++++++--
.../meshtastic/feature/map/hud/MeshHudViewModel.kt | 8 +++++++-
.../org/meshtastic/feature/map/hud/HudPathsTest.kt | 12 ++++++++++++
3 files changed, 29 insertions(+), 3 deletions(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPaths.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPaths.kt
index 2a3a07b7694..bbc53a7140a 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPaths.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPaths.kt
@@ -99,9 +99,14 @@ private fun snrFor(route: List, quarterDb: List?): List =
List((route.size - 1).coerceAtLeast(0)) { null }
}
+/** The phone itself, where a leg to or from the connected radio starts or ends. */
+private val ViewerPoint = PathPoint(bearingDeg = 0.0, elevationDeg = 0.0, distanceM = 0.0, atViewer = true)
+
/**
* A traceroute as legs of points from here. Each hop takes [positionOf]'s answer, which should prefer the position the
- * route was recorded with and fall back to the live one, so a hop is only blank when neither is known.
+ * route was recorded with and fall back to the live one, so a hop is only blank when neither is known. [viewerNum], the
+ * connected radio, is always at the viewer: it is within Bluetooth or cable reach of the phone, and often has no
+ * position of its own.
*/
@Suppress("LongParameterList")
internal fun buildHudRoute(
@@ -111,11 +116,14 @@ internal fun buildHudRoute(
back: List,
snrTowards: List?,
snrBack: List?,
+ viewerNum: Int? = null,
positionOf: (Int) -> ProtoPosition?,
): HudRoute {
+ fun pointOf(num: Int) =
+ if (num == viewerNum) ViewerPoint else positionOf(num)?.let { here.pathPoint(it, declination) }
fun leg(route: List, snr: List?, isForward: Boolean) = RouteLeg(
nodeNums = route,
- points = route.map { num -> positionOf(num)?.let { here.pathPoint(it, declination) } },
+ points = route.map(::pointOf),
snrDb = snrFor(route, snr),
forward = isForward,
)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
index abcecefe221..114bbde4eb1 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
@@ -324,7 +324,12 @@ class MeshHudViewModel(
/** The traceroute in view, if one was opened, as legs of points from here. */
val route: StateFlow =
- combine(location, routeSource, nodeRepository.nodeDBbyNum) { state, source, nodes ->
+ combine(location, routeSource, nodeRepository.nodeDBbyNum, nodeRepository.myNodeInfo) {
+ state,
+ source,
+ nodes,
+ myNode,
+ ->
val here = state.location
if (source == null || here == null) {
null
@@ -336,6 +341,7 @@ class MeshHudViewModel(
source.back,
source.snrTowards,
source.snrBack,
+ viewerNum = myNode?.myNodeNum,
) { num ->
source.snapshot[num] ?: nodes[num]?.validPosition
}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt
index b43acac3f4a..ac0eadd3598 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt
@@ -107,6 +107,18 @@ class HudPathsTest {
assertFalse(assertNotNull(route.forward.points[1]).atViewer)
}
+ @Test
+ fun `the connected radio is at the viewer even with no position or a distant one`() {
+ val unplaced =
+ buildHudRoute(here, 0f, listOf(1, 2), listOf(2, 1), null, null, viewerNum = 1) { num ->
+ if (num == 1) null else at(40.01)
+ }
+ assertTrue(assertNotNull(unplaced.forward.points[0]).atViewer)
+ assertTrue(assertNotNull(assertNotNull(unplaced.back).points[1]).atViewer)
+ val distant = buildHudRoute(here, 0f, listOf(1, 2), emptyList(), null, null, viewerNum = 1) { at(40.01) }
+ assertTrue(assertNotNull(distant.forward.points[0]).atViewer)
+ }
+
@Test
fun `a track runs oldest first and flags points with no altitude`() {
val track =
From 16a605e31e17afdb8d2315c7165e8b65848d4060 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 14:54:55 -0500
Subject: [PATCH 22/31] feat(map): mesh view tags the legs to the connected
radio with their SNR
---
.../feature/map/hud/HudPathsDraw.kt | 26 ++++++++++++-------
1 file changed, 16 insertions(+), 10 deletions(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
index 99d8ef827f7..2a265db55b1 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
@@ -19,6 +19,7 @@ package org.meshtastic.feature.map.hud
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.geometry.lerp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
@@ -36,6 +37,9 @@ private const val SNR_SPAN_DB = 30f
private const val UNKNOWN_SNR_QUALITY = 0.5f
private const val ARC_MIN_ALPHA = 0.55f
private const val TRACK_LEVELS = 8
+
+/** How far along a stem from the viewer its tag sits. */
+private const val STEM_TAG_AT = 0.7f
private const val OLDEST_TRACK_ALPHA = 0.25f
private val ArcMinWidth = 2.dp
private val ArcExtraWidth = 4.dp
@@ -113,24 +117,26 @@ internal fun DrawScope.drawRoute(
val width = ArcMinWidth.toPx() + ArcExtraWidth.toPx() * quality
val stroke = color.copy(alpha = ARC_MIN_ALPHA + (1 - ARC_MIN_ALPHA) * quality)
val viewer = Offset(size.width / 2, size.height)
+ val tag =
+ when {
+ skipped > 0 -> formatString(labels.skippedTemplate, skipped)
+ snr != null -> formatString(labels.snrTemplate, NumberFormatter.format(snr, 1))
+ else -> null
+ }
if (start.atViewer != point.atViewer) {
// A link to or from the phone itself: the forward leg leaves the viewer, the return leg comes home.
val far = if (start.atViewer) point else start
val end = screen(directionEnu(far.bearingDeg, far.elevationDeg), pose, focalPx)
- if (end != null) drawLine(stroke, viewer, end, width, StrokeCap.Round)
+ if (end != null) {
+ drawLine(stroke, viewer, end, width, StrokeCap.Round)
+ // Toward the hop, so the tag stays clear of the bottom panel and the reticle.
+ tag?.let { drawTag(it, lerp(viewer, end, STEM_TAG_AT), textMeasurer, labelStyle) }
+ }
} else if (!start.atViewer) {
val arc = arcDirections(start, point, liftSign)
drawPolyline(arc, pose, focalPx, stroke, width, dashed = skipped > 0)
val apex = screen(arc[arc.size / 2], pose, focalPx)
- if (apex != null) {
- val tag =
- when {
- skipped > 0 -> formatString(labels.skippedTemplate, skipped)
- snr != null -> formatString(labels.snrTemplate, NumberFormatter.format(snr, 1))
- else -> null
- }
- tag?.let { drawTag(it, apex, textMeasurer, labelStyle) }
- }
+ if (apex != null) tag?.let { drawTag(it, apex, textMeasurer, labelStyle) }
}
}
from = index
From 4da0b2222e61b575c068a4b615bf8fc5a6cd4c45 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 14:57:28 -0500
Subject: [PATCH 23/31] fix(map): mesh view stem tags sit at different heights
per leg
---
.../org/meshtastic/feature/map/hud/HudPathsDraw.kt | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
index 2a265db55b1..84fa536662c 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt
@@ -38,8 +38,12 @@ private const val UNKNOWN_SNR_QUALITY = 0.5f
private const val ARC_MIN_ALPHA = 0.55f
private const val TRACK_LEVELS = 8
-/** How far along a stem from the viewer its tag sits. */
-private const val STEM_TAG_AT = 0.7f
+/**
+ * How far along a stem from the viewer its tag sits. The two legs' stems leave the same point, so their tags sit at
+ * different heights rather than on top of each other.
+ */
+private const val FORWARD_STEM_TAG_AT = 0.7f
+private const val RETURN_STEM_TAG_AT = 0.45f
private const val OLDEST_TRACK_ALPHA = 0.25f
private val ArcMinWidth = 2.dp
private val ArcExtraWidth = 4.dp
@@ -130,7 +134,8 @@ internal fun DrawScope.drawRoute(
if (end != null) {
drawLine(stroke, viewer, end, width, StrokeCap.Round)
// Toward the hop, so the tag stays clear of the bottom panel and the reticle.
- tag?.let { drawTag(it, lerp(viewer, end, STEM_TAG_AT), textMeasurer, labelStyle) }
+ val along = if (leg.forward) FORWARD_STEM_TAG_AT else RETURN_STEM_TAG_AT
+ tag?.let { drawTag(it, lerp(viewer, end, along), textMeasurer, labelStyle) }
}
} else if (!start.atViewer) {
val arc = arcDirections(start, point, liftSign)
From 20247cfdc03551744a1f4bc22bc4187555fd7a5c Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 15:32:47 -0500
Subject: [PATCH 24/31] fix(map): mesh view thins the pose itself when the
phone runs hot
---
.../feature/map/hud/AndroidCameraPoseProvider.kt | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
index 549e5c24f62..fe38bcb9a46 100644
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidCameraPoseProvider.kt
@@ -43,6 +43,12 @@ private const val HEADING_ACCURACY_INDEX = 4
private const val NINETY_FIVE_PERCENT_SIGMAS = 1.96
private const val MAX_HEADING_ERROR_DEG = 180f
+/**
+ * Running hot, at most one pose per this long, the SENSOR_DELAY_UI period. The fused orientation provider ignores a
+ * slower requested period (measured on a Pixel 6a: 55 Hz when asked for 15), so the pose is thinned here.
+ */
+private const val REDUCED_INTERVAL_NS = 66_667_000L
+
/**
* Prefers [fused] and falls back to the platform rotation vector, or the geomagnetic one on devices without a
* gyroscope. Running hot, either source is slowed.
@@ -91,6 +97,7 @@ private class PoseSession(
private var sensorRegistered = false
private var stopThermal: (() -> Unit)? = null
private var stopped = false
+ private var lastPublishedNs = 0L
// Some fusions (measured on a Pixel 9 Pro) report a fixed pi/6 here rather than an estimate, so the value is
// trusted only once it has moved.
@@ -185,6 +192,8 @@ private class PoseSession(
headingAccuracyDeg: Double?,
trueNorth: Boolean,
) {
+ if (reduced && timestampNs - lastPublishedNs < REDUCED_INTERVAL_NS) return
+ lastPublishedNs = timestampNs
filter.filter(attitude, timestampNs, smoothed)
val rotation = displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation ?: Surface.ROTATION_0
emit(
From 4cea4b7187a093136832eaa27c2e4b30d73653ab Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 15:32:47 -0500
Subject: [PATCH 25/31] fix(map): mesh view shows a short age on the focused
chip only
---
.../meshtastic/feature/map/hud/HudChips.kt | 35 ++++++++++++++-----
1 file changed, 27 insertions(+), 8 deletions(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
index cf9970bb3ca..ab905ab9842 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
@@ -37,11 +37,12 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.toCodePointString
import org.meshtastic.core.model.util.waypointIconOrDefault
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.duration_ago
+import org.meshtastic.core.resources.formatDuration
import org.meshtastic.core.resources.mesh_hud_age
import org.meshtastic.core.resources.mesh_hud_chip_description
import org.meshtastic.core.resources.mesh_hud_distance_no_altitude
@@ -49,12 +50,14 @@ import org.meshtastic.core.resources.mesh_hud_find
import org.meshtastic.core.resources.mesh_hud_open_node
import org.meshtastic.core.resources.mesh_hud_open_waypoint
import org.meshtastic.core.ui.component.NodeChip
-import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.proto.Waypoint
import kotlin.math.roundToInt
+import kotlin.time.DurationUnit
private const val FRESH_POSITION_SEC = 15 * 60L
private const val OLD_POSITION_SEC = 6 * 60 * 60L
+private const val SECONDS_PER_HOUR = 60 * 60L
+private const val SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR
private const val OLDEST_ALPHA = 0.45f
private const val WAYPOINT_SHADOW_ALPHA = 0.8f
private const val WAYPOINT_SHADOW_BLUR = 8f
@@ -103,9 +106,11 @@ internal fun HudChip(
remember(target.bearingDeg) {
derivedStateOf { currentPose()?.let { clockHour(target.bearingDeg, it.azimuthDeg) } }
}
- val distanceText = chipDistanceText(target)
+ val distanceText = chipDistanceText(target, withAge = target.focused)
+ // TalkBack always hears the age: speech does not crowd the view, and the fade alone is visual only.
+ val spokenDistance = chipDistanceText(target, withAge = true)
val description = hour?.let {
- stringResource(Res.string.mesh_hud_chip_description, target.subject.spokenName(), distanceText, it)
+ stringResource(Res.string.mesh_hud_chip_description, target.subject.spokenName(), spokenDistance, it)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -138,9 +143,13 @@ internal fun HudChip(
}
}
-/** The distance under a chip, marked when the altitude is missing and dated once the position is no longer fresh. */
+/**
+ * The distance under a chip, marked when the altitude is missing, and dated in its largest unit once the position is no
+ * longer fresh. [withAge] is false for an unfocused chip on screen: there the fade marks staleness, and the date would
+ * widen every label in a crowded view.
+ */
@Composable
-private fun chipDistanceText(target: HudTarget): String {
+private fun chipDistanceText(target: HudTarget, withAge: Boolean): String {
val showsMissingAltitude = target.subject is HudSubject.NodeSubject && !target.elevationKnown
val distance =
if (showsMissingAltitude) {
@@ -148,8 +157,18 @@ private fun chipDistanceText(target: HudTarget): String {
} else {
target.distanceText
}
- val age = target.positionAgeSec?.takeIf { it > FRESH_POSITION_SEC } ?: return distance
- return stringResource(Res.string.mesh_hud_age, distance, formatAgo((nowSeconds - age).toInt()))
+ val age = target.positionAgeSec?.takeIf { withAge && it > FRESH_POSITION_SEC } ?: return distance
+ val unit =
+ when {
+ age >= SECONDS_PER_DAY -> DurationUnit.DAYS
+ age >= SECONDS_PER_HOUR -> DurationUnit.HOURS
+ else -> DurationUnit.MINUTES
+ }
+ return stringResource(
+ Res.string.mesh_hud_age,
+ distance,
+ stringResource(Res.string.duration_ago, formatDuration(age, unit)),
+ )
}
@Composable
From b5a505cf05a25228de97a4bdbceb38b1588044f6 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 15:36:17 -0500
Subject: [PATCH 26/31] feat(map): mesh view coaching carries Chirpy's battery
and heat warning
---
.skills/compose-ui/strings-index.txt | 1 +
.../composeResources/values/strings.xml | 1 +
.../meshtastic/feature/map/hud/HudPanels.kt | 31 +++++++++++++++++++
3 files changed, 33 insertions(+)
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index e0252493561..d17593905bb 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1104,6 +1104,7 @@ mesh_hud_cardinal_north
mesh_hud_cardinal_south
mesh_hud_cardinal_west
mesh_hud_chip_description
+mesh_hud_chirpy_says
mesh_hud_coach_dismiss
mesh_hud_coach_find
mesh_hud_coach_point
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 72cb79d0215..c522f590b2c 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1146,6 +1146,7 @@
S
W
%1$s, %2$s, at %3$d o'clock
+ Chirpy says, "This'll use your battery and make your phone hot!"
Got it
Long-press a node to find it.
Hold the phone up and point it toward the horizon.
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
index ac84f0dd4e1..cad5d5dde85 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.map.hud
+import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
@@ -26,6 +27,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.selection.toggleable
@@ -39,20 +41,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.chirpy
import org.meshtastic.core.resources.compass_location_disabled
import org.meshtastic.core.resources.compass_no_location_fix
import org.meshtastic.core.resources.compass_no_magnetometer
+import org.meshtastic.core.resources.img_chirpy
import org.meshtastic.core.resources.map_filter_title
import org.meshtastic.core.resources.mesh_hud_camera_unavailable
+import org.meshtastic.core.resources.mesh_hud_chirpy_says
import org.meshtastic.core.resources.mesh_hud_coach_dismiss
import org.meshtastic.core.resources.mesh_hud_coach_find
import org.meshtastic.core.resources.mesh_hud_coach_point
@@ -267,6 +275,7 @@ internal fun HudCoach(onDismiss: () -> Unit, modifier: Modifier = Modifier) {
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
+ ChirpyWarning(Modifier.align(Alignment.CenterHorizontally))
listOf(Res.string.mesh_hud_coach_point, Res.string.mesh_hud_coach_tap, Res.string.mesh_hud_coach_find)
.forEach { Text(stringResource(it), color = Color.White, style = MaterialTheme.typography.bodyLarge) }
Button(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
@@ -276,6 +285,28 @@ internal fun HudCoach(onDismiss: () -> Unit, modifier: Modifier = Modifier) {
}
}
+/** Chirpy's word on what camera, GPS and sensors running together cost, as on the firmware update's warning. */
+@Composable
+private fun ChirpyWarning(modifier: Modifier = Modifier) {
+ Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(text = "🔋🔥", style = MaterialTheme.typography.headlineLarge)
+ Image(
+ painter = painterResource(Res.drawable.img_chirpy),
+ contentScale = ContentScale.Fit,
+ contentDescription = stringResource(Res.string.chirpy),
+ modifier = Modifier.size(48.dp),
+ )
+ }
+ Text(
+ text = stringResource(Res.string.mesh_hud_chirpy_says),
+ color = Color.White,
+ style = MaterialTheme.typography.labelLarge,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
/** The traceroute's two colors, as on the map, and how many of its nodes have a position to draw. */
@Composable
private fun RouteLegend(route: HudRoute) {
From 426b2aee1abda8ed1f511693a1eb5525857741b0 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 16:14:31 -0500
Subject: [PATCH 27/31] feat(map): mesh view chips lead to their point with a
callout, and the focused chip says its height
---
.skills/compose-ui/strings-index.txt | 6 +-
.../composeResources/values/strings.xml | 6 +-
.../feature/map/hud/HudChipLayer.kt | 93 +++++++++++++++----
.../meshtastic/feature/map/hud/HudChips.kt | 49 +++++++---
.../meshtastic/feature/map/hud/HudGeometry.kt | 48 +++++++---
.../meshtastic/feature/map/hud/HudTargets.kt | 22 ++++-
.../feature/map/hud/HudGeometryTest.kt | 19 ++++
.../feature/map/hud/HudTargetsTest.kt | 25 ++++-
8 files changed, 215 insertions(+), 53 deletions(-)
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index d17593905bb..e36bfc18b39 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1096,7 +1096,6 @@ mesh_beacon_target_channel_index
mesh_beacon_target_default
mesh_beacon_target_remove
mesh_beacon_targets
-mesh_hud_age
mesh_hud_camera_rationale
mesh_hud_camera_unavailable
mesh_hud_cardinal_east
@@ -1118,6 +1117,7 @@ mesh_hud_heading_accuracy
mesh_hud_heading_low_accuracy
mesh_hud_inside_geofence
mesh_hud_inside_unnamed_geofence
+mesh_hud_joined
mesh_hud_location_rationale
mesh_hud_more
mesh_hud_nearby
@@ -1131,6 +1131,10 @@ mesh_hud_radar_description
mesh_hud_range_all
mesh_hud_range_within
mesh_hud_range_within_hidden
+mesh_hud_rise_above
+mesh_hud_rise_above_spoken
+mesh_hud_rise_below
+mesh_hud_rise_below_spoken
mesh_hud_snr
mesh_hud_thermal_critical
mesh_hud_thermal_hot
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index c522f590b2c..fb104f683c0 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1138,7 +1138,6 @@
Default
Remove target
Broadcast targets
- %1$s · %2$s
Allow camera access to see nodes over the camera view.
Camera unavailable. The view still works as a compass.
E
@@ -1160,6 +1159,7 @@
Low heading accuracy. Move the phone in a figure eight to calibrate.
Inside %1$s
Inside a geofence
+ %1$s · %2$s
The mesh view uses your location to place nodes around you.
+%1$d
Nearby
@@ -1173,6 +1173,10 @@
All distances
Within %1$s
Within %1$s · %2$d farther
+ ▲ %1$s
+ %1$s above you
+ ▼ %1$s
+ %1$s below you
%1$s dB
Phone is too hot. Camera paused.
Phone is hot. Updating less often.
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
index b6666be14e8..9ff9e18409c 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
@@ -27,6 +27,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.Layout
@@ -46,26 +47,46 @@ import org.meshtastic.core.resources.mesh_hud_more
import kotlin.math.abs
private const val OFFSCREEN_ALPHA = 0.7f
-private const val STEM_ALPHA = 0.8f
+private const val LEADER_ALPHA = 0.8f
+
+/** Rows a chip may be lifted to clear nearer ones; the rest of a crowded column is counted, not drawn. */
+private const val MAX_CHIP_LEVEL = 3
private const val NEAR_SCALE_M = 1_000.0
private const val MID_SCALE_M = 10_000.0
private const val MID_SCALE = 0.9f
private const val FAR_SCALE = 0.8f
private val ChipGap = 4.dp
-/** Chips scale about their foot, so the stem stays on the true point. */
+/** A chip on its point scales about its foot; a lifted one about the corner its leader meets. */
private val BottomCenter = TransformOrigin(pivotFractionX = 0.5f, pivotFractionY = 1f)
+private val BottomLeft = TransformOrigin(pivotFractionX = 0f, pivotFractionY = 1f)
+private val BottomRight = TransformOrigin(pivotFractionX = 1f, pivotFractionY = 1f)
+
+/** How far past its point a lifted chip's near corner sits, so the leader leans and never reads as a height. */
+private val LeaderRun = 16.dp
+private val LeaderWidth = 1.dp
+private val LeaderEdgeWidth = 3.dp
+private val LeaderDashes = floatArrayOf(10f, 8f)
private val EdgeGutter = 28.dp
private val ArrowSize = 10.dp
private val AnchorRadius = 4.dp
-private val StemWidth = 2.dp
-private val StemEdge = Color.Black.copy(alpha = 0.4f)
+private val LeaderEdge = Color.Black.copy(alpha = 0.4f)
private val OverflowPadding = 6.dp
private val OverflowBand = 28.dp
private val OverflowScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA)
-/** Where a chip went, so the draw pass can join it to its true point or point it toward the turn. */
-private class PlacedChip(val rect: IntRect, val anchor: Offset?, val edge: PinEdge?, val color: Color)
+/**
+ * Where a chip went, so the draw pass can mark its true point, lead a lifted chip back to it from [port], or point an
+ * off-screen chip toward the turn. [dashed] is true when the point sits level only as a guess, with no altitude.
+ */
+private class PlacedChip(
+ val rect: IntRect,
+ val anchor: Offset?,
+ val port: Offset?,
+ val edge: PinEdge?,
+ val color: Color,
+ val dashed: Boolean,
+)
/** A column of chips cut off by the top limit: its center and how many did not fit. */
private class Overflow(val centerX: Int, var count: Int)
@@ -97,11 +118,12 @@ private fun distanceScale(distanceM: Double): Float = when {
}
/**
- * Places each chip with its bottom center on the target's projected point, nearest first, lifting a chip above any
- * earlier one it would overlap. The lift comes from [stackLevels], which works in view angles and so does not change as
- * the phone moves. A lifted chip keeps a stem down to its true point. Chips that would leave the band between
- * [topLimitPx] and [bottomLimitPx] are dropped into a "+N" count for their column. Targets out of view are pinned to
- * the edge they lie beyond (see [pinEdge]) with an arrow toward them.
+ * Places each chip with its bottom center on the target's projected point, focused then nearest first, lifting a chip
+ * above any earlier one it would overlap. The lift comes from [stackLevels], which works in view angles and so does not
+ * change as the phone moves. A lifted chip steps aside, alternating right and left, and a thin neutral leader runs from
+ * its near bottom corner to the point: a callout, not a height. Chips lifted past [MAX_CHIP_LEVEL] rows, or that would
+ * leave the band between [topLimitPx] and [bottomLimitPx] are dropped into a "+N" count for their column. Targets out
+ * of view are pinned to the edge they lie beyond (see [pinEdge]) with an arrow toward them.
*
* The pose is read in the placement and draw blocks, so sensor updates relayout and redraw without recomposing.
*/
@@ -128,7 +150,7 @@ internal fun HudChipLayer(
.semantics { isTraversalGroup = true }
.drawBehind {
if (currentPose() == null) return@drawBehind
- placement.chips.forEach { drawStemOrArrow(it) }
+ placement.chips.forEach { drawLeaderOrArrow(it) }
placement.overflow.forEach {
drawOverflow(it, topLimitPx(), textMeasurer, overflowStyle, overflowTemplate)
}
@@ -152,7 +174,10 @@ internal fun HudChipLayer(
)
},
gapDeg = pxToDeg(gap, focalPx),
+ leaderRunDeg = pxToDeg(LeaderRun.roundToPx(), focalPx),
+ maxLevel = MAX_CHIP_LEVEL,
)
+ val leaderRun = LeaderRun.roundToPx()
layout(width, height) {
placement.clear()
placement.lastEdge.keys.retainAll(keys)
@@ -179,6 +204,12 @@ internal fun HudChipLayer(
}
}
val rowHeight = placeable.height + gap
+ if (point != null && levels[index] == OVERFLOW_LEVEL) {
+ placement.addOverflow(point.x.toInt(), placeable.width)
+ return@forEachIndexed
+ }
+ val level = if (point != null) levels[index] else 0
+ val side = leaderSide(level)
// Pinned chips spread along their edge by where the target lies, rather than piling in one place.
val along = edge?.let { edgeOffsetPx(it, yaw, pitchDelta, focalPx) } ?: 0f
val alongX =
@@ -193,6 +224,8 @@ internal fun HudChipLayer(
)
val left =
when {
+ point != null && side > 0 -> point.x.toInt() + leaderRun
+ point != null && side < 0 -> point.x.toInt() - leaderRun - placeable.width
point != null -> point.x.toInt() - placeable.width / 2
edge == PinEdge.RIGHT -> width - placeable.width - gutter
edge == PinEdge.LEFT -> gutter
@@ -200,7 +233,7 @@ internal fun HudChipLayer(
}
var top =
when {
- point != null -> point.y.toInt() - placeable.height - levels[index] * rowHeight
+ point != null -> point.y.toInt() - placeable.height - level * rowHeight
edge == PinEdge.TOP -> topLimit + gutter
edge == PinEdge.BOTTOM -> bottomLimit - placeable.height - gutter
else -> alongY
@@ -217,26 +250,46 @@ internal fun HudChipLayer(
return@forEachIndexed
}
if (edge != null) edgeOccupied += rect
+ val port =
+ when {
+ point == null || side == 0 -> null
+ side > 0 -> Offset(rect.left.toFloat(), rect.bottom.toFloat())
+ else -> Offset(rect.right.toFloat(), rect.bottom.toFloat())
+ }
placement.chips +=
- PlacedChip(rect, point?.let { Offset(it.x, it.y) }, edge, Color(target.subject.color))
+ PlacedChip(
+ rect = rect,
+ anchor = point?.let { Offset(it.x, it.y) },
+ port = port,
+ edge = edge,
+ color = Color(target.subject.color),
+ dashed = !target.elevationKnown,
+ )
val scale = distanceScale(target.distanceM)
placeable.placeWithLayer(left, top) {
alpha = if (edge == null) 1f else OFFSCREEN_ALPHA
scaleX = scale
scaleY = scale
- transformOrigin = BottomCenter
+ transformOrigin =
+ when {
+ side > 0 -> BottomLeft
+ side < 0 -> BottomRight
+ else -> BottomCenter
+ }
}
}
}
}
}
-private fun DrawScope.drawStemOrArrow(chip: PlacedChip) {
+private fun DrawScope.drawLeaderOrArrow(chip: PlacedChip) {
chip.anchor?.let { anchor ->
- val foot = Offset(chip.rect.center.x.toFloat(), chip.rect.bottom.toFloat())
- // In the node's color, over a dark edge so it holds up against a bright sky.
- drawLine(StemEdge, foot, anchor, strokeWidth = StemWidth.toPx() + 2.dp.toPx())
- drawLine(chip.color.copy(alpha = STEM_ALPHA), foot, anchor, strokeWidth = StemWidth.toPx())
+ chip.port?.let { port ->
+ // Neutral white over a dark edge: the node's color stays on the chip and the point it marks.
+ val effect = if (chip.dashed) PathEffect.dashPathEffect(LeaderDashes) else null
+ drawLine(LeaderEdge, port, anchor, LeaderEdgeWidth.toPx(), pathEffect = effect)
+ drawLine(Color.White.copy(alpha = LEADER_ALPHA), port, anchor, LeaderWidth.toPx(), pathEffect = effect)
+ }
drawCircle(Color.White, radius = AnchorRadius.toPx() + 1.dp.toPx(), center = anchor)
drawCircle(chip.color, radius = AnchorRadius.toPx(), center = anchor)
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
index ab905ab9842..1e576fd15d6 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt
@@ -43,12 +43,16 @@ import org.meshtastic.core.model.util.waypointIconOrDefault
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.duration_ago
import org.meshtastic.core.resources.formatDuration
-import org.meshtastic.core.resources.mesh_hud_age
import org.meshtastic.core.resources.mesh_hud_chip_description
import org.meshtastic.core.resources.mesh_hud_distance_no_altitude
import org.meshtastic.core.resources.mesh_hud_find
+import org.meshtastic.core.resources.mesh_hud_joined
import org.meshtastic.core.resources.mesh_hud_open_node
import org.meshtastic.core.resources.mesh_hud_open_waypoint
+import org.meshtastic.core.resources.mesh_hud_rise_above
+import org.meshtastic.core.resources.mesh_hud_rise_above_spoken
+import org.meshtastic.core.resources.mesh_hud_rise_below
+import org.meshtastic.core.resources.mesh_hud_rise_below_spoken
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.proto.Waypoint
import kotlin.math.roundToInt
@@ -106,9 +110,9 @@ internal fun HudChip(
remember(target.bearingDeg) {
derivedStateOf { currentPose()?.let { clockHour(target.bearingDeg, it.azimuthDeg) } }
}
- val distanceText = chipDistanceText(target, withAge = target.focused)
- // TalkBack always hears the age: speech does not crowd the view, and the fade alone is visual only.
- val spokenDistance = chipDistanceText(target, withAge = true)
+ val distanceText = chipDistanceText(target, details = target.focused)
+ // TalkBack always hears the rise and age: speech does not crowd the view, and fade and placement are visual only.
+ val spokenDistance = chipDistanceText(target, details = true, spoken = true)
val description = hour?.let {
stringResource(Res.string.mesh_hud_chip_description, target.subject.spokenName(), spokenDistance, it)
}
@@ -144,12 +148,13 @@ internal fun HudChip(
}
/**
- * The distance under a chip, marked when the altitude is missing, and dated in its largest unit once the position is no
- * longer fresh. [withAge] is false for an unfocused chip on screen: there the fade marks staleness, and the date would
- * widen every label in a crowded view.
+ * The distance under a chip, marked when the altitude is missing, then, with [details], how far above or below the
+ * phone it is and how old its position is in its largest unit. [details] is false for an unfocused chip on screen:
+ * there the fade marks staleness and the point's place marks height, and more text would widen every label in a crowded
+ * view. [spoken] words the rise for TalkBack rather than with an arrow glyph.
*/
@Composable
-private fun chipDistanceText(target: HudTarget, withAge: Boolean): String {
+private fun chipDistanceText(target: HudTarget, details: Boolean, spoken: Boolean = false): String {
val showsMissingAltitude = target.subject is HudSubject.NodeSubject && !target.elevationKnown
val distance =
if (showsMissingAltitude) {
@@ -157,18 +162,34 @@ private fun chipDistanceText(target: HudTarget, withAge: Boolean): String {
} else {
target.distanceText
}
- val age = target.positionAgeSec?.takeIf { withAge && it > FRESH_POSITION_SEC } ?: return distance
+ if (!details) return distance
+ return listOfNotNull(distance, target.rise?.let { riseText(it, spoken) }, ageText(target.positionAgeSec)).reduce {
+ text,
+ part,
+ ->
+ stringResource(Res.string.mesh_hud_joined, text, part)
+ }
+}
+
+@Composable
+private fun riseText(rise: HudRise, spoken: Boolean): String = when {
+ spoken && rise.above -> stringResource(Res.string.mesh_hud_rise_above_spoken, rise.text)
+ spoken -> stringResource(Res.string.mesh_hud_rise_below_spoken, rise.text)
+ rise.above -> stringResource(Res.string.mesh_hud_rise_above, rise.text)
+ else -> stringResource(Res.string.mesh_hud_rise_below, rise.text)
+}
+
+/** A stale position's age in its largest unit, or null while it is fresh. */
+@Composable
+private fun ageText(ageSec: Long?): String? {
+ val age = ageSec?.takeIf { it > FRESH_POSITION_SEC } ?: return null
val unit =
when {
age >= SECONDS_PER_DAY -> DurationUnit.DAYS
age >= SECONDS_PER_HOUR -> DurationUnit.HOURS
else -> DurationUnit.MINUTES
}
- return stringResource(
- Res.string.mesh_hud_age,
- distance,
- stringResource(Res.string.duration_ago, formatDuration(age, unit)),
- )
+ return stringResource(Res.string.duration_ago, formatDuration(age, unit))
}
@Composable
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt
index fc43c17eaaf..5d5fe861c09 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt
@@ -130,26 +130,52 @@ data class AngularBox(val bearingDeg: Double, val elevationDeg: Double, val half
private const val MAX_STACK_LEVEL = 64
+/** The level [stackLevels] gives a chip that would need more than its cap: it is counted, not drawn. */
+const val OVERFLOW_LEVEL = -1
+
+/** Which way a chip at [level] steps aside from its point: none at level 0, then right and left in turn. */
+fun leaderSide(level: Int): Int = when {
+ level <= 0 -> 0
+ level % 2 == 1 -> 1
+ else -> -1
+}
+
/**
* How many rows each chip is lifted so none overlaps an earlier one, in list order. Computed from view angles rather
- * than screen positions, so it holds still while the phone moves and changes only when the targets do.
+ * than screen positions, so it holds still while the phone moves and changes only when the targets do. A lifted chip
+ * also steps aside by its half width plus [leaderRunDeg], to the side [leaderSide] gives, so its leader leans rather
+ * than rising plumb; a chip that would need more than [maxLevel] rows gets [OVERFLOW_LEVEL].
*/
-fun stackLevels(boxes: List, gapDeg: Double): IntArray {
+fun stackLevels(
+ boxes: List,
+ gapDeg: Double,
+ leaderRunDeg: Double = 0.0,
+ maxLevel: Int = MAX_STACK_LEVEL,
+): IntArray {
val levels = IntArray(boxes.size)
+ fun center(index: Int, level: Int): Double {
+ val box = boxes[index]
+ val side = leaderSide(level)
+ return if (side == 0 || leaderRunDeg == 0.0) {
+ box.bearingDeg
+ } else {
+ box.bearingDeg + side * (box.halfWidthDeg + leaderRunDeg)
+ }
+ }
boxes.forEachIndexed { i, box ->
fun bottom(index: Int, level: Int) = boxes[index].elevationDeg + level * (boxes[index].heightDeg + gapDeg)
- fun clashes(level: Int) = (0 until i).any { j ->
+ fun overlaps(j: Int, level: Int): Boolean {
val other = boxes[j]
- val apart = abs(signedTurnDeg(other.bearingDeg, box.bearingDeg))
- val myBottom = bottom(i, level)
- val theirBottom = bottom(j, levels[j])
- apart < box.halfWidthDeg + other.halfWidthDeg &&
- myBottom < theirBottom + other.heightDeg &&
- theirBottom < myBottom + box.heightDeg
+ val theirLevel = levels[j]
+ return abs(signedTurnDeg(center(j, theirLevel), center(i, level))) <
+ box.halfWidthDeg + other.halfWidthDeg &&
+ bottom(i, level) < bottom(j, theirLevel) + other.heightDeg &&
+ bottom(j, theirLevel) < bottom(i, level) + box.heightDeg
}
+ fun clashes(level: Int) = (0 until i).any { j -> levels[j] != OVERFLOW_LEVEL && overlaps(j, level) }
var level = 0
- while (level < MAX_STACK_LEVEL && clashes(level)) level++
- levels[i] = level
+ while (level <= maxLevel && clashes(level)) level++
+ levels[i] = if (level > maxLevel) OVERFLOW_LEVEL else level
}
return levels
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
index 384f467055a..c653dd316d3 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
@@ -25,14 +25,17 @@ import org.meshtastic.core.model.geofence.Geofence
import org.meshtastic.core.model.geofence.toGeofence
import org.meshtastic.core.model.util.fixTimeSecondsOrNull
import org.meshtastic.core.model.util.toDistanceString
+import org.meshtastic.core.model.util.toElevationString
import org.meshtastic.core.model.util.uncertaintyRadiusMetersOrNull
import org.meshtastic.core.model.util.verticalUncertaintyMetersOrNull
import org.meshtastic.core.repository.PhoneLocation
import org.meshtastic.core.repository.PhoneLocationState
import org.meshtastic.proto.Waypoint
import kotlin.math.PI
+import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.hypot
+import kotlin.math.roundToInt
import kotlin.math.sin
import org.meshtastic.proto.Position as ProtoPosition
@@ -81,8 +84,16 @@ data class HudTarget(
val elevationKnown: Boolean,
val positionAgeSec: Long?,
val focused: Boolean,
+ /** How far above or below the phone the target is, when that is known and beyond its own uncertainty. */
+ val rise: HudRise? = null,
)
+/** A rise worth saying: [text] is the magnitude in the user's units, [above] its direction. */
+data class HudRise(val text: String, val above: Boolean)
+
+/** A rise smaller than this, or than the altitudes' own uncertainty, is not worth a label. */
+private const val MIN_SHOWN_RISE_M = 30.0
+
/**
* A target too close to point at: the phone sits inside its uncertainty circle. The radar plots it by offset instead,
* [eastM] and [northM] from the phone, inside a ring of [radiusM]. [bearingDeg] is magnetic, like the camera's.
@@ -109,7 +120,10 @@ enum class HudWarning {
}
data class MeshHudTargets(
- /** Sorted nearest first, so the declutter pass stacks farther chips above nearer ones. */
+ /**
+ * Sorted focused first, then nearest, so the declutter pass keeps the focused chip on its point and stacks farther
+ * chips above nearer ones.
+ */
val targets: List = emptyList(),
/** Nodes and waypoints whose uncertainty circle contains the phone, so their direction means nothing. */
val directionUnknown: List = emptyList(),
@@ -208,7 +222,7 @@ internal fun buildHudTargets(
}
}
return MeshHudTargets(
- targets = placed.sortedBy { it.distanceM },
+ targets = placed.sortedWith(compareByDescending { it.focused }.thenBy { it.distanceM }),
directionUnknown = unknown,
fences = waypoints.mapNotNull { here.fenceFor(it, declination) },
elevationApproximate = here.altitude == null && here.mslAltitude == null,
@@ -270,6 +284,10 @@ private fun PhoneLocation.targetAt(
elevationKnown = riseM != null,
positionAgeSec = null,
focused = false,
+ rise =
+ riseM
+ ?.takeIf { abs(it) > maxOf(MIN_SHOWN_RISE_M, verticalM) }
+ ?.let { HudRise(abs(it).roundToInt().toElevationString(units), above = it > 0) },
)
}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudGeometryTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudGeometryTest.kt
index 3749f437627..d0e9bf651a9 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudGeometryTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudGeometryTest.kt
@@ -183,6 +183,25 @@ class HudGeometryTest {
assertEquals(listOf(0, 1), stackLevels(listOf(a, b), gapDeg = 0.1).toList())
}
+ @Test
+ fun `a stack past its cap overflows rather than rising further`() {
+ val box = AngularBox(bearingDeg = 90.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0)
+ val levels = stackLevels(List(5) { box }, gapDeg = 0.1, leaderRunDeg = 0.5, maxLevel = 3)
+ assertEquals(listOf(0, 1, 2, 3, OVERFLOW_LEVEL), levels.toList())
+ }
+
+ @Test
+ fun `lifted chips step aside right then left and a chip on its point does not`() {
+ assertEquals(listOf(0, 1, -1, 1), (0..3).map(::leaderSide))
+ }
+
+ @Test
+ fun `stepping aside still keeps lifted chips from overlapping`() {
+ val box = AngularBox(bearingDeg = 90.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0)
+ val levels = stackLevels(List(3) { box }, gapDeg = 0.1, leaderRunDeg = 0.5, maxLevel = 3)
+ assertEquals(listOf(0, 1, 2), levels.toList())
+ }
+
@Test
fun `a chip fills the lowest free row`() {
val low = AngularBox(bearingDeg = 0.0, elevationDeg = 0.0, halfWidthDeg = 2.0, heightDeg = 1.0)
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt
index fa0b3fb15e9..e863e8d4c3c 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudTargetsTest.kt
@@ -25,6 +25,8 @@ import org.meshtastic.proto.Waypoint
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
import kotlin.test.assertTrue
private const val LAT = 40.0
@@ -187,10 +189,25 @@ class HudTargetsTest {
}
@Test
- fun `targets are sorted nearest first and the focused one is marked`() {
- val result = build(nodes = listOf(node(1, lat = 40.02), node(2)), focus = "node-1")
- assertEquals(listOf("node-2", "node-1"), result.targets.map { it.subject.key })
- assertTrue(result.targets.last().focused)
+ fun `the focused target comes first and the rest nearest first`() {
+ val result = build(nodes = listOf(node(1, lat = 40.03), node(2, lat = 40.02), node(3)), focus = "node-1")
+ assertEquals(listOf("node-1", "node-3", "node-2"), result.targets.map { it.subject.key })
+ assertTrue(result.targets.first().focused)
+ }
+
+ @Test
+ fun `a rise well beyond the altitudes uncertainty is worded with its direction`() {
+ val above = build(nodes = listOf(node(1, hae = 220))).targets.single().rise
+ assertTrue(assertNotNull(above).above)
+ assertTrue(above.text.contains("120"))
+ val below = build(nodes = listOf(node(1, hae = 40))).targets.single().rise
+ assertFalse(assertNotNull(below).above)
+ }
+
+ @Test
+ fun `a small rise or an unknown altitude is not worded`() {
+ assertNull(build(nodes = listOf(node(1, hae = 120))).targets.single().rise)
+ assertNull(build(nodes = listOf(node(1))).targets.single().rise)
}
@Test
From 8eccd96992adb920f664a0907db17734c57f458e Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 16:34:48 -0500
Subject: [PATCH 28/31] fix(map): mesh view chips step aside toward the inside
of the screen
---
.../org/meshtastic/feature/map/hud/HudChipLayer.kt | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
index 9ff9e18409c..81ff1416000 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
@@ -209,7 +209,16 @@ internal fun HudChipLayer(
return@forEachIndexed
}
val level = if (point != null) levels[index] else 0
- val side = leaderSide(level)
+ // A chip that would step off the screen steps to the other side of its point instead.
+ val side =
+ leaderSide(level).let { preferred ->
+ val x = point?.x?.toInt() ?: 0
+ when {
+ preferred > 0 && x + leaderRun + placeable.width > width - gutter -> -1
+ preferred < 0 && x - leaderRun - placeable.width < gutter -> 1
+ else -> preferred
+ }
+ }
// Pinned chips spread along their edge by where the target lies, rather than piling in one place.
val along = edge?.let { edgeOffsetPx(it, yaw, pitchDelta, focalPx) } ?: 0f
val alongX =
From 29eabce91060b9724badc58a7fd810f623944097 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 17:46:36 -0500
Subject: [PATCH 29/31] refactor(map): mesh view drops its range limit and
leaves distance to the map filters
---
.skills/compose-ui/strings-index.txt | 3 --
.../composeResources/values/strings.xml | 3 --
.../meshtastic/feature/map/hud/HudPanels.kt | 30 ---------------
.../meshtastic/feature/map/hud/HudTargets.kt | 6 +--
.../feature/map/hud/MeshHudScreen.kt | 1 -
.../feature/map/hud/MeshHudViewModel.kt | 37 ++-----------------
.../feature/map/hud/MeshHudViewModelTest.kt | 13 +++----
7 files changed, 9 insertions(+), 84 deletions(-)
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index e36bfc18b39..6367b8ba307 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1128,9 +1128,6 @@ mesh_hud_open_node
mesh_hud_open_waypoint
mesh_hud_own_accuracy
mesh_hud_radar_description
-mesh_hud_range_all
-mesh_hud_range_within
-mesh_hud_range_within_hidden
mesh_hud_rise_above
mesh_hud_rise_above_spoken
mesh_hud_rise_below
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index fb104f683c0..b90dc9caa46 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1170,9 +1170,6 @@
Open waypoint
You ± %1$s
%1$s, %2$s
- All distances
- Within %1$s
- Within %1$s · %2$d farther
▲ %1$s
%1$s above you
▼ %1$s
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
index cad5d5dde85..bce2eb23c81 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -39,11 +38,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.LiveRegionMode
-import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
@@ -77,9 +74,6 @@ import org.meshtastic.core.resources.mesh_hud_nearby
import org.meshtastic.core.resources.mesh_hud_no_nodes
import org.meshtastic.core.resources.mesh_hud_on_target
import org.meshtastic.core.resources.mesh_hud_own_accuracy
-import org.meshtastic.core.resources.mesh_hud_range_all
-import org.meshtastic.core.resources.mesh_hud_range_within
-import org.meshtastic.core.resources.mesh_hud_range_within_hidden
import org.meshtastic.core.resources.mesh_hud_thermal_critical
import org.meshtastic.core.resources.mesh_hud_thermal_hot
import org.meshtastic.core.resources.mesh_hud_tilt_down
@@ -131,7 +125,6 @@ internal fun HudTopBar(
status: HudSensorStatus,
targets: MeshHudTargets,
onNavigateUp: () -> Unit,
- onToggleRange: () -> Unit,
onOpenFilters: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
@@ -148,10 +141,6 @@ internal fun HudTopBar(
} ?: stringResource(Res.string.mesh_hud_heading, headingText),
)
}
- if (targets.ready && !targets.scoped) {
- Spacer(Modifier.width(8.dp))
- HudRangeToggle(targets, onToggleRange)
- }
Spacer(Modifier.weight(1f))
onOpenFilters?.let { open ->
IconButton(onClick = open) {
@@ -355,25 +344,6 @@ private fun FinderHintLabel(hint: FinderHint, modifier: Modifier = Modifier) {
)
}
-@Composable
-private fun HudRangeToggle(targets: MeshHudTargets, onToggle: () -> Unit) {
- val text =
- when {
- !targets.rangeLimited -> stringResource(Res.string.mesh_hud_range_all)
-
- targets.hiddenByRange > 0 ->
- stringResource(Res.string.mesh_hud_range_within_hidden, targets.nearRangeText, targets.hiddenByRange)
-
- else -> stringResource(Res.string.mesh_hud_range_within, targets.nearRangeText)
- }
- HudLabel(
- text = text,
- modifier =
- Modifier.clip(MaterialTheme.shapes.small)
- .toggleable(value = targets.rangeLimited, role = Role.Switch, onValueChange = { onToggle() }),
- )
-}
-
@Composable
internal fun HudLabel(text: String, modifier: Modifier = Modifier) {
Text(
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
index c653dd316d3..b11a0e9bc92 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudTargets.kt
@@ -136,12 +136,8 @@ data class MeshHudTargets(
/** Added to the camera's magnetic azimuth to give a true heading. */
val declinationDeg: Float = 0f,
val units: MeasurementSystem = MeasurementSystem.METRIC,
- val rangeLimited: Boolean = true,
- /** True when the view was opened for one purpose, so it shows only that and has no range to toggle. */
+ /** True when the view was opened for one purpose, so it shows only that and has no filters to change. */
val scoped: Boolean = false,
- val nearRangeText: String = "",
- /** Positioned targets left out by the range limit. */
- val hiddenByRange: Int = 0,
val warnings: List = emptyList(),
/** False until the first computation, so the screen does not report an empty mesh before it has looked. */
val ready: Boolean = false,
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
index af76c58868f..53e9d939dcc 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
@@ -293,7 +293,6 @@ fun MeshHudScreen(
),
targets = targets,
onNavigateUp = onNavigateUp,
- onToggleRange = viewModel::toggleRangeLimit,
onOpenFilters = if (targets.ready && !targets.scoped) ({ showFilters = true }) else null,
modifier =
Modifier.align(Alignment.TopStart).onPlaced {
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
index 114bbde4eb1..e0060614ab6 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt
@@ -70,7 +70,6 @@ private const val WALKING_MPS = 1.0f
private data class HudSettings(
val units: MeasurementSystem,
val focusKey: String?,
- val rangeLimited: Boolean,
val filters: MapFilterPrefs,
/** Nodes a traceroute or track names, shown whatever the filters and range say. */
val pinned: Set,
@@ -133,13 +132,6 @@ private data class RouteRequest(val destNum: Int, val requestId: Int, val logUui
private const val TRACK_FALLBACK_COLOR = 0xFFFFFFFF.toInt()
-/** Default reach of the view, a round number in each unit system; farther targets hide unless focused. */
-private const val NEAR_RANGE_METRIC_M = 50_000
-private const val NEAR_RANGE_IMPERIAL_M = 48_280 // 30 mi
-
-private fun nearRangeM(units: MeasurementSystem) =
- if (units == MeasurementSystem.IMPERIAL) NEAR_RANGE_IMPERIAL_M else NEAR_RANGE_METRIC_M
-
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongParameterList")
@KoinViewModel
@@ -162,7 +154,6 @@ class MeshHudViewModel(
// The provider checks the permission once per collection, so a grant made while the screen is open needs a new one.
private val locationPermissionGranted = MutableStateFlow(false)
- private val rangeLimited = MutableStateFlow(true)
private val fixAverager = FixAverager()
private val routeRequest = MutableStateFlow(null)
private val soloNodeNum = MutableStateFlow(null)
@@ -267,11 +258,10 @@ class MeshHudViewModel(
combine(
localeUnitsProvider.measurementSystem,
focusKey,
- rangeLimited,
mapPrefs.mapFilters,
combine(pinned, soloNodeNum, positionHints, ::Triple),
- ) { units, focus, limited, filters, (pinnedNums, solo, hints) ->
- HudSettings(units, focus, limited, filters, pinnedNums, solo, hints)
+ ) { units, focus, filters, (pinnedNums, solo, hints) ->
+ HudSettings(units, focus, filters, pinnedNums, solo, hints)
},
packetRepository.getWaypoints(),
) { location, nodes, myNode, settings, waypointPackets ->
@@ -306,10 +296,9 @@ class MeshHudViewModel(
now,
::declinationAt,
)
- // A scoped view has no range toggle, so it must not drop its own node for being far.
- .limitedTo(settings.rangeLimited && solo == null, settings.units, settings.pinned)
.let { built ->
built.copy(
+ units = settings.units,
averagedFixes = fixAverager.count,
scoped = solo != null,
ready = true,
@@ -405,10 +394,6 @@ class MeshHudViewModel(
locationPermissionGranted.value = granted
}
- fun toggleRangeLimit() {
- rangeLimited.value = !rangeLimited.value
- }
-
private fun MeshHudTargets.findingFor(focusKey: String?): HudFinding? =
targets.firstOrNull { it.focused }?.let { HudFinding(it.subject, it.distanceText) }
?: directionUnknown
@@ -419,20 +404,4 @@ class MeshHudViewModel(
val coachSeen: StateFlow = mapPrefs.meshHudCoachSeen
fun dismissCoach() = mapPrefs.setMeshHudCoachSeen(true)
-
- private fun MeshHudTargets.limitedTo(limited: Boolean, units: MeasurementSystem, pinned: Set): MeshHudTargets {
- val range = nearRangeM(units)
- fun HudTarget.isPinned() = (subject as? HudSubject.NodeSubject)?.node?.num in pinned
- val kept = if (limited) targets.filter { it.distanceM <= range || it.focused || it.isPinned() } else targets
- val keptFences =
- if (limited) fences.filter { f -> f.inside || f.edge.any { it.distanceM <= range } } else fences
- return copy(
- targets = kept,
- fences = keptFences,
- rangeLimited = limited,
- units = units,
- nearRangeText = range.toDistanceString(units),
- hiddenByRange = targets.size - kept.size,
- )
- }
}
diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
index 5799b91a6b5..5bef647acc6 100644
--- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
+++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModelTest.kt
@@ -62,7 +62,7 @@ private const val LON = -105.0
/** About 1.1 km north of the phone. */
private const val NEAR_LAT = 40.01
-/** About 111 km north of the phone, past the 50 km range limit. */
+/** About 111 km north of the phone. */
private const val FAR_LAT = 41.0
private const val FAR_ENOUGH_M = 100_000.0
@@ -240,16 +240,14 @@ class MeshHudViewModelTest {
}
@Test
- fun `the whole mesh view hides nodes past the range limit`() = runTest(testDispatcher) {
+ fun `the whole mesh view leaves distance to the map filters`() = runTest(testDispatcher) {
nodeRepository.setNodes(listOf(node(1), node(3, lat = FAR_LAT)))
viewModel.openFor(null)
- val targets = collectTargets()
- assertEquals(setOf("node-1"), targets.keys)
- assertEquals(1, targets.value.hiddenByRange)
+ assertEquals(setOf("node-1", "node-3"), collectTargets().keys)
}
@Test
- fun `a scoped view keeps its node past the range limit even unfocused`() = runTest(testDispatcher) {
+ fun `a scoped view keeps its node when the focus is cleared`() = runTest(testDispatcher) {
val far = node(3, lat = FAR_LAT)
nodeRepository.setNodes(listOf(node(1), far))
viewModel.openFor(3)
@@ -260,11 +258,10 @@ class MeshHudViewModelTest {
runCurrent()
assertEquals(setOf("node-3"), targets.keys)
assertFalse(targets.value.targets.single().focused)
- assertEquals(0, targets.value.hiddenByRange)
}
@Test
- fun `a tracked node past the range limit stays in the whole mesh view`() = runTest(testDispatcher) {
+ fun `a tracked node stays in the whole mesh view`() = runTest(testDispatcher) {
nodeRepository.setNodes(listOf(node(1), node(3, lat = FAR_LAT)))
viewModel.openFor(null)
viewModel.showTrack(3)
From 15be75ddc6ebb7e01b5fbeb218988f6fb0d52495 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 17:50:17 -0500
Subject: [PATCH 30/31] feat(map): mesh view tightens its top bar and lightens
the compass tape
---
.../meshtastic/feature/map/hud/HudOverlay.kt | 4 +-
.../meshtastic/feature/map/hud/HudPanels.kt | 46 +++++++++++--------
.../feature/map/hud/MeshHudScreen.kt | 36 +++++++++++----
3 files changed, 56 insertions(+), 30 deletions(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
index 9ce5b50614b..dd57aa8862d 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudOverlay.kt
@@ -52,7 +52,9 @@ private val TapeMinorTick = 5.dp
private val TapeMajorTick = 9.dp
private val TapeMarker = 5.dp
private val ReticleRadius = 36.dp
-private val TapeScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA)
+
+/** Light enough to read as part of the view; the labels carry their own shadow for contrast. */
+private val TapeScrim = Color.Black.copy(alpha = 0.3f)
/** The four cardinal letters, localized, for the compass tape. */
internal data class Cardinals(val north: String, val east: String, val south: String, val west: String) {
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
index bce2eb23c81..27e5967054e 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt
@@ -119,7 +119,7 @@ internal data class HudSensorStatus(
val thermal: HudThermal,
)
-/** [onOpenFilters] is null where filters do not apply, in a view scoped to one purpose. */
+/** The heading and the phone's own accuracy beside it; [onOpenFilters] is null in a view scoped to one purpose. */
@Composable
internal fun HudTopBar(
status: HudSensorStatus,
@@ -128,27 +128,20 @@ internal fun HudTopBar(
onOpenFilters: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
- Column(modifier.fillMaxWidth()) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- IconButton(onClick = onNavigateUp) {
- Icon(MeshtasticIcons.ArrowBack, stringResource(Res.string.navigate_back), tint = Color.White)
- }
- status.headingDeg?.let { heading ->
- val headingText = formatString("%d°", heading)
- HudLabel(
- status.headingAccuracyDeg?.let {
- stringResource(Res.string.mesh_hud_heading_accuracy, headingText, formatString("%d°", it))
- } ?: stringResource(Res.string.mesh_hud_heading, headingText),
- )
- }
- Spacer(Modifier.weight(1f))
- onOpenFilters?.let { open ->
- IconButton(onClick = open) {
- Icon(MeshtasticIcons.Tune, stringResource(Res.string.map_filter_title), tint = Color.White)
- }
- }
+ Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = onNavigateUp) {
+ Icon(MeshtasticIcons.ArrowBack, stringResource(Res.string.navigate_back), tint = Color.White)
+ }
+ status.headingDeg?.let { heading ->
+ val headingText = formatString("%d°", heading)
+ HudLabel(
+ status.headingAccuracyDeg?.let {
+ stringResource(Res.string.mesh_hud_heading_accuracy, headingText, formatString("%d°", it))
+ } ?: stringResource(Res.string.mesh_hud_heading, headingText),
+ )
}
targets.ownAccuracyM?.let { accuracy ->
+ Spacer(Modifier.width(6.dp))
HudLabel(
stringResource(
Res.string.mesh_hud_own_accuracy,
@@ -156,6 +149,19 @@ internal fun HudTopBar(
),
)
}
+ Spacer(Modifier.weight(1f))
+ onOpenFilters?.let { open ->
+ IconButton(onClick = open) {
+ Icon(MeshtasticIcons.Tune, stringResource(Res.string.map_filter_title), tint = Color.White)
+ }
+ }
+ }
+}
+
+/** Sensor and phone notices, under the compass tape so they never push it down. */
+@Composable
+internal fun HudTopNotices(status: HudSensorStatus, modifier: Modifier = Modifier) {
+ Column(modifier.padding(horizontal = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
when {
!status.hasSensor -> HudLabel(stringResource(Res.string.compass_no_magnetometer))
status.waitingForSensor -> HudLabel(stringResource(Res.string.mesh_hud_waiting_sensor))
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
index 53e9d939dcc..b029811e1ef 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt
@@ -20,6 +20,7 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -34,6 +35,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInParent
@@ -41,6 +43,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
@@ -84,13 +87,14 @@ private const val ON_TARGET_DEG = 5.0
/** Turn hints move in steps this coarse, so the label does not churn with every sensor sample. */
private const val HINT_STEP_DEG = 5
-private val TapeGap = 4.dp
+private const val TAPE_SHADOW_ALPHA = 0.9f
+private const val TAPE_SHADOW_BLUR = 6f
/** Pitched further down than this, the user is looking for something close by, so the radar grows. */
private const val POINTING_DOWN_DEG = -45.0
-/** The band under the top bar the compass tape takes, which chips stay out of. */
-private val TapeClearance = 52.dp
+/** The band under the top bar the compass tape and its caret take, which chips and notices stay out of. */
+private val TapeClearance = 48.dp
/** Walking pace held this long brings up the reminder to look around, so a step or two does not. */
private const val WALK_NOTICE_MS = 3_000L
@@ -144,6 +148,7 @@ fun MeshHudScreen(
val poseState = viewModel.pose.collectAsStateWithLifecycle()
var intrinsics by remember { mutableStateOf(null) }
var topBarBottomPx by remember { mutableIntStateOf(0) }
+ var noticesHeightPx by remember { mutableIntStateOf(0) }
var bottomPanelTopPx by remember { mutableIntStateOf(0) }
var sensorSilent by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -198,7 +203,12 @@ fun MeshHudScreen(
val tapeClearancePx = with(LocalDensity.current) { TapeClearance.roundToPx() }
val textMeasurer = rememberTextMeasurer()
- val tapeStyle = MaterialTheme.typography.labelSmall.copy(color = Color.White)
+ // A shadow rather than a solid band keeps the tape's figures legible over a bright sky.
+ val tapeStyle =
+ MaterialTheme.typography.labelSmall.copy(
+ color = Color.White,
+ shadow = Shadow(Color.Black.copy(alpha = TAPE_SHADOW_ALPHA), blurRadius = TAPE_SHADOW_BLUR),
+ )
val routeLabels =
RouteLabels(
skippedTemplate = stringResource(Res.string.mesh_hud_more),
@@ -253,7 +263,7 @@ fun MeshHudScreen(
declinationDeg = targets.declinationDeg,
targets = targets.targets,
focalPx = focalPx,
- topPx = topBarBottomPx + TapeGap.toPx(),
+ topPx = topBarBottomPx.toFloat(),
cardinals = cardinals,
textMeasurer = textMeasurer,
labelStyle = tapeStyle,
@@ -263,7 +273,7 @@ fun MeshHudScreen(
targets = targets.targets,
currentPose = { poseState.value.pose },
focalPx = focalPx,
- topLimitPx = { topBarBottomPx + tapeClearancePx },
+ topLimitPx = { topBarBottomPx + tapeClearancePx + noticesHeightPx },
bottomLimitPx = { bottomPanelTopPx },
modifier = Modifier.fillMaxSize(),
) { target ->
@@ -281,8 +291,7 @@ fun MeshHudScreen(
)
}
- HudTopBar(
- status =
+ val sensorStatus =
HudSensorStatus(
headingDeg = headingDeg,
headingAccuracyDeg = headingAccuracyDeg,
@@ -290,7 +299,16 @@ fun MeshHudScreen(
needsCalibration = needsCalibration,
waitingForSensor = waitingForSensor,
thermal = thermal,
- ),
+ )
+ HudTopNotices(
+ status = sensorStatus,
+ modifier =
+ Modifier.align(Alignment.TopStart)
+ .offset { IntOffset(0, topBarBottomPx + tapeClearancePx) }
+ .onPlaced { noticesHeightPx = it.size.height },
+ )
+ HudTopBar(
+ status = sensorStatus,
targets = targets,
onNavigateUp = onNavigateUp,
onOpenFilters = if (targets.ready && !targets.scoped) ({ showFilters = true }) else null,
From 0a5c6e598641f6b14657ba290e97ac2304922394 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 20 Oct 2026 17:57:50 -0500
Subject: [PATCH 31/31] fix(map): mesh view leaves room for the arrows of chips
stacked on the top and bottom edges
---
.../org/meshtastic/feature/map/hud/HudChipLayer.kt | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
index 81ff1416000..980409ebf69 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChipLayer.kt
@@ -69,6 +69,7 @@ private val LeaderEdgeWidth = 3.dp
private val LeaderDashes = floatArrayOf(10f, 8f)
private val EdgeGutter = 28.dp
private val ArrowSize = 10.dp
+private val ArrowGap = 2.dp
private val AnchorRadius = 4.dp
private val LeaderEdge = Color.Black.copy(alpha = 0.4f)
private val OverflowPadding = 6.dp
@@ -178,6 +179,7 @@ internal fun HudChipLayer(
maxLevel = MAX_CHIP_LEVEL,
)
val leaderRun = LeaderRun.roundToPx()
+ val arrowRoomPx = (ArrowSize + ArrowGap).roundToPx()
layout(width, height) {
placement.clear()
placement.lastEdge.keys.retainAll(keys)
@@ -247,8 +249,10 @@ internal fun HudChipLayer(
edge == PinEdge.BOTTOM -> bottomLimit - placeable.height - gutter
else -> alongY
}
- // Top-pinned chips stack downward, away from the edge; every other kind stacks upward.
- val step = if (edge == PinEdge.TOP) rowHeight else -rowHeight
+ // Top-pinned chips stack downward, away from the edge; every other kind stacks upward. A top or bottom
+ // pin's arrow sits outside its chip, so those rows leave room for it.
+ val arrowRoom = if (edge == PinEdge.TOP || edge == PinEdge.BOTTOM) arrowRoomPx else 0
+ val step = if (edge == PinEdge.TOP) rowHeight + arrowRoom else -(rowHeight + arrowRoom)
var rect = IntRect(left, top, left + placeable.width, top + placeable.height)
while (edge != null && edgeOccupied.any { it.overlaps(rect) }) {
top += step
@@ -308,7 +312,7 @@ private fun DrawScope.drawLeaderOrArrow(chip: PlacedChip) {
/** A triangle just outside [rect], pointing past the edge the target lies beyond. */
private fun DrawScope.drawEdgeArrow(rect: IntRect, edge: PinEdge) {
val size = ArrowSize.toPx()
- val gap = 2.dp.toPx()
+ val gap = ArrowGap.toPx()
val cx = rect.center.x.toFloat()
val cy = rect.center.y.toFloat()
val arrow =