Skip to content

Commit c758ef7

Browse files
bmanderclaude
andauthored
Restyle the selected route-stop circle (bullseye) (#1935)
* Restyle the selected route-stop circle (bullseye) Give the focused/selected route-stop circle a clearer, theme-aware look now that selecting a stop drops straight into the route-focus (bullseye) view: - Selected circle fills orange (the shared map_stop_focus highlight) instead of white, with a smaller center dot in the outline color. - Route-stop circle fill and outline are now theme-aware resources (route_stop_fill / route_stop_outline): white fill + dark-gray ring in light mode, near-black (#4D4D4D) fill + white ring in dark mode. - Selected fill (map_stop_focus) is theme-aware too: lighter #FFA726 in light mode, deeper #FB8C00 in dark mode. - The selected circle is enlarged (FOCUSED_SCALE 1.8) but its ring keeps the same on-screen weight as every other stop (the selection scale is divided back out of the stroke on both platforms). - Thinner base ring (STROKE_WIDTH_PX 2.7) and larger center dot (INNER_RADIUS_SCALE 0.36). Colors are resolved from Context in each renderer and passed into the layers; HomeActivity recreates on a light<->dark switch so there is no stale bitmap cache. Applied consistently to both the Google and MapLibre map flavors. * Update GoogleRouteStopBitmapLayerTest for FOCUSED_SCALE 1.5 -> 1.8 The selected route-stop circle now scales by FOCUSED_SCALE 1.8 (was 1.5), so every `selected = true` expectation in the bitmap-diameter test was stale and failing CI. Recompute them (they scale by 1.8/1.5 = 1.2): fixed selected 90 -> 108, and the ramp's selected samples 27 -> 32, 59 -> 70, 90 -> 108. The `selected = false` values are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 99a133d commit c758ef7

8 files changed

Lines changed: 106 additions & 29 deletions

File tree

onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/GoogleMapRenderer.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,13 @@ class GoogleMapRenderer(
103103
private val stopMarkerLayer = GoogleStopMarkerLayer(map, context)
104104
// The GoogleRouteStopLayer seam keeps route-stop drawing strategies swappable behind one line.
105105
private val routeStopLayer: GoogleRouteStopLayer =
106-
GoogleRouteStopBitmapLayer(map, context.resources.displayMetrics.density)
106+
GoogleRouteStopBitmapLayer(
107+
map,
108+
context.resources.displayMetrics.density,
109+
ContextCompat.getColor(context, R.color.route_stop_fill),
110+
ContextCompat.getColor(context, R.color.map_stop_focus),
111+
ContextCompat.getColor(context, R.color.route_stop_outline),
112+
)
107113
private val bikeByMarker = HashMap<Marker, BikeMarker>()
108114

109115
private val vehicleByMarker = HashMap<Marker, VehicleMarker>()

onebusaway-android/src/google/java/org/onebusaway/android/map/googlemapsv2/GoogleRouteStopBitmapLayer.kt

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ package org.onebusaway.android.map.googlemapsv2
1717

1818
import android.graphics.Bitmap
1919
import android.graphics.Canvas
20-
import android.graphics.Color
2120
import android.graphics.Paint
2221
import androidx.core.graphics.createBitmap
2322
import com.google.android.gms.maps.GoogleMap
@@ -40,6 +39,9 @@ import org.onebusaway.android.map.render.stopZIndex
4039
internal class GoogleRouteStopBitmapLayer(
4140
private val map: GoogleMap,
4241
private val density: Float,
42+
private val fillColor: Int,
43+
private val selectedFillColor: Int,
44+
private val outlineColor: Int,
4345
) : GoogleRouteStopLayer {
4446
private data class RenderedStop(
4547
val marker: Marker,
@@ -163,7 +165,14 @@ internal class GoogleRouteStopBitmapLayer(
163165

164166
private fun icon(diameterPx: Int, selected: Boolean): BitmapDescriptor =
165167
icons.getOrPut(IconKey(diameterPx, selected)) {
166-
BitmapDescriptorFactory.fromBitmap(drawRouteStopBitmap(diameterPx, selected))
168+
BitmapDescriptorFactory.fromBitmap(
169+
drawRouteStopBitmap(
170+
diameterPx,
171+
selected,
172+
if (selected) selectedFillColor else fillColor,
173+
outlineColor,
174+
)
175+
)
167176
}
168177

169178
private fun zIndex(selected: Boolean): Float =
@@ -190,20 +199,28 @@ internal fun routeStopDiameterPx(
190199
.coerceAtLeast(1)
191200
}
192201

193-
private fun drawRouteStopBitmap(diameterPx: Int, selected: Boolean): Bitmap {
202+
private fun drawRouteStopBitmap(
203+
diameterPx: Int,
204+
selected: Boolean,
205+
fillColor: Int,
206+
outlineColor: Int,
207+
): Bitmap {
194208
val bitmap = createBitmap(diameterPx, diameterPx)
195209
val canvas = Canvas(bitmap)
196210
val center = diameterPx / 2f
197211
val scale = diameterPx / (2f * RouteStopCircles.RADIUS_PX)
198-
val strokeWidth = RouteStopCircles.STROKE_WIDTH_PX * scale
212+
// The selected circle is drawn at FOCUSED_SCALE, but its ring keeps the same on-screen weight as
213+
// every other stop — divide the selection scale back out of the stroke (not the diameter).
214+
val selectedScale = if (selected) RouteStopCircles.FOCUSED_SCALE else 1f
215+
val strokeWidth = RouteStopCircles.STROKE_WIDTH_PX * scale / selectedScale
199216
val radius = center - strokeWidth / 2f
200217
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
201218

202-
paint.color = Color.WHITE
219+
paint.color = fillColor
203220
paint.style = Paint.Style.FILL
204221
canvas.drawCircle(center, center, radius, paint)
205222

206-
paint.color = RouteStopCircles.STROKE_COLOR
223+
paint.color = outlineColor
207224
paint.style = Paint.Style.STROKE
208225
paint.strokeWidth = strokeWidth
209226
canvas.drawCircle(center, center, radius, paint)

onebusaway-android/src/main/java/org/onebusaway/android/map/render/RouteStopCircles.kt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@
1515
*/
1616
package org.onebusaway.android.map.render
1717

18-
import android.graphics.Color
19-
2018
/** Shared screen-space styling for GPU/native route-stop circles in both map flavors. */
2119
object RouteStopCircles {
2220
const val RADIUS_PX = 10f
23-
const val STROKE_WIDTH_PX = 3f
24-
const val FOCUSED_SCALE = 1.5f
25-
const val INNER_RADIUS_SCALE = 0.4f
21+
const val STROKE_WIDTH_PX = 2.7f
22+
const val FOCUSED_SCALE = 1.8f
23+
const val INNER_RADIUS_SCALE = 0.36f
2624

27-
const val FILL_COLOR = Color.WHITE
28-
const val STROKE_COLOR: Int = TripMarkerBitmaps.STROKE_COLOR
25+
// All three route-stop circle colors are theme-aware resources resolved by the flavor layers (this
26+
// pure styling layer has no Context): the unselected fill `R.color.route_stop_fill`, the outline
27+
// `R.color.route_stop_outline`, and the selected fill `R.color.map_stop_focus` (the shared
28+
// selected-stop highlight — lighter in light mode, deeper in dark mode).
2929
}

onebusaway-android/src/main/res/values-night/colors.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@
2424
<color name="body_text_2">#8affffff</color>
2525
<color name="body_text_disabled">#44ffffff</color>
2626

27+
<!-- Route-stop circle outline is white in dark mode for contrast against the dark base map. -->
28+
<color name="route_stop_outline">#FFFFFF</color>
29+
30+
<!-- Route-stop circle fill is a 70% gray in dark mode (paired with the white outline). -->
31+
<color name="route_stop_fill">#4D4D4D</color>
32+
33+
<!-- The selected-stop highlight is a deeper orange in dark mode. -->
34+
<color name="map_stop_focus">#FB8C00</color>
35+
2736

2837
<color name="stop_info_arrival_list_background">@android:color/black</color>
2938

onebusaway-android/src/main/res/values/colors.xml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,17 @@
2626
<color name="theme_primary_variant">@color/brand_color_dark</color>
2727
<color name="theme_accent">#5db53b</color>
2828

29-
<!-- The focused/selected map stop highlight: the selected_map_stop_icon.xml fill + the focused dot. -->
30-
<color name="map_stop_focus">#FF9500</color>
29+
<!-- The focused/selected map stop highlight: the selected_map_stop_icon.xml fill + the focused dot +
30+
the selected route-stop circle fill. Lighter in light mode, deeper in dark mode (see values-night). -->
31+
<color name="map_stop_focus">#FFA726</color>
32+
33+
<!-- Route-stop circle outline (ring + selected inner dot): dark in light mode, white in dark mode
34+
(see values-night). Same for selected and unselected stops. -->
35+
<color name="route_stop_outline">#616161</color>
36+
37+
<!-- Route-stop circle fill (unselected): white in light mode, black in dark mode (see values-night).
38+
Selected stops override this with the orange focus fill. -->
39+
<color name="route_stop_fill">#FFFFFF</color>
3140

3241
<!-- The starred (favorite) stop marker's star: a gold gradient (light top, dark bottom), #1680. -->
3342
<color name="map_stop_favorite_light">#FFD54F</color>

onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/MapLibreRenderer.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,14 @@ class MapLibreRenderer(
166166
private var pingStart: WallTime? = null
167167
private val pingColor by lazy { ContextCompat.getColor(context, R.color.theme_primary) }
168168
private val density = context.resources.displayMetrics.density
169-
private val routeStopCircleLayer = MapLibreRouteStopCircleLayer(map, mapStyle, density)
169+
private val routeStopCircleLayer = MapLibreRouteStopCircleLayer(
170+
map,
171+
mapStyle,
172+
density,
173+
ContextCompat.getColor(context, R.color.route_stop_fill),
174+
ContextCompat.getColor(context, R.color.map_stop_focus),
175+
ContextCompat.getColor(context, R.color.route_stop_outline),
176+
)
170177
// Reused across the ripple's frames — redrawn in place rather than reallocated each frame (the ring
171178
// is a bitmap because the classic annotation API has no circle). Freed with the ping in clearPing.
172179
private var pingBitmap: Bitmap? = null

onebusaway-android/src/maplibre/java/org/onebusaway/android/map/maplibre/MapLibreRouteStopCircleLayer.kt

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@ import org.maplibre.android.geometry.LatLng
2020
import org.maplibre.android.maps.MapLibreMap
2121
import org.maplibre.android.maps.Style
2222
import org.maplibre.android.style.expressions.Expression
23+
import org.maplibre.android.style.expressions.Expression.color
2324
import org.maplibre.android.style.expressions.Expression.eq
2425
import org.maplibre.android.style.expressions.Expression.get
2526
import org.maplibre.android.style.expressions.Expression.interpolate
2627
import org.maplibre.android.style.expressions.Expression.linear
2728
import org.maplibre.android.style.expressions.Expression.literal
2829
import org.maplibre.android.style.expressions.Expression.product
2930
import org.maplibre.android.style.expressions.Expression.stop
31+
import org.maplibre.android.style.expressions.Expression.switchCase
3032
import org.maplibre.android.style.expressions.Expression.zoom
3133
import org.maplibre.android.style.layers.CircleLayer
3234
import org.maplibre.android.style.layers.PropertyFactory.circleColor
@@ -53,6 +55,9 @@ internal class MapLibreRouteStopCircleLayer(
5355
private val map: MapLibreMap,
5456
private val style: Style,
5557
private val density: Float,
58+
private val fillColor: Int,
59+
private val selectedFillColor: Int,
60+
private val outlineColor: Int,
5661
) {
5762
private val source = GeoJsonSource(
5863
SOURCE_ID,
@@ -68,10 +73,22 @@ internal class MapLibreRouteStopCircleLayer(
6873
style.addLayer(
6974
CircleLayer(OUTER_LAYER_ID, SOURCE_ID).withProperties(
7075
circleRadius(radiusExpression()),
71-
circleColor(RouteStopCircles.FILL_COLOR),
72-
circleStrokeColor(RouteStopCircles.STROKE_COLOR),
76+
circleColor(
77+
switchCase(
78+
eq(get(SELECTED_PROPERTY), true),
79+
color(selectedFillColor),
80+
color(fillColor),
81+
)
82+
),
83+
circleStrokeColor(outlineColor),
84+
// Stroke width rides the base (non-selection-scaled) radius so the selected circle
85+
// keeps the same ring weight as every other stop even though it's drawn larger.
7386
circleStrokeWidth(
74-
radiusExpression(RouteStopCircles.STROKE_WIDTH_PX / RouteStopCircles.RADIUS_PX)
87+
radiusExpression(
88+
STROKE_MIN_RADIUS_PROPERTY,
89+
STROKE_MAX_RADIUS_PROPERTY,
90+
RouteStopCircles.STROKE_WIDTH_PX / RouteStopCircles.RADIUS_PX,
91+
)
7592
),
7693
circleSortKey(get(MAX_RADIUS_PROPERTY)),
7794
)
@@ -80,8 +97,8 @@ internal class MapLibreRouteStopCircleLayer(
8097
CircleLayer(INNER_LAYER_ID, SOURCE_ID)
8198
.withFilter(eq(get(SELECTED_PROPERTY), true))
8299
.withProperties(
83-
circleRadius(radiusExpression(RouteStopCircles.INNER_RADIUS_SCALE)),
84-
circleColor(RouteStopCircles.STROKE_COLOR),
100+
circleRadius(radiusExpression(scale = RouteStopCircles.INNER_RADIUS_SCALE)),
101+
circleColor(outlineColor),
85102
circleSortKey(get(MAX_RADIUS_PROPERTY)),
86103
)
87104
)
@@ -110,6 +127,12 @@ internal class MapLibreRouteStopCircleLayer(
110127
RouteStopCircles.RADIUS_PX * stopFocusMinScale * selectedScale,
111128
)
112129
addNumberProperty(MAX_RADIUS_PROPERTY, RouteStopCircles.RADIUS_PX * selectedScale)
130+
// Base radii without the selection scale, so the stroke-width ramp stays constant weight.
131+
addNumberProperty(
132+
STROKE_MIN_RADIUS_PROPERTY,
133+
RouteStopCircles.RADIUS_PX * stopFocusMinScale,
134+
)
135+
addNumberProperty(STROKE_MAX_RADIUS_PROPERTY, RouteStopCircles.RADIUS_PX)
113136
}
114137
}
115138
source.setGeoJson(FeatureCollection.fromFeatures(features))
@@ -147,11 +170,15 @@ internal class MapLibreRouteStopCircleLayer(
147170
* top-level step or interpolate expression"), so keeping `zoom()` at the top level is required;
148171
* scaling the stop outputs is mathematically equivalent for a linear interpolation. (#1927)
149172
*/
150-
private fun radiusExpression(scale: Float = 1f): Expression = interpolate(
173+
private fun radiusExpression(
174+
minProperty: String = MIN_RADIUS_PROPERTY,
175+
maxProperty: String = MAX_RADIUS_PROPERTY,
176+
scale: Float = 1f,
177+
): Expression = interpolate(
151178
linear(),
152179
zoom(),
153-
stop(DETAIL_RAMP_START_ZOOM, scaledRadius(MIN_RADIUS_PROPERTY, scale)),
154-
stop(DETAIL_RAMP_END_ZOOM, scaledRadius(MAX_RADIUS_PROPERTY, scale)),
180+
stop(DETAIL_RAMP_START_ZOOM, scaledRadius(minProperty, scale)),
181+
stop(DETAIL_RAMP_END_ZOOM, scaledRadius(maxProperty, scale)),
155182
)
156183

157184
private fun scaledRadius(property: String, scale: Float): Expression =
@@ -165,6 +192,8 @@ internal class MapLibreRouteStopCircleLayer(
165192
const val SELECTED_PROPERTY = "selected"
166193
const val MIN_RADIUS_PROPERTY = "minRadius"
167194
const val MAX_RADIUS_PROPERTY = "maxRadius"
195+
const val STROKE_MIN_RADIUS_PROPERTY = "strokeMinRadius"
196+
const val STROKE_MAX_RADIUS_PROPERTY = "strokeMaxRadius"
168197
const val TAP_RADIUS_DP = 12f
169198
}
170199
}

onebusaway-android/src/testGoogle/java/org/onebusaway/android/map/googlemapsv2/GoogleRouteStopBitmapLayerTest.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,17 @@ class GoogleRouteStopBitmapLayerTest {
2222
@Test
2323
fun `ordinary route mode keeps the normal fixed bitmap size`() {
2424
assertEquals(60, diameter(zoom = 10f, stopFocused = false, selected = false))
25-
assertEquals(90, diameter(zoom = 18f, stopFocused = false, selected = true))
25+
assertEquals(108, diameter(zoom = 18f, stopFocused = false, selected = true))
2626
}
2727

2828
@Test
2929
fun `stop focus bitmap size follows the zoom ramp`() {
3030
assertEquals(18, diameter(zoom = 10f, stopFocused = true, selected = false))
31-
assertEquals(27, diameter(zoom = 11f, stopFocused = true, selected = true))
31+
assertEquals(32, diameter(zoom = 11f, stopFocused = true, selected = true))
3232
assertEquals(39, diameter(zoom = 13.5f, stopFocused = true, selected = false))
33-
assertEquals(59, diameter(zoom = 13.5f, stopFocused = true, selected = true))
33+
assertEquals(70, diameter(zoom = 13.5f, stopFocused = true, selected = true))
3434
assertEquals(60, diameter(zoom = 16f, stopFocused = true, selected = false))
35-
assertEquals(90, diameter(zoom = 18f, stopFocused = true, selected = true))
35+
assertEquals(108, diameter(zoom = 18f, stopFocused = true, selected = true))
3636
}
3737

3838
private fun diameter(zoom: Float, stopFocused: Boolean, selected: Boolean): Int =

0 commit comments

Comments
 (0)