neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Plot ADS-B aircraft on the map
5f17b6e · Gabriel Morell · 2026-08-10 22:23
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