From 37c023e4cfbfeaae54cae326e750aca44ffdf6ea Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:16:14 -0500 Subject: [PATCH 01/12] feat(map): save a picture of the mesh view from its top bar The viewfinder renders embedded so a window copy holds the camera feed and the overlay together. --- .../map/hud/HudCameraPreview.android.kt | 9 +- .../feature/map/hud/HudCapture.android.kt | 127 ++++++++++++++++++ .../meshtastic/feature/map/hud/HudCapture.kt | 22 +++ .../meshtastic/feature/map/hud/HudPanels.kt | 13 +- .../feature/map/hud/MeshHudScreen.kt | 1 + .../feature/map/hud/HudCapture.ios.kt | 21 +++ .../feature/map/hud/HudCapture.jvm.kt | 21 +++ 7 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.android.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.kt create mode 100644 feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.ios.kt create mode 100644 feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.jvm.kt 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..0bd9989d58a --- /dev/null +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/HudCapture.android.kt @@ -0,0 +1,127 @@ +/* + * 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.ContentValues +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.provider.MediaStore +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.rememberCoroutineScope +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView +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 java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.coroutines.resume + +private const val PICTURES_RELATIVE_PATH = "Pictures/Meshtastic" +private const val JPEG_QUALITY = 92 +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) + return { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + scope.launch { + 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() + } + } +} + +/** + * 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 -> + PixelCopy.request( + window, + bitmap, + { result -> continuation.resume(result == PixelCopy.SUCCESS) }, + Handler(Looper.getMainLooper()), + ) + } + return if (copied) withContext(io) { saveToPictures(decor.context, bitmap, FILE_TITLE) } else null +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + 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 = resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), pending) + val written = + uri != null && + runCatching { + resolver.openOutputStream(uri)?.use { bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, it) } + } + .getOrNull() == true + // A row left pending would sit hidden in the gallery, so a failed write removes it. + if (written) { + resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) + } else if (uri != null) { + resolver.delete(uri, null, null) + } + return uri.takeIf { written } +} 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/HudPanels.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPanels.kt index 9dca6cac374..2fbc01aa418 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 @@ -54,6 +54,7 @@ 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.img_chirpy import org.meshtastic.core.resources.map_filter_title import org.meshtastic.core.resources.mesh_hud_camera_unavailable @@ -91,6 +92,7 @@ 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 @@ -120,13 +122,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 +162,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) + } + } } } 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..e083145e62b 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 @@ -312,6 +312,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() 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 From b9f3de27499a7c0dd51b9e24f98e65a09c36c0ef Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:31:23 -0500 Subject: [PATCH 02/12] feat(map): mesh view track shows its span, tags its oldest fixes, and leaves the viewer from the bottom The bottom panel sums the track up and says when the window holds no positions; legs at the viewer draw from the bottom of the view, as route links do. --- .skills/compose-ui/strings-index.txt | 4 + .../composeResources/values/strings.xml | 7 ++ .../meshtastic/feature/map/hud/HudPanels.kt | 43 ++++++++++- .../meshtastic/feature/map/hud/HudPaths.kt | 55 ++++++++++++- .../feature/map/hud/HudPathsDraw.kt | 77 +++++++++++++++---- .../feature/map/hud/MeshHudScreen.kt | 6 +- .../feature/map/hud/MeshHudViewModel.kt | 2 +- .../feature/map/hud/HudPathsTest.kt | 70 ++++++++++++++++- 8 files changed, 242 insertions(+), 22 deletions(-) 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/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/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 2fbc01aa418..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 @@ -55,6 +56,7 @@ 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 @@ -80,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 @@ -96,11 +102,14 @@ 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. */ @@ -196,6 +205,7 @@ internal fun HudBottomPanel( targets: MeshHudTargets, hint: FinderHint?, route: HudRoute?, + track: HudTrack?, notices: HudNotices, location: PermissionUiState, onOpenLocationSettings: () -> Unit, @@ -213,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) @@ -227,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()) { @@ -335,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/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..6113c1d9aa0 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 @@ -31,6 +31,7 @@ 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 private const val SNR_FLOOR_DB = -20f private const val SNR_SPAN_DB = 30f @@ -50,6 +51,8 @@ private val ArcExtraWidth = 4.dp private val TrackWidth = 3.dp private val TrackRing = 4.dp 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 +78,27 @@ 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) +} + /** 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) @@ -120,21 +141,21 @@ 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 + drawFromViewer(far, pose, focalPx, stroke, width, dashed = false) val end = screen(directionEnu(far.bearingDeg, far.elevationDeg), pose, focalPx) if (end != null) { - drawLine(stroke, viewer, end, width, StrokeCap.Round) // Toward the hop, so the tag stays clear of the bottom panel and the reticle. val along = if (leg.forward) FORWARD_STEM_TAG_AT else RETURN_STEM_TAG_AT + val viewer = Offset(size.width / 2, size.height) tag?.let { drawTag(it, lerp(viewer, end, along), textMeasurer, labelStyle) } } } else if (!start.atViewer) { @@ -151,9 +172,19 @@ internal fun DrawScope.drawRoute( /** * 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. + * two views agree. 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, +) { val points = track.points if (points.isEmpty()) return val base = Color(track.color) @@ -163,14 +194,21 @@ internal fun DrawScope.drawTrack(track: HudTrack, pose: CameraPose, focalPx: Flo } 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, - ) + val color = base.copy(alpha = alphaAt(i + 1)) + val far = viewerLeg(a.point, b.point) + when { + far != null -> drawFromViewer(far, pose, focalPx, color, TrackWidth.toPx(), dashed = !b.altitudeKnown) + + !a.point.atViewer -> + drawPolyline( + listOf(a.direction(), b.direction()), + pose, + focalPx, + color, + TrackWidth.toPx(), + dashed = !b.altitudeKnown, + ) + } } points.forEachIndexed { i, p -> if (p.point.atViewer) return@forEachIndexed @@ -178,4 +216,15 @@ internal fun DrawScope.drawTrack(track: HudTrack, pose: CameraPose, focalPx: Flo drawCircle(base.copy(alpha = alphaAt(i)), TrackRing.toPx(), at, style = Stroke(2.dp.toPx())) } } + 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/MeshHudScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudScreen.kt index e083145e62b..e8c27dcc2c6 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 @@ -57,6 +57,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 @@ -214,6 +216,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 +244,7 @@ fun MeshHudScreen( val pose = state.pose ?: return@Canvas drawHorizon(pose, focalPx) targets.fences.forEach { drawGeofence(it, pose, focalPx) } - track?.let { drawTrack(it, pose, focalPx) } + track?.let { drawTrack(it, pose, focalPx, textMeasurer, tapeStyle, trackLabels) } route?.let { drawRoute( it, @@ -322,6 +325,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..4d83e971552 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 @@ -346,7 +346,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/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/HudPathsTest.kt index d0f1f212e22..c30e5825e58 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 @@ -128,9 +128,75 @@ 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()) + } + + 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) }, + ) } From 716e5b585f031224baf984e150de3e32487a1890 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:37:44 -0500 Subject: [PATCH 03/12] feat(map): tracks fade by the time of each fix, on both maps and the mesh view One fade in feature:map, measured across the track's own span and falling back to order when a time is missing, replaces four copies that faded by order. --- .../kotlin/org/meshtastic/app/map/MapView.kt | 40 +++++------ .../feature/map/maplibre/NodeTrackMap.kt | 68 +++++++++++++------ .../map/maplibre/TrackLineStopsTest.kt | 49 +++++++++++++ .../org/meshtastic/feature/map/TrackFade.kt | 53 +++++++++++++++ .../feature/map/hud/HudPathsDraw.kt | 18 +++-- .../feature/map/hud/HudRadarPaths.kt | 6 +- .../meshtastic/feature/map/TrackFadeTest.kt | 59 ++++++++++++++++ 7 files changed, 240 insertions(+), 53 deletions(-) create mode 100644 feature/map-maplibre/src/commonTest/kotlin/org/meshtastic/feature/map/maplibre/TrackLineStopsTest.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/TrackFade.kt create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/TrackFadeTest.kt 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/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..41bec259746 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. @@ -230,6 +259,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 +270,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 +286,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 +335,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/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/HudPathsDraw.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt index 6113c1d9aa0..2424537c6fa 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 @@ -32,12 +32,13 @@ 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 @@ -45,7 +46,6 @@ 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 @@ -171,10 +171,10 @@ internal fun DrawScope.drawRoute( } /** - * 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 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. + * 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. */ @Suppress("LongParameterList") internal fun DrawScope.drawTrack( @@ -188,10 +188,8 @@ internal fun DrawScope.drawTrack( 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) - } + 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) -> val color = base.copy(alpha = alphaAt(i + 1)) 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/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)) + } +} From 19557de2bf3ea8ab6bfc20e96a76c5f1d0f0ed7c Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:53:17 -0500 Subject: [PATCH 04/12] feat(navigation): deep links open the mesh view for a node, its track, or the whole mesh `/nodes/{n}/mesh-view`, `/nodes/{n}/position/mesh-view` and `/map/mesh-view`, each over the screens its button lives on. --- .../core/navigation/DeepLinkRouter.kt | 12 +++++++ .../core/navigation/DeepLinkRouterTest.kt | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+) 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( From 62839a85d3b0edbff898b6c7ea3371d7e0647a22 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:53:17 -0500 Subject: [PATCH 05/12] fix(map): mesh view track rides on an outline of the opposite shade A dark node color vanished against a dark scene, worst on the faint old end of a track. --- .../feature/map/hud/HudPathsDraw.kt | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt index 2424537c6fa..8f880f1b772 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,6 +25,7 @@ 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 @@ -50,6 +51,9 @@ 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 LabelPadding = 4.dp private val TagLift = 16.dp private val TagMinGap = 48.dp @@ -188,14 +192,16 @@ internal fun DrawScope.drawTrack( val points = track.points if (points.isEmpty()) return val base = Color(track.color) + // 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 = if (base.luminance() > CASING_LUMINANCE_SPLIT) Color.Black else Color.White 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) -> - val color = base.copy(alpha = alphaAt(i + 1)) + fun leg(a: TrackPoint, b: TrackPoint, color: Color, width: Float) { val far = viewerLeg(a.point, b.point) when { - far != null -> drawFromViewer(far, pose, focalPx, color, TrackWidth.toPx(), dashed = !b.altitudeKnown) + far != null -> drawFromViewer(far, pose, focalPx, color, width, dashed = !b.altitudeKnown) !a.point.atViewer -> drawPolyline( @@ -203,15 +209,25 @@ internal fun DrawScope.drawTrack( pose, focalPx, color, - TrackWidth.toPx(), + width, dashed = !b.altitudeKnown, ) } } + val casingWidth = TrackWidth.toPx() + 2 * TrackCasing.toPx() + points.zipWithNext().forEachIndexed { i, (a, b) -> leg(a, b, casing.copy(alpha = alphaAt(i + 1)), casingWidth) } + points.zipWithNext().forEachIndexed { i, (a, b) -> leg(a, b, base.copy(alpha = alphaAt(i + 1)), TrackWidth.toPx()) } 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() From 69dae45ae0e4be027c8f8baea2d25e475b54b975 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:05:53 -0500 Subject: [PATCH 06/12] feat(map): sparks run along mesh view tracks and traceroutes in their direction of travel As in the Chirpy Hop backdrop: sparks spaced by length on screen, flowing oldest to newest along a track and hop by hop along each traceroute leg. They hold still while the phone is hot. --- .../feature/map/hud/HudPathsDraw.kt | 120 ++++++++++++++---- .../feature/map/hud/MeshHudScreen.kt | 17 ++- .../feature/map/hud/HudPathsTest.kt | 28 ++++ 3 files changed, 142 insertions(+), 23 deletions(-) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt index 8f880f1b772..3dbe773a308 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 @@ -54,6 +54,14 @@ 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 +private val SparkSpacing = 120.dp + +/** How far a spark travels in a second, on screen. */ +private val SparkSpeed = 90.dp +private const val SPARK_GLOW_ALPHA = 0.6f private val LabelPadding = 4.dp private val TagLift = 16.dp private val TagMinGap = 48.dp @@ -64,7 +72,10 @@ private val Dashes = floatArrayOf(18f, 12f) private fun DrawScope.screen(direction: Vec3, pose: CameraPose, focalPx: Float): Offset? = project(direction, pose, focalPx, size.width, size.height)?.let { Offset(it.x, it.y) } -/** Draws the polyline through [directions], clipping each piece at the near plane so nothing wraps around the view. */ +/** + * Draws the polyline through [directions], clipping each piece at the near plane so nothing wraps around the view. + * Returns the pieces as drawn on screen, in order, null where a piece was behind the camera. + */ private fun DrawScope.drawPolyline( directions: List, pose: CameraPose, @@ -72,17 +83,21 @@ private fun DrawScope.drawPolyline( color: Color, width: Float, dashed: Boolean, -) { +): List?> { val effect = if (dashed) PathEffect.dashPathEffect(Dashes) else null - directions.zipWithNext { a, b -> - val (ca, cb) = clipToNearPlane(a, b, pose) ?: return@zipWithNext - val pa = screen(ca, pose, focalPx) ?: return@zipWithNext - val pb = screen(cb, pose, focalPx) ?: return@zipWithNext + return directions.zipWithNext { a, b -> + val (ca, cb) = clipToNearPlane(a, b, pose) ?: return@zipWithNext null + val pa = screen(ca, pose, focalPx) ?: return@zipWithNext null + val pb = screen(cb, pose, focalPx) ?: return@zipWithNext null drawLine(color, pa, pb, strokeWidth = width, cap = StrokeCap.Round, pathEffect = effect) + pa to pb } } -/** A straight line from the bottom center of the view, where the viewer stands, to [far]. */ +/** + * A straight line from the bottom center of the view, where the viewer stands, to [far]. Returns it as drawn, oriented + * away from the viewer when [leaving] and toward it otherwise, or null when [far] is behind the camera. + */ @Suppress("LongParameterList") private fun DrawScope.drawFromViewer( far: PathPoint, @@ -91,10 +106,56 @@ private fun DrawScope.drawFromViewer( color: Color, width: Float, dashed: Boolean, -) { - val end = screen(directionEnu(far.bearingDeg, far.elevationDeg), pose, focalPx) ?: return + leaving: Boolean, +): Pair? { + val end = screen(directionEnu(far.bearingDeg, far.elevationDeg), pose, focalPx) ?: return null + val viewer = Offset(size.width / 2, size.height) val effect = if (dashed) PathEffect.dashPathEffect(Dashes) else null - drawLine(color, Offset(size.width / 2, size.height), end, width, StrokeCap.Round, pathEffect = effect) + drawLine(color, viewer, end, width, StrokeCap.Round, pathEffect = effect) + return if (leaving) viewer to end else end to viewer +} + +/** 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 + +/** A spark on a path: where it is, and the unit direction it travels. */ +internal data class Spark(val at: Offset, val heading: Offset) + +/** + * Sparks spaced [spacingPx] apart along the pieces of a path drawn on screen, in path order, all shifted [phasePx] + * forward so that a growing phase moves them along it. Spacing is by length on screen, so a dense track carries no more + * sparks than a sparse one of the same size. A gap between pieces is skipped, not crossed. + */ +internal fun sparksAlong(pieces: List>, phasePx: Float, spacingPx: Float): List { + if (spacingPx <= 0f) return emptyList() + val sparks = mutableListOf() + var next = phasePx.mod(spacingPx) + var walked = 0f + pieces.forEach { (from, to) -> + val length = (to - from).getDistance() + if (length == 0f) return@forEach + val heading = (to - from) / length + while (next <= walked + length) { + sparks += Spark(from + heading * (next - walked), heading) + next += spacingPx + } + walked += length + } + return sparks +} + +/** + * Packets in flight along a 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]. + */ +private fun DrawScope.drawSparks(pieces: List>, seconds: Double, core: Color, glow: Color) { + val tail = SparkTail.toPx() + sparksAlong(pieces, (seconds * SparkSpeed.toPx()).toFloat(), SparkSpacing.toPx()).forEach { (at, heading) -> + drawLine(glow, at - heading * tail, at, SparkGlow.toPx(), StrokeCap.Round) + drawCircle(glow, SparkGlow.toPx(), at) + drawCircle(core, SparkCore.toPx(), at) + } } /** The localized patterns for a route's apex tags, read in composition since drawing cannot reach resources. */ @@ -129,10 +190,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 as drawn, hop by hop, for its sparks to run along once every link is down. + val pieces = mutableListOf>() var from: Int? = null leg.points.forEachIndexed { index, point -> if (point == null) return@forEachIndexed @@ -154,24 +219,25 @@ internal fun DrawScope.drawRoute( } if (far != null) { // A link to or from the phone itself: the forward leg leaves the viewer, the return leg comes home. - drawFromViewer(far, pose, focalPx, stroke, width, dashed = false) - val end = screen(directionEnu(far.bearingDeg, far.elevationDeg), pose, focalPx) - if (end != null) { + drawFromViewer(far, pose, focalPx, stroke, width, dashed = false, leaving = start.atViewer)?.let { + pieces += it // Toward the hop, so the tag stays clear of the bottom panel and the reticle. + val end = if (start.atViewer) it.second else it.first val along = if (leg.forward) FORWARD_STEM_TAG_AT else RETURN_STEM_TAG_AT - val viewer = Offset(size.width / 2, size.height) - tag?.let { drawTag(it, lerp(viewer, end, along), textMeasurer, labelStyle) } + tag?.let { text -> tags += text 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) + pieces += drawPolyline(arc, pose, focalPx, stroke, width, dashed = skipped > 0).filterNotNull() 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(pieces, sparkSeconds, core = contrastOn(color), glow = color.copy(alpha = SPARK_GLOW_ALPHA)) } + tags.forEach { (text, at) -> drawTag(text, at, textMeasurer, labelStyle) } } /** @@ -188,20 +254,24 @@ internal fun DrawScope.drawTrack( textMeasurer: TextMeasurer, labelStyle: TextStyle, labels: TrackLabels, + sparkSeconds: Double, ) { val points = track.points if (points.isEmpty()) return val base = Color(track.color) // 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 = if (base.luminance() > CASING_LUMINANCE_SPLIT) Color.Black else Color.White + 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) - fun leg(a: TrackPoint, b: TrackPoint, color: Color, width: Float) { + + // The leg from older a to newer b as drawn on screen, oriented that way, or null where none of it shows. + fun leg(a: TrackPoint, b: TrackPoint, color: Color, width: Float): Pair? { val far = viewerLeg(a.point, b.point) - when { - far != null -> drawFromViewer(far, pose, focalPx, color, width, dashed = !b.altitudeKnown) + return when { + far != null -> + drawFromViewer(far, pose, focalPx, color, width, dashed = !b.altitudeKnown, leaving = a.point.atViewer) !a.point.atViewer -> drawPolyline( @@ -212,11 +282,17 @@ internal fun DrawScope.drawTrack( width, dashed = !b.altitudeKnown, ) + .singleOrNull() + + else -> null } } val casingWidth = TrackWidth.toPx() + 2 * TrackCasing.toPx() points.zipWithNext().forEachIndexed { i, (a, b) -> leg(a, b, casing.copy(alpha = alphaAt(i + 1)), casingWidth) } - points.zipWithNext().forEachIndexed { i, (a, b) -> leg(a, b, base.copy(alpha = alphaAt(i + 1)), TrackWidth.toPx()) } + val drawn = + points.zipWithNext().mapIndexed { i, (a, b) -> leg(a, b, base.copy(alpha = alphaAt(i + 1)), TrackWidth.toPx()) } + // Older to newer, the core in the outline's shade so it stands off the line it runs along. + drawSparks(drawn.filterNotNull(), sparkSeconds, core = casing, glow = base.copy(alpha = SPARK_GLOW_ALPHA)) points.forEachIndexed { i, p -> if (p.point.atViewer) return@forEachIndexed screen(p.direction(), pose, focalPx)?.let { at -> 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 e8c27dcc2c6..aeb43fa9bab 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 @@ -83,6 +85,7 @@ 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 /** 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 @@ -178,6 +181,16 @@ 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 frame redraws the canvas and recomposes nothing. It runs only + // with a path open and a cool phone; a hot phone keeps its sparks where they stopped. + val sparkMillis = remember { mutableLongStateOf(0L) } + val animateSparks = (route != null || track != null) && thermal == HudThermal.NORMAL + LaunchedEffect(animateSparks) { + if (animateSparks) { + val start = withFrameMillis { it } - sparkMillis.longValue + while (true) withFrameMillis { sparkMillis.longValue = it - start } + } + } val waitingForSensor by remember { derivedStateOf { sensorSilent && poseState.value.hasSensor && poseState.value.pose == null } } @@ -244,7 +257,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, textMeasurer, tapeStyle, trackLabels) } + val sparkSeconds = sparkMillis.longValue / MILLIS_PER_SECOND + track?.let { drawTrack(it, pose, focalPx, textMeasurer, tapeStyle, trackLabels, sparkSeconds) } route?.let { drawRoute( it, @@ -255,6 +269,7 @@ fun MeshHudScreen( textMeasurer, tapeStyle, routeLabels, + sparkSeconds, ) } if (targets.showPrecision) { 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 c30e5825e58..1f9406e94b8 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 @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.map.hud +import androidx.compose.ui.geometry.Offset import org.meshtastic.core.repository.PhoneLocation import org.meshtastic.proto.Position import kotlin.test.Test @@ -187,6 +188,33 @@ class HudPathsTest { assertTrue(trackLabelIndices(track(100L).points).isEmpty()) } + @Test + fun `sparks sit a spacing apart from the phase and head along the path`() { + val pieces = listOf(Offset(0f, 0f) to Offset(100f, 0f), Offset(100f, 0f) to Offset(100f, 100f)) + val sparks = sparksAlong(pieces, phasePx = 10f, spacingPx = 50f) + assertEquals( + listOf(Offset(10f, 0f), Offset(60f, 0f), Offset(100f, 10f), Offset(100f, 60f)), + sparks.map { it.at }, + ) + assertEquals(listOf(Offset(1f, 0f), Offset(1f, 0f), Offset(0f, 1f), Offset(0f, 1f)), sparks.map { it.heading }) + } + + @Test + fun `a growing phase moves every spark forward and wraps at the spacing`() { + val pieces = listOf(Offset(0f, 0f) to Offset(100f, 0f)) + assertEquals( + sparksAlong(pieces, phasePx = 5f, spacingPx = 50f), + sparksAlong(pieces, phasePx = 55f, spacingPx = 50f), + ) + assertEquals(Offset(25f, 0f), sparksAlong(pieces, phasePx = 25f, spacingPx = 50f).first().at) + } + + @Test + fun `a path with no length carries no sparks`() { + assertTrue(sparksAlong(listOf(Offset(3f, 3f) to Offset(3f, 3f)), phasePx = 0f, spacingPx = 50f).isEmpty()) + assertTrue(sparksAlong(emptyList(), phasePx = 0f, spacingPx = 50f).isEmpty()) + } + private fun point(atViewer: Boolean) = PathPoint( bearingDeg = if (atViewer) 0.0 else 90.0, elevationDeg = 0.0, From 6243b9ec40fd812791ad8807a7c923f7f48ecb2a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:21:07 -0500 Subject: [PATCH 07/12] fix(map): mesh view sparks ride the path in the world, and track legs follow the ground Sparks were spaced by length on screen, so turning the phone slid them along the path; they now sit by angle along it, fewer and slower. A long track leg is stepped along the ground instead of cutting a chord between its ends. --- .../meshtastic/feature/map/hud/HudGeometry.kt | 5 + .../feature/map/hud/HudPathSampling.kt | 111 +++++++++++++ .../feature/map/hud/HudPathsDraw.kt | 150 ++++++++---------- .../feature/map/hud/HudPathsTest.kt | 79 +++++++-- 4 files changed, 246 insertions(+), 99 deletions(-) create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathSampling.kt 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..4b5497c86d1 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 @@ -24,6 +24,7 @@ import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.max import kotlin.math.sin +import kotlin.math.tan private const val DEGREES_PER_RADIAN = 180.0 / PI @@ -96,6 +97,10 @@ fun elevationDeg(distanceM: Double, riseM: Double?): Double { return atan2((riseM ?: 0.0) - drop, distanceM) * DEGREES_PER_RADIAN } +/** The rise that [elevationDeg] turns into [elevationDeg] at [distanceM]: its inverse. */ +fun riseM(distanceM: Double, elevationDeg: Double): Double = + distanceM * tan(elevationDeg.toRadians()) + distanceM * distanceM / (2 * EFFECTIVE_EARTH_RADIUS_M) + /** A target with no shared altitude frame could sit anywhere in a band this tall either side of level. */ private const val UNKNOWN_ELEVATION_SPREAD_DEG = 4.0 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..1f05b7373ea --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathSampling.kt @@ -0,0 +1,111 @@ +/* + * 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 + +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)) + } +} + +/** 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/HudPathsDraw.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudPathsDraw.kt index 3dbe773a308..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 @@ -57,10 +57,10 @@ private const val CASING_LUMINANCE_SPLIT = 0.5f private val SparkCore = 2.5.dp private val SparkGlow = 5.dp private val SparkTail = 14.dp -private val SparkSpacing = 120.dp -/** How far a spark travels in a second, on screen. */ -private val SparkSpeed = 90.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 @@ -72,10 +72,7 @@ private val Dashes = floatArrayOf(18f, 12f) private fun DrawScope.screen(direction: Vec3, pose: CameraPose, focalPx: Float): Offset? = project(direction, pose, focalPx, size.width, size.height)?.let { Offset(it.x, it.y) } -/** - * Draws the polyline through [directions], clipping each piece at the near plane so nothing wraps around the view. - * Returns the pieces as drawn on screen, in order, null where a piece was behind the camera. - */ +/** Draws the polyline through [directions], clipping each piece at the near plane so nothing wraps around the view. */ private fun DrawScope.drawPolyline( directions: List, pose: CameraPose, @@ -83,21 +80,17 @@ private fun DrawScope.drawPolyline( color: Color, width: Float, dashed: Boolean, -): List?> { +) { val effect = if (dashed) PathEffect.dashPathEffect(Dashes) else null - return directions.zipWithNext { a, b -> - val (ca, cb) = clipToNearPlane(a, b, pose) ?: return@zipWithNext null - val pa = screen(ca, pose, focalPx) ?: return@zipWithNext null - val pb = screen(cb, pose, focalPx) ?: return@zipWithNext null + directions.zipWithNext { a, b -> + val (ca, cb) = clipToNearPlane(a, b, pose) ?: return@zipWithNext + val pa = screen(ca, pose, focalPx) ?: return@zipWithNext + val pb = screen(cb, pose, focalPx) ?: return@zipWithNext drawLine(color, pa, pb, strokeWidth = width, cap = StrokeCap.Round, pathEffect = effect) - pa to pb } } -/** - * A straight line from the bottom center of the view, where the viewer stands, to [far]. Returns it as drawn, oriented - * away from the viewer when [leaving] and toward it otherwise, or null when [far] is behind the camera. - */ +/** A straight line from the bottom center of the view, where the viewer stands, to [far]. */ @Suppress("LongParameterList") private fun DrawScope.drawFromViewer( far: PathPoint, @@ -106,58 +99,52 @@ private fun DrawScope.drawFromViewer( color: Color, width: Float, dashed: Boolean, - leaving: Boolean, -): Pair? { - val end = screen(directionEnu(far.bearingDeg, far.elevationDeg), pose, focalPx) ?: return null - val viewer = Offset(size.width / 2, size.height) +) { + val end = screen(directionEnu(far.bearingDeg, far.elevationDeg), pose, focalPx) ?: return val effect = if (dashed) PathEffect.dashPathEffect(Dashes) else null - drawLine(color, viewer, end, width, StrokeCap.Round, pathEffect = effect) - return if (leaving) viewer to end else end to viewer + 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 -/** A spark on a path: where it is, and the unit direction it travels. */ -internal data class Spark(val at: Offset, val heading: Offset) - /** - * Sparks spaced [spacingPx] apart along the pieces of a path drawn on screen, in path order, all shifted [phasePx] - * forward so that a growing phase moves them along it. Spacing is by length on screen, so a dense track carries no more - * sparks than a sparse one of the same size. A gap between pieces is skipped, not crossed. - */ -internal fun sparksAlong(pieces: List>, phasePx: Float, spacingPx: Float): List { - if (spacingPx <= 0f) return emptyList() - val sparks = mutableListOf() - var next = phasePx.mod(spacingPx) - var walked = 0f - pieces.forEach { (from, to) -> - val length = (to - from).getDistance() - if (length == 0f) return@forEach - val heading = (to - from) / length - while (next <= walked + length) { - sparks += Spark(from + heading * (next - walked), heading) - next += spacingPx - } - walked += length - } - return sparks -} - -/** - * Packets in flight along a path, after the Chirpy Hop backdrop's hops: each spark a bright core over a halo in the + * 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]. */ -private fun DrawScope.drawSparks(pieces: List>, seconds: Double, core: Color, glow: Color) { +@Suppress("LongParameterList") +private fun DrawScope.drawSparks( + path: List, + seconds: Double, + pose: CameraPose, + focalPx: Float, + core: Color, + glow: Color, +) { val tail = SparkTail.toPx() - sparksAlong(pieces, (seconds * SparkSpeed.toPx()).toFloat(), SparkSpacing.toPx()).forEach { (at, heading) -> - drawLine(glow, at - heading * tail, at, SparkGlow.toPx(), StrokeCap.Round) + 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) @@ -196,8 +183,8 @@ internal fun DrawScope.drawRoute( 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 as drawn, hop by hop, for its sparks to run along once every link is down. - val pieces = mutableListOf>() + // 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 @@ -219,23 +206,24 @@ internal fun DrawScope.drawRoute( } if (far != null) { // A link to or from the phone itself: the forward leg leaves the viewer, the return leg comes home. - drawFromViewer(far, pose, focalPx, stroke, width, dashed = false, leaving = start.atViewer)?.let { - pieces += it + 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 end = if (start.atViewer) it.second else it.first val along = if (leg.forward) FORWARD_STEM_TAG_AT else RETURN_STEM_TAG_AT - tag?.let { text -> tags += text to lerp(Offset(size.width / 2, size.height), end, along) } + tag?.let { tags += it to lerp(Offset(size.width / 2, size.height), end, along) } } } else if (!start.atViewer) { val arc = arcDirections(start, point, liftSign) - pieces += drawPolyline(arc, pose, focalPx, stroke, width, dashed = skipped > 0).filterNotNull() + 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 != null) tags += tag to apex } } from = index } - drawSparks(pieces, sparkSeconds, core = contrastOn(color), glow = color.copy(alpha = SPARK_GLOW_ALPHA)) + drawSparks(path, sparkSeconds, pose, focalPx, contrastOn(color), color.copy(alpha = SPARK_GLOW_ALPHA)) } tags.forEach { (text, at) -> drawTag(text, at, textMeasurer, labelStyle) } } @@ -266,33 +254,29 @@ internal fun DrawScope.drawTrack( fun alphaAt(index: Int): Float = trackFadeAlpha(recency[index]) fun TrackPoint.direction() = directionEnu(point.bearingDeg, point.elevationDeg) - // The leg from older a to newer b as drawn on screen, oriented that way, or null where none of it shows. - fun leg(a: TrackPoint, b: TrackPoint, color: Color, width: Float): Pair? { + // 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) - return when { - far != null -> - drawFromViewer(far, pose, focalPx, color, width, dashed = !b.altitudeKnown, leaving = a.point.atViewer) - - !a.point.atViewer -> - drawPolyline( - listOf(a.direction(), b.direction()), - pose, - focalPx, - color, - width, - dashed = !b.altitudeKnown, - ) - .singleOrNull() + 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) - else -> null + legs[i].isNotEmpty() -> + drawPolyline(legs[i], pose, focalPx, color(i + 1), width, dashed = !b.altitudeKnown) } } - val casingWidth = TrackWidth.toPx() + 2 * TrackCasing.toPx() - points.zipWithNext().forEachIndexed { i, (a, b) -> leg(a, b, casing.copy(alpha = alphaAt(i + 1)), casingWidth) } - val drawn = - points.zipWithNext().mapIndexed { i, (a, b) -> leg(a, b, base.copy(alpha = alphaAt(i + 1)), TrackWidth.toPx()) } + 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. - drawSparks(drawn.filterNotNull(), sparkSeconds, core = casing, glow = base.copy(alpha = SPARK_GLOW_ALPHA)) + 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 -> 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 1f9406e94b8..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 @@ -16,9 +16,12 @@ */ package org.meshtastic.feature.map.hud -import androidx.compose.ui.geometry.Offset 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 @@ -189,30 +192,74 @@ class HudPathsTest { } @Test - fun `sparks sit a spacing apart from the phase and head along the path`() { - val pieces = listOf(Offset(0f, 0f) to Offset(100f, 0f), Offset(100f, 0f) to Offset(100f, 100f)) - val sparks = sparksAlong(pieces, phasePx = 10f, spacingPx = 50f) - assertEquals( - listOf(Offset(10f, 0f), Offset(60f, 0f), Offset(100f, 10f), Offset(100f, 60f)), - sparks.map { it.at }, - ) - assertEquals(listOf(Offset(1f, 0f), Offset(1f, 0f), Offset(0f, 1f), Offset(0f, 1f)), sparks.map { it.heading }) + 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 growing phase moves every spark forward and wraps at the spacing`() { - val pieces = listOf(Offset(0f, 0f) to Offset(100f, 0f)) + 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(pieces, phasePx = 5f, spacingPx = 50f), - sparksAlong(pieces, phasePx = 55f, spacingPx = 50f), + sparksAlong(path, 5.0, 30.0).map { bearingOf(it.at) }, + sparksAlong(path, 35.0, 30.0).map { bearingOf(it.at) }, + absoluteTolerance = 1e-9, ) - assertEquals(Offset(25f, 0f), sparksAlong(pieces, phasePx = 25f, spacingPx = 50f).first().at) } @Test fun `a path with no length carries no sparks`() { - assertTrue(sparksAlong(listOf(Offset(3f, 3f) to Offset(3f, 3f)), phasePx = 0f, spacingPx = 50f).isEmpty()) - assertTrue(sparksAlong(emptyList(), phasePx = 0f, spacingPx = 50f).isEmpty()) + 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( From 4b18b1540c51e6bcb502e7b5eec995e01a4fa164 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:23:45 -0500 Subject: [PATCH 08/12] fix(map): mesh view sparks keep moving on a hot phone, at a few ticks a second Frozen sparks only shifted as the view turned, which read as motion tied to the camera. --- .../feature/map/hud/MeshHudScreen.kt | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) 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 aeb43fa9bab..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 @@ -87,6 +87,9 @@ private const val FULL_CIRCLE_DEG = 360.0 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 @@ -181,14 +184,18 @@ 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 frame redraws the canvas and recomposes nothing. It runs only - // with a path open and a cool phone; a hot phone keeps its sparks where they stopped. + // 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 animateSparks = (route != null || track != null) && thermal == HudThermal.NORMAL - LaunchedEffect(animateSparks) { - if (animateSparks) { - val start = withFrameMillis { it } - sparkMillis.longValue - while (true) withFrameMillis { sparkMillis.longValue = it - start } + 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 { From f29ac44399440660a94bbffd47ce5b99750b3f10 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:59:56 -0500 Subject: [PATCH 09/12] feat(map): mesh view averages its height and fuses it with the barometer GPS height now averages like the position, weighted by vertical accuracy. Where the phone has a barometer, pressure carries every change in height and GPS settles the level over a minute. --- .../desktop/di/DesktopStubsModule.kt | 4 + .../meshtastic/desktop/stub/CompassStubs.kt | 7 ++ .../map/hud/AndroidPhonePressureProvider.kt | 50 ++++++++++ .../feature/map/hud/AltitudeFuser.kt | 94 ++++++++++++++++++ .../meshtastic/feature/map/hud/FixAverager.kt | 23 +++++ .../feature/map/hud/MeshHudViewModel.kt | 23 ++++- .../feature/map/hud/PhonePressureProvider.kt | 24 +++++ .../feature/map/hud/AltitudeFuserTest.kt | 96 +++++++++++++++++++ .../feature/map/hud/FixAveragerTest.kt | 21 ++++ .../feature/map/hud/MeshHudViewModelTest.kt | 6 ++ 10 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/hud/AndroidPhonePressureProvider.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/AltitudeFuser.kt create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/PhonePressureProvider.kt create mode 100644 feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/AltitudeFuserTest.kt 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/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/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..4b72175ca41 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/AltitudeFuser.kt @@ -0,0 +1,94 @@ +/* + * 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. + */ +internal class AltitudeFuser { + private var baroM: Double? = null + private var offsetM: Double? = null + private var lastFixMillis: Long? = 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 + if (baro == null || gps == null) return fix + 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/MeshHudViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/MeshHudViewModel.kt index 4d83e971552..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. */ 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/hud/AltitudeFuserTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/AltitudeFuserTest.kt new file mode 100644 index 00000000000..40b65fcb8ca --- /dev/null +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/hud/AltitudeFuserTest.kt @@ -0,0 +1,96 @@ +/* + * 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 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/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, From 5034357024f7f7d097a7c3c24ac79e8c5107766f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:23:35 -0500 Subject: [PATCH 10/12] refactor(ui): one Pictures saver for the AR viewer and the mesh view --- .../org/meshtastic/app/node/ArCapture.kt | 39 +----------- .../meshtastic/core/ui/util/PicturesStore.kt | 63 +++++++++++++++++++ .../feature/map/hud/HudCapture.android.kt | 39 +----------- 3 files changed, 68 insertions(+), 73 deletions(-) create mode 100644 core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PicturesStore.kt 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/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..9a9f61ffc3d --- /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/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 index 0bd9989d58a..f23e801a815 100644 --- 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 @@ -17,7 +17,6 @@ package org.meshtastic.feature.map.hud import android.app.Activity -import android.content.ContentValues import android.content.Context import android.content.ContextWrapper import android.graphics.Bitmap @@ -25,7 +24,6 @@ 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.Window import android.widget.Toast @@ -45,13 +43,9 @@ 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 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 private const val FILE_TITLE = "Meshtastic mesh view" /** Android 10 and later only, where saving to Pictures needs no storage permission. */ @@ -89,39 +83,12 @@ private suspend fun captureWindow(window: Window, io: CoroutineDispatcher): Uri? Handler(Looper.getMainLooper()), ) } - return if (copied) withContext(io) { saveToPictures(decor.context, bitmap, FILE_TITLE) } else null + 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 } - -@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 = resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), pending) - val written = - uri != null && - runCatching { - resolver.openOutputStream(uri)?.use { bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, it) } - } - .getOrNull() == true - // A row left pending would sit hidden in the gallery, so a failed write removes it. - if (written) { - resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) - } else if (uri != null) { - resolver.delete(uri, null, null) - } - return uri.takeIf { written } -} From ebd74963d206cb00229664bf6f461cca24fb4592 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:02:39 -0500 Subject: [PATCH 11/12] style(map): lint after rebasing onto the merged mesh view riseM moves next to its only caller to keep HudGeometry under the function cap, and the track gradient notes why its stops are spread. --- .../kotlin/org/meshtastic/core/ui/util/PicturesStore.kt | 4 ++-- .../org/meshtastic/feature/map/maplibre/NodeTrackMap.kt | 1 + .../org/meshtastic/feature/map/hud/HudCapture.android.kt | 1 - .../kotlin/org/meshtastic/feature/map/hud/HudGeometry.kt | 7 +------ .../org/meshtastic/feature/map/hud/HudPathSampling.kt | 5 +++++ 5 files changed, 9 insertions(+), 9 deletions(-) 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 index 9a9f61ffc3d..950273ddc53 100644 --- 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 @@ -32,8 +32,8 @@ 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. + * 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? { 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 41bec259746..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 @@ -249,6 +249,7 @@ internal fun trackLineStops(points: List, recency: List): Li * `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 = 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 index f23e801a815..18f7320564a 100644 --- 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 @@ -86,7 +86,6 @@ private suspend fun captureWindow(window: Window, io: CoroutineDispatcher): Uri? 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() 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 4b5497c86d1..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 @@ -24,12 +24,11 @@ import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.max import kotlin.math.sin -import kotlin.math.tan 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 @@ -97,10 +96,6 @@ fun elevationDeg(distanceM: Double, riseM: Double?): Double { return atan2((riseM ?: 0.0) - drop, distanceM) * DEGREES_PER_RADIAN } -/** The rise that [elevationDeg] turns into [elevationDeg] at [distanceM]: its inverse. */ -fun riseM(distanceM: Double, elevationDeg: Double): Double = - distanceM * tan(elevationDeg.toRadians()) + distanceM * distanceM / (2 * EFFECTIVE_EARTH_RADIUS_M) - /** A target with no shared altitude frame could sit anywhere in a band this tall either side of level. */ private const val UNKNOWN_ELEVATION_SPREAD_DEG = 4.0 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 index 1f05b7373ea..b6719c6efe3 100644 --- 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 @@ -24,6 +24,7 @@ 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 @@ -51,6 +52,10 @@ internal fun groundDirections(a: PathPoint, b: PathPoint): List { } } +/** 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 From 08072c62cffe71eba6b928c1bd9815ab713b1c8b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:04:51 -0500 Subject: [PATCH 12/12] fix(map): one mesh view capture at a time, and a held height when a fix has none A tap during a save is ignored, a window that loses its surface reports a failed save instead of crashing, and a fix without a GPS height keeps the barometer height on the last level. --- .../feature/map/hud/HudCapture.android.kt | 40 ++++++++++++++----- .../feature/map/hud/AltitudeFuser.kt | 22 +++++++++- .../feature/map/hud/AltitudeFuserTest.kt | 19 +++++++++ 3 files changed, 69 insertions(+), 12 deletions(-) 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 index 18f7320564a..e8470e572bd 100644 --- 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 @@ -29,10 +29,15 @@ 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 @@ -58,11 +63,20 @@ actual fun rememberHudCapture(): (() -> Unit)? { 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 { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - scope.launch { - 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() + 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 + } + } } } } @@ -76,12 +90,18 @@ 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 -> - PixelCopy.request( - window, - bitmap, - { result -> continuation.resume(result == PixelCopy.SUCCESS) }, - Handler(Looper.getMainLooper()), - ) + 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 } 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 index 4b72175ca41..183283f3b2f 100644 --- 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 @@ -57,13 +57,17 @@ internal fun pressureAltitudeM(hPa: Float): Double = * 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. + * 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) } @@ -72,7 +76,21 @@ internal class AltitudeFuser { fun fuse(fix: PhoneLocation): PhoneLocation { val baro = baroM val gps = fix.altitude - if (baro == null || gps == null) return fix + 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 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 index 40b65fcb8ca..af7213a6fca 100644 --- 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 @@ -86,6 +86,25 @@ class AltitudeFuserTest { 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))