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