Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ class RouteMapController(
private var basePolylines: List<RoutePolyline> = emptyList()
private var baseStopPresentation: RouteStopPresentation? = null

private data class SelectedTripPresentation(
val points: List<GeoPoint>,
val stopIds: List<String>,
val routeDirection: RouteDirectionKey,
)

private data class StopFocusSession(
val stopId: String,
val trips: Set<FocusedTrip>,
Expand Down Expand Up @@ -175,8 +181,9 @@ class RouteMapController(

private var vehicleJob: Job? = null

// Drives everything a tap-selected vehicle shows: the trip overlay (the uncertainty band +
// fast-estimate marker, [showSelectionOverlay]) and the route continuation (#1691, [showContinuation]).
// Drives everything a tap-selected vehicle shows: its exact shape + scheduled stops
// ([showSelectionPresentation]), the trip overlay (the uncertainty band + fast-estimate marker,
// [showSelectionOverlay]), and the route continuation (#1691, [showContinuation]).
// A tap selects a vehicle (renderState.selectedVehicleTripId); collectLatest so a fast reselect
// cancels an in-flight continuation resolution (a suspending network fetch) for the previous
// selection instead of racing it — [showSelectionOverlay] has no suspension point, so it's unaffected
Expand Down Expand Up @@ -234,14 +241,15 @@ class RouteMapController(
// route-id filter set is built once here, not per frame, since it's constant for this route;
// directionState is read live since it's resolved only once the route loads.
renderState.setVehiclesSampler { nowMs -> sampleVehicles(WallTime(nowMs)) }
// A tapped vehicle enters a focused state that shows its extrapolation band + fast-estimate
// marker, and resolves its route continuation (#1691); react to the selection state here (a tap
// sets it, a map/background tap clears it). Cancel any collector from a prior start() so a
// A tapped vehicle enters a focused state that shows its exact shape/stops, extrapolation band
// + fast-estimate marker, and route continuation (#1691); react to the selection state here (a
// tap sets it, a map/background tap clears it). Cancel any collector from a prior start() so a
// re-entered session doesn't leak one that stop() can no longer reach.
selectionJob?.cancel()
selectionJob = scope.launch {
renderState.selectedVehicleTripId.collectLatest { tripId ->
showSelectionOverlay(tripId)
showSelectionPresentation(tripId)
showContinuation(tripId)
}
}
Expand Down Expand Up @@ -360,6 +368,21 @@ class RouteMapController(
}
}

/** Replace the direction line + stops with the selected vehicle's exact trip presentation. */
private suspend fun showSelectionPresentation(tripId: String?) {
if (tripId != null) {
val state = tripObservationRepository.lookupTripState(tripId)
val shapeId = state?.shapeId ?: latestPoll?.response?.trip(tripId)?.shapeId
// Warm both resources concurrently; supervisorScope joins them and keeps one failing
// fetch from cancelling the other. selectedTripPresentation() re-reads the results.
supervisorScope {
launch { shapeId?.let { tripObservationRepository.ensureShape(tripId, it) } }
launch { tripObservationRepository.ensureSchedule(tripId) }
}
}
publishMapPresentation()
}

/** The shown route's GTFS color (the band tint's basis), or the default when it carries none. */
private fun currentRouteColor(): Int =
(_loadedRoute.value as? LoadedRoute.Loaded)?.route?.color ?: DEFAULT_ROUTE_LINE_COLOR
Expand Down Expand Up @@ -569,20 +592,45 @@ class RouteMapController(

private fun publishMapPresentation() {
val focus = stopFocusSession
if (focus == null) {
val emphasizedRoute = routeId?.let { RouteDirectionKey(it, presentationDirectionId) }
val routeColors = _focusedRouteColors.value
val badges = if (emphasizedRoute == null) {
focusedGeometry.toRouteBadges(focusedRoutes, routeColors)
} else emptyList()
val selected = selectedTripPresentation()
val selectedTrip = selected?.points
?.takeIf { it.size >= 2 }
?.let { points ->
focusedRoutePolyline(currentRouteColor(), points, directional = true)
}
// A selected vehicle shows its exact trip everywhere: the trip line over its thin
// same-direction underlay (with any focused-stop siblings beneath), framed to that trip,
// and only its scheduled stops. In whole-route mode the empty [focusedGeometry] collapses
// toTripFocusedRoutePolylines to just the underlay + trip.
if (selected != null && selectedTrip != null) {
renderState.setRoutePolylines(
basePolylines,
polylines = focusedGeometry.toTripFocusedRoutePolylines(
selected.routeDirection,
routeColors,
selectedDirectionUnderlay(selected.routeDirection.directionId),
selectedTrip,
),
framingPolylines = listOf(selectedTrip),
routeModeScalesStopsWithZoom = isActive,
)
renderState.setRouteBadges(badges)
stopsController.setRoutePresentation(selectedTripStopPresentation(selected))
return
}
if (focus == null) {
renderState.setRoutePolylines(basePolylines, routeModeScalesStopsWithZoom = isActive)
renderState.setRouteBadges(emptyList())
stopsController.setRoutePresentation(baseStopPresentation)
return
}
val emphasizedRoute = routeId?.let { RouteDirectionKey(it, presentationDirectionId) }
val showBaseRoute = isActive && emphasizedRoute != null &&
focus.trips.none { it.routeDirection == emphasizedRoute }
val routesByStopId = focusedStops.routeDirectionsByStopId(focus.trips, emphasizedRoute)
val routeColors = _focusedRouteColors.value
renderState.setRoutePolylines(
polylines = focusedGeometry.toRoutePolylines(emphasizedRoute, routeColors) +
if (showBaseRoute) basePolylines else emptyList(),
Expand All @@ -592,12 +640,7 @@ class RouteMapController(
framingPolylines = if (isActive) basePolylines else emptyList(),
routeModeScalesStopsWithZoom = isActive,
)
renderState.setRouteBadges(
if (emphasizedRoute == null) {
focusedGeometry.toRouteBadges(focusedRoutes, routeColors)
}
else emptyList()
)
renderState.setRouteBadges(badges)
stopsController.setRoutePresentation(
if (showBaseRoute) {
baseStopPresentation
Expand All @@ -612,6 +655,46 @@ class RouteMapController(
)
}

/** Current selected-trip data, read fresh after selection's shape/schedule activation. */
private fun selectedTripPresentation(): SelectedTripPresentation? {
val tripId = renderState.selectedVehicleTripId.value ?: return null
val currentRouteId = routeId ?: return null
val state = tripObservationRepository.lookupTripState(tripId)
// Keep the base route visible until both the exact shape and schedule resolve; a
// half-loaded presentation would blank the base stops behind an empty selected trip.
val polyline = state?.polyline ?: return null
val schedule = state.schedule ?: return null
// The marker is keyed by activeTripId, which resolves directly through the poll references.
val activeTrip = latestPoll?.response?.trip(tripId)
return SelectedTripPresentation(
points = polyline.points.map { it.toGeoPoint() },
stopIds = schedule.stopTimes.map { it.stopId },
routeDirection = RouteDirectionKey(
currentRouteId,
activeTrip?.directionId ?: currentDirectionId,
),
)
}

/**
* The selected trip's own direction shape, thinned to sit beneath its exact trip line. In
* whole-route mode [basePolylines] is the merged both-directions geometry, so drawing that as
* the underlay would surface the opposite direction; resolve the direction shape instead.
*/
private fun selectedDirectionUnderlay(directionId: Int?): List<RoutePolyline> =
directionPolylines(directionId).asDeemphasizedRouteUnderlay()

/** The selected trip's scheduled stops, projected onto its exact shape in schedule order. */
private fun selectedTripStopPresentation(selected: SelectedTripPresentation): RouteStopPresentation {
val stops = routeStops.stopsForTrip(selected.stopIds)
return RouteStopPresentation(
stops = stops,
routes = routeStopRoutes,
routeDirectionsByStopId = stops.associate { it.id to setOf(selected.routeDirection) },
projectedPoints = projectStopsOntoPolylines(stops, listOf(selected.points)),
)
}

/** Snap each exact scheduled stop to the closest successful shape of a trip that serves it. */
private fun projectFocusedStops(
trips: Set<FocusedTrip>,
Expand Down Expand Up @@ -727,7 +810,14 @@ class RouteMapController(
*/
private fun projectStopsOntoShape(stops: List<ObaStop>): Map<String, GeoPoint> {
val route = routeShape ?: return emptyMap()
val shapes = route.shapeForDirection(currentDirectionId).polylines
return projectStopsOntoPolylines(stops, route.shapeForDirection(currentDirectionId).polylines)
}

private fun projectStopsOntoPolylines(
stops: List<ObaStop>,
points: List<List<GeoPoint>>,
): Map<String, GeoPoint> {
val shapes = points
.filter { it.size >= 2 }
.map { line -> Polyline(line.map(GeoPoint::toLocation)) }
if (shapes.isEmpty()) return emptyMap()
Expand All @@ -741,25 +831,30 @@ class RouteMapController(
}

/**
* Re-draw the route shape for [currentDirectionId]: the selected direction's own travel-ordered
* shape (with direction arrows), or the whole-route merged shape drawn undirected when none is
* selected. Passes the route's raw GTFS color through; the render layer picks the fallback when
* it's absent. Uses [focusedRoutePolyline], the same complete line presentation as a route selected
* from focused-stop mode. Called on load and on every direction switch.
* The drawn lines for [directionId]'s shape: the selected direction's own travel-ordered shape
* (with direction arrows), or the whole-route merged shape drawn undirected when [directionId] is
* null. Passes the route's raw GTFS color through; the render layer picks the fallback when it's
* absent. Uses [focusedRoutePolyline], the same complete line presentation as a route selected
* from focused-stop mode. shapeForDirection pairs the drawn shape with its directionality, so
* arrows are stamped only when the direction's own travel-ordered shape is used — never on the
* whole-route merged fallback (a direction that carried no shape on the wire).
*/
private fun showDirectionPolylines() {
val route = routeShape ?: return
// shapeForDirection pairs the drawn shape with its directionality, so arrows are stamped only
// when the selected direction's own travel-ordered shape is used — never on the whole-route
// merged fallback (a direction that carried no shape on the wire).
val shape = route.shapeForDirection(currentDirectionId)
basePolylines = shape.polylines.map { points ->
private fun directionPolylines(directionId: Int?): List<RoutePolyline> {
val route = routeShape ?: return emptyList()
val shape = route.shapeForDirection(directionId)
return shape.polylines.map { points ->
focusedRoutePolyline(
color = route.route?.color,
points = points,
directional = shape.directional,
)
}
}

/** Re-draw the base route for [currentDirectionId]. Called on load and on every direction switch. */
private fun showDirectionPolylines() {
if (routeShape == null) return
basePolylines = directionPolylines(currentDirectionId)
publishMapPresentation()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,9 @@ internal fun List<RouteMapStop>.anchorDirectionId(anchorStopId: String?): Int? {
internal fun List<RouteMapStop>.stopsForDirection(directionId: Int?): List<ObaStop> =
if (directionId == null) map { it.stop }
else filter { directionId in it.directionIds }.map { it.stop }

/** Narrows the route catalog to one trip's scheduled stop ids, preserving schedule order. */
internal fun List<RouteMapStop>.stopsForTrip(stopIds: List<String>): List<ObaStop> {
val stopsById = associateBy { it.stop.id }
return stopIds.distinct().mapNotNull { stopsById[it]?.stop }
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ internal fun focusedRoutePolyline(
transforms = ROUTE_VIEW_TRANSFORMS,
)

/** The active route's broader geometry retained beneath an exact selected-trip line. */
internal fun List<RoutePolyline>.asDeemphasizedRouteUnderlay(): List<RoutePolyline> = map { line ->
line.copy(
widthProfile = DEEMPHASIZED_ROUTE_LINE_WIDTH_PROFILE,
directional = false,
)
}

/**
* Convert exact trip shapes into uniform-width directional route lines. When [emphasizedRoute] is
* set, that route-direction uses a 1.5x stroke while siblings use a thin, plain stroke and render
Expand Down Expand Up @@ -80,6 +88,16 @@ internal fun FocusedTripGeometry.toRoutePolylines(
}
}

/** Sibling routes, then the selected route's thin underlay, then its exact trip shape. */
internal fun FocusedTripGeometry.toTripFocusedRoutePolylines(
selectedRoute: RouteDirectionKey,
routeColors: Map<RouteDirectionKey, Int>,
selectedRouteUnderlay: List<RoutePolyline>,
selectedTrip: RoutePolyline,
): List<RoutePolyline> =
FocusedTripGeometry(shapes.filterNot { it.routeDirection == selectedRoute })
.toRoutePolylines(selectedRoute, routeColors) + selectedRouteUnderlay + selectedTrip

/**
* One Google-first badge model per successfully drawn route-direction, preserving the focused-trip
* order that mirrors the arrivals drawer. The shared layout chooses stable geographic line-center
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ import org.onebusaway.android.map.render.GeoPoint
import org.onebusaway.android.models.RouteMapStop

/**
* [anchorDirectionId] (resolve the launch anchor stop → its direction) and [stopsForDirection]
* (narrow a route's stops to a direction) — the pure repository/controller direction helpers, plus
* [RouteMap.shapeForDirection] (pick the drawn shape + its directionality, with the whole-route fallback).
* [anchorDirectionId] (resolve the launch anchor stop → its direction), [stopsForDirection]
* (narrow a route's stops to a direction), and [stopsForTrip] (narrow them to one schedule) — the
* pure repository/controller helpers, plus [RouteMap.shapeForDirection] (pick the drawn shape + its
* directionality, with the whole-route fallback).
*/
class RouteDirectionFocusTest {

Expand Down Expand Up @@ -96,6 +97,16 @@ class RouteDirectionFocusTest {
assertEquals(listOf("a", "b", "s"), withShared.stopsForDirection(0).map { it.id })
}

// ----- stopsForTrip -----

@Test
fun tripStopsFollowScheduleRatherThanRouteDirection() {
assertEquals(
listOf("b", "d"),
stops.stopsForTrip(listOf("b", "missing", "d", "b")).map { it.id },
)
}

// ----- shapeForDirection -----

private val whole = listOf(listOf(GeoPoint(0.0, 0.0), GeoPoint(1.0, 1.0)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.onebusaway.android.map.render.GeoPoint
import org.onebusaway.android.map.render.DEEMPHASIZED_ROUTE_LINE_WIDTH_PROFILE
import org.onebusaway.android.map.render.FOCUSED_ROUTE_LINE_WIDTH_PROFILE
import org.onebusaway.android.map.render.GeoPoint
import org.onebusaway.android.map.render.ROUTE_LINE_WIDTH_PROFILE
import org.onebusaway.android.map.render.RoutePolyline
import org.onebusaway.android.map.render.RoutePolylineTransform
import org.onebusaway.android.map.render.haversineMeters
import org.onebusaway.android.models.FocusedTrip
Expand Down Expand Up @@ -89,6 +90,49 @@ class RouteViewGeometryTest {
assertEquals(listOf(false, true), lines.map { it.directional })
}

@Test
fun `selected vehicle trip replaces every shape in its route direction`() {
val sibling = listOf(GeoPoint(1.0, 0.0), GeoPoint(1.0, 1.0))
val firstDirectionVariant = listOf(GeoPoint(2.0, 0.0), GeoPoint(2.0, 1.0))
val secondDirectionVariant = listOf(GeoPoint(3.0, 0.0), GeoPoint(3.0, 1.0))
val exactTrip = listOf(GeoPoint(4.0, 0.0), GeoPoint(4.0, 1.0))
val geometry = FocusedTripGeometry(
listOf(
FocusedTripShape("sibling", "other", 1, sibling, directionId = 1),
FocusedTripShape("variant-a", "selected", 2, firstDirectionVariant, directionId = 0),
FocusedTripShape("variant-b", "selected", 2, secondDirectionVariant, directionId = 0),
)
)
val selectedTrip = RoutePolyline(
color = 2,
points = exactTrip,
widthProfile = FOCUSED_ROUTE_LINE_WIDTH_PROFILE,
directional = true,
)
val underlay = listOf(
RoutePolyline(2, secondDirectionVariant, widthProfile = ROUTE_LINE_WIDTH_PROFILE, directional = true)
).asDeemphasizedRouteUnderlay()

val lines = geometry.toTripFocusedRoutePolylines(
selectedRoute = RouteDirectionKey("selected", 0),
routeColors = emptyMap(),
selectedRouteUnderlay = underlay,
selectedTrip = selectedTrip,
)

assertEquals(listOf(sibling, secondDirectionVariant, exactTrip), lines.map { it.points })
assertEquals(
listOf(
DEEMPHASIZED_ROUTE_LINE_WIDTH_PROFILE,
DEEMPHASIZED_ROUTE_LINE_WIDTH_PROFILE,
FOCUSED_ROUTE_LINE_WIDTH_PROFILE,
),
lines.map { it.widthProfile },
)
assertEquals(listOf(false, false, true), lines.map { it.directional })
assertSame(selectedTrip, lines.last())
}

@Test
fun `scheduled stops carry every presented route that serves them`() {
val trips = setOf(
Expand Down