diff --git a/android/README.md b/android/README.md index 00230d9f9..abc593d05 100644 --- a/android/README.md +++ b/android/README.md @@ -37,3 +37,74 @@ 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: + +* `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 +* `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. +* `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: + +```kotlin +val navigationMapState = rememberNavigationMapState() + +DynamicallyOrientingNavigationView( + modifier = Modifier.fillMaxSize(), + 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: + +* 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/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..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 @@ -3,29 +3,34 @@ 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.style.BaseStyle +import org.maplibre.compose.util.MaplibreComposable +import uniffi.ferrostar.GeographicCoordinate @Composable fun DemoNavigationScene( @@ -76,46 +81,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, + baseStyle = BaseStyle.Uri(AppModule.mapStyleUrl), 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.Green), + radius = const(12.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..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 @@ -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 @@ -23,14 +17,12 @@ 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 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 @@ -52,6 +44,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 = @@ -66,17 +61,18 @@ class DemoNavigationViewModel( .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(), - initialValue = NavigationUiState.empty()) + initialValue = NavigationUiState.empty() + ) init { viewModelScope.launch { _hasLocationPermission .flatMapLatest { hasPermission -> - if (hasPermission) { + if (!hasPermission) { + flowOf(initialSimulatedLocation) + } else { locationProvider.locationUpdates(5000L) .map { it.toUserLocation() } - } else { - flowOf(initialSimulatedLocation) } } .collect { @@ -91,27 +87,17 @@ class DemoNavigationViewModel( fun toggleSimulation() { _simulated.value = !_simulated.value + if (!_simulated.value) { + locationProvider.disableSimulation() + } } fun enableAutoDriveSimulation() { _simulated.value = true } - init { - viewModelScope.launch { - _hasLocationPermission - .flatMapLatest { hasPermission -> - if (hasPermission) { - locationProvider.locationUpdates(5000L) - .map { it.toUserLocation() } - } else { - flowOf(initialSimulatedLocation) - } - } - .collect { - locationStateFlow.emit(it) - } - } + fun setDroppedPin(coordinate: GeographicCoordinate) { + _droppedPin.value = coordinate } override fun toggleMute() { @@ -156,56 +142,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..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,38 +1,42 @@ 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 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 +76,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 +102,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 +146,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() @@ -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() } @@ -170,4 +172,22 @@ 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() + } + + companion object { + private const val TAG = "DemoNavigationScreen" + } } 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..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 @@ -2,55 +2,96 @@ 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 kotlin.math.min +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationCameraOptions +import com.stadiamaps.ferrostar.maplibreui.runtime.NavigationMapState +import kotlinx.serialization.json.buildJsonObject +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 +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( 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, - ) - - 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, - ) - } - } + DemoRouteEndpointsOverlay(uiState) } } + +@Composable +@MaplibreComposable +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-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), + ) + + 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 {}, + ) + ) 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 9b20c4ebc..73411b5fc 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.7.0" +maplibre-compose = "0.12.1" +rallista-maplibre-compose = "1.7.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..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 @@ -4,21 +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.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,9 +19,15 @@ 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.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.style.BaseStyle +import org.maplibre.compose.util.MaplibreComposable /** * A navigation view designed for Android Auto car displays. @@ -42,24 +39,16 @@ import com.stadiamaps.ferrostar.maplibreui.runtime.navigationMapViewCamera fun CarAppNavigationView( modifier: Modifier, styleUrl: String, - camera: MutableState = rememberSaveableMapViewCamera(), - navigationCamera: MapViewCamera = navigationMapViewCamera(), + navigationMapState: NavigationMapState = rememberNavigationMapState(), + navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, - locationRequestProperties: LocationRequestProperties = - LocationRequestProperties.NavigationDefault(), config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), surfaceAreaTracker: SurfaceAreaTracker? = null, - mapContent: @Composable @MapLibreComposable() ((NavigationUiState) -> Unit)? = null, + mapContent: @Composable @MaplibreComposable ((NavigationUiState) -> Unit)? = null, ) { val uiState by viewModel.navigationUiState.collectAsState() - val mapControls = remember { - mutableStateOf( - MapControls( - attribution = AttributionSettings(enabled = false), - compass = CompassSettings(enabled = false), - logo = LogoSettings(enabled = false))) - } + surfaceAreaTracker?.rememberGestureDelegate(navigationMapState) val surfaceArea by surfaceAreaTracker ?.let { screenSurfaceState(it) } @@ -67,27 +56,15 @@ 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, + baseStyle = BaseStyle.Uri(styleUrl), + navigationMapState = navigationMapState, uiState = uiState, - mapControls = mapControls, - locationRequestProperties = locationRequestProperties, + mapOptions = MapOptions(ornamentOptions = OrnamentOptions.AllDisabled), + navigationCameraOptions = navigationCameraOptions, routeOverlayBuilder = routeOverlayBuilder, - onMapReadyCallback = { - // No definition - }, - content = wrappedContent + content = mapContent, ) Box( 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-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/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/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..ba1ec8320 --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapPuckStyle.kt @@ -0,0 +1,21 @@ +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 = 7.dp, + val dotStrokeWidth: Dp = 3.dp, + val showBearing: Boolean = true, + val showBearingAccuracy: Boolean = false, +) 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..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 @@ -2,85 +2,179 @@ 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 org.maplibre.android.maps.Style +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.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.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.spatialk.geojson.Position +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 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. - * @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 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 + * 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(), + baseStyle: BaseStyle, + navigationMapState: NavigationMapState = rememberNavigationMapState(), uiState: NavigationUiState, - mapControls: State, - locationRequestProperties: LocationRequestProperties = - LocationRequestProperties.NavigationDefault(), - routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), - onMapReadyCallback: ((Style) -> Unit)? = null, - content: @Composable @MapLibreComposable ((NavigationUiState) -> Unit)? = null + mapOptions: MapOptions, + routeOverlayBuilder: RouteOverlayBuilder? = RouteOverlayBuilder.Default(), + navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle(), + showDefaultPuck: Boolean = true, + onMapLoadFinished: () -> Unit = {}, + onMapLoadFailed: (String?) -> Unit = {}, + onMapClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, + onMapLongClick: NavigationMapClickHandler = { _, _ -> NavigationMapClickResult.Pass }, + content: @Composable @MaplibreComposable ((NavigationUiState) -> Unit)? = null, ) { + val cameraState = navigationMapState.cameraState + val userLocationState = rememberFerrostarLocationState(uiState.location) + val displayedNavigationLocation = rememberDisplayedNavigationLocation(uiState) + var lastKnownNavigationPuckBearing by remember { mutableStateOf(0.0) } + navigationMapState.navigationCameraOptions = navigationCameraOptions + var isNavigating by remember { mutableStateOf(uiState.isNavigating()) } if (uiState.isNavigating() != isNavigating) { isNavigating = uiState.isNavigating() + navigationMapState.cameraMode = defaultNavigationCameraMode(isNavigating) + } - if (isNavigating) { - camera.value = navigationCamera - } + LaunchedEffect(displayedNavigationLocation?.bearing) { + displayedNavigationLocation?.bearing?.let { lastKnownNavigationPuckBearing = it } } - val locationEngine = remember { StaticLocationEngine() } - locationEngine.lastLocation = uiState.location?.toAndroidLocation() + TrackingCameraEffect( + navigationMapState = navigationMapState, + userLocation = displayedNavigationLocation, + ) + + 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, + cameraState = cameraState, + onMapClick = { position, screenPosition -> + onMapClick(position.toGeographicCoordinate(), screenPosition).toComposeClickResult() + }, + onMapLongClick = { position, screenPosition -> + onMapLongClick(position.toGeographicCoordinate(), screenPosition).toComposeClickResult() + }, + onMapLoadFailed = onMapLoadFailed, + onMapLoadFinished = { + if (displayedNavigationLocation != null && navigationMapState.isTrackingUser) { + navigationMapState.snapTrackingCameraToUserLocation(displayedNavigationLocation) + } + onMapLoadFinished() + }, + options = mapOptions, ) { - routeOverlayBuilder.navigationPath(uiState) + routeOverlayBuilder?.navigationPath(uiState) + + 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) { content(uiState) } } } + +private fun Position.toGeographicCoordinate(): GeographicCoordinate = + GeographicCoordinate(lat = latitude, lng = longitude) + +private fun NavigationMapClickResult.toComposeClickResult(): ClickResult = + when (this) { + NavigationMapClickResult.Pass -> ClickResult.Pass + NavigationMapClickResult.Consume -> ClickResult.Consume + } 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..e1d353279 --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationPuckOverlay.kt @@ -0,0 +1,153 @@ +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.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 +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.IconPitchAlignment +import org.maplibre.compose.expressions.value.IconRotationAlignment +import org.maplibre.compose.expressions.value.SymbolAnchor +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) + }, + ) + ) + +internal data class NavigationPuckTarget( + val longitude: Double, + val latitude: Double, + val bearingDegrees: Double, +) + +@Composable +@MaplibreComposable +internal fun NavigationPuckOverlay( + target: NavigationPuckTarget, + style: NavigationMapPuckStyle, +) { + val source = + rememberGeoJsonSource( + GeoJsonData.Features( + navigationPuckFeatureCollection( + longitude = target.longitude, + latitude = target.latitude, + bearingDegrees = target.bearingDegrees, + ), + ), + ) + val puckPainter = rememberNavigationPuckPainter(style.dotFillColorCurrentLocation) + val puckSize = 80.dp + + SymbolLayer( + id = "ferrostar-navigation-puck", + source = source, + iconImage = + image( + value = puckPainter, + size = DpSize( + width = puckSize, + height = puckSize, + ), + 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 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, + ) + 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() + } +} 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/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/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/NavigationCamera.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt index b5948e3d6..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 @@ -1,39 +1,133 @@ 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 - val cameraPadding = CameraPadding.fractionOfScreen(start = start, top = 0.5f) + 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 + } + +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)) } + +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/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..69e029ddb --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapState.kt @@ -0,0 +1,218 @@ +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 +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 +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( + val cameraState: CameraState, + initialCameraMode: NavigationCameraMode, + navigationCameraOptions: NavigationCameraOptions, + private val coroutineScope: CoroutineScope, +) { + private var cameraAnimationJob: Job? = null + internal var suppressTrackingUpdates: Boolean = false + + 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, + duration: Duration = DEFAULT_ZOOM_ANIMATION_DURATION, + ) { + animateZoomBy(delta = delta, duration = duration) + } + + fun zoomOut( + delta: Double = 1.0, + duration: Duration = DEFAULT_ZOOM_ANIMATION_DURATION, + ) { + animateZoomBy(delta = -delta, duration = duration) + } + + 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 + launchCameraAnimation { + 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(), + duration: Duration = DEFAULT_ROUTE_OVERVIEW_ANIMATION_DURATION, + ) { + cameraMode = NavigationCameraMode.OVERVIEW + 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 { + try { + block() + } finally { + this@NavigationMapState.suppressTrackingUpdates = false + } + } + } +} + +// 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), + 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/runtime/TrackingCameraEffect.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/TrackingCameraEffect.kt new file mode 100644 index 000000000..59427c1f3 --- /dev/null +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/TrackingCameraEffect.kt @@ -0,0 +1,63 @@ +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 (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 = + navigationMapState.trackingFollowingCameraPosition( + target = userLocation.position, + bearing = userLocation.bearing, + ) + } + state.hadLocation = true + state.lastMode = navigationMapState.cameraMode + } else { + state.hadLocation = userLocation != null + state.lastMode = navigationMapState.cameraMode + } +} + +internal fun NavigationMapState.snapTrackingCameraToUserLocation(userLocation: Location) { + cameraState.position = + templateFollowingCameraPosition( + target = userLocation.position, + bearing = userLocation.bearing, + ) +} 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..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 @@ -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,66 @@ 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 +import org.maplibre.compose.style.BaseStyle /** - * 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(), + baseStyle: BaseStyle, + navigationMapState: NavigationMapState = rememberNavigationMapState(), + navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, - locationRequestProperties: LocationRequestProperties = - LocationRequestProperties.NavigationDefault(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle(), theme: NavigationUITheme = DefaultNavigationUITheme, 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, - 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, + baseStyle = baseStyle, + navigationMapState = navigationMapState, uiState = uiState, - mapControls = mapControls, - locationRequestProperties = locationRequestProperties, + mapOptions = mapOptions, + navigationCameraOptions = navigationCameraOptions, routeOverlayBuilder = routeOverlayBuilder, - content = mapContent) + locationPuckStyle = locationPuckStyle, + showDefaultPuck = showDefaultPuck, + onMapClick = onMapClick, + onMapLongClick = onMapLongClick, + content = mapContent, + ) if (uiState.isNavigating()) { when (orientation) { @@ -112,18 +96,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 +117,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..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 @@ -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,78 @@ 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 +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 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(), + baseStyle: BaseStyle, + navigationMapState: NavigationMapState = rememberNavigationMapState(), + navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, - locationRequestProperties: LocationRequestProperties = - LocationRequestProperties.NavigationDefault(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle(), theme: NavigationUITheme = DefaultNavigationUITheme, 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, - 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) + baseStyle = baseStyle, + navigationMapState = navigationMapState, + uiState = uiState, + mapOptions = mapOptions, + navigationCameraOptions = navigationCameraOptions, + routeOverlayBuilder = routeOverlayBuilder, + showDefaultPuck = showDefaultPuck, + 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 +116,8 @@ val previewViewModel = @Composable private fun LandscapeNavigationViewPreview() { LandscapeNavigationView( - Modifier.fillMaxSize(), - styleUrl = "https://demotiles.maplibre.org/style.json", - viewModel = previewViewModel) + modifier = Modifier.fillMaxSize(), + 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 7d95c3f41..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 @@ -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,79 +26,82 @@ 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 +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 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 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 + * 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(), + baseStyle: BaseStyle, + navigationMapState: NavigationMapState = rememberNavigationMapState(), + navigationCameraOptions: NavigationCameraOptions = navigationCameraOptions(), viewModel: NavigationViewModel, - locationRequestProperties: LocationRequestProperties = - LocationRequestProperties.NavigationDefault(), + locationPuckStyle: NavigationMapPuckStyle = NavigationMapPuckStyle(), theme: NavigationUITheme = DefaultNavigationUITheme, 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, - 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) + baseStyle = baseStyle, + navigationMapState = navigationMapState, + uiState = uiState, + mapOptions = mapOptions, + navigationCameraOptions = navigationCameraOptions, + routeOverlayBuilder = routeOverlayBuilder, + locationPuckStyle = locationPuckStyle, + showDefaultPuck = showDefaultPuck, + onMapClick = onMapClick, + onMapLongClick = onMapLongClick, + content = mapContent, + ) if (uiState.isNavigating()) { PortraitNavigationOverlayView( @@ -113,18 +109,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 +135,8 @@ fun PortraitNavigationView( @Composable private fun PortraitNavigationViewPreview() { PortraitNavigationView( - Modifier.fillMaxSize(), - styleUrl = "https://demotiles.maplibre.org/style.json", - viewModel = previewViewModel) + modifier = Modifier.fillMaxSize(), + baseStyle = BaseStyle.Uri("https://demotiles.maplibre.org/style.json"), + 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..a3d5c8f5a --- /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() + + assertEquals(Color(0xFF3583DD), style.dotFillColorCurrentLocation) + assertEquals(Color(0xFF0F5FB8), style.bearingColor) + assertEquals(7.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/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, + ) +} 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..9e7eeaabc --- /dev/null +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCameraTest.kt @@ -0,0 +1,146 @@ +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 +import org.junit.Test +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.CameraState +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) + } + + @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 = CoroutineScope( + Job() + 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 new file mode 100644 index 000000000..aeae08180 --- /dev/null +++ b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationMapStateTest.kt @@ -0,0 +1,305 @@ +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) + + @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 zoomHelpersAnimateCameraZoom() { + val cameraState = mockCameraState(initialPosition = CameraPosition(zoom = 10.0)) + val state = createState(cameraState = cameraState, initialCameraMode = NavigationCameraMode.FREE) + + state.zoomIn() + state.zoomOut(delta = 2.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 + 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)) + 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 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), + paddingValues = PaddingValues(), + ) + + 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 + 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) + } + + @Test + fun zeroOverviewAnimationDurationIsNormalized() { + assertEquals(1.milliseconds, normalizeOverviewAnimationDuration(0.milliseconds)) + assertEquals(1.milliseconds, normalizeOverviewAnimationDuration((-50).milliseconds)) + assertEquals(300.milliseconds, normalizeOverviewAnimationDuration(300.milliseconds)) + } + + private fun mockCameraState( + initialPosition: CameraPosition, + projection: CameraProjection? = null, + ): 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() + } + coEvery { + cameraState.animateTo( + any(), + any(), + any(), + any(), + any(), + ) + } returns Unit + + return cameraState + } + + 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, + ) +} 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), // ...