Say which way a stop is, too

dfde4b2 · Gabriel Morell · 2026-09-03 20:17

32 files +97 -9
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