diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/ColorUtils.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/ColorUtils.kt index fcfe834da547f44f1992f4b0f630371ae3f61aa2..67a6e23af0f3b11f671c858889a0b34dfd66a2ca 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/ColorUtils.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/ColorUtils.kt @@ -20,14 +20,34 @@ package earth.maps.cardinal.data import androidx.compose.ui.graphics.Color -fun Color.isYellow(): Boolean { +/** + * Desaturates a color by reducing its saturation by the specified amount. + * + * @param amount The amount to desaturate, where 0.0f means no change and 1.0f means completely grayscale. + * Values outside 0.0f..1.0f will be clamped to this range. + * @return A new Color with reduced saturation. + */ +fun Color.desaturate(amount: Float): Color { + val clampedAmount = amount.coerceIn(0f, 1f) + val r = red val g = green val b = blue val max = maxOf(r, g, b) val min = minOf(r, g, b) - if (max == min) return false // gray + + // If the color is already grayscale, no desaturation needed + if (max == min) { + return this + } + val delta = max - min + val l = (max + min) / 2f + + // Calculate saturation + val s = if (l <= 0.5f) delta / (max + min) else delta / (2f - max - min) + + // Calculate hue val h = when (max) { r -> 60 * (g - b) / delta g -> 60 * (2 + (b - r) / delta) @@ -35,6 +55,47 @@ fun Color.isYellow(): Boolean { else -> 0f } val hue = if (h < 0) h + 360f else h - return hue in 30f..75f + + // Apply desaturation + val newSaturation = s * (1f - clampedAmount) + + // Convert back to RGB + return hslToRgb(hue, newSaturation, l, alpha) } +/** + * Converts HSL color values to RGB. + * + * @param h Hue in degrees (0-360) + * @param s Saturation (0-1) + * @param l Lightness (0-1) + * @param a Alpha (0-1) + * @return Color in RGB space + */ +private fun hslToRgb(h: Float, s: Float, l: Float, a: Float = 1f): Color { + if (s == 0f) { + // Grayscale + return Color(l, l, l, a) + } + + val c = (1f - kotlin.math.abs(2f * l - 1f)) * s + val hPrime = h / 60f + val x = c * (1f - kotlin.math.abs(hPrime % 2f - 1f)) + val m = l - c / 2f + + val (rPrime, gPrime, bPrime) = when { + hPrime < 1f -> Triple(c, x, 0f) + hPrime < 2f -> Triple(x, c, 0f) + hPrime < 3f -> Triple(0f, c, x) + hPrime < 4f -> Triple(0f, x, c) + hPrime < 5f -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) + } + + return Color( + red = rPrime + m, + green = gPrime + m, + blue = bPrime + m, + alpha = a + ) +} diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeoUtils.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeoUtils.kt index 6394c19406efc541b645ed338a1a913834acf088..ed0b43c01caa9bcaa6731651fa113b6acd58f97e 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeoUtils.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/GeoUtils.kt @@ -30,12 +30,11 @@ import kotlin.math.sqrt */ object GeoUtils { + private const val EARTH_RADIUS_METERS = 6371000.0 private const val METERS_TO_KILOMETERS = 1000.0 private const val METERS_TO_MILES = 1609.34 private const val METERS_TO_FEET = 3.28084 - private const val SHORT_DISTANCE_THRESHOLD_METERS = 200.0 - /** * Formats a distance in meters to a human-readable string based on the unit preference. * @@ -112,10 +111,31 @@ object GeoUtils { val a = sin(deltaLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(deltaLon / 2).pow(2) val c = 2 * atan2(sqrt(a), sqrt(1 - a)) - // Earth's radius in meters - val earthRadius = 6371000.0 + return EARTH_RADIUS_METERS * c + } - return earthRadius * c + /** + * Calculates the distance between two points using a locally cartesian approximation. + * This is faster than haversine for short distances but less accurate for long distances. + * Uses the mean of the two latitudes for the longitude scaling factor. + * + * @param latLng1 First point + * @param latLng2 Second point + * @return Distance in meters + */ + fun fastDistance(latLng1: LatLng, latLng2: LatLng): Double { + // Calculate mean latitude for longitude scaling + val meanLat = (latLng1.latitude + latLng2.latitude) / 2.0 + val meanLatRad = Math.toRadians(meanLat) + + // Convert latitude and longitude differences to meters + val latDiff = (latLng2.latitude - latLng1.latitude) * Math.toRadians(EARTH_RADIUS_METERS) + val lonDiff = (latLng2.longitude - latLng1.longitude) * Math.toRadians( + EARTH_RADIUS_METERS * cos(meanLatRad) + ) + + // Calculate cartesian distance + return sqrt(latDiff * latDiff + lonDiff * lonDiff) } /** @@ -126,13 +146,10 @@ object GeoUtils { * @return A BoundingBox representing the area around the center point */ fun createBoundingBoxAroundPoint(center: LatLng, radiusMeters: Double): BoundingBox { - // Earth's radius in meters - val earthRadius = 6371000.0 - // Calculate the approximate delta in degrees for the given radius - val latDelta = Math.toDegrees(radiusMeters / earthRadius) + val latDelta = Math.toDegrees(radiusMeters / EARTH_RADIUS_METERS) val lonDelta = - Math.toDegrees(radiusMeters / (earthRadius * cos(Math.toRadians(center.latitude)))) + Math.toDegrees(radiusMeters / (EARTH_RADIUS_METERS * cos(Math.toRadians(center.latitude)))) // Create the bounding box with north, south, east, west boundaries return BoundingBox( diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LatLng.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LatLng.kt index 4b71971559b0c608e86387f7cfc07fa3b0556203..7e1cb8522bdb44d61ebd7423d02a240019ac5c24 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LatLng.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/LatLng.kt @@ -25,4 +25,8 @@ data class LatLng( fun distanceTo(other: LatLng): Double { return GeoUtils.haversineDistance(this, other) } + + fun fastDistanceTo(other: LatLng): Double { + return GeoUtils.fastDistance(this, other) + } } \ No newline at end of file diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/RouteStateRepository.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/RouteStateRepository.kt index 78572bdc1ef19a23f4b1daa0fa6a9c2529a19cc3..dbbd580303f440eb4efc989eb5f14e1395445303 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/RouteStateRepository.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/RouteStateRepository.kt @@ -25,9 +25,10 @@ import kotlinx.coroutines.flow.asStateFlow import uniffi.ferrostar.Route data class RouteState( - val route: Route? = null, + val routes: List = emptyList(), val isLoading: Boolean = false, - val error: String? = null + val error: String? = null, + val selectedRouteIndex: Int? = null, ) class RouteStateRepository { @@ -38,8 +39,12 @@ class RouteStateRepository { _routeState.value = _routeState.value.copy(isLoading = isLoading) } - fun setRoute(route: Route?) { - _routeState.value = _routeState.value.copy(route = route, isLoading = false, error = null) + fun setRoutes(routes: List) { + _routeState.value = _routeState.value.copy(routes = routes, isLoading = false, error = null) + } + + fun selectRoute(index: Int) { + _routeState.value = _routeState.value.copy(selectedRouteIndex = index) } fun setError(error: String?) { diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/RoutingOptions.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/RoutingOptions.kt index 96328dc83a92ebf7d259d316d5c838d1c9762f15..2208af0638107408123fa14c7c6a2ea643a4b8a0 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/RoutingOptions.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/RoutingOptions.kt @@ -36,6 +36,7 @@ abstract class RoutingOptions { val gson = GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .create() val wrapper = object { + val alternates = 5 val costing_options = mapOf(costingType to this@RoutingOptions) } return gson.toJson(wrapper) diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt index 2a62dc80c6118fdc016234f649ad97ee0f2ba97c..31077ea61da1cf8447b47efcf9cb92956c8f0bed 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt @@ -169,77 +169,17 @@ fun AppContent( LaunchedEffect(state.peekHeight) { mapViewModel.peekHeight = state.peekHeight } - Box( - modifier = Modifier - .fillMaxSize() - .onGloballyPositioned { - state.screenHeightDp = with(state.density) { it.size.height.toDp() } - state.screenWidthDp = with(state.density) { it.size.width.toDp() } - // For very annoying reasons, this ViewModel needs to know the size of the screen. - // Specifically, it is responsible for tracking the state of the "locate me" button across - // a permission request lifecycle. When the permission request is done, it has zero - // business calling back into the view to perform the animateTo operation, and in order - // to perform the animateTo you need to calculate padding based on screen size and peek - // height. :( - mapViewModel.screenWidth = state.screenWidthDp - mapViewModel.screenHeight = state.screenHeightDp - }, - - ) { - if (port != null && port != -1) { - MapView( - port = port, - mapViewModel = mapViewModel, - onMapInteraction = { - if (navController.currentBackStackEntry?.destination?.route?.startsWith("place_card") == true) { - navController.popBackStack() - } - }, - onMapPoiClick = { - NavigationUtils.navigate(navController, Screen.PlaceCard(it)) - }, - onDropPin = { - val place = Place( - name = droppedPinName, - description = "", - icon = "place", - latLng = it, - address = null, - isMyLocation = false - ) - NavigationUtils.navigate(navController, Screen.PlaceCard(place)) - }, - onRequestLocationPermission = onRequestLocationPermission, - hasLocationPermission = hasLocationPermission, - fabInsets = PaddingValues( - start = 0.dp, - top = 0.dp, - end = 0.dp, - bottom = if (state.screenHeightDp > state.fabHeight) { - state.screenHeightDp - state.fabHeight - } else { - 0.dp - } - ), - cameraState = state.cameraState, - mapPins = state.mapPins, - appPreferences = appPreferenceRepository, - selectedOfflineArea = state.selectedOfflineArea, - currentRoute = state.currentRoute, - currentTransitItinerary = state.currentTransitItinerary - ) - } else { - LaunchedEffect(key1 = port) { - Log.e("AppContent", "Tileserver port is $port, can't display a map!") - } - } - - Box( - modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing) - ) { - BirdSettingsFab(navController) - } - } + MapViewContainer( + port = port, + mapViewModel = mapViewModel, + state = state, + navController = navController, + topOfBackStack = topOfBackStack, + droppedPinName = droppedPinName, + onRequestLocationPermission = onRequestLocationPermission, + hasLocationPermission = hasLocationPermission, + appPreferenceRepository = appPreferenceRepository + ) NavHost( navController = navController, startDestination = Screen.HOME_SEARCH @@ -248,7 +188,14 @@ fun AppContent( Screen.HOME_SEARCH, enterTransition = { slideInVertically(initialOffsetY = { it }) }, exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry -> - HomeRoute(state, homeViewModel, navController, topOfBackStack, appPreferenceRepository, backStackEntry) + HomeRoute( + state, + homeViewModel, + navController, + topOfBackStack, + appPreferenceRepository, + backStackEntry + ) } composable( @@ -262,21 +209,39 @@ fun AppContent( Screen.NEARBY_TRANSIT, enterTransition = { slideInVertically(initialOffsetY = { it }) }, exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry -> - NearbyTransitRoute(state, transitViewModel, navController, topOfBackStack, backStackEntry) + NearbyTransitRoute( + state, + transitViewModel, + navController, + topOfBackStack, + backStackEntry + ) } composable( Screen.PLACE_CARD, enterTransition = { slideInVertically(initialOffsetY = { it }) }, exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry -> - PlaceCardRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry) + PlaceCardRoute( + state, + navController, + topOfBackStack, + appPreferenceRepository, + backStackEntry + ) } composable( Screen.OFFLINE_AREAS, enterTransition = { slideInVertically(initialOffsetY = { it }) }, exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry -> - OfflineAreasRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry) + OfflineAreasRoute( + state, + navController, + topOfBackStack, + appPreferenceRepository, + backStackEntry + ) } composable( @@ -353,14 +318,31 @@ fun AppContent( Screen.DIRECTIONS, enterTransition = { slideInVertically(initialOffsetY = { it }) }, exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry -> - DirectionsRoute(state, mapViewModel, navController, topOfBackStack, appPreferenceRepository, hasLocationPermission, onRequestLocationPermission, hasNotificationPermission, onRequestNotificationPermission, backStackEntry) + DirectionsRoute( + state, + mapViewModel, + navController, + topOfBackStack, + appPreferenceRepository, + hasLocationPermission, + onRequestLocationPermission, + hasNotificationPermission, + onRequestNotificationPermission, + backStackEntry + ) } composable( Screen.TRANSIT_ITINERARY_DETAIL, enterTransition = { slideInVertically(initialOffsetY = { it }) }, exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry -> - TransitItineraryDetailRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry) + TransitItineraryDetailRoute( + state, + navController, + topOfBackStack, + appPreferenceRepository, + backStackEntry + ) } composable(Screen.TURN_BY_TURN) { backStackEntry -> @@ -398,6 +380,120 @@ fun AppContent( } } +@Composable +@Suppress("CognitiveComplexMethod") +private fun MapViewContainer( + port: Int?, + mapViewModel: MapViewModel, + state: AppContentState, + navController: NavHostController, + topOfBackStack: NavBackStackEntry?, + droppedPinName: String, + onRequestLocationPermission: () -> Unit, + hasLocationPermission: Boolean, + appPreferenceRepository: AppPreferenceRepository +) { + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { + state.screenHeightDp = with(state.density) { it.size.height.toDp() } + state.screenWidthDp = with(state.density) { it.size.width.toDp() } + // For very annoying reasons, this ViewModel needs to know the size of the screen. + // Specifically, it is responsible for tracking the state of the "locate me" button across + // a permission request lifecycle. When the permission request is done, it has zero + // business calling back into the view to perform the animateTo operation, and in order + // to perform the animateTo you need to calculate padding based on screen size and peek + // height. :( + mapViewModel.screenWidth = state.screenWidthDp + mapViewModel.screenHeight = state.screenHeightDp + }, + ) { + if (port != null && port != -1) { + MapView( + port = port, + mapViewModel = mapViewModel, + onMapInteraction = { + if (topOfBackStack?.destination?.route?.startsWith("place_card") == true) { + navController.popBackStack() + } + }, + onMapPoiClick = { + if (topOfBackStack?.destination?.route?.startsWith("directions") != true && topOfBackStack?.destination?.route?.startsWith( + "transit_itinerary_detail" + ) != true + ) { + NavigationUtils.navigate(navController, Screen.PlaceCard(it)) + } + }, + onDropPin = { + val place = Place( + name = droppedPinName, + description = "", + icon = "place", + latLng = it, + address = null, + isMyLocation = false + ) + NavigationUtils.navigate(navController, Screen.PlaceCard(place)) + }, + onRequestLocationPermission = onRequestLocationPermission, + hasLocationPermission = hasLocationPermission, + fabInsets = PaddingValues( + start = 0.dp, + top = 0.dp, + end = 0.dp, + bottom = if (state.screenHeightDp > state.fabHeight) { + state.screenHeightDp - state.fabHeight + } else { + 0.dp + } + ), + cameraState = state.cameraState, + mapPins = state.mapPins, + appPreferences = appPreferenceRepository, + selectedOfflineArea = state.selectedOfflineArea, + currentRoute = state.currentRoute, + allRoutes = state.allRoutes, + currentTransitItinerary = state.currentTransitItinerary, + onRouteAnnotationClick = { routeIndex -> + handleRouteAnnotationClick(routeIndex, state) + } + ) + } else { + LaunchedEffect(key1 = port) { + Log.e("AppContent", "Tileserver port is $port, can't display a map!") + } + } + + Box( + modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing) + ) { + BirdSettingsFab(navController) + } + } +} + +private fun handleRouteAnnotationClick(routeIndex: Int, state: AppContentState) { + // Handle route annotation click by updating the selected route index in AppContentState + // The DirectionsScreen will observe this change and update the DirectionsViewModel + if (state.allRoutes.isNotEmpty()) { + val actualIndex = if (routeIndex == -1) { + // If -1 is passed, it means the current selected route was tapped + // Keep the current selection + state.selectedRouteIndex ?: 0 + } else { + // Convert the reversed index back to the correct index + // because routes are displayed in reverse order in the RouteLayer + state.allRoutes.size - 1 - routeIndex + } + + if (actualIndex >= 0 && actualIndex < state.allRoutes.size) { + state.selectedRouteIndex = actualIndex + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun HomeRoute( @@ -408,6 +504,11 @@ private fun HomeRoute( appPreferenceRepository: AppPreferenceRepository, backStackEntry: NavBackStackEntry ) { + LaunchedEffect(Unit) { + state.mapPins.clear() + state.currentRoute = null + state.allRoutes = emptyList() + } state.showToolbar = true HomeScreenComposable( viewModel = homeViewModel, @@ -541,9 +642,15 @@ private fun RoutingProfilesRoute(state: AppContentState, navController: NavHostC @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ProfileEditorRoute(state: AppContentState, navController: NavHostController, backStackEntry: NavBackStackEntry) { +private fun ProfileEditorRoute( + state: AppContentState, + navController: NavHostController, + backStackEntry: NavBackStackEntry +) { LaunchedEffect(key1 = Unit) { state.mapPins.clear() + state.currentRoute = null + state.allRoutes = emptyList() } val snackBarHostState = remember { SnackbarHostState() } @@ -565,7 +672,11 @@ private fun ProfileEditorRoute(state: AppContentState, navController: NavHostCon @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ManagePlacesRoute(state: AppContentState, navController: NavHostController, backStackEntry: NavBackStackEntry) { +private fun ManagePlacesRoute( + state: AppContentState, + navController: NavHostController, + backStackEntry: NavBackStackEntry +) { state.showToolbar = true val listIdRaw = backStackEntry.arguments?.getString("listId") @@ -616,6 +727,8 @@ private fun PlaceCardRoute( viewModel.setPlace(place) // Clear any existing pins and add the new one to ensure only one pin is shown at a time state.mapPins.clear() + state.currentRoute = null + state.allRoutes = emptyList() state.mapPins.add(place) val previousBackStackEntry = navController.previousBackStackEntry @@ -689,6 +802,8 @@ private fun OfflineAreasRoute( rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState) LaunchedEffect(key1 = Unit) { + state.currentRoute = null + state.allRoutes = emptyList() state.mapPins.clear() state.peekHeight = state.screenHeightDp / 3 // Approx, empirical state.coroutineScope.launch { @@ -809,7 +924,20 @@ private fun DirectionsRoute( cameraState = state.cameraState, appPreferences = appPreferenceRepository, padding = polylinePadding, - onRouteUpdate = { route -> state.currentRoute = route }) + onRouteUpdate = { route, allRoutes -> + state.currentRoute = route + state.allRoutes = allRoutes + }) + + // Observe selectedRouteIndex changes from AppContentState and update DirectionsViewModel + LaunchedEffect(state.selectedRouteIndex) { + state.selectedRouteIndex?.let { selectedIndex -> + if (selectedIndex >= 0 && selectedIndex < viewModel.routeState.value.routes.size) { + viewModel.selectRoute(selectedIndex) + } + } + } + DisposableEffect(key1 = Unit) { onDispose { state.currentRoute = null @@ -927,6 +1055,8 @@ private fun TransitItineraryDetailRoute( } // Clear any existing pins + state.currentRoute = null + state.allRoutes = emptyList() state.mapPins.clear() } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContentState.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContentState.kt index 4ff03cee998e12e7540c1f8b3f773a10eb2db356..a1727a9a91e59b0c314a0f6264d70c8f2d37bc18 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContentState.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContentState.kt @@ -49,19 +49,23 @@ class AppContentState( selectedOfflineArea: OfflineArea? = null, showToolbar: Boolean = true, currentRoute: Route? = null, + allRoutes: List = emptyList(), currentTransitItinerary: Itinerary? = null, screenHeightDp: Dp = 0.dp, screenWidthDp: Dp = 0.dp, peekHeight: Dp = 0.dp, + selectedRouteIndex: Int? = null, ) { var fabHeight by mutableStateOf(fabHeight) var selectedOfflineArea by mutableStateOf(selectedOfflineArea) var showToolbar by mutableStateOf(showToolbar) var currentRoute by mutableStateOf(currentRoute) + var allRoutes by mutableStateOf(allRoutes) var currentTransitItinerary by mutableStateOf(currentTransitItinerary) var screenHeightDp by mutableStateOf(screenHeightDp) var screenWidthDp by mutableStateOf(screenWidthDp) var peekHeight by mutableStateOf(peekHeight) + var selectedRouteIndex by mutableStateOf(selectedRouteIndex) } @Composable diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt index 51c22c419713111eb0442cb48be88933456155b8..6c1431773b907838b935b505d4e0128d08d6c8ec 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapView.kt @@ -35,9 +35,11 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource @@ -55,6 +57,8 @@ import earth.maps.cardinal.data.AppPreferenceRepository import earth.maps.cardinal.data.LatLng import earth.maps.cardinal.data.Place import earth.maps.cardinal.data.PolylineUtils +import earth.maps.cardinal.data.desaturate +import earth.maps.cardinal.data.formatDuration import earth.maps.cardinal.data.room.OfflineArea import earth.maps.cardinal.transit.Itinerary import earth.maps.cardinal.transit.Mode @@ -62,10 +66,16 @@ import earth.maps.cardinal.ui.map.LocationPuck import io.github.dellisd.spatialk.geojson.Feature import io.github.dellisd.spatialk.geojson.FeatureCollection import io.github.dellisd.spatialk.geojson.LineString +import io.github.dellisd.spatialk.geojson.Point import io.github.dellisd.spatialk.geojson.Polygon import io.github.dellisd.spatialk.geojson.Position import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.encodeToJsonElement import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.expressions.dsl.Feature.get +import org.maplibre.compose.expressions.dsl.Feature.has import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.image import org.maplibre.compose.expressions.dsl.offset @@ -105,7 +115,9 @@ fun MapView( appPreferences: AppPreferenceRepository, selectedOfflineArea: OfflineArea? = null, currentRoute: Route? = null, + allRoutes: List, currentTransitItinerary: Itinerary? = null, + onRouteAnnotationClick: ((Int) -> Unit)? = null, ) { val context = LocalContext.current val styleState = rememberStyleState() @@ -150,12 +162,15 @@ fun MapView( padding = fabInsets, isAttributionEnabled = true, attributionAlignment = Alignment.BottomStart - ), - renderOptions = RenderOptions() + ), renderOptions = RenderOptions() ), onMapClick = { position, dpOffset -> mapViewModel.handleMapTap( - cameraState, dpOffset, onMapPoiClick, onMapInteraction + cameraState, + dpOffset, + onMapPoiClick, + onMapInteraction, + onRouteAnnotationClick, ) ClickResult.Consume }, @@ -172,7 +187,7 @@ fun MapView( OfflineBoundsLayer(selectedOfflineArea) - RouteLayer(currentRoute) + RouteLayer(mapViewModel, currentRoute, allRoutes) TransitLayer(currentTransitItinerary) @@ -250,59 +265,167 @@ private fun OfflineBoundsLayer(selectedOfflineArea: OfflineArea?) { val color = MaterialTheme.colorScheme.onSurface LineLayer( - id = "offline_download_bounds", - source = offlineDownloadBoundsSource, - color = rgbColor( + id = "offline_download_bounds", source = offlineDownloadBoundsSource, color = rgbColor( const((color.red * 255).toInt()), const((color.green * 255).toInt()), const((color.blue * 255).toInt()) - ), - width = const(3.dp) + ), width = const(3.dp) ) } } @Composable -private fun RouteLayer(currentRoute: Route?) { - currentRoute?.let { route -> +private fun RouteLayer(viewModel: MapViewModel, currentRoute: Route?, allRoutes: List) { + val annotations = remember(allRoutes) { viewModel.placeRouteAnnotations(allRoutes) } + + // Create all route features in a single collection + val routeFeatures = allRoutes.reversed().mapIndexed { index, route -> val routePositions = route.geometry.map { coord -> Position(coord.lng, coord.lat) // [longitude, latitude] } val routeLineString = LineString(routePositions) - val routeFeature = Feature(geometry = routeLineString) - val routeSource = rememberGeoJsonSource( - GeoJsonData.Features(FeatureCollection(features = listOf(routeFeature))) + val desaturateAmount = 0.8f + val polylineCasingColor = if (route == currentRoute) { + formatColorAsJson(colorResource(R.color.polyline_casing_color)) + } else { + formatColorAsJson( + colorResource(R.color.polyline_casing_color).desaturate(desaturateAmount) + ) + } + val polylineColor = if (route == currentRoute) { + formatColorAsJson(colorResource(R.color.polyline_color)) + } else { + formatColorAsJson( + colorResource(R.color.polyline_color).desaturate(desaturateAmount) + ) + } + Feature( + geometry = routeLineString, + properties = mapOf( + "routeIndex" to Json.encodeToJsonElement(index.toString()), + "routeColor" to polylineColor, + "routeColorCasing" to polylineCasingColor, + if(route == currentRoute) { + "current" to Json.encodeToJsonElement(true) + } else { + "notCurrent" to Json.encodeToJsonElement(true) + } + ) ) + }.toMutableList() - val polylineColor = colorResource(R.color.polyline_color) - val polylineCasingColor = - colorResource(R.color.polyline_casing_color) - LineLayer( - id = "route_line_casing", source = routeSource, - color = rgbColor( - const((polylineCasingColor.red * 255.0).toInt()), // Blue color - const((polylineCasingColor.green * 255.0).toInt()), - const((polylineCasingColor.blue * 255.0).toInt()) - ), - width = const(8.dp), - opacity = const(1f), - cap = const(LineCap.Round), - join = const(LineJoin.Round), + // Create single source for all routes + val routeSource = rememberGeoJsonSource( + GeoJsonData.Features(FeatureCollection(features = routeFeatures)) + ) + + // Route casing layer + LineLayer( + id = "route_lines_casing", source = routeSource, + color = get("routeColorCasing").cast(), + width = const(11.dp), + opacity = const(1f), + cap = const(LineCap.Round), + join = const(LineJoin.Round), + ) + + // Route main line layer + LineLayer( + id = "route_lines", source = routeSource, + color = get("routeColor").cast(), + width = const(8.dp), + opacity = const(1f), + cap = const(LineCap.Round), + join = const(LineJoin.Round), + ) + + // Route casing layer + LineLayer( + id = "route_lines_casing_selected", source = routeSource, + color = get("routeColorCasing").cast(), + filter = has("current"), + width = const(9.dp), + opacity = const(1f), + cap = const(LineCap.Round), + join = const(LineJoin.Round), + ) + + // Route main line layer + LineLayer( + id = "route_lines_selected", source = routeSource, + color = get("routeColor").cast(), + filter = has("current"), + width = const(6.dp), + opacity = const(1f), + cap = const(LineCap.Round), + join = const(LineJoin.Round), + ) + + RouteAnnotations( + allRoutes.reversed().mapIndexedNotNull { index, route -> + annotations[route]?.let { Triple(index, route, it) } + } + ) +} + +@Composable +private fun formatColorAsJson(polylineCasingColor: Color): JsonElement = Json.encodeToJsonElement( + "#${ + String.format( + "%02x%02x%02x", + (polylineCasingColor.red * 255).toInt(), + (polylineCasingColor.green * 255).toInt(), + (polylineCasingColor.blue * 255).toInt() ) - LineLayer( - id = "route_line", source = routeSource, - color = rgbColor( - const((polylineColor.red * 255.0).toInt()), // Blue color - const((polylineColor.green * 255.0).toInt()), - const((polylineColor.blue * 255.0).toInt()) + }" +) + +@Composable +private fun RouteAnnotations( + annotations: List> +) { + val features = annotations.map { triple -> + val index = triple.first.toString() + val route = triple.second + val duration = formatDuration(route.steps.sumOf { it.duration }.toInt()) + return@map Feature( + geometry = Point( + coordinates = Position( + longitude = triple.third.longitude, latitude = triple.third.latitude + ) ), - width = const(6.dp), - opacity = const(1f), - cap = const(LineCap.Round), - join = const(LineJoin.Round), + properties = mapOf( + "routeIndex" to Json.encodeToJsonElement(index), + "duration" to Json.encodeToJsonElement(duration) + ) ) } + val annotationSource = rememberGeoJsonSource( + GeoJsonData.Features(FeatureCollection(features = features)) + ) + val textHaloColor = MaterialTheme.colorScheme.surface + val textColor = MaterialTheme.colorScheme.onSurface + + SymbolLayer( + id = "route_annotations", + source = annotationSource, + textField = org.maplibre.compose.expressions.dsl.Feature["duration"].cast(), + textAnchor = const(SymbolAnchor.Bottom), + textColor = rgbColor( + const((textColor.red * 255.0).toInt()), // Blue color + const((textColor.green * 255.0).toInt()), + const((textColor.blue * 255.0).toInt()) + ), + textFont = const(listOf("Roboto Medium")), + textHaloColor = rgbColor( + const((textHaloColor.red * 255.0).toInt()), // Blue color + const((textHaloColor.green * 255.0).toInt()), + const((textHaloColor.blue * 255.0).toInt()) + ), + textHaloBlur = const(1.dp), + textHaloWidth = const(2.dp) + ) } @Composable @@ -311,8 +434,7 @@ private fun TransitLayer(currentTransitItinerary: Itinerary?) { itinerary.legs.forEachIndexed { legIndex, leg -> leg.legGeometry?.let { geometry -> val positions = PolylineUtils.decodePolyline( - encoded = geometry.points, - precision = geometry.precision + encoded = geometry.points, precision = geometry.precision ) if (positions.isNotEmpty()) { val lineString = LineString(positions) @@ -447,8 +569,7 @@ private fun MapControls( cameraState.animateTo( cameraState.position.copy( zoom = min( - 22.0, - cameraState.position.zoom + 1 + 22.0, cameraState.position.zoom + 1 ) ), duration = appPreferences.animationSpeedDurationValue, @@ -467,13 +588,13 @@ private fun MapControls( FloatingActionButton( modifier = Modifier .align(Alignment.CenterHorizontally) - .padding(bottom = dimensionResource(dimen.padding_minor)), onClick = { + .padding(bottom = dimensionResource(dimen.padding_minor)), + onClick = { coroutineScope.launch { cameraState.animateTo( cameraState.position.copy( zoom = max( - 0.0, - cameraState.position.zoom - 1 + 0.0, cameraState.position.zoom - 1 ) ), duration = appPreferences.animationSpeedDurationValue / 2, @@ -492,7 +613,8 @@ private fun MapControls( FloatingActionButton( modifier = Modifier .align(Alignment.CenterHorizontally) - .padding(bottom = dimensionResource(dimen.padding_minor)), onClick = { + .padding(bottom = dimensionResource(dimen.padding_minor)), + onClick = { // Request location permissions if we don't have them if (!hasLocationPermission) { mapViewModel.markLocationRequestPending() diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt index 551f30c0795b77ec3b851e88cd333966a26d8925..2ca0a057187e6819b3f91c1fbee12ca661ace3aa 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/MapViewModel.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.times import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import earth.maps.cardinal.data.LatLng import earth.maps.cardinal.data.LocationRepository import earth.maps.cardinal.data.OrientationRepository import earth.maps.cardinal.data.Place @@ -38,6 +39,7 @@ import earth.maps.cardinal.data.ViewportRepository import earth.maps.cardinal.data.room.SavedPlace import earth.maps.cardinal.data.room.SavedPlaceDao import earth.maps.cardinal.geocoding.OfflineGeocodingService +import earth.maps.cardinal.ui.util.AnnotationPlacer import io.github.dellisd.spatialk.geojson.Feature import io.github.dellisd.spatialk.geojson.FeatureCollection import io.github.dellisd.spatialk.geojson.Point @@ -52,6 +54,7 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonPrimitive import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.CameraState +import uniffi.ferrostar.Route import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -67,6 +70,7 @@ class MapViewModel @Inject constructor( private val orientationRepository: OrientationRepository, private val offlineGeocodingService: OfflineGeocodingService, private val placeDao: SavedPlaceDao, + private val annotationPlacer: AnnotationPlacer, ) : ViewModel() { private companion object { @@ -195,8 +199,27 @@ class MapViewModel @Inject constructor( cameraState: CameraState, dpOffset: DpOffset, onMapPoiClick: (Place) -> Unit, - onMapInteraction: () -> Unit + onMapInteraction: () -> Unit, + onRouteAnnotationClick: ((Int) -> Unit)? = null, ) { + // Check for route annotation features first + val routeAnnotationFeatures = cameraState.projection?.queryRenderedFeatures( + dpOffset, + layerIds = setOf("route_annotations", "route_lines_casing", "route_lines") + ) + + val routeAnnotationFeature = routeAnnotationFeatures?.firstOrNull() + if (routeAnnotationFeature != null) { + // Extract route index from layer ID + val routeIndex = + routeAnnotationFeature.properties["routeIndex"]?.jsonPrimitive?.content?.toIntOrNull() + + if (routeIndex != null && onRouteAnnotationClick != null) { + onRouteAnnotationClick(routeIndex) + return + } + } + val features = cameraState.projection?.queryRenderedFeatures( dpOffset, layerIds = setOf( @@ -270,6 +293,10 @@ class MapViewModel @Inject constructor( } } + fun placeRouteAnnotations(routes: List): Map { + return annotationPlacer.placeAnnotations(routes) + } + /** * Fetches the current location and returns a CameraPosition to animate to. * Returns null if location cannot be determined. diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt index ec848f88ff6b5d55ece39935413798fe75361738..18e1070489a60c354e8c6d3eb8c13ecdd1fb41c1 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsScreen.kt @@ -486,9 +486,9 @@ private fun NonTransitRouteResults( ) } - routeState.route != null -> { + routeState.routes.isNotEmpty() -> { FerrostarRouteResults( - ferrostarRoute = routeState.route, + routeState = routeState, viewModel = viewModel, modifier = Modifier.fillMaxWidth(), distanceUnit = appPreferences.distanceUnit.collectAsState().value, @@ -687,18 +687,19 @@ fun RouteDisplayHandler( cameraState: org.maplibre.compose.camera.CameraState, appPreferences: AppPreferenceRepository, padding: PaddingValues, - onRouteUpdate: (Route?) -> Unit + onRouteUpdate: (Route?, List) -> Unit ) { val routeState by viewModel.routeState.collectAsState() + val selectedRoute = routeState.routes.getOrNull(routeState.selectedRouteIndex ?: 0) val selectedMode = viewModel.selectedRoutingMode val coroutineScope = rememberCoroutineScope() // Update the route state and animate camera when route changes - LaunchedEffect(routeState.route, selectedMode) { + LaunchedEffect(selectedRoute, selectedMode) { if (selectedMode != RoutingMode.PUBLIC_TRANSPORT) { - onRouteUpdate(routeState.route) + onRouteUpdate(selectedRoute, routeState.routes) // Animate camera to show the full route when it's calculated - routeState.route?.let { route -> + selectedRoute?.let { route -> val coordinates = route.geometry if (coordinates.isNotEmpty()) { val lats = coordinates.map { it.lat } @@ -720,7 +721,7 @@ fun RouteDisplayHandler( } } } else { - onRouteUpdate(null) + onRouteUpdate(null, routeState.routes) } } } @@ -913,118 +914,121 @@ private fun RoutingProfileSelector( @Composable private fun FerrostarRouteResults( - ferrostarRoute: Route, + routeState: RouteState, viewModel: DirectionsViewModel, modifier: Modifier = Modifier, distanceUnit: Int, availableProfiles: List, onPendingNavigationChange: (Boolean) -> Unit, ) { + val selectedRoute = routeState.routes.getOrNull(routeState.selectedRouteIndex ?: 0) var showProfileDialog by remember { mutableStateOf(false) } val selectedProfile = viewModel.selectedRoutingProfile - LazyColumn(modifier = modifier) { - item { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = dimensionResource(dimen.padding)), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) - ) { - Column( + selectedRoute?.let { ferrostarRoute -> + LazyColumn(modifier = modifier) { + item { + Card( modifier = Modifier .fillMaxWidth() - .padding(dimensionResource(dimen.padding)) + .padding(bottom = dimensionResource(dimen.padding)), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween + .padding(dimensionResource(dimen.padding)) ) { - Text( - text = "Distance:", style = MaterialTheme.typography.bodyLarge - ) - Text( - text = GeoUtils.formatDistance( - ferrostarRoute.distance, distanceUnit - ), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary - ) - } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Distance:", style = MaterialTheme.typography.bodyLarge + ) + Text( + text = GeoUtils.formatDistance( + ferrostarRoute.distance, distanceUnit + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary + ) + } - // Calculate total duration from steps - val totalDuration = ferrostarRoute.steps.sumOf { it.duration } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = dimensionResource(dimen.padding)), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "Duration:", style = MaterialTheme.typography.bodyLarge - ) - Text( - text = "${(totalDuration / 60).toInt()} min", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary - ) - } + // Calculate total duration from steps + val totalDuration = ferrostarRoute.steps.sumOf { it.duration } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = dimensionResource(dimen.padding)), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Duration:", style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "${(totalDuration / 60).toInt()} min", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary + ) + } - Button( - onClick = { - onPendingNavigationChange(true) - }, modifier = Modifier.fillMaxWidth(), enabled = true - ) { - Text(stringResource(string.start_navigation)) + Button( + onClick = { + onPendingNavigationChange(true) + }, modifier = Modifier.fillMaxWidth(), enabled = true + ) { + Text(stringResource(string.start_navigation)) + } } } } - } - // Actual route steps - items(ferrostarRoute.steps.size) { index -> - val step = ferrostarRoute.steps[index] - Card( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) - ) { - Row( + // Actual route steps + items(ferrostarRoute.steps.size) { index -> + val step = ferrostarRoute.steps[index] + Card( modifier = Modifier .fillMaxWidth() - .padding(dimensionResource(dimen.padding)), - verticalAlignment = Alignment.CenterVertically + .padding(bottom = 8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { - // Step number - Box( + Row( modifier = Modifier - .size(32.dp) - .padding(4.dp), - contentAlignment = Alignment.Center + .fillMaxWidth() + .padding(dimensionResource(dimen.padding)), + verticalAlignment = Alignment.CenterVertically ) { + // Step number + Box( + modifier = Modifier + .size(32.dp) + .padding(4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "${index + 1}", style = MaterialTheme.typography.labelLarge + ) + } + + // Step instruction Text( - text = "${index + 1}", style = MaterialTheme.typography.labelLarge + text = step.instruction, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .weight(1f) + .padding(horizontal = dimensionResource(dimen.padding)) ) - } - // Step instruction - Text( - text = step.instruction, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .weight(1f) - .padding(horizontal = dimensionResource(dimen.padding)) - ) - - // Step distance - Text( - text = GeoUtils.formatDistance(step.distance, distanceUnit), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + // Step distance + Text( + text = GeoUtils.formatDistance(step.distance, distanceUnit), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt index cc182ef76b0594407aef9dd6ac53e0929ff737be..ae8febe5bda8d2e57e68a4918e285917d110abc7 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/directions/DirectionsViewModel.kt @@ -214,7 +214,7 @@ class DirectionsViewModel @Inject constructor( ferrostarWrapper.core.getRoutes(userLocation, waypoints) } - routeStateRepository.setRoute(routes.firstOrNull()) + routeStateRepository.setRoutes(routes) } catch (e: Exception) { Log.e(TAG, "Error while fetching route", e) routeStateRepository.setError( @@ -397,7 +397,7 @@ class DirectionsViewModel @Inject constructor( fun startNavigation(navController: NavController, state: RouteState) { - state.route?.let { route -> + state.routes.getOrNull(state.selectedRouteIndex ?: 0)?.let { route -> NavigationUtils.navigate( navController, Screen.TurnByTurnNavigation( @@ -493,6 +493,35 @@ class DirectionsViewModel @Inject constructor( } } + /** + * Selects a route by index from the available routes. + * This method is called when a route annotation is tapped on the map. + * + * @param routeIndex The index of the route to select. Use -1 for the current selected route. + */ + fun selectRouteByIndex(routeIndex: Int) { + val currentRoutes = routeState.value.routes + if (currentRoutes.isNotEmpty()) { + val actualIndex = if (routeIndex == -1) { + // If -1 is passed, it means the current selected route was tapped + // Keep the current selection + routeState.value.selectedRouteIndex ?: 0 + } else { + // Convert the reversed index back to the correct index + // because routes are displayed in reverse order in the RouteLayer + currentRoutes.size - 1 - routeIndex + } + + if (actualIndex >= 0 && actualIndex < currentRoutes.size) { + routeStateRepository.selectRoute(actualIndex) + } + } + } + + fun selectRoute(selectedIndex: Int) { + routeStateRepository.selectRoute(selectedIndex) + } + companion object { private const val TAG = "DirectionsViewModel" } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/util/AnnotationPlacer.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/util/AnnotationPlacer.kt new file mode 100644 index 0000000000000000000000000000000000000000..b94f446996cd7d1cbbfae7b9af76f5097729c592 --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/util/AnnotationPlacer.kt @@ -0,0 +1,193 @@ +package earth.maps.cardinal.ui.util + +import android.util.Log +import earth.maps.cardinal.data.LatLng +import uniffi.ferrostar.GeographicCoordinate +import uniffi.ferrostar.Route +import javax.inject.Inject +import kotlin.math.abs + +/** + * Utility class for determining optimal placement of route duration annotations on a map. + * + * This class solves the problem of placing annotations (such as route duration text) on polylines + * in a way that users can clearly identify which annotation applies to which route. The primary + * challenge is avoiding placement on route segments where multiple routes overlap, as this would + * create visual ambiguity. + * + * The algorithm works by: + * 1. Analyzing all routes to identify overlapping segments + * 2. Finding divergent segments (where only one route exists) for each route + * 3. Selecting the longest divergent segment for optimal visibility + * 4. Placing the annotation at the midpoint of that segment + * + * @constructor Creates an instance of AnnotationPlacer using dependency injection + */ +class AnnotationPlacer @Inject constructor() { + + /** + * Determines optimal annotation placement points for a collection of routes. + * + * This method analyzes the geometry of all provided routes to find non-overlapping + * segments where annotations can be placed without visual ambiguity. For each route, + * it identifies the longest segment that doesn't overlap with any other route and + * places the annotation at the midpoint of that segment. + * + * The algorithm follows these steps: + * 1. Count overlapping points across all routes + * 2. For each route, identify contiguous segments where overlap count == 1 (diverged) + * 3. Track cumulative distance along each divergent segment + * 4. Select the longest divergent segment for each route + * 5. Place annotation at the midpoint of the selected segment + * + * @param routes A list of Route objects containing geographic coordinate data + * @return A Map where each Route is mapped to its optimal annotation placement LatLng coordinate. + * Routes with no divergent segments will be excluded from the result. + * + * @see Route for the route data structure + * @see LatLng for the coordinate representation + * + * Example usage: + * ```kotlin + * val annotationPlacer = AnnotationPlacer() + * val routes = listOf(route1, route2, route3) + * val placements = annotationPlacer.placeAnnotations(routes) + * + * placements.forEach { (route, position) -> + * // Place annotation at 'position' for 'route' + * } + * ``` + */ + fun placeAnnotations(routes: List): Map { + Log.d(TAG, "calculating annotations for ${routes.size} routes") + + if (routes.isEmpty()) { + return emptyMap() + } + + val routesLatLng = convertRoutesToLatLng(routes) + val routeOverlapCount = countOverlappingPoints(routesLatLng) + + return routesLatLng.indices.mapNotNull { index -> + findOptimalPlacement(routes[index], routesLatLng[index], routeOverlapCount) + }.toMap() + } + + /** + * Converts Route objects to lists of LatLng coordinates. + */ + private fun convertRoutesToLatLng(routes: List): List> { + return routes.map { route -> + route.geometry.map { LatLng(it.lat, it.lng) } + } + } + + /** + * Counts how many routes pass through each geographic point. + */ + private fun countOverlappingPoints(routesLatLng: List>): Map { + val routeOverlapCount: MutableMap = mutableMapOf() + + for (route in routesLatLng) { + for (point in route) { + routeOverlapCount.merge(point, 1, Int::plus) + } + } + + return routeOverlapCount + } + + /** + * Finds the optimal annotation placement for a single route. + * Returns null if no suitable placement is found. + */ + private fun findOptimalPlacement( + route: Route, + routeLatLng: List, + routeOverlapCount: Map + ): Pair? { + val divergentSegments = identifyDivergentSegments(routeLatLng, routeOverlapCount) + + if (divergentSegments.isEmpty()) { + Log.w(TAG, "Route with zero divergent segments found, skipping") + return null + } + + val bestSegment = selectLongestSegment(divergentSegments) + val midpoint = findSegmentMidpoint(bestSegment) + + return Pair(route, midpoint) + } + + /** + * Identifies all contiguous divergent segments in a route. + * A segment is divergent when only one route passes through its points. + */ + private fun identifyDivergentSegments( + route: List, + routeOverlapCount: Map + ): List>> { + val segments: MutableList>> = mutableListOf() + var currentSegment: MutableList>? = null + var currentSegmentLengthMeters: Double? = null + + for (point in route) { + val isDiverged = routeOverlapCount[point] == 1 + + if (isDiverged) { + if (currentSegment != null) { + // Continue current segment + currentSegmentLengthMeters = currentSegmentLengthMeters?.plus( + currentSegment.lastOrNull()?.first?.fastDistanceTo(point) ?: 0.0 + ) + currentSegment.add(Pair(point, currentSegmentLengthMeters ?: 0.0)) + } else { + // Start new segment + currentSegment = mutableListOf(Pair(point, 0.0)) + currentSegmentLengthMeters = 0.0 + } + } else { + // End current segment if we hit an overlapping point + if (currentSegment != null) { + segments.add(currentSegment) + currentSegment = null + currentSegmentLengthMeters = null + } + } + } + + // Add the final segment if the route ends while diverged + if (currentSegment != null) { + segments.add(currentSegment) + } + + return segments + } + + /** + * Selects the longest divergent segment from all available segments. + */ + private fun selectLongestSegment(segments: List>>): List> { + return segments + .maxByOrNull { segment -> segment.lastOrNull()?.second ?: 0.0 } + ?: throw IllegalStateException("No segments available when selecting longest segment") + } + + /** + * Finds the midpoint of a segment based on cumulative distance. + */ + private fun findSegmentMidpoint(segment: List>): LatLng { + val segmentLength = segment.lastOrNull()?.second + ?: throw IllegalStateException("Empty segment found when finding midpoint") + + val midpoint = segment.minByOrNull { abs(it.second - segmentLength / 2.0) } + ?: throw IllegalStateException("Empty segment found after sorting when finding midpoint") + + return midpoint.first + } + + companion object { + /** Tag used for logging debug and warning messages */ + const val TAG = "AnnotationPlacer" + } +} diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/data/GeoUtilsTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/GeoUtilsTest.kt index 2f513ac16d04f1c95f310a034a7e949f9514a947..c82e770004c4245e7415870883b9aea7790a4cf5 100644 --- a/cardinal-android/app/src/test/java/earth/maps/cardinal/data/GeoUtilsTest.kt +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/GeoUtilsTest.kt @@ -124,4 +124,64 @@ class GeoUtilsTest { assertEquals(center.longitude + lonDelta, boundingBox.east, 0.01) assertEquals(center.longitude - lonDelta, boundingBox.west, 0.01) } + + @Test + fun fastDistance_samePoint() { + val latLng1 = LatLng(37.7749, -122.4194) // San Francisco + val latLng2 = LatLng(37.7749, -122.4194) // Same point + val expectedDistance = 0.0 + val actualDistance = GeoUtils.fastDistance(latLng1, latLng2) + assertEquals(expectedDistance, actualDistance, 0.01) + } + + @Test + fun fastDistance_shortDistance_equals_haversine() { + // Short distance between two nearby points in San Francisco + val point1 = LatLng(37.7749, -122.4194) + val point2 = LatLng(37.7750, -122.4195) // About 14m apart + val expectedDistance = GeoUtils.haversineDistance(point1, point2) + val actualDistance = GeoUtils.fastDistance(point1, point2) + assertEquals(expectedDistance, actualDistance, 0.01) + } + + + @Test + fun fastDistance_shortDistance() { + // Short distance between two nearby points in San Francisco + val point1 = LatLng(37.7749, -122.4194) + val point2 = LatLng(37.7750, -122.4195) // About 14m apart + val expectedDistance = 14.173617226379271 + val actualDistance = GeoUtils.fastDistance(point1, point2) + assertEquals(expectedDistance, actualDistance, 0.01) + } + + @Test + fun fastDistance_mediumDistance() { + // Distance between San Francisco and Oakland (across the bay) + val sf = LatLng(37.7749, -122.4194) + val oakland = LatLng(37.8044, -122.2711) + val expectedDistance = 13438.14841287118 + val actualDistance = GeoUtils.fastDistance(sf, oakland) + assertEquals(expectedDistance, actualDistance, 0.01) + } + + @Test + fun fastDistance_longDistance() { + // Distance between New York City and Los Angeles + val nyc = LatLng(40.7128, -74.0060) + val la = LatLng(34.0522, -118.2437) + val expectedDistance = 3978193.533035539 + val actualDistance = GeoUtils.fastDistance(nyc, la) + assertEquals(expectedDistance, actualDistance, 0.01) + } + + @Test + fun fastDistance_highLatitude() { + // Test at high latitude (near Arctic Circle) + val point1 = LatLng(66.5, 0.0) + val point2 = LatLng(66.6, 0.1) + val expectedDistance = 11967.60736579976 + val actualDistance = GeoUtils.fastDistance(point1, point2) + assertEquals(expectedDistance, actualDistance, 0.01) + } } diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/core/MapViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/core/MapViewModelTest.kt index 6175d18be1c0117e3b2528d0fdcf509f0121a70e..6d15d7b24844ca744e757a3dd3a0acad8107c087 100644 --- a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/core/MapViewModelTest.kt +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/core/MapViewModelTest.kt @@ -9,6 +9,7 @@ import earth.maps.cardinal.data.ViewportPreferences import earth.maps.cardinal.data.ViewportRepository import earth.maps.cardinal.data.room.SavedPlace import earth.maps.cardinal.geocoding.OfflineGeocodingService +import earth.maps.cardinal.ui.util.AnnotationPlacer import io.github.dellisd.spatialk.geojson.Position import io.mockk.coEvery import io.mockk.every @@ -64,7 +65,8 @@ class MapViewModelTest { locationRepository = mockLocationRepository, orientationRepository = mockOrientationRepository, offlineGeocodingService = mockOfflineGeocodingService, - placeDao = mockPlaceDao + placeDao = mockPlaceDao, + annotationPlacer = AnnotationPlacer() ) // Initialize screen dimensions @@ -138,7 +140,8 @@ class MapViewModelTest { locationRepository = mockLocationRepository, orientationRepository = mockOrientationRepository, offlineGeocodingService = mockOfflineGeocodingService, - placeDao = mockPlaceDao + placeDao = mockPlaceDao, + annotationPlacer = AnnotationPlacer() ) assertThat(viewModel.isLocating.first()).isFalse() @@ -167,7 +170,9 @@ class MapViewModelTest { locationRepository = mockLocationRepository, orientationRepository = mockOrientationRepository, offlineGeocodingService = mockOfflineGeocodingService, - placeDao = mockPlaceDao + placeDao = mockPlaceDao, + annotationPlacer = AnnotationPlacer() + ) assertThat(viewModel.locationFlow.first()).isEqualTo(expectedLocation) @@ -194,7 +199,8 @@ class MapViewModelTest { locationRepository = mockLocationRepository, orientationRepository = mockOrientationRepository, offlineGeocodingService = mockOfflineGeocodingService, - placeDao = mockPlaceDao + placeDao = mockPlaceDao, + annotationPlacer = AnnotationPlacer() ) assertThat(viewModel.heading.first()).isEqualTo(expectedHeading) @@ -228,7 +234,8 @@ class MapViewModelTest { locationRepository = mockLocationRepository, orientationRepository = mockOrientationRepository, offlineGeocodingService = mockOfflineGeocodingService, - placeDao = mockPlaceDao + placeDao = mockPlaceDao, + annotationPlacer = AnnotationPlacer() ) val featureCollection = viewModel.savedPlacesFlow.first() diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/directions/DirectionsViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/directions/DirectionsViewModelTest.kt index 66708f6714ae863c9bfbc6cba2f850ca9e764891..4c9b73ef77dbc202d0dd3f11ec7232540a87d85e 100644 --- a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/directions/DirectionsViewModelTest.kt +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/directions/DirectionsViewModelTest.kt @@ -84,7 +84,7 @@ class DirectionsViewModelTest { coEvery { mockPlanStateRepository.clear() } returns Unit coEvery { mockRouteStateRepository.setLoading(any()) } returns Unit coEvery { mockPlanStateRepository.setLoading(any()) } returns Unit - coEvery { mockRouteStateRepository.setRoute(any()) } returns Unit + coEvery { mockRouteStateRepository.setRoutes(any()) } returns Unit coEvery { mockPlanStateRepository.setPlanResponse(any()) } returns Unit coEvery { mockRouteStateRepository.setError(any()) } returns Unit coEvery { mockPlanStateRepository.setError(any()) } returns Unit @@ -158,7 +158,7 @@ class DirectionsViewModelTest { val initialState = viewModel.routeState.value assertEquals(RouteState(), initialState) assertFalse(initialState.isLoading) - assertNull(initialState.route) + assertTrue(initialState.routes.isEmpty()) // Trigger the state changes viewModel.updateFromPlace(fromPlace) @@ -169,7 +169,7 @@ class DirectionsViewModelTest { // Verify repository interactions coVerify { mockRouteStateRepository.setLoading(true) } - coVerify { mockRouteStateRepository.setRoute(mockRoute) } + coVerify { mockRouteStateRepository.setRoutes(listOf(mockRoute)) } } @Test @@ -287,7 +287,7 @@ class DirectionsViewModelTest { // Verify that no loading or route setting was called coVerify(exactly = 0) { mockRouteStateRepository.setLoading(any()) } - coVerify(exactly = 0) { mockRouteStateRepository.setRoute(any()) } + coVerify(exactly = 0) { mockRouteStateRepository.setRoutes(any()) } coVerify(exactly = 0) { mockRouteStateRepository.setError(any()) } } @@ -306,7 +306,7 @@ class DirectionsViewModelTest { // Verify that no loading or route setting was called coVerify(exactly = 0) { mockRouteStateRepository.setLoading(any()) } - coVerify(exactly = 0) { mockRouteStateRepository.setRoute(any()) } + coVerify(exactly = 0) { mockRouteStateRepository.setRoutes(any()) } coVerify(exactly = 0) { mockRouteStateRepository.setError(any()) } } } diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/util/AnnotationPlacerTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/util/AnnotationPlacerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..22ed813ea1b7262933e4bc5f298498359b0b2a49 --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/util/AnnotationPlacerTest.kt @@ -0,0 +1,283 @@ +package earth.maps.cardinal.ui.util + +import earth.maps.cardinal.data.LatLng +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import uniffi.ferrostar.GeographicCoordinate +import uniffi.ferrostar.Route + +@RunWith(RobolectricTestRunner::class) +class AnnotationPlacerTest { + + private val annotationPlacer = AnnotationPlacer() + + @Test + fun placeAnnotations_emptyRoutes_returnsEmptyMap() { + val routes = emptyList() + val result = annotationPlacer.placeAnnotations(routes) + + assertTrue("Result should be empty for empty input", result.isEmpty()) + } + + @Test + fun placeAnnotations_singleRoute_placesAnnotationAtMidpoint() { + // Create a simple straight route + val route = createRoute( + listOf( + LatLng(37.7749, -122.4194), // San Francisco + LatLng(37.7750, -122.4195), // ~14m north-east + LatLng(37.7751, -122.4196) // ~14m further north-east + ) + ) + + val result = annotationPlacer.placeAnnotations(listOf(route)) + + assertEquals("Should have one placement for one route", 1, result.size) + val placement = result[route] + assertNotNull("Should have a placement for the route", placement) + + // The placement should be near the middle of the route + val expectedMidpoint = LatLng(37.7750, -122.4195) + assertEquals( + "Latitude should be near midpoint", + expectedMidpoint.latitude, + placement!!.latitude, + 0.001 + ) + assertEquals( + "Longitude should be near midpoint", + expectedMidpoint.longitude, + placement.longitude, + 0.001 + ) + } + + @Test + fun placeAnnotations_twoNonOverlappingRoutes_placesBothAnnotations() { + // Create two routes that don't overlap + val route1 = createRoute( + listOf( + LatLng(37.7749, -122.4194), // San Francisco + LatLng(37.7750, -122.4195) + ) + ) + + val route2 = createRoute( + listOf( + LatLng(40.7128, -74.0060), // New York + LatLng(40.7129, -74.0061) + ) + ) + + val result = annotationPlacer.placeAnnotations(listOf(route1, route2)) + + assertEquals("Should have placements for both routes", 2, result.size) + assertNotNull("Should have placement for route 1", result[route1]) + assertNotNull("Should have placement for route 2", result[route2]) + + // Verify placements are in different cities + val placement1 = result[route1]!! + val placement2 = result[route2]!! + + assertTrue( + "Placement 1 should be in San Francisco area", + placement1.latitude > 37.7 && placement1.latitude < 37.8 + ) + assertTrue( + "Placement 2 should be in New York area", + placement2.latitude > 40.7 && placement2.latitude < 40.8 + ) + } + + @Test + fun placeAnnotations_overlappingRoutes_placesAnnotationsInDivergentSegments() { + // Create two routes that share a common segment but diverge + val commonStart = LatLng(37.7749, -122.4194) + val commonMiddle = LatLng(37.7750, -122.4195) + + val route1End = LatLng(37.7751, -122.4196) // Continues northeast + val route2End = LatLng(37.7751, -122.4194) // Continues east + + val route1 = createRoute(listOf(commonStart, commonMiddle, route1End)) + val route2 = createRoute(listOf(commonStart, commonMiddle, route2End)) + + val result = annotationPlacer.placeAnnotations(listOf(route1, route2)) + + assertEquals("Should have placements for both routes", 2, result.size) + + val placement1 = result[route1]!! + val placement2 = result[route2]!! + + // Both placements should be in their divergent segments (after the common point) + assertTrue( + "Route 1 placement should be after divergence", + placement1.latitude >= commonMiddle.latitude + ) + assertTrue( + "Route 2 placement should be after divergence", + placement2.latitude >= commonMiddle.latitude + ) + + // They should be in different locations + assertTrue( + "Placements should be different", + placement1 != placement2 + ) + } + + @Test + fun placeAnnotations_fullyOverlappingRoutes_returnsEmptyMap() { + // Create two identical routes that completely overlap + val route1 = createRoute( + listOf( + LatLng(37.7749, -122.4194), + LatLng(37.7750, -122.4195), + LatLng(37.7751, -122.4196) + ) + ) + + val route2 = createRoute( + listOf( + LatLng(37.7749, -122.4194), + LatLng(37.7750, -122.4195), + LatLng(37.7751, -122.4196) + ) + ) + + val result = annotationPlacer.placeAnnotations(listOf(route1, route2)) + + assertTrue("Should have no placements for fully overlapping routes", result.isEmpty()) + } + + @Test + fun placeAnnotations_routeWithMultipleDivergentSegments_choosesLongestSegment() { + // Create a route with two divergent segments of different lengths + val route = createRoute( + listOf( + LatLng(37.7749, -122.4194), // Start + LatLng(37.7750, -122.4195), // Short segment start + LatLng(37.7751, -122.4196), // Short segment end + LatLng(37.7752, -122.4197), // Overlap point (simulated overlap with another route) + LatLng(37.7753, -122.4198), // Long segment start + LatLng(37.7755, -122.4200), // Long segment end + LatLng(37.7756, -122.4201) // End + ) + ) + + // Create a second route that overlaps only at one point to create divergent segments + val overlappingRoute = createRoute( + listOf( + LatLng(37.7752, -122.4197) // Only overlaps at one point + ) + ) + + val result = annotationPlacer.placeAnnotations(listOf(route, overlappingRoute)) + + assertEquals("Should have one placement for the main route", 1, result.size) + val placement = result[route]!! + + // The placement should be in the longer segment (the one with more points) + assertTrue( + "Placement should be in the longer divergent segment", + placement.latitude >= 37.7753 + ) + } + + @Test + fun placeAnnotations_routeWithSinglePoint_placesAnnotation() { + // Create a route with just one point + val route = createRoute(listOf(LatLng(37.7749, -122.4194))) + + val result = annotationPlacer.placeAnnotations(listOf(route)) + + assertEquals("Should have one placement", 1, result.size) + val placement = result[route]!! + + assertEquals( + "Placement should be at the single point", + LatLng(37.7749, -122.4194), placement + ) + } + + @Test + fun placeAnnotations_complexRealWorldScenario_handlesCorrectly() { + // Simulate a realistic scenario with multiple route options + val startPoint = LatLng(37.7749, -122.4194) // San Francisco + + // Route 1: Goes east then north, then west. + val route1 = createRoute( + listOf( + LatLng(37.0, -122.0), // Starting point (common) + LatLng(37.5, -122.0), // Common point (route2's midpoint) + LatLng(37.5, -121.75), // East + LatLng(37.5, -121.5), // East + LatLng(37.75, -121.25), // Northeast + LatLng(38.0, -121.0), // Continue northeast + LatLng(38.0, -121.0), // North + LatLng(38.0, -122.0), // West + ) + ) + + // Route 2: Direct path north. + val route2 = createRoute( + listOf( + LatLng(37.0, -122.0), // Starting point (common) + LatLng(37.5, -122.0), // North (midpoint of this route) + LatLng(37.65, -122.0), // North (begin divergent segment) + LatLng(37.75, -122.0), // North + LatLng(37.85, -122.0), // North + LatLng(38.0, -122.0), // North (common endpoint) + ) + ) + + val result = annotationPlacer.placeAnnotations(listOf(route1, route2)) + + assertEquals("Should have placements for all routes", 2, result.size) + + // Get specific placements for each route + val placement1 = result[route1] + val placement2 = result[route2] + + assertNotNull("Route 1 should have placement", placement1) + assertNotNull("Route 2 should have placement", placement2) + + // Route 1: Should be placed in its unique northern segment (after going north) + // The algorithm should place it at the midpoint of the longest divergent segment + assertEquals( + "Route 1 placement should be in the east", + LatLng(37.5, -121.5), placement1 + ) + + // Route 2: Should be placed in its unique eastern segment (after going east) + assertEquals( + "Route 2 placement should be in its northern half's midpoint", + LatLng(37.75, -122.0), placement2 + ) + } + + /** + * Helper method to create a mock Route object for testing. + * Since Route is from the uniffi.ferrostar package, we use mockk to create it. + */ + private fun createRoute(points: List): Route { + // Convert LatLng points to GeographicCoordinate + val geometry = points.map { + GeographicCoordinate(it.latitude, it.longitude) + } + + // Create a mock Route using mockk + val mockRoute = mockk() + + // Mock the geometry property to return our test points + every { mockRoute.geometry } returns geometry + + return mockRoute + } +}