From 6120ff0eb268fca8b7a7e9eae171a06f612ce183 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 12:28:11 -0500 Subject: [PATCH] =?UTF-8?q?fix(map):=20address=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20precision=20circles,=20traceroute,=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix precision circle radius: use zoom-based exponential interpolation to convert meters to pixels instead of treating meters as dp values - Fix InlineMap precision circle: compute pixel radius from meters at the fixed zoom-15 display level - Fix TracerouteLayers: wrap callback in LaunchedEffect to avoid state updates during composition; add nodes to remember keys for fresh hop labels; use relatedNodeNums.size for accurate total count - Fix compass bearing: use epsilon comparison (±0.5°) instead of exact float equality to prevent flickering near north - Localize EditWaypointDialog: replace hardcoded English strings with stringResource() using existing waypoint_edit/waypoint_new resources - Format coordinates to 6 decimal places in waypoint position display --- .../composeResources/values/strings.xml | 1 + .../map/component/EditWaypointDialog.kt | 19 ++++++++++-- .../feature/map/component/InlineMap.kt | 8 +++-- .../map/component/MapControlsOverlay.kt | 5 +++- .../map/component/MaplibreMapContent.kt | 29 +++++++++++++++++-- .../feature/map/component/TracerouteLayers.kt | 9 +++--- 6 files changed, 59 insertions(+), 12 deletions(-) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 191d7ba7a..4ed865783 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -362,6 +362,7 @@ Edit waypoint Delete waypoint? New waypoint + Lock to my node Received waypoint: %1$s Duty Cycle limit reached. Cannot send messages right now, please try again later. Remove diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index 1392ab1aa..bcbc83624 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -46,12 +46,16 @@ import org.meshtastic.core.resources.delete import org.meshtastic.core.resources.description import org.meshtastic.core.resources.name import org.meshtastic.core.resources.send +import org.meshtastic.core.resources.waypoint_edit +import org.meshtastic.core.resources.waypoint_lock_to_my_node +import org.meshtastic.core.resources.waypoint_new import org.meshtastic.feature.map.util.convertIntToEmoji import org.maplibre.spatialk.geojson.Position as GeoPosition private const val MAX_NAME_LENGTH = 29 private const val MAX_DESCRIPTION_LENGTH = 99 private const val DEFAULT_EMOJI = 0x1F4CD // Round Pushpin +private const val COORDINATE_PRECISION = 1_000_000L /** * Dialog for creating or editing a waypoint on the map. @@ -81,7 +85,7 @@ fun EditWaypointDialog( onDismissRequest = onDismiss, title = { Text( - text = if (isEditing) "Edit Waypoint" else "New Waypoint", + text = stringResource(if (isEditing) Res.string.waypoint_edit else Res.string.waypoint_new), style = MaterialTheme.typography.headlineSmall, ) }, @@ -122,7 +126,10 @@ fun EditWaypointDialog( horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth(), ) { - Text("Lock to my node", style = MaterialTheme.typography.bodyMedium) + Text( + stringResource(Res.string.waypoint_lock_to_my_node), + style = MaterialTheme.typography.bodyMedium, + ) Switch(checked = locked, onCheckedChange = { locked = it }) } @@ -130,7 +137,7 @@ fun EditWaypointDialog( if (position != null) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "${position.latitude}, ${position.longitude}", + text = "${position.latitude.formatCoord()}, ${position.longitude.formatCoord()}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -166,3 +173,9 @@ fun EditWaypointDialog( }, ) } + +/** Format a coordinate to 6 decimal places without using JVM-only String.format(). */ +private fun Double.formatCoord(): String { + val rounded = (this * COORDINATE_PRECISION).toLong() / COORDINATE_PRECISION.toDouble() + return rounded.toString() +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt index 7e9cfa2cd..70d7f4874 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/InlineMap.kt @@ -45,6 +45,9 @@ private const val COORDINATE_SCALE = 1e-7 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.15f private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f +/** Ground resolution at zoom 15 (equatorial): ~4.773 meters per pixel. */ +private const val METERS_PER_PIXEL_ZOOM15 = 4.773 + /** * A compact, non-interactive map showing a single node's position. Used in node detail screens. Replaces both the * Google Maps and OSMDroid inline map implementations. @@ -89,13 +92,14 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { strokeColor = const(Color.White), ) - // Precision circle + // Precision circle — radius computed from precision_meters at zoom 15 val precisionMeters = precisionBitsToMeters(position.precision_bits ?: 0) if (precisionMeters > 0) { + val radiusDp = (precisionMeters / METERS_PER_PIXEL_ZOOM15).dp CircleLayer( id = "inline-node-precision", source = source, - radius = const(40.dp), // visual approximation + radius = const(radiusDp), color = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), strokeWidth = const(1.dp), strokeColor = const(Color(node.colors.second).copy(alpha = PRECISION_CIRCLE_STROKE_ALPHA)), diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt index 721dab9f2..ff964c042 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -42,6 +42,7 @@ import org.meshtastic.core.ui.icon.NearMe import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.core.ui.icon.Tune import org.meshtastic.core.ui.theme.StatusColors.StatusRed +import kotlin.math.abs /** * Shared map controls overlay using [HorizontalFloatingToolbar] for Material 3 Expressive styling. Provides compass, @@ -131,12 +132,14 @@ fun MapControlsOverlay( } } +private const val BEARING_NORTH_THRESHOLD = 0.5f + @Composable private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) { val iconTint = when { isFollowing -> MaterialTheme.colorScheme.primary - bearing == 0f -> MaterialTheme.colorScheme.StatusRed + abs(bearing) < BEARING_NORTH_THRESHOLD -> MaterialTheme.colorScheme.StatusRed else -> null } MapButton( diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt index dae42ed61..346d5bf9d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MaplibreMapContent.kt @@ -32,9 +32,13 @@ import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.convertToColor import org.maplibre.compose.expressions.dsl.convertToNumber import org.maplibre.compose.expressions.dsl.dp +import org.maplibre.compose.expressions.dsl.exponential import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.interpolate import org.maplibre.compose.expressions.dsl.not import org.maplibre.compose.expressions.dsl.offset +import org.maplibre.compose.expressions.dsl.times +import org.maplibre.compose.expressions.dsl.zoom import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.HillshadeLayer import org.maplibre.compose.layers.SymbolLayer @@ -63,6 +67,19 @@ private const val CLUSTER_RADIUS = 50 private const val CLUSTER_MIN_POINTS = 10 private const val PRECISION_CIRCLE_FILL_ALPHA = 0.1f private const val PRECISION_CIRCLE_STROKE_ALPHA = 0.3f + +/** + * Ground resolution at the equator: meters per pixel = 156543.03 / 2^zoom. We use an exponential(2) interpolation with + * two stops to compute the conversion factor from meters to pixels at each zoom level. The result is multiplied by the + * per-feature `precision_meters` property to produce a screen-pixel radius. + */ +private const val EQUATORIAL_METERS_PER_PIXEL_ZOOM0 = 156543.03f +private const val PRECISION_ZOOM_MIN = 0 +private const val PRECISION_ZOOM_MAX = 24 +private const val PRECISION_SCALE_MIN = 1f / EQUATORIAL_METERS_PER_PIXEL_ZOOM0 + +@Suppress("MagicNumber") +private const val PRECISION_SCALE_MAX = 16_777_216f / EQUATORIAL_METERS_PER_PIXEL_ZOOM0 // 2^24 private const val CLUSTER_OPACITY = 0.85f private const val LABEL_OFFSET_EM = 1.5f private const val CLUSTER_ZOOM_INCREMENT = 2.0 @@ -232,13 +249,21 @@ private fun NodeMarkerLayers( iconAllowOverlap = const(true), ) - // Precision circles — sized by precision_meters property + // Precision circles — sized by precision_meters property converted to screen pixels via zoom interpolation if (showPrecisionCircle) { + // Meters-to-pixels factor doubles with each zoom level (equatorial approximation) + val metersToPixels = + interpolate( + exponential(2f), + zoom(), + PRECISION_ZOOM_MIN to const(PRECISION_SCALE_MIN), + PRECISION_ZOOM_MAX to const(PRECISION_SCALE_MAX), + ) CircleLayer( id = "node-precision", source = nodesSource, filter = !feature.has("cluster"), - radius = feature["precision_meters"].convertToNumber(const(0f)).dp, + radius = (feature["precision_meters"].convertToNumber(const(0f)) * metersToPixels).dp, color = feature["background_color"].convertToColor( const(NodeMarkerColor.copy(alpha = PRECISION_CIRCLE_FILL_ALPHA)), diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt index d87c21602..a386cc7c6 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/TracerouteLayers.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.map.component import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -62,12 +63,12 @@ fun TracerouteLayers( if (overlay == null) return // Build route line features - val routeData = remember(overlay, nodePositions) { buildTracerouteGeoJson(overlay, nodePositions, nodes) } + val routeData = remember(overlay, nodePositions, nodes) { buildTracerouteGeoJson(overlay, nodePositions, nodes) } - // Report mappable count + // Report mappable count via side effect (avoid state updates during composition) val mappableCount = routeData.hopFeatures.features.size - val totalCount = overlay.forwardRoute.size + overlay.returnRoute.size - onMappableCountChanged(mappableCount, totalCount) + val totalCount = overlay.relatedNodeNums.size + LaunchedEffect(mappableCount, totalCount) { onMappableCountChanged(mappableCount, totalCount) } // Forward route line if (routeData.forwardLine.features.isNotEmpty()) {