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 the board in the web board's own glyphs
e1f6192 · Gabriel Morell · 2026-09-02 20:19
Message
{commit_body(@commit)}
Files changed
modified
clients/android/README.md
+18
−0
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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