Show ADS-B aircraft on the query maps

a94201e · Gabriel Morell · 2026-08-10 22:29

15 files +106 -1
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+25 −0
@@ -607,6 +607,31 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
607 607 end)
608 608 end
609 609
610 + @doc """
611 + Aircraft to plot from a query preview.
612 + """
613 + # The preview already holds what the worker returned for this query, so the
614 + # map does not need its own read: an area query's result *is* the matching
615 + # aircraft, filtered by class and altitude the way the query asks; a flight
616 + # query returns one watch carrying the last position it was seen at.
617 + #
618 + # A watch that has not been sighted yet has no position, and is simply not
619 + # plotted rather than being drawn somewhere invented.
620 + def aircraft_from_preview(:icarus, preview) when is_list(preview) do
621 + Enum.flat_map(preview, fn
622 + %{"kind" => "flight", "position" => %{"lat" => _, "lon" => _} = position} -> [position]
623 + %{"lat" => _, "lon" => _} = aircraft -> [aircraft]
624 + _other -> []
625 + end)
626 + end
627 +
628 + def aircraft_from_preview(_type, _preview), do: []
629 +
630 + @doc """
631 + As aircraft_from_preview/2, for a preview already known to be icarus'.
632 + """
633 + def aircraft_from_preview_list(preview), do: aircraft_from_preview(:icarus, preview)
634 +
610 635 # ADS-B aircraft. Positions are string-keyed because that is how they leave
611 636 # the adsb.fi payload, and the callsign is the useful label -- registration
612 637 # or the ICAO address only when the flight is not transmitting one.
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/index.ex
+27 −1
@@ -30,6 +30,7 @@ defmodule RoomSanctumWeb.QueryLive.Index do
30 30 |> assign(:available_tints, available_tints)
31 31 |> assign(:queries, queries)
32 32 |> assign(:vehicle_positions, [])
33 + |> assign(:aircraft, [])
33 34 |> assign(:show_route_lines, false)
34 35 |> assign(:route_lines, [])
35 36 |> stream(:cfg_queries, queries)}
@@ -111,7 +112,32 @@ defmodule RoomSanctumWeb.QueryLive.Index do
111 112 # Schedule next update
112 113 Process.send_after(self(), :update_vehicle_positions, 30_000) # Every 30 seconds
113 114
114 − {:noreply, socket |> assign(:vehicle_positions, vehicle_positions)}
115 + {:noreply,
116 + socket
117 + |> assign(:vehicle_positions, vehicle_positions)
118 + |> assign(:aircraft, get_all_aircraft(socket.assigns.queries))}
119 + end
120 +
121 + # One read per icarus query, so each contributes the aircraft it actually
122 + # matches -- its own radius, altitude band and class filters -- rather than
123 + # everything its source can see. The worker answers from cache between its
124 + # own refreshes, and an absent one contributes nothing.
125 + defp get_all_aircraft(queries) do
126 + if Code.ensure_loaded?(RoomIcarus.Worker) do
127 + queries
128 + |> Enum.filter(&(&1.source && &1.source.type == :icarus))
129 + |> Enum.flat_map(fn query ->
130 + try do
131 + RoomIcarus.Worker.read(query.source_id, query.query)
132 + catch
133 + :exit, _ -> []
134 + end
135 + end)
136 + |> RoomSanctumWeb.Components.QueryGeospatialMap.aircraft_from_preview_list()
137 + |> Enum.uniq_by(& &1["hex"])
138 + else
139 + []
140 + end
115 141 end
116 142
117 143 # Handle streaming map data batches for high-performance rendering
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/index.html.heex
+1 −0
@@ -44,6 +44,7 @@
44 44 <.query_geospatial_map
45 45 queries={@queries}
46 46 vehicle_positions={@vehicle_positions}
47 + aircraft={@aircraft}
47 48 route_lines={if @show_route_lines, do: @route_lines, else: []}
48 49 route_lines_event={if Enum.any?(@queries, &(&1.source && &1.source.type == :gtfs)), do: "toggle-route-lines"}
49 50 show_route_lines={@show_route_lines}
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.html.heex
+1 −0
@@ -123,6 +123,7 @@
123 123 <.query_geospatial_map
124 124 queries={[@query]}
125 125 vehicle_positions={@vehicle_positions}
126 + aircraft={aircraft_from_preview(@query.source && @query.source.type, @preview)}
126 127 route_lines={if @show_route_lines, do: @route_lines, else: []}
127 128 route_lines_event={if @query.source && @query.source.type == :gtfs, do: "toggle-route-lines"}
128 129 show_route_lines={@show_route_lines}
modified apps/room_sanctum/test/room_sanctum_web/components/aircraft_map_test.exs
+52 −0
@@ -97,6 +97,58 @@ defmodule RoomSanctumWeb.AircraftMapTest do
97 97 assert html =~ ~s(lat="51.47")
98 98 end
99 99
100 + describe "aircraft from a query preview" do
101 + test "an area query's preview is already the matching aircraft" do
102 + preview = [plane(%{"hex" => "aaa"}), plane(%{"hex" => "bbb"})]
103 +
104 + assert aircraft_from_preview(:icarus, preview) == preview
105 + end
106 +
107 + test "a flight query contributes the position its watch was last seen at" do
108 + watch = %{
109 + "kind" => "flight",
110 + "callsign" => "UAL558",
111 + "hex" => "a1b2c3",
112 + "state" => "enroute",
113 + "position" => plane(%{})
114 + }
115 +
116 + assert aircraft_from_preview(:icarus, [watch]) == [plane(%{})]
117 + end
118 +
119 + test "a watch with no sighting yet is not plotted somewhere invented" do
120 + for position <- [nil, %{}, %{"lat" => 37.6}] do
121 + watch = %{"kind" => "flight", "callsign" => "UAL558", "position" => position}
122 + assert aircraft_from_preview(:icarus, [watch]) == [],
123 + "plotted a watch whose position was #{inspect(position)}"
124 + end
125 + end
126 +
127 + test "other source types contribute nothing" do
128 + assert aircraft_from_preview(:gtfs, [plane(%{})]) == []
129 + assert aircraft_from_preview(:gbfs, [plane(%{})]) == []
130 + end
131 +
132 + test "a preview that is not a list is tolerated" do
133 + assert aircraft_from_preview(:icarus, nil) == []
134 + assert aircraft_from_preview(:icarus, %{"lat" => 1.0, "lon" => 2.0}) == []
135 + end
136 +
137 + test "entries without a position are skipped rather than failing the lot" do
138 + preview = [plane(%{"hex" => "ok"}), %{"hex" => "nofix", "flight" => "GHOST"}]
139 +
140 + assert aircraft_from_preview(:icarus, preview) == [plane(%{"hex" => "ok"})]
141 + end
142 +
143 + test "what it returns is what the map renders" do
144 + watch = %{"kind" => "flight", "position" => plane(%{"hex" => "inflight"})}
145 + html = render_map(aircraft_from_preview(:icarus, [watch]))
146 +
147 + assert html =~ "aircraft_inflight"
148 + assert html =~ ~s(bearing="271.4")
149 + end
150 + end
151 +
100 152 test "no aircraft is not an error" do
101 153 html = render_map([])
102 154

Parents: 5f17b6e