Plot ADS-B aircraft on the map

5f17b6e · Gabriel Morell · 2026-08-10 22:23

23 files +254 -3
Message
{commit_body(@commit)}

Files changed

modified apps/room_icarus/lib/room_icarus/worker.ex
+20 −0
@@ -78,6 +78,14 @@ defmodule RoomIcarus.Worker do
78 78 def refresh_db_cfg(name), do: GenServer.cast(via("icarus#{name}"), :refresh_db_cfg)
79 79 def refresh_aircraft(name), do: GenServer.cast(via("icarus#{name}"), :refresh_aircraft)
80 80 def read(name, query), do: GenServer.call(via("icarus#{name}"), {:read, query})
81 +
82 + @doc """
83 + Every aircraft the worker is currently holding, across all watched areas.
84 +
85 + This is the whole picture rather than one query's slice, which is what a map
86 + of the source wants; per-query filtering stays in read/2.
87 + """
88 + def current_aircraft(name), do: GenServer.call(via("icarus#{name}"), :current_aircraft)
81 89 def sweep_watches(name), do: GenServer.cast(via("icarus#{name}"), :sweep_watches)
82 90
83 91 # Cast handlers
@@ -134,6 +142,18 @@ defmodule RoomIcarus.Worker do
134 142 end
135 143 end
136 144
145 + def handle_call(:current_aircraft, _from, state) do
146 + aircraft =
147 + state.aircraft
148 + |> Map.values()
149 + |> List.flatten()
150 + |> Enum.filter(&(&1["lat"] && &1["lon"]))
151 + # Overlapping areas hold the same airframe twice.
152 + |> Enum.uniq_by(& &1["hex"])
153 +
154 + {:reply, aircraft, state}
155 + end
156 +
137 157 def handle_call(_msg, _from, state), do: {:reply, :ok, state}
138 158
139 159 defp mode_of(query) do
modified apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+56 −0
@@ -97,6 +97,7 @@ class LeafletMap extends HTMLElement {
97 97 this.queriesLayer = L.layerGroup().addTo(this.map);
98 98 this.vehiclesLayer = L.layerGroup().addTo(this.map);
99 99 this.freeBikesLayer = L.layerGroup().addTo(this.map);
100 + this.aircraftLayer = L.layerGroup().addTo(this.map);
100 101 this.stationsLayer = L.layerGroup().addTo(this.map);
101 102
102 103 // Set up streaming event handlers if enabled
@@ -295,6 +296,11 @@ class LeafletMap extends HTMLElement {
295 296 leafletMarker = this.createFreeBikeMarker(lat, lng, markerEl);
296 297 layer = this.freeBikesLayer;
297 298 break;
299 + case 'aircraft':
300 + leafletMarker = this.createAircraftMarker(lat, lng, markerEl);
301 + layer = this.aircraftLayer;
302 + break;
303 +
298 304 case 'station':
299 305 leafletMarker = this.createStationMarker(lat, lng, markerEl);
300 306 layer = this.stationsLayer;
@@ -476,6 +482,56 @@ class LeafletMap extends HTMLElement {
476 482 });
477 483 }
478 484
485 + createAircraftMarker(lat, lng, markerEl) {
486 + const track = parseFloat(markerEl.getAttribute('bearing'));
487 +
488 + return L.marker([lat, lng], {
489 + icon: this.createAircraftIcon(
490 + this.aircraftColor(markerEl.getAttribute('aircraft-class')),
491 + this.getStrokeColor(markerEl),
492 + isNaN(track) ? null : track,
493 + 22
494 + ),
495 + keyboard: false,
496 + }).bindPopup(() => this.createPopupContent(markerEl));
497 + }
498 +
499 + // Unlike a bus, the whole aircraft turns: a plane symbol that did not point
500 + // along its track would be misleading, so the silhouette rotates rather
501 + // than a separate pointer.
502 + createAircraftIcon(fill, stroke, track, size) {
503 + const plane =
504 + '12,1 13.6,8 22,12.5 22,14.5 13.6,12.3 13.2,18.5 16,20.5 16,22 ' +
505 + '12,20.8 8,22 8,20.5 10.8,18.5 10.4,12.3 2,14.5 2,12.5 10.4,8';
506 +
507 + // No track at all: a disc, so the marker never claims a heading it
508 + // does not have.
509 + const body =
510 + track === null
511 + ? `<circle cx="12" cy="12" r="5.5" fill="${fill}" stroke="${stroke}" stroke-width="1.4" />`
512 + : `<polygon points="${plane}" fill="${fill}" stroke="${stroke}" stroke-width="1.2"
513 + stroke-linejoin="round" transform="rotate(${track} 12 12)" />`;
514 +
515 + return L.divIcon({
516 + className: 'leaflet-aircraft-icon',
517 + html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">${body}</svg>`,
518 + iconSize: [size, size],
519 + iconAnchor: [size / 2, size / 2],
520 + popupAnchor: [0, -size / 2],
521 + });
522 + }
523 +
524 + aircraftColor(klass) {
525 + const byClass = {
526 + military: '#dc2626', // red
527 + cargo: '#f97316', // orange
528 + commercial: '#38bdf8', // light blue
529 + general: '#a3a3a3' // grey
530 + };
531 +
532 + return byClass[klass] || byClass.general;
533 + }
534 +
479 535 createFreeBikeMarker(lat, lng, markerEl) {
480 536 // Smaller markers for bikes
481 537 const color = this.getMarkerColor(markerEl);
modified apps/room_sanctum/assets/js/leaflet/leaflet-marker.js
+1 −1
@@ -15,7 +15,7 @@ class LeafletMarker extends HTMLElement {
15 15 }
16 16
17 17 static get observedAttributes() {
18 − return ['lat', 'lng', 'name', 'type', 'tint', 'bearing', 'route-type', 'vehicle-id', 'route-id', 'selected'];
18 + return ['lat', 'lng', 'name', 'type', 'tint', 'bearing', 'route-type', 'aircraft-class', 'vehicle-id', 'route-id', 'selected'];
19 19 }
20 20
21 21 attributeChangedCallback(name, oldValue, newValue) {
modified apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+43 −2
@@ -40,6 +40,9 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
40 40
41 41 attr :vehicle_positions, :list, default: []
42 42 attr :free_bikes, :list, default: []
43 + attr :aircraft, :list, default: [],
44 + doc: "ADS-B aircraft as room_icarus normalises them: string keys, lat/lon/track/class."
45 +
43 46 attr :stations, :list, default: []
44 47 attr :station_statuses, :list, default: []
45 48 attr :source_tint, :string, default: nil
@@ -97,8 +100,15 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
97 100 |> assign(:map_queries, get_mappable_queries(assigns.queries))
98 101 |> assign(:map_vehicles, format_vehicle_positions(assigns.vehicle_positions, assigns.route_types))
99 102 |> assign(:map_free_bikes, format_free_bikes(assigns.free_bikes))
103 + |> assign(:map_aircraft, format_aircraft(assigns.aircraft))
100 104 |> assign(:map_stations, format_stations(assigns.stations, Map.get(assigns, :station_statuses, []), Map.get(assigns, :source_tint, nil)))
101 − groups = [assigns.map_queries, assigns.map_vehicles, assigns.map_free_bikes, assigns.map_stations]
105 + groups = [
106 + assigns.map_queries,
107 + assigns.map_vehicles,
108 + assigns.map_aircraft,
109 + assigns.map_free_bikes,
110 + assigns.map_stations
111 + ]
102 112 all_points = Enum.concat(groups)
103 113 # Centre on everything, even any points a cap would drop -- unless the
104 114 # caller named a place to look at.
@@ -178,6 +188,7 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
178 188 tint={Map.get(p, :tint)}
179 189 bearing={Map.get(p, :bearing)}
180 190 route-type={Map.get(p, :route_type)}
191 + aircraft-class={Map.get(p, :aircraft_class)}
181 192 id={"#{@id}-marker-#{p.id}"}
182 193 data-has-query={
183 194 if Map.get(p, :type) == "station" &&
@@ -218,7 +229,7 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
218 229 <h3 class="text-sm font-semibold text-base-content">
219 230 Map Legend
220 231 <span class="ml-2 text-xs text-base-content/60">
221 − (<%= length(@map_queries) %> queries with location data<%= if length(@map_stations) > 0, do: ", #{length(@map_stations)} stations", else: "" %><%= if length(@map_free_bikes) > 0, do: ", #{length(@map_free_bikes)} free bikes", else: "" %>)
232 + (<%= length(@map_queries) %> queries with location data<%= if length(@map_stations) > 0, do: ", #{length(@map_stations)} stations", else: "" %><%= if length(@map_free_bikes) > 0, do: ", #{length(@map_free_bikes)} free bikes", else: "" %><%= if length(@map_aircraft) > 0, do: ", #{length(@map_aircraft)} aircraft", else: "" %>)
222 233 </span>
223 234 </h3>
224 235
@@ -244,6 +255,13 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
244 255 <span class="capitalize text-base-content/80">Free Bikes</span>
245 256 </div>
246 257 <% end %>
258 +
259 + <%= if length(@map_aircraft) > 0 do %>
260 + <div class="flex items-center">
261 + <i class="fas fa-fw fa-plane mr-1 text-sky-400"></i>
262 + <span class="capitalize text-base-content/80">Aircraft</span>
263 + </div>
264 + <% end %>
247 265 </div>
248 266
249 267 <%= if length(@map_queries) == 0 and length(@map_free_bikes) == 0 do %>
@@ -589,6 +607,29 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
589 607 end)
590 608 end
591 609
610 + # ADS-B aircraft. Positions are string-keyed because that is how they leave
611 + # the adsb.fi payload, and the callsign is the useful label -- registration
612 + # or the ICAO address only when the flight is not transmitting one.
613 + defp format_aircraft(aircraft) do
614 + aircraft
615 + |> Enum.filter(fn ac -> ac["lat"] != nil and ac["lon"] != nil end)
616 + |> Enum.map(fn ac ->
617 + %{
618 + id: "aircraft_#{ac["hex"]}",
619 + type: "aircraft",
620 + lat: ac["lat"],
621 + lng: ac["lon"],
622 + name: ac["flight"] || ac["registration"] || ac["hex"],
623 + bearing: ac["track"],
624 + aircraft_class: ac["class"],
625 + altitude: ac["alt_baro"],
626 + speed: ac["gs"],
627 + registration: ac["registration"],
628 + aircraft_type: ac["type"]
629 + }
630 + end)
631 + end
632 +
592 633 # Format free bikes for map display
593 634 defp format_free_bikes(free_bikes) do
594 635 free_bikes
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+25 −0
@@ -40,6 +40,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
40 40 # Route ids calling at the stop the tester has selected, so its map can
41 41 # show that stop's routes rather than the whole system's.
42 42 |> assign(:tester_route_ids, [])
43 + |> assign(:aircraft, [])
43 44 |> assign(:view_mode, :system)
44 45 # Same palette the query form offers, so a tint picked here and one
45 46 # picked there are drawn from the same set. Every entry needs its
@@ -86,6 +87,9 @@ defmodule RoomSanctumWeb.SourceLive.Show do
86 87 _ -> []
87 88 end
88 89
90 + # Whatever the ADS-B worker is currently holding across its watched areas.
91 + aircraft = current_aircraft(socket.assigns.source.type, socket.assigns.source_id)
92 +
89 93 # Vehicle positions name a route, not a vehicle kind; the type comes from
90 94 # the route table.
91 95 route_types = case socket.assigns.source.type do
@@ -107,6 +111,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
107 111 |> assign(:stations, stations)
108 112 |> assign(:station_statuses, station_statuses)
109 113 |> assign(:route_types, route_types)
114 + |> assign(:aircraft, aircraft)
110 115 |> assign(:source_tint, source_tint)}
111 116 end
112 117
@@ -160,6 +165,26 @@ defmodule RoomSanctumWeb.SourceLive.Show do
160 165 }
161 166 end
162 167
168 + # The worker is only running for an enabled source, and it holds nothing
169 + # until its first sweep, so an absent process is an ordinary state rather
170 + # than an error worth taking the page down for.
171 + defp current_aircraft(:icarus, source_id) do
172 + # room_icarus is not a declared dependency of room_sanctum -- the umbrella
173 + # loads it at runtime, but it is absent from this app's test env, and an
174 + # unloaded module would raise rather than exit.
175 + if Code.ensure_loaded?(RoomIcarus.Worker) do
176 + try do
177 + RoomIcarus.Worker.current_aircraft(source_id)
178 + catch
179 + :exit, _ -> []
180 + end
181 + else
182 + []
183 + end
184 + end
185 +
186 + defp current_aircraft(_type, _source_id), do: []
187 +
163 188 # format_stations/3 reaches for :place directly, so every key it touches has
164 189 # to be present -- a plain map without :place raises rather than falling
165 190 # through to the lat/lon branch.
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+3 −0
@@ -367,6 +367,7 @@ gl_available_projects={@gitlab_available_projects}
367 367 queries={filter_queries_by_tint(@queries, @tint)}
368 368 vehicle_positions={@vehicle_positions}
369 369 free_bikes={@free_bikes}
370 + aircraft={@aircraft}
370 371 stations={@stations}
371 372 station_statuses={@station_statuses}
372 373 route_types={@route_types}
@@ -574,6 +575,7 @@ gl_available_projects={@gitlab_available_projects}
574 575 stations={within_area(@stations, focus_at)}
575 576 vehicle_positions={within_area(@vehicle_positions, focus_at)}
576 577 free_bikes={within_area(@free_bikes, focus_at)}
578 + aircraft={@aircraft}
577 579 station_statuses={@station_statuses}
578 580 route_types={@route_types}
579 581 route_lines={if @show_route_lines, do: lines_for_stop(@route_lines, @tester_route_ids), else: []}
@@ -674,6 +676,7 @@ gl_available_projects={@gitlab_available_projects}
674 676 stations={within_area(@stations, focus_point)}
675 677 vehicle_positions={within_area(@vehicle_positions, focus_point)}
676 678 free_bikes={within_area(@free_bikes, focus_point)}
679 + aircraft={@aircraft}
677 680 station_statuses={@station_statuses}
678 681 route_types={@route_types}
679 682 route_lines={if @show_route_lines, do: lines_for_stop(@route_lines, @tester_route_ids), else: []}
added apps/room_sanctum/test/room_sanctum_web/components/aircraft_map_test.exs
+106 −0
@@ -0,0 +1,106 @@
1 +defmodule RoomSanctumWeb.AircraftMapTest do
2 + @moduledoc """
3 + ADS-B aircraft on the map. Positions arrive string-keyed, straight off the
4 + adsb.fi payload, which is what room_icarus normalises them to.
5 + """
6 + use RoomSanctumWeb.ConnCase, async: true
7 +
8 + import Phoenix.LiveViewTest
9 + import RoomSanctumWeb.Components.QueryGeospatialMap
10 +
11 + defp plane(attrs) do
12 + Map.merge(
13 + %{
14 + "hex" => "a1b2c3",
15 + "flight" => "UAL558",
16 + "lat" => 37.62,
17 + "lon" => -122.38,
18 + "track" => 271.4,
19 + "class" => "commercial",
20 + "alt_baro" => 32_000,
21 + "gs" => 450,
22 + "registration" => "N12345",
23 + "type" => "B739"
24 + },
25 + attrs
26 + )
27 + end
28 +
29 + defp render_map(aircraft) do
30 + render_component(&query_geospatial_map/1, queries: [], aircraft: aircraft)
31 + end
32 +
33 + test "an aircraft becomes a marker carrying its track and class" do
34 + html = render_map([plane(%{})])
35 +
36 + assert html =~ ~s(id="geospatial-map-marker-aircraft_a1b2c3")
37 + assert html =~ ~s(type="aircraft")
38 + assert html =~ ~s(bearing="271.4")
39 + assert html =~ ~s(aircraft-class="commercial")
40 + end
41 +
42 + test "the callsign labels the marker" do
43 + assert render_map([plane(%{})]) =~ ~s(name="UAL558")
44 + end
45 +
46 + test "without a callsign it falls back to the registration, then the icao address" do
47 + assert render_map([plane(%{"flight" => nil})]) =~ ~s(name="N12345")
48 +
49 + assert render_map([plane(%{"flight" => nil, "registration" => nil})]) =~
50 + ~s(name="a1b2c3")
51 + end
52 +
53 + test "an aircraft with no position is dropped rather than plotted at null island" do
54 + html = render_map([plane(%{"hex" => "ghost", "lat" => nil, "lon" => nil})])
55 +
56 + refute html =~ "aircraft_ghost"
57 + refute html =~ ~s(lat="0")
58 + end
59 +
60 + test "a partial position is not good enough either" do
61 + refute render_map([plane(%{"hex" => "half", "lon" => nil})]) =~ "aircraft_half"
62 + end
63 +
64 + test "an aircraft not transmitting a track still gets a marker" do
65 + html = render_map([plane(%{"track" => nil})])
66 +
67 + assert html =~ "aircraft_a1b2c3"
68 + # no bearing to claim: the map draws it as a disc rather than pointing it
69 + refute html =~ ~s(bearing="271.4")
70 + end
71 +
72 + test "several aircraft each get their own marker" do
73 + html =
74 + render_map([
75 + plane(%{"hex" => "aaa", "flight" => "AAL1"}),
76 + plane(%{"hex" => "bbb", "flight" => "SWA2", "class" => "cargo"}),
77 + plane(%{"hex" => "ccc", "flight" => "RCH3", "class" => "military"})
78 + ])
79 +
80 + for hex <- ~w(aaa bbb ccc), do: assert(html =~ "aircraft_#{hex}")
81 + assert html =~ ~s(aircraft-class="cargo")
82 + assert html =~ ~s(aircraft-class="military")
83 + end
84 +
85 + test "the legend counts aircraft" do
86 + html = render_map([plane(%{"hex" => "aaa"}), plane(%{"hex" => "bbb"})])
87 +
88 + assert html =~ "2 aircraft"
89 + assert html =~ "Aircraft"
90 + end
91 +
92 + test "the map centres on the aircraft when there is nothing else" do
93 + html = render_map([plane(%{"lat" => 51.47, "lon" => -0.45})])
94 +
95 + # not the empty-map fallback over Kansas
96 + refute html =~ ~s(lat="39.8283")
97 + assert html =~ ~s(lat="51.47")
98 + end
99 +
100 + test "no aircraft is not an error" do
101 + html = render_map([])
102 +
103 + refute html =~ ~s(type="aircraft")
104 + refute html =~ "aircraft)"
105 + end
106 +end

Parents: 568e93a