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

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

feat: annotation placer

parent 2c3265e3
Loading
Loading
Loading
Loading
+135 −2
Original line number Diff line number Diff line
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() {

    fun placeAnnotations(routes: List<List<LatLng>>): List<LatLng> {
        return emptyList()
    /**
     * 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<Route>): Map<Route, LatLng> {
        val routeOverlapCount: MutableMap<LatLng, Int> = mutableMapOf()
        val routesLatLng = routes.map { route -> route.geometry.map { LatLng(it.lat, it.lng) } }
        val placements: MutableMap<Route, LatLng> = mutableMapOf()
        
        // Step 1: Count how many routes pass through each point
        for (route in routesLatLng) {
            for (point in route) {
                routeOverlapCount.merge(point, 1, Int::plus)
            }
        }
        
        // Step 2: For each route, find divergent segments and place annotations
        routesLatLng.forEachIndexed { index, route ->
            val segments: MutableList<List<Pair<LatLng, Double>>> = mutableListOf()
            var currentSegment: MutableList<Pair<LatLng, Double>>? = null
            var currentSegmentLengthMeters: Double? = null
            
            // Step 2a: Identify contiguous divergent segments
            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)
            }
            
            // Step 2b: Select the longest divergent segment
            val bestSegment =
                segments.sortedByDescending { segment -> segment.lastOrNull()?.second }
                    .firstOrNull()
            
            if (bestSegment == null) {
                Log.w(TAG, "Route with zero divergent segments found, skipping")
                return@forEachIndexed
            }
            
            val bestSegmentLength = bestSegment.lastOrNull()?.second
            if (bestSegmentLength == null) {
                Log.w(TAG, "Empty divergent segment found, skipping. This is a logic error.")
                return@forEachIndexed
            }
            
            // Step 2c: Find the midpoint of the selected segment
            val segmentMidpoint =
                bestSegment.minByOrNull { abs(it.second - bestSegmentLength / 2.0) }
            
            if (segmentMidpoint == null) {
                Log.w(TAG, "Empty divergent segment found after sorting, skipping. This is a logic error.")
                return@forEachIndexed
            }
            
            // Step 2d: Store the placement for this route
            placements.put(routes[index], segmentMidpoint.first)
        }
        
        return placements
    }

    companion object {
        /** Tag used for logging debug and warning messages */
        const val TAG = "AnnotationPlacer"
    }
}