fix(map): address review round 2 — precision circles, traceroute, i18n

- 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
This commit is contained in:
James Rich
2026-04-13 12:28:11 -05:00
parent 5cae109ec9
commit 6120ff0eb2
6 changed files with 59 additions and 12 deletions

View File

@@ -362,6 +362,7 @@
<string name="waypoint_edit">Edit waypoint</string>
<string name="waypoint_delete">Delete waypoint?</string>
<string name="waypoint_new">New waypoint</string>
<string name="waypoint_lock_to_my_node">Lock to my node</string>
<string name="waypoint_received">Received waypoint: %1$s</string>
<string name="error_duty_cycle">Duty Cycle limit reached. Cannot send messages right now, please try again later.</string>
<string name="remove">Remove</string>

View File

@@ -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()
}

View File

@@ -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)),

View File

@@ -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(

View File

@@ -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)),

View File

@@ -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()) {