Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit cba365e2 authored by Ellen Poe's avatar Ellen Poe
Browse files

feat: route alternate annotation

parent 605d428b
Loading
Loading
Loading
Loading
+64 −3
Original line number Diff line number Diff line
@@ -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
    )
}
+9 −4
Original line number Diff line number Diff line
@@ -25,9 +25,10 @@ import kotlinx.coroutines.flow.asStateFlow
import uniffi.ferrostar.Route

data class RouteState(
    val route: Route? = null,
    val routes: List<Route> = 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<Route>) {
        _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?) {
+5 −1
Original line number Diff line number Diff line
@@ -226,6 +226,7 @@ fun AppContent(
                appPreferences = appPreferenceRepository,
                selectedOfflineArea = state.selectedOfflineArea,
                currentRoute = state.currentRoute,
                allRoutes = state.allRoutes,
                currentTransitItinerary = state.currentTransitItinerary
            )
        } else {
@@ -809,7 +810,10 @@ private fun DirectionsRoute(
        cameraState = state.cameraState,
        appPreferences = appPreferenceRepository,
        padding = polylinePadding,
        onRouteUpdate = { route -> state.currentRoute = route })
        onRouteUpdate = { route, allRoutes ->
            state.currentRoute = route
            state.allRoutes = allRoutes
        })
    DisposableEffect(key1 = Unit) {
        onDispose {
            state.currentRoute = null
+2 −0
Original line number Diff line number Diff line
@@ -49,6 +49,7 @@ class AppContentState(
    selectedOfflineArea: OfflineArea? = null,
    showToolbar: Boolean = true,
    currentRoute: Route? = null,
    allRoutes: List<Route> = emptyList(),
    currentTransitItinerary: Itinerary? = null,
    screenHeightDp: Dp = 0.dp,
    screenWidthDp: Dp = 0.dp,
@@ -58,6 +59,7 @@ class AppContentState(
    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)
+120 −22
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@
package earth.maps.cardinal.ui.core

import android.content.Context
import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -35,6 +36,7 @@ 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
@@ -45,6 +47,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.util.fastSumBy
import androidx.compose.ui.zIndex
import androidx.core.graphics.toColorInt
import earth.maps.cardinal.R
@@ -55,6 +58,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,6 +67,7 @@ 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
@@ -105,6 +111,7 @@ fun MapView(
    appPreferences: AppPreferenceRepository,
    selectedOfflineArea: OfflineArea? = null,
    currentRoute: Route? = null,
    allRoutes: List<Route>,
    currentTransitItinerary: Itinerary? = null,
) {
    val context = LocalContext.current
@@ -150,8 +157,7 @@ fun MapView(
                        padding = fabInsets,
                        isAttributionEnabled = true,
                        attributionAlignment = Alignment.BottomStart
                    ),
                    renderOptions = RenderOptions()
                    ), renderOptions = RenderOptions()
                ),
                onMapClick = { position, dpOffset ->
                    mapViewModel.handleMapTap(
@@ -172,7 +178,7 @@ fun MapView(

                OfflineBoundsLayer(selectedOfflineArea)

                RouteLayer(currentRoute)
                RouteLayer(mapViewModel, currentRoute, allRoutes)

                TransitLayer(currentTransitItinerary)

@@ -250,20 +256,69 @@ 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?) {
private fun RouteLayer(viewModel: MapViewModel, currentRoute: Route?, allRoutes: List<Route>) {
    val annotations = remember(allRoutes) { viewModel.placeRouteAnnotations(allRoutes) }

    Log.d("annotations", "${annotations.values}")

    allRoutes.reversed().forEachIndexed { index, route ->
        if (route == currentRoute) {
            return@forEachIndexed
        }
        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 polylineColor = colorResource(R.color.polyline_color).desaturate(desaturateAmount)
        val polylineCasingColor =
            colorResource(R.color.polyline_casing_color).desaturate(desaturateAmount)

        LineLayer(
            id = "route_line_casing_$index", 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(9.dp),
            opacity = const(1f),
            cap = const(LineCap.Round),
            join = const(LineJoin.Round),
        )
        LineLayer(
            id = "route_line_$index", source = routeSource,
            color = rgbColor(
                const((polylineColor.red * 255.0).toInt()), // Blue color
                const((polylineColor.green * 255.0).toInt()),
                const((polylineColor.blue * 255.0).toInt())
            ),
            width = const(6.dp),
            opacity = const(1f),
            cap = const(LineCap.Round),
            join = const(LineJoin.Round),
        )

        annotations[route]?.let { annotationLatLng ->
            RouteAnnotation(annotationLatLng, index, route)
        }
    }

    currentRoute?.let { route ->
        val routePositions = route.geometry.map { coord ->
            Position(coord.lng, coord.lat) // [longitude, latitude]
@@ -275,23 +330,22 @@ private fun RouteLayer(currentRoute: Route?) {
        )

        val polylineColor = colorResource(R.color.polyline_color)
        val polylineCasingColor =
            colorResource(R.color.polyline_casing_color)
        val polylineCasingColor = colorResource(R.color.polyline_casing_color)

        LineLayer(
            id = "route_line_casing", source = routeSource,
            id = "current_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),
            width = const(9.dp),
            opacity = const(1f),
            cap = const(LineCap.Round),
            join = const(LineJoin.Round),
        )
        LineLayer(
            id = "route_line", source = routeSource,
            id = "current_route_line", source = routeSource,
            color = rgbColor(
                const((polylineColor.red * 255.0).toInt()), // Blue color
                const((polylineColor.green * 255.0).toInt()),
@@ -302,8 +356,53 @@ private fun RouteLayer(currentRoute: Route?) {
            cap = const(LineCap.Round),
            join = const(LineJoin.Round),
        )

        annotations[route]?.let { annotationLatLng ->
            RouteAnnotation(annotationLatLng, index = null, route)
        }
    }
}

@Composable
private fun RouteAnnotation(
    annotationLatLng: LatLng,
    index: Int?,
    route: Route
) {
    val index = index?.toString() ?: "selected"
    val annotationFeature = Feature(
        geometry = Point(
            coordinates = Position(
                longitude = annotationLatLng.longitude, latitude = annotationLatLng.latitude
            )
        )
    )
    val annotationSource = rememberGeoJsonSource(
        GeoJsonData.Features(FeatureCollection(features = listOf(annotationFeature)))
    )
    val textHaloColor = MaterialTheme.colorScheme.surface
    val textColor = MaterialTheme.colorScheme.onSurface

    SymbolLayer(
        id = "route_annotation_$index",
        source = annotationSource,
        textField = const(formatDuration(route.steps.sumOf { it.duration }.toInt())),
        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
private fun TransitLayer(currentTransitItinerary: Itinerary?) {
@@ -311,8 +410,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 +545,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 +564,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 +589,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()
Loading