Place subway trains at their stop, and put realtime on the tint map

8b198ec · Gabriel Morell · 2026-08-24 05:57

13 files +269 -76
Message
{commit_body(@commit)}

Files changed

modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+97 −74
@@ -265,6 +265,9 @@ defmodule RoomGtfs.Worker.RT do
265 265 alias RoomSanctum.Repo
266 266 alias RoomGtfs.FeedCache
267 267
268 + # Static stops only change when a feed is reimported.
269 + @stops_ttl :timer.minutes(5)
270 +
268 271 def start_link(opts) do
269 272 GenServer.start_link(__MODULE__, opts, name: via_tuple("gtfs-rt" <> opts[:name]))
270 273 end
@@ -310,7 +313,11 @@ defmodule RoomGtfs.Worker.RT do
310 313 inst: nil,
311 314 rt_sa: nil,
312 315 rt_tu: nil,
313 − rt_vp: nil
316 + rt_vp: nil,
317 + # stop_id => {lat, lon}, for feeds that report a station instead of a
318 + # coordinate. See vehicle_positions_from/2.
319 + stops: %{},
320 + stops_at: nil
314 321 }}
315 322 end
316 323
@@ -451,31 +458,87 @@ defmodule RoomGtfs.Worker.RT do
451 458 defp feed_summary(_), do: %{loaded: false}
452 459
453 460 # Helper function to extract vehicle positions from protobuf data
454 − defp extract_vehicle_positions(data_vp) do
455 − case data_vp do
456 − nil ->
457 − []
458 −
459 − %{entity: entities} ->
460 − entities
461 − |> Enum.filter(fn entity -> entity.vehicle && entity.vehicle.position end)
462 − |> Enum.map(fn entity ->
463 − %{
464 − vehicle_id: entity.vehicle.vehicle.id,
465 − trip_id: if(entity.vehicle.trip, do: entity.vehicle.trip.trip_id, else: nil),
466 − route_id: if(entity.vehicle.trip, do: entity.vehicle.trip.route_id, else: nil),
467 − latitude: entity.vehicle.position.latitude,
468 − longitude: entity.vehicle.position.longitude,
469 − bearing: entity.vehicle.position.bearing,
470 − timestamp: entity.vehicle.timestamp
471 − }
472 − end)
473 −
461 + @doc """
462 + Vehicles from a realtime feed, placed on the map.
463 +
464 + A GTFS-RT VehiclePosition may carry a `position` and may not. Buses do:
465 + every one of the MTA's 318 had a latitude. NYCT subway trains do not -- not
466 + one of 45 did -- because what the subway reports is which station a train is
467 + at or heading to, `stop_id` plus `current_status`, and never a coordinate.
468 + Filtering on `position` therefore drew every bus and no train at all.
469 +
470 + So a train without a position is placed at the stop it names, and marked
471 + `position_inferred: true` so nothing downstream mistakes it for a fix. For a
472 + subway that is arguably the more useful reading anyway: you care which
473 + station it is at, not where it is in the tunnel.
474 +
475 + `stops` is a `stop_id => {lat, lon}` map from the source's own static feed.
476 + Empty, this behaves as it always did.
477 + """
478 + def vehicle_positions_from(nil, _stops), do: []
479 +
480 + def vehicle_positions_from(%{entity: entities}, stops) do
481 + entities
482 + |> Enum.filter(& &1.vehicle)
483 + |> Enum.flat_map(fn entity -> [place_vehicle(entity.vehicle, stops)] end)
484 + |> Enum.reject(&is_nil/1)
485 + end
486 +
487 + def vehicle_positions_from(_feed, _stops), do: []
488 +
489 + defp place_vehicle(v, stops) do
490 + base = %{
491 + vehicle_id: v.vehicle && v.vehicle.id,
492 + trip_id: v.trip && v.trip.trip_id,
493 + route_id: v.trip && v.trip.route_id,
494 + stop_id: v.stop_id,
495 + current_status: v.current_status,
496 + timestamp: v.timestamp
497 + }
498 +
499 + case {v.position, v.stop_id && Map.get(stops, v.stop_id)} do
500 + {%{latitude: lat, longitude: lon} = pos, _} when not is_nil(lat) and not is_nil(lon) ->
501 + Map.merge(base, %{
502 + latitude: lat,
503 + longitude: lon,
504 + bearing: pos.bearing,
505 + position_inferred: false
506 + })
507 +
508 + {_, {lat, lon}} when not is_nil(lat) and not is_nil(lon) ->
509 + # No bearing: the stop knows where it is, not which way the train faces.
510 + Map.merge(base, %{
511 + latitude: lat,
512 + longitude: lon,
513 + bearing: nil,
514 + position_inferred: true
515 + })
516 +
474 517 _ ->
475 − []
518 + nil
476 519 end
477 520 end
478 521
522 + # Static stops for this source, as the fallback above needs them. Reloaded at
523 + # most every few minutes: they only change when a feed is reimported, and a
524 + # subway feed is 1,488 rows that would otherwise be read on every poll.
525 + defp ensure_stops(state) do
526 + now = System.monotonic_time(:millisecond)
527 + age = now - Map.get(state, :stops_at, -@stops_ttl)
528 +
529 + if age >= @stops_ttl do
530 + stops =
531 + state.id
532 + |> Storage.list_stops()
533 + |> Map.new(fn stop -> {stop.stop_id, {stop.stop_lat, stop.stop_lon}} end)
534 +
535 + state |> Map.put(:stops, stops) |> Map.put(:stops_at, now)
536 + else
537 + state
538 + end
539 + end
540 +
541 +
479 542 def fetch_rt_url(url) do
480 543 case HTTPoison.get(url, [], follow_redirect: true) do
481 544 {:ok, %{status_code: 200} = result} ->
@@ -651,10 +714,10 @@ defmodule RoomGtfs.Worker.RT do
651 714 val ->
652 715 case FeedCache.get(val) do
653 716 {:ok, data_vp} when is_struct(data_vp, TransitRealtime.FeedMessage) ->
654 − new_state = state |> Map.put(:rt_vp, data_vp)
655 −
717 + new_state = state |> Map.put(:rt_vp, data_vp) |> ensure_stops()
718 +
656 719 # Broadcast vehicle position updates
657 − vehicles = extract_vehicle_positions(data_vp)
720 + vehicles = vehicle_positions_from(data_vp, new_state.stops)
658 721
659 722 # Broadcast to specific source channel for source page
660 723 Phoenix.PubSub.broadcast(
@@ -723,59 +786,19 @@ defmodule RoomGtfs.Worker.RT do
723 786 end
724 787 end
725 788
789 + # Both of these place vehicles through vehicle_positions_from/2, so a pull
790 + # here and the push broadcast on each poll agree about where a train is.
726 791 def handle_call(:query_vehicle_positions, _from, state) do
727 − # Return all vehicle positions
728 − case state.rt_vp do
729 − nil ->
730 − {:reply, [], state}
731 −
732 − _otherwise ->
733 − vehicles = state.rt_vp.entity
734 − |> Enum.filter(fn entity -> entity.vehicle && entity.vehicle.position end)
735 − |> Enum.map(fn entity ->
736 − %{
737 − vehicle_id: entity.vehicle.vehicle.id,
738 − trip_id: if(entity.vehicle.trip, do: entity.vehicle.trip.trip_id, else: nil),
739 − route_id: if(entity.vehicle.trip, do: entity.vehicle.trip.route_id, else: nil),
740 − latitude: entity.vehicle.position.latitude,
741 − longitude: entity.vehicle.position.longitude,
742 − bearing: entity.vehicle.position.bearing,
743 − timestamp: entity.vehicle.timestamp
744 − }
745 − end)
746 −
747 − {:reply, vehicles, state}
748 − end
792 + {:reply, vehicle_positions_from(state.rt_vp, state.stops), state}
749 793 end
750 794
751 795 def handle_call({:query_vehicle_positions, trips}, _from, state) do
752 − # Return vehicle positions for specific trips
753 − case state.rt_vp do
754 − nil ->
755 − {:reply, [], state}
756 −
757 − _otherwise ->
758 − vehicles = state.rt_vp.entity
759 − |> Enum.filter(fn entity ->
760 − entity.vehicle &&
761 − entity.vehicle.position &&
762 − entity.vehicle.trip &&
763 − Enum.member?(trips, entity.vehicle.trip.trip_id)
764 − end)
765 − |> Enum.map(fn entity ->
766 − %{
767 − vehicle_id: entity.vehicle.vehicle.id,
768 − trip_id: entity.vehicle.trip.trip_id,
769 − route_id: entity.vehicle.trip.route_id,
770 − latitude: entity.vehicle.position.latitude,
771 − longitude: entity.vehicle.position.longitude,
772 − bearing: entity.vehicle.position.bearing,
773 − timestamp: entity.vehicle.timestamp
774 − }
775 − end)
776 −
777 − {:reply, vehicles, state}
778 − end
796 + vehicles =
797 + state.rt_vp
798 + |> vehicle_positions_from(state.stops)
799 + |> Enum.filter(&(&1.trip_id && &1.trip_id in trips))
800 +
801 + {:reply, vehicles, state}
779 802 end
780 803 end
781 804
added apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/live_layers.ex
+102 −0
@@ -0,0 +1,102 @@
1 +defmodule RoomSanctumWeb.SourceLive.LiveLayers do
2 + @moduledoc """
3 + What a source has moving right now: vehicles, loose bikes, aircraft.
4 +
5 + The companion to `RoomSanctumWeb.SourceLive.Stations`, which answers the same
6 + question about places that do not move. Both exist because the tint map draws
7 + many sources at once and needs one shape per layer regardless of what kind of
8 + source produced it.
9 +
10 + Every read here goes through a worker rather than the database, and a worker
11 + may not be running -- a source added a minute ago, one whose supervisor is
12 + restarting, an app absent from this environment. All of those come back as an
13 + empty layer, because a map missing its buses is worth more than a page that
14 + will not render.
15 +
16 + `SourceLive.Show` gathers the same three things its own way, driven by PubSub
17 + for one source at a time. That is a better fit for a page watching a single
18 + feed, and merging the two is not obviously an improvement; what would be
19 + worth sharing is this module's habit of never letting a dead worker through.
20 + """
21 +
22 + alias RoomSanctum.Storage
23 +
24 + @doc """
25 + Everything live for one source, as `%{vehicles:, free_bikes:, aircraft:,
26 + route_types:}`.
27 +
28 + `route_types` comes back alongside the vehicles because a vehicle position
29 + names a route and the glyph is chosen from the route's type, so the caller
30 + would otherwise have to know to look it up separately.
31 + """
32 + def for_source(%{type: :gtfs, id: id}) do
33 + vehicles =
34 + safely(fn -> RoomGtfs.Worker.get_current_vehicle_positions(id) end, [])
35 + |> Storage.with_trip_context(id)
36 +
37 + %{
38 + vehicles: vehicles,
39 + free_bikes: [],
40 + aircraft: [],
41 + # Only worth the query if something is actually being drawn.
42 + route_types: if(vehicles == [], do: %{}, else: Storage.route_types(id))
43 + }
44 + end
45 +
46 + def for_source(%{type: :gbfs, id: id}) do
47 + bikes =
48 + safely(fn -> Storage.list_gbfs_free_bike_status() end, [])
49 + |> Enum.filter(&(&1.source_id == id))
50 +
51 + %{vehicles: [], free_bikes: bikes, aircraft: [], route_types: %{}}
52 + end
53 +
54 + def for_source(%{type: :icarus, id: id}) do
55 + %{vehicles: [], free_bikes: [], aircraft: aircraft(id), route_types: %{}}
56 + end
57 +
58 + def for_source(_source), do: empty()
59 +
60 + @doc "Merged layers across several sources, ready to hand to the map."
61 + def for_sources(sources) do
62 + sources
63 + |> Enum.map(&for_source/1)
64 + |> Enum.reduce(empty(), fn layer, acc ->
65 + %{
66 + vehicles: acc.vehicles ++ layer.vehicles,
67 + free_bikes: acc.free_bikes ++ layer.free_bikes,
68 + aircraft: acc.aircraft ++ layer.aircraft,
69 + # Route ids are namespaced per source in the static feed, so a plain
70 + # merge is safe: two agencies both having a route "1" would collide, but
71 + # route_types is keyed on the id the vehicle reports and both agencies'
72 + # vehicles report their own.
73 + route_types: Map.merge(acc.route_types, layer.route_types)
74 + }
75 + end)
76 + end
77 +
78 + @doc "Whether a source can contribute a live layer at all."
79 + def live?(%{type: type}), do: type in [:gtfs, :gbfs, :icarus]
80 + def live?(_source), do: false
81 +
82 + defp empty, do: %{vehicles: [], free_bikes: [], aircraft: [], route_types: %{}}
83 +
84 + # room_icarus is not a declared dependency of room_sanctum -- the umbrella
85 + # loads it at runtime, but it is absent from this app's test env, and an
86 + # unloaded module raises rather than exits.
87 + defp aircraft(id) do
88 + if Code.ensure_loaded?(RoomIcarus.Worker) do
89 + safely(fn -> RoomIcarus.Worker.current_aircraft(id) end, [])
90 + else
91 + []
92 + end
93 + end
94 +
95 + defp safely(fun, default) do
96 + fun.()
97 + rescue
98 + _error -> default
99 + catch
100 + :exit, _reason -> default
101 + end
102 +end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/map_view.ex
+48 −1
@@ -27,6 +27,7 @@ defmodule RoomSanctumWeb.SourceLive.MapView do
27 27 import RoomSanctumWeb.Components.QueryGeospatialMap
28 28
29 29 alias RoomSanctum.Configuration
30 + alias RoomSanctumWeb.SourceLive.LiveLayers
30 31 alias RoomSanctumWeb.SourceLive.Stations
31 32
32 33 # A backstop, not a budget. An offering's own map draws MBTA's ten thousand
@@ -38,8 +39,16 @@ defmodule RoomSanctumWeb.SourceLive.MapView do
38 39 # loud, rather than as a wedged browser.
39 40 @max_markers 50_000
40 41
42 + # Matches the source page. The realtime workers poll every 30s, so asking more
43 + # often than that returns the same feed.
44 + @refresh :timer.seconds(30)
45 +
41 46 @impl true
42 47 def mount(_params, _session, socket) do
48 + # Only once connected: the static render would pay for every worker read and
49 + # then be replaced.
50 + if connected?(socket), do: Process.send_after(self(), :refresh_live, 100)
51 +
43 52 {:ok,
44 53 socket
45 54 |> assign(:tint, nil)
@@ -47,7 +56,28 @@ defmodule RoomSanctumWeb.SourceLive.MapView do
47 56 |> assign(:stations, [])
48 57 |> assign(:station_statuses, [])
49 58 |> assign(:counts, %{})
50 − |> assign(:max_markers, @max_markers)}
59 + |> assign(:max_markers, @max_markers)
60 + |> assign(:show_aircraft, false)
61 + |> assign_live(%{vehicles: [], free_bikes: [], aircraft: [], route_types: %{}})}
62 + end
63 +
64 + @impl true
65 + def handle_info(:refresh_live, socket) do
66 + Process.send_after(self(), :refresh_live, @refresh)
67 + {:noreply, assign_live(socket, LiveLayers.for_sources(socket.assigns.sources))}
68 + end
69 +
70 + @impl true
71 + def handle_event("toggle-aircraft", _params, socket) do
72 + {:noreply, assign(socket, :show_aircraft, not socket.assigns.show_aircraft)}
73 + end
74 +
75 + defp assign_live(socket, layers) do
76 + socket
77 + |> assign(:vehicles, layers.vehicles)
78 + |> assign(:free_bikes, layers.free_bikes)
79 + |> assign(:aircraft, layers.aircraft)
80 + |> assign(:route_types, layers.route_types)
51 81 end
52 82
53 83 @impl true
@@ -89,6 +119,23 @@ defmodule RoomSanctumWeb.SourceLive.MapView do
89 119 |> assign(:stations, Enum.flat_map(per_source, fn {_source, stations} -> stations end))
90 120 |> assign(:counts, Map.new(per_source, fn {source, stations} -> {source.id, length(stations)} end))
91 121 |> assign(:station_statuses, Enum.flat_map(sources, &Stations.statuses_for_source/1))
122 + |> then(fn s ->
123 + # The sources just changed, so anything live already assigned describes the
124 + # tint that was on screen a moment ago.
125 + if connected?(s), do: assign_live(s, LiveLayers.for_sources(sources)), else: s
126 + end)
127 + end
128 +
129 + defp any_aircraft?(sources), do: Enum.any?(sources, &(&1.type == :icarus))
130 +
131 + # Subway trains report a station rather than a coordinate, so they are drawn at
132 + # the stop they name. Saying how many is the difference between a map that is
133 + # slightly approximate and one that is quietly lying.
134 + defp inferred_note(vehicles) do
135 + case Enum.count(vehicles, &Map.get(&1, :position_inferred)) do
136 + 0 -> ""
137 + n -> " (#{n} placed at a stop)"
138 + end
92 139 end
93 140
94 141 defp mappable?(source), do: Stations.mappable?(source)
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/map_view.html.heex
+22 −1
@@ -35,7 +35,7 @@
35 35 <% end %>
36 36 </div>
37 37
38 − <%= if @stations == [] do %>
38 + <%= if @stations == [] and @vehicles == [] and @free_bikes == [] and @aircraft == [] do %>
39 39 <div class="card card-compact w-full bg-base-100 shadow-xl mx-6 my-2">
40 40 <div class="card-body">
41 41 <h3 class="card-title italic">Nothing to draw</h3>
@@ -58,12 +58,33 @@
58 58 queries={[]}
59 59 stations={@stations}
60 60 station_statuses={@station_statuses}
61 + vehicle_positions={@vehicles}
62 + route_types={@route_types}
63 + free_bikes={@free_bikes}
64 + aircraft={if @show_aircraft, do: @aircraft, else: []}
65 + aircraft_event={if any_aircraft?(@sources), do: "toggle-aircraft"}
66 + show_aircraft={@show_aircraft}
61 67 source_tint={@tint}
62 68 selected_tint={@tint}
63 69 max_markers={@max_markers}
64 70 class=""
65 71 height="700px"
66 72 />
73 +
74 + <%!-- Counts rather than a legend entry: the map's own legend explains the
75 + marker kinds, and what is useful here is knowing whether a layer is
76 + empty because nothing is running or because no source supplies it. --%>
77 + <div class="mt-2 text-xs opacity-70 flex flex-wrap gap-3">
78 + <span><%= length(@stations) %> places</span>
79 + <span :if={@vehicles != []}>
80 + <%= length(@vehicles) %> vehicles<%= inferred_note(@vehicles) %>
81 + </span>
82 + <span :if={@free_bikes != []}><%= length(@free_bikes) %> loose bikes</span>
83 + <span :if={@aircraft != [] and @show_aircraft}><%= length(@aircraft) %> aircraft</span>
84 + <span :if={Enum.any?(@sources, &LiveLayers.live?(&1)) and @vehicles == [] and @free_bikes == []}>
85 + nothing moving right now
86 + </span>
87 + </div>
67 88 </div>
68 89 <% end %>
69 90 <% end %>

Parents: 011a33a