From ab5c764360094b0125fddf06c2b232b144b5834e Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Wed, 25 Mar 2026 15:32:42 +0100 Subject: [PATCH 01/14] WIP - Migrate `ui-maplibre` to official MapLibre Compose Android 0.12.1 Migrates the Android phone and tablet navigation views from the legacy dependency to the official `org.maplibre.compose:maplibre-compose-android` artifact. Key changes include: * Introduced `NavigationMapState` to manage camera modes (follow user, overview, free) and zoom behavior. * Replaced `MapViewCamera` with a custom camera layer supporting automatic orientation and recentering logic. * Updated route rendering to use GeoJSON sources and `LineLayer` instead of legacy polyline APIs. * Added `NavigationMapPuckStyle` for configurable location puck appearance. * Refactored `NavigationMapView` to support official `MapOptions`, `LocationPuck`, and native `Style` access. * Updated gesture handling to use Ferrostar-specific `NavigationMapClickHandler` returning geographic coordinates. * Maintained legacy compatibility for Android Auto in `ui-maplibre-car-app` by keeping the Rallista dependency for that module. * Added unit tests for GeoJSON serialization, location mapping, and camera state logic. --- android/README.md | 48 +++++ android/demo-app/build.gradle | 1 - .../ferrostar/DemoNavigationScene.kt | 86 ++++---- .../ferrostar/DemoNavigationViewModel.kt | 8 +- .../ferrostar/DemoDroppedPinGeoJsonTest.kt | 41 ++++ android/gradle/libs.versions.toml | 8 +- android/ui-maplibre-car-app/build.gradle | 2 + .../maplibre/car/app/CarAppNavigationView.kt | 50 ++--- .../maplibreui/NavigationMapGestures.kt | 11 + .../maplibreui/NavigationMapPuckStyle.kt | 25 +++ .../ferrostar/maplibreui/NavigationMapView.kt | 193 ++++++++++++++---- .../extensions/LocationRequestProperties.kt | 15 -- .../extensions/VisualNavigationViewConfig.kt | 41 ++-- .../maplibreui/routeline/BorderedPolyline.kt | 52 +++-- .../maplibreui/routeline/RouteGeoJson.kt | 20 ++ .../routeline/RouteOverlayBuilder.kt | 7 +- .../maplibreui/runtime/FerrostarLocation.kt | 46 +++++ .../maplibreui/runtime/MapControls.kt | 101 +++------ .../ferrostar/maplibreui/runtime/MapReady.kt | 36 ++++ .../maplibreui/runtime/NavigationCamera.kt | 94 +++++++-- .../maplibreui/runtime/NavigationMapState.kt | 86 ++++++++ .../DynamicallyOrientingNavigationView.kt | 93 ++++----- .../views/LandscapeNavigationView.kt | 93 ++++----- .../views/PortraitNavigationView.kt | 97 +++++---- .../maplibreui/NavigationMapPuckStyleTest.kt | 22 ++ .../maplibreui/routeline/RouteGeoJsonTest.kt | 28 +++ .../runtime/FerrostarLocationTest.kt | 53 +++++ .../runtime/NavigationCameraTest.kt | 63 ++++++ .../runtime/NavigationMapStateTest.kt | 97 +++++++++ 29 files changed, 1085 insertions(+), 432 deletions(-) create mode 100644 android/demo-app/src/test/java/com/stadiamaps/ferrostar/DemoDroppedPinGeoJsonTest.kt create mode 100644 android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapGestures.kt create mode 100644 android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyle.kt delete mode 100644 android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/LocationRequestProperties.kt create mode 100644 android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteGeoJson.kt create mode 100644 android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/FerrostarLocation.kt create mode 100644 android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt create mode 100644 android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt create mode 100644 android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyleTest.kt create mode 100644 android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteGeoJsonTest.kt create mode 100644 android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/FerrostarLocationTest.kt create mode 100644 android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt create mode 100644 android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt diff --git a/android/README.md b/android/README.md index 00230d9f9..ed9f5db5a 100644 --- a/android/README.md +++ b/android/README.md @@ -37,3 +37,51 @@ To update the snapshots, run `./gradlew recordPaparazziDebug`. * Bump the version number to a `SNAPSHOT` in `build.gradle`. * run `./gradlew publishToMavenLocal -Pskip.signing` * reference the updated version number in the project, and ensure that `mavenLocal` is one of the `repositories`. + +## MapLibre Compose Migration + +`ui-maplibre` now targets the official MapLibre Compose Android artifact: + +```kotlin +implementation("org.maplibre.compose:maplibre-compose-android:0.12.1") +``` + +Notable Android phone/tablet migration changes: + +* `io.github.rallista:maplibre-compose` is no longer used by `ui-maplibre`. +* `NavigationMapView`, `PortraitNavigationView`, `LandscapeNavigationView`, and `DynamicallyOrientingNavigationView` now use a Ferrostar-owned `NavigationMapState` facade via `rememberNavigationMapState()`. +* The old `MapViewCamera`-based camera state has been replaced by a small Ferrostar camera layer for: + * follow user + * follow user with bearing + * route overview + * free camera +* `onMapReadyCallback` is still available on `NavigationMapView` for the 0.x series. +* Location puck styling is configurable through `NavigationMapPuckStyle`. +* Route rendering now uses a GeoJSON source plus `LineLayer` instead of legacy polyline convenience APIs. +* Map tap and long-press callbacks use Ferrostar-facing callbacks with `GeographicCoordinate` plus screen position. + +Example migration for default usage: + +```kotlin +val navigationMapState = rememberNavigationMapState() + +DynamicallyOrientingNavigationView( + modifier = Modifier.fillMaxSize(), + styleUrl = styleUrl, + navigationMapState = navigationMapState, + viewModel = viewModel, +) +``` + +Custom camera control now goes through `NavigationMapState`: + +```kotlin +navigationMapState.zoomIn() +navigationMapState.recenter(isNavigating = true) +navigationMapState.showRouteOverview(boundingBox, paddingValues = mapInsets) +``` + +Current scope notes: + +* This migration covers Android phone/tablet Compose only. +* Android Auto remains out of scope for this issue; the legacy car-specific path is kept separately so the repo still builds. diff --git a/android/demo-app/build.gradle b/android/demo-app/build.gradle index 2ddf3a603..286316e97 100644 --- a/android/demo-app/build.gradle +++ b/android/demo-app/build.gradle @@ -74,7 +74,6 @@ dependencies { implementation project(':ui-maplibre-car-app') implementation libs.maplibre.compose - implementation libs.maplibre.compose.car.app implementation platform(libs.okhttp.bom) implementation libs.okhttp.core diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt index ad7c2ed35..e1c20a395 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt @@ -3,29 +3,33 @@ package com.stadiamaps.ferrostar import android.Manifest import android.content.pm.PackageManager import android.os.Build +import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Button -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import com.maplibre.compose.camera.MapViewCamera -import com.maplibre.compose.rememberSaveableMapViewCamera -import com.maplibre.compose.symbols.Circle import com.stadiamaps.ferrostar.composeui.config.NavigationViewComponentBuilder import com.stadiamaps.ferrostar.composeui.config.VisualNavigationViewConfig import com.stadiamaps.ferrostar.composeui.config.withCustomOverlayView import com.stadiamaps.ferrostar.composeui.config.withSpeedLimitStyle import com.stadiamaps.ferrostar.composeui.runtime.KeepScreenOnDisposableEffect import com.stadiamaps.ferrostar.composeui.views.components.speedlimit.SignageStyle +import com.stadiamaps.ferrostar.maplibreui.NavigationMapClickResult import com.stadiamaps.ferrostar.maplibreui.views.DynamicallyOrientingNavigationView -import kotlin.math.min -import org.maplibre.android.geometry.LatLng +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.util.MaplibreComposable +import uniffi.ferrostar.GeographicCoordinate @Composable fun DemoNavigationScene( @@ -76,46 +80,56 @@ fun DemoNavigationScene( permissionsLauncher.launch(allPermissions) } } - - // Set up the map! - val camera = rememberSaveableMapViewCamera(MapViewCamera.TrackingUserLocation()) + val droppedPin by viewModel.droppedPin.collectAsState() DynamicallyOrientingNavigationView( modifier = Modifier.fillMaxSize(), styleUrl = AppModule.mapStyleUrl, - camera = camera, viewModel = viewModel, - // Configure speed limit signage based on user preference or location config = VisualNavigationViewConfig.Default().withSpeedLimitStyle(SignageStyle.MUTCD), views = NavigationViewComponentBuilder.Default() .withCustomOverlayView( customOverlayView = { modifier -> - NotNavigatingOverlay(modifier, viewModel) + NotNavigatingOverlay(modifier, viewModel) }, ), onTapExit = { viewModel.stopNavigation() }, - ) { uiState -> - // Trivial, if silly example of how to add your own overlay layers. - // (Also incidentally highlights the lag inherent in MapLibre location tracking - // as-is.) - uiState.location?.let { location -> - Circle( - center = LatLng(location.coordinates.lat, location.coordinates.lng), - radius = 10f, - color = "Blue", - zIndex = 3, - ) + onMapLongClick = { position, screenPosition -> + Log.d( + "DemoNavigationScene", + "Long press at lat=${position.lat}, lng=${position.lng}, screen=$screenPosition", + ) + viewModel.setDroppedPin(position) + NavigationMapClickResult.Pass + }, + ) { + DemoDroppedPinOverlay(droppedPin) + } +} - if (location.horizontalAccuracy > 15) { - Circle( - center = LatLng(location.coordinates.lat, location.coordinates.lng), - radius = min(location.horizontalAccuracy.toFloat(), 150f), - color = "Blue", - opacity = 0.2f, - zIndex = 2, - ) - } - } - } +@Composable +@MaplibreComposable +private fun DemoDroppedPinOverlay(droppedPin: GeographicCoordinate?) { + val pinJson = droppedPinFeatureCollectionJsonOrNull(droppedPin) ?: return + val pointSource = rememberGeoJsonSource(GeoJsonData.JsonString(pinJson)) + + CircleLayer( + id = "demo-dropped-pin", + source = pointSource, + color = const(Color(0xFFD95F02)), + radius = const(8.dp), + strokeColor = const(Color.White), + strokeWidth = const(3.dp), + ) } + +internal fun droppedPinFeatureCollectionJsonOrNull(pin: GeographicCoordinate?): String? = + pin?.let { + droppedPinFeatureCollectionJson(it) + } + +internal fun droppedPinFeatureCollectionJson(pin: GeographicCoordinate): String = + """ + {"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[${pin.lng},${pin.lat}]},"properties":{}}]} + """.trimIndent() diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt index 4b3a7ccc3..c71f692fc 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -52,6 +51,9 @@ class DemoNavigationViewModel( private val locationStateFlow = MutableStateFlow(null) val location = locationStateFlow.asStateFlow() + private val _droppedPin = MutableStateFlow(null) + val droppedPin = _droppedPin.asStateFlow() + // Here's an example of injecting a custom location into the navigation UI state when isNavigating // is false. override val navigationUiState: StateFlow = @@ -97,6 +99,10 @@ class DemoNavigationViewModel( _simulated.value = true } + fun setDroppedPin(coordinate: GeographicCoordinate) { + _droppedPin.value = coordinate + } + init { viewModelScope.launch { _hasLocationPermission diff --git a/android/demo-app/src/test/java/com/stadiamaps/ferrostar/DemoDroppedPinGeoJsonTest.kt b/android/demo-app/src/test/java/com/stadiamaps/ferrostar/DemoDroppedPinGeoJsonTest.kt new file mode 100644 index 000000000..ecfcef944 --- /dev/null +++ b/android/demo-app/src/test/java/com/stadiamaps/ferrostar/DemoDroppedPinGeoJsonTest.kt @@ -0,0 +1,41 @@ +package com.stadiamaps.ferrostar + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import uniffi.ferrostar.GeographicCoordinate + +class DemoDroppedPinGeoJsonTest { + @Test + fun returnsNullWhenNoDroppedPinExists() { + assertNull(droppedPinFeatureCollectionJsonOrNull(null)) + } + + @Test + fun serializesDroppedPinInLongitudeLatitudeOrder() { + val json = droppedPinFeatureCollectionJson(GeographicCoordinate(48.2082, 16.3738)) + + assertEquals( + """{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[16.3738,48.2082]},"properties":{}}]}""", + json, + ) + } + + @Test + fun nullableHelperUsesLatestPinPosition() { + val firstPin = GeographicCoordinate(48.2, 16.3) + val secondPin = GeographicCoordinate(48.3, 16.4) + + val initialJson = droppedPinFeatureCollectionJsonOrNull(firstPin) + val updatedJson = droppedPinFeatureCollectionJsonOrNull(secondPin) + + assertEquals( + """{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[16.4,48.3]},"properties":{}}]}""", + updatedJson, + ) + assertEquals( + """{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[16.3,48.2]},"properties":{}}]}""", + initialJson, + ) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index e593ac008..abede2028 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -15,7 +15,8 @@ androidx-car-app = "1.7.0" androidx-activity-compose = "1.12.4" compose = "2026.02.01" okhttp = "5.3.2" -maplibre-compose = "1.6.0" +maplibre-compose = "0.12.1" +rallista-maplibre-compose = "1.6.0" playServicesLocation = "21.3.0" junit = "4.13.2" junitVersion = "1.3.0" @@ -61,8 +62,9 @@ okhttp-core = { group = "com.squareup.okhttp3", name = "okhttp" } okhttp-mock = { group = "com.github.gmazzo", name = "okhttp-mock", version.ref = "okhttp-mock" } kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" } # MapLibre -maplibre-compose = { group = "io.github.rallista", name = "maplibre-compose", version.ref = "maplibre-compose" } -maplibre-compose-car-app = { group = "io.github.rallista", name = "maplibre-compose-car-app", version.ref = "maplibre-compose" } +maplibre-compose = { group = "org.maplibre.compose", name = "maplibre-compose-android", version.ref = "maplibre-compose" } +rallista-maplibre-compose = { group = "io.github.rallista", name = "maplibre-compose", version.ref = "rallista-maplibre-compose" } +rallista-maplibre-compose-car-app = { group = "io.github.rallista", name = "maplibre-compose-car-app", version.ref = "rallista-maplibre-compose" } # Google Play Services (for Google module) play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } # Testing diff --git a/android/ui-maplibre-car-app/build.gradle b/android/ui-maplibre-car-app/build.gradle index d86f281bb..ed05b6cf7 100644 --- a/android/ui-maplibre-car-app/build.gradle +++ b/android/ui-maplibre-car-app/build.gradle @@ -51,6 +51,8 @@ dependencies { implementation libs.androidx.compose.ui.graphics implementation libs.androidx.compose.ui.tooling implementation libs.androidx.compose.material3 + api libs.rallista.maplibre.compose + api libs.rallista.maplibre.compose.car.app implementation project(':core') implementation project(':ui-compose') diff --git a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt index 9c31300ef..c748f6438 100644 --- a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt +++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt @@ -15,10 +15,6 @@ import com.maplibre.compose.camera.MapViewCamera import com.maplibre.compose.ramani.LocationRequestProperties import com.maplibre.compose.ramani.MapLibreComposable import com.maplibre.compose.rememberSaveableMapViewCamera -import com.maplibre.compose.settings.AttributionSettings -import com.maplibre.compose.settings.CompassSettings -import com.maplibre.compose.settings.LogoSettings -import com.maplibre.compose.settings.MapControls import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.SurfaceAreaTracker import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.screenSurfaceState import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.surfaceStableFractionalPadding @@ -28,38 +24,39 @@ import com.stadiamaps.ferrostar.composeui.views.components.speedlimit.SpeedLimit import com.stadiamaps.ferrostar.core.NavigationUiState import com.stadiamaps.ferrostar.core.NavigationViewModel import com.stadiamaps.ferrostar.maplibreui.NavigationMapView -import com.stadiamaps.ferrostar.maplibreui.extensions.NavigationDefault import com.stadiamaps.ferrostar.maplibreui.routeline.RouteOverlayBuilder -import com.stadiamaps.ferrostar.maplibreui.runtime.navigationMapViewCamera +import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState +import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.map.OrnamentOptions /** * A navigation view designed for Android Auto car displays. * * Renders a [NavigationMapView] with speed limit and road name overlays positioned within the * stable area of the car display surface (the area not covered by the NavigationTemplate's chrome). + * + * Note: `camera`, `navigationCamera`, `locationRequestProperties`, and `mapContent` are legacy + * Android Auto compatibility parameters. They are currently accepted so the old car app API keeps + * compiling while the Android Auto path remains on the legacy stack, but they are ignored by the + * current implementation. */ @Composable fun CarAppNavigationView( modifier: Modifier, styleUrl: String, camera: MutableState = rememberSaveableMapViewCamera(), - navigationCamera: MapViewCamera = navigationMapViewCamera(), + navigationCamera: MapViewCamera = MapViewCamera.TrackingUserLocationWithBearing(), viewModel: NavigationViewModel, locationRequestProperties: LocationRequestProperties = - LocationRequestProperties.NavigationDefault(), + LocationRequestProperties.Builder().build(), config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), surfaceAreaTracker: SurfaceAreaTracker? = null, mapContent: @Composable @MapLibreComposable() ((NavigationUiState) -> Unit)? = null, ) { + keepLegacyCompatibilityParameters(camera, navigationCamera, locationRequestProperties, mapContent) val uiState by viewModel.navigationUiState.collectAsState() - val mapControls = remember { - mutableStateOf( - MapControls( - attribution = AttributionSettings(enabled = false), - compass = CompassSettings(enabled = false), - logo = LogoSettings(enabled = false))) - } + val navigationMapState = rememberNavigationMapState() val surfaceArea by surfaceAreaTracker ?.let { screenSurfaceState(it) } @@ -67,27 +64,14 @@ fun CarAppNavigationView( val gridPadding = surfaceStableFractionalPadding(surfaceArea?.compositeArea) - // Wrap mapContent to also wire surface gesture handling inside the MapLibreComposable context. - val wrappedContent: (@Composable @MapLibreComposable (NavigationUiState) -> Unit)? = - if (surfaceAreaTracker != null || mapContent != null) { - { uiState -> - surfaceAreaTracker?.rememberGestureDelegate() - mapContent?.invoke(uiState) - } - } else null - Box(modifier) { NavigationMapView( - styleUrl, - camera, + styleUrl = styleUrl, + navigationMapState = navigationMapState, uiState = uiState, - mapControls = mapControls, - locationRequestProperties = locationRequestProperties, + mapOptions = MapOptions(ornamentOptions = OrnamentOptions.AllDisabled), routeOverlayBuilder = routeOverlayBuilder, - onMapReadyCallback = { - // No definition - }, - content = wrappedContent + content = null, ) Box( @@ -114,3 +98,5 @@ fun CarAppNavigationView( } } } + +private fun keepLegacyCompatibilityParameters(vararg ignored: Any?) = Unit diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapGestures.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapGestures.kt new file mode 100644 index 000000000..b77623519 --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapGestures.kt @@ -0,0 +1,11 @@ +package com.stadiamaps.ferrostar.maplibreui + +import androidx.compose.ui.unit.DpOffset +import uniffi.ferrostar.GeographicCoordinate + +enum class NavigationMapClickResult { + Pass, + Consume, +} + +typealias NavigationMapClickHandler = (GeographicCoordinate, DpOffset) -> NavigationMapClickResult diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyle.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyle.kt new file mode 100644 index 000000000..5b07728fd --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyle.kt @@ -0,0 +1,25 @@ +package com.stadiamaps.ferrostar.maplibreui + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class NavigationMapPuckStyle( + val dotFillColorCurrentLocation: Color = Color(0xFF3583DD), + val dotFillColorOldLocation: Color = Color(0xFF3583DD), + val dotStrokeColor: Color = Color.White, + val shadowColor: Color = Color.Black.copy(alpha = 0.2f), + val accuracyStrokeColor: Color = Color(0xFF3583DD), + val accuracyFillColor: Color = Color(0xFF3583DD).copy(alpha = 0.16f), + val bearingColor: Color = Color(0xFF0F5FB8), + val dotRadius: Dp = 6.dp, + val dotStrokeWidth: Dp = 3.dp, + val showBearing: Boolean = true, + val showBearingAccuracy: Boolean = false, +) { + companion object { + fun Default() = NavigationMapPuckStyle() + } +} diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt index c6b0901b1..caf609c2b 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt @@ -2,85 +2,190 @@ package com.stadiamaps.ferrostar.maplibreui import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import com.maplibre.compose.MapView -import com.maplibre.compose.StaticLocationEngine -import com.maplibre.compose.camera.MapViewCamera -import com.maplibre.compose.ramani.LocationRequestProperties -import com.maplibre.compose.ramani.MapLibreComposable -import com.maplibre.compose.settings.MapControls import com.stadiamaps.ferrostar.core.NavigationUiState -import com.stadiamaps.ferrostar.core.location.toAndroidLocation -import com.stadiamaps.ferrostar.maplibreui.extensions.NavigationDefault import com.stadiamaps.ferrostar.maplibreui.routeline.RouteOverlayBuilder -import com.stadiamaps.ferrostar.maplibreui.runtime.navigationMapViewCamera +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraMode +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState +import com.stadiamaps.ferrostar.maplibreui.runtime.defaultNavigationCameraMode +import com.stadiamaps.ferrostar.maplibreui.runtime.nativeStyleOrNull +import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.rememberFerrostarLocationState +import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState +import com.stadiamaps.ferrostar.maplibreui.runtime.toMapLibreLocation +import kotlinx.coroutines.flow.collectLatest +import org.maplibre.compose.camera.CameraMoveReason +import org.maplibre.compose.location.LocationPuck +import org.maplibre.compose.location.LocationPuckColors +import org.maplibre.compose.location.LocationPuckSizes +import org.maplibre.compose.location.LocationTrackingEffect +import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.style.BaseStyle +import org.maplibre.compose.util.ClickResult +import org.maplibre.compose.util.MaplibreComposable import org.maplibre.android.maps.Style +import uniffi.ferrostar.GeographicCoordinate /** - * The base MapLibre MapView configured for navigation with a polyline representing the route. + * The base MapLibre map configured for navigation with a route line, location puck, gesture + * callbacks, and Ferrostar-specific camera behavior for phone and tablet use. * * @param styleUrl The MapLibre style URL to use for the map. - * @param camera The bi-directional camera state to use for the map. Note: this is a bit - * non-standard as far as normal compose patterns go, but we independently came up with this - * approach and later verified that Google Maps does the same thing in their compose SDK. - * @param navigationCamera The default camera state to use for navigation. This is a *template* - * value, which will be applied on initial display and when re-centering. The default value is - * sufficient for most applications. If you set a custom value (e.g.) to change the pitch), you - * must ensure that it is some variation on [MapViewCamera.TrackingUserLocationWithBearing]. + * @param navigationMapState The Ferrostar-owned map state used to control follow, overview, free + * camera, and zoom behavior. * @param uiState The navigation UI state. - * @param locationRequestProperties The location request properties to use for the map's location - * engine. - * @param routeOverlayBuilder The route overlay builder to use for rendering the route line on the - * MapView. - * @param onMapReadyCallback A callback that is invoked when the map is ready to be interacted with. - * If unspecified, the camera will change to `navigationCamera` if navigation is in progress. + * @param mapOptions The official MapLibre Compose options for ornaments, gestures, and map + * behavior. + * @param routeOverlayBuilder The route overlay builder to use for rendering the route line. + * @param navigationCameraOptions The camera templates applied when following the user in browsing + * and navigation modes. + * @param locationPuckStyle The style to use for the official MapLibre location puck. + * @param onMapReadyCallback A callback that is invoked when the underlying map style is ready to be + * interacted with. + * @param onMapClick Callback invoked for taps on the map with geographic coordinates and screen + * position. + * @param onMapLongClick Callback invoked for long presses on the map with geographic coordinates + * and screen position. * @param content Any additional composable map symbol content to render. */ @Composable fun NavigationMapView( styleUrl: String, - camera: MutableState, - navigationCamera: MapViewCamera = navigationMapViewCamera(), + navigationMapState: NavigationMapState = rememberNavigationMapState(), uiState: NavigationUiState, - mapControls: State, - locationRequestProperties: LocationRequestProperties = - LocationRequestProperties.NavigationDefault(), + mapOptions: MapOptions, routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), + navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle.Default(), onMapReadyCallback: ((Style) -> Unit)? = null, - content: @Composable @MapLibreComposable ((NavigationUiState) -> Unit)? = null + onMapClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, + onMapLongClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, + content: @Composable @MaplibreComposable ((NavigationUiState) -> Unit)? = null, ) { + val cameraState = navigationMapState.cameraState + val userLocationState = rememberFerrostarLocationState(uiState.location) + val userLocation = uiState.location?.toMapLibreLocation() + navigationMapState.navigationCameraOptions = navigationCameraOptions + var isNavigating by remember { mutableStateOf(uiState.isNavigating()) } + var mapReadyCallbackFired by remember(styleUrl, onMapReadyCallback) { mutableStateOf(false) } if (uiState.isNavigating() != isNavigating) { isNavigating = uiState.isNavigating() + navigationMapState.cameraMode = defaultNavigationCameraMode(isNavigating) + } - if (isNavigating) { - camera.value = navigationCamera - } + LocationTrackingEffect( + locationState = userLocationState, + enabled = navigationMapState.isTrackingUser, + trackBearing = navigationMapState.cameraMode == NavigationCameraMode.FOLLOW_USER_WITH_BEARING, + ) { + cameraState.position = + navigationMapState.followingCameraPosition( + target = currentLocation.position, + bearing = currentLocation.bearing, + ) } - val locationEngine = remember { StaticLocationEngine() } - locationEngine.lastLocation = uiState.location?.toAndroidLocation() + LaunchedEffect(cameraState, navigationMapState) { + snapshotFlow { cameraState.moveReason }.collectLatest { moveReason -> + if (moveReason == CameraMoveReason.GESTURE && navigationMapState.isTrackingUser) { + navigationMapState.cameraMode = NavigationCameraMode.FREE + } + } + } - MapView( + MaplibreMap( modifier = Modifier.fillMaxSize(), - styleUrl, - camera, - mapControls, - locationRequestProperties = locationRequestProperties, - locationEngine = locationEngine, - onMapReadyCallback = - onMapReadyCallback ?: { if (isNavigating) camera.value = navigationCamera }, + baseStyle = BaseStyle.Uri(styleUrl), + cameraState = cameraState, + onMapClick = { position, screenPosition -> + onMapClick(position.toGeographicCoordinate(), screenPosition).toComposeClickResult() + }, + onMapLongClick = { position, screenPosition -> + onMapLongClick(position.toGeographicCoordinate(), screenPosition).toComposeClickResult() + }, + onMapLoadFinished = { + if (userLocation != null && navigationMapState.isTrackingUser) { + cameraState.position = + navigationMapState.followingCameraPosition( + target = userLocation.position, + bearing = userLocation.bearing, + ) + } + + if (!mapReadyCallbackFired) { + if (onMapReadyCallback != null) { + cameraState.nativeStyleOrNull()?.let { + mapReadyCallbackFired = true + onMapReadyCallback(it) + } ?: run { + mapReadyCallbackFired = true + } + } else { + mapReadyCallbackFired = true + } + } + }, + options = mapOptions, ) { routeOverlayBuilder.navigationPath(uiState) + LocationPuck( + idPrefix = "ferrostar-location", + locationState = userLocationState, + cameraState = cameraState, + colors = + LocationPuckColors( + dotFillColorCurrentLocation = locationPuckStyle.dotFillColorCurrentLocation, + dotFillColorOldLocation = locationPuckStyle.dotFillColorOldLocation, + dotStrokeColor = locationPuckStyle.dotStrokeColor, + shadowColor = locationPuckStyle.shadowColor, + accuracyStrokeColor = locationPuckStyle.accuracyStrokeColor, + accuracyFillColor = locationPuckStyle.accuracyFillColor, + bearingColor = locationPuckStyle.bearingColor, + ), + sizes = + LocationPuckSizes( + dotRadius = locationPuckStyle.dotRadius, + dotStrokeWidth = locationPuckStyle.dotStrokeWidth, + ), + showBearing = locationPuckStyle.showBearing, + showBearingAccuracy = locationPuckStyle.showBearingAccuracy, + ) + if (content != null) { content(uiState) } } } + +private fun org.maplibre.spatialk.geojson.Position.toGeographicCoordinate(): GeographicCoordinate = + GeographicCoordinate(lat = latitude, lng = longitude) + +private fun NavigationMapClickResult.toComposeClickResult(): ClickResult = + when (this) { + NavigationMapClickResult.Pass -> ClickResult.Pass + NavigationMapClickResult.Consume -> ClickResult.Consume + } + +private fun NavigationMapState.followingCameraPosition( + target: org.maplibre.spatialk.geojson.Position, + bearing: Double?, +): org.maplibre.compose.camera.CameraPosition = + when (cameraMode) { + NavigationCameraMode.FOLLOW_USER -> navigationCameraOptions.browsingUser(target) + NavigationCameraMode.FOLLOW_USER_WITH_BEARING -> + navigationCameraOptions.navigatingUser( + target = target, + bearing = bearing ?: cameraState.position.bearing, + ) + else -> cameraState.position + } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/LocationRequestProperties.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/LocationRequestProperties.kt deleted file mode 100644 index a0e0596d6..000000000 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/LocationRequestProperties.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.stadiamaps.ferrostar.maplibreui.extensions - -import com.maplibre.compose.ramani.LocationPriority -import com.maplibre.compose.ramani.LocationRequestProperties - -/** Default location request properties for navigation. */ -fun LocationRequestProperties.Companion.NavigationDefault(): LocationRequestProperties { - return LocationRequestProperties.Builder() - .priority(LocationPriority.PRIORITY_HIGH_ACCURACY) - .interval(1000L) - .fastestInterval(0L) - .displacement(0F) - .maxWaitTime(1000L) - .build() -} diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/VisualNavigationViewConfig.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/VisualNavigationViewConfig.kt index 08f84fc64..0bfc9f3bf 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/VisualNavigationViewConfig.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/VisualNavigationViewConfig.kt @@ -1,40 +1,27 @@ package com.stadiamaps.ferrostar.maplibreui.extensions import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import com.maplibre.compose.camera.CameraState -import com.maplibre.compose.camera.MapViewCamera -import com.maplibre.compose.camera.models.CameraPadding import com.stadiamaps.ferrostar.composeui.config.VisualNavigationViewConfig import com.stadiamaps.ferrostar.composeui.models.CameraControlState import com.stadiamaps.ferrostar.core.BoundingBox -import org.maplibre.android.geometry.LatLngBounds +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState -@Composable fun VisualNavigationViewConfig.cameraControlState( - camera: MutableState, - navigationCamera: MapViewCamera, + navigationMapState: NavigationMapState, + isNavigating: Boolean, mapViewInsets: PaddingValues, - boundingBox: BoundingBox? + boundingBox: BoundingBox?, ): CameraControlState { - val cameraIsTrackingLocation = camera.value.state is CameraState.TrackingUserLocationWithBearing - val cameraPadding = CameraPadding.padding(mapViewInsets) - - return if (!cameraIsTrackingLocation) { - CameraControlState.ShowRecenter { camera.value = navigationCamera } - } else { - if (boundingBox != null) { - CameraControlState.ShowRouteOverview { - camera.value = - MapViewCamera.BoundingBox( - bounds = - LatLngBounds.from( - boundingBox.north, boundingBox.east, boundingBox.south, boundingBox.west), - padding = cameraPadding) - } - } else { - CameraControlState.Hidden + return if (!navigationMapState.isTrackingUser) { + CameraControlState.ShowRecenter { navigationMapState.recenter(isNavigating) } + } else if (boundingBox != null) { + CameraControlState.ShowRouteOverview { + navigationMapState.showRouteOverview( + boundingBox = boundingBox, + paddingValues = mapViewInsets, + ) } + } else { + CameraControlState.Hidden } } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/BorderedPolyline.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/BorderedPolyline.kt index 60eafac4d..bdc0b2578 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/BorderedPolyline.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/BorderedPolyline.kt @@ -1,34 +1,44 @@ package com.stadiamaps.ferrostar.maplibreui.routeline import androidx.compose.runtime.Composable -import com.maplibre.compose.symbols.Polyline -import org.maplibre.android.geometry.LatLng +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.value.LineCap +import org.maplibre.compose.expressions.value.LineJoin +import org.maplibre.compose.layers.LineLayer +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.rememberGeoJsonSource +import uniffi.ferrostar.GeographicCoordinate @Composable fun BorderedPolyline( - points: List, - zIndex: Int = 1, - color: String = "#3583dd", + points: List, + idPrefix: String = "ferrostar-route", + color: Color = Color(0xFF3583DD), opacity: Float = 1.0f, - borderColor: String = "#ffffff", + borderColor: Color = Color.White, borderOpacity: Float = 1.0f, lineWidth: Float = 10f, - borderWidth: Float = 3f + borderWidth: Float = 3f, ) { - // Border - Polyline( - points = points, - color = borderColor, - opacity = borderOpacity, - lineWidth = lineWidth + borderWidth * 2f, - zIndex = zIndex, + val routeJson = lineStringFeatureCollectionJson(points) ?: return + val routeSource = rememberGeoJsonSource(GeoJsonData.JsonString(routeJson)) + + LineLayer( + id = "$idPrefix-border", + source = routeSource, + color = const(borderColor.copy(alpha = borderOpacity)), + width = const((lineWidth + borderWidth * 2f).dp), + cap = const(LineCap.Round), + join = const(LineJoin.Round), ) - // Body - Polyline( - points = points, - color = color, - opacity = opacity, - lineWidth = lineWidth, - zIndex = zIndex, + LineLayer( + id = "$idPrefix-fill", + source = routeSource, + color = const(color.copy(alpha = opacity)), + width = const(lineWidth.dp), + cap = const(LineCap.Round), + join = const(LineJoin.Round), ) } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteGeoJson.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteGeoJson.kt new file mode 100644 index 000000000..d10797497 --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteGeoJson.kt @@ -0,0 +1,20 @@ +package com.stadiamaps.ferrostar.maplibreui.routeline + +import java.util.logging.Logger +import uniffi.ferrostar.GeographicCoordinate + +private val routeGeoJsonLogger: Logger = Logger.getLogger("RouteGeoJson") + +internal fun lineStringFeatureCollectionJson(points: List): String? { + if (points.size < 2) { + routeGeoJsonLogger.warning( + "Skipping route line render because fewer than 2 points were provided." + ) + return null + } + + val coordinates = points.joinToString(separator = ",") { "[${it.lng},${it.lat}]" } + return """ + {"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[$coordinates]},"properties":{}}]} + """.trimIndent() +} diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteOverlayBuilder.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteOverlayBuilder.kt index 38539672c..7f6168a7a 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteOverlayBuilder.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteOverlayBuilder.kt @@ -1,9 +1,8 @@ package com.stadiamaps.ferrostar.maplibreui.routeline import androidx.compose.runtime.Composable -import com.maplibre.compose.ramani.MapLibreComposable import com.stadiamaps.ferrostar.core.NavigationUiState -import org.maplibre.android.geometry.LatLng +import org.maplibre.compose.util.MaplibreComposable /** * A Route Overlay (Polyline) Builder with sensible defaults - showing the full Navigation Route @@ -17,7 +16,7 @@ import org.maplibre.android.geometry.LatLng data class RouteOverlayBuilder( internal val navigationPath: @Composable - @MapLibreComposable + @MaplibreComposable (uiState: NavigationUiState) -> Unit ) { companion object { @@ -25,7 +24,7 @@ data class RouteOverlayBuilder( RouteOverlayBuilder( navigationPath = { uiState -> uiState.routeGeometry?.let { geometry -> - BorderedPolyline(points = geometry.map { LatLng(it.lat, it.lng) }, zIndex = 0) + BorderedPolyline(points = geometry) } }) } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/FerrostarLocation.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/FerrostarLocation.kt new file mode 100644 index 000000000..721ab3790 --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/FerrostarLocation.kt @@ -0,0 +1,46 @@ +package com.stadiamaps.ferrostar.maplibreui.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.time.TimeSource +import org.maplibre.compose.location.Location +import org.maplibre.compose.location.LocationProvider +import org.maplibre.compose.location.UserLocationState +import org.maplibre.compose.location.rememberUserLocationState +import org.maplibre.spatialk.geojson.Position +import uniffi.ferrostar.UserLocation + +internal fun UserLocation.toMapLibreLocation(): Location = + Location( + position = Position(coordinates.lng, coordinates.lat), + accuracy = horizontalAccuracy, + bearing = courseOverGround?.degrees?.toDouble(), + bearingAccuracy = courseOverGround?.accuracy?.toDouble(), + speed = speed?.value, + speedAccuracy = speed?.accuracy, + timestamp = TimeSource.Monotonic.markNow(), + ) + +private class FerrostarLocationProvider : LocationProvider { + private val locationState = MutableStateFlow(null) + + override val location: StateFlow = locationState + + fun update(location: Location?) { + locationState.value = location + } +} + +@Composable +internal fun rememberFerrostarLocationState(userLocation: UserLocation?): UserLocationState { + val provider = remember { FerrostarLocationProvider() } + + LaunchedEffect(userLocation) { + provider.update(userLocation?.toMapLibreLocation()) + } + + return rememberUserLocationState(provider) +} diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapControls.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapControls.kt index 527c0c4d2..04fc8f5b2 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapControls.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapControls.kt @@ -1,93 +1,60 @@ package com.stadiamaps.ferrostar.maplibreui.runtime -import android.annotation.SuppressLint import android.content.res.Configuration +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.systemBars import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.maplibre.compose.settings.AttributionSettings -import com.maplibre.compose.settings.CompassSettings -import com.maplibre.compose.settings.LogoSettings -import com.maplibre.compose.settings.MapControlPosition -import com.maplibre.compose.settings.MapControls import com.stadiamaps.ferrostar.composeui.runtime.paddingForGridView +import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.map.OrnamentOptions /** - * Returns the map controls for the given configuration. - * - * @param progressViewHeight The height of the progress view. - * @param horizontalPadding The horizontal padding to apply to the map controls. Defaults to 16.dp - * to match the default padding of the progress view. - * @param verticalPadding The vertical padding to apply to the map controls from the top of the - * progress view. Defaults to 8.dp. - * - * TODO: This function is attempting to optimize the map controls for many screen sizes, system - * bars, and orientations. We should remain open to feedback for specific cases. - * TODO: Remove this suppress lint w/ https://issuetracker.google.com/issues/349411310 + * Returns map options that keep the built-in ornaments clear of navigation overlays while leaving + * gesture handling enabled. */ -@SuppressLint("ProduceStateDoesNotAssignValue") @Composable -internal fun rememberMapControlsForProgressViewHeight( +internal fun rememberMapOptionsForProgressViewHeight( progressViewHeight: Dp = 0.dp, horizontalPadding: Dp = 16.dp, - verticalPadding: Dp = 8.dp -): State { + verticalPadding: Dp = 8.dp, +): MapOptions { val layoutDirection = LocalLayoutDirection.current - val density = LocalDensity.current val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - val gridPadding = paddingForGridView() val windowInsetPadding = WindowInsets.systemBars.asPaddingValues() - return produceState( - initialValue = MapControls(), - key1 = progressViewHeight, - key2 = gridPadding, - key3 = windowInsetPadding) { - // This calculation clamps the controls to the trailing edge of the screen in landscape mode - // with less padding in that case. The reason for this is that with edge-to-edge, there's - // a larger map canvas available. - val endPaddingDp = - windowInsetPadding.calculateEndPadding(layoutDirection) + - gridPadding.calculateEndPadding(layoutDirection) - val endOffsetDp = if (isLandscape) endPaddingDp else horizontalPadding - - val bottomPaddingDp = - windowInsetPadding.calculateBottomPadding() + gridPadding.calculateBottomPadding() - val bottomOffsetDp = - if (isLandscape) bottomPaddingDp else bottomPaddingDp + progressViewHeight - - // TODO: This could be improved if we want to add pixel width to dp conversion in - // maplibre-compose. - val attributionOffset = 24.dp + return remember(progressViewHeight, horizontalPadding, verticalPadding, isLandscape, gridPadding, windowInsetPadding) { + val endPadding = + windowInsetPadding.calculateEndPadding(layoutDirection) + + gridPadding.calculateEndPadding(layoutDirection) + + horizontalPadding + val bottomPadding = + windowInsetPadding.calculateBottomPadding() + + gridPadding.calculateBottomPadding() + + if (isLandscape) { + verticalPadding + } else { + progressViewHeight + verticalPadding + } - value = - MapControls( - attribution = - AttributionSettings.initWithLayoutAndPosition( - layoutDirection, - density, - position = - MapControlPosition.BottomEnd( - horizontal = endOffsetDp, - vertical = bottomOffsetDp + verticalPadding)), - compass = CompassSettings(enabled = false), - logo = - LogoSettings.initWithLayoutAndPosition( - layoutDirection, - density, - position = - MapControlPosition.BottomEnd( - horizontal = endOffsetDp + attributionOffset, - vertical = bottomOffsetDp + verticalPadding))) - } + MapOptions( + ornamentOptions = + OrnamentOptions( + padding = PaddingValues(end = endPadding, bottom = bottomPadding), + isCompassEnabled = false, + isScaleBarEnabled = false, + logoAlignment = Alignment.BottomStart, + attributionAlignment = Alignment.BottomEnd, + ), + ) + } } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt new file mode 100644 index 000000000..cac9557bf --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt @@ -0,0 +1,36 @@ +package com.stadiamaps.ferrostar.maplibreui.runtime + +import android.util.Log +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.compose.camera.CameraState + +private const val MAP_READY_TAG = "NavigationMapView" + +internal fun CameraState.nativeStyleOrNull(): Style? { + val mapAdapter = + runCatching { + javaClass.getMethod("getMap\$maplibre_compose").invoke(this) + } + .onFailure { + Log.w(MAP_READY_TAG, "Unable to read compose map adapter for onMapReadyCallback", it) + } + .getOrNull() ?: return null + + val rawMap = + runCatching { + val field = mapAdapter.javaClass.getDeclaredField("map") + field.isAccessible = true + field.get(mapAdapter) as? MapLibreMap + } + .onFailure { + Log.w(MAP_READY_TAG, "Unable to read MapLibreMap from compose adapter", it) + } + .getOrNull() + + return rawMap?.style.also { style -> + if (style == null) { + Log.w(MAP_READY_TAG, "onMapReadyCallback could not access native Style from compose map") + } + } +} diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt index b5948e3d6..946b11249 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt @@ -1,39 +1,97 @@ package com.stadiamaps.ferrostar.maplibreui.runtime import android.content.res.Configuration +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalConfiguration -import com.maplibre.compose.camera.MapViewCamera -import com.maplibre.compose.camera.models.CameraPadding +import androidx.compose.ui.unit.dp +import com.stadiamaps.ferrostar.core.BoundingBox +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.CameraState +import org.maplibre.spatialk.geojson.Position -sealed class NavigationActivity(val zoom: Double, val pitch: Double) { +sealed class NavigationActivity(val zoom: Double, val tilt: Double) { /** The recommended camera configuration for automotive navigation. */ - data object Automotive : NavigationActivity(zoom = 16.0, pitch = 45.0) + data object Automotive : NavigationActivity(zoom = 16.0, tilt = 45.0) /** The recommended camera configuration for bicycle navigation. */ - data object Bicycle : NavigationActivity(zoom = 18.0, pitch = 45.0) + data object Bicycle : NavigationActivity(zoom = 18.0, tilt = 45.0) /** The recommended camera configuration for pedestrian navigation. */ - data object Pedestrian : NavigationActivity(zoom = 20.0, pitch = 10.0) + data object Pedestrian : NavigationActivity(zoom = 20.0, tilt = 10.0) +} + +enum class NavigationCameraMode { + FOLLOW_USER, + FOLLOW_USER_WITH_BEARING, + OVERVIEW, + FREE; + + fun tracksLocation(): Boolean = + this == FOLLOW_USER || this == FOLLOW_USER_WITH_BEARING +} + +data class NavigationCameraOptions( + val browsingZoom: Double, + val navigationZoom: Double, + val navigationTilt: Double, + val browsingPadding: PaddingValues, + val navigationPadding: PaddingValues, +) { + fun browsingUser(target: Position): CameraPosition = + CameraPosition( + target = target, + zoom = browsingZoom, + tilt = 0.0, + bearing = 0.0, + padding = browsingPadding, + ) + + fun navigatingUser(target: Position, bearing: Double = 0.0): CameraPosition = + CameraPosition( + target = target, + zoom = navigationZoom, + tilt = navigationTilt, + bearing = bearing, + padding = navigationPadding, + ) } /** - * The camera configuration for navigation. This configuration sets the camera to track the user, - * with a high zoom level and moderate pitch for a 2.5D isometric view. It automatically adjusts the - * padding based on the screen size and orientation. - * - * @param activity The type of activity the camera is being used for. - * @return The recommended navigation MapViewCamera + * Returns the recommended camera configuration for navigation. The default keeps the user's + * location lower in the viewport to leave room for instructions and overlays. */ @Composable -fun navigationMapViewCamera( +fun navigationCameraOptions( activity: NavigationActivity = NavigationActivity.Automotive, -): MapViewCamera { +): NavigationCameraOptions { val screenOrientation = LocalConfiguration.current.orientation - val start = if (screenOrientation == Configuration.ORIENTATION_LANDSCAPE) 0.5f else 0.0f + val start = if (screenOrientation == Configuration.ORIENTATION_LANDSCAPE) 180.dp else 0.dp + + return NavigationCameraOptions( + browsingZoom = activity.zoom, + navigationZoom = activity.zoom, + navigationTilt = activity.tilt, + browsingPadding = PaddingValues(0.dp), + navigationPadding = PaddingValues(start = start, top = 240.dp), + ) +} + +fun defaultNavigationCameraMode(isNavigating: Boolean): NavigationCameraMode = + if (isNavigating) { + NavigationCameraMode.FOLLOW_USER_WITH_BEARING + } else { + NavigationCameraMode.FOLLOW_USER + } - val cameraPadding = CameraPadding.fractionOfScreen(start = start, top = 0.5f) +fun BoundingBox.toMapLibreBoundingBox(): org.maplibre.spatialk.geojson.BoundingBox = + org.maplibre.spatialk.geojson.BoundingBox( + west = west, + south = south, + east = east, + north = north, + ) - return MapViewCamera.TrackingUserLocationWithBearing( - zoom = activity.zoom, pitch = activity.pitch, padding = cameraPadding) +fun CameraState.incrementZoom(delta: Double) { + position = position.copy(zoom = (position.zoom + delta).coerceAtLeast(0.0)) } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt new file mode 100644 index 000000000..5858d390e --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt @@ -0,0 +1,86 @@ +package com.stadiamaps.ferrostar.maplibreui.runtime + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.stadiamaps.ferrostar.core.BoundingBox +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.camera.rememberCameraState + +@Stable +class NavigationMapState +internal constructor( + internal val cameraState: CameraState, + initialCameraMode: NavigationCameraMode, + navigationCameraOptions: NavigationCameraOptions, + private val coroutineScope: CoroutineScope, +) { + private var routeOverviewJob: Job? = null + + var cameraMode by mutableStateOf(initialCameraMode) + + var navigationCameraOptions by mutableStateOf(navigationCameraOptions) + + val isTrackingUser: Boolean + get() = cameraMode.tracksLocation() + + fun recenter(isNavigating: Boolean) { + cameraMode = defaultNavigationCameraMode(isNavigating) + } + + fun zoomIn(delta: Double = 1.0) { + cameraState.incrementZoom(delta) + } + + fun zoomOut(delta: Double = 1.0) { + cameraState.incrementZoom(-delta) + } + + fun showRouteOverview( + boundingBox: BoundingBox, + paddingValues: PaddingValues = PaddingValues(), + duration: Duration = 0.milliseconds, + ) { + cameraMode = NavigationCameraMode.OVERVIEW + routeOverviewJob?.cancel() + routeOverviewJob = + coroutineScope.launch { + cameraState.animateTo( + boundingBox = boundingBox.toMapLibreBoundingBox(), + padding = paddingValues, + duration = duration, + ) + } + } +} + +@Composable +fun rememberNavigationMapState( + initialCameraMode: NavigationCameraMode = defaultNavigationCameraMode(isNavigating = false), + navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), +): NavigationMapState { + val cameraState = rememberCameraState() + val coroutineScope = rememberCoroutineScope() + val navigationMapState = + remember(cameraState, coroutineScope) { + NavigationMapState( + cameraState = cameraState, + initialCameraMode = initialCameraMode, + navigationCameraOptions = navigationCameraOptions, + coroutineScope = coroutineScope, + ) + } + + navigationMapState.navigationCameraOptions = navigationCameraOptions + return navigationMapState +} diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt index 2a846fecb..2123266c3 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt @@ -17,11 +17,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import com.maplibre.compose.camera.MapViewCamera -import com.maplibre.compose.camera.extensions.incrementZoom -import com.maplibre.compose.ramani.LocationRequestProperties -import com.maplibre.compose.ramani.MapLibreComposable -import com.maplibre.compose.rememberSaveableMapViewCamera import com.stadiamaps.ferrostar.composeui.config.NavigationViewComponentBuilder import com.stadiamaps.ferrostar.composeui.config.VisualNavigationViewConfig import com.stadiamaps.ferrostar.composeui.runtime.paddingForGridView @@ -32,77 +27,63 @@ import com.stadiamaps.ferrostar.composeui.views.overlays.PortraitNavigationOverl import com.stadiamaps.ferrostar.core.NavigationUiState import com.stadiamaps.ferrostar.core.NavigationViewModel import com.stadiamaps.ferrostar.core.boundingBox +import com.stadiamaps.ferrostar.maplibreui.NavigationMapClickHandler +import com.stadiamaps.ferrostar.maplibreui.NavigationMapClickResult import com.stadiamaps.ferrostar.maplibreui.NavigationMapView -import com.stadiamaps.ferrostar.maplibreui.extensions.NavigationDefault +import com.stadiamaps.ferrostar.maplibreui.NavigationMapPuckStyle import com.stadiamaps.ferrostar.maplibreui.extensions.cameraControlState import com.stadiamaps.ferrostar.maplibreui.routeline.RouteOverlayBuilder -import com.stadiamaps.ferrostar.maplibreui.runtime.navigationMapViewCamera -import com.stadiamaps.ferrostar.maplibreui.runtime.rememberMapControlsForProgressViewHeight +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState +import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.rememberMapOptionsForProgressViewHeight +import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState +import org.maplibre.compose.util.MaplibreComposable /** - * A dynamically orienting navigation view that switches between portrait and landscape orientations - * based on the device's current orientation. - * - * @param modifier The modifier to apply to the view. - * @param styleUrl The MapLibre style URL to use for the map. - * @param camera The bi-directional camera state to use for the map. - * @param navigationCamera The default camera state to use for navigation. This is a *template* - * value, which will be applied on initial display and when re-centering. The default value is - * sufficient for most applications. If you set a custom value (e.g.) to change the pitch), you - * must ensure that it is some variation on [MapViewCamera.TrackingUserLocationWithBearing]. - * @param viewModel The navigation view model (see - * [com.stadiamaps.ferrostar.core.DefaultNavigationViewModel] for a common implementation]). - * @param locationRequestProperties The location request properties to use for the map's location - * engine. - * @param routeOverlayBuilder The route overlay builder to use for rendering the route line on the - * MapView. - * @param theme The navigation UI theme to use for the view. - * @param config The configuration for the navigation view. - * @param views The navigation view component builder to use for the view. - * @param mapViewInsets The padding inset representing the open area of the map. - * @param onTapExit The callback to invoke when the exit button is tapped. - * @param mapContent Any additional composable map symbol content to render. + * A dynamically orienting navigation view that switches between portrait and landscape overlays + * based on the current device orientation. */ @Composable fun DynamicallyOrientingNavigationView( modifier: Modifier, styleUrl: String, - camera: MutableState = rememberSaveableMapViewCamera(), - navigationCamera: MapViewCamera = navigationMapViewCamera(), + navigationMapState: NavigationMapState = rememberNavigationMapState(), + navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, - locationRequestProperties: LocationRequestProperties = - LocationRequestProperties.NavigationDefault(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle.Default(), theme: NavigationUITheme = DefaultNavigationUITheme, config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), views: NavigationViewComponentBuilder = NavigationViewComponentBuilder.Default(theme), mapViewInsets: MutableState = remember { mutableStateOf(PaddingValues(0.dp)) }, routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), onTapExit: (() -> Unit)? = null, - mapContent: @Composable @MapLibreComposable ((NavigationUiState) -> Unit)? = null, + onMapClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, + onMapLongClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, + mapContent: @Composable @MaplibreComposable ((NavigationUiState) -> Unit)? = null, ) { val orientation = LocalConfiguration.current.orientation - // Maintain the actual size of the progress view for dynamic layout purposes. val rememberProgressViewSize = remember { mutableStateOf(DpSize.Zero) } val progressViewSize by rememberProgressViewSize val uiState by viewModel.navigationUiState.collectAsState() - // Get the correct padding based on edge-to-edge status. val gridPadding = paddingForGridView() - - // Get the map control positioning based on the progress view. - val mapControls = rememberMapControlsForProgressViewHeight(progressViewSize.height) + val mapOptions = rememberMapOptionsForProgressViewHeight(progressViewSize.height) Box(modifier) { NavigationMapView( styleUrl = styleUrl, - camera = camera, - navigationCamera = navigationCamera, + navigationMapState = navigationMapState, uiState = uiState, - mapControls = mapControls, - locationRequestProperties = locationRequestProperties, + mapOptions = mapOptions, + navigationCameraOptions = navigationCameraOptions, routeOverlayBuilder = routeOverlayBuilder, - content = mapContent) + locationPuckStyle = locationPuckStyle, + onMapClick = onMapClick, + onMapLongClick = onMapLongClick, + content = mapContent, + ) if (uiState.isNavigating()) { when (orientation) { @@ -112,18 +93,19 @@ fun DynamicallyOrientingNavigationView( viewModel = viewModel, cameraControlState = config.cameraControlState( - camera = camera, - navigationCamera = navigationCamera, + navigationMapState = navigationMapState, + isNavigating = true, mapViewInsets = mapViewInsets.value, boundingBox = uiState.routeGeometry?.boundingBox(), ), theme = theme, config = config, - onClickZoomIn = { camera.value = camera.value.incrementZoom(1.0) }, - onClickZoomOut = { camera.value = camera.value.incrementZoom(-1.0) }, + onClickZoomIn = { navigationMapState.zoomIn() }, + onClickZoomOut = { navigationMapState.zoomOut() }, views = views, mapViewInsets = mapViewInsets, - onTapExit = onTapExit) + onTapExit = onTapExit, + ) } else -> { @@ -132,18 +114,19 @@ fun DynamicallyOrientingNavigationView( viewModel = viewModel, cameraControlState = config.cameraControlState( - camera = camera, - navigationCamera = navigationCamera, + navigationMapState = navigationMapState, + isNavigating = true, mapViewInsets = mapViewInsets.value, boundingBox = uiState.routeGeometry?.boundingBox(), ), theme = theme, config = config, - onClickZoomIn = { camera.value = camera.value.incrementZoom(1.0) }, - onClickZoomOut = { camera.value = camera.value.incrementZoom(-1.0) }, + onClickZoomIn = { navigationMapState.zoomIn() }, + onClickZoomOut = { navigationMapState.zoomOut() }, views = views, mapViewInsets = mapViewInsets, - onTapExit = onTapExit) + onTapExit = onTapExit, + ) } } } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt index d6941ef2b..d8d45df90 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt @@ -16,11 +16,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.maplibre.compose.camera.MapViewCamera -import com.maplibre.compose.camera.extensions.incrementZoom -import com.maplibre.compose.ramani.LocationRequestProperties -import com.maplibre.compose.ramani.MapLibreComposable -import com.maplibre.compose.rememberSaveableMapViewCamera import com.stadiamaps.ferrostar.composeui.config.NavigationViewComponentBuilder import com.stadiamaps.ferrostar.composeui.config.VisualNavigationViewConfig import com.stadiamaps.ferrostar.composeui.runtime.paddingForGridView @@ -32,92 +27,75 @@ import com.stadiamaps.ferrostar.core.NavigationViewModel import com.stadiamaps.ferrostar.core.boundingBox import com.stadiamaps.ferrostar.core.mock.MockNavigationViewModel import com.stadiamaps.ferrostar.core.mock.pedestrianExample +import com.stadiamaps.ferrostar.maplibreui.NavigationMapClickHandler +import com.stadiamaps.ferrostar.maplibreui.NavigationMapClickResult import com.stadiamaps.ferrostar.maplibreui.NavigationMapView -import com.stadiamaps.ferrostar.maplibreui.extensions.NavigationDefault +import com.stadiamaps.ferrostar.maplibreui.NavigationMapPuckStyle import com.stadiamaps.ferrostar.maplibreui.extensions.cameraControlState import com.stadiamaps.ferrostar.maplibreui.routeline.RouteOverlayBuilder -import com.stadiamaps.ferrostar.maplibreui.runtime.navigationMapViewCamera -import com.stadiamaps.ferrostar.maplibreui.runtime.rememberMapControlsForProgressViewHeight +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState +import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.rememberMapOptionsForProgressViewHeight +import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import org.maplibre.compose.util.MaplibreComposable -/** - * A portrait orientation of the navigation view with instructions, default controls and the - * navigation map view. - * - * @param modifier The modifier to apply to the view. - * @param styleUrl The MapLibre style URL to use for the map. - * @param camera The bi-directional camera state to use for the map. - * @param navigationCamera The default camera state to use for navigation. This is a *template* - * value, which will be applied on initial display and when re-centering. The default value is - * sufficient for most applications. If you set a custom value (e.g.) to change the pitch), you - * must ensure that it is some variation on [MapViewCamera.TrackingUserLocationWithBearing]. - * @param viewModel The navigation view model (see - * [com.stadiamaps.ferrostar.core.DefaultNavigationViewModel] for a common implementation]). - * @param locationRequestProperties The location request properties to use for the map's location - * engine. - * @param routeOverlayBuilder The route overlay builder to use for rendering the route line on the - * MapView. - * @param theme The navigation UI theme to use for the view. - * @param config The configuration for the navigation view. - * @param views The navigation view component builder to use for the view. - * @param mapViewInsets The padding inset representing the open area of the map. - * @param onTapExit The callback to invoke when the exit button is tapped. - * @param mapContent Any additional composable map symbol content to render. - */ @Composable fun LandscapeNavigationView( modifier: Modifier, styleUrl: String, - camera: MutableState = rememberSaveableMapViewCamera(), - navigationCamera: MapViewCamera = navigationMapViewCamera(), + navigationMapState: NavigationMapState = rememberNavigationMapState(), + navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, - locationRequestProperties: LocationRequestProperties = - LocationRequestProperties.NavigationDefault(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle.Default(), theme: NavigationUITheme = DefaultNavigationUITheme, config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), views: NavigationViewComponentBuilder = NavigationViewComponentBuilder.Default(theme), mapViewInsets: MutableState = remember { mutableStateOf(PaddingValues(0.dp)) }, routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), onTapExit: (() -> Unit)? = null, - mapContent: @Composable @MapLibreComposable() ((NavigationUiState) -> Unit)? = null, + onMapClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, + onMapLongClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, + mapContent: @Composable @MaplibreComposable ((NavigationUiState) -> Unit)? = null, ) { val uiState by viewModel.navigationUiState.collectAsState() - - // Get the correct padding based on edge-to-edge status. val gridPadding = paddingForGridView() - - val mapControls = rememberMapControlsForProgressViewHeight() + val mapOptions = rememberMapOptionsForProgressViewHeight() Box(modifier) { NavigationMapView( - styleUrl, - camera, - navigationCamera, - uiState, - mapControls, - locationRequestProperties, - routeOverlayBuilder, - onMapReadyCallback = { camera.value = navigationCamera }, - mapContent) + styleUrl = styleUrl, + navigationMapState = navigationMapState, + uiState = uiState, + mapOptions = mapOptions, + navigationCameraOptions = navigationCameraOptions, + routeOverlayBuilder = routeOverlayBuilder, + locationPuckStyle = locationPuckStyle, + onMapClick = onMapClick, + onMapLongClick = onMapLongClick, + content = mapContent, + ) LandscapeNavigationOverlayView( modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars).padding(gridPadding), viewModel = viewModel, cameraControlState = config.cameraControlState( - camera = camera, - navigationCamera = navigationCamera, + navigationMapState = navigationMapState, + isNavigating = uiState.isNavigating(), mapViewInsets = mapViewInsets.value, boundingBox = uiState.routeGeometry?.boundingBox(), ), theme = theme, config = config, - onClickZoomIn = { camera.value = camera.value.incrementZoom(1.0) }, - onClickZoomOut = { camera.value = camera.value.incrementZoom(-1.0) }, + onClickZoomIn = { navigationMapState.zoomIn() }, + onClickZoomOut = { navigationMapState.zoomOut() }, views = views, mapViewInsets = mapViewInsets, - onTapExit = onTapExit) + onTapExit = onTapExit, + ) views.getCustomOverlayView()?.let { customOverlayView -> customOverlayView(Modifier.windowInsetsPadding(WindowInsets.systemBars).padding(gridPadding)) @@ -135,7 +113,8 @@ val previewViewModel = @Composable private fun LandscapeNavigationViewPreview() { LandscapeNavigationView( - Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), styleUrl = "https://demotiles.maplibre.org/style.json", - viewModel = previewViewModel) + viewModel = previewViewModel, + ) } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt index 7d95c3f41..8b8d6bd3d 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt @@ -1,6 +1,5 @@ package com.stadiamaps.ferrostar.maplibreui.views -import android.util.Log import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets @@ -9,7 +8,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -19,11 +17,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.maplibre.compose.camera.MapViewCamera -import com.maplibre.compose.camera.extensions.incrementZoom -import com.maplibre.compose.ramani.LocationRequestProperties -import com.maplibre.compose.ramani.MapLibreComposable -import com.maplibre.compose.rememberSaveableMapViewCamera import com.stadiamaps.ferrostar.composeui.config.NavigationViewComponentBuilder import com.stadiamaps.ferrostar.composeui.config.VisualNavigationViewConfig import com.stadiamaps.ferrostar.composeui.runtime.paddingForGridView @@ -33,12 +26,18 @@ import com.stadiamaps.ferrostar.composeui.views.overlays.PortraitNavigationOverl import com.stadiamaps.ferrostar.core.NavigationUiState import com.stadiamaps.ferrostar.core.NavigationViewModel import com.stadiamaps.ferrostar.core.boundingBox +import com.stadiamaps.ferrostar.maplibreui.NavigationMapClickHandler +import com.stadiamaps.ferrostar.maplibreui.NavigationMapClickResult import com.stadiamaps.ferrostar.maplibreui.NavigationMapView -import com.stadiamaps.ferrostar.maplibreui.extensions.NavigationDefault +import com.stadiamaps.ferrostar.maplibreui.NavigationMapPuckStyle import com.stadiamaps.ferrostar.maplibreui.extensions.cameraControlState import com.stadiamaps.ferrostar.maplibreui.routeline.RouteOverlayBuilder -import com.stadiamaps.ferrostar.maplibreui.runtime.navigationMapViewCamera -import com.stadiamaps.ferrostar.maplibreui.runtime.rememberMapControlsForProgressViewHeight +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState +import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.rememberMapOptionsForProgressViewHeight +import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState +import org.maplibre.compose.util.MaplibreComposable /** * A portrait orientation of the navigation view with instructions, default controls and the @@ -46,66 +45,60 @@ import com.stadiamaps.ferrostar.maplibreui.runtime.rememberMapControlsForProgres * * @param modifier The modifier to apply to the view. * @param styleUrl The MapLibre style URL to use for the map. - * @param camera The bi-directional camera state to use for the map. - * @param navigationCamera The default camera state to use for navigation. This is a *template* - * value, which will be applied on initial display and when re-centering. The default value is - * sufficient for most applications. If you set a custom value (e.g.) to change the pitch), you - * must ensure that it is some variation on [MapViewCamera.TrackingUserLocationWithBearing]. + * @param navigationMapState The Ferrostar-owned map state used to coordinate follow, overview, + * free-camera behavior, and zoom actions. + * @param navigationCameraOptions The camera templates applied when following the user in browsing + * and navigation modes. * @param viewModel The navigation view model (see * [com.stadiamaps.ferrostar.core.DefaultNavigationViewModel] for a common implementation]). - * @param locationRequestProperties The location request properties to use for the map's location - * engine. - * @param routeOverlayBuilder The route overlay builder to use for rendering the route line on the - * MapView. + * @param locationPuckStyle The style to use for the official MapLibre location puck. * @param theme The navigation UI theme to use for the view. * @param config The configuration for the navigation view. * @param views The navigation view component builder to use for the view. * @param mapViewInsets The padding inset representing the open area of the map. + * @param routeOverlayBuilder The route overlay builder to use for rendering the route line. * @param onTapExit The callback to invoke when the exit button is tapped. + * @param onMapClick Callback invoked for taps on the map with geographic coordinates and screen + * position. + * @param onMapLongClick Callback invoked for long presses on the map with geographic coordinates + * and screen position. * @param mapContent Any additional composable map symbol content to render. */ @Composable fun PortraitNavigationView( modifier: Modifier, styleUrl: String, - camera: MutableState = rememberSaveableMapViewCamera(), - navigationCamera: MapViewCamera = navigationMapViewCamera(), + navigationMapState: NavigationMapState = rememberNavigationMapState(), + navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, - locationRequestProperties: LocationRequestProperties = - LocationRequestProperties.NavigationDefault(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle.Default(), theme: NavigationUITheme = DefaultNavigationUITheme, config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), views: NavigationViewComponentBuilder = NavigationViewComponentBuilder.Default(theme), mapViewInsets: MutableState = remember { mutableStateOf(PaddingValues(0.dp)) }, routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), onTapExit: (() -> Unit)? = null, - mapContent: @Composable @MapLibreComposable() ((NavigationUiState) -> Unit)? = null, + onMapClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, + onMapLongClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, + mapContent: @Composable @MaplibreComposable ((NavigationUiState) -> Unit)? = null, ) { val uiState by viewModel.navigationUiState.collectAsState() - - LaunchedEffect(mapViewInsets.value) { - Log.d("PortraitNavigationView", "mapViewInsets.value: ${mapViewInsets.value}") - } - - // Get the correct padding based on edge-to-edge status. val gridPadding = paddingForGridView() - - // Get the map control positioning based on the progress view. - // TODO: I think we should just remove all annotations for nav & make a better tool if needed. - // val mapControls = rememberMapControlsForProgressViewHeight(progressViewSize.height) - val mapControls = rememberMapControlsForProgressViewHeight() + val mapOptions = rememberMapOptionsForProgressViewHeight() Box(modifier) { NavigationMapView( - styleUrl, - camera, - navigationCamera, - uiState, - mapControls, - locationRequestProperties, - routeOverlayBuilder, - onMapReadyCallback = { camera.value = navigationCamera }, - mapContent) + styleUrl = styleUrl, + navigationMapState = navigationMapState, + uiState = uiState, + mapOptions = mapOptions, + navigationCameraOptions = navigationCameraOptions, + routeOverlayBuilder = routeOverlayBuilder, + locationPuckStyle = locationPuckStyle, + onMapClick = onMapClick, + onMapLongClick = onMapLongClick, + content = mapContent, + ) if (uiState.isNavigating()) { PortraitNavigationOverlayView( @@ -113,18 +106,19 @@ fun PortraitNavigationView( viewModel = viewModel, cameraControlState = config.cameraControlState( - camera = camera, - navigationCamera = navigationCamera, + navigationMapState = navigationMapState, + isNavigating = true, mapViewInsets = mapViewInsets.value, boundingBox = uiState.routeGeometry?.boundingBox(), ), theme = theme, config = config, - onClickZoomIn = { camera.value = camera.value.incrementZoom(1.0) }, - onClickZoomOut = { camera.value = camera.value.incrementZoom(-1.0) }, + onClickZoomIn = { navigationMapState.zoomIn() }, + onClickZoomOut = { navigationMapState.zoomOut() }, views = views, mapViewInsets = mapViewInsets, - onTapExit = onTapExit) + onTapExit = onTapExit, + ) views.getCustomOverlayView()?.let { customOverlayView -> customOverlayView( @@ -138,7 +132,8 @@ fun PortraitNavigationView( @Composable private fun PortraitNavigationViewPreview() { PortraitNavigationView( - Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), styleUrl = "https://demotiles.maplibre.org/style.json", - viewModel = previewViewModel) + viewModel = previewViewModel, + ) } diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyleTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyleTest.kt new file mode 100644 index 000000000..a8c274f0e --- /dev/null +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyleTest.kt @@ -0,0 +1,22 @@ +package com.stadiamaps.ferrostar.maplibreui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class NavigationMapPuckStyleTest { + @Test + fun defaultPuckStyleMatchesFerrostarColorsAndSizes() { + val style = NavigationMapPuckStyle.Default() + + assertEquals(Color(0xFF3583DD), style.dotFillColorCurrentLocation) + assertEquals(Color(0xFF0F5FB8), style.bearingColor) + assertEquals(6.dp, style.dotRadius) + assertEquals(3.dp, style.dotStrokeWidth) + assertTrue(style.showBearing) + assertFalse(style.showBearingAccuracy) + } +} diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteGeoJsonTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteGeoJsonTest.kt new file mode 100644 index 000000000..157a9497b --- /dev/null +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteGeoJsonTest.kt @@ -0,0 +1,28 @@ +package com.stadiamaps.ferrostar.maplibreui.routeline + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import uniffi.ferrostar.GeographicCoordinate + +class RouteGeoJsonTest { + @Test + fun returnsNullWhenTooFewPointsExist() { + assertNull(lineStringFeatureCollectionJson(listOf(GeographicCoordinate(48.2, 16.3)))) + } + + @Test + fun serializesLineStringInLongitudeLatitudeOrder() { + val json = + lineStringFeatureCollectionJson( + listOf( + GeographicCoordinate(48.2, 16.3), + GeographicCoordinate(48.3, 16.4), + )) + + assertEquals( + """{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[16.3,48.2],[16.4,48.3]]},"properties":{}}]}""", + json, + ) + } +} diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/FerrostarLocationTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/FerrostarLocationTest.kt new file mode 100644 index 000000000..0bb296f9d --- /dev/null +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/FerrostarLocationTest.kt @@ -0,0 +1,53 @@ +package com.stadiamaps.ferrostar.maplibreui.runtime + +import java.time.Instant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import uniffi.ferrostar.CourseOverGround +import uniffi.ferrostar.GeographicCoordinate +import uniffi.ferrostar.Speed +import uniffi.ferrostar.UserLocation + +class FerrostarLocationTest { + @Test + fun toMapLibreLocationMapsAllSupportedFields() { + val location = + UserLocation( + coordinates = GeographicCoordinate(48.2082, 16.3738), + horizontalAccuracy = 4.5, + courseOverGround = CourseOverGround(degrees = 123.toUShort(), accuracy = 9.toUShort()), + timestamp = Instant.EPOCH, + speed = Speed(value = 13.4, accuracy = 0.8), + ) + + val mapLibreLocation = location.toMapLibreLocation() + + assertEquals(16.3738, mapLibreLocation.position.longitude, 0.0) + assertEquals(48.2082, mapLibreLocation.position.latitude, 0.0) + assertEquals(4.5, mapLibreLocation.accuracy, 0.0) + assertEquals(123.0, mapLibreLocation.bearing!!, 0.0) + assertEquals(9.0, mapLibreLocation.bearingAccuracy!!, 0.0) + assertEquals(13.4, mapLibreLocation.speed!!, 0.0) + assertEquals(0.8, mapLibreLocation.speedAccuracy!!, 0.0) + } + + @Test + fun toMapLibreLocationLeavesOptionalFieldsNullWhenUnavailable() { + val location = + UserLocation( + coordinates = GeographicCoordinate(48.2082, 16.3738), + horizontalAccuracy = 12.0, + courseOverGround = null, + timestamp = Instant.EPOCH, + speed = null, + ) + + val mapLibreLocation = location.toMapLibreLocation() + + assertNull(mapLibreLocation.bearing) + assertNull(mapLibreLocation.bearingAccuracy) + assertNull(mapLibreLocation.speed) + assertNull(mapLibreLocation.speedAccuracy) + } +} diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt new file mode 100644 index 000000000..2cb9b9025 --- /dev/null +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt @@ -0,0 +1,63 @@ +package com.stadiamaps.ferrostar.maplibreui.runtime + +import androidx.compose.foundation.layout.PaddingValues +import com.stadiamaps.ferrostar.core.BoundingBox +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.maplibre.spatialk.geojson.Position + +class NavigationCameraTest { + @Test + fun defaultNavigationCameraModeUsesBearingWhenNavigating() { + assertEquals( + NavigationCameraMode.FOLLOW_USER_WITH_BEARING, + defaultNavigationCameraMode(isNavigating = true), + ) + assertEquals( + NavigationCameraMode.FOLLOW_USER, + defaultNavigationCameraMode(isNavigating = false), + ) + } + + @Test + fun tracksLocationOnlyForFollowingModes() { + assertTrue(NavigationCameraMode.FOLLOW_USER.tracksLocation()) + assertTrue(NavigationCameraMode.FOLLOW_USER_WITH_BEARING.tracksLocation()) + assertFalse(NavigationCameraMode.OVERVIEW.tracksLocation()) + assertFalse(NavigationCameraMode.FREE.tracksLocation()) + } + + @Test + fun mapLibreBoundingBoxPreservesEdges() { + val boundingBox = BoundingBox(north = 48.5, east = 16.7, south = 48.1, west = 16.2) + + val converted = boundingBox.toMapLibreBoundingBox() + + assertEquals(16.2, converted.west, 0.0) + assertEquals(48.1, converted.south, 0.0) + assertEquals(16.7, converted.east, 0.0) + assertEquals(48.5, converted.north, 0.0) + } + + @Test + fun browsingCameraIsTopDownAndNorthUp() { + val options = + NavigationCameraOptions( + browsingZoom = 16.0, + navigationZoom = 16.0, + navigationTilt = 45.0, + browsingPadding = PaddingValues(), + navigationPadding = PaddingValues(), + ) + + val browsing = options.browsingUser(Position(16.37, 48.21)) + val navigating = options.navigatingUser(Position(16.37, 48.21), bearing = 87.0) + + assertEquals(0.0, browsing.tilt, 0.0) + assertEquals(0.0, browsing.bearing, 0.0) + assertEquals(45.0, navigating.tilt, 0.0) + assertEquals(87.0, navigating.bearing, 0.0) + } +} diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt new file mode 100644 index 000000000..98282071d --- /dev/null +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt @@ -0,0 +1,97 @@ +package com.stadiamaps.ferrostar.maplibreui.runtime + +import androidx.compose.foundation.layout.PaddingValues +import com.stadiamaps.ferrostar.core.BoundingBox +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.CameraState + +class NavigationMapStateTest { + private val testScope = CoroutineScope(Job() + Dispatchers.Unconfined) + + @Test + fun recenterUsesBrowsingModeWhenNotNavigating() { + val state = createState(initialCameraMode = NavigationCameraMode.FREE) + + state.recenter(isNavigating = false) + + assertEquals(NavigationCameraMode.FOLLOW_USER, state.cameraMode) + assertTrue(state.isTrackingUser) + } + + @Test + fun recenterUsesBearingModeWhenNavigating() { + val state = createState(initialCameraMode = NavigationCameraMode.FREE) + + state.recenter(isNavigating = true) + + assertEquals(NavigationCameraMode.FOLLOW_USER_WITH_BEARING, state.cameraMode) + assertTrue(state.isTrackingUser) + } + + @Test + fun zoomHelpersAdjustCameraZoom() { + val cameraState = CameraState(CameraPosition(zoom = 10.0)) + val state = createState(cameraState = cameraState) + + state.zoomIn() + state.zoomOut(delta = 2.0) + + assertEquals(9.0, cameraState.position.zoom, 0.0) + } + + @Test + fun routeOverviewSwitchesToOverviewMode() { + val state = createState(initialCameraMode = NavigationCameraMode.FOLLOW_USER) + + state.showRouteOverview( + boundingBox = BoundingBox(north = 48.5, east = 16.7, south = 48.1, west = 16.2), + paddingValues = PaddingValues(), + ) + + assertEquals(NavigationCameraMode.OVERVIEW, state.cameraMode) + assertFalse(state.isTrackingUser) + } + + @Test + fun navigationCameraOptionsRemainMutable() { + val options = + NavigationCameraOptions( + browsingZoom = 12.0, + navigationZoom = 15.0, + navigationTilt = 30.0, + browsingPadding = PaddingValues(), + navigationPadding = PaddingValues(), + ) + val state = createState() + + state.navigationCameraOptions = options + + assertSame(options, state.navigationCameraOptions) + } + + private fun createState( + cameraState: CameraState = CameraState(CameraPosition()), + initialCameraMode: NavigationCameraMode = defaultNavigationCameraMode(isNavigating = false), + ): NavigationMapState = + NavigationMapState( + cameraState = cameraState, + initialCameraMode = initialCameraMode, + navigationCameraOptions = + NavigationCameraOptions( + browsingZoom = 16.0, + navigationZoom = 16.0, + navigationTilt = 45.0, + browsingPadding = PaddingValues(), + navigationPadding = PaddingValues(), + ), + coroutineScope = testScope, + ) +} From 2675759894a747f754b7cd5746b02cae271f3725 Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Wed, 25 Mar 2026 16:07:12 +0100 Subject: [PATCH 02/14] Migration to official maplibre-compose: Update `NavigationMapPuckStyle` and refine demo UI --- .../java/com/stadiamaps/ferrostar/DemoNavigationScene.kt | 4 ++-- .../ferrostar/maplibreui/NavigationMapPuckStyle.kt | 8 ++------ .../stadiamaps/ferrostar/maplibreui/NavigationMapView.kt | 2 +- .../views/DynamicallyOrientingNavigationView.kt | 2 +- .../ferrostar/maplibreui/views/LandscapeNavigationView.kt | 2 +- .../ferrostar/maplibreui/views/PortraitNavigationView.kt | 2 +- .../ferrostar/maplibreui/NavigationMapPuckStyleTest.kt | 4 ++-- 7 files changed, 10 insertions(+), 14 deletions(-) diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt index e1c20a395..c8704665a 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt @@ -117,8 +117,8 @@ private fun DemoDroppedPinOverlay(droppedPin: GeographicCoordinate?) { CircleLayer( id = "demo-dropped-pin", source = pointSource, - color = const(Color(0xFFD95F02)), - radius = const(8.dp), + color = const(Color.Green), + radius = const(12.dp), strokeColor = const(Color.White), strokeWidth = const(3.dp), ) diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyle.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyle.kt index 5b07728fd..ba1ec8320 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyle.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyle.kt @@ -14,12 +14,8 @@ data class NavigationMapPuckStyle( val accuracyStrokeColor: Color = Color(0xFF3583DD), val accuracyFillColor: Color = Color(0xFF3583DD).copy(alpha = 0.16f), val bearingColor: Color = Color(0xFF0F5FB8), - val dotRadius: Dp = 6.dp, + val dotRadius: Dp = 7.dp, val dotStrokeWidth: Dp = 3.dp, val showBearing: Boolean = true, val showBearingAccuracy: Boolean = false, -) { - companion object { - fun Default() = NavigationMapPuckStyle() - } -} +) diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt index caf609c2b..6025d20c3 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt @@ -64,7 +64,7 @@ fun NavigationMapView( mapOptions: MapOptions, routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), - locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle.Default(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle(), onMapReadyCallback: ((Style) -> Unit)? = null, onMapClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, onMapLongClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt index 2123266c3..6e553e5d3 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt @@ -51,7 +51,7 @@ fun DynamicallyOrientingNavigationView( navigationMapState: NavigationMapState = rememberNavigationMapState(), navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, - locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle.Default(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle(), theme: NavigationUITheme = DefaultNavigationUITheme, config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), views: NavigationViewComponentBuilder = NavigationViewComponentBuilder.Default(theme), diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt index d8d45df90..b8a5a542f 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt @@ -49,7 +49,7 @@ fun LandscapeNavigationView( navigationMapState: NavigationMapState = rememberNavigationMapState(), navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, - locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle.Default(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle(), theme: NavigationUITheme = DefaultNavigationUITheme, config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), views: NavigationViewComponentBuilder = NavigationViewComponentBuilder.Default(theme), diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt index 8b8d6bd3d..03c4e38ec 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt @@ -71,7 +71,7 @@ fun PortraitNavigationView( navigationMapState: NavigationMapState = rememberNavigationMapState(), navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, - locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle.Default(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle(), theme: NavigationUITheme = DefaultNavigationUITheme, config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), views: NavigationViewComponentBuilder = NavigationViewComponentBuilder.Default(theme), diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyleTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyleTest.kt index a8c274f0e..a3d5c8f5a 100644 --- a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyleTest.kt +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyleTest.kt @@ -10,11 +10,11 @@ import org.junit.Test class NavigationMapPuckStyleTest { @Test fun defaultPuckStyleMatchesFerrostarColorsAndSizes() { - val style = NavigationMapPuckStyle.Default() + val style = NavigationMapPuckStyle() assertEquals(Color(0xFF3583DD), style.dotFillColorCurrentLocation) assertEquals(Color(0xFF0F5FB8), style.bearingColor) - assertEquals(6.dp, style.dotRadius) + assertEquals(7.dp, style.dotRadius) assertEquals(3.dp, style.dotStrokeWidth) assertTrue(style.showBearing) assertFalse(style.showBearingAccuracy) From ebdbebc7c49521666e838cb0cf41318da9c21c00 Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Wed, 25 Mar 2026 16:24:17 +0100 Subject: [PATCH 03/14] Add logging and documentation for native style access in `NavigationMapView` --- .../ferrostar/maplibreui/NavigationMapView.kt | 12 +++++++++--- .../ferrostar/maplibreui/runtime/MapReady.kt | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt index 6025d20c3..c0ecfbfb7 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt @@ -32,6 +32,8 @@ import org.maplibre.compose.style.BaseStyle import org.maplibre.compose.util.ClickResult import org.maplibre.compose.util.MaplibreComposable import org.maplibre.android.maps.Style +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.spatialk.geojson.Position import uniffi.ferrostar.GeographicCoordinate /** @@ -127,6 +129,10 @@ fun NavigationMapView( mapReadyCallbackFired = true onMapReadyCallback(it) } ?: run { + android.util.Log.w( + "NavigationMapView", + "onMapReadyCallback was requested but the native Style could not be accessed. " + + "The callback will not fire.") mapReadyCallbackFired = true } } else { @@ -167,7 +173,7 @@ fun NavigationMapView( } } -private fun org.maplibre.spatialk.geojson.Position.toGeographicCoordinate(): GeographicCoordinate = +private fun Position.toGeographicCoordinate(): GeographicCoordinate = GeographicCoordinate(lat = latitude, lng = longitude) private fun NavigationMapClickResult.toComposeClickResult(): ClickResult = @@ -177,9 +183,9 @@ private fun NavigationMapClickResult.toComposeClickResult(): ClickResult = } private fun NavigationMapState.followingCameraPosition( - target: org.maplibre.spatialk.geojson.Position, + target: Position, bearing: Double?, -): org.maplibre.compose.camera.CameraPosition = +): CameraPosition = when (cameraMode) { NavigationCameraMode.FOLLOW_USER -> navigationCameraOptions.browsingUser(target) NavigationCameraMode.FOLLOW_USER_WITH_BEARING -> diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt index cac9557bf..89f51eff1 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt @@ -7,6 +7,12 @@ import org.maplibre.compose.camera.CameraState private const val MAP_READY_TAG = "NavigationMapView" +/** + * Extracts the native [Style] from the compose [CameraState] via reflection. This is a workaround + * because maplibre-compose (tested against 0.12.1) does not expose a public API for accessing the + * underlying style after the map is ready. The function degrades gracefully — if the internal API + * changes, it returns null and logs a warning. + */ internal fun CameraState.nativeStyleOrNull(): Style? { val mapAdapter = runCatching { From bb01a366697b7200c20957809347f38ad5474433 Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Wed, 25 Mar 2026 16:44:53 +0100 Subject: [PATCH 04/14] Ensure overview animation duration is non-zero and improve demo simulation --- .../ferrostar/DemoNavigationViewModel.kt | 35 +++++++------------ .../maplibreui/runtime/NavigationMapState.kt | 13 ++++++- .../runtime/NavigationMapStateTest.kt | 8 +++++ 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt index c71f692fc..362b72391 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt @@ -68,17 +68,20 @@ class DemoNavigationViewModel( .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(), - initialValue = NavigationUiState.empty()) + initialValue = NavigationUiState.empty() + ) init { viewModelScope.launch { - _hasLocationPermission - .flatMapLatest { hasPermission -> - if (hasPermission) { + combine(_hasLocationPermission, _simulated) { hasPermission, simulated -> + hasPermission to simulated + } + .flatMapLatest { (hasPermission, simulated) -> + if (simulated || !hasPermission) { + flowOf(initialSimulatedLocation) + } else { locationProvider.locationUpdates(5000L) .map { it.toUserLocation() } - } else { - flowOf(initialSimulatedLocation) } } .collect { @@ -93,6 +96,9 @@ class DemoNavigationViewModel( fun toggleSimulation() { _simulated.value = !_simulated.value + if (!_simulated.value) { + locationProvider.disableSimulation() + } } fun enableAutoDriveSimulation() { @@ -103,23 +109,6 @@ class DemoNavigationViewModel( _droppedPin.value = coordinate } - init { - viewModelScope.launch { - _hasLocationPermission - .flatMapLatest { hasPermission -> - if (hasPermission) { - locationProvider.locationUpdates(5000L) - .map { it.toUserLocation() } - } else { - flowOf(initialSimulatedLocation) - } - } - .collect { - locationStateFlow.emit(it) - } - } - } - override fun toggleMute() { val spokenInstructionObserver = ferrostarCore.spokenInstructionObserver if (spokenInstructionObserver == null) { diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt index 5858d390e..8b56c3ace 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt @@ -58,12 +58,23 @@ internal constructor( cameraState.animateTo( boundingBox = boundingBox.toMapLibreBoundingBox(), padding = paddingValues, - duration = duration, + duration = normalizeOverviewAnimationDuration(duration), ) } } } +// MapLibre Compose 0.12.1 crashes on Android when the bounds-animation path forwards a zero +// duration to MapLibreMap.animateCamera(...), which throws +// IllegalArgumentException("Null duration passed into animateCamera"). Keep overview transitions +// at >= 1 ms until that Android adapter path is fixed upstream. +internal fun normalizeOverviewAnimationDuration(duration: Duration): Duration = + if (duration.inWholeMilliseconds <= 0L) { + 1.milliseconds + } else { + duration + } + @Composable fun rememberNavigationMapState( initialCameraMode: NavigationCameraMode = defaultNavigationCameraMode(isNavigating = false), diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt index 98282071d..b5e367c73 100644 --- a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt @@ -5,6 +5,7 @@ import com.stadiamaps.ferrostar.core.BoundingBox import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlin.time.Duration.Companion.milliseconds import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame @@ -77,6 +78,13 @@ class NavigationMapStateTest { assertSame(options, state.navigationCameraOptions) } + @Test + fun zeroOverviewAnimationDurationIsNormalized() { + assertEquals(1.milliseconds, normalizeOverviewAnimationDuration(0.milliseconds)) + assertEquals(1.milliseconds, normalizeOverviewAnimationDuration((-50).milliseconds)) + assertEquals(300.milliseconds, normalizeOverviewAnimationDuration(300.milliseconds)) + } + private fun createState( cameraState: CameraState = CameraState(CameraPosition()), initialCameraMode: NavigationCameraMode = defaultNavigationCameraMode(isNavigating = false), From 839031ea603b338858fee472dfa6df0beffe99e7 Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Wed, 25 Mar 2026 23:53:29 +0100 Subject: [PATCH 05/14] Migration to official maplibre-compose: Refactor navigation camera state logic to differentiate between "template" and "tracking" camera positions. --- .../ferrostar/maplibreui/NavigationMapView.kt | 33 ++++---- .../maplibreui/runtime/NavigationCamera.kt | 36 +++++++++ .../runtime/NavigationCameraTest.kt | 80 +++++++++++++++++++ .../runtime/NavigationMapStateTest.kt | 43 ++++++++++ 4 files changed, 174 insertions(+), 18 deletions(-) diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt index c0ecfbfb7..056f22cef 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt @@ -19,9 +19,12 @@ import com.stadiamaps.ferrostar.maplibreui.runtime.nativeStyleOrNull import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions import com.stadiamaps.ferrostar.maplibreui.runtime.rememberFerrostarLocationState import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState +import com.stadiamaps.ferrostar.maplibreui.runtime.templateFollowingCameraPosition +import com.stadiamaps.ferrostar.maplibreui.runtime.trackingFollowingCameraPosition import com.stadiamaps.ferrostar.maplibreui.runtime.toMapLibreLocation import kotlinx.coroutines.flow.collectLatest import org.maplibre.compose.camera.CameraMoveReason +import org.maplibre.android.maps.Style import org.maplibre.compose.location.LocationPuck import org.maplibre.compose.location.LocationPuckColors import org.maplibre.compose.location.LocationPuckSizes @@ -31,8 +34,6 @@ import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.style.BaseStyle import org.maplibre.compose.util.ClickResult import org.maplibre.compose.util.MaplibreComposable -import org.maplibre.android.maps.Style -import org.maplibre.compose.camera.CameraPosition import org.maplibre.spatialk.geojson.Position import uniffi.ferrostar.GeographicCoordinate @@ -84,13 +85,23 @@ fun NavigationMapView( navigationMapState.cameraMode = defaultNavigationCameraMode(isNavigating) } + LaunchedEffect(navigationMapState.cameraMode, userLocation != null) { + if (userLocation != null && navigationMapState.isTrackingUser) { + cameraState.position = + navigationMapState.templateFollowingCameraPosition( + target = userLocation.position, + bearing = userLocation.bearing, + ) + } + } + LocationTrackingEffect( locationState = userLocationState, enabled = navigationMapState.isTrackingUser, trackBearing = navigationMapState.cameraMode == NavigationCameraMode.FOLLOW_USER_WITH_BEARING, ) { cameraState.position = - navigationMapState.followingCameraPosition( + navigationMapState.trackingFollowingCameraPosition( target = currentLocation.position, bearing = currentLocation.bearing, ) @@ -117,7 +128,7 @@ fun NavigationMapView( onMapLoadFinished = { if (userLocation != null && navigationMapState.isTrackingUser) { cameraState.position = - navigationMapState.followingCameraPosition( + navigationMapState.templateFollowingCameraPosition( target = userLocation.position, bearing = userLocation.bearing, ) @@ -181,17 +192,3 @@ private fun NavigationMapClickResult.toComposeClickResult(): ClickResult = NavigationMapClickResult.Pass -> ClickResult.Pass NavigationMapClickResult.Consume -> ClickResult.Consume } - -private fun NavigationMapState.followingCameraPosition( - target: Position, - bearing: Double?, -): CameraPosition = - when (cameraMode) { - NavigationCameraMode.FOLLOW_USER -> navigationCameraOptions.browsingUser(target) - NavigationCameraMode.FOLLOW_USER_WITH_BEARING -> - navigationCameraOptions.navigatingUser( - target = target, - bearing = bearing ?: cameraState.position.bearing, - ) - else -> cameraState.position - } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt index 946b11249..f69022952 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt @@ -95,3 +95,39 @@ fun BoundingBox.toMapLibreBoundingBox(): org.maplibre.spatialk.geojson.BoundingB fun CameraState.incrementZoom(delta: Double) { position = position.copy(zoom = (position.zoom + delta).coerceAtLeast(0.0)) } + +internal fun NavigationMapState.templateFollowingCameraPosition( + target: Position, + bearing: Double?, +): CameraPosition = + when (cameraMode) { + NavigationCameraMode.FOLLOW_USER -> navigationCameraOptions.browsingUser(target) + NavigationCameraMode.FOLLOW_USER_WITH_BEARING -> + navigationCameraOptions.navigatingUser( + target = target, + bearing = bearing ?: cameraState.position.bearing, + ) + else -> cameraState.position + } + +internal fun NavigationMapState.trackingFollowingCameraPosition( + target: Position, + bearing: Double?, +): CameraPosition = + when (cameraMode) { + NavigationCameraMode.FOLLOW_USER -> + cameraState.position.copy( + target = target, + tilt = 0.0, + bearing = 0.0, + padding = navigationCameraOptions.browsingPadding, + ) + NavigationCameraMode.FOLLOW_USER_WITH_BEARING -> + cameraState.position.copy( + target = target, + tilt = navigationCameraOptions.navigationTilt, + bearing = bearing ?: cameraState.position.bearing, + padding = navigationCameraOptions.navigationPadding, + ) + else -> cameraState.position + } diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt index 2cb9b9025..e170cd886 100644 --- a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt @@ -6,6 +6,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.CameraState import org.maplibre.spatialk.geojson.Position class NavigationCameraTest { @@ -60,4 +62,82 @@ class NavigationCameraTest { assertEquals(45.0, navigating.tilt, 0.0) assertEquals(87.0, navigating.bearing, 0.0) } + + @Test + fun trackingCameraPreservesCurrentZoomInBrowsingMode() { + val state = + createState( + cameraMode = NavigationCameraMode.FOLLOW_USER, + cameraPosition = CameraPosition(zoom = 13.5), + ) + + val position = state.trackingFollowingCameraPosition(Position(16.37, 48.21), bearing = null) + + assertEquals(13.5, position.zoom, 0.0) + assertEquals(0.0, position.bearing, 0.0) + assertEquals(0.0, position.tilt, 0.0) + } + + @Test + fun trackingCameraPreservesCurrentZoomInNavigationMode() { + val state = + createState( + cameraMode = NavigationCameraMode.FOLLOW_USER_WITH_BEARING, + cameraPosition = CameraPosition(zoom = 14.5), + ) + + val position = + state.trackingFollowingCameraPosition(Position(16.37, 48.21), bearing = 87.0) + + assertEquals(14.5, position.zoom, 0.0) + assertEquals(87.0, position.bearing, 0.0) + assertEquals(45.0, position.tilt, 0.0) + } + + @Test + fun templateCameraUsesConfiguredZoomInBrowsingMode() { + val state = + createState( + cameraMode = NavigationCameraMode.FOLLOW_USER, + cameraPosition = CameraPosition(zoom = 13.5), + ) + + val position = state.templateFollowingCameraPosition(Position(16.37, 48.21), bearing = null) + + assertEquals(16.0, position.zoom, 0.0) + } + + @Test + fun templateCameraUsesConfiguredZoomInNavigationMode() { + val state = + createState( + cameraMode = NavigationCameraMode.FOLLOW_USER_WITH_BEARING, + cameraPosition = CameraPosition(zoom = 13.5), + ) + + val position = state.templateFollowingCameraPosition(Position(16.37, 48.21), bearing = 87.0) + + assertEquals(16.0, position.zoom, 0.0) + assertEquals(87.0, position.bearing, 0.0) + } + + private fun createState( + cameraMode: NavigationCameraMode, + cameraPosition: CameraPosition, + ): NavigationMapState = + NavigationMapState( + cameraState = CameraState(cameraPosition), + initialCameraMode = cameraMode, + navigationCameraOptions = + NavigationCameraOptions( + browsingZoom = 16.0, + navigationZoom = 16.0, + navigationTilt = 45.0, + browsingPadding = PaddingValues(), + navigationPadding = PaddingValues(), + ), + coroutineScope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.Job() + kotlinx.coroutines.Dispatchers.Unconfined + ), + ) } diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt index b5e367c73..7a6ab90db 100644 --- a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt @@ -48,6 +48,49 @@ class NavigationMapStateTest { assertEquals(9.0, cameraState.position.zoom, 0.0) } + @Test + fun recenterUsesBrowsingTemplateZoomAfterFreeCamera() { + val cameraState = CameraState(CameraPosition(zoom = 10.0)) + val state = + createState( + cameraState = cameraState, + initialCameraMode = NavigationCameraMode.FREE, + ) + + state.recenter(isNavigating = false) + + val position = + state.templateFollowingCameraPosition( + target = CameraPosition().target, + bearing = null, + ) + + assertEquals(16.0, position.zoom, 0.0) + assertEquals(NavigationCameraMode.FOLLOW_USER, state.cameraMode) + } + + @Test + fun recenterUsesNavigationTemplateZoomAfterOverview() { + val cameraState = CameraState(CameraPosition(zoom = 10.0)) + val state = + createState( + cameraState = cameraState, + initialCameraMode = NavigationCameraMode.OVERVIEW, + ) + + state.recenter(isNavigating = true) + + val position = + state.templateFollowingCameraPosition( + target = CameraPosition().target, + bearing = 87.0, + ) + + assertEquals(16.0, position.zoom, 0.0) + assertEquals(87.0, position.bearing, 0.0) + assertEquals(NavigationCameraMode.FOLLOW_USER_WITH_BEARING, state.cameraMode) + } + @Test fun routeOverviewSwitchesToOverviewMode() { val state = createState(initialCameraMode = NavigationCameraMode.FOLLOW_USER) From 73ef51aad415976cd2140f6c0b228447a8bee46b Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Thu, 26 Mar 2026 00:31:57 +0100 Subject: [PATCH 06/14] Update README.md and NavigationCameraTest.kt for Android Compose migration --- android/README.md | 15 ++++++++------- .../maplibreui/runtime/NavigationCameraTest.kt | 7 +++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/android/README.md b/android/README.md index ed9f5db5a..240366b9b 100644 --- a/android/README.md +++ b/android/README.md @@ -48,19 +48,20 @@ implementation("org.maplibre.compose:maplibre-compose-android:0.12.1") Notable Android phone/tablet migration changes: -* `io.github.rallista:maplibre-compose` is no longer used by `ui-maplibre`. -* `NavigationMapView`, `PortraitNavigationView`, `LandscapeNavigationView`, and `DynamicallyOrientingNavigationView` now use a Ferrostar-owned `NavigationMapState` facade via `rememberNavigationMapState()`. -* The old `MapViewCamera`-based camera state has been replaced by a small Ferrostar camera layer for: +* `ui-maplibre` no longer uses `io.github.rallista:maplibre-compose`. +* `NavigationMapView`, `PortraitNavigationView`, `LandscapeNavigationView`, and `DynamicallyOrientingNavigationView` now use a Ferrostar-owned `NavigationMapState` via `rememberNavigationMapState()`. +* The old `MapViewCamera`-based camera API has been replaced by a small Ferrostar camera layer for: * follow user * follow user with bearing * route overview * free camera -* `onMapReadyCallback` is still available on `NavigationMapView` for the 0.x series. +* `NavigationMapView` now takes `MapOptions` instead of the old `MapControls` API. * Location puck styling is configurable through `NavigationMapPuckStyle`. * Route rendering now uses a GeoJSON source plus `LineLayer` instead of legacy polyline convenience APIs. * Map tap and long-press callbacks use Ferrostar-facing callbacks with `GeographicCoordinate` plus screen position. +* `onMapReadyCallback` is still available on `NavigationMapView` for the current 0.x migration path. -Example migration for default usage: +Example usage: ```kotlin val navigationMapState = rememberNavigationMapState() @@ -83,5 +84,5 @@ navigationMapState.showRouteOverview(boundingBox, paddingValues = mapInsets) Current scope notes: -* This migration covers Android phone/tablet Compose only. -* Android Auto remains out of scope for this issue; the legacy car-specific path is kept separately so the repo still builds. +* This migration covers Android phone/tablet Compose first. +* `ui-maplibre-car-app` still exists as a compatibility path, but Android Auto has not been fully migrated to the new map state/camera model yet. diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt index e170cd886..9e7eeaabc 100644 --- a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt @@ -2,6 +2,9 @@ package com.stadiamaps.ferrostar.maplibreui.runtime import androidx.compose.foundation.layout.PaddingValues import com.stadiamaps.ferrostar.core.BoundingBox +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -136,8 +139,8 @@ class NavigationCameraTest { browsingPadding = PaddingValues(), navigationPadding = PaddingValues(), ), - coroutineScope = kotlinx.coroutines.CoroutineScope( - kotlinx.coroutines.Job() + kotlinx.coroutines.Dispatchers.Unconfined + coroutineScope = CoroutineScope( + Job() + Dispatchers.Unconfined, ), ) } From e1f75f730d61d0f38742f15053e8eed918f88f50 Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Thu, 26 Mar 2026 17:09:27 +0100 Subject: [PATCH 07/14] Refactor Android navigation UI to use the new `NavigationMapState` and introduce a specialized navigation puck overlay. --- .../ferrostar/DemoNavigationViewModel.kt | 57 -------- .../ferrostar/auto/DemoNavigationScreen.kt | 82 ++++++----- .../ferrostar/auto/DemoNavigationView.kt | 76 ++++++---- .../maplibre/car/app/CarAppNavigationView.kt | 29 ++-- .../car/app/runtime/SurfaceStablePadding.kt | 30 ---- .../ferrostar/maplibreui/NavigationMapView.kt | 62 +++++--- .../maplibreui/NavigationPuckOverlay.kt | 135 ++++++++++++++++++ .../maplibreui/NavigationPuckOverlayTest.kt | 64 +++++++++ 8 files changed, 347 insertions(+), 188 deletions(-) create mode 100644 android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt create mode 100644 android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlayTest.kt diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt index 362b72391..ca205ef24 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt @@ -2,18 +2,12 @@ package com.stadiamaps.ferrostar import android.location.Location import android.util.Log -import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.viewModelScope -import com.maplibre.compose.camera.CameraState -import com.maplibre.compose.camera.MapViewCamera -import com.maplibre.compose.camera.extensions.incrementZoom -import com.maplibre.compose.camera.models.CameraPadding import com.stadiamaps.ferrostar.core.DefaultNavigationViewModel import com.stadiamaps.ferrostar.core.FerrostarCore import com.stadiamaps.ferrostar.core.NavigationUiState import com.stadiamaps.ferrostar.core.annotation.AnnotationPublisher import com.stadiamaps.ferrostar.core.annotation.valhalla.valhallaExtendedOSRMAnnotationPublisher -import com.stadiamaps.ferrostar.core.boundingBox import com.stadiamaps.ferrostar.core.location.NavigationLocationProvider import com.stadiamaps.ferrostar.core.location.toUserLocation import com.stadiamaps.ferrostar.support.initialSimulatedLocation @@ -29,7 +23,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.maplibre.android.geometry.LatLngBounds import uniffi.ferrostar.GeographicCoordinate import uniffi.ferrostar.UserLocation import uniffi.ferrostar.Waypoint @@ -151,56 +144,6 @@ class DemoNavigationViewModel( ferrostarCore.stopNavigation() } - val mapViewCamera = mutableStateOf( - MapViewCamera.TrackingUserLocation() - ) - val cameraPadding = mutableStateOf(CameraPadding()) - val navigationCamera = mutableStateOf(MapViewCamera.TrackingUserLocationWithBearing(zoom = 16.0, pitch = 45.0)) - - fun isTrackingUser(): Boolean = - when (mapViewCamera.value.state) { - is CameraState.TrackingUserLocation, - is CameraState.TrackingUserLocationWithBearing -> true - else -> false - } - - fun zoomIn() { - mapViewCamera.value = mapViewCamera.value.incrementZoom(1.0) - } - - fun zoomOut() { - mapViewCamera.value = mapViewCamera.value.incrementZoom(-1.0) - } - - fun centerCamera() { - if (isTrackingUser()) { - centerOnRoute() - } else { - centerOnUser() - } - } - - private fun centerOnRoute() { - val boundingBox = navigationUiState.value.routeGeometry?.boundingBox() - boundingBox?.let { - val latLngBounds = LatLngBounds.from( - boundingBox.north, - boundingBox.east, - boundingBox.south, - boundingBox.west - ) - mapViewCamera.value = MapViewCamera.BoundingBox( - latLngBounds, - pitch = 0.0, - padding = cameraPadding.value - ) - } - } - - private fun centerOnUser() { - mapViewCamera.value = navigationCamera.value - } - companion object { const val TAG = "DemoNavigationViewModel" } diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt index 2d2f2295e..81c04df7b 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt @@ -2,37 +2,40 @@ package com.stadiamaps.ferrostar.auto import androidx.car.app.CarContext import androidx.car.app.model.Template +import androidx.car.app.navigation.model.NavigationTemplate import androidx.car.app.navigation.NavigationManager -import androidx.lifecycle.Lifecycle +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.Lifecycle import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import com.maplibre.compose.camera.CameraState -import com.maplibre.compose.camera.MapViewCamera import com.maplibre.compose.car.ComposableScreen import com.stadiamaps.ferrostar.AppModule import com.stadiamaps.ferrostar.R +import com.stadiamaps.ferrostar.ui.maplibre.car.app.CarAppNavigationView import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.SurfaceAreaTracker import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.screenSurfaceState -import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.surfaceStableFractionalCameraPadding +import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.surfaceStableFractionalPadding import com.stadiamaps.ferrostar.car.app.navigation.NavigationManagerBridge import com.stadiamaps.ferrostar.car.app.template.NavigationTemplateBuilder import com.stadiamaps.ferrostar.core.NavigationUiState +import com.stadiamaps.ferrostar.core.boundingBox +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState +import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import androidx.car.app.navigation.model.Destination import com.stadiamaps.ferrostar.car.app.intent.NavigationDestination import com.stadiamaps.ferrostar.car.app.navigation.TurnByTurnNotificationManager import uniffi.ferrostar.DrivingSide @@ -72,6 +75,8 @@ class DemoNavigationScreen( ) private var uiState: NavigationUiState? by mutableStateOf(null) + private var navigationMapState: NavigationMapState? = null + private var browsingPadding: PaddingValues = PaddingValues() private val surfaceAreaTracker = SurfaceAreaTracker { surfaceGestureCallback = it } @@ -96,38 +101,33 @@ class DemoNavigationScreen( @Composable override fun content() { val surfaceArea by screenSurfaceState(surfaceAreaTracker) - val normalPaddingState = rememberUpdatedState(surfaceStableFractionalCameraPadding(surfaceArea?.compositeArea)) - val trackingPaddingState = rememberUpdatedState(surfaceStableFractionalCameraPadding(surfaceArea?.compositeArea, top = 0.5f)) - val camera = remember { mutableStateOf(viewModel.mapViewCamera.value) } + val normalPadding = surfaceStableFractionalPadding(surfaceArea?.compositeArea) + val trackingPadding = surfaceStableFractionalPadding(surfaceArea?.compositeArea, top = 0.5f) + val mapCameraOptions = + navigationCameraOptions().copy( + browsingPadding = normalPadding, + navigationPadding = trackingPadding, + ) + val mapState = rememberNavigationMapState(navigationCameraOptions = mapCameraOptions) + + browsingPadding = normalPadding + navigationMapState = mapState // Transition to navigation camera and publish destination when navigation starts - LaunchedEffect(uiState?.isNavigating()) { + LaunchedEffect(uiState?.isNavigating(), mapState) { if (uiState?.isNavigating() == true) { - viewModel.mapViewCamera.value = viewModel.navigationCamera.value + mapState.recenter(isNavigating = true) } } - // Sync camera with padding based on current state - LaunchedEffect(Unit) { - snapshotFlow { - val base = viewModel.mapViewCamera.value - val normalPadding = normalPaddingState.value - val trackingPadding = trackingPaddingState.value - if (uiState?.isNavigating() == true) { - when (base.state) { - is CameraState.TrackingUserLocation, - is CameraState.TrackingUserLocationWithBearing -> base.copy(padding = trackingPadding) - else -> base.copy(padding = normalPadding) - } - } else { - base - } - }.collect { camera.value = it } + LaunchedEffect(mapState) { + snapshotFlow { mapState.cameraMode }.collectLatest { invalidate() } } DemoNavigationView( - viewModel, - camera = camera, + viewModel = viewModel, + navigationMapState = mapState, + navigationCameraOptions = mapCameraOptions, surfaceAreaTracker = surfaceAreaTracker ) } @@ -145,11 +145,11 @@ class DemoNavigationScreen( viewModel.toggleMute() } .setOnZoom( - onZoomInTapped = { viewModel.zoomIn() }, - onZoomOutTapped = { viewModel.zoomOut() } + onZoomInTapped = { navigationMapState?.zoomIn() }, + onZoomOutTapped = { navigationMapState?.zoomOut() } ) - .setOnCycleCamera(viewModel.isTrackingUser()) { - viewModel.centerCamera() + .setOnCycleCamera(navigationMapState?.isTrackingUser == true) { + centerCamera() } .setTripState(state.tripState) .build() @@ -170,4 +170,18 @@ class DemoNavigationScreen( } }) } + + private fun centerCamera() { + val mapState = navigationMapState ?: return + if (mapState.isTrackingUser) { + val routeBounds = viewModel.navigationUiState.value.routeGeometry?.boundingBox() ?: return + mapState.showRouteOverview( + boundingBox = routeBounds, + paddingValues = browsingPadding, + ) + } else { + mapState.recenter(isNavigating = uiState?.isNavigating() == true) + } + invalidate() + } } diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt index eb84a0789..db2fe6898 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt @@ -2,55 +2,81 @@ package com.stadiamaps.ferrostar.auto import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier -import com.maplibre.compose.camera.MapViewCamera -import com.maplibre.compose.symbols.Circle +import androidx.compose.ui.unit.dp import com.stadiamaps.ferrostar.AppModule import com.stadiamaps.ferrostar.DemoNavigationViewModel +import com.stadiamaps.ferrostar.core.NavigationUiState import com.stadiamaps.ferrostar.ui.maplibre.car.app.CarAppNavigationView import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.SurfaceAreaTracker import com.stadiamaps.ferrostar.composeui.config.VisualNavigationViewConfig import com.stadiamaps.ferrostar.composeui.config.withSpeedLimitStyle import com.stadiamaps.ferrostar.composeui.views.components.speedlimit.SignageStyle -import org.maplibre.android.geometry.LatLng +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState +import kotlinx.serialization.json.buildJsonObject import kotlin.math.min +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.util.MaplibreComposable +import org.maplibre.spatialk.geojson.Feature +import org.maplibre.spatialk.geojson.FeatureCollection +import org.maplibre.spatialk.geojson.Point @Composable fun DemoNavigationView( viewModel: DemoNavigationViewModel = AppModule.viewModel, - camera: MutableState, + navigationMapState: NavigationMapState, + navigationCameraOptions: NavigationCameraOptions, surfaceAreaTracker: SurfaceAreaTracker? = null, ) { CarAppNavigationView( modifier = Modifier.fillMaxSize(), styleUrl = AppModule.mapStyleUrl, - camera = camera, + navigationMapState = navigationMapState, + navigationCameraOptions = navigationCameraOptions, viewModel = viewModel, config = VisualNavigationViewConfig.Default() .withSpeedLimitStyle(SignageStyle.MUTCD), surfaceAreaTracker = surfaceAreaTracker, ) { uiState -> - // Trivial, if silly example of how to add your own overlay layers. - // (Also incidentally highlights the lag inherent in MapLibre location tracking - // as-is.) - uiState.location?.let { location -> - Circle( - center = LatLng(location.coordinates.lat, location.coordinates.lng), - radius = 10f, - color = "Blue", - zIndex = 3, - ) + DemoCarLocationOverlay(uiState) + } +} - if (location.horizontalAccuracy > 15) { - Circle( - center = LatLng(location.coordinates.lat, location.coordinates.lng), - radius = min(location.horizontalAccuracy.toFloat(), 150f), - color = "Blue", - opacity = 0.2f, - zIndex = 2, +@Composable +@MaplibreComposable +private fun DemoCarLocationOverlay(uiState: NavigationUiState) { + val location = uiState.location ?: return + val locationSource = + rememberGeoJsonSource( + GeoJsonData.Features( + FeatureCollection( + Feature( + geometry = Point(location.coordinates.lng, location.coordinates.lat), + properties = buildJsonObject {}, + ) ) - } - } + ), + ) + + CircleLayer( + id = "demo-car-location-dot", + source = locationSource, + color = const(Color.Blue), + radius = const(10.dp), + ) + + if (location.horizontalAccuracy > 15) { + CircleLayer( + id = "demo-car-location-accuracy", + source = locationSource, + color = const(Color.Blue), + opacity = const(0.2f), + radius = const(min(location.horizontalAccuracy.toFloat(), 150f).dp), + ) } } diff --git a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt index c748f6438..bb540dbd4 100644 --- a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt +++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt @@ -4,17 +4,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.maplibre.compose.camera.MapViewCamera -import com.maplibre.compose.ramani.LocationRequestProperties -import com.maplibre.compose.ramani.MapLibreComposable -import com.maplibre.compose.rememberSaveableMapViewCamera import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.SurfaceAreaTracker import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.screenSurfaceState import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.surfaceStableFractionalPadding @@ -25,38 +20,33 @@ import com.stadiamaps.ferrostar.core.NavigationUiState import com.stadiamaps.ferrostar.core.NavigationViewModel import com.stadiamaps.ferrostar.maplibreui.NavigationMapView import com.stadiamaps.ferrostar.maplibreui.routeline.RouteOverlayBuilder +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState +import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.OrnamentOptions +import org.maplibre.compose.util.MaplibreComposable /** * A navigation view designed for Android Auto car displays. * * Renders a [NavigationMapView] with speed limit and road name overlays positioned within the * stable area of the car display surface (the area not covered by the NavigationTemplate's chrome). - * - * Note: `camera`, `navigationCamera`, `locationRequestProperties`, and `mapContent` are legacy - * Android Auto compatibility parameters. They are currently accepted so the old car app API keeps - * compiling while the Android Auto path remains on the legacy stack, but they are ignored by the - * current implementation. */ @Composable fun CarAppNavigationView( modifier: Modifier, styleUrl: String, - camera: MutableState = rememberSaveableMapViewCamera(), - navigationCamera: MapViewCamera = MapViewCamera.TrackingUserLocationWithBearing(), + navigationMapState: NavigationMapState = rememberNavigationMapState(), + navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, - locationRequestProperties: LocationRequestProperties = - LocationRequestProperties.Builder().build(), config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), surfaceAreaTracker: SurfaceAreaTracker? = null, - mapContent: @Composable @MapLibreComposable() ((NavigationUiState) -> Unit)? = null, + mapContent: @Composable @MaplibreComposable ((NavigationUiState) -> Unit)? = null, ) { - keepLegacyCompatibilityParameters(camera, navigationCamera, locationRequestProperties, mapContent) val uiState by viewModel.navigationUiState.collectAsState() - val navigationMapState = rememberNavigationMapState() val surfaceArea by surfaceAreaTracker ?.let { screenSurfaceState(it) } @@ -70,8 +60,9 @@ fun CarAppNavigationView( navigationMapState = navigationMapState, uiState = uiState, mapOptions = MapOptions(ornamentOptions = OrnamentOptions.AllDisabled), + navigationCameraOptions = navigationCameraOptions, routeOverlayBuilder = routeOverlayBuilder, - content = null, + content = mapContent, ) Box( @@ -98,5 +89,3 @@ fun CarAppNavigationView( } } } - -private fun keepLegacyCompatibilityParameters(vararg ignored: Any?) = Unit diff --git a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceStablePadding.kt b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceStablePadding.kt index c91ed3150..0e427943a 100644 --- a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceStablePadding.kt +++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceStablePadding.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.dp -import com.maplibre.compose.camera.models.CameraPadding @Composable fun surfaceStablePadding( @@ -72,32 +71,3 @@ fun surfaceStableFractionalPadding( return padding } - -// CameraPadding - -@Composable -fun surfaceStableCameraPadding( - stableArea: Rect?, - additionalPadding: PaddingValues? = null -): CameraPadding { - val padding = surfaceStablePadding(stableArea, additionalPadding) - return CameraPadding.padding(padding) -} - -@Composable -fun surfaceStableFractionalCameraPadding( - stableArea: Rect?, - @FloatRange(from = 0.0, to = 1.0) start: Float = 0.0f, - @FloatRange(from = 0.0, to = 1.0) top: Float = 0.0f, - @FloatRange(from = 0.0, to = 1.0) end: Float = 0.0f, - @FloatRange(from = 0.0, to = 1.0) bottom: Float = 0.0f -): CameraPadding { - val padding = surfaceStableFractionalPadding( - stableArea, - start = start, - top = top, - end = end, - bottom = bottom - ) - return CameraPadding.padding(padding) -} diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt index 056f22cef..09fcfccfe 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt @@ -76,6 +76,7 @@ fun NavigationMapView( val cameraState = navigationMapState.cameraState val userLocationState = rememberFerrostarLocationState(uiState.location) val userLocation = uiState.location?.toMapLibreLocation() + var lastKnownNavigationPuckBearing by remember { mutableStateOf(0.0) } navigationMapState.navigationCameraOptions = navigationCameraOptions var isNavigating by remember { mutableStateOf(uiState.isNavigating()) } @@ -95,6 +96,10 @@ fun NavigationMapView( } } + LaunchedEffect(userLocation?.bearing) { + userLocation?.bearing?.let { lastKnownNavigationPuckBearing = it } + } + LocationTrackingEffect( locationState = userLocationState, enabled = navigationMapState.isTrackingUser, @@ -155,28 +160,41 @@ fun NavigationMapView( ) { routeOverlayBuilder.navigationPath(uiState) - LocationPuck( - idPrefix = "ferrostar-location", - locationState = userLocationState, - cameraState = cameraState, - colors = - LocationPuckColors( - dotFillColorCurrentLocation = locationPuckStyle.dotFillColorCurrentLocation, - dotFillColorOldLocation = locationPuckStyle.dotFillColorOldLocation, - dotStrokeColor = locationPuckStyle.dotStrokeColor, - shadowColor = locationPuckStyle.shadowColor, - accuracyStrokeColor = locationPuckStyle.accuracyStrokeColor, - accuracyFillColor = locationPuckStyle.accuracyFillColor, - bearingColor = locationPuckStyle.bearingColor, - ), - sizes = - LocationPuckSizes( - dotRadius = locationPuckStyle.dotRadius, - dotStrokeWidth = locationPuckStyle.dotStrokeWidth, - ), - showBearing = locationPuckStyle.showBearing, - showBearingAccuracy = locationPuckStyle.showBearingAccuracy, - ) + if (shouldRenderNavigationPuck(uiState) && userLocation != null) { + NavigationPuckOverlay( + longitude = userLocation.position.longitude, + latitude = userLocation.position.latitude, + bearingDegrees = + navigationPuckBearingDegrees( + currentBearing = userLocation.bearing, + lastKnownBearing = lastKnownNavigationPuckBearing, + ), + style = locationPuckStyle, + ) + } else { + LocationPuck( + idPrefix = "ferrostar-location", + locationState = userLocationState, + cameraState = cameraState, + colors = + LocationPuckColors( + dotFillColorCurrentLocation = locationPuckStyle.dotFillColorCurrentLocation, + dotFillColorOldLocation = locationPuckStyle.dotFillColorOldLocation, + dotStrokeColor = locationPuckStyle.dotStrokeColor, + shadowColor = locationPuckStyle.shadowColor, + accuracyStrokeColor = locationPuckStyle.accuracyStrokeColor, + accuracyFillColor = locationPuckStyle.accuracyFillColor, + bearingColor = locationPuckStyle.bearingColor, + ), + sizes = + LocationPuckSizes( + dotRadius = locationPuckStyle.dotRadius, + dotStrokeWidth = locationPuckStyle.dotStrokeWidth, + ), + showBearing = locationPuckStyle.showBearing, + showBearingAccuracy = locationPuckStyle.showBearingAccuracy, + ) + } if (content != null) { content(uiState) diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt new file mode 100644 index 000000000..c75329d47 --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt @@ -0,0 +1,135 @@ +package com.stadiamaps.ferrostar.maplibreui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.stadiamaps.ferrostar.core.NavigationUiState +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.maplibre.compose.expressions.dsl.asNumber +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.image +import org.maplibre.compose.expressions.value.CirclePitchAlignment +import org.maplibre.compose.expressions.value.IconPitchAlignment +import org.maplibre.compose.expressions.value.IconRotationAlignment +import org.maplibre.compose.expressions.value.SymbolAnchor +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.util.MaplibreComposable +import org.maplibre.spatialk.geojson.Feature +import org.maplibre.spatialk.geojson.FeatureCollection +import org.maplibre.spatialk.geojson.Point + +internal fun shouldRenderNavigationPuck(uiState: NavigationUiState): Boolean = + uiState.isNavigating() && uiState.location != null + +internal fun navigationPuckBearingDegrees( + currentBearing: Double?, + lastKnownBearing: Double +): Double = + currentBearing ?: lastKnownBearing + +internal fun navigationPuckFeatureCollection( + longitude: Double, + latitude: Double, + bearingDegrees: Double, +): FeatureCollection = + FeatureCollection( + Feature( + geometry = Point(longitude, latitude), + properties = + buildJsonObject { + put("bearing", bearingDegrees) + }, + ) + ) + +@Composable +@MaplibreComposable +internal fun NavigationPuckOverlay( + longitude: Double, + latitude: Double, + bearingDegrees: Double, + style: NavigationMapPuckStyle, +) { + val source = + rememberGeoJsonSource( + GeoJsonData.Features( + navigationPuckFeatureCollection(longitude, latitude, bearingDegrees), + ), + ) + val arrowPainter = rememberNavigationPuckArrowPainter(style.dotFillColorCurrentLocation) + val puckRadius = 23.dp + val puckShadowRadius = 32.dp + val arrowSize = 23.dp + + CircleLayer( + id = "ferrostar-navigation-puck-shadow", + source = source, + color = const(Color.Black), + radius = const(puckShadowRadius), + opacity = const(0.1f), + pitchAlignment = const(CirclePitchAlignment.Map), + ) + + CircleLayer( + id = "ferrostar-navigation-puck-background", + source = source, + color = const(Color.White), + radius = const(puckRadius), + strokeColor = const(Color(0xFFE5E5EA)), + strokeWidth = const(0.75.dp), + pitchAlignment = const(CirclePitchAlignment.Map), + ) + + SymbolLayer( + id = "ferrostar-navigation-puck-arrow", + source = source, + iconImage = + image( + arrowPainter, + size = DpSize(arrowSize, arrowSize), + drawAsSdf = false, + ), + iconAnchor = const(SymbolAnchor.Center), + iconRotate = feature["bearing"].asNumber(const(0f)), + iconPitchAlignment = const(IconPitchAlignment.Map), + iconRotationAlignment = const(IconRotationAlignment.Map), + iconAllowOverlap = const(true), + iconIgnorePlacement = const(true), + ) +} + +@Composable +private fun rememberNavigationPuckArrowPainter(color: Color): VectorPainter = + rememberVectorPainter( + remember(color) { + ImageVector.Builder( + name = "ferrostar_navigation_puck_arrow", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ) + .apply { + path(fill = androidx.compose.ui.graphics.SolidColor(color)) { + moveTo(10f, 1.6f) + lineTo(17.8f, 17.2f) + lineTo(10f, 12.6f) + lineTo(2.2f, 17.2f) + close() + } + } + .build() + }, + ) diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlayTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlayTest.kt new file mode 100644 index 000000000..252b61122 --- /dev/null +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlayTest.kt @@ -0,0 +1,64 @@ +package com.stadiamaps.ferrostar.maplibreui + +import com.stadiamaps.ferrostar.core.NavigationUiState +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import uniffi.ferrostar.GeographicCoordinate +import uniffi.ferrostar.TripProgress +import uniffi.ferrostar.UserLocation +import java.time.Instant + +class NavigationPuckOverlayTest { + @Test + fun rendersOnlyWhileNavigatingWithLocation() { + assertFalse(shouldRenderNavigationPuck(NavigationUiState.empty())) + + val location = sampleLocation() + + assertFalse(shouldRenderNavigationPuck(NavigationUiState.empty().copy(location = location))) + assertTrue( + shouldRenderNavigationPuck( + NavigationUiState.empty().copy( + progress = sampleProgress(), + location = location, + ), + ), + ) + } + + @Test + fun fallsBackToLastKnownBearingWhenCurrentBearingMissing() { + assertEquals(87.0, navigationPuckBearingDegrees(null, lastKnownBearing = 87.0), 0.0) + assertEquals(42.0, navigationPuckBearingDegrees(42.0, lastKnownBearing = 87.0), 0.0) + } + + @Test + fun emitsPointGeoJsonWithBearingProperty() { + val featureCollection = navigationPuckFeatureCollection(16.37, 48.21, 123.0) + val feature = featureCollection.features.single() + val bearing = feature.properties["bearing"]?.jsonPrimitive?.content?.toDouble() + + assertEquals(16.37, feature.geometry.longitude, 0.0) + assertEquals(48.21, feature.geometry.latitude, 0.0) + assertEquals(123.0, requireNotNull(bearing), 0.0) + } + + private fun sampleLocation() = + UserLocation( + coordinates = GeographicCoordinate(48.21, 16.37), + horizontalAccuracy = 5.0, + courseOverGround = null, + timestamp = Instant.now(), + speed = null, + ) + + private fun sampleProgress() = + TripProgress( + distanceToNextManeuver = 1.0, + distanceRemaining = 1.0, + durationRemaining = 1.0, + ) +} From 5758870c7ea1b1ccf9ffe30a9011958bf57db3ac Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Thu, 26 Mar 2026 18:09:50 +0100 Subject: [PATCH 08/14] Update DemoNavigationView to display route endpoints --- .../ferrostar/auto/DemoNavigationView.kt | 69 +++++++++++-------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt index db2fe6898..c3c026ef7 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt @@ -16,8 +16,8 @@ import com.stadiamaps.ferrostar.composeui.views.components.speedlimit.SignageSty import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraOptions import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState import kotlinx.serialization.json.buildJsonObject -import kotlin.math.min import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.value.CirclePitchAlignment import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.rememberGeoJsonSource @@ -25,6 +25,7 @@ import org.maplibre.compose.util.MaplibreComposable import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point +import uniffi.ferrostar.GeographicCoordinate @Composable fun DemoNavigationView( @@ -43,40 +44,54 @@ fun DemoNavigationView( .withSpeedLimitStyle(SignageStyle.MUTCD), surfaceAreaTracker = surfaceAreaTracker, ) { uiState -> - DemoCarLocationOverlay(uiState) + DemoRouteEndpointsOverlay(uiState) } } @Composable @MaplibreComposable -private fun DemoCarLocationOverlay(uiState: NavigationUiState) { - val location = uiState.location ?: return - val locationSource = - rememberGeoJsonSource( - GeoJsonData.Features( - FeatureCollection( - Feature( - geometry = Point(location.coordinates.lng, location.coordinates.lat), - properties = buildJsonObject {}, - ) - ) - ), - ) +private fun DemoRouteEndpointsOverlay(uiState: NavigationUiState) { + val route = uiState.routeGeometry ?: return + val start = route.firstOrNull() ?: return + val end = route.lastOrNull() ?: return + + val startSource = rememberGeoJsonSource( + GeoJsonData.Features(start.toRouteEndpointFeatureCollection()) + ) + val endSource = rememberGeoJsonSource( + GeoJsonData.Features(end.toRouteEndpointFeatureCollection()) + ) CircleLayer( - id = "demo-car-location-dot", - source = locationSource, - color = const(Color.Blue), + id = "demo-route-start", + source = startSource, + color = const(Color.Gray), radius = const(10.dp), + strokeColor = const(Color.White), + strokeWidth = const(2.dp), + pitchAlignment = const(CirclePitchAlignment.Map), + opacity = const(0.6f), ) - if (location.horizontalAccuracy > 15) { - CircleLayer( - id = "demo-car-location-accuracy", - source = locationSource, - color = const(Color.Blue), - opacity = const(0.2f), - radius = const(min(location.horizontalAccuracy.toFloat(), 150f).dp), - ) - } + CircleLayer( + id = "demo-route-end", + source = endSource, + color = const(Color.Green), + radius = const(10.dp), + strokeColor = const(Color.White), + strokeWidth = const(10.dp), + pitchAlignment = const(CirclePitchAlignment.Map), + opacity = const(0.6f), + ) } + +private fun GeographicCoordinate.toRouteEndpointFeatureCollection() = + FeatureCollection( + Feature( + geometry = Point( + longitude = this.lng, + latitude = this.lat, + ), + properties = buildJsonObject {}, + ) + ) From 6ed4111877ee43abcdcbe5789b4fe43268939d43 Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Thu, 26 Mar 2026 23:13:22 +0100 Subject: [PATCH 09/14] Add logging and refactor navigation puck rendering in Android --- .../ferrostar/auto/DemoNavigationScreen.kt | 6 + .../maplibreui/NavigationPuckOverlay.kt | 116 ++++++++++-------- 2 files changed, 69 insertions(+), 53 deletions(-) diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt index 81c04df7b..e965903f1 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt @@ -1,5 +1,6 @@ package com.stadiamaps.ferrostar.auto +import android.util.Log import androidx.car.app.CarContext import androidx.car.app.model.Template import androidx.car.app.navigation.model.NavigationTemplate @@ -157,6 +158,7 @@ class DemoNavigationScreen( } // Fall back to a basic map template of your App's preference here. + Log.d(TAG, "onGetTemplate fallback to demo map template") return buildDemoMapTemplate() } @@ -184,4 +186,8 @@ class DemoNavigationScreen( } invalidate() } + + companion object { + private const val TAG = "DemoNavigationScreen" + } } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt index c75329d47..7c041be16 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt @@ -2,11 +2,12 @@ package com.stadiamaps.ferrostar.maplibreui import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.VectorPainter -import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.stadiamaps.ferrostar.core.NavigationUiState @@ -17,11 +18,9 @@ import org.maplibre.compose.expressions.dsl.asNumber import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.feature import org.maplibre.compose.expressions.dsl.image -import org.maplibre.compose.expressions.value.CirclePitchAlignment import org.maplibre.compose.expressions.value.IconPitchAlignment import org.maplibre.compose.expressions.value.IconRotationAlignment import org.maplibre.compose.expressions.value.SymbolAnchor -import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.rememberGeoJsonSource @@ -68,37 +67,19 @@ internal fun NavigationPuckOverlay( navigationPuckFeatureCollection(longitude, latitude, bearingDegrees), ), ) - val arrowPainter = rememberNavigationPuckArrowPainter(style.dotFillColorCurrentLocation) - val puckRadius = 23.dp - val puckShadowRadius = 32.dp - val arrowSize = 23.dp - - CircleLayer( - id = "ferrostar-navigation-puck-shadow", - source = source, - color = const(Color.Black), - radius = const(puckShadowRadius), - opacity = const(0.1f), - pitchAlignment = const(CirclePitchAlignment.Map), - ) - - CircleLayer( - id = "ferrostar-navigation-puck-background", - source = source, - color = const(Color.White), - radius = const(puckRadius), - strokeColor = const(Color(0xFFE5E5EA)), - strokeWidth = const(0.75.dp), - pitchAlignment = const(CirclePitchAlignment.Map), - ) + val puckPainter = rememberNavigationPuckPainter(style.dotFillColorCurrentLocation) + val puckSize = 80.dp SymbolLayer( - id = "ferrostar-navigation-puck-arrow", + id = "ferrostar-navigation-puck", source = source, iconImage = image( - arrowPainter, - size = DpSize(arrowSize, arrowSize), + value = puckPainter, + size = DpSize( + width = puckSize, + height = puckSize, + ), drawAsSdf = false, ), iconAnchor = const(SymbolAnchor.Center), @@ -111,25 +92,54 @@ internal fun NavigationPuckOverlay( } @Composable -private fun rememberNavigationPuckArrowPainter(color: Color): VectorPainter = - rememberVectorPainter( - remember(color) { - ImageVector.Builder( - name = "ferrostar_navigation_puck_arrow", - defaultWidth = 20.dp, - defaultHeight = 20.dp, - viewportWidth = 20f, - viewportHeight = 20f, +private fun rememberNavigationPuckPainter(color: Color): Painter = + remember(color) { + object : Painter() { + override val intrinsicSize: Size = Size.Unspecified + + override fun androidx.compose.ui.graphics.drawscope.DrawScope.onDraw() { + val minDimension = size.minDimension + val center = Offset(size.width / 2f, size.height / 2f) + val haloRadius = minDimension * 0.5f + val puckRadius = minDimension * 0.359375f + val borderWidth = minDimension * 0.012f + + drawCircle( + color = Color.Black.copy(alpha = 0.1f), + radius = haloRadius, + center = center, ) - .apply { - path(fill = androidx.compose.ui.graphics.SolidColor(color)) { - moveTo(10f, 1.6f) - lineTo(17.8f, 17.2f) - lineTo(10f, 12.6f) - lineTo(2.2f, 17.2f) - close() - } - } - .build() - }, - ) + drawCircle( + color = Color.White, + radius = puckRadius, + center = center, + ) + drawCircle( + color = Color(0xFFE5E5EA), + radius = puckRadius, + center = center, + style = Stroke(width = borderWidth), + ) + drawPath( + path = navigationArrowPath(size), + color = color, + ) + } + } + } + +private fun navigationArrowPath(size: Size): Path { + val minDimension = size.minDimension + val centerX = size.width / 2f + val centerY = size.height / 2f + val arrowHeight = minDimension * 0.34f + val arrowWidth = minDimension * 0.26f + + return Path().apply { + moveTo(centerX, centerY - arrowHeight * 0.55f) + lineTo(centerX + arrowWidth * 0.5f, centerY + arrowHeight * 0.35f) + lineTo(centerX, centerY + arrowHeight * 0.1f) + lineTo(centerX - arrowWidth * 0.5f, centerY + arrowHeight * 0.35f) + close() + } +} From 924f21b4bbdeaaceaa5d36275960d4849b18a3d5 Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Fri, 27 Mar 2026 10:49:04 +0100 Subject: [PATCH 10/14] Replace `onMapReadyCallback` with standard map load callbacks in `NavigationMapView` The `NavigationMapView` component in the Android MapLibre UI module now exposes `onMapLoadFinished` and `onMapLoadFailed` callbacks instead of the legacy `onMapReadyCallback`. This change removes the internal reflection-based workaround (`nativeStyleOrNull`) previously used to extract the native MapLibre `Style` from the compose `CameraState`. --- android/README.md | 2 +- .../ferrostar/maplibreui/NavigationMapView.kt | 29 +++---------- .../ferrostar/maplibreui/runtime/MapReady.kt | 42 ------------------- 3 files changed, 7 insertions(+), 66 deletions(-) delete mode 100644 android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt diff --git a/android/README.md b/android/README.md index 240366b9b..303009bd1 100644 --- a/android/README.md +++ b/android/README.md @@ -59,7 +59,7 @@ Notable Android phone/tablet migration changes: * Location puck styling is configurable through `NavigationMapPuckStyle`. * Route rendering now uses a GeoJSON source plus `LineLayer` instead of legacy polyline convenience APIs. * Map tap and long-press callbacks use Ferrostar-facing callbacks with `GeographicCoordinate` plus screen position. -* `onMapReadyCallback` is still available on `NavigationMapView` for the current 0.x migration path. +* `NavigationMapView` now exposes `onMapLoadFinished` and `onMapLoadFailed` instead of a native-style `onMapReadyCallback`. Example usage: diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt index 09fcfccfe..060c4908f 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt @@ -15,7 +15,6 @@ import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraMode import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraOptions import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState import com.stadiamaps.ferrostar.maplibreui.runtime.defaultNavigationCameraMode -import com.stadiamaps.ferrostar.maplibreui.runtime.nativeStyleOrNull import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions import com.stadiamaps.ferrostar.maplibreui.runtime.rememberFerrostarLocationState import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState @@ -24,7 +23,6 @@ import com.stadiamaps.ferrostar.maplibreui.runtime.trackingFollowingCameraPositi import com.stadiamaps.ferrostar.maplibreui.runtime.toMapLibreLocation import kotlinx.coroutines.flow.collectLatest import org.maplibre.compose.camera.CameraMoveReason -import org.maplibre.android.maps.Style import org.maplibre.compose.location.LocationPuck import org.maplibre.compose.location.LocationPuckColors import org.maplibre.compose.location.LocationPuckSizes @@ -51,8 +49,8 @@ import uniffi.ferrostar.GeographicCoordinate * @param navigationCameraOptions The camera templates applied when following the user in browsing * and navigation modes. * @param locationPuckStyle The style to use for the official MapLibre location puck. - * @param onMapReadyCallback A callback that is invoked when the underlying map style is ready to be - * interacted with. + * @param onMapLoadFinished A callback that is invoked when the map finished loading. + * @param onMapLoadFailed A callback that is invoked when the map failed to load. * @param onMapClick Callback invoked for taps on the map with geographic coordinates and screen * position. * @param onMapLongClick Callback invoked for long presses on the map with geographic coordinates @@ -68,7 +66,8 @@ fun NavigationMapView( routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle(), - onMapReadyCallback: ((Style) -> Unit)? = null, + onMapLoadFinished: () -> Unit = {}, + onMapLoadFailed: (String?) -> Unit = {}, onMapClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, onMapLongClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, content: @Composable @MaplibreComposable ((NavigationUiState) -> Unit)? = null, @@ -80,7 +79,6 @@ fun NavigationMapView( navigationMapState.navigationCameraOptions = navigationCameraOptions var isNavigating by remember { mutableStateOf(uiState.isNavigating()) } - var mapReadyCallbackFired by remember(styleUrl, onMapReadyCallback) { mutableStateOf(false) } if (uiState.isNavigating() != isNavigating) { isNavigating = uiState.isNavigating() navigationMapState.cameraMode = defaultNavigationCameraMode(isNavigating) @@ -130,6 +128,7 @@ fun NavigationMapView( onMapLongClick = { position, screenPosition -> onMapLongClick(position.toGeographicCoordinate(), screenPosition).toComposeClickResult() }, + onMapLoadFailed = onMapLoadFailed, onMapLoadFinished = { if (userLocation != null && navigationMapState.isTrackingUser) { cameraState.position = @@ -138,23 +137,7 @@ fun NavigationMapView( bearing = userLocation.bearing, ) } - - if (!mapReadyCallbackFired) { - if (onMapReadyCallback != null) { - cameraState.nativeStyleOrNull()?.let { - mapReadyCallbackFired = true - onMapReadyCallback(it) - } ?: run { - android.util.Log.w( - "NavigationMapView", - "onMapReadyCallback was requested but the native Style could not be accessed. " + - "The callback will not fire.") - mapReadyCallbackFired = true - } - } else { - mapReadyCallbackFired = true - } - } + onMapLoadFinished() }, options = mapOptions, ) { diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt deleted file mode 100644 index 89f51eff1..000000000 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapReady.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.stadiamaps.ferrostar.maplibreui.runtime - -import android.util.Log -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.Style -import org.maplibre.compose.camera.CameraState - -private const val MAP_READY_TAG = "NavigationMapView" - -/** - * Extracts the native [Style] from the compose [CameraState] via reflection. This is a workaround - * because maplibre-compose (tested against 0.12.1) does not expose a public API for accessing the - * underlying style after the map is ready. The function degrades gracefully — if the internal API - * changes, it returns null and logs a warning. - */ -internal fun CameraState.nativeStyleOrNull(): Style? { - val mapAdapter = - runCatching { - javaClass.getMethod("getMap\$maplibre_compose").invoke(this) - } - .onFailure { - Log.w(MAP_READY_TAG, "Unable to read compose map adapter for onMapReadyCallback", it) - } - .getOrNull() ?: return null - - val rawMap = - runCatching { - val field = mapAdapter.javaClass.getDeclaredField("map") - field.isAccessible = true - field.get(mapAdapter) as? MapLibreMap - } - .onFailure { - Log.w(MAP_READY_TAG, "Unable to read MapLibreMap from compose adapter", it) - } - .getOrNull() - - return rawMap?.style.also { style -> - if (style == null) { - Log.w(MAP_READY_TAG, "onMapReadyCallback could not access native Style from compose map") - } - } -} From 62099b572338a42fe2c4c80e67c4e34ea9239d71 Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Mon, 30 Mar 2026 23:43:44 +0200 Subject: [PATCH 11/14] Add pan, fling, and scale gesture support to `NavigationMapState` and Android Auto UI --- .../maplibre/car/app/CarAppNavigationView.kt | 1 + .../maplibre/car/app/runtime/ScreenState.kt | 62 ++++++--- .../car/app/runtime/SurfaceGestureBridge.kt | 49 +++++++ android/ui-maplibre/build.gradle | 1 + .../maplibreui/runtime/NavigationMapState.kt | 38 ++++++ .../runtime/NavigationMapStateTest.kt | 127 ++++++++++++++++++ 6 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceGestureBridge.kt diff --git a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt index bb540dbd4..7f074726f 100644 --- a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt +++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt @@ -47,6 +47,7 @@ fun CarAppNavigationView( mapContent: @Composable @MaplibreComposable ((NavigationUiState) -> Unit)? = null, ) { val uiState by viewModel.navigationUiState.collectAsState() + surfaceAreaTracker?.rememberGestureDelegate(navigationMapState) val surfaceArea by surfaceAreaTracker ?.let { screenSurfaceState(it) } diff --git a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/ScreenState.kt b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/ScreenState.kt index eb1fa5690..ad96ca9a2 100644 --- a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/ScreenState.kt +++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/ScreenState.kt @@ -2,18 +2,20 @@ package com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime import android.graphics.Rect import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import com.maplibre.compose.ramani.MapLibreComposable +import androidx.compose.ui.platform.LocalDensity import com.maplibre.compose.surface.SurfaceGestureCallback -import com.maplibre.compose.surface.rememberMapSurfaceGestureCallback +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState +import kotlin.time.Duration /** * Bridges [SurfaceGestureCallback] events into Compose-observable [MutableState], while - * forwarding gesture events (scroll, fling, scale) to a map gesture delegate. + * optionally forwarding gesture events (scroll, fling, scale) to a map gesture delegate. * * Usage: * 1. Create an instance, passing the registration lambda so it self-registers immediately: @@ -56,27 +58,56 @@ class SurfaceAreaTracker(register: (SurfaceAreaTracker) -> Unit) : SurfaceGestur delegate?.onScale(focusX, focusY, scaleFactor) } - /** - * Wires up map gesture handling (scroll, fling, scale). Must be called within a - * [MapLibreComposable] context. Use this when the surface area state is already observed - * elsewhere (e.g. via [screenSurfaceState] in a parent composable). - */ + /** Wires up best-effort map gesture handling using the public compose camera/projection API. */ @Composable - @MapLibreComposable - fun rememberGestureDelegate() { - rememberMapSurfaceGestureCallback { delegate = it } + fun rememberGestureDelegate( + navigationMapState: NavigationMapState, + flingDuration: Duration = defaultFlingDuration(), + flingVelocityFactor: Float = DEFAULT_FLING_VELOCITY_FACTOR, + ) { + val density = LocalDensity.current + val callback = remember( + navigationMapState, + density, + flingDuration, + flingVelocityFactor, + ) { + ComposeMapSurfaceGestureCallback( + navigationMapState = navigationMapState, + density = density, + flingDuration = flingDuration, + flingVelocityFactor = flingVelocityFactor, + ) + } + + DisposableEffect(callback) { + delegate = callback + onDispose { + if (delegate === callback) { + delegate = null + } + } + } } /** * Wires up map gesture handling (scroll, fling, scale) and returns a [State] tracking the - * current [SurfaceArea]. Must be called within a [MapLibreComposable] context. + * current [SurfaceArea]. */ @Composable - @MapLibreComposable - fun rememberSurfaceArea(): State { - rememberGestureDelegate() + fun rememberSurfaceArea( + navigationMapState: NavigationMapState, + flingDuration: Duration = defaultFlingDuration(), + flingVelocityFactor: Float = DEFAULT_FLING_VELOCITY_FACTOR, + ): State { + rememberGestureDelegate( + navigationMapState = navigationMapState, + flingDuration = flingDuration, + flingVelocityFactor = flingVelocityFactor + ) return screenSurfaceState(stableArea, visibleArea) } + } data class SurfaceArea( @@ -106,4 +137,3 @@ fun screenSurfaceState( } } } - diff --git a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceGestureBridge.kt b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceGestureBridge.kt new file mode 100644 index 000000000..ae92fa2dd --- /dev/null +++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceGestureBridge.kt @@ -0,0 +1,49 @@ +package com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpOffset +import com.maplibre.compose.surface.SurfaceGestureCallback +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal const val DEFAULT_FLING_VELOCITY_FACTOR = 0.1f + +@Composable +internal fun defaultFlingDuration(): Duration = 300.milliseconds + +internal class ComposeMapSurfaceGestureCallback( + private val navigationMapState: NavigationMapState, + private val density: Density, + private val flingDuration: Duration, + private val flingVelocityFactor: Float, +) : SurfaceGestureCallback { + override fun onScroll(distanceX: Float, distanceY: Float) { + // Preserve the old Ramani surface-gesture sign convention until DHU validation proves + // that Android Auto's host scroll callbacks need to be inverted here. + navigationMapState.panBy( + density.toDpOffset( + xPx = distanceX, + yPx = distanceY, + )) + } + + override fun onFling(velocityX: Float, velocityY: Float) { + navigationMapState.flingBy( + screenDistance = + density.toDpOffset( + xPx = -velocityX * flingVelocityFactor, + yPx = -velocityY * flingVelocityFactor, + ), + duration = flingDuration, + ) + } + + override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { + navigationMapState.scaleBy(scaleFactor) + } +} + +internal fun Density.toDpOffset(xPx: Float, yPx: Float): DpOffset = + DpOffset(x = xPx.toDp(), y = yPx.toDp()) diff --git a/android/ui-maplibre/build.gradle b/android/ui-maplibre/build.gradle index 5893c9011..c3af3711d 100644 --- a/android/ui-maplibre/build.gradle +++ b/android/ui-maplibre/build.gradle @@ -53,6 +53,7 @@ dependencies { implementation project(':ui-compose') testImplementation libs.junit + testImplementation libs.mockk androidTestImplementation libs.androidx.test.junit androidTestImplementation libs.androidx.test.espresso diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt index 8b56c3ace..8d7d228d6 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt @@ -8,7 +8,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpOffset import com.stadiamaps.ferrostar.core.BoundingBox +import kotlin.math.ln import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -46,6 +48,42 @@ internal constructor( cameraState.incrementZoom(-delta) } + fun panBy(screenDistance: DpOffset) { + val projection = cameraState.projection ?: return + val currentPosition = cameraState.position + val translatedTarget = + projection.positionFromScreenLocation( + projection.screenLocationFromPosition(currentPosition.target) + screenDistance) + + cameraMode = NavigationCameraMode.FREE + cameraState.position = currentPosition.copy(target = translatedTarget) + } + + fun flingBy(screenDistance: DpOffset, duration: Duration = 300.milliseconds) { + val projection = cameraState.projection ?: return + val currentPosition = cameraState.position + val translatedTarget = + projection.positionFromScreenLocation( + projection.screenLocationFromPosition(currentPosition.target) + screenDistance) + + cameraMode = NavigationCameraMode.FREE + coroutineScope.launch { + cameraState.animateTo( + finalPosition = currentPosition.copy(target = translatedTarget), + duration = duration, + ) + } + } + + fun scaleBy(scaleFactor: Float) { + if (scaleFactor <= 0f) return + + val zoomDelta = ln(scaleFactor.toDouble()) / ln(2.0) + cameraMode = NavigationCameraMode.FREE + cameraState.position = + cameraState.position.copy(zoom = (cameraState.position.zoom + zoomDelta).coerceAtLeast(0.0)) + } + fun showRouteOverview( boundingBox: BoundingBox, paddingValues: PaddingValues = PaddingValues(), diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt index 7a6ab90db..fc9a936d3 100644 --- a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt @@ -1,18 +1,27 @@ package com.stadiamaps.ferrostar.maplibreui.runtime import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp import com.stadiamaps.ferrostar.core.BoundingBox +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test +import org.maplibre.compose.camera.CameraProjection import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.CameraState +import org.maplibre.spatialk.geojson.Position class NavigationMapStateTest { private val testScope = CoroutineScope(Job() + Dispatchers.Unconfined) @@ -48,6 +57,107 @@ class NavigationMapStateTest { assertEquals(9.0, cameraState.position.zoom, 0.0) } + @Test + fun panBySwitchesToFreeCameraAndUpdatesTarget() { + val initialPosition = CameraPosition(target = Position(16.0, 48.0), zoom = 10.0) + val updatedTarget = Position(16.1, 48.2) + val projection = mockk() + val cameraState = mockCameraState(initialPosition = initialPosition, projection = projection) + val state = createState(cameraState = cameraState, initialCameraMode = NavigationCameraMode.FOLLOW_USER) + + every { projection.screenLocationFromPosition(initialPosition.target) } returns DpOffset(100.dp, 200.dp) + every { projection.positionFromScreenLocation(DpOffset(120.dp, 180.dp)) } returns updatedTarget + + state.panBy(DpOffset(20.dp, (-20).dp)) + + assertEquals(NavigationCameraMode.FREE, state.cameraMode) + assertEquals(updatedTarget, cameraState.position.target) + } + + @Test + fun panByIsNoOpWithoutProjection() { + val initialPosition = CameraPosition(target = Position(16.0, 48.0), zoom = 10.0) + val cameraState = CameraState(initialPosition) + val state = createState(cameraState = cameraState, initialCameraMode = NavigationCameraMode.FOLLOW_USER) + + state.panBy(DpOffset(5.dp, 7.dp)) + + assertEquals(NavigationCameraMode.FOLLOW_USER, state.cameraMode) + assertEquals(initialPosition, cameraState.position) + } + + @Test + fun flingBySwitchesToFreeCameraAndAnimatesTarget() { + val initialPosition = CameraPosition(target = Position(16.0, 48.0), zoom = 10.0) + val updatedTarget = Position(16.3, 48.4) + val projection = mockk() + val cameraState = mockCameraState(initialPosition = initialPosition, projection = projection) + val state = createState(cameraState = cameraState, initialCameraMode = NavigationCameraMode.FOLLOW_USER) + + every { projection.screenLocationFromPosition(initialPosition.target) } returns DpOffset(50.dp, 80.dp) + every { projection.positionFromScreenLocation(DpOffset(65.dp, 100.dp)) } returns updatedTarget + + state.flingBy(DpOffset(15.dp, 20.dp), duration = 120.milliseconds) + + assertEquals(NavigationCameraMode.FREE, state.cameraMode) + coVerify { + cameraState.animateTo( + finalPosition = match { it.target == updatedTarget }, + duration = 120.milliseconds, + ) + } + } + + @Test + fun flingByIsNoOpWithoutProjection() { + val initialPosition = CameraPosition(target = Position(16.0, 48.0), zoom = 10.0) + val cameraState = mockk(relaxed = true) + every { cameraState.projection } returns null + every { cameraState.position } returns initialPosition + coEvery { cameraState.animateTo(any(), any()) } returns Unit + val state = createState(cameraState = cameraState, initialCameraMode = NavigationCameraMode.FOLLOW_USER) + + state.flingBy(DpOffset(15.dp, 20.dp), duration = 120.milliseconds) + + assertEquals(NavigationCameraMode.FOLLOW_USER, state.cameraMode) + coVerify(exactly = 0) { cameraState.animateTo(any(), any()) } + } + + @Test + fun scaleByIgnoresNonPositiveFactors() { + val cameraState = CameraState(CameraPosition(zoom = 10.0)) + val state = createState(cameraState = cameraState, initialCameraMode = NavigationCameraMode.FOLLOW_USER) + + state.scaleBy(0f) + state.scaleBy(-1f) + + assertEquals(NavigationCameraMode.FOLLOW_USER, state.cameraMode) + assertEquals(10.0, cameraState.position.zoom, 0.0) + } + + @Test + fun scaleByUsesLogBase2ZoomDelta() { + val cameraState = CameraState(CameraPosition(zoom = 10.0)) + val state = createState(cameraState = cameraState, initialCameraMode = NavigationCameraMode.FOLLOW_USER) + + state.scaleBy(2f) + assertEquals(NavigationCameraMode.FREE, state.cameraMode) + assertEquals(11.0, cameraState.position.zoom, 0.0) + + state.scaleBy(0.5f) + assertEquals(10.0, cameraState.position.zoom, 0.0001) + } + + @Test + fun scaleByClampsZoomAtZero() { + val cameraState = CameraState(CameraPosition(zoom = 0.25)) + val state = createState(cameraState = cameraState) + + state.scaleBy(0.1f) + + assertEquals(0.0, cameraState.position.zoom, 0.0) + } + @Test fun recenterUsesBrowsingTemplateZoomAfterFreeCamera() { val cameraState = CameraState(CameraPosition(zoom = 10.0)) @@ -128,6 +238,23 @@ class NavigationMapStateTest { assertEquals(300.milliseconds, normalizeOverviewAnimationDuration(300.milliseconds)) } + private fun mockCameraState( + initialPosition: CameraPosition, + projection: CameraProjection, + ): CameraState { + val cameraState = mockk(relaxed = true) + var currentPosition = initialPosition + + every { cameraState.projection } returns projection + every { cameraState.position } answers { currentPosition } + every { cameraState.position = any() } answers { currentPosition = firstArg() } + coEvery { cameraState.animateTo(any(), any()) } answers { + currentPosition = firstArg() + } + + return cameraState + } + private fun createState( cameraState: CameraState = CameraState(CameraPosition()), initialCameraMode: NavigationCameraMode = defaultNavigationCameraMode(isNavigating = false), From 50b6a3a58e0a30a658a96c5409decb8718ad6e8b Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Wed, 1 Apr 2026 00:20:24 +0200 Subject: [PATCH 12/14] Implement route snapping and improved camera tracking in Android MapLibre UI Introduces a route-snapping mechanism for the navigation puck and camera using a new `rememberDisplayedNavigationLocation` hook. This ensures the displayed position and bearing stay locked to the route geometry during navigation, reducing jitter from noisy GPS data. --- .../ferrostar/DemoNavigationViewModel.kt | 8 +- .../ferrostar/maplibreui/NavigationMapView.kt | 63 ++--- .../maplibreui/NavigationPuckOverlay.kt | 16 +- .../runtime/DisplayedNavigationLocation.kt | 229 ++++++++++++++++++ .../runtime/TrackingCameraEffect.kt | 57 +++++ 5 files changed, 323 insertions(+), 50 deletions(-) create mode 100644 android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/DisplayedNavigationLocation.kt create mode 100644 android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/TrackingCameraEffect.kt diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt index ca205ef24..43daa8cfc 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt @@ -66,11 +66,9 @@ class DemoNavigationViewModel( init { viewModelScope.launch { - combine(_hasLocationPermission, _simulated) { hasPermission, simulated -> - hasPermission to simulated - } - .flatMapLatest { (hasPermission, simulated) -> - if (simulated || !hasPermission) { + _hasLocationPermission + .flatMapLatest { hasPermission -> + if (!hasPermission) { flowOf(initialSimulatedLocation) } else { locationProvider.locationUpdates(5000L) diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt index 060c4908f..51eff8c6f 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt @@ -14,19 +14,18 @@ import com.stadiamaps.ferrostar.maplibreui.routeline.RouteOverlayBuilder import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraMode import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraOptions import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState +import com.stadiamaps.ferrostar.maplibreui.runtime.TrackingCameraEffect import com.stadiamaps.ferrostar.maplibreui.runtime.defaultNavigationCameraMode +import com.stadiamaps.ferrostar.maplibreui.runtime.rememberDisplayedNavigationLocation import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions import com.stadiamaps.ferrostar.maplibreui.runtime.rememberFerrostarLocationState import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState -import com.stadiamaps.ferrostar.maplibreui.runtime.templateFollowingCameraPosition -import com.stadiamaps.ferrostar.maplibreui.runtime.trackingFollowingCameraPosition -import com.stadiamaps.ferrostar.maplibreui.runtime.toMapLibreLocation +import com.stadiamaps.ferrostar.maplibreui.runtime.snapTrackingCameraToUserLocation import kotlinx.coroutines.flow.collectLatest import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.location.LocationPuck import org.maplibre.compose.location.LocationPuckColors import org.maplibre.compose.location.LocationPuckSizes -import org.maplibre.compose.location.LocationTrackingEffect import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.style.BaseStyle @@ -74,7 +73,7 @@ fun NavigationMapView( ) { val cameraState = navigationMapState.cameraState val userLocationState = rememberFerrostarLocationState(uiState.location) - val userLocation = uiState.location?.toMapLibreLocation() + val displayedNavigationLocation = rememberDisplayedNavigationLocation(uiState) var lastKnownNavigationPuckBearing by remember { mutableStateOf(0.0) } navigationMapState.navigationCameraOptions = navigationCameraOptions @@ -84,31 +83,14 @@ fun NavigationMapView( navigationMapState.cameraMode = defaultNavigationCameraMode(isNavigating) } - LaunchedEffect(navigationMapState.cameraMode, userLocation != null) { - if (userLocation != null && navigationMapState.isTrackingUser) { - cameraState.position = - navigationMapState.templateFollowingCameraPosition( - target = userLocation.position, - bearing = userLocation.bearing, - ) - } - } - - LaunchedEffect(userLocation?.bearing) { - userLocation?.bearing?.let { lastKnownNavigationPuckBearing = it } + LaunchedEffect(displayedNavigationLocation?.bearing) { + displayedNavigationLocation?.bearing?.let { lastKnownNavigationPuckBearing = it } } - LocationTrackingEffect( - locationState = userLocationState, - enabled = navigationMapState.isTrackingUser, - trackBearing = navigationMapState.cameraMode == NavigationCameraMode.FOLLOW_USER_WITH_BEARING, - ) { - cameraState.position = - navigationMapState.trackingFollowingCameraPosition( - target = currentLocation.position, - bearing = currentLocation.bearing, - ) - } + TrackingCameraEffect( + navigationMapState = navigationMapState, + userLocation = displayedNavigationLocation, + ) LaunchedEffect(cameraState, navigationMapState) { snapshotFlow { cameraState.moveReason }.collectLatest { moveReason -> @@ -130,12 +112,8 @@ fun NavigationMapView( }, onMapLoadFailed = onMapLoadFailed, onMapLoadFinished = { - if (userLocation != null && navigationMapState.isTrackingUser) { - cameraState.position = - navigationMapState.templateFollowingCameraPosition( - target = userLocation.position, - bearing = userLocation.bearing, - ) + if (displayedNavigationLocation != null && navigationMapState.isTrackingUser) { + navigationMapState.snapTrackingCameraToUserLocation(displayedNavigationLocation) } onMapLoadFinished() }, @@ -143,14 +121,17 @@ fun NavigationMapView( ) { routeOverlayBuilder.navigationPath(uiState) - if (shouldRenderNavigationPuck(uiState) && userLocation != null) { + if (shouldRenderNavigationPuck(uiState) && displayedNavigationLocation != null) { NavigationPuckOverlay( - longitude = userLocation.position.longitude, - latitude = userLocation.position.latitude, - bearingDegrees = - navigationPuckBearingDegrees( - currentBearing = userLocation.bearing, - lastKnownBearing = lastKnownNavigationPuckBearing, + target = + NavigationPuckTarget( + longitude = displayedNavigationLocation.position.longitude, + latitude = displayedNavigationLocation.position.latitude, + bearingDegrees = + navigationPuckBearingDegrees( + currentBearing = displayedNavigationLocation.bearing, + lastKnownBearing = lastKnownNavigationPuckBearing, + ), ), style = locationPuckStyle, ) diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt index 7c041be16..e1d353279 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt @@ -53,18 +53,26 @@ internal fun navigationPuckFeatureCollection( ) ) +internal data class NavigationPuckTarget( + val longitude: Double, + val latitude: Double, + val bearingDegrees: Double, +) + @Composable @MaplibreComposable internal fun NavigationPuckOverlay( - longitude: Double, - latitude: Double, - bearingDegrees: Double, + target: NavigationPuckTarget, style: NavigationMapPuckStyle, ) { val source = rememberGeoJsonSource( GeoJsonData.Features( - navigationPuckFeatureCollection(longitude, latitude, bearingDegrees), + navigationPuckFeatureCollection( + longitude = target.longitude, + latitude = target.latitude, + bearingDegrees = target.bearingDegrees, + ), ), ) val puckPainter = rememberNavigationPuckPainter(style.dotFillColorCurrentLocation) diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/DisplayedNavigationLocation.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/DisplayedNavigationLocation.kt new file mode 100644 index 000000000..8f43e24c1 --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/DisplayedNavigationLocation.kt @@ -0,0 +1,229 @@ +package com.stadiamaps.ferrostar.maplibreui.runtime + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.stadiamaps.ferrostar.core.NavigationUiState +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sqrt +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource +import org.maplibre.compose.location.Location +import org.maplibre.spatialk.geojson.Position +import uniffi.ferrostar.GeographicCoordinate +import uniffi.ferrostar.RouteDeviation + +internal val DISPLAY_LOCATION_ANIMATION_DURATION = 1000.milliseconds +private const val DISPLAY_BEARING_LOOKBACK_METERS = 5.0 +private const val DISPLAY_BEARING_LOOKAHEAD_METERS = 15.0 + +@Composable +internal fun rememberDisplayedNavigationLocation( + uiState: NavigationUiState, +): Location? { + val userLocation = uiState.location?.toMapLibreLocation() ?: return null + return rememberRouteSnappedLocation(uiState, userLocation) +} + +@Composable +private fun rememberRouteSnappedLocation( + uiState: NavigationUiState, + userLocation: Location, +): Location { + val route = + remember(uiState.routeGeometry) { + uiState.routeGeometry?.takeIf { it.size >= 2 }?.let(::RoutePolyline) + } + + if (!uiState.isNavigating() || uiState.routeDeviation !is RouteDeviation.NoDeviation || route == null) { + return userLocation + } + + val targetProjection = remember(route, userLocation.position) { route.project(userLocation.position) } + val animatedProgress = remember(route) { Animatable(targetProjection.progressMeters, DoubleToVector) } + val displayedProgress by animatedProgress.asState() + + LaunchedEffect(route, targetProjection.progressMeters) { + val targetProgress = max(animatedProgress.value, targetProjection.progressMeters) + if (targetProgress == animatedProgress.value) { + return@LaunchedEffect + } + + animatedProgress.animateTo( + targetValue = targetProgress, + animationSpec = + tween( + durationMillis = DISPLAY_LOCATION_ANIMATION_DURATION.inWholeMilliseconds.toInt(), + easing = LinearEasing, + ), + ) + } + + val displayedPosition = remember(route, displayedProgress) { route.positionAt(displayedProgress) } + // While on route, use the route tangent as the display bearing so puck and camera rotation stay + // stable even when course-over-ground is noisy. + val displayedBearing = remember(route, displayedProgress) { route.bearingAt(displayedProgress) } + + return Location( + position = displayedPosition, + accuracy = userLocation.accuracy, + bearing = displayedBearing, + bearingAccuracy = userLocation.bearingAccuracy, + speed = userLocation.speed, + speedAccuracy = userLocation.speedAccuracy, + timestamp = TimeSource.Monotonic.markNow(), + ) +} + +private class RoutePolyline( + routeGeometry: List, +) { + private val points = routeGeometry.map { Position(it.lng, it.lat) } + private val cumulativeDistancesMeters = buildCumulativeDistances(points) + private val totalLengthMeters = cumulativeDistancesMeters.last() + + fun project(position: Position): RouteProjection { + var bestDistanceSquared = Double.POSITIVE_INFINITY + var bestProjection = RouteProjection(progressMeters = 0.0) + + for (index in 0 until points.lastIndex) { + val segmentProjection = projectOntoSegment(index, position) + if (segmentProjection.distanceSquaredMeters < bestDistanceSquared) { + bestDistanceSquared = segmentProjection.distanceSquaredMeters + bestProjection = RouteProjection(progressMeters = segmentProjection.progressMeters) + } + } + + return bestProjection + } + + fun positionAt(progressMeters: Double): Position { + val clampedProgress = progressMeters.coerceIn(0.0, totalLengthMeters) + val segmentIndex = segmentIndexAt(clampedProgress) + val segmentStart = points[segmentIndex] + val segmentEnd = points[segmentIndex + 1] + val segmentLength = cumulativeDistancesMeters[segmentIndex + 1] - cumulativeDistancesMeters[segmentIndex] + + if (segmentLength == 0.0) { + return segmentStart + } + + val t = (clampedProgress - cumulativeDistancesMeters[segmentIndex]) / segmentLength + return Position( + interpolateCoordinate(segmentStart.longitude, segmentEnd.longitude, t), + interpolateCoordinate(segmentStart.latitude, segmentEnd.latitude, t), + ) + } + + fun bearingAt(progressMeters: Double): Double { + val clampedProgress = progressMeters.coerceIn(0.0, totalLengthMeters) + val startProgress = (clampedProgress - DISPLAY_BEARING_LOOKBACK_METERS).coerceAtLeast(0.0) + val endProgress = (clampedProgress + DISPLAY_BEARING_LOOKAHEAD_METERS).coerceAtMost(totalLengthMeters) + + if (endProgress <= startProgress) { + val segmentIndex = segmentIndexAt(clampedProgress) + return bearingDegrees(points[segmentIndex], points[segmentIndex + 1]) + } + + return bearingDegrees(positionAt(startProgress), positionAt(endProgress)) + } + + private fun segmentIndexAt(progressMeters: Double): Int { + for (index in 0 until cumulativeDistancesMeters.lastIndex) { + if (progressMeters <= cumulativeDistancesMeters[index + 1]) { + return index + } + } + return max(0, points.lastIndex - 1) + } + + private fun projectOntoSegment(index: Int, position: Position): SegmentProjection { + val start = points[index] + val end = points[index + 1] + val meanLatitudeRadians = Math.toRadians((start.latitude + end.latitude + position.latitude) / 3.0) + val scaleX = 111_320.0 * cos(meanLatitudeRadians) + val scaleY = 111_320.0 + + val startX = start.longitude * scaleX + val startY = start.latitude * scaleY + val endX = end.longitude * scaleX + val endY = end.latitude * scaleY + val pointX = position.longitude * scaleX + val pointY = position.latitude * scaleY + + val segmentX = endX - startX + val segmentY = endY - startY + val segmentLengthSquared = segmentX * segmentX + segmentY * segmentY + val rawT = + if (segmentLengthSquared == 0.0) { + 0.0 + } else { + ((pointX - startX) * segmentX + (pointY - startY) * segmentY) / segmentLengthSquared + } + val t = rawT.coerceIn(0.0, 1.0) + val projectedX = startX + segmentX * t + val projectedY = startY + segmentY * t + val distanceSquaredMeters = + (pointX - projectedX) * (pointX - projectedX) + (pointY - projectedY) * (pointY - projectedY) + val segmentLengthMeters = sqrt(segmentLengthSquared) + + return SegmentProjection( + progressMeters = cumulativeDistancesMeters[index] + segmentLengthMeters * t, + distanceSquaredMeters = distanceSquaredMeters, + ) + } +} + +private data class RouteProjection( + val progressMeters: Double, +) + +private data class SegmentProjection( + val progressMeters: Double, + val distanceSquaredMeters: Double, +) + +private val DoubleToVector = + TwoWayConverter( + convertToVector = { AnimationVector1D(it.toFloat()) }, + convertFromVector = { it.value.toDouble() }, + ) + +private fun interpolateCoordinate(start: Double, end: Double, t: Double): Double = start + (end - start) * t + +private fun buildCumulativeDistances(points: List): List { + val cumulativeDistances = ArrayList(points.size) + cumulativeDistances += 0.0 + + for (index in 1 until points.size) { + cumulativeDistances += cumulativeDistances.last() + distanceMeters(points[index - 1], points[index]) + } + + return cumulativeDistances +} + +private fun distanceMeters(a: Position, b: Position): Double { + val latitudeDeltaMeters = (b.latitude - a.latitude) * 111_320.0 + val averageLatitudeRadians = Math.toRadians((a.latitude + b.latitude) / 2.0) + val longitudeDeltaMeters = (b.longitude - a.longitude) * 111_320.0 * cos(averageLatitudeRadians) + + return sqrt( + latitudeDeltaMeters * latitudeDeltaMeters + + longitudeDeltaMeters * longitudeDeltaMeters, + ) +} + +private fun bearingDegrees(start: Position, end: Position): Double { + val meanLatitudeRadians = Math.toRadians((start.latitude + end.latitude) / 2.0) + val deltaX = (end.longitude - start.longitude) * cos(meanLatitudeRadians) + val deltaY = end.latitude - start.latitude + return (Math.toDegrees(atan2(deltaX, deltaY)) + 360.0) % 360.0 +} diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/TrackingCameraEffect.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/TrackingCameraEffect.kt new file mode 100644 index 000000000..1b26f9188 --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/TrackingCameraEffect.kt @@ -0,0 +1,57 @@ +package com.stadiamaps.ferrostar.maplibreui.runtime + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import org.maplibre.compose.location.Location + +/** + * Mutable holder for tracking state that does NOT use Compose State, + * so writes during composition don't trigger recomposition. + */ +private class TrackingState { + var hadLocation = false + var lastMode: NavigationCameraMode? = null +} + +/** + * Sets the camera position synchronously during composition so that + * camera and puck update on the same frame. + * + * On the first location or after a mode change, the camera snaps to + * the template position (setting zoom/tilt from [NavigationCameraOptions]). + * On subsequent updates it preserves zoom/tilt and only updates target/bearing. + */ +@Composable +internal fun TrackingCameraEffect( + navigationMapState: NavigationMapState, + userLocation: Location?, +) { + val state = remember { TrackingState() } + val cameraState = navigationMapState.cameraState + + if (userLocation != null && navigationMapState.isTrackingUser) { + val shouldSnap = !state.hadLocation || state.lastMode != navigationMapState.cameraMode + if (shouldSnap) { + navigationMapState.snapTrackingCameraToUserLocation(userLocation) + } else { + cameraState.position = + navigationMapState.trackingFollowingCameraPosition( + target = userLocation.position, + bearing = userLocation.bearing, + ) + } + state.hadLocation = true + state.lastMode = navigationMapState.cameraMode + } else { + state.hadLocation = false + state.lastMode = navigationMapState.cameraMode + } +} + +internal fun NavigationMapState.snapTrackingCameraToUserLocation(userLocation: Location) { + cameraState.position = + templateFollowingCameraPosition( + target = userLocation.position, + bearing = userLocation.bearing, + ) +} From 38bf99ca14e8b3614148af9804d16f5b68bd7cf8 Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Wed, 1 Apr 2026 12:16:43 +0200 Subject: [PATCH 13/14] Implement route snapping and improved camera tracking in Android Compose MapLibre UI --- android/README.md | 24 ++- .../ferrostar/DemoNavigationScene.kt | 3 +- .../maplibre/car/app/CarAppNavigationView.kt | 3 +- .../ferrostar/maplibreui/NavigationMapView.kt | 88 ++++----- .../maplibreui/runtime/NavigationMapState.kt | 2 +- .../DynamicallyOrientingNavigationView.kt | 9 +- .../views/LandscapeNavigationView.kt | 11 +- .../views/PortraitNavigationView.kt | 13 +- guide/src/jetpack-compose-customization.md | 173 +++++++++++------- 9 files changed, 199 insertions(+), 127 deletions(-) diff --git a/android/README.md b/android/README.md index 303009bd1..abc593d05 100644 --- a/android/README.md +++ b/android/README.md @@ -60,6 +60,9 @@ Notable Android phone/tablet migration changes: * Route rendering now uses a GeoJSON source plus `LineLayer` instead of legacy polyline convenience APIs. * Map tap and long-press callbacks use Ferrostar-facing callbacks with `GeographicCoordinate` plus screen position. * `NavigationMapView` now exposes `onMapLoadFinished` and `onMapLoadFailed` instead of a native-style `onMapReadyCallback`. +* `NavigationMapView` and the phone/tablet wrapper views now take `baseStyle: BaseStyle` directly. +* `NavigationMapState.cameraState` is public for direct access to projection queries and imperative camera animation. +* Default route and puck rendering can be disabled with `routeOverlayBuilder = null` and `showDefaultPuck = false`. Example usage: @@ -68,18 +71,37 @@ val navigationMapState = rememberNavigationMapState() DynamicallyOrientingNavigationView( modifier = Modifier.fillMaxSize(), - styleUrl = styleUrl, + baseStyle = BaseStyle.Uri(styleUrl), navigationMapState = navigationMapState, viewModel = viewModel, ) ``` +Using a `BaseStyle` directly: + +```kotlin +val navigationMapState = rememberNavigationMapState() + +NavigationMapView( + baseStyle = BaseStyle.Uri(styleUrl), + navigationMapState = navigationMapState, + uiState = uiState, + mapOptions = MapOptions(), + routeOverlayBuilder = null, + showDefaultPuck = false, +) +``` + +If you generate style JSON in memory, `BaseStyle.Json(styleJson)` works as well. + Custom camera control now goes through `NavigationMapState`: ```kotlin navigationMapState.zoomIn() navigationMapState.recenter(isNavigating = true) navigationMapState.showRouteOverview(boundingBox, paddingValues = mapInsets) +navigationMapState.cameraState.animateTo(finalPosition = cameraPosition) +navigationMapState.cameraState.animateTo(boundingBox = bounds, padding = mapInsets) ``` Current scope notes: diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt index c8704665a..a58594c9b 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt @@ -28,6 +28,7 @@ import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.style.BaseStyle import org.maplibre.compose.util.MaplibreComposable import uniffi.ferrostar.GeographicCoordinate @@ -84,7 +85,7 @@ fun DemoNavigationScene( DynamicallyOrientingNavigationView( modifier = Modifier.fillMaxSize(), - styleUrl = AppModule.mapStyleUrl, + baseStyle = BaseStyle.Uri(AppModule.mapStyleUrl), viewModel = viewModel, config = VisualNavigationViewConfig.Default().withSpeedLimitStyle(SignageStyle.MUTCD), views = diff --git a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt index 7f074726f..1205356db 100644 --- a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt +++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt @@ -26,6 +26,7 @@ import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.OrnamentOptions +import org.maplibre.compose.style.BaseStyle import org.maplibre.compose.util.MaplibreComposable /** @@ -57,7 +58,7 @@ fun CarAppNavigationView( Box(modifier) { NavigationMapView( - styleUrl = styleUrl, + baseStyle = BaseStyle.Uri(styleUrl), navigationMapState = navigationMapState, uiState = uiState, mapOptions = MapOptions(ornamentOptions = OrnamentOptions.AllDisabled), diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt index 51eff8c6f..2ee693bee 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt @@ -38,7 +38,7 @@ import uniffi.ferrostar.GeographicCoordinate * The base MapLibre map configured for navigation with a route line, location puck, gesture * callbacks, and Ferrostar-specific camera behavior for phone and tablet use. * - * @param styleUrl The MapLibre style URL to use for the map. + * @param baseStyle The MapLibre base style to use for the map. * @param navigationMapState The Ferrostar-owned map state used to control follow, overview, free * camera, and zoom behavior. * @param uiState The navigation UI state. @@ -48,6 +48,7 @@ import uniffi.ferrostar.GeographicCoordinate * @param navigationCameraOptions The camera templates applied when following the user in browsing * and navigation modes. * @param locationPuckStyle The style to use for the official MapLibre location puck. + * @param showDefaultPuck Whether Ferrostar should render its built-in location puck. * @param onMapLoadFinished A callback that is invoked when the map finished loading. * @param onMapLoadFailed A callback that is invoked when the map failed to load. * @param onMapClick Callback invoked for taps on the map with geographic coordinates and screen @@ -58,13 +59,14 @@ import uniffi.ferrostar.GeographicCoordinate */ @Composable fun NavigationMapView( - styleUrl: String, + baseStyle: BaseStyle, navigationMapState: NavigationMapState = rememberNavigationMapState(), uiState: NavigationUiState, mapOptions: MapOptions, - routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), + routeOverlayBuilder: RouteOverlayBuilder? = RouteOverlayBuilder.Default(), navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle(), + showDefaultPuck: Boolean = true, onMapLoadFinished: () -> Unit = {}, onMapLoadFailed: (String?) -> Unit = {}, onMapClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, @@ -102,7 +104,7 @@ fun NavigationMapView( MaplibreMap( modifier = Modifier.fillMaxSize(), - baseStyle = BaseStyle.Uri(styleUrl), + baseStyle = baseStyle, cameraState = cameraState, onMapClick = { position, screenPosition -> onMapClick(position.toGeographicCoordinate(), screenPosition).toComposeClickResult() @@ -119,45 +121,47 @@ fun NavigationMapView( }, options = mapOptions, ) { - routeOverlayBuilder.navigationPath(uiState) + routeOverlayBuilder?.navigationPath(uiState) - if (shouldRenderNavigationPuck(uiState) && displayedNavigationLocation != null) { - NavigationPuckOverlay( - target = - NavigationPuckTarget( - longitude = displayedNavigationLocation.position.longitude, - latitude = displayedNavigationLocation.position.latitude, - bearingDegrees = - navigationPuckBearingDegrees( - currentBearing = displayedNavigationLocation.bearing, - lastKnownBearing = lastKnownNavigationPuckBearing, - ), - ), - style = locationPuckStyle, - ) - } else { - LocationPuck( - idPrefix = "ferrostar-location", - locationState = userLocationState, - cameraState = cameraState, - colors = - LocationPuckColors( - dotFillColorCurrentLocation = locationPuckStyle.dotFillColorCurrentLocation, - dotFillColorOldLocation = locationPuckStyle.dotFillColorOldLocation, - dotStrokeColor = locationPuckStyle.dotStrokeColor, - shadowColor = locationPuckStyle.shadowColor, - accuracyStrokeColor = locationPuckStyle.accuracyStrokeColor, - accuracyFillColor = locationPuckStyle.accuracyFillColor, - bearingColor = locationPuckStyle.bearingColor, - ), - sizes = - LocationPuckSizes( - dotRadius = locationPuckStyle.dotRadius, - dotStrokeWidth = locationPuckStyle.dotStrokeWidth, - ), - showBearing = locationPuckStyle.showBearing, - showBearingAccuracy = locationPuckStyle.showBearingAccuracy, - ) + if (showDefaultPuck) { + if (shouldRenderNavigationPuck(uiState) && displayedNavigationLocation != null) { + NavigationPuckOverlay( + target = + NavigationPuckTarget( + longitude = displayedNavigationLocation.position.longitude, + latitude = displayedNavigationLocation.position.latitude, + bearingDegrees = + navigationPuckBearingDegrees( + currentBearing = displayedNavigationLocation.bearing, + lastKnownBearing = lastKnownNavigationPuckBearing, + ), + ), + style = locationPuckStyle, + ) + } else { + LocationPuck( + idPrefix = "ferrostar-location", + locationState = userLocationState, + cameraState = cameraState, + colors = + LocationPuckColors( + dotFillColorCurrentLocation = locationPuckStyle.dotFillColorCurrentLocation, + dotFillColorOldLocation = locationPuckStyle.dotFillColorOldLocation, + dotStrokeColor = locationPuckStyle.dotStrokeColor, + shadowColor = locationPuckStyle.shadowColor, + accuracyStrokeColor = locationPuckStyle.accuracyStrokeColor, + accuracyFillColor = locationPuckStyle.accuracyFillColor, + bearingColor = locationPuckStyle.bearingColor, + ), + sizes = + LocationPuckSizes( + dotRadius = locationPuckStyle.dotRadius, + dotStrokeWidth = locationPuckStyle.dotStrokeWidth, + ), + showBearing = locationPuckStyle.showBearing, + showBearingAccuracy = locationPuckStyle.showBearingAccuracy, + ) + } } if (content != null) { diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt index 8d7d228d6..3efca57ca 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt @@ -22,7 +22,7 @@ import org.maplibre.compose.camera.rememberCameraState @Stable class NavigationMapState internal constructor( - internal val cameraState: CameraState, + val cameraState: CameraState, initialCameraMode: NavigationCameraMode, navigationCameraOptions: NavigationCameraOptions, private val coroutineScope: CoroutineScope, diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt index 6e553e5d3..3d1684b41 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt @@ -39,6 +39,7 @@ import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions import com.stadiamaps.ferrostar.maplibreui.runtime.rememberMapOptionsForProgressViewHeight import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState import org.maplibre.compose.util.MaplibreComposable +import org.maplibre.compose.style.BaseStyle /** * A dynamically orienting navigation view that switches between portrait and landscape overlays @@ -47,7 +48,7 @@ import org.maplibre.compose.util.MaplibreComposable @Composable fun DynamicallyOrientingNavigationView( modifier: Modifier, - styleUrl: String, + baseStyle: BaseStyle, navigationMapState: NavigationMapState = rememberNavigationMapState(), navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, @@ -56,7 +57,8 @@ fun DynamicallyOrientingNavigationView( config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), views: NavigationViewComponentBuilder = NavigationViewComponentBuilder.Default(theme), mapViewInsets: MutableState = remember { mutableStateOf(PaddingValues(0.dp)) }, - routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), + routeOverlayBuilder: RouteOverlayBuilder? = RouteOverlayBuilder.Default(), + showDefaultPuck: Boolean = true, onTapExit: (() -> Unit)? = null, onMapClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, onMapLongClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, @@ -73,13 +75,14 @@ fun DynamicallyOrientingNavigationView( Box(modifier) { NavigationMapView( - styleUrl = styleUrl, + baseStyle = baseStyle, navigationMapState = navigationMapState, uiState = uiState, mapOptions = mapOptions, navigationCameraOptions = navigationCameraOptions, routeOverlayBuilder = routeOverlayBuilder, locationPuckStyle = locationPuckStyle, + showDefaultPuck = showDefaultPuck, onMapClick = onMapClick, onMapLongClick = onMapLongClick, content = mapContent, diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt index b8a5a542f..8cd6064a3 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt @@ -41,11 +41,12 @@ import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import org.maplibre.compose.util.MaplibreComposable +import org.maplibre.compose.style.BaseStyle @Composable fun LandscapeNavigationView( modifier: Modifier, - styleUrl: String, + baseStyle: BaseStyle, navigationMapState: NavigationMapState = rememberNavigationMapState(), navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, @@ -54,7 +55,8 @@ fun LandscapeNavigationView( config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), views: NavigationViewComponentBuilder = NavigationViewComponentBuilder.Default(theme), mapViewInsets: MutableState = remember { mutableStateOf(PaddingValues(0.dp)) }, - routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), + routeOverlayBuilder: RouteOverlayBuilder? = RouteOverlayBuilder.Default(), + showDefaultPuck: Boolean = true, onTapExit: (() -> Unit)? = null, onMapClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, onMapLongClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, @@ -66,12 +68,13 @@ fun LandscapeNavigationView( Box(modifier) { NavigationMapView( - styleUrl = styleUrl, + baseStyle = baseStyle, navigationMapState = navigationMapState, uiState = uiState, mapOptions = mapOptions, navigationCameraOptions = navigationCameraOptions, routeOverlayBuilder = routeOverlayBuilder, + showDefaultPuck = showDefaultPuck, locationPuckStyle = locationPuckStyle, onMapClick = onMapClick, onMapLongClick = onMapLongClick, @@ -114,7 +117,7 @@ val previewViewModel = private fun LandscapeNavigationViewPreview() { LandscapeNavigationView( modifier = Modifier.fillMaxSize(), - styleUrl = "https://demotiles.maplibre.org/style.json", + baseStyle = BaseStyle.Uri("https://demotiles.maplibre.org/style.json"), viewModel = previewViewModel, ) } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt index 03c4e38ec..a583fd83b 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt @@ -38,13 +38,14 @@ import com.stadiamaps.ferrostar.maplibreui.runtime.navigationCameraOptions import com.stadiamaps.ferrostar.maplibreui.runtime.rememberMapOptionsForProgressViewHeight import com.stadiamaps.ferrostar.maplibreui.runtime.rememberNavigationMapState import org.maplibre.compose.util.MaplibreComposable +import org.maplibre.compose.style.BaseStyle /** * A portrait orientation of the navigation view with instructions, default controls and the * navigation map view. * * @param modifier The modifier to apply to the view. - * @param styleUrl The MapLibre style URL to use for the map. + * @param baseStyle The MapLibre style to use for the map. * @param navigationMapState The Ferrostar-owned map state used to coordinate follow, overview, * free-camera behavior, and zoom actions. * @param navigationCameraOptions The camera templates applied when following the user in browsing @@ -67,7 +68,7 @@ import org.maplibre.compose.util.MaplibreComposable @Composable fun PortraitNavigationView( modifier: Modifier, - styleUrl: String, + baseStyle: BaseStyle, navigationMapState: NavigationMapState = rememberNavigationMapState(), navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, @@ -76,7 +77,8 @@ fun PortraitNavigationView( config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), views: NavigationViewComponentBuilder = NavigationViewComponentBuilder.Default(theme), mapViewInsets: MutableState = remember { mutableStateOf(PaddingValues(0.dp)) }, - routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), + routeOverlayBuilder: RouteOverlayBuilder? = RouteOverlayBuilder.Default(), + showDefaultPuck: Boolean = true, onTapExit: (() -> Unit)? = null, onMapClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, onMapLongClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, @@ -88,13 +90,14 @@ fun PortraitNavigationView( Box(modifier) { NavigationMapView( - styleUrl = styleUrl, + baseStyle = baseStyle, navigationMapState = navigationMapState, uiState = uiState, mapOptions = mapOptions, navigationCameraOptions = navigationCameraOptions, routeOverlayBuilder = routeOverlayBuilder, locationPuckStyle = locationPuckStyle, + showDefaultPuck = showDefaultPuck, onMapClick = onMapClick, onMapLongClick = onMapLongClick, content = mapContent, @@ -133,7 +136,7 @@ fun PortraitNavigationView( private fun PortraitNavigationViewPreview() { PortraitNavigationView( modifier = Modifier.fillMaxSize(), - styleUrl = "https://demotiles.maplibre.org/style.json", + baseStyle = BaseStyle.Uri("https://demotiles.maplibre.org/style.json"), viewModel = previewViewModel, ) } diff --git a/guide/src/jetpack-compose-customization.md b/guide/src/jetpack-compose-customization.md index d89f9dc46..31199a10b 100644 --- a/guide/src/jetpack-compose-customization.md +++ b/guide/src/jetpack-compose-customization.md @@ -1,6 +1,6 @@ # UI customization with Jetpack Compose -The tutorial get you set up with defaults using a “batteries included” UI, +The tutorial gets you set up with defaults using a “batteries included” UI, but realistically this doesn’t work for every use case. This page walks you through the ways to customize the Compose UI to your liking. @@ -18,91 +18,94 @@ the map view itself is actually not that complex. ### Style The demo app uses the MapLibre demo tiles, but you’ll need a proper basemap eventually. -Just pass in the URL of any MapLibre-compatible JSON style. +Wrap the URL in `BaseStyle.Uri(...)`, or pass `BaseStyle.Json(...)` if you generate +style JSON in memory. See the [vendors page](./vendors.md) for some ideas. +```kotlin + val navigationMapState = rememberNavigationMapState() + + NavigationMapView( + baseStyle = BaseStyle.Uri(styleUrl), + navigationMapState = navigationMapState, + uiState = uiState, + mapOptions = MapOptions(), + ) +``` + +If you already manage styles yourself, `BaseStyle.Json(styleJson)` works as well. + ### Camera Ferrostar's Jetpack Compose views provide several forms of camera configuration. -`DynamicallyOrientingNavigationView` and other built-in composable layouts have two camera parameters: -`camera` and `navigationCamera`. - -`camera` contains the mutable state of the map camera. -It is bidirectional, so you can mutate this state on your own to set the camera from your app code -(e.g., your view model may respond to a list selection by changing the map viewport). -This always reflects the current state of the camera. -You typically create instances of this camera with the `rememberSaveableMapViewCamera` helper function. - -The `navigationCamera` parameter controls the camera to use during active navigation. -This is a _template value_, not a binding! -When you start a navigation session, or need to reset the camera (e.g, when the user presses a re-center button -after manually panning the camera or looking at the route overview), -the `camera` will be internally reset to the value of `navigationCamera`. - -`navigationMapViewCamera` provides a default value, but you can also manually create your own instance of `MapViewCamera` -for maximal control. -The default is to keep the location puck toward the bottom of the view, -but the following code shows how you can change the top padding -to bring the puck "up" closer to the center of the screen. +`NavigationMapState` owns the mutable camera state for `NavigationMapView` and the built-in +phone/tablet wrapper views. It supports Ferrostar's follow/recenter/overview helpers, and its +public `cameraState` gives you direct access to MapLibre Compose camera APIs for custom animation +and projection queries. ```kotlin - val camera = rememberSaveableMapViewCamera(MapViewCamera.TrackingUserLocation()) - val screenOrientation = LocalConfiguration.current.orientation - val start = if (screenOrientation == Configuration.ORIENTATION_LANDSCAPE) 0.5f else 0.0f - - val cameraPadding = CameraPadding.fractionOfScreen(start = start, top = 0.25f) - - val navigationCamera = MapViewCamera.TrackingUserLocationWithBearing( - zoom = NavigationActivity.Automotive.zoom, - pitch = NavigationActivity.Automotive.pitch, - padding = cameraPadding) + val navigationMapState = rememberNavigationMapState() DynamicallyOrientingNavigationView( modifier = Modifier.fillMaxSize(), - styleUrl = AppModule.mapStyleUrl, - camera = camera, - navigationCamera = navigationCamera, + baseStyle = BaseStyle.Uri(AppModule.mapStyleUrl), + navigationMapState = navigationMapState, // ... ``` +```kotlin + navigationMapState.zoomIn() + navigationMapState.recenter(isNavigating = true) + navigationMapState.showRouteOverview(boundingBox, paddingValues = mapInsets) + + // For app-specific camera control, switch to FREE mode first so tracking + // does not immediately overwrite your custom camera animation. + navigationMapState.cameraMode = NavigationCameraMode.FREE + navigationMapState.cameraState.animateTo(finalPosition = customPosition) + + navigationMapState.cameraMode = NavigationCameraMode.FREE + navigationMapState.cameraState.animateTo( + boundingBox = bounds, + padding = mapInsets, + ) +``` + ### Adding map layers -You can add your own overlays to the map as well (any class, including `DynamicallyOrientingNavigationView`)! -The `content` closure argument lets you add more layers. +You can add your own overlays to the map on any Ferrostar MapLibre composable view. +The `content` closure runs inside the underlying `MaplibreMap`, so you can use the +normal MapLibre Compose source and layer APIs there. ```kotlin + val pinJson = """ + {"type":"FeatureCollection","features":[ + {"type":"Feature","geometry":{"type":"Point","coordinates":[16.3738,48.2082]},"properties":{}} + ]} + """.trimIndent() + DynamicallyOrientingNavigationView( modifier = Modifier.fillMaxSize(), - styleUrl = AppModule.mapStyleUrl, + baseStyle = BaseStyle.Uri(AppModule.mapStyleUrl), // Other arguments elided... ) { uiState -> - // Trivial, if silly example of how to add your own overlay layers. - uiState.location?.let { location -> - // Add a little blue dot where the user is now - Circle( - center = LatLng(location.coordinates.lat, location.coordinates.lng), - radius = 10f, - color = "Blue", - zIndex = 3, - ) - - // If the reported GPS accuracy is worse than 15m, - // show a large blue translucent circle (this is an example; not to scale). - if (location.horizontalAccuracy > 15) { - Circle( - center = LatLng(location.coordinates.lat, location.coordinates.lng), - radius = min(location.horizontalAccuracy.toFloat(), 150f), - color = "Blue", - opacity = 0.2f, - zIndex = 2, - ) - } - } + val pointSource = rememberGeoJsonSource(GeoJsonData.JsonString(pinJson)) + + CircleLayer( + id = "custom-pin", + source = pointSource, + color = const(Color.Green), + radius = const(12.dp), + strokeColor = const(Color.White), + strokeWidth = const(3.dp), + ) } ``` -The map drawing features are provided by [this library](https://github.com/Rallista/maplibre-compose-playground/), -which also includes polygines, lines, and other drawing primitives. +If you need complete control over route lines, selection layers, pins, or a custom puck, +you can combine `content` with: + +- `routeOverlayBuilder = null` +- `showDefaultPuck = false` ### Styling the route polyline @@ -113,20 +116,53 @@ Here's an example with `DynamicallyOrientingNavigationView`: ```kotlin DynamicallyOrientingNavigationView( modifier = Modifier.fillMaxSize(), - styleUrl = AppModule.mapStyleUrl, - camera = camera, + baseStyle = BaseStyle.Uri(AppModule.mapStyleUrl), viewModel = viewModel, routeOverlayBuilder = RouteOverlayBuilder( navigationPath = { uiState -> uiState.routeGeometry?.let { geometry -> - // BorderedPolyline is part of Ferrostar's MapLibre UI package. - // You can also drop down to the raw Polyline and build your own custom style. - BorderedPolyline(points = geometry.map { LatLng(it.lat, it.lng) }, zIndex = 0, color = "#3583dd", opacity = 0.7f, borderOpacity = 0.3f) + // BorderedPolyline is part of Ferrostar's MapLibre UI package. + // You can also drop down to raw layers and build your own custom style. + BorderedPolyline( + points = geometry, + color = Color(0xFF3583DD), + opacity = 0.7f, + borderOpacity = 0.3f, + ) } }), // ... ``` +To disable Ferrostar's default route rendering entirely and draw your own route in `content`: + +```kotlin + NavigationMapView( + baseStyle = BaseStyle.Uri(styleUrl), + navigationMapState = navigationMapState, + uiState = uiState, + mapOptions = MapOptions(), + routeOverlayBuilder = null, + ) { uiState -> + // Draw your own route layers here. + } +``` + +### Customizing or disabling the puck + +The built-in puck can be disabled if you want to render your own location indicator in `content`: + +```kotlin + NavigationMapView( + baseStyle = BaseStyle.Uri(styleUrl), + uiState = uiState, + mapOptions = MapOptions(), + showDefaultPuck = false, + ) { uiState -> + // Draw your own puck, route, selection, or other app-specific layers here. + } +``` + ## Configuring visual elements of the composable map views You can configure which controls appear in our MapLibre composable views with the `config` parameter. @@ -135,8 +171,7 @@ Here's an example with `DynamicallyOrientingNavigationView`: ```kotlin DynamicallyOrientingNavigationView( modifier = Modifier.fillMaxSize(), - styleUrl = AppModule.mapStyleUrl, - camera = camera, + baseStyle = BaseStyle.Uri(AppModule.mapStyleUrl), viewModel = viewModel, config = VisualNavigationViewConfig(showMute = true, showZoom = false, showRecenter = true, speedLimitStyle = SignageStyle.MUTCD), // ... From 5adb65385c760ba158dddfd6d1ef452dbb0431f4 Mon Sep 17 00:00:00 2001 From: Klemens Zleptnig Date: Wed, 1 Apr 2026 13:25:28 +0200 Subject: [PATCH 14/14] Refactor navigation camera animation and tracking logic in Android UI-MapLibre (Compose) --- .../maplibreui/runtime/NavigationMapState.kt | 111 +++++++++++++++--- .../runtime/TrackingCameraEffect.kt | 10 +- .../runtime/NavigationMapStateTest.kt | 42 ++++++- 3 files changed, 141 insertions(+), 22 deletions(-) diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt index 3efca57ca..69e029ddb 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt @@ -1,6 +1,9 @@ package com.stadiamaps.ferrostar.maplibreui.runtime import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -17,8 +20,13 @@ import kotlinx.coroutines.launch import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.location.Location import org.maplibre.compose.camera.rememberCameraState +internal val DEFAULT_TRACKING_CAMERA_TRANSITION_DURATION = 1000.milliseconds +internal val DEFAULT_ROUTE_OVERVIEW_ANIMATION_DURATION = 1000.milliseconds +internal val DEFAULT_ZOOM_ANIMATION_DURATION = 300.milliseconds + @Stable class NavigationMapState internal constructor( @@ -27,7 +35,8 @@ internal constructor( navigationCameraOptions: NavigationCameraOptions, private val coroutineScope: CoroutineScope, ) { - private var routeOverviewJob: Job? = null + private var cameraAnimationJob: Job? = null + internal var suppressTrackingUpdates: Boolean = false var cameraMode by mutableStateOf(initialCameraMode) @@ -40,12 +49,18 @@ internal constructor( cameraMode = defaultNavigationCameraMode(isNavigating) } - fun zoomIn(delta: Double = 1.0) { - cameraState.incrementZoom(delta) + fun zoomIn( + delta: Double = 1.0, + duration: Duration = DEFAULT_ZOOM_ANIMATION_DURATION, + ) { + animateZoomBy(delta = delta, duration = duration) } - fun zoomOut(delta: Double = 1.0) { - cameraState.incrementZoom(-delta) + fun zoomOut( + delta: Double = 1.0, + duration: Duration = DEFAULT_ZOOM_ANIMATION_DURATION, + ) { + animateZoomBy(delta = -delta, duration = duration) } fun panBy(screenDistance: DpOffset) { @@ -67,7 +82,7 @@ internal constructor( projection.screenLocationFromPosition(currentPosition.target) + screenDistance) cameraMode = NavigationCameraMode.FREE - coroutineScope.launch { + launchCameraAnimation { cameraState.animateTo( finalPosition = currentPosition.copy(target = translatedTarget), duration = duration, @@ -87,17 +102,85 @@ internal constructor( fun showRouteOverview( boundingBox: BoundingBox, paddingValues: PaddingValues = PaddingValues(), - duration: Duration = 0.milliseconds, + duration: Duration = DEFAULT_ROUTE_OVERVIEW_ANIMATION_DURATION, ) { cameraMode = NavigationCameraMode.OVERVIEW - routeOverviewJob?.cancel() - routeOverviewJob = + launchCameraAnimation { + cameraState.animateTo( + boundingBox = boundingBox.toMapLibreBoundingBox(), + padding = paddingValues, + duration = normalizeOverviewAnimationDuration(duration), + ) + } + } + + internal fun animateTrackingCameraToUserLocation( + userLocation: Location, + duration: Duration = DEFAULT_TRACKING_CAMERA_TRANSITION_DURATION, + ) { + launchCameraAnimation(suppressTrackingUpdates = true) { + cameraState.animateTo( + finalPosition = + templateFollowingCameraPosition( + target = userLocation.position, + bearing = userLocation.bearing, + ), + duration = duration, + ) + } + } + + private fun animateZoomBy( + delta: Double, + duration: Duration, + ) { + if (delta == 0.0) return + + val targetZoom = (cameraState.position.zoom + delta).coerceAtLeast(0.0) + if (isTrackingUser) { + animateTrackingZoomTo(targetZoom = targetZoom, duration = duration) + } else { + launchCameraAnimation { + cameraState.animateTo( + finalPosition = cameraState.position.copy(zoom = targetZoom), + duration = duration, + ) + } + } + } + + private fun animateTrackingZoomTo( + targetZoom: Double, + duration: Duration, + ) { + launchCameraAnimation { + val animatedZoom = Animatable(cameraState.position.zoom.toFloat()) + animatedZoom.animateTo( + targetValue = targetZoom.toFloat(), + animationSpec = + tween( + durationMillis = duration.inWholeMilliseconds.toInt(), + easing = LinearEasing, + ), + ) { + cameraState.position = cameraState.position.copy(zoom = value.coerceAtLeast(0f).toDouble()) + } + } + } + + private fun launchCameraAnimation( + suppressTrackingUpdates: Boolean = false, + block: suspend () -> Unit, + ) { + cameraAnimationJob?.cancel() + this.suppressTrackingUpdates = suppressTrackingUpdates + cameraAnimationJob = coroutineScope.launch { - cameraState.animateTo( - boundingBox = boundingBox.toMapLibreBoundingBox(), - padding = paddingValues, - duration = normalizeOverviewAnimationDuration(duration), - ) + try { + block() + } finally { + this@NavigationMapState.suppressTrackingUpdates = false + } } } } diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/TrackingCameraEffect.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/TrackingCameraEffect.kt index 1b26f9188..59427c1f3 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/TrackingCameraEffect.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/TrackingCameraEffect.kt @@ -31,7 +31,13 @@ internal fun TrackingCameraEffect( if (userLocation != null && navigationMapState.isTrackingUser) { val shouldSnap = !state.hadLocation || state.lastMode != navigationMapState.cameraMode - if (shouldSnap) { + if (navigationMapState.suppressTrackingUpdates) { + // Keep the animated transition in control until it completes. + } else if (!state.hadLocation) { + navigationMapState.snapTrackingCameraToUserLocation(userLocation) + } else if (state.lastMode?.tracksLocation() == false && shouldSnap) { + navigationMapState.animateTrackingCameraToUserLocation(userLocation) + } else if (shouldSnap) { navigationMapState.snapTrackingCameraToUserLocation(userLocation) } else { cameraState.position = @@ -43,7 +49,7 @@ internal fun TrackingCameraEffect( state.hadLocation = true state.lastMode = navigationMapState.cameraMode } else { - state.hadLocation = false + state.hadLocation = userLocation != null state.lastMode = navigationMapState.cameraMode } } diff --git a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt index fc9a936d3..aeae08180 100644 --- a/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt @@ -47,14 +47,25 @@ class NavigationMapStateTest { } @Test - fun zoomHelpersAdjustCameraZoom() { - val cameraState = CameraState(CameraPosition(zoom = 10.0)) - val state = createState(cameraState = cameraState) + fun zoomHelpersAnimateCameraZoom() { + val cameraState = mockCameraState(initialPosition = CameraPosition(zoom = 10.0)) + val state = createState(cameraState = cameraState, initialCameraMode = NavigationCameraMode.FREE) state.zoomIn() state.zoomOut(delta = 2.0) - assertEquals(9.0, cameraState.position.zoom, 0.0) + coVerify { + cameraState.animateTo( + finalPosition = match { it.zoom == 11.0 }, + duration = DEFAULT_ZOOM_ANIMATION_DURATION, + ) + } + coVerify { + cameraState.animateTo( + finalPosition = match { it.zoom == 9.0 }, + duration = DEFAULT_ZOOM_ANIMATION_DURATION, + ) + } } @Test @@ -203,7 +214,8 @@ class NavigationMapStateTest { @Test fun routeOverviewSwitchesToOverviewMode() { - val state = createState(initialCameraMode = NavigationCameraMode.FOLLOW_USER) + val cameraState = mockCameraState(initialPosition = CameraPosition()) + val state = createState(cameraState = cameraState, initialCameraMode = NavigationCameraMode.FOLLOW_USER) state.showRouteOverview( boundingBox = BoundingBox(north = 48.5, east = 16.7, south = 48.1, west = 16.2), @@ -212,6 +224,15 @@ class NavigationMapStateTest { assertEquals(NavigationCameraMode.OVERVIEW, state.cameraMode) assertFalse(state.isTrackingUser) + coVerify { + cameraState.animateTo( + boundingBox = any(), + bearing = 0.0, + tilt = 0.0, + padding = PaddingValues(), + duration = DEFAULT_ROUTE_OVERVIEW_ANIMATION_DURATION, + ) + } } @Test @@ -240,7 +261,7 @@ class NavigationMapStateTest { private fun mockCameraState( initialPosition: CameraPosition, - projection: CameraProjection, + projection: CameraProjection? = null, ): CameraState { val cameraState = mockk(relaxed = true) var currentPosition = initialPosition @@ -251,6 +272,15 @@ class NavigationMapStateTest { coEvery { cameraState.animateTo(any(), any()) } answers { currentPosition = firstArg() } + coEvery { + cameraState.animateTo( + any(), + any(), + any(), + any(), + any(), + ) + } returns Unit return cameraState }