diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 7b50476b54e..355df50ac09 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1149,6 +1149,10 @@ mesh_hud_thermal_critical mesh_hud_thermal_hot mesh_hud_tilt_down mesh_hud_tilt_up +mesh_hud_track_empty +mesh_hud_track_newest +mesh_hud_track_points +mesh_hud_track_summary mesh_hud_turn_left mesh_hud_turn_right mesh_hud_waiting_sensor diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 1cb20c30305..9d365f206fc 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -197,6 +197,7 @@ import org.meshtastic.feature.coverage.rememberCoverageEstimate import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.MapBounds import org.meshtastic.feature.map.MapNodePolicy +import org.meshtastic.feature.map.TRACK_FADE_LEVELS import org.meshtastic.feature.map.TRACK_STOP_RADIUS_METERS import org.meshtastic.feature.map.component.ClusterMemberEntry import org.meshtastic.feature.map.component.ClusterMembersDialog @@ -227,6 +228,10 @@ import org.meshtastic.feature.map.mergeStationaryRuns import org.meshtastic.feature.map.terrain.MapterhornEndpoints import org.meshtastic.feature.map.tiles.mapAttributionText import org.meshtastic.feature.map.tracerouteNodeSelection +import org.meshtastic.feature.map.trackFadeAlpha +import org.meshtastic.feature.map.trackFadeLevel +import org.meshtastic.feature.map.trackFadeLevelAlpha +import org.meshtastic.feature.map.trackRecency import org.meshtastic.proto.BoundingBox import org.meshtastic.proto.Position import org.meshtastic.proto.Waypoint @@ -316,10 +321,6 @@ private const val TERRAIN_HILLSHADE_Z_INDEX = -0.9f /** Clears Google's own logo and the zoom controls, both of which sit along the bottom edge. */ private val ATTRIBUTION_BOTTOM_PADDING = 4.dp private const val ATTRIBUTION_SCRIM_ALPHA = 0.7f - -/** Faded rather than transparent, so the oldest point stays visible and tappable, as on the MapLibre track. */ -private const val OLDEST_TRACK_ALPHA = 0.25f -private const val TRACK_FADE_LEVELS = 8 private const val MAX_TRACK_MARKERS = 500 private const val TRACK_POINT_SIZE_DP = 24f private const val SELECTED_TRACK_POINT_SIZE_DP = 32f @@ -1457,7 +1458,7 @@ private fun NodeTrackOverlay( val pointIcons = remember(trackColor, density) { List(TRACK_FADE_LEVELS) { level -> - trackPointIcon(trackColor.copy(alpha = trackFadeAlpha(level)), TRACK_POINT_SIZE_DP, density) + trackPointIcon(trackColor.copy(alpha = trackFadeLevelAlpha(level)), TRACK_POINT_SIZE_DP, density) } } val selectedIcon = @@ -1465,6 +1466,7 @@ private fun NodeTrackOverlay( val pointTitle = stringResource(Res.string.position) val pointDescription = stringResource(Res.string.track_point) val runs = remember(sortedPositions) { mergeStationaryRuns(sortedPositions) } + val recency = remember(runs) { trackRecency(runs.map { it.position.time.toLong() }) } // Every marker is added on the main thread, so only the newest points and the selected one get one. val firstMarkerIndex = (runs.size - MAX_TRACK_MARKERS).coerceAtLeast(0) @@ -1489,14 +1491,15 @@ private fun NodeTrackOverlay( NodeChip(node = focusedNode) } } else { - val level = trackFadeLevel(index, runs.lastIndex) + // Bucketed, so a new fix changes the icon of only the markers that cross a level. + val level = trackFadeLevel(recency[index]) MarkerInfoWindow( state = markerState, contentDescription = pointDescription, icon = if (isSelected) selectedIcon else pointIcons[level], title = pointTitle, snippet = formatAgo(position.time), - zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + trackFadeAlpha(level), + zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + trackFadeLevelAlpha(level), onClick = { onPositionSelect?.invoke(position.time) false // Allow default info window behavior @@ -1510,24 +1513,23 @@ private fun NodeTrackOverlay( if (runs.size > 1) { val points = remember(runs) { runs.map { it.position.toLatLng() } } - // A span without a segment count covers only the first segment. + // One gradient span per segment, between its two ends' fade, so the line follows time rather than order. val spans = - remember(trackColor, points.size) { - val oldest = trackColor.copy(alpha = OLDEST_TRACK_ALPHA).toArgb() - val gradient = StrokeStyle.gradientBuilder(oldest, trackColor.toArgb()).build() - listOf(StyleSpan(gradient, (points.size - 1).toDouble())) + remember(trackColor, recency) { + recency.zipWithNext { from, to -> + val gradient = + StrokeStyle.gradientBuilder( + trackColor.copy(alpha = trackFadeAlpha(from)).toArgb(), + trackColor.copy(alpha = trackFadeAlpha(to)).toArgb(), + ) + .build() + StyleSpan(gradient, 1.0) + } } Polyline(points = points, spans = spans, jointType = JointType.ROUND, width = 8f, zIndex = 0.6f) } } -/** Buckets a point's position along the track so that appending a point changes the icon of only a few markers. */ -private fun trackFadeLevel(index: Int, lastIndex: Int): Int = - if (lastIndex <= 0) TRACK_FADE_LEVELS - 1 else index * (TRACK_FADE_LEVELS - 1) / lastIndex - -private fun trackFadeAlpha(level: Int): Float = - OLDEST_TRACK_ALPHA + (1f - OLDEST_TRACK_ALPHA) * level / (TRACK_FADE_LEVELS - 1) - /** A ring, drawn straight to a bitmap so building it never composes. */ private fun trackPointIcon(color: Color, sizeDp: Float, density: Float): BitmapDescriptor { val sizePx = (sizeDp * density).roundToInt().coerceAtLeast(1) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/node/ArCapture.kt b/androidApp/src/google/kotlin/org/meshtastic/app/node/ArCapture.kt index a75aa8da276..04d85ac3336 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/node/ArCapture.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/node/ArCapture.kt @@ -16,14 +16,11 @@ */ package org.meshtastic.app.node -import android.content.ContentValues -import android.content.Context import android.graphics.Bitmap import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper -import android.provider.MediaStore import android.view.PixelCopy import android.view.SurfaceView import android.view.View @@ -33,14 +30,9 @@ import androidx.core.view.children import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import org.meshtastic.core.ui.util.saveJpegToPictures import kotlin.coroutines.resume -private const val PICTURES_RELATIVE_PATH = "Pictures/Meshtastic" -private const val JPEG_QUALITY = 92 - /** * Copies the AR view's GL surface into Pictures/Meshtastic: the camera feed and the model, none of the Compose controls * drawn over it. The scene renders every camera frame, so the surface is never stale. Null when nothing was saved. @@ -57,7 +49,7 @@ internal suspend fun captureArScene(root: View, title: String, io: CoroutineDisp Handler(Looper.getMainLooper()), ) } - return if (copied) withContext(io) { saveToPictures(root.context, bitmap, title) } else null + return if (copied) withContext(io) { saveJpegToPictures(root.context, bitmap, title) } else null } private fun View.findSurfaceView(): SurfaceView? = when (this) { @@ -65,30 +57,3 @@ private fun View.findSurfaceView(): SurfaceView? = when (this) { is ViewGroup -> children.firstNotNullOfOrNull { it.findSurfaceView() } else -> null } - -@RequiresApi(Build.VERSION_CODES.Q) -private fun saveToPictures(context: Context, bitmap: Bitmap, title: String): Uri? { - val resolver = context.contentResolver - val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) - val name = "${title.replace(Regex("[^A-Za-z0-9]+"), "-").trim('-')}-$stamp.jpg" - val pending = - ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, name) - put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") - put(MediaStore.Images.Media.RELATIVE_PATH, PICTURES_RELATIVE_PATH) - put(MediaStore.Images.Media.IS_PENDING, 1) - } - val uri = - runCatching { - resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), pending) - } - .getOrNull() ?: return null - val written = runCatching { - resolver.openOutputStream(uri)?.use { bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, it) } == true && - resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) > 0 - } - .getOrDefault(false) - // A row left pending would sit hidden in the gallery, so a failed write removes it. - if (!written) runCatching { resolver.delete(uri, null, null) } - return uri.takeIf { written } -} diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt index efe8aa2911f..27509a330a0 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt @@ -45,6 +45,9 @@ private typealias DeepLinkArgs = Map> * - `/nodes` -> List of all nodes * - `/nodes/{destNum}` -> Node details * - `/nodes/{destNum}/{metric}` -> Specific node metric (e.g., `/nodes/1234/device-metrics`) + * - `/nodes/{destNum}/mesh-view` -> Camera mesh view finding that node + * - `/nodes/{destNum}/position/mesh-view` -> Camera mesh view of that node's position track + * - `/map/mesh-view` -> Camera mesh view of the whole mesh * - `/messages` -> Conversation list * - `/messages/{contactKey}` -> Specific conversation * - `/settings` -> Settings root @@ -144,10 +147,18 @@ object DeepLinkRouter { listOf(key) }, pattern("discovery", DiscoveryRoute.DiscoveryGraph.serializer()) { key, _ -> listOf(key) }, + pattern("map/mesh-view", MapRoute.Map.serializer()) { key, _ -> listOf(key, MapRoute.MeshHud()) }, pattern("map/{waypointId}", MapRoute.Map.serializer()) { key, _ -> listOf(key) }, pattern("map?waypointId={waypointId}", MapRoute.Map.serializer()) { key, _ -> listOf(key) }, // A non-numeric waypointId fails to decode above and opens the plain map. pattern("map/.*", MapRoute.Map.serializer()) { key, _ -> listOf(key) }, + pattern("nodes/{destNum}/position/mesh-view", NodesRoute.NodeDetail.serializer()) { key, _ -> + listOf(NodesRoute.Nodes, key) + + listOfNotNull( + key.destNum?.let { NodeDetailRoute.PositionLog(it) }, + key.destNum?.let { MapRoute.MeshHud(focusNodeNum = it, trackNodeNum = it) }, + ) + }, pattern("nodes/{destNum}/{metric}", NodesRoute.NodeDetail.serializer()) { key, args -> val metric = key.destNum?.let { nodeDetailSubRoutes[args.value("metric").lowercase()]?.invoke(it) } listOf(NodesRoute.Nodes, key) + listOfNotNull(metric) @@ -269,5 +280,6 @@ object DeepLinkRouter { "host-metrics" to { destNum -> NodeDetailRoute.HostMetricsLog(destNum) }, "pax" to { destNum -> NodeDetailRoute.PaxMetrics(destNum) }, "neighbors" to { destNum -> NodeDetailRoute.NeighborInfoLog(destNum) }, + "mesh-view" to { destNum -> MapRoute.MeshHud(focusNodeNum = destNum) }, ) } diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt index d4b637a3d2f..5b82d382e0b 100644 --- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt +++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt @@ -165,6 +165,11 @@ class DeepLinkRouterTest { assertEquals(listOf(MapRoute.Map(waypointId = null)), route("/map/not-a-number")) } + @Test + fun `map mesh-view opens the mesh view over the map`() { + assertEquals(listOf(MapRoute.Map(waypointId = null), MapRoute.MeshHud()), route("/map/mesh-view")) + } + // endregion // region nodes @@ -211,6 +216,32 @@ class DeepLinkRouterTest { ) } + @Test + fun `nodes mesh-view finds the node over its detail`() { + assertEquals( + listOf(NodesRoute.Nodes, NodesRoute.NodeDetail(destNum = 100), MapRoute.MeshHud(focusNodeNum = 100)), + route("/nodes/100/mesh-view"), + ) + } + + @Test + fun `nodes position mesh-view shows the track over its position log`() { + assertEquals( + listOf( + NodesRoute.Nodes, + NodesRoute.NodeDetail(destNum = 100), + NodeDetailRoute.PositionLog(destNum = 100), + MapRoute.MeshHud(focusNodeNum = 100, trackNodeNum = 100), + ), + route("/nodes/100/position/mesh-view"), + ) + } + + @Test + fun `nodes position mesh-view with a bad destNum opens the node list`() { + assertEquals(listOf(NodesRoute.Nodes), route("/nodes/abc/position/mesh-view")) + } + @Test fun `nodes with destNum and environment sub-route`() { assertEquals( diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index a12b56dccee..4f7b47b2c1e 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1191,6 +1191,13 @@ Phone is hot. Updating less often. Tilt down %1$d° Tilt up %1$d° + No positions in the track window + %1$s · newest %2$s + + %1$d point + %1$d points + + %1$s over %2$s · newest %3$s Turn left %1$d° Turn right %1$d° Waiting for the orientation sensor diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PicturesStore.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PicturesStore.kt new file mode 100644 index 00000000000..950273ddc53 --- /dev/null +++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PicturesStore.kt @@ -0,0 +1,63 @@ +/* + * 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.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private const val PICTURES_RELATIVE_PATH = "Pictures/Meshtastic" +private const val JPEG_QUALITY = 92 + +/** + * Saves [bitmap] as a JPEG in Pictures/Meshtastic, named after [title] and the time, and returns it, or null when + * nothing was saved. Android 10 and later only, where writing there needs no storage permission. Blocks on disk, so + * call it off the main thread. + */ +@RequiresApi(Build.VERSION_CODES.Q) +fun saveJpegToPictures(context: Context, bitmap: Bitmap, title: String): Uri? { + val resolver = context.contentResolver + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + val name = "${title.replace(Regex("[^A-Za-z0-9]+"), "-").trim('-')}-$stamp.jpg" + val pending = + ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, name) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + put(MediaStore.Images.Media.RELATIVE_PATH, PICTURES_RELATIVE_PATH) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = + runCatching { + resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), pending) + } + .getOrNull() ?: return null + val written = runCatching { + resolver.openOutputStream(uri)?.use { bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, it) } == true && + resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) > 0 + } + .getOrDefault(false) + // A row left pending would sit hidden in the gallery, so a failed write removes it. + if (!written) runCatching { resolver.delete(uri, null, null) } + return uri.takeIf { written } +} 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 772b2aa9717..2a6215328d4 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt @@ -33,8 +33,10 @@ import org.meshtastic.desktop.stub.NoopMagneticFieldProvider import org.meshtastic.desktop.stub.NoopMeshLocationManager import org.meshtastic.desktop.stub.NoopMeshWorkerManager import org.meshtastic.desktop.stub.NoopPhoneLocationProvider +import org.meshtastic.desktop.stub.NoopPhonePressureProvider import org.meshtastic.desktop.stub.NoopPlatformAnalytics import org.meshtastic.feature.map.hud.CameraPoseProvider +import org.meshtastic.feature.map.hud.PhonePressureProvider import org.meshtastic.feature.node.compass.CompassHeadingProvider /** @@ -61,4 +63,6 @@ class DesktopStubsModule { @Single fun magneticFieldProvider(): MagneticFieldProvider = NoopMagneticFieldProvider() @Single fun cameraPoseProvider(): CameraPoseProvider = NoopCameraPoseProvider() + + @Single fun phonePressureProvider(): PhonePressureProvider = NoopPhonePressureProvider() } 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 546af73b0db..da987030d08 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/CompassStubs.kt @@ -17,12 +17,14 @@ package org.meshtastic.desktop.stub import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow 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.map.hud.PhonePressureProvider import org.meshtastic.feature.node.compass.CompassHeadingProvider import org.meshtastic.feature.node.compass.HeadingState @@ -42,6 +44,11 @@ class NoopCameraPoseProvider : CameraPoseProvider { override fun poseUpdates(): Flow = flowOf(CameraPoseState(hasSensor = false)) } +/** No-op [PhonePressureProvider]: desktop has no barometer. */ +class NoopPhonePressureProvider : PhonePressureProvider { + override fun pressureUpdates(): Flow = emptyFlow() +} + /** 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-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt index d5145a4f19a..4ed730677ad 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/NodeTrackMap.kt @@ -47,6 +47,7 @@ import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.LineString import org.maplibre.spatialk.geojson.Point +import org.meshtastic.core.common.util.latLongToMeter import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Node import org.meshtastic.feature.map.LastHeardFilter @@ -63,13 +64,15 @@ import org.meshtastic.feature.map.maplibre.geojson.toNodeChip import org.meshtastic.feature.map.maplibre.layers.NodeChipLayer import org.meshtastic.feature.map.maplibre.style.Basemap import org.meshtastic.feature.map.maplibre.style.MapColors +import org.meshtastic.feature.map.trackFadeAlpha +import org.meshtastic.feature.map.trackRecency import org.maplibre.spatialk.geojson.Position as GeoPosition import org.meshtastic.proto.Position as ProtoPosition private const val TRACK_POINT_KEY = "positionTime" -/** How far along the track a point sits: 0 for the oldest, 1 for the newest. Drives the age fade. */ -private const val TRACK_AGE_KEY = "age" +/** A point's opacity along the track's time fade, from the shared [trackFadeAlpha]. */ +private const val TRACK_ALPHA_KEY = "alpha" /** A timestamped point on a node's position track. */ private typealias TrackPoint = Pair @@ -201,18 +204,44 @@ private fun List.olderThanCutoffRemoved(filter: LastHeardFilter): Li filter.includes(time, nowSeconds) } -private fun pointFeatures(points: List) = FeatureCollection( - points.mapIndexed { index, (position, time) -> - Feature( - geometry = Point(position), - properties = - buildJsonObject { - put(TRACK_POINT_KEY, time) - put(TRACK_AGE_KEY, if (points.size > 1) index.toDouble() / (points.size - 1) else 1.0) - }, - ) - }, -) +private fun List.recency(): List = trackRecency(map { it.second.toLong() }) + +private fun pointFeatures(points: List): FeatureCollection { + val recency = points.recency() + return FeatureCollection( + points.mapIndexed { index, (position, time) -> + Feature( + geometry = Point(position), + properties = + buildJsonObject { + put(TRACK_POINT_KEY, time) + put(TRACK_ALPHA_KEY, trackFadeAlpha(recency[index])) + }, + ) + }, + ) +} + +/** + * The line's fade as `line-progress` stops. Line progress runs by distance, so each vertex sits at its share of the + * track's length and carries its fix's alpha. The inputs must strictly ascend, so a vertex that adds no length replaces + * the stop before it, the newer fix winning. + */ +internal fun trackLineStops(points: List, recency: List): List> { + val along = DoubleArray(points.size) + for (i in 1 until points.size) { + val (a, b) = points[i - 1] to points[i] + along[i] = along[i - 1] + latLongToMeter(a.latitude, a.longitude, b.latitude, b.longitude) + } + val total = along.lastOrNull() ?: 0.0 + val stops = mutableListOf>() + points.indices.forEach { i -> + val progress = if (total > 0) along[i] / total else 0.0 + val stop = progress to trackFadeAlpha(recency[i]) + if (stops.isNotEmpty() && progress <= stops.last().first) stops[stops.lastIndex] = stop else stops += stop + } + return stops +} /** * The track itself, fading from faint at the oldest end to solid at the newest. @@ -220,6 +249,7 @@ private fun pointFeatures(points: List) = FeatureCollection( * `line-gradient` needs the source to compute line-distance metrics, which is what `lineMetrics` turns on; without it * the gradient is silently ignored and the line draws in its base colour. */ +@Suppress("SpreadOperator") // interpolate() only takes its stops as varargs @Composable private fun TrackLineLayer(points: List, color: Color) { val source = @@ -230,6 +260,7 @@ private fun TrackLineLayer(points: List, color: Color) { ), ) } + val stops = remember(points) { trackLineStops(points.map { it.first }, points.recency()) } LineLayer( id = "track-line", source = source, @@ -240,8 +271,7 @@ private fun TrackLineLayer(points: List, color: Color) { interpolate( linear(), feature.lineProgress(), - 0 to const(color.copy(alpha = OLDEST_ALPHA)), - 1 to const(color), + *stops.map { (progress, alpha) -> progress to const(color.copy(alpha = alpha)) }.toTypedArray(), ), ) } @@ -257,9 +287,7 @@ private fun TrackPointLayer(points: List, color: Color, onPositionSe radius = const(5.dp), strokeColor = const(Color.White), strokeWidth = const(2.dp), - // Faded rather than fully transparent at the oldest end. The Google flavor takes its alpha straight to zero, - // which makes the first point of a track invisible and untappable. - opacity = interpolate(linear(), feature[TRACK_AGE_KEY].asNumber(), 0 to const(OLDEST_ALPHA), 1 to const(1f)), + opacity = feature[TRACK_ALPHA_KEY].asNumber(), // Where points overlap the newest one wins, rather than whichever the query happened to return first — // the same reason the node chips resolve a stacked tap by rank instead of by query order. onClick = { features -> @@ -308,6 +336,3 @@ private fun NewestPositionChip(node: Node, newest: TrackPoint) { } NodeChipLayer(id = "track-head", source = source, nodes = listOf(node)) } - -/** Alpha the oldest end of a track fades to. */ -private const val OLDEST_ALPHA = 0.25f diff --git a/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/TrackLineStopsTest.kt b/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/TrackLineStopsTest.kt new file mode 100644 index 00000000000..bcf2bbdf1e6 --- /dev/null +++ b/feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/TrackLineStopsTest.kt @@ -0,0 +1,49 @@ +/* + * 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.maplibre + +import org.maplibre.spatialk.geojson.Position +import org.meshtastic.feature.map.OLDEST_TRACK_ALPHA +import org.meshtastic.feature.map.trackFadeAlpha +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TrackLineStopsTest { + + @Test + fun `each stop sits at its vertex share of the length with that fix alpha`() { + val points = listOf(Position(0.0, 0.0), Position(0.0, 0.01), Position(0.0, 0.04)) + val stops = trackLineStops(points, listOf(0f, 0.5f, 1f)) + assertEquals(listOf(0.0, 0.25, 1.0), stops.map { it.first }, absoluteTolerance = 1e-6) + assertEquals(listOf(OLDEST_TRACK_ALPHA, trackFadeAlpha(0.5f), 1f), stops.map { it.second }) + } + + @Test + fun `a vertex adding no length replaces the stop before it so inputs strictly ascend`() { + val points = listOf(Position(0.0, 0.0), Position(0.0, 0.01), Position(0.0, 0.01), Position(0.0, 0.02)) + val stops = trackLineStops(points, listOf(0f, 0.2f, 0.6f, 1f)) + assertEquals(3, stops.size) + assertTrue(stops.zipWithNext().all { (a, b) -> b.first > a.first }) + assertEquals(trackFadeAlpha(0.6f), stops[1].second) + } + + private fun assertEquals(expected: List, actual: List, absoluteTolerance: Double) { + assertEquals(expected.size, actual.size) + expected.zip(actual).forEach { (e, a) -> assertEquals(e, a, absoluteTolerance) } + } +} diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidPhonePressureProvider.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidPhonePressureProvider.kt new file mode 100644 index 00000000000..f77820e762b --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidPhonePressureProvider.kt @@ -0,0 +1,50 @@ +/* + * 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 kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import org.koin.core.annotation.Single + +@Single +class AndroidPhonePressureProvider(context: Context) : PhonePressureProvider { + private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager + + override fun pressureUpdates(): Flow = callbackFlow { + val sensor = sensorManager?.getDefaultSensor(Sensor.TYPE_PRESSURE) + if (sensor == null) { + close() + return@callbackFlow + } + val listener = + object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + trySend(event.values[0]) + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit + } + sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL) + awaitClose { sensorManager.unregisterListener(listener) } + } +} 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 36ead24db24..296e49c5f53 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 @@ -26,6 +26,7 @@ import androidx.camera.core.CameraState import androidx.camera.core.Preview import androidx.camera.core.SurfaceRequest import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.viewfinder.core.ImplementationMode import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -128,8 +129,14 @@ actual fun HudCameraPreview( } // Crop, not fit: CameraIntrinsics.focalPx assumes the preview fills the view and overflows the long side. + // Embedded, so the feed draws into the window and a capture of the window holds it. surfaceRequest?.let { - CameraXViewfinder(surfaceRequest = it, modifier = modifier, contentScale = ContentScale.Crop) + CameraXViewfinder( + surfaceRequest = it, + modifier = modifier, + implementationMode = ImplementationMode.EMBEDDED, + contentScale = ContentScale.Crop, + ) } } diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.android.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.android.kt new file mode 100644 index 00000000000..e8470e572bd --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.android.kt @@ -0,0 +1,113 @@ +/* + * 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.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.PixelCopy +import android.view.Window +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.device_model_capture_failed +import org.meshtastic.core.resources.device_model_capture_saved +import org.meshtastic.core.ui.util.saveJpegToPictures +import kotlin.coroutines.resume + +private const val FILE_TITLE = "Meshtastic mesh view" + +/** Android 10 and later only, where saving to Pictures needs no storage permission. */ +@Composable +actual fun rememberHudCapture(): (() -> Unit)? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null + val view = LocalView.current + val scope = rememberCoroutineScope() + val dispatchers = koinInject() + val haptic = LocalHapticFeedback.current + val saved = stringResource(Res.string.device_model_capture_saved) + val failed = stringResource(Res.string.device_model_capture_failed) + // One capture at a time: each holds a full-window bitmap, so a burst of taps must not stack them. + var capturing by remember { mutableStateOf(false) } + return { + if (!capturing) { + capturing = true + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + scope.launch { + try { + val uri = view.context.findActivity()?.window?.let { captureWindow(it, dispatchers.io) } + Toast.makeText(view.context, if (uri != null) saved else failed, Toast.LENGTH_SHORT).show() + } finally { + capturing = false + } + } + } + } +} + +/** + * Copies the window as drawn. The camera preview must render into the window ([HudCameraPreview] pins the embedded + * viewfinder): a SurfaceView sits on its own layer, which a window copy leaves out. + */ +@RequiresApi(Build.VERSION_CODES.Q) +private suspend fun captureWindow(window: Window, io: CoroutineDispatcher): Uri? { + val decor = window.decorView.takeIf { it.width > 0 && it.height > 0 } ?: return null + val bitmap = Bitmap.createBitmap(decor.width, decor.height, Bitmap.Config.ARGB_8888) + val copied = suspendCancellableCoroutine { continuation -> + try { + PixelCopy.request( + window, + bitmap, + { result -> continuation.resume(result == PixelCopy.SUCCESS) }, + Handler(Looper.getMainLooper()), + ) + } catch (e: IllegalArgumentException) { + // The window can lose its surface between the size check and the request. + Logger.w(e) { "Mesh view capture lost its window surface" } + continuation.resume(false) + } + } + return if (copied) withContext(io) { saveJpegToPictures(decor.context, bitmap, FILE_TITLE) } else null +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/TrackFade.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/TrackFade.kt new file mode 100644 index 00000000000..76c85ffebcd --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/TrackFade.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 + +/** The alpha a track's oldest end fades to: faint, never invisible, so its first point can still be seen and tapped. */ +const val OLDEST_TRACK_ALPHA = 0.25f + +/** Steps the fade is bucketed into where each step costs a prebuilt marker icon. */ +const val TRACK_FADE_LEVELS = 8 + +/** + * Where each point of an oldest-first track sits between its oldest and newest fix in time, from 0 to 1, so a burst of + * fixes after a quiet hour reads as recent and the quiet hour as old. Measured across the track's own span, so a track + * that is old throughout still fades from end to end. When any time is unknown (null or not positive) or every time is + * the same, the track fades by its order instead. + */ +fun trackRecency(times: List): List { + val known = times.mapNotNull { it?.takeIf { t -> t > 0 } } + val oldest = known.minOrNull() + val newest = known.maxOrNull() + return when { + times.size < 2 -> List(times.size) { 1f } + + known.size < times.size || oldest == null || newest == null || newest == oldest -> + List(times.size) { it.toFloat() / times.lastIndex } + + else -> times.map { (((it ?: oldest) - oldest).toFloat() / (newest - oldest)).coerceIn(0f, 1f) } + } +} + +/** The alpha at [recency] along the fade. */ +fun trackFadeAlpha(recency: Float): Float = OLDEST_TRACK_ALPHA + (1f - OLDEST_TRACK_ALPHA) * recency.coerceIn(0f, 1f) + +/** The [TRACK_FADE_LEVELS] bucket [recency] falls in, the newest point always in the top one. */ +fun trackFadeLevel(recency: Float): Int = + (recency.coerceIn(0f, 1f) * (TRACK_FADE_LEVELS - 1)).toInt().coerceAtMost(TRACK_FADE_LEVELS - 1) + +/** The alpha every point in fade bucket [level] draws at. */ +fun trackFadeLevelAlpha(level: Int): Float = trackFadeAlpha(level.toFloat() / (TRACK_FADE_LEVELS - 1)) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/AltitudeFuser.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/AltitudeFuser.kt new file mode 100644 index 00000000000..183283f3b2f --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/AltitudeFuser.kt @@ -0,0 +1,112 @@ +/* + * 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.core.repository.PhoneLocationState +import kotlin.math.abs +import kotlin.math.pow + +/** Standard-atmosphere sea-level pressure; only differences in the altitude it gives are used, never its level. */ +private const val SEA_LEVEL_HPA = 1013.25 +private const val ATMOSPHERE_SCALE_M = 44_330.0 +private const val ATMOSPHERE_EXPONENT = 1 / 5.255 + +/** How long GPS takes to pull the fused height onto its own, in seconds: long against wander, short against weather. */ +private const val GPS_TIME_CONSTANT_S = 60.0 +private const val MILLIS_PER_SECOND = 1_000.0 + +/** A fused height must move this far before the view takes it, so barometer noise rebuilds nothing. */ +private const val HEIGHT_STEP_M = 0.25 + +/** + * True when [new] is [old] again as far as the view cares: the same fix, its heights within [HEIGHT_STEP_M]. Compared + * against the last state the view took, so a slow climb still passes once it adds up. + */ +internal fun sameFix(old: PhoneLocationState, new: PhoneLocationState): Boolean { + val a = old.location + val b = new.location + if (a == null || b == null) return a == b + fun close(x: Double?, y: Double?) = if (x == null || y == null) x == y else abs(x - y) < HEIGHT_STEP_M + return a.copy(altitude = null, mslAltitude = null) == b.copy(altitude = null, mslAltitude = null) && + close(a.altitude, b.altitude) && + close(a.mslAltitude, b.mslAltitude) +} + +/** Height from pressure under the standard atmosphere, as Android's `SensorManager.getAltitude` computes it. */ +internal fun pressureAltitudeM(hPa: Float): Double = + ATMOSPHERE_SCALE_M * (1 - (hPa / SEA_LEVEL_HPA).pow(ATMOSPHERE_EXPONENT)) + +/** + * The phone's height from GPS and the barometer together, the way a fused altimeter does it. The barometer feels a step + * up a stair at once and holds still while standing, but its level drifts with the weather; GPS knows the level but + * wanders by many meters from fix to fix. So the barometer gives the changes and GPS, averaged over a minute, keeps the + * level: the fused height is the pressure altitude plus an offset that leans toward GPS a little with each fix. + * + * Without a barometer reading the fix passes through as GPS gave it. A fix without a GPS height, once the level is set, + * keeps the barometer's height on the last level rather than losing the height altogether. + */ +internal class AltitudeFuser { + private var baroM: Double? = null + private var offsetM: Double? = null + private var lastFixMillis: Long? = null + + /** How far the ellipsoid height sits above the sea-level one here, from the last fix that gave both. */ + private var geoidSeparationM: Double? = null + + fun onPressure(hPa: Float) { + baroM = pressureAltitudeM(hPa) + } + + /** [fix] with its heights moved onto the fused one; the sea-level height moves with it, the geoid being fixed. */ + fun fuse(fix: PhoneLocation): PhoneLocation { + val baro = baroM + val gps = fix.altitude + return when { + baro == null -> fix + gps == null -> held(fix, baro) + else -> calibrated(fix, baro, gps) + } + } + + /** A fix with no GPS height: the barometer's height on the last level, or the fix as it is before any level. */ + private fun held(fix: PhoneLocation, baro: Double): PhoneLocation { + val height = offsetM?.let { baro + it } ?: return fix + return fix.copy(altitude = height, mslAltitude = geoidSeparationM?.let { height - it } ?: fix.mslAltitude) + } + + private fun calibrated(fix: PhoneLocation, baro: Double, gps: Double): PhoneLocation { + fix.mslAltitude?.let { geoidSeparationM = gps - it } + val target = gps - baro + val offset = offsetM + val previous = lastFixMillis + offsetM = + when { + offset == null || previous == null -> target + + fix.timeMillis > previous -> { + val dt = (fix.timeMillis - previous) / MILLIS_PER_SECOND + offset + (target - offset) * dt / (GPS_TIME_CONSTANT_S + dt) + } + + else -> offset + } + lastFixMillis = maxOf(fix.timeMillis, previous ?: fix.timeMillis) + val fused = baro + (offsetM ?: target) + return fix.copy(altitude = fused, mslAltitude = fix.mslAltitude?.let { it + (fused - gps) }) + } +} 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 482b8252813..1a52d34b93e 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 @@ -33,6 +33,9 @@ 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 +/** Weight for a height that reports no vertical accuracy, as if it were this many meters: GPS height is the weaker. */ +private const val UNKNOWN_VERTICAL_ACCURACY_M = 30f + /** * 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 @@ -79,6 +82,26 @@ internal class FixAverager { latitude = lat / weightSum, longitude = lon / weightSum, accuracyM = fixes.mapNotNull { it.accuracyM }.minOrNull(), + altitude = meanHeight { it.altitude }, + mslAltitude = meanHeight { it.mslAltitude }, + verticalAccuracyM = fixes.mapNotNull { it.verticalAccuracyM }.minOrNull(), ) } + + /** + * A height averaged like the position, each fix weighted by the inverse square of its vertical accuracy, over the + * fixes that report it. GPS height wanders further than position, which is why it most needs the average. + */ + private fun meanHeight(height: (PhoneLocation) -> Double?): Double? { + var weightSum = 0.0 + var sum = 0.0 + fixes.forEach { f -> + val h = height(f) ?: return@forEach + val accuracy = (f.verticalAccuracyM ?: UNKNOWN_VERTICAL_ACCURACY_M).coerceAtLeast(MIN_ACCURACY_M).toDouble() + val w = 1 / (accuracy * accuracy) + weightSum += w + sum += w * h + } + return if (weightSum > 0) sum / weightSum else null + } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.kt new file mode 100644 index 00000000000..3bb0237bde3 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.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 + +/** Saves a picture of the mesh view as shown, camera and overlay together; null where the platform cannot. */ +@Composable expect fun rememberHudCapture(): (() -> Unit)? 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 5d5fe861c09..60d8f8961e6 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 @@ -28,7 +28,7 @@ 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 +internal 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 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 9dca6cac374..6d24e49338c 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 @@ -46,6 +46,7 @@ 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.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.util.toDistanceString @@ -54,6 +55,8 @@ 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.device_model_capture +import org.meshtastic.core.resources.formatDuration import org.meshtastic.core.resources.img_chirpy import org.meshtastic.core.resources.map_filter_title import org.meshtastic.core.resources.mesh_hud_camera_unavailable @@ -79,6 +82,10 @@ 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_track_empty +import org.meshtastic.core.resources.mesh_hud_track_newest +import org.meshtastic.core.resources.mesh_hud_track_points +import org.meshtastic.core.resources.mesh_hud_track_summary 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 @@ -91,14 +98,18 @@ 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.PhotoCamera import org.meshtastic.core.ui.icon.Tune import org.meshtastic.core.ui.theme.TracerouteColors import org.meshtastic.core.ui.util.PermissionUiState +import org.meshtastic.core.ui.util.formatAgo +import kotlin.time.DurationUnit /** 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 const val SECONDS_PER_MINUTE = 60L private val CoachMaxWidth = 360.dp /** Where the finder's target is from the center of the view, in whole hint steps. */ @@ -120,13 +131,17 @@ internal data class HudSensorStatus( val thermal: HudThermal, ) -/** The heading and the phone's own accuracy beside it; [onOpenFilters] is null 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, and + * [onCapture] is null without a live camera feed or where the platform cannot save a picture. + */ @Composable internal fun HudTopBar( status: HudSensorStatus, targets: MeshHudTargets, onNavigateUp: () -> Unit, onOpenFilters: (() -> Unit)?, + onCapture: (() -> Unit)?, modifier: Modifier = Modifier, ) { Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { @@ -156,6 +171,11 @@ internal fun HudTopBar( Icon(MeshtasticIcons.Tune, stringResource(Res.string.map_filter_title), tint = Color.White) } } + onCapture?.let { + IconButton(onClick = it) { + Icon(MeshtasticIcons.PhotoCamera, stringResource(Res.string.device_model_capture), tint = Color.White) + } + } } } @@ -185,6 +205,7 @@ internal fun HudBottomPanel( targets: MeshHudTargets, hint: FinderHint?, route: HudRoute?, + track: HudTrack?, notices: HudNotices, location: PermissionUiState, onOpenLocationSettings: () -> Unit, @@ -202,6 +223,7 @@ internal fun HudBottomPanel( } hint?.let { FinderHintLabel(it, Modifier.align(Alignment.CenterHorizontally)) } route?.let { RouteLegend(it) } + track?.let { TrackLegend(it) } cameraContent() if (notices.cameraUnavailable) HudLabel(stringResource(Res.string.mesh_hud_camera_unavailable)) LocationWarnings(targets.warnings, location, onOpenLocationSettings) @@ -216,7 +238,7 @@ internal fun HudBottomPanel( }, ) } - if (targets.ready && route == null && targets.showsNothing) { + if (targets.ready && route == null && track == null && targets.showsNothing) { HudLabel(stringResource(Res.string.mesh_hud_no_nodes)) } if (targets.elevationApproximate && targets.targets.isNotEmpty()) { @@ -324,6 +346,36 @@ private fun RouteLegend(route: HudRoute) { } } +/** + * What the track covers, bordered in its color: how many points, over how long, and how old the newest is. The camera + * overlay is hidden from TalkBack, so this line is also what a screen reader says about the track. + */ +@Composable +private fun TrackLegend(track: HudTrack) { + val summary = track.summary() + if (summary == null) { + HudLabel(stringResource(Res.string.mesh_hud_track_empty)) + return + } + val count = pluralStringResource(Res.plurals.mesh_hud_track_points, summary.points, summary.points) + val newest = summary.newestFixTimeSec?.let { formatAgo(it.toInt()) } + val text = + when { + newest == null -> count + + summary.spanSec < SECONDS_PER_MINUTE -> stringResource(Res.string.mesh_hud_track_newest, count, newest) + + else -> + stringResource( + Res.string.mesh_hud_track_summary, + count, + formatDuration(summary.spanSec, DurationUnit.MINUTES), + newest, + ) + } + HudLabel(text, Modifier.border(2.dp, Color(track.color), MaterialTheme.shapes.small)) +} + @Composable private fun FinderHintLabel(hint: FinderHint, modifier: Modifier = Modifier) { val text = diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathSampling.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathSampling.kt new file mode 100644 index 00000000000..b6719c6efe3 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathSampling.kt @@ -0,0 +1,116 @@ +/* + * 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.acos +import kotlin.math.atan2 +import kotlin.math.ceil +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan + +private const val FULL_CIRCLE = 360.0 + +/** + * The directions along the ground from [a] to [b], so a track leg follows where the node walked rather than cutting a + * chord between its ends: a leg passing close by dips toward the viewer's feet, as the ground does. Each end's ground + * offset and height come back from its bearing, elevation and distance, and the leg is stepped straight between them. A + * leg spanning no more than [GROUND_STEP_DEG] of view is left as its two ends. + */ +internal fun groundDirections(a: PathPoint, b: PathPoint): List { + val from = directionEnu(a.bearingDeg, a.elevationDeg) + val to = directionEnu(b.bearingDeg, b.elevationDeg) + val spanDeg = acos((from dot to).coerceIn(-1.0, 1.0)) * FULL_CIRCLE / (2 * PI) + val steps = ceil(spanDeg / GROUND_STEP_DEG).toInt().coerceIn(1, GROUND_MAX_STEPS) + if (steps == 1) return listOf(from, to) + fun east(p: PathPoint) = p.distanceM * sin(p.bearingDeg.toRadians()) + fun north(p: PathPoint) = p.distanceM * cos(p.bearingDeg.toRadians()) + val (riseA, riseB) = riseM(a.distanceM, a.elevationDeg) to riseM(b.distanceM, b.elevationDeg) + return (0..steps).map { i -> + val t = i.toDouble() / steps + val e = east(a) + (east(b) - east(a)) * t + val n = north(a) + (north(b) - north(a)) * t + val bearing = (atan2(e, n) * FULL_CIRCLE / (2 * PI) + FULL_CIRCLE) % FULL_CIRCLE + directionEnu(bearing, elevationDeg(hypot(e, n), riseA + (riseB - riseA) * t)) + } +} + +/** The rise that [elevationDeg] turns into [elevationDeg] at [distanceM]: its inverse. */ +internal fun riseM(distanceM: Double, elevationDeg: Double): Double = + distanceM * tan(elevationDeg.toRadians()) + distanceM * distanceM / (2 * EFFECTIVE_EARTH_RADIUS_M) + +/** A track leg is stepped along the ground once it spans more than this much of the view. */ +private const val GROUND_STEP_DEG = 5.0 +private const val GROUND_MAX_STEPS = 24 + +/** Straight down from the viewer: the phone's end of a leg to or from it, as far as anything riding the leg goes. */ +internal val VIEWER_DOWN = Vec3(0.0, 0.0, -1.0) + +/** How far either side of a spark its heading is read, in degrees of the path. */ +private const val SPARK_HEADING_DEG = 0.5 +private const val MIN_LEG_DEG = 1e-6 + +/** A spark on a path through the view, as directions: where it is, and a step behind and ahead of it. */ +data class Spark(val at: Vec3, val behind: Vec3, val ahead: Vec3) + +/** + * Sparks [spacingDeg] apart along [path], a run of directions, all shifted [phaseDeg] forward so that a growing phase + * moves them along it. Spacing is by angle in the world, not length on screen, so a spark stays on its piece of the + * path while the camera turns, and a dense track carries no more sparks than a sparse one across the same view. + */ +internal fun sparksAlong(path: List, phaseDeg: Double, spacingDeg: Double): List { + if (spacingDeg <= 0.0) return emptyList() + val sparks = mutableListOf() + var next = phaseDeg.mod(spacingDeg) + var walked = 0.0 + path.zipWithNext { a, b -> + val length = acos((a dot b).coerceIn(-1.0, 1.0)) * FULL_CIRCLE / (2 * PI) + // Two copies of one direction rarely dot to exactly 1, so a leg this short counts as none. + if (length < MIN_LEG_DEG) return@zipWithNext + while (next <= walked + length) { + val t = (next - walked) / length + val step = SPARK_HEADING_DEG / length + sparks += + Spark( + at = between(a, b, t), + behind = between(a, b, (t - step).coerceAtLeast(0.0)), + ahead = between(a, b, (t + step).coerceAtMost(1.0)), + ) + next += spacingDeg + } + walked += length + } + return sparks +} + +/** The direction [t] of the way from [a] to [b] along the great circle through both, at an even angular pace. */ +private fun between(a: Vec3, b: Vec3, t: Double): Vec3 { + val angle = acos((a dot b).coerceIn(-1.0, 1.0)) + val s = sin(angle) + // Opposite or identical directions have no one circle between them; a straight blend is as good as any. + val (wa, wb) = if (s < MIN_SLERP_SIN) (1 - t) to t else sin((1 - t) * angle) / s to sin(t * angle) / s + val x = a.x * wa + b.x * wb + val y = a.y * wa + b.y * wb + val z = a.z * wa + b.z * wb + val norm = sqrt(x * x + y * y + z * z) + return if (norm == 0.0) a else Vec3(x / norm, y / norm, z / norm) +} + +private const val MIN_SLERP_SIN = 1e-6 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 6286ab5cca5..625eaa7034c 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 @@ -25,6 +25,7 @@ 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.abs import kotlin.math.hypot import kotlin.math.sin import org.meshtastic.proto.Position as ProtoPosition @@ -63,11 +64,60 @@ data class RouteLeg( /** 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) -data class TrackPoint(val point: PathPoint, val ageSec: Long?, val altitudeKnown: Boolean) +/** [fixTimeSec] is absolute, so ages read from it stay current while the track itself is unchanged. */ +data class TrackPoint(val point: PathPoint, val fixTimeSec: 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) +/** What a track covers: how many points, from its oldest timed fix to its newest. */ +data class TrackSummary(val points: Int, val spanSec: Long, val newestFixTimeSec: Long?) + +/** Null for an empty track. Points with no fix time count but do not stretch the span. */ +internal fun HudTrack.summary(): TrackSummary? { + if (points.isEmpty()) return null + val times = points.mapNotNull { it.fixTimeSec } + return TrackSummary( + points = points.size, + spanSec = if (times.isEmpty()) 0 else times.max() - times.min(), + newestFixTimeSec = times.maxOrNull(), + ) +} + +/** A track spanning longer than this also labels a point near its middle. */ +private const val MIDPOINT_LABEL_SPAN_SEC = 3_600L + +/** + * The points whose fix time is tagged in the air: the oldest, and on a track over an hour one near the middle. Never + * the newest, where the node's chip already gives its age, and never a point at the viewer, which has no direction. + */ +internal fun trackLabelIndices(points: List): List { + val timed = + points.indices.filter { + it != points.lastIndex && !points[it].point.atViewer && points[it].fixTimeSec != null + } + val oldest = timed.firstOrNull() ?: return emptyList() + val oldestTime = points[oldest].fixTimeSec ?: 0L + val newestTime = points.mapNotNull { it.fixTimeSec }.max() + val middleTime = (oldestTime + newestTime) / 2 + val middle = + timed + .filter { it != oldest } + .minByOrNull { abs((points[it].fixTimeSec ?: 0L) - middleTime) } + .takeIf { newestTime - oldestTime > MIDPOINT_LABEL_SPAN_SEC } + return listOfNotNull(oldest, middle) +} + +/** + * The far end of a leg with exactly one end at the viewer, which is drawn from the bottom of the view: the near end's + * bearing comes from an offset smaller than the phone's own fix, so it points nowhere. Null when neither or both are. + */ +internal fun viewerLeg(a: PathPoint, b: PathPoint): PathPoint? = when { + a.atViewer == b.atViewer -> null + a.atViewer -> b + else -> a +} + /** The node num a traceroute uses for a relay it could not identify. */ internal const val UNKNOWN_HOP = NodeAddress.NODENUM_BROADCAST @@ -139,7 +189,6 @@ internal fun buildHudTrack( nodeNum: Int, color: Int, positions: List, - nowSeconds: Long, ): HudTrack { val points = positions @@ -150,7 +199,7 @@ internal fun buildHudTrack( here.pathPoint(position, declination)?.let { point -> TrackPoint( point = point, - ageSec = position.fixTimeSecondsOrNull()?.let { maxOf(0, nowSeconds - it) }, + fixTimeSec = position.fixTimeSecondsOrNull(), altitudeKnown = position.altitude != null || position.altitude_hae != null, ) } 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 84fa536662c..2866a668592 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 @@ -25,18 +25,21 @@ 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.graphics.luminance 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 +import org.meshtastic.core.ui.util.formatAgo +import org.meshtastic.feature.map.trackFadeAlpha +import org.meshtastic.feature.map.trackRecency 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 /** * How far along a stem from the viewer its tag sits. The two legs' stems leave the same point, so their tags sit at @@ -44,12 +47,24 @@ private const val TRACK_LEVELS = 8 */ 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 private val TrackWidth = 3.dp private val TrackRing = 4.dp +private val TrackRingStroke = 2.dp +private val TrackCasing = 1.5.dp +private const val CASING_LUMINANCE_SPLIT = 0.5f +private val SparkCore = 2.5.dp +private val SparkGlow = 5.dp +private val SparkTail = 14.dp + +/** Sparks ride a path this many degrees apart, and travel this many degrees a second, so they hold to the world. */ +private const val SPARK_SPACING_DEG = 30.0 +private const val SPARK_DEG_PER_SECOND = 4.0 +private const val SPARK_GLOW_ALPHA = 0.6f private val LabelPadding = 4.dp +private val TagLift = 16.dp +private val TagMinGap = 48.dp private val LabelScrim = Color.Black.copy(alpha = HUD_SCRIM_ALPHA) private val Dashes = floatArrayOf(18f, 12f) @@ -75,9 +90,67 @@ private fun DrawScope.drawPolyline( } } +/** A straight line from the bottom center of the view, where the viewer stands, to [far]. */ +@Suppress("LongParameterList") +private fun DrawScope.drawFromViewer( + far: PathPoint, + pose: CameraPose, + focalPx: Float, + color: Color, + width: Float, + dashed: Boolean, +) { + val end = screen(directionEnu(far.bearingDeg, far.elevationDeg), pose, focalPx) ?: return + val effect = if (dashed) PathEffect.dashPathEffect(Dashes) else null + drawLine(color, Offset(size.width / 2, size.height), end, width, StrokeCap.Round, pathEffect = effect) +} + +/** A leg to or from the viewer as directions, from its feet out to [far] when [leaving], and back otherwise. */ +private fun viewerPath(far: PathPoint, leaving: Boolean): List { + val out = listOf(VIEWER_DOWN, directionEnu(far.bearingDeg, far.elevationDeg)) + return if (leaving) out else out.reversed() +} + +/** Black on a light color and white on a dark one, for whatever is drawn over or around it. */ +private fun contrastOn(color: Color): Color = + if (color.luminance() > CASING_LUMINANCE_SPLIT) Color.Black else Color.White + +/** + * Packets in flight along [path], after the Chirpy Hop backdrop's hops: each spark a bright core over a halo in the + * path's color, with a short tail behind it, moving with [seconds]. + */ +@Suppress("LongParameterList") +private fun DrawScope.drawSparks( + path: List, + seconds: Double, + pose: CameraPose, + focalPx: Float, + core: Color, + glow: Color, +) { + val tail = SparkTail.toPx() + sparksAlong(path, seconds * SPARK_DEG_PER_SECOND, SPARK_SPACING_DEG).forEach { spark -> + val at = screen(spark.at, pose, focalPx) ?: return@forEach + val behind = screen(spark.behind, pose, focalPx) ?: return@forEach + val ahead = screen(spark.ahead, pose, focalPx) ?: return@forEach + val heading = (ahead - behind).let { d -> d.getDistance().takeIf { it > 0f }?.let { d / it } } + heading?.let { drawLine(glow, at - it * tail, at, SparkGlow.toPx(), StrokeCap.Round) } + drawCircle(glow, SparkGlow.toPx(), at) + drawCircle(core, SparkCore.toPx(), at) + } +} + +/** Appends [leg] to [path], dropping its first direction where it repeats the path's last. */ +private fun MutableList.extend(leg: List) { + addAll(if (leg.firstOrNull() == lastOrNull()) leg.drop(1) else leg) +} + /** 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) +/** The words a track's time tags fall back on, read in composition like [RouteLabels]. */ +internal class TrackLabels(val unknown: String, val now: 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) @@ -104,10 +177,14 @@ internal fun DrawScope.drawRoute( textMeasurer: TextMeasurer, labelStyle: TextStyle, labels: RouteLabels, + sparkSeconds: Double, ) { + val tags = mutableListOf>() listOfNotNull(route.forward, route.back).forEach { leg -> val color = if (leg.forward) forwardColor else backColor val liftSign = if (leg.forward) 1 else -1 + // The leg hop by hop as directions, for its sparks to run along once every link is down. + val path = mutableListOf() var from: Int? = null leg.points.forEachIndexed { index, point -> if (point == null) return@forEachIndexed @@ -120,62 +197,108 @@ 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) - val viewer = Offset(size.width / 2, size.height) + val far = viewerLeg(start, point) 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) { + if (far != null) { // 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) + drawFromViewer(far, pose, focalPx, stroke, width, dashed = false) + path.extend(viewerPath(far, leaving = start.atViewer)) + screen(directionEnu(far.bearingDeg, far.elevationDeg), pose, focalPx)?.let { end -> // Toward the hop, so the tag stays clear of the bottom panel and the reticle. val along = if (leg.forward) FORWARD_STEM_TAG_AT else RETURN_STEM_TAG_AT - tag?.let { drawTag(it, lerp(viewer, end, along), textMeasurer, labelStyle) } + tag?.let { tags += it to lerp(Offset(size.width / 2, size.height), end, along) } } } else if (!start.atViewer) { val arc = arcDirections(start, point, liftSign) drawPolyline(arc, pose, focalPx, stroke, width, dashed = skipped > 0) + path.extend(arc) val apex = screen(arc[arc.size / 2], pose, focalPx) - if (apex != null) tag?.let { drawTag(it, apex, textMeasurer, labelStyle) } + if (apex != null && tag != null) tags += tag to apex } } from = index } + drawSparks(path, sparkSeconds, pose, focalPx, contrastOn(color), color.copy(alpha = SPARK_GLOW_ALPHA)) } + tags.forEach { (text, at) -> drawTag(text, at, textMeasurer, labelStyle) } } /** - * 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. + * A node's track as breadcrumbs in its color, fading with the time of each fix as both track maps do. A leg into a + * point with no altitude draws dashed, since that point only sits level as a guess. A leg to or from a point at the + * viewer rises from the bottom of the view, as a route's does. The oldest point, and one near the middle of a long + * track, is tagged with how long ago it was fixed. */ -internal fun DrawScope.drawTrack(track: HudTrack, pose: CameraPose, focalPx: Float) { +@Suppress("LongParameterList") +internal fun DrawScope.drawTrack( + track: HudTrack, + pose: CameraPose, + focalPx: Float, + textMeasurer: TextMeasurer, + labelStyle: TextStyle, + labels: TrackLabels, + sparkSeconds: Double, +) { 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) - } + // A node's color can be as dark or as light as the scene behind it, so the track rides on an outline of the + // opposite shade, as a chip's text does on its fill. + val casing = contrastOn(base) + val recency = trackRecency(points.map { it.fixTimeSec }) + fun alphaAt(index: Int): Float = trackFadeAlpha(recency[index]) 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, - ) + + // Each leg from older a to newer b as directions along the ground; one with an end at the viewer runs to its feet. + val legs = points.zipWithNext { a, b -> + val far = viewerLeg(a.point, b.point) + when { + far != null -> viewerPath(far, leaving = a.point.atViewer) + a.point.atViewer -> emptyList() + else -> groundDirections(a.point, b.point) + } } + fun drawLegs(width: Float, color: (Int) -> Color) = points.zipWithNext().forEachIndexed { i, (a, b) -> + val far = viewerLeg(a.point, b.point) + when { + far != null -> drawFromViewer(far, pose, focalPx, color(i + 1), width, dashed = !b.altitudeKnown) + + legs[i].isNotEmpty() -> + drawPolyline(legs[i], pose, focalPx, color(i + 1), width, dashed = !b.altitudeKnown) + } + } + drawLegs(TrackWidth.toPx() + 2 * TrackCasing.toPx()) { casing.copy(alpha = alphaAt(it)) } + drawLegs(TrackWidth.toPx()) { base.copy(alpha = alphaAt(it)) } + // Older to newer, the core in the outline's shade so it stands off the line it runs along. + val path = mutableListOf().apply { legs.forEach { extend(it) } } + drawSparks(path, sparkSeconds, pose, focalPx, casing, base.copy(alpha = SPARK_GLOW_ALPHA)) 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())) + val ring = TrackRingStroke.toPx() + drawCircle( + casing.copy(alpha = alphaAt(i)), + TrackRing.toPx(), + at, + style = Stroke(ring + 2 * TrackCasing.toPx()), + ) + drawCircle(base.copy(alpha = alphaAt(i)), TrackRing.toPx(), at, style = Stroke(ring)) } } + val placed = mutableListOf() + val minGap = TagMinGap.toPx() + trackLabelIndices(points).forEach { i -> + val time = points[i].fixTimeSec ?: return@forEach + val at = screen(points[i].direction(), pose, focalPx) ?: return@forEach + // Two tags that close would overlap; the first, the oldest, wins. + if (placed.any { (it - at).getDistance() < minGap }) return@forEach + placed += at + val text = formatAgo(time.toInt(), labels.unknown, labels.now) + drawTag(text, at - Offset(0f, TagLift.toPx()), textMeasurer, labelStyle) + } } 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 f2e2919e8a3..27e5d153cac 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 @@ -24,10 +24,11 @@ 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 org.meshtastic.feature.map.trackFadeAlpha +import org.meshtastic.feature.map.trackRecency 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 @@ -89,7 +90,8 @@ 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() + val recency = trackRecency(track.points.map { it.fixTimeSec }) + fun alphaAt(index: Int) = trackFadeAlpha(recency[index]) 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 b029811e1ef..0d316c8bd2c 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 @@ -28,10 +28,12 @@ import androidx.compose.runtime.SideEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -57,6 +59,8 @@ 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_more import org.meshtastic.core.resources.mesh_hud_snr +import org.meshtastic.core.resources.now +import org.meshtastic.core.resources.unknown import org.meshtastic.core.ui.component.PermissionRecoveryCard import org.meshtastic.core.ui.theme.TracerouteColors import org.meshtastic.core.ui.util.KeepScreenOn @@ -81,6 +85,10 @@ 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 +private const val MILLIS_PER_SECOND = 1_000.0 + +/** How often a hot phone moves the sparks along, rather than every frame. */ +private const val HOT_SPARK_TICK_MS = 100L /** 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 @@ -176,6 +184,20 @@ fun MeshHudScreen( } val hasSensor by remember { derivedStateOf { poseState.value.hasSensor } } val thermal by remember { derivedStateOf { poseState.value.thermal } } + // The sparks' clock, read only while drawing, so a tick redraws the canvas and recomposes nothing. It runs only + // with + // a path open: every frame on a cool phone, a few times a second on a hot one, which still shows the direction. + val sparkMillis = remember { mutableLongStateOf(0L) } + val sparksOpen = route != null || track != null + val hotTickMs = if (thermal == HudThermal.NORMAL) 0L else HOT_SPARK_TICK_MS + LaunchedEffect(sparksOpen, hotTickMs) { + if (!sparksOpen) return@LaunchedEffect + val start = withFrameMillis { it } - sparkMillis.longValue + while (true) { + if (hotTickMs > 0) delay(hotTickMs) + withFrameMillis { sparkMillis.longValue = it - start } + } + } val waitingForSensor by remember { derivedStateOf { sensorSilent && poseState.value.hasSensor && poseState.value.pose == null } } @@ -214,6 +236,7 @@ fun MeshHudScreen( skippedTemplate = stringResource(Res.string.mesh_hud_more), snrTemplate = stringResource(Res.string.mesh_hud_snr), ) + val trackLabels = TrackLabels(unknown = stringResource(Res.string.unknown), now = stringResource(Res.string.now)) val cardinals = Cardinals( north = stringResource(Res.string.mesh_hud_cardinal_north), @@ -241,7 +264,8 @@ fun MeshHudScreen( val pose = state.pose ?: return@Canvas drawHorizon(pose, focalPx) targets.fences.forEach { drawGeofence(it, pose, focalPx) } - track?.let { drawTrack(it, pose, focalPx) } + val sparkSeconds = sparkMillis.longValue / MILLIS_PER_SECOND + track?.let { drawTrack(it, pose, focalPx, textMeasurer, tapeStyle, trackLabels, sparkSeconds) } route?.let { drawRoute( it, @@ -252,6 +276,7 @@ fun MeshHudScreen( textMeasurer, tapeStyle, routeLabels, + sparkSeconds, ) } if (targets.showPrecision) { @@ -312,6 +337,7 @@ fun MeshHudScreen( targets = targets, onNavigateUp = onNavigateUp, onOpenFilters = if (targets.ready && !targets.scoped) ({ showFilters = true }) else null, + onCapture = rememberHudCapture().takeIf { showCamera && cameraAvailable }, modifier = Modifier.align(Alignment.TopStart).onPlaced { topBarBottomPx = (it.positionInParent().y + it.size.height).toInt() @@ -321,6 +347,7 @@ fun MeshHudScreen( targets = targets, hint = hint, route = route, + track = track, notices = HudNotices(cameraUnavailable = showCamera && !cameraAvailable, walking = walking), location = location, onOpenLocationSettings = openLocationSettings, 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 213f6277b56..f705721c6e3 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 @@ -138,6 +138,7 @@ private const val TRACK_FALLBACK_COLOR = 0xFFFFFFFF.toInt() class MeshHudViewModel( poseProvider: CameraPoseProvider, phoneLocationProvider: PhoneLocationProvider, + pressureProvider: PhonePressureProvider, private val magneticFieldProvider: MagneticFieldProvider, nodeRepository: NodeRepository, packetRepository: PacketRepository, @@ -155,6 +156,7 @@ 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 fixAverager = FixAverager() + private val altitudeFuser = AltitudeFuser() private val routeRequest = MutableStateFlow(null) private val soloNodeNum = MutableStateFlow(null) private var opened = false @@ -224,11 +226,24 @@ class MeshHudViewModel( ) } - /** The phone's averaged fix, shared by the targets, the route and the track. */ + /** + * The phone's averaged fix, its height fused with the barometer, shared by the targets, the route and the track. A + * pressure reading moves only the height, so it passes on only once the height has moved a little, and the targets + * are not rebuilt several times a second for a phone held still. + */ private val location = - locationPermissionGranted - .flatMapLatest { phoneLocationProvider.locationUpdates() } - .map { state -> state.location?.let { state.copy(location = fixAverager.add(it)) } ?: state } + combine( + locationPermissionGranted + .flatMapLatest { phoneLocationProvider.locationUpdates() } + .map { state -> state.location?.let { state.copy(location = fixAverager.add(it)) } ?: state }, + pressureProvider.pressureUpdates().map { it }.onStart { emit(null) }, + ) { state, hPa -> + hPa?.let(altitudeFuser::onPressure) + state.location?.let { state.copy(location = altitudeFuser.fuse(it)) } ?: state + } + .distinctUntilChanged { old, new -> + old.copy(location = null) == new.copy(location = null) && sameFix(old, new) + } .shareIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), replay = 1) /** The magnetic declination where the phone is, zero until it has a fix. */ @@ -346,7 +361,7 @@ class MeshHudViewModel( null } else { val color = nodes[source.nodeNum]?.colors?.second ?: TRACK_FALLBACK_COLOR - buildHudTrack(here, declinationAt(here), source.nodeNum, color, source.positions, nowSeconds) + buildHudTrack(here, declinationAt(here), source.nodeNum, color, source.positions) } } .flowOn(dispatchers.default) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/PhonePressureProvider.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/PhonePressureProvider.kt new file mode 100644 index 00000000000..6211f1dbae4 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/PhonePressureProvider.kt @@ -0,0 +1,24 @@ +/* + * 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 + +/** The phone's barometer, in hectopascals, while collected; it completes without a value on a phone that has none. */ +interface PhonePressureProvider { + fun pressureUpdates(): Flow +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TrackFadeTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TrackFadeTest.kt new file mode 100644 index 00000000000..edbf46bd97e --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TrackFadeTest.kt @@ -0,0 +1,59 @@ +/* + * 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 + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TrackFadeTest { + + @Test + fun `recency follows time across the track span`() { + assertEquals(listOf(0f, 0.9f, 1f), trackRecency(listOf(1_000L, 10_000L, 11_000L))) + } + + @Test + fun `a track with an unknown time fades by order`() { + assertEquals(listOf(0f, 0.5f, 1f), trackRecency(listOf(1_000L, null, 11_000L))) + assertEquals(listOf(0f, 0.5f, 1f), trackRecency(listOf(0L, 5_000L, 11_000L))) + } + + @Test + fun `a track whose fixes share one time fades by order`() { + assertEquals(listOf(0f, 1f), trackRecency(listOf(500L, 500L))) + } + + @Test + fun `a single point is fully recent`() { + assertEquals(listOf(1f), trackRecency(listOf(500L))) + assertEquals(emptyList(), trackRecency(emptyList())) + } + + @Test + fun `the fade runs from the oldest alpha to solid`() { + assertEquals(OLDEST_TRACK_ALPHA, trackFadeAlpha(0f)) + assertEquals(1f, trackFadeAlpha(1f)) + } + + @Test + fun `the newest point lands in the top level and the oldest in the bottom`() { + assertEquals(TRACK_FADE_LEVELS - 1, trackFadeLevel(1f)) + assertEquals(0, trackFadeLevel(0f)) + assertEquals(1f, trackFadeLevelAlpha(TRACK_FADE_LEVELS - 1)) + assertEquals(OLDEST_TRACK_ALPHA, trackFadeLevelAlpha(0)) + } +} diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/AltitudeFuserTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/AltitudeFuserTest.kt new file mode 100644 index 00000000000..af7213a6fca --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/AltitudeFuserTest.kt @@ -0,0 +1,115 @@ +/* + * 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.core.repository.PhoneLocationState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +private fun fix(altitude: Double?, t: Long, msl: Double? = altitude?.minus(25.0)) = + PhoneLocation(40.0, -105.0, altitude, t, mslAltitude = msl) + +/** The pressure that reads [meters] above the standard atmosphere's sea level, inverting [pressureAltitudeM]. */ +private fun hPaAt(meters: Double): Float { + var lo = 300f + var hi = 1100f + repeat(60) { + val mid = (lo + hi) / 2 + if (pressureAltitudeM(mid) > meters) lo = mid else hi = mid + } + return (lo + hi) / 2 +} + +class AltitudeFuserTest { + + @Test + fun `without a barometer the fix passes through`() { + val fix = fix(150.0, 1_000) + assertEquals(fix, AltitudeFuser().fuse(fix)) + } + + @Test + fun `the first fix sets the level and the barometer then carries every change`() { + val fuser = AltitudeFuser() + fuser.onPressure(hPaAt(300.0)) + assertEquals(150.0, assertNotNull(fuser.fuse(fix(150.0, 1_000)).altitude), 1e-6) + // Up one floor by pressure, with no new fix: the height follows at once. + fuser.onPressure(hPaAt(303.0)) + assertEquals(153.0, assertNotNull(fuser.fuse(fix(150.0, 1_000)).altitude), 0.01) + } + + @Test + fun `gps wander barely moves a height the barometer holds`() { + val fuser = AltitudeFuser() + fuser.onPressure(hPaAt(300.0)) + fuser.fuse(fix(150.0, 1_000)) + // A fix 12 m high one second later moves the fused height by about a sixtieth of that. + val fused = assertNotNull(fuser.fuse(fix(162.0, 2_000)).altitude) + assertTrue(fused - 150.0 in 0.1..0.3, "fused $fused") + } + + @Test + fun `over minutes the level settles onto gps`() { + val fuser = AltitudeFuser() + fuser.onPressure(hPaAt(300.0)) + fuser.fuse(fix(150.0, 0)) + var fused = 0.0 + for (s in 1..600) fused = assertNotNull(fuser.fuse(fix(160.0, s * 1_000L)).altitude) + assertEquals(160.0, fused, 0.01) + } + + @Test + fun `the sea level height moves with the fused one`() { + val fuser = AltitudeFuser() + fuser.onPressure(hPaAt(300.0)) + fuser.fuse(fix(150.0, 1_000)) + fuser.onPressure(hPaAt(305.0)) + val fused = fuser.fuse(fix(150.0, 1_000)) + assertEquals(assertNotNull(fused.altitude) - 25.0, assertNotNull(fused.mslAltitude), 1e-6) + } + + @Test + fun `a fix without a gps height keeps the barometer height on the last level`() { + val fuser = AltitudeFuser() + fuser.onPressure(hPaAt(300.0)) + fuser.fuse(fix(150.0, 1_000)) + fuser.onPressure(hPaAt(302.0)) + val held = fuser.fuse(fix(null, 2_000, msl = null)) + assertEquals(152.0, assertNotNull(held.altitude), 0.01) + assertEquals(127.0, assertNotNull(held.mslAltitude), 0.01) + } + + @Test + fun `before any level is set a fix without a gps height passes through`() { + val fuser = AltitudeFuser() + fuser.onPressure(hPaAt(300.0)) + val fix = fix(null, 1_000, msl = null) + assertEquals(fix, fuser.fuse(fix)) + } + + @Test + fun `a height change under a quarter meter is the same fix`() { + val a = PhoneLocationState(true, true, fix(150.0, 1_000)) + assertTrue(sameFix(a, a.copy(location = fix(150.2, 1_000)))) + assertFalse(sameFix(a, a.copy(location = fix(150.3, 1_000)))) + assertFalse(sameFix(a, a.copy(location = fix(150.0, 2_000)))) + } +} 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 b2c49b1c76a..bbcde4f42f3 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 @@ -20,6 +20,7 @@ import org.meshtastic.core.repository.PhoneLocation import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull /** About 1.1 m of latitude. */ @@ -49,6 +50,26 @@ class FixAveragerTest { assertEquals(2f, mean.accuracyM) } + @Test + fun `heights average weighted by their vertical accuracy`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000).copy(altitude = 100.0, mslAltitude = 120.0, verticalAccuracyM = 2f)) + val mean = averager.add(fix(0.0, 2_000).copy(altitude = 117.0, mslAltitude = 137.0, verticalAccuracyM = 8f)) + // Weights 1/4 and 1/64, as for position. + assertEquals(101.0, assertNotNull(mean.altitude), 1e-9) + assertEquals(121.0, assertNotNull(mean.mslAltitude), 1e-9) + assertEquals(2f, mean.verticalAccuracyM) + } + + @Test + fun `a fix without a height leaves the height average to the fixes that have one`() { + val averager = FixAverager() + averager.add(fix(0.0, 1_000).copy(altitude = 150.0)) + val mean = averager.add(fix(0.0, 2_000).copy(altitude = null)) + assertEquals(150.0, assertNotNull(mean.altitude), 1e-9) + assertNull(mean.mslAltitude) + } + @Test fun `walking restarts the average`() { val averager = FixAverager() 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 d0f1f212e22..3df095c1a15 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 @@ -18,6 +18,10 @@ package org.meshtastic.feature.map.hud import org.meshtastic.core.repository.PhoneLocation import org.meshtastic.proto.Position +import kotlin.math.PI +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.hypot import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -128,9 +132,146 @@ class HudPathsTest { 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 }) + assertEquals(listOf(100L, 300L), track.points.map { it.fixTimeSec }) assertFalse(track.points.first().altitudeKnown) } + + @Test + fun `a leg out of the viewer names its far end`() { + val near = point(atViewer = true) + val far = point(atViewer = false) + assertEquals(far, viewerLeg(near, far)) + assertEquals(far, viewerLeg(far, near)) + } + + @Test + fun `a leg between two viewer points or two far points is not a viewer leg`() { + assertNull(viewerLeg(point(atViewer = true), point(atViewer = true))) + assertNull(viewerLeg(point(atViewer = false), point(atViewer = false))) + } + + @Test + fun `a summary counts points and spans oldest to newest`() { + val summary = assertNotNull(track(100L, null, 400L).summary()) + assertEquals(3, summary.points) + assertEquals(300L, summary.spanSec) + assertEquals(400L, summary.newestFixTimeSec) + } + + @Test + fun `an empty track has no summary`() { + assertNull(HudTrack(nodeNum = 7, color = 0, points = emptyList()).summary()) + } + + @Test + fun `the oldest point is labeled and the newest is not`() { + assertEquals(listOf(0), trackLabelIndices(track(0L, 600L, 1_200L).points)) + } + + @Test + fun `a track over an hour also labels the point nearest its middle`() { + assertEquals(listOf(0, 2), trackLabelIndices(track(0L, 1_000L, 3_700L, 7_200L).points)) + } + + @Test + fun `viewer points and untimed points are never labeled`() { + val points = + listOf( + TrackPoint(point(atViewer = true), 0L, altitudeKnown = true), + TrackPoint(point(atViewer = false), null, altitudeKnown = true), + TrackPoint(point(atViewer = false), 500L, altitudeKnown = true), + TrackPoint(point(atViewer = false), 900L, altitudeKnown = true), + ) + assertEquals(listOf(2), trackLabelIndices(points)) + } + + @Test + fun `a one point track labels nothing`() { + assertTrue(trackLabelIndices(track(100L).points).isEmpty()) + } + + @Test + fun `sparks sit a spacing apart in angle from the phase across every leg`() { + val path = listOf(directionEnu(0.0, 0.0), directionEnu(45.0, 0.0), directionEnu(90.0, 0.0)) + val sparks = sparksAlong(path, phaseDeg = 10.0, spacingDeg = 30.0) + assertEquals(listOf(10.0, 40.0, 70.0), sparks.map { bearingOf(it.at) }, absoluteTolerance = 1e-6) + } + + @Test + fun `a spark looks ahead the way the path runs`() { + val spark = sparksAlong(listOf(directionEnu(0.0, 0.0), directionEnu(90.0, 0.0)), 20.0, 30.0).first() + assertTrue(bearingOf(spark.behind) < bearingOf(spark.at)) + assertTrue(bearingOf(spark.at) < bearingOf(spark.ahead)) + } + + @Test + fun `a phase one spacing on puts every spark back where it was`() { + val path = listOf(directionEnu(0.0, 0.0), directionEnu(90.0, 0.0)) + assertEquals( + sparksAlong(path, 5.0, 30.0).map { bearingOf(it.at) }, + sparksAlong(path, 35.0, 30.0).map { bearingOf(it.at) }, + absoluteTolerance = 1e-9, + ) + } + + @Test + fun `a path with no length carries no sparks`() { + assertTrue(sparksAlong(listOf(directionEnu(10.0, 0.0), directionEnu(10.0, 0.0)), 0.0, 30.0).isEmpty()) + assertTrue(sparksAlong(emptyList(), 0.0, 30.0).isEmpty()) + } + + @Test + fun `a short leg stays a straight chord between its ends`() { + val a = ground(east = 100.0, north = 1_000.0, rise = 0.0) + val b = ground(east = 120.0, north = 1_000.0, rise = 0.0) + assertEquals(2, groundDirections(a, b).size) + } + + @Test + fun `a leg passing close by follows the ground and dips toward the viewer`() { + val a = ground(east = -100.0, north = 20.0, rise = -2.0) + val b = ground(east = 100.0, north = 20.0, rise = -2.0) + val steps = groundDirections(a, b) + assertTrue(steps.size > 2) + val middle = steps[steps.size / 2] + assertEquals(0.0, bearingOf(middle), absoluteTolerance = 1e-6) + assertEquals(elevationDeg(20.0, -2.0), elevationOf(middle), absoluteTolerance = 1e-6) + assertEquals(a.bearingDeg, bearingOf(steps.first()), absoluteTolerance = 1e-6) + assertEquals(b.elevationDeg, elevationOf(steps.last()), absoluteTolerance = 1e-6) + } + + private fun ground(east: Double, north: Double, rise: Double): PathPoint { + val distance = hypot(east, north) + return PathPoint( + bearingDeg = (degrees(atan2(east, north)) + 360.0) % 360.0, + elevationDeg = elevationDeg(distance, rise), + distanceM = distance, + atViewer = false, + ) + } + + private fun degrees(radians: Double) = radians * 180.0 / PI + + private fun bearingOf(v: Vec3) = (degrees(atan2(v.x, v.y)) + 360.0) % 360.0 + + private fun elevationOf(v: Vec3) = degrees(asin(v.z)) + + private fun assertEquals(expected: List, actual: List, absoluteTolerance: Double) { + assertEquals(expected.size, actual.size) + expected.zip(actual).forEach { (e, a) -> assertEquals(e, a, absoluteTolerance) } + } + + private fun point(atViewer: Boolean) = PathPoint( + bearingDeg = if (atViewer) 0.0 else 90.0, + elevationDeg = 0.0, + distanceM = if (atViewer) 1.0 else 1_000.0, + atViewer = atViewer, + ) + + private fun track(vararg times: Long?) = HudTrack( + nodeNum = 7, + color = 0, + points = times.map { TrackPoint(point(atViewer = false), it, altitudeKnown = true) }, + ) } 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 33fe57aaf3b..d77dc450693 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 @@ -24,6 +24,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -82,6 +83,10 @@ private class FakePoseProvider : CameraPoseProvider { override fun poseUpdates(): Flow = MutableStateFlow(CameraPoseState()) } +private class NoBarometer : PhonePressureProvider { + override fun pressureUpdates(): Flow = emptyFlow() +} + private class FakePhoneLocationProvider : PhoneLocationProvider { val state = MutableStateFlow( @@ -126,6 +131,7 @@ class MeshHudViewModelTest { MeshHudViewModel( poseProvider = FakePoseProvider(), phoneLocationProvider = FakePhoneLocationProvider(), + pressureProvider = NoBarometer(), magneticFieldProvider = ZeroDeclination(), nodeRepository = nodeRepository, packetRepository = packetRepository, diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.ios.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.ios.kt new file mode 100644 index 00000000000..27c3720d30e --- /dev/null +++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.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.feature.map.hud + +import androidx.compose.runtime.Composable + +@Composable actual fun rememberHudCapture(): (() -> Unit)? = null diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.jvm.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.jvm.kt new file mode 100644 index 00000000000..27c3720d30e --- /dev/null +++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.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.feature.map.hud + +import androidx.compose.runtime.Composable + +@Composable actual fun rememberHudCapture(): (() -> Unit)? = null