Draw a car share as cars

0a726e7 · Gabriel Morell · 2026-09-04 16:02

35 files +161 -3
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+14 −0
@@ -172,6 +172,10 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
172 172 %{
173 173 kind: :free_bike,
174 174 name: vehicle_label(vehicle_types, b) || bike_id,
175 + # What kind of thing it is, so a client can draw a car as a car.
176 + # The name already says "Car", but a name is read and an icon is
177 + # glanced at, and these cards are built to be glanced at.
178 + form_factor: vehicle_form_factor(vehicle_types, b),
175 179 id: bike_id,
176 180 lat: b.lat,
177 181 lon: b.lon,
@@ -386,6 +390,16 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
386 390 end)
387 391 end
388 392
393 + # Straight off the vehicle type, in GBFS's own words -- "car", "moped",
394 + # "scooter_standing". Left untranslated because the client is choosing a
395 + # glyph from it rather than showing it.
396 + defp vehicle_form_factor(vehicle_types, bike) do
397 + case Map.get(vehicle_types, Map.get(bike, :vehicle_type_id)) do
398 + %{form_factor: form_factor} -> form_factor
399 + _ -> nil
400 + end
401 + end
402 +
389 403 defp vehicle_label(vehicle_types, bike) do
390 404 vehicle_types
391 405 |> Map.get(Map.get(bike, :vehicle_type_id))
modified apps/room_sanctum/lib/room_sanctum_web/live/live_preview.ex
+14 −1
@@ -557,13 +557,26 @@ use PhoenixHTMLHelpers
557 557 # is what kind it is and how much of it is left.
558 558 defp free_bike?(entry), do: Map.get(entry, :kind) == :free_bike
559 559
560 + # A GBFS feed is not always bicycles. There is no kick scooter in the free
561 + # icon set, so those keep the bicycle rather than being drawn as something
562 + # they are even less like.
563 + defp gbfs_icon(entry) do
564 + case Map.get(entry, :form_factor) do
565 + "car" -> "fa-car"
566 + "moped" -> "fa-motorcycle"
567 + _ -> "fa-bicycle"
568 + end
569 + end
570 +
560 571 def p_gbfs(assigns) do
561 572 ~H"""
562 573 <%= for e <- @entries.data do %>
563 574 <div class="card card-compact w-full bg-primary text-primary-content shadow-xl">
564 575 <div class="card-body text-left">
565 576 <h2 class="card-title">
566 − <p><i class="fa-solid fa-fw fa-bicycle"></i> <%= e.name %> </p>
577 + <%!-- Not always a bicycle: Getaround publishes cars, others mopeds.
578 + Only where the free set has a truthful glyph. --%>
579 + <p><i class={"fa-solid fa-fw #{gbfs_icon(e)}"}></i> <%= e.name %> </p>
567 580 <%!-- Which way to walk. Only a Plani sends it: a vision's foci is a
568 581 fixed place, so there is nothing for a bearing to be relative
569 582 to, and it is absent for anything close enough to have no
modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GbfsRenderer.kt
+50 −2
@@ -49,9 +49,25 @@ object GbfsRenderer : SourceRenderer {
49 49 * has only its charge. The web board stamps the same three things --
50 50 * bikes, a bolt for the electric ones, and parking for the docks.
51 51 */
52 + /**
53 + * The glyph for what this actually is.
54 + *
55 + * A GBFS feed is not always bicycles -- Getaround publishes cars, other
56 + * operators mopeds -- and a car share drawn as a row of bicycles is wrong
57 + * in the way a glance cannot recover from, because nobody reads the label
58 + * first. Only where the free icon set has a truthful glyph: it has no kick
59 + * scooter, and drawing a standing scooter as a motorcycle would trade one
60 + * wrong picture for another.
61 + */
62 + private fun formIcon(form: String?): Int = when (form) {
63 + "car" -> R.drawable.fa_car
64 + "moped" -> R.drawable.fa_motorcycle
65 + else -> R.drawable.fa_bicycle
66 + }
67 +
52 68 private fun row(station: GbfsCondensed, named: Boolean): Row = when {
53 69 station.isFreeBike() -> Row(
54 − iconRes = R.drawable.fa_bicycle,
70 + iconRes = formIcon(station.form_factor),
55 71 text = if (named) station.name else "",
56 72 stamps = buildList {
57 73 addAll(bearing(station))
@@ -88,9 +104,41 @@ object GbfsRenderer : SourceRenderer {
88 104 private fun bearing(station: GbfsCondensed): List<Stamp> =
89 105 station.dir?.let { listOf(Stamp(R.drawable.fa_location_arrow, it, flat = it)) }.orEmpty()
90 106
107 + /**
108 + * What to call them, when they are all the same kind of thing.
109 + *
110 + * "4 bikes nearby" is wrong for a car share and "4 vehicles nearby" is
111 + * clumsy for a bike share, so the fleet decides. A mixed fleet, or one
112 + * whose feed publishes no vehicle types, gets the neutral word rather than
113 + * the majority's -- being vague is recoverable, being confidently wrong is
114 + * not.
115 + */
116 + private fun noun(stations: List<GbfsCondensed>, plural: Boolean): String {
117 + val forms = stations.map { it.form_factor }.distinct()
118 +
119 + // Nothing said what these are. Almost every GBFS feed is bicycles and
120 + // this renderer's own card is titled Bikeshare, so the old word stays
121 + // the default -- calling a bike share "vehicles" for want of a field
122 + // would make every existing source read worse to no purpose.
123 + if (forms == listOf(null)) return if (plural) "bikes" else "bike"
124 +
125 + return when (forms.singleOrNull()) {
126 + "car" -> if (plural) "cars" else "car"
127 + "moped" -> if (plural) "mopeds" else "moped"
128 + "scooter_standing", "scooter_seated", "scooter" ->
129 + if (plural) "scooters" else "scooter"
130 + "bicycle", "cargo_bicycle" -> if (plural) "bikes" else "bike"
131 + else -> if (plural) "vehicles" else "vehicle"
132 + }
133 + }
134 +
91 135 private fun summary(stations: List<GbfsCondensed>): String? {
92 136 if (stations.isEmpty()) return null
93 − if (stations.all { it.isFreeBike() }) return "${stations.size} bikes nearby"
137 +
138 + if (stations.all { it.isFreeBike() }) {
139 + return "${stations.size} ${noun(stations, stations.size != 1)} nearby"
140 + }
141 +
94 142 val bikes = stations.sumOf { it.avail ?: 0 }
95 143 val electric = stations.sumOf { it.avail_elec ?: 0 }
96 144 return if (electric > 0) "$bikes bikes · $electric electric" else "$bikes bikes"
modified clients/android/app/src/main/java/io/neiam/apolloscrib/types/Gbfs.kt
+6 −0
@@ -21,6 +21,12 @@ data class GbfsCondensed(
21 21 val ebikes_info: List<EbikeInfo> = emptyList(),
22 22 // Free-bike shape.
23 23 val kind: String? = null,
24 + /**
25 + * What kind of vehicle, in GBFS's own words: "bicycle", "cargo_bicycle",
26 + * "scooter_standing", "moped", "car". Present where the feed publishes
27 + * vehicle types.
28 + */
29 + val form_factor: String? = null,
24 30 val lat: Double? = null,
25 31 val lon: Double? = null,
26 32 val range_m: Double? = null,
added clients/android/app/src/main/res/drawable/fa_car.xml
+12 −0
@@ -0,0 +1,12 @@
1 +<!-- Generated by tools/fa_to_vector.py from Font Awesome Free 6.2.0 (car).
2 + Font Awesome Free icons are CC BY 4.0. Do not edit by hand. -->
3 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
4 + android:width="24dp"
5 + android:height="24dp"
6 + android:viewportWidth="512"
7 + android:viewportHeight="512"
8 + android:tint="?android:attr/colorControlNormal">
9 + <path
10 + android:fillColor="@android:color/white"
11 + android:pathData="M135.2 117.4L109.1 192H402.9l-26.1-74.6C372.3 104.6 360.2 96 346.6 96H165.4c-13.6 0-25.7 8.6-30.2 21.4zM39.6 196.8L74.8 96.3C88.3 57.8 124.6 32 165.4 32H346.6c40.8 0 77.1 25.8 90.6 64.3l35.2 100.5c23.2 9.6 39.6 32.5 39.6 59.2V400v48c0 17.7-14.3 32-32 32H448c-17.7 0-32-14.3-32-32V400H96v48c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32V400 256c0-26.7 16.4-49.6 39.6-59.2zM128 288c0-17.7-14.3-32-32-32s-32 14.3-32 32s14.3 32 32 32s32-14.3 32-32zm288 32c17.7 0 32-14.3 32-32s-14.3-32-32-32s-32 14.3-32 32s14.3 32 32 32z" />
12 +</vector>
added clients/android/app/src/main/res/drawable/fa_motorcycle.xml
+12 −0
@@ -0,0 +1,12 @@
1 +<!-- Generated by tools/fa_to_vector.py from Font Awesome Free 6.2.0 (motorcycle).
2 + Font Awesome Free icons are CC BY 4.0. Do not edit by hand. -->
3 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
4 + android:width="24dp"
5 + android:height="24dp"
6 + android:viewportWidth="640"
7 + android:viewportHeight="512"
8 + android:tint="?android:attr/colorControlNormal">
9 + <path
10 + android:fillColor="@android:color/white"
11 + android:pathData="M280 32c-13.3 0-24 10.7-24 24s10.7 24 24 24h57.7l16.4 30.3L256 192l-45.3-45.3c-12-12-28.3-18.7-45.3-18.7H64c-17.7 0-32 14.3-32 32v32h96c88.4 0 160 71.6 160 160c0 11-1.1 21.7-3.2 32h70.4c-2.1-10.3-3.2-21-3.2-32c0-52.2 25-98.6 63.7-127.8l15.4 28.6C402.4 276.3 384 312 384 352c0 70.7 57.3 128 128 128s128-57.3 128-128s-57.3-128-128-128c-13.5 0-26.5 2.1-38.7 6L418.2 128H480c17.7 0 32-14.3 32-32V64c0-17.7-14.3-32-32-32H459.6c-7.5 0-14.7 2.6-20.5 7.4L391.7 78.9l-14-26c-7-12.9-20.5-21-35.2-21H280zM462.7 311.2l28.2 52.2c6.3 11.7 20.9 16 32.5 9.7s16-20.9 9.7-32.5l-28.2-52.2c2.3-.3 4.7-.4 7.1-.4c35.3 0 64 28.7 64 64s-28.7 64-64 64s-64-28.7-64-64c0-15.5 5.5-29.7 14.7-40.8zM187.3 376c-9.5 23.5-32.5 40-59.3 40c-35.3 0-64-28.7-64-64s28.7-64 64-64c26.9 0 49.9 16.5 59.3 40h66.4C242.5 268.8 190.5 224 128 224C57.3 224 0 281.3 0 352s57.3 128 128 128c62.5 0 114.5-44.8 125.8-104H187.3zM128 384c17.7 0 32-14.3 32-32s-14.3-32-32-32s-32 14.3-32 32s14.3 32 32 32z" />
12 +</vector>
modified clients/android/app/src/test/java/io/neiam/apolloscrib/types/WireTest.kt
+49 −0
@@ -1,5 +1,6 @@
1 1 package io.neiam.apolloscrib.types
2 2
3 +import io.neiam.apolloscrib.targets.Targets
3 4 import org.junit.Assert.assertEquals
4 5 import org.junit.Assert.assertNull
5 6 import org.junit.Assert.assertTrue
@@ -143,6 +144,54 @@ class WireTest {
143 144 assertTrue(bikes[1].isFreeBike())
144 145 }
145 146
147 + @Test
148 + fun `a car share is cars, not bikes`() {
149 + // Getaround Barcelona publishes 293 cars over GBFS. Nothing about the
150 + // format says bicycle.
151 + val payload = """
152 + {"gbfs-9": [
153 + {"kind":"free_bike","name":"Car","id":"YGA:Vehicle:59615e46d7db",
154 + "lat":41.379253,"lon":2.145276,"form_factor":"car"},
155 + {"kind":"free_bike","name":"Car","id":"YGA:Vehicle:4a0c7e18f6e1",
156 + "lat":41.38,"lon":2.14,"form_factor":"car"}
157 + ]}
158 + """.trimIndent()
159 +
160 + val entry = Wire.parse(payload).single()
161 + val rows = entry.decode<List<GbfsCondensed>>()!!
162 +
163 + assertEquals("car", rows[0].form_factor)
164 +
165 + // And what the card actually says, which is the point of carrying it.
166 + val preview = Targets.preview(entry).single()
167 + assertEquals("2 cars nearby", preview.subtitle)
168 + }
169 +
170 + @Test
171 + fun `a fleet of mixed kinds is described neutrally`() {
172 + val payload = """
173 + {"gbfs-9": [
174 + {"kind":"free_bike","name":"Car","id":"c1","form_factor":"car"},
175 + {"kind":"free_bike","name":"Bike","id":"b1","form_factor":"bicycle"}
176 + ]}
177 + """.trimIndent()
178 +
179 + // Being vague is recoverable; being confidently wrong is not.
180 + val preview = Targets.preview(Wire.parse(payload).single()).single()
181 + assertEquals("2 vehicles nearby", preview.subtitle)
182 + }
183 +
184 + @Test
185 + fun `a feed with no vehicle types still reads as bikes`() {
186 + // The overwhelming majority of GBFS feeds, and every one published
187 + // before this field existed. Saying "vehicles" here would make them
188 + // all read worse in exchange for nothing.
189 + val payload = """{"gbfs-9": [{"kind":"free_bike","name":"E-bike","id":"b1"}]}"""
190 +
191 + val preview = Targets.preview(Wire.parse(payload).single()).single()
192 + assertEquals("1 bike nearby", preview.subtitle)
193 + }
194 +
146 195 @Test
147 196 fun `a Plani stamps which way to walk, and a vision does not`() {
148 197 val payload = """
modified clients/android/tools/fa_to_vector.py
+4 −0
@@ -36,6 +36,10 @@ ICONS = [
36 36 "bicycle", "bolt-lightning", "square-parking", "battery-half",
37 37 # Which way to walk to it, on a Plani's cards.
38 38 "location-arrow",
39 + # A GBFS feed is not always bicycles: Getaround publishes cars, and other
40 + # operators mopeds. Only where the free set has a truthful glyph -- there
41 + # is no kick scooter in it, so those stay on the bicycle.
42 + "car", "motorcycle",
39 43 # Alerts: the web uses a pin when the alert names the stop.
40 44 "location-dot", "triangle-exclamation",
41 45 # Tides: the web marks a high and a low with arrows to a line.

Parents: efd60b3