From 22d46a50efaf1df40be85a20b82e7fc29ac1dfe5 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 13 Apr 2026 14:11:21 -0500 Subject: [PATCH] feat(map): material3 controls + latitude-aware precision circles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DisappearingScaleBar overlay (bottom-start) that auto-shows on zoom change and hides after 3 seconds, using CameraState.metersPerDpAtTarget - Add ExpandingAttributionButton overlay (bottom-end) for tile provider attribution display (legal compliance), auto-dismisses on map gesture - Thread StyleState from MapScreen → MaplibreMapContent → MaplibreMap to provide source attribution data for the attribution button - Use LocationPuckDefaults.colors() for Material 3 themed location puck (derives colors from MaterialTheme.colorScheme instead of hardcoded blue) - Replace hardcoded METERS_PER_PIXEL_ZOOM15 equatorial constant in InlineMap with CameraState.metersPerDpAtTarget for latitude-aware precision circles --- .../org/meshtastic/feature/map/MapScreen.kt | 21 +++++++++++++++++++ .../feature/map/component/InlineMap.kt | 10 ++++----- .../map/component/MaplibreMapContent.kt | 12 ++++++++++- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt index 043c4ec50..17a0b50c2 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -40,6 +41,9 @@ import org.maplibre.compose.location.LocationTrackingEffect import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.compose.location.rememberUserLocationState import org.maplibre.compose.map.GestureOptions +import org.maplibre.compose.material3.DisappearingScaleBar +import org.maplibre.compose.material3.ExpandingAttributionButton +import org.maplibre.compose.style.rememberStyleState import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.map import org.meshtastic.core.ui.component.MainAppBar @@ -53,6 +57,7 @@ import org.meshtastic.feature.map.util.toGeoPositionOrNull import org.maplibre.spatialk.geojson.Position as GeoPosition private const val WAYPOINT_ZOOM = 15.0 +private val MAP_OVERLAY_PADDING = 16.dp /** * Main map screen composable. Uses MapLibre Compose Multiplatform to render an interactive map with mesh node markers, @@ -81,6 +86,7 @@ fun MapScreen( LaunchedEffect(waypointId) { viewModel.setWaypointId(waypointId) } val cameraState = rememberCameraState(firstPosition = viewModel.initialCameraPosition) + val styleState = rememberStyleState() var filterMenuExpanded by remember { mutableStateOf(false) } @@ -155,6 +161,7 @@ fun MapScreen( }, modifier = Modifier.fillMaxSize(), gestureOptions = gestureOptions, + styleState = styleState, onCameraMoved = { position -> viewModel.saveCameraPosition(position) }, onWaypointClick = { wpId -> editingWaypointId = wpId @@ -228,6 +235,20 @@ fun MapScreen( } }, ) + + // Scale bar — auto-shows on zoom change, hides after 3 seconds + DisappearingScaleBar( + metersPerDp = cameraState.metersPerDpAtTarget, + zoom = cameraState.position.zoom, + modifier = Modifier.align(Alignment.BottomStart).padding(MAP_OVERLAY_PADDING), + ) + + // Attribution button — shows tile provider attributions (legal compliance) + ExpandingAttributionButton( + cameraState = cameraState, + styleState = styleState, + modifier = Modifier.align(Alignment.BottomEnd).padding(MAP_OVERLAY_PADDING), + ) } } 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 88d0325a0..977e2bd52 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 @@ -44,9 +44,6 @@ private const val DEFAULT_ZOOM = 15.0 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. @@ -83,10 +80,11 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) { strokeColor = const(Color.White), ) - // Precision circle — radius computed from precision_meters at zoom 15 + // Precision circle — radius computed from precision_meters using latitude-aware metersPerDp val precisionMeters = precisionBitsToMeters(position.precision_bits ?: 0) - if (precisionMeters > 0) { - val radiusDp = (precisionMeters / METERS_PER_PIXEL_ZOOM15).dp + val metersPerDp = cameraState.metersPerDpAtTarget + if (precisionMeters > 0 && metersPerDp > 0) { + val radiusDp = (precisionMeters / metersPerDp).dp CircleLayer( id = "inline-node-precision", source = source, 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 7299b387c..19871567d 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 @@ -48,12 +48,15 @@ import org.maplibre.compose.map.GestureOptions import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions +import org.maplibre.compose.material3.LocationPuckDefaults import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonOptions import org.maplibre.compose.sources.RasterDemEncoding import org.maplibre.compose.sources.rememberGeoJsonSource import org.maplibre.compose.sources.rememberRasterDemSource import org.maplibre.compose.style.BaseStyle +import org.maplibre.compose.style.StyleState +import org.maplibre.compose.style.rememberStyleState import org.maplibre.compose.util.ClickResult import org.maplibre.spatialk.geojson.Point import org.meshtastic.core.model.DataPacket @@ -108,6 +111,7 @@ fun MaplibreMapContent( onMapLongClick: (GeoPosition) -> Unit, modifier: Modifier = Modifier, gestureOptions: GestureOptions = GestureOptions.Standard, + styleState: StyleState = rememberStyleState(), onCameraMoved: (CameraPosition) -> Unit = {}, onWaypointClick: (Int) -> Unit = {}, onMapLoadFinished: () -> Unit = {}, @@ -118,6 +122,7 @@ fun MaplibreMapContent( modifier = modifier, baseStyle = baseStyle, cameraState = cameraState, + styleState = styleState, options = MapOptions(gestureOptions = gestureOptions, ornamentOptions = OrnamentOptions.OnlyLogo), onMapLongClick = { position, _ -> onMapLongClick(position) @@ -148,7 +153,12 @@ fun MaplibreMapContent( // --- User location puck --- if (locationState != null) { - LocationPuck(idPrefix = "user-location", locationState = locationState, cameraState = cameraState) + LocationPuck( + idPrefix = "user-location", + locationState = locationState, + cameraState = cameraState, + colors = LocationPuckDefaults.colors(), + ) } }