neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Draw a car share as cars
0a726e7 · Gabriel Morell · 2026-09-04 16:02
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