Draw the board in the web board's own glyphs

e1f6192 · Gabriel Morell · 2026-09-02 20:19

49 files +546 -38
Message
{commit_body(@commit)}

Files changed

modified clients/android/README.md
+18 −0
@@ -105,3 +105,21 @@ The palette and typography are lifted from the DMS Android app: the same OKLCh
105 105 themes ("Her", "After Dark", "Forest" ...) that the Scribus public routes render
106 106 under, and B612 for text. The Smartspace targets themselves are drawn by the
107 107 launcher and are not ours to type.
108 +
109 +The board's glyphs are the ones the web board draws -- a bus for a bus, a
110 +subway for a subway, a broadcast tower on a time the feed gave and a clock on
111 +one the timetable did, a pin on an alert that names your stop. They are not
112 +lookalikes: `tools/fa_to_vector.py` reads the Font Awesome metadata already
113 +vendored at `clients/lilygo4.7/fontawesome-free-6.2.0-desktop` -- the same
114 +source `RoomSanctumWeb.IconHelpers` and `live_preview.ex` name -- and emits the
115 +same paths as Android drawables. Regenerate with:
116 +
117 +```sh
118 +python3 tools/fa_to_vector.py
119 +```
120 +
121 +Font Awesome Free icons are CC BY 4.0; the vendored `LICENSE.txt` covers them.
122 +
123 +A Smartspace list row is plain text, so the glyphs are the app's alone. Rows
124 +carry a text form for that surface -- `8b 1e 7d` rather than `8 1 7`, which is
125 +not a number of bikes, e-bikes and docks to anybody reading it.
modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GbfsRenderer.kt
+30 −12
@@ -15,7 +15,7 @@ import io.neiam.apolloscrib.types.VisionEntry
15 15 object GbfsRenderer : SourceRenderer {
16 16
17 17 override val type = SourceType.Gbfs
18 − override val iconRes = R.drawable.ic_bike
18 + override val iconRes = R.drawable.fa_bicycle
19 19 override val label = "Apollo's Crib: Bikeshare"
20 20 override val description = "Bikes and docks at a station in one of your visions"
21 21
@@ -32,22 +32,40 @@ object GbfsRenderer : SourceRenderer {
32 32 title = entry.label(),
33 33 subtitle = summary(ordered),
34 34 iconRes = iconRes,
35 − items = ordered.map { line(it) },
35 + rows = ordered.map { row(it) },
36 36 empty = "No bikes nearby"
37 37 )
38 38 )
39 39 }
40 40
41 − private fun line(station: GbfsCondensed): String = when {
42 − station.isFreeBike() -> {
43 − val charge = station.fuel_pct?.let { " ${(it * 100).toInt()}%" }.orEmpty()
44 − "${station.name}$charge"
45 − }
46 − else -> {
47 − val bikes = station.avail ?: 0
48 − val docks = station.docks_avail ?: 0
49 − "${station.name} ${bikes}b ${docks}d"
50 − }
41 + /**
42 + * A dock counts what is in it and what is free to return to; a loose bike
43 + * has only its charge. The web board stamps the same three things --
44 + * bikes, a bolt for the electric ones, and parking for the docks.
45 + */
46 + private fun row(station: GbfsCondensed): Row = when {
47 + station.isFreeBike() -> Row(
48 + iconRes = R.drawable.fa_bicycle,
49 + text = station.name,
50 + stamps = station.fuel_pct?.let {
51 + listOf(Stamp(R.drawable.fa_battery_half, "${(it * 100).toInt()}%"))
52 + }.orEmpty()
53 + )
54 +
55 + else -> Row(
56 + iconRes = R.drawable.fa_bicycle,
57 + text = station.name,
58 + stamps = buildList {
59 + val bikes = station.avail ?: 0
60 + add(Stamp(R.drawable.fa_bicycle, "$bikes", flat = "${bikes}b"))
61 + val electric = station.avail_elec ?: 0
62 + if (electric > 0) {
63 + add(Stamp(R.drawable.fa_bolt_lightning, "$electric", flat = "${electric}e"))
64 + }
65 + val docks = station.docks_avail ?: 0
66 + add(Stamp(R.drawable.fa_square_parking, "$docks", flat = "${docks}d"))
67 + }
68 + )
51 69 }
52 70
53 71 private fun summary(stations: List<GbfsCondensed>): String? {
modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GtfsRenderer.kt
+44 −9
@@ -1,6 +1,7 @@
1 1 package io.neiam.apolloscrib.targets
2 2
3 3 import io.neiam.apolloscrib.R
4 +import io.neiam.apolloscrib.types.AlertCondensed
4 5 import io.neiam.apolloscrib.types.GtfsCondensed
5 6 import io.neiam.apolloscrib.types.SourceType
6 7 import io.neiam.apolloscrib.types.VisionEntry
@@ -19,7 +20,7 @@ import io.neiam.apolloscrib.types.VisionEntry
19 20 object GtfsRenderer : SourceRenderer {
20 21
21 22 override val type = SourceType.Gtfs
22 − override val iconRes = R.drawable.ic_transit
23 + override val iconRes = R.drawable.fa_bus_simple
23 24 override val label = "Apollo's Crib: Transit"
24 25 override val description = "Next departures from a stop in one of your visions"
25 26
@@ -34,19 +35,33 @@ object GtfsRenderer : SourceRenderer {
34 35 id = entry.key,
35 36 title = entry.label(),
36 37 subtitle = summary(soonest),
37 − iconRes = iconRes,
38 − items = soonest.map { line(it) },
38 + // The stop's own glyph is whatever mostly calls there -- a bus
39 + // stop and a subway platform should not look alike.
40 + iconRes = modeIcon(soonest.firstOrNull()?.mode),
41 + rows = soonest.map { row(it) },
39 42 empty = "Nothing due"
40 43 )
41 44
42 45 return listOfNotNull(alert(entry, routes)) + board
43 46 }
44 47
45 − /** `14 → Heath St 08:00` -- the template truncates around 25 characters. */
46 − private fun line(route: GtfsCondensed): String {
47 − val next = route.departures().take(2).joinToString(" ") { it.asClockTime() }
48 − return "${route.displayName()} ${route.dest} $next"
49 − }
48 + /**
49 + * `[bus] 87 Arlington Center [tower] 15:33 [clock] 15:56` -- each time
50 + * stamped with where it came from, as the web board stamps them. Two
51 + * departures: a third rarely fits the width, and the row after it is
52 + * another route the rider might take instead.
53 + */
54 + private fun row(route: GtfsCondensed): Row = Row(
55 + iconRes = modeIcon(route.mode),
56 + text = "${route.displayName()} ${route.dest}",
57 + stamps = route.times.indices.take(2).map { index ->
58 + val live = route.times_live?.getOrNull(index)
59 + Stamp(
60 + iconRes = if (live != null) R.drawable.fa_tower_broadcast else R.drawable.fa_clock,
61 + text = (live ?: route.times[index]).asClockTime()
62 + )
63 + }
64 + )
50 65
51 66 /** The one thing worth putting on the second line: what leaves first. */
52 67 private fun summary(routes: List<GtfsCondensed>): String? {
@@ -69,11 +84,31 @@ object GtfsRenderer : SourceRenderer {
69 84 id = "${entry.key}-alert",
70 85 title = lead.header ?: lead.effect.humanise(),
71 86 subtitle = entry.label(),
72 − iconRes = R.drawable.ic_alert,
87 + iconRes = alertIcon(lead),
73 88 style = Preview.Style.Basic
74 89 )
75 90 }
76 91
92 + /** A pin when the alert is about this stop, as the web board does it. */
93 + private fun alertIcon(alert: AlertCondensed): Int =
94 + if (alert.stop_specific == true) R.drawable.fa_location_dot
95 + else R.drawable.fa_triangle_exclamation
96 +
97 + /** The same mapping live_preview.ex uses, so a route looks like itself. */
98 + private fun modeIcon(mode: String?): Int = when (mode) {
99 + "Bus" -> R.drawable.fa_bus
100 + "Trolleybus" -> R.drawable.fa_bus_simple
101 + "Subway" -> R.drawable.fa_train_subway
102 + "Rail" -> R.drawable.fa_train
103 + "LightRail" -> R.drawable.fa_train_tram
104 + "Ferry" -> R.drawable.fa_ferry
105 + "CableCar", "Gondola" -> R.drawable.fa_cable_car
106 + "Funicular" -> R.drawable.fa_mountain
107 + // Monorail has no glyph of its own in the free set, and a tram reads
108 + // closer than the web's stand-in does.
109 + else -> R.drawable.fa_train_tram
110 + }
111 +
77 112 /** GTFS-RT spells its enums `NO_SERVICE`. */
78 113 private fun String.humanise(): String =
79 114 lowercase().replace('_', ' ').replaceFirstChar { it.uppercase() }
modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/Renderers.kt
+32 −1
@@ -26,13 +26,44 @@ data class Preview(
26 26 val title: String,
27 27 val subtitle: String?,
28 28 val iconRes: Int,
29 − val items: List<String> = emptyList(),
29 + val rows: List<Row> = emptyList(),
30 30 val empty: String = "Nothing to show",
31 31 val style: Style = Style.List
32 32 ) {
33 33 enum class Style { Basic, List }
34 +
35 + /** The rows as the Smartspace template wants them: one line of text each. */
36 + val items: List<String> get() = rows.map { it.flat() }
37 +}
38 +
39 +/**
40 + * One line of a card: a leading glyph, what it is, and any number of stamped
41 + * values after it.
42 + *
43 + * The shape the web board draws -- a bus for a bus, then each departure behind
44 + * a broadcast tower if the feed gave it and a clock if the timetable did. The
45 + * Smartspace templates take one icon for a whole list and plain strings for
46 + * its rows, so there [flat] is what shows; in the app, where there is room,
47 + * the icons are drawn.
48 + */
49 +data class Row(
50 + val iconRes: Int,
51 + val text: String,
52 + val stamps: List<Stamp> = emptyList()
53 +) {
54 + fun flat(): String =
55 + (listOf(text) + stamps.map { it.flat }).joinToString(" ").trim()
34 56 }
35 57
58 +/**
59 + * A value with a glyph saying what it counts.
60 + *
61 + * [flat] is the same value for somewhere the glyph cannot go -- a Smartspace
62 + * list row is plain text, and "8 1 7" is not a number of bikes, a number of
63 + * e-bikes and a number of docks to anybody reading it.
64 + */
65 +data class Stamp(val iconRes: Int, val text: String, val flat: String = text)
66 +
36 67 /**
37 68 * What one source type looks like.
38 69 *
modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/WeatherRenderer.kt
+1 −1
@@ -15,7 +15,7 @@ import kotlin.math.roundToInt
15 15 object WeatherRenderer : SourceRenderer {
16 16
17 17 override val type = SourceType.Weather
18 − override val iconRes = R.drawable.ic_weather
18 + override val iconRes = R.drawable.fa_cloud_sun
19 19 override val label = "Apollo's Crib: Weather"
20 20 override val description = "Conditions at a focus in one of your visions"
21 21
modified clients/android/app/src/main/java/io/neiam/apolloscrib/ui/BoardScreen.kt
+48 −12

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_battery_half.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_bicycle.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_bolt_lightning.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_bus.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_bus_simple.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_cable_car.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_clock.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_cloud_sun.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_ferry.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_house.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_location_dot.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_lungs.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_moon.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_mountain.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_square_parking.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_tower_broadcast.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_train.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_train_subway.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_train_tram.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_triangle_exclamation.xml
+12 −0

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_water.xml
+12 −0

Click to load diff…

modified clients/android/app/src/test/java/io/neiam/apolloscrib/targets/RendererTest.kt
+30 −3

Click to load diff…

added clients/android/tools/fa_to_vector.py
+91 −0

Click to load diff…

Parents: fb93a66