feat(map): material3 controls + latitude-aware precision circles

- 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
This commit is contained in:
James Rich 2026-04-13 14:11:21 -05:00
parent 2fd93bc67f
commit 22d46a50ef
3 changed files with 36 additions and 7 deletions

View file

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

View file

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

View file

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