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.
Say which way a stop is, too
dfde4b2 · Gabriel Morell · 2026-09-03 20:17
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+19
−4
@@ -95,7 +95,8 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
| 95 | 95 | route: f.trip.route_id, |
| 96 | 96 | presentation: route_presentation(f.trip.route, f.trip.route_id), |
| 97 | 97 | mode: f.trip.route.route_type |> gtfs_mode, |
| 98 | − tz: f.tz | |
| 98 | + tz: f.tz, | |
| 99 | + bearing: Map.get(f, :bearing) | |
| 99 | 100 | } |
| 100 | 101 | end) |
| 101 | 102 | |> Enum.reduce(%{}, fn %{ |
@@ -106,7 +107,8 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
| 106 | 107 | route: route, |
| 107 | 108 | presentation: presentation, |
| 108 | 109 | mode: mode, |
| 109 | − tz: tz | |
| 110 | + tz: tz, | |
| 111 | + bearing: bearing | |
| 110 | 112 | }, |
| 111 | 113 | acc -> |
| 112 | 114 | update_in(acc, [{route, dest, dir}], fn |
@@ -117,7 +119,8 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
| 117 | 119 | dir: dir, |
| 118 | 120 | mode: mode, |
| 119 | 121 | times: [time], |
| 120 | − times_live: [livetime(time_live, tz)] | |
| 122 | + times_live: [livetime(time_live, tz)], | |
| 123 | + bearing: bearing | |
| 121 | 124 | } |
| 122 | 125 | |> Map.merge(presentation) |
| 123 | 126 |
@@ -125,7 +128,13 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
| 125 | 128 | %{ |
| 126 | 129 | refs |
| 127 | 130 | | times: [time | refs.times], |
| 128 | − times_live: [livetime(time_live, tz) | refs.times_live] | |
| 131 | + times_live: [livetime(time_live, tz) | refs.times_live], | |
| 132 | + # A blended Plani groups a route across every stop it calls | |
| 133 | + # at inside the radius, so these times can come from two | |
| 134 | + # places at once. Agreeing keeps the bearing; disagreeing | |
| 135 | + # drops it, because "north east" would then be true of only | |
| 136 | + # some of the departures under it. | |
| 137 | + bearing: if(refs.bearing == bearing, do: bearing, else: nil) | |
| 129 | 138 | } |
| 130 | 139 | end) |
| 131 | 140 | end) |
@@ -140,6 +149,12 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
| 140 | 149 | v |> Map.put(:times, v.times |> Enum.reverse()) |> Map.delete(:times_live) |
| 141 | 150 | end |
| 142 | 151 | end) |
| 152 | + # Only a Plani has a bearing to give, and only for a stop far enough | |
| 153 | + # away to have one. Dropped rather than published as null, so a | |
| 154 | + # vision's payload is byte for byte what it always was. | |
| 155 | + |> Enum.map(fn route -> | |
| 156 | + if route.bearing, do: route, else: Map.delete(route, :bearing) | |
| 157 | + end) | |
| 143 | 158 | |
| 144 | 159 | :gbfs -> |
| 145 | 160 | # A bike names its type by an id of the feed's own choosing -- Bay |
modified
apps/room_sanctum/lib/room_sanctum/condensers/plus_mqtt.ex
+14
−3
@@ -26,6 +26,7 @@ defmodule RoomSanctum.Condenser.PlusMQTT do
| 26 | 26 | %{ |
| 27 | 27 | key: {f.trip.route_id, f.trip.trip_headsign, f.trip.direction.direction}, |
| 28 | 28 | mode: f.trip.route.route_type |> gtfs_mode(), |
| 29 | + bearing: Map.get(f, :bearing), | |
| 29 | 30 | presentation: BasicMQTT.route_presentation(f.trip.route, f.trip.route_id), |
| 30 | 31 | arrival: %{ |
| 31 | 32 | time: f.arrival_time, |
@@ -53,19 +54,29 @@ defmodule RoomSanctum.Condenser.PlusMQTT do
| 53 | 54 | key: {route, dest, dir} = key, |
| 54 | 55 | mode: mode, |
| 55 | 56 | presentation: presentation, |
| 57 | + bearing: bearing, | |
| 56 | 58 | arrival: arrival |
| 57 | 59 | }, |
| 58 | 60 | acc -> |
| 59 | 61 | update_in(acc, [key], fn |
| 60 | 62 | nil -> |
| 61 | − %{route: route, dest: dest, dir: dir, mode: mode, arrivals: [arrival]} | |
| 63 | + %{route: route, dest: dest, dir: dir, mode: mode, arrivals: [arrival], bearing: bearing} | |
| 62 | 64 | |> Map.merge(presentation) |
| 63 | 65 | |
| 64 | 66 | refs -> |
| 65 | − %{refs | arrivals: [arrival | refs.arrivals]} | |
| 67 | + # As in Basic: a blended Plani can group one route across two stops, | |
| 68 | + # and a bearing true of only half the arrivals is worse than none. | |
| 69 | + %{ | |
| 70 | + refs | |
| 71 | + | arrivals: [arrival | refs.arrivals], | |
| 72 | + bearing: if(refs.bearing == bearing, do: bearing, else: nil) | |
| 73 | + } | |
| 66 | 74 | end) |
| 67 | 75 | end) |
| 68 | − |> Enum.map(fn {_k, v} -> Map.put(v, :arrivals, v.arrivals |> Enum.reverse()) end) | |
| 76 | + |> Enum.map(fn {_k, v} -> | |
| 77 | + v = Map.put(v, :arrivals, v.arrivals |> Enum.reverse()) | |
| 78 | + if v.bearing, do: v, else: Map.delete(v, :bearing) | |
| 79 | + end) | |
| 69 | 80 | |> attach_alerts(id, stop_of(data)) |
| 70 | 81 | end |
| 71 | 82 |
modified
apps/room_sanctum/lib/room_sanctum/workers/plani_worker.ex
+6
−1
@@ -218,7 +218,12 @@ defmodule RoomSanctum.Worker.Plani do
| 218 | 218 | |> Storage.fix_arrival_times() |
| 219 | 219 | |> Enum.sort_by(& &1.arrival_time) |
| 220 | 220 | |
| 221 | − {stop, arrivals} | |
| 221 | + # Which way the stop lies, carried on each arrival: the condenser | |
| 222 | + # groups arrivals into routes and only it knows which end up | |
| 223 | + # together, so the bearing has to travel with them. | |
| 224 | + bearing = compass(anchor, Map.get(stop, :stop_lat), Map.get(stop, :stop_lon)) | |
| 225 | + | |
| 226 | + {stop, Enum.map(arrivals, &Map.put(&1, :bearing, bearing))} | |
| 222 | 227 | end) |
| 223 | 228 | |> nearest_per_route(plani) |
| 224 | 229 |
modified
apps/room_sanctum/lib/room_sanctum_web/live/live_preview.ex
+12
−0
@@ -139,6 +139,12 @@ use PhoenixHTMLHelpers
| 139 | 139 | <i class={"fa-solid fa-fw #{gtfs_icon(e.mode)}"}></i> |
| 140 | 140 | <.route_badge entry={e} /> to <%= e.dest %> |
| 141 | 141 | </p> |
| 142 | + <%!-- Which way the stop lies from the anchor. Only a Plani sends | |
| 143 | + it, and only where the route was not blended across two stops | |
| 144 | + pointing different ways. --%> | |
| 145 | + <span :if={Map.get(e, :bearing)} class="badge badge-sm badge-outline gap-1"> | |
| 146 | + <i class="fa-solid fa-location-arrow"></i><%= Map.get(e, :bearing) %> | |
| 147 | + </span> | |
| 142 | 148 | </h2> |
| 143 | 149 | <%= if Map.get(e, :times_live, []) != [] do %> |
| 144 | 150 | <%= for t <- (e.times_live |> Enum.filter(fn t -> !is_nil(t) end )) do %> |
@@ -374,6 +380,12 @@ use PhoenixHTMLHelpers
| 374 | 380 | <i class={"fa-solid fa-fw #{gtfs_icon(e.mode)}"}></i> |
| 375 | 381 | <.route_badge entry={e} /> to <%= e.dest %> |
| 376 | 382 | </p> |
| 383 | + <%!-- Which way the stop lies from the anchor. Only a Plani sends | |
| 384 | + it, and only where the route was not blended across two stops | |
| 385 | + pointing different ways. --%> | |
| 386 | + <span :if={Map.get(e, :bearing)} class="badge badge-sm badge-outline gap-1"> | |
| 387 | + <i class="fa-solid fa-location-arrow"></i><%= Map.get(e, :bearing) %> | |
| 388 | + </span> | |
| 377 | 389 | </h2> |
| 378 | 390 | <%= for a <- shown_arrivals(e.arrivals) do %> |
| 379 | 391 | <div class="flex items-center gap-2 flex-wrap"> |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GtfsRenderer.kt
+20
−1
@@ -79,9 +79,28 @@ object GtfsRenderer : SourceRenderer {
| 79 | 79 | val first = routes.firstOrNull() ?: return null |
| 80 | 80 | val time = first.departures().firstOrNull()?.asClockTime() ?: return null |
| 81 | 81 | val live = first.times_live?.firstOrNull() != null |
| 82 | − return if (live) "${first.displayName()} at $time · live" else "${first.displayName()} at $time" | |
| 82 | + val next = if (live) { | |
| 83 | + "${first.displayName()} at $time · live" | |
| 84 | + } else { | |
| 85 | + "${first.displayName()} at $time" | |
| 86 | + } | |
| 87 | + | |
| 88 | + return bearing(routes)?.let { "$it · $next" } ?: next | |
| 83 | 89 | } |
| 84 | 90 | |
| 91 | + /** | |
| 92 | + * Which way to walk to this stop, when every route on the card agrees. | |
| 93 | + * | |
| 94 | + * On the card rather than on each row: broken out, the card *is* one stop, | |
| 95 | + * so a bearing repeated down four rows says the same thing four times. A | |
| 96 | + * blended card can hold two stops in different directions, and there the | |
| 97 | + * publisher sends no bearing for the rows that disagree -- so requiring | |
| 98 | + * agreement here is the same rule read at the other end, and a mixed card | |
| 99 | + * says nothing rather than picking one. | |
| 100 | + */ | |
| 101 | + private fun bearing(routes: List<GtfsCondensed>): String? = | |
| 102 | + routes.map { it.bearing }.distinct().singleOrNull() | |
| 103 | + | |
| 85 | 104 | /** |
| 86 | 105 | * Alerts, when one is bad enough to lead with. Everything the feed marked |
| 87 | 106 | * severe, plus anything naming this stop specifically -- a lift out of |
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/types/Gtfs.kt
+8
−0
@@ -22,6 +22,14 @@ data class GtfsCondensed(
| 22 | 22 | /** What is written on the front of the vehicle. Older publishers omit it. */ |
| 23 | 23 | val route_name: String? = null, |
| 24 | 24 | val route_long: String? = null, |
| 25 | + /** | |
| 26 | + * Which way this stop lies from where you are, as a sixteen-point bearing. | |
| 27 | + * | |
| 28 | + * Not [dir], which is the feed's own inbound/outbound. Only a Plani sets | |
| 29 | + * it, and it is absent when a route was blended across two stops lying in | |
| 30 | + * different directions. | |
| 31 | + */ | |
| 32 | + val bearing: String? = null, | |
| 25 | 33 | /** `#RRGGBB` -- the hash is added publisher-side. */ |
| 26 | 34 | val color: String? = null, |
| 27 | 35 | val text_color: String? = null |
modified
clients/android/app/src/test/java/io/neiam/apolloscrib/types/WireTest.kt
+18
−0
@@ -96,6 +96,24 @@ class WireTest {
| 96 | 96 | assertEquals("Boston Ave @ College Ave", entry.label()) |
| 97 | 97 | } |
| 98 | 98 | |
| 99 | + @Test | |
| 100 | + fun `a stop's bearing is separate from the route's direction`() { | |
| 101 | + val payload = """ | |
| 102 | + {"gtfs-40:2378": { | |
| 103 | + "query": {"name": "Boston Ave @ College Ave", "meta": {}}, | |
| 104 | + "data": [{"route":"96","dest":"Harvard","dir":"In","mode":"Bus", | |
| 105 | + "times":["08:00:00"],"bearing":"NE"}] | |
| 106 | + }} | |
| 107 | + """.trimIndent() | |
| 108 | + | |
| 109 | + val route = Wire.parse(payload).single().decode<List<GtfsCondensed>>()!!.single() | |
| 110 | + | |
| 111 | + // `dir` stays the feed's own inbound/outbound; the compass bearing is | |
| 112 | + // its own field, because they are different things. | |
| 113 | + assertEquals("In", route.dir) | |
| 114 | + assertEquals("NE", route.bearing) | |
| 115 | + } | |
| 116 | + | |
| 99 | 117 | @Test |
| 100 | 118 | fun `a source with no shape is dropped rather than failing the board`() { |
| 101 | 119 | // `bourse` is published by apollos-crib but has no type in the |
Parents: ce20d2b