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 @@ -67,8 +67,8 @@ import org.onebusaway.android.map.render.TripOverlay
import org.onebusaway.android.map.render.VehicleBitmaps
import org.onebusaway.android.map.render.VehicleMarker
import org.onebusaway.android.map.render.bikeZoomBand
import org.onebusaway.android.map.render.RoutePolylineReconciler
import org.onebusaway.android.map.render.routeLineWidthScale
import org.onebusaway.android.map.render.reconcileEqualItems
import org.onebusaway.android.time.WallTime
import org.onebusaway.android.util.MyTextUtils
import org.onebusaway.android.util.ThemeUtils
Expand Down Expand Up @@ -128,11 +128,15 @@ class GoogleMapRenderer(
private val staticPolylines = mutableListOf<Polyline>()

// Whole-route lines are reconciled independently from the combined static snapshot: stop list,
// focus, or bike changes retain these native polylines. Snapshot copies keep the same List instance,
// making the common stop-only update an O(1) identity check; equal republished values are retained too.
private val routePolylines = mutableListOf<Polyline>()
private var renderedRoutePolylines: List<RoutePolyline> = emptyList()
private var renderedRouteWidths: List<Float> = emptyList()
// focus, or bike changes retain these native polylines. The flavor-neutral reconcile/width bookkeeping
// lives in the shared [RoutePolylineReconciler] (#1906); only the four gms-specific line operations
// below are supplied here.
private val routePolylineReconciler = RoutePolylineReconciler<Polyline>(
widthOf = ::routeWidthPx,
createLine = ::addRoutePolyline,
removeLines = { lines -> lines.forEach { it.remove() } },
setWidth = { line, width -> line.width = width },
)

// The dynamic layer, tracked by identity so [renderDynamic] can move markers in place: route vehicles
// keyed by active trip id, and the band's (interaction-free) polylines re-added each frame. The
Expand Down Expand Up @@ -301,27 +305,7 @@ class GoogleMapRenderer(

/** Reconcile the independently collected route layer, retaining equal native polylines. */
fun renderRoutePolylines(next: List<RoutePolyline> = renderState.snapshot.value.routePolylines) {
if (renderedRoutePolylines === next || renderedRoutePolylines == next) return

val previousNative = routePolylines.toList()
val previousWidths = renderedRouteWidths
val reconciliation = reconcileEqualItems(renderedRoutePolylines, next)
reconciliation.removedPreviousIndices.forEach { previousNative[it].remove() }
val nextWidths = next.map { routeWidthPx(it, map.cameraPosition.zoom) }
val reconciled = next.mapIndexed { index, polyline ->
val previousIndex = reconciliation.previousIndexForNext[index]
previousIndex?.let(previousNative::get)
?.also {
if (previousWidths.getOrNull(previousIndex) != nextWidths[index]) {
it.width = nextWidths[index]
}
}
?: addRoutePolyline(polyline, nextWidths[index])
}
renderedRoutePolylines = next
renderedRouteWidths = nextWidths
routePolylines.clear()
routePolylines.addAll(reconciled)
routePolylineReconciler.reconcile(next, map.cameraPosition.zoom)
}

private fun addRoutePolyline(polyline: RoutePolyline, widthPx: Float): Polyline {
Expand Down Expand Up @@ -428,10 +412,7 @@ class GoogleMapRenderer(
routeStopLayer.dispose()

clearStatic()
routePolylines.forEach { it.remove() }
routePolylines.clear()
renderedRoutePolylines = emptyList()
renderedRouteWidths = emptyList()
routePolylineReconciler.clear()

stopMarkerLayer.dispose()

Expand Down Expand Up @@ -780,13 +761,7 @@ class GoogleMapRenderer(

fun onCameraSettled(zoom: Float) {
routeStopLayer.onCameraSettled(zoom)
val nextWidths = renderedRoutePolylines.map { routeWidthPx(it, zoom) }
if (nextWidths != renderedRouteWidths) {
renderedRouteWidths = nextWidths
for (index in routePolylines.indices) {
routePolylines[index].width = nextWidths[index]
}
}
routePolylineReconciler.resyncWidths(zoom)
val detailScale = routeLineWidthScale(zoom)
updateVehicleScale(detailScale)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright (C) 2026 Open Transit Software Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.onebusaway.android.map.render

/**
* Reconciles the independently collected route layer against a native map, retaining equal native
* lines and keeping their widths in sync with the camera zoom. This is the flavor-neutral half of the
* route-polyline draw that the Google and MapLibre renderers previously duplicated verbatim (#1906):
* the identity/equality early-exit, the [reconcileEqualItems] diff, the per-index width comparison
* against the last-drawn widths, the native width patch, the three-field state update, and the
* camera-settle width resync all live here once.
*
* The only genuinely platform-specific parts are supplied as callbacks over the opaque [NativeLine]
* type: how to resolve a line's pixel width at a zoom ([widthOf]), how to create a native line
* ([createLine]), how to remove a batch of them ([removeLines]), and how to patch a live line's width
* ([setWidth]). Everything else — the bookkeeping the fix was about — is shared.
*
* Not thread-safe: every method mutates native map state and must run on the map's main thread, which
* is where both renderers already call it.
*/
class RoutePolylineReconciler<NativeLine>(
private val widthOf: (RoutePolyline, Float) -> Float,
private val createLine: (RoutePolyline, Float) -> NativeLine,
private val removeLines: (List<NativeLine>) -> Unit,
private val setWidth: (NativeLine, Float) -> Unit,
) {
// The native lines currently drawn, positionally aligned with [renderedPolylines]/[renderedWidths].
private val lines = mutableListOf<NativeLine>()
private var renderedPolylines: List<RoutePolyline> = emptyList()
private var renderedWidths: List<Float> = emptyList()

/**
* Reconcile the drawn lines to [next], computing widths at [zoom]: equal lines are retained (their
* width patched only when it actually changed), disappearing lines removed, and new lines created.
* Snapshot copies keep the same list instance, so the common stop-only update is an O(1) identity
* check; an equal republished value is retained too.
*/
fun reconcile(next: List<RoutePolyline>, zoom: Float) {
if (renderedPolylines === next || renderedPolylines == next) return

val previousNative = lines.toList()
val previousWidths = renderedWidths
val reconciliation = reconcileEqualItems(renderedPolylines, next)
val removed = reconciliation.removedPreviousIndices.map(previousNative::get)
if (removed.isNotEmpty()) removeLines(removed)
val nextWidths = next.map { widthOf(it, zoom) }
val reconciled = next.mapIndexed { index, polyline ->
val previousIndex = reconciliation.previousIndexForNext[index]
previousIndex?.let(previousNative::get)
?.also {
if (previousWidths.getOrNull(previousIndex) != nextWidths[index]) {
setWidth(it, nextWidths[index])
}
}
?: createLine(polyline, nextWidths[index])
}
renderedPolylines = next
renderedWidths = nextWidths
lines.clear()
lines.addAll(reconciled)
}

/**
* On a camera settle, recompute every retained line's width at [zoom] and patch the ones that
* changed. A no-op when nothing changed, so a settle that doesn't cross a width breakpoint touches
* no native state.
*/
fun resyncWidths(zoom: Float) {
val nextWidths = renderedPolylines.map { widthOf(it, zoom) }
if (nextWidths != renderedWidths) {
renderedWidths = nextWidths
for (index in lines.indices) {
setWidth(lines[index], nextWidths[index])
}
}
}

/** Remove every drawn line and drop all state — the renderer's dispose path. */
fun clear() {
if (lines.isNotEmpty()) removeLines(lines.toList())
lines.clear()
renderedPolylines = emptyList()
renderedWidths = emptyList()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ import org.onebusaway.android.map.render.TripOverlay
import org.onebusaway.android.map.render.VehicleBitmaps
import org.onebusaway.android.map.render.VehicleMarker
import org.onebusaway.android.map.render.bikeZoomBand
import org.onebusaway.android.map.render.RoutePolylineReconciler
import org.onebusaway.android.map.render.routeLineWidthScale
import org.onebusaway.android.map.render.reconcileEqualItems
import org.onebusaway.android.time.WallTime
import org.onebusaway.android.util.MyTextUtils
import org.onebusaway.android.util.getRouteDisplayName
Expand Down Expand Up @@ -103,11 +103,22 @@ class MapLibreRenderer(
private val staticAnnotations = mutableListOf<Annotation>()

// Whole-route lines are reconciled independently from the combined static snapshot: stop list,
// focus, or bike changes retain these native polylines. Snapshot copies keep the same List instance,
// making the common stop-only update an O(1) identity check; equal republished values are retained too.
private val routePolylines = mutableListOf<Polyline>()
private var renderedRoutePolylines: List<RoutePolyline> = emptyList()
private var renderedRouteWidths: List<Float> = emptyList()
// focus, or bike changes retain these native polylines. The flavor-neutral reconcile/width bookkeeping
// lives in the shared [RoutePolylineReconciler] (#1906); only the four maplibre-specific line
// operations below are supplied here.
private val routePolylineReconciler = RoutePolylineReconciler<Polyline>(
widthOf = ::routeWidth,
createLine = { polyline, width ->
map.addPolyline(
PolylineOptions()
.color(polyline.resolvedColor)
.width(width)
.addPoints(polyline.points)
)
},
removeLines = { lines -> map.removeAnnotations(lines) },
setWidth = { line, width -> line.width = width },
)

// The dynamic layer, tracked by identity so [renderDynamic] can move markers in place: route
// vehicles keyed by active trip id, the trip-focus estimate markers keyed by role, and the band's
Expand Down Expand Up @@ -209,33 +220,7 @@ class MapLibreRenderer(

/** Reconcile the independently collected route layer, retaining equal native polylines. */
fun renderRoutePolylines(next: List<RoutePolyline> = renderState.snapshot.value.routePolylines) {
if (renderedRoutePolylines === next || renderedRoutePolylines == next) return

val previousNative = routePolylines.toList()
val previousWidths = renderedRouteWidths
val reconciliation = reconcileEqualItems(renderedRoutePolylines, next)
val removed = reconciliation.removedPreviousIndices.map(previousNative::get)
if (removed.isNotEmpty()) map.removeAnnotations(removed)
val nextWidths = next.map { routeWidth(it, map.cameraPosition.zoom.toFloat()) }
val reconciled = next.mapIndexed { index, polyline ->
val previousIndex = reconciliation.previousIndexForNext[index]
previousIndex?.let(previousNative::get)
?.also {
if (previousWidths.getOrNull(previousIndex) != nextWidths[index]) {
it.width = nextWidths[index]
}
}
?: map.addPolyline(
PolylineOptions()
.color(polyline.resolvedColor)
.width(nextWidths[index])
.addPoints(polyline.points)
)
}
renderedRoutePolylines = next
renderedRouteWidths = nextWidths
routePolylines.clear()
routePolylines.addAll(reconciled)
routePolylineReconciler.reconcile(next, map.cameraPosition.zoom.toFloat())
}

private fun PolylineOptions.addPoints(points: List<GeoPoint>): PolylineOptions {
Expand All @@ -244,13 +229,7 @@ class MapLibreRenderer(
}

fun onCameraSettled(zoom: Float) {
val nextWidths = renderedRoutePolylines.map { routeWidth(it, zoom) }
if (nextWidths != renderedRouteWidths) {
renderedRouteWidths = nextWidths
for (index in routePolylines.indices) {
routePolylines[index].width = nextWidths[index]
}
}
routePolylineReconciler.resyncWidths(zoom)
val detailScale = routeLineWidthScale(zoom)
updateVehicleScale(detailScale)
}
Expand All @@ -276,12 +255,11 @@ class MapLibreRenderer(
clearPing()
stopMarkerLayer.dispose()
routeStopCircleLayer.dispose()
// Clear the route lines first (removes them from the map), then mass-remove the rest.
routePolylineReconciler.clear()
map.removeAnnotations()

staticAnnotations.clear()
routePolylines.clear()
renderedRoutePolylines = emptyList()
renderedRouteWidths = emptyList()
vehicleMarkersByTripId.clear()
tripMarkersByRole.clear()
bandPolylines.clear()
Expand Down
Loading