neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Make aircraft a layer on the query index, off by default
781a566 · Gabriel Morell · 2026-08-10 22:35
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+27
−1
@@ -86,6 +86,20 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 86 | 86 | default: false, |
| 87 | 87 | doc: "Whether the toggle reads as on. The state lives with the caller." |
| 88 | 88 | |
| 89 | + attr :aircraft_event, :string, | |
| 90 | + default: nil, | |
| 91 | + doc: """ | |
| 92 | + When set, the map carries a toggle for the aircraft layer pushing this | |
| 93 | + event. For maps where aircraft are incidental rather than the subject -- | |
| 94 | + the query index draws every query the user owns, and a couple of area | |
| 95 | + queries in different cities will fill it with planes and zoom out to fit | |
| 96 | + them. | |
| 97 | + """ | |
| 98 | + | |
| 99 | + attr :show_aircraft, :boolean, | |
| 100 | + default: false, | |
| 101 | + doc: "Whether the aircraft toggle reads as on. State lives with the caller." | |
| 102 | + | |
| 89 | 103 | attr :legend, :boolean, |
| 90 | 104 | default: true, |
| 91 | 105 | doc: """ |
@@ -148,8 +162,20 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 148 | 162 | <div class="mt-4 relative" id={"#{@id}-wrap"}> |
| 149 | 163 | <%!-- Above Leaflet's own controls, which top out at z-index 1000 |
| 150 | 164 | inside the map's shadow root. --%> |
| 151 | − <div :if={@route_lines_event} class="absolute top-2 right-2 z-[1100]"> | |
| 165 | + <div :if={@route_lines_event || @aircraft_event} class="absolute top-2 right-2 z-[1100] flex gap-1"> | |
| 166 | + <button | |
| 167 | + :if={@aircraft_event} | |
| 168 | + type="button" | |
| 169 | + class={"btn btn-xs gap-1 shadow #{if @show_aircraft, do: "btn-primary", else: "btn-neutral"}"} | |
| 170 | + phx-click={@aircraft_event} | |
| 171 | + title={if @show_aircraft, do: "Hide aircraft", else: "Show aircraft"} | |
| 172 | + > | |
| 173 | + <i class="fa-solid fa-plane"></i> | |
| 174 | + Aircraft | |
| 175 | + </button> | |
| 176 | + | |
| 152 | 177 | <button |
| 178 | + :if={@route_lines_event} | |
| 153 | 179 | type="button" |
| 154 | 180 | class={"btn btn-xs gap-1 shadow #{if @show_route_lines, do: "btn-primary", else: "btn-neutral"}"} |
| 155 | 181 | phx-click={@route_lines_event} |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/index.ex
+16
−1
@@ -31,6 +31,9 @@ defmodule RoomSanctumWeb.QueryLive.Index do
| 31 | 31 | |> assign(:queries, queries) |
| 32 | 32 | |> assign(:vehicle_positions, []) |
| 33 | 33 | |> assign(:aircraft, []) |
| 34 | + # Off by default: this map is about the user's queries, and a couple of | |
| 35 | + # area queries in different cities bury them under live traffic. | |
| 36 | + |> assign(:show_aircraft, false) | |
| 34 | 37 | |> assign(:show_route_lines, false) |
| 35 | 38 | |> assign(:route_lines, []) |
| 36 | 39 | |> stream(:cfg_queries, queries)} |
@@ -115,9 +118,21 @@ defmodule RoomSanctumWeb.QueryLive.Index do
| 115 | 118 | {:noreply, |
| 116 | 119 | socket |
| 117 | 120 | |> assign(:vehicle_positions, vehicle_positions) |
| 118 | − |> assign(:aircraft, get_all_aircraft(socket.assigns.queries))} | |
| 121 | + |> assign(:aircraft, refresh_aircraft(socket.assigns))} | |
| 119 | 122 | end |
| 120 | 123 | |
| 124 | + def handle_event("toggle-aircraft", _params, socket) do | |
| 125 | + showing? = not socket.assigns.show_aircraft | |
| 126 | + | |
| 127 | + {:noreply, | |
| 128 | + socket | |
| 129 | + |> assign(:show_aircraft, showing?) | |
| 130 | + |> assign(:aircraft, if(showing?, do: get_all_aircraft(socket.assigns.queries), else: []))} | |
| 131 | + end | |
| 132 | + | |
| 133 | + defp refresh_aircraft(%{show_aircraft: true, queries: queries}), do: get_all_aircraft(queries) | |
| 134 | + defp refresh_aircraft(_assigns), do: [] | |
| 135 | + | |
| 121 | 136 | # One read per icarus query, so each contributes the aircraft it actually |
| 122 | 137 | # matches -- its own radius, altitude band and class filters -- rather than |
| 123 | 138 | # everything its source can see. The worker answers from cache between its |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/index.html.heex
+2
−0
@@ -45,6 +45,8 @@
| 45 | 45 | queries={@queries} |
| 46 | 46 | vehicle_positions={@vehicle_positions} |
| 47 | 47 | aircraft={@aircraft} |
| 48 | + aircraft_event={if Enum.any?(@queries, &(&1.source && &1.source.type == :icarus)), do: "toggle-aircraft"} | |
| 49 | + show_aircraft={@show_aircraft} | |
| 48 | 50 | route_lines={if @show_route_lines, do: @route_lines, else: []} |
| 49 | 51 | route_lines_event={if Enum.any?(@queries, &(&1.source && &1.source.type == :gtfs)), do: "toggle-route-lines"} |
| 50 | 52 | show_route_lines={@show_route_lines} |
added
apps/room_sanctum/test/room_sanctum_web/live/query_index_aircraft_test.exs
+123
−0
@@ -0,0 +1,123 @@
| 1 | +defmodule RoomSanctumWeb.QueryIndexAircraftTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The query index draws every query the user owns, so live aircraft are a layer | |
| 4 | + on it rather than its subject: two area queries in different cities fill the | |
| 5 | + map and zoom it out to fit them, burying everything else. | |
| 6 | + """ | |
| 7 | + use RoomSanctumWeb.ConnCase | |
| 8 | + | |
| 9 | + import Phoenix.LiveViewTest | |
| 10 | + | |
| 11 | + alias RoomSanctum.{Accounts, Configuration} | |
| 12 | + | |
| 13 | + setup %{conn: conn} do | |
| 14 | + {:ok, user} = | |
| 15 | + Accounts.register_user(%{ | |
| 16 | + email: "idx#{System.unique_integer([:positive])}@example.com", | |
| 17 | + password: "hello world!hello world!" | |
| 18 | + }) | |
| 19 | + | |
| 20 | + %{conn: log_in_user(conn, user), user: user} | |
| 21 | + end | |
| 22 | + | |
| 23 | + defp icarus_query(user, name) do | |
| 24 | + {:ok, source} = | |
| 25 | + Configuration.create_source(%{ | |
| 26 | + name: "adsb #{name}", | |
| 27 | + notes: "", | |
| 28 | + type: :icarus, | |
| 29 | + enabled: true, | |
| 30 | + user_id: user.id, | |
| 31 | + config: %{"__type__" => "icarus", "endpoint" => "https://example.test/v3"} | |
| 32 | + }) | |
| 33 | + | |
| 34 | + # Inserted directly: the icarus query changeset validates against | |
| 35 | + # RoomIcarus.Classify, and room_icarus is not a dependency of this app, so | |
| 36 | + # it cannot be loaded here. | |
| 37 | + RoomSanctum.Repo.insert!(%RoomSanctum.Configuration.Query{ | |
| 38 | + name: name, | |
| 39 | + notes: "", | |
| 40 | + source_id: source.id, | |
| 41 | + user_id: user.id, | |
| 42 | + public: true, | |
| 43 | + query: %RoomSanctum.Configuration.Queries.Icarus{mode: :area, dist: 25, foci_id: 1} | |
| 44 | + }) | |
| 45 | + end | |
| 46 | + | |
| 47 | + defp bourse_query(user) do | |
| 48 | + {:ok, source} = | |
| 49 | + Configuration.create_source(%{ | |
| 50 | + name: "Markets", | |
| 51 | + notes: "", | |
| 52 | + type: :bourse, | |
| 53 | + enabled: true, | |
| 54 | + user_id: user.id, | |
| 55 | + config: %{"__type__" => "bourse"} | |
| 56 | + }) | |
| 57 | + | |
| 58 | + {:ok, query} = | |
| 59 | + Configuration.create_query(%{ | |
| 60 | + name: "AAPL", | |
| 61 | + notes: "", | |
| 62 | + source_id: source.id, | |
| 63 | + user_id: user.id, | |
| 64 | + query: %{"__type__" => "bourse", "symbol" => "AAPL"} | |
| 65 | + }) | |
| 66 | + | |
| 67 | + query | |
| 68 | + end | |
| 69 | + | |
| 70 | + test "aircraft are off when the map opens", %{conn: conn, user: user} do | |
| 71 | + icarus_query(user, "BOS") | |
| 72 | + | |
| 73 | + {:ok, live, _html} = live(conn, Routes.query_index_path(conn, :index)) | |
| 74 | + html = live |> element("button", "Map View") |> render_click() | |
| 75 | + | |
| 76 | + refute html =~ ~s(type="aircraft") | |
| 77 | + assert html =~ "Show aircraft" | |
| 78 | + end | |
| 79 | + | |
| 80 | + test "the toggle is offered when an icarus query is on the map", %{conn: conn, user: user} do | |
| 81 | + icarus_query(user, "BOS") | |
| 82 | + | |
| 83 | + {:ok, live, _html} = live(conn, Routes.query_index_path(conn, :index)) | |
| 84 | + html = live |> element("button", "Map View") |> render_click() | |
| 85 | + | |
| 86 | + assert html =~ ~s(phx-click="toggle-aircraft") | |
| 87 | + end | |
| 88 | + | |
| 89 | + test "no icarus query means no aircraft toggle to clutter the map", %{conn: conn, user: user} do | |
| 90 | + bourse_query(user) | |
| 91 | + | |
| 92 | + {:ok, live, _html} = live(conn, Routes.query_index_path(conn, :index)) | |
| 93 | + html = live |> element("button", "Map View") |> render_click() | |
| 94 | + | |
| 95 | + refute html =~ ~s(phx-click="toggle-aircraft") | |
| 96 | + end | |
| 97 | + | |
| 98 | + test "the toggle flips, and flips back", %{conn: conn, user: user} do | |
| 99 | + icarus_query(user, "BOS") | |
| 100 | + | |
| 101 | + {:ok, live, _html} = live(conn, Routes.query_index_path(conn, :index)) | |
| 102 | + live |> element("button", "Map View") |> render_click() | |
| 103 | + | |
| 104 | + on = render_click(live, "toggle-aircraft", %{}) | |
| 105 | + assert on =~ "Hide aircraft" | |
| 106 | + | |
| 107 | + off = render_click(live, "toggle-aircraft", %{}) | |
| 108 | + assert off =~ "Show aircraft" | |
| 109 | + refute off =~ ~s(type="aircraft") | |
| 110 | + end | |
| 111 | + | |
| 112 | + test "several icarus queries still leave the layer off", %{conn: conn, user: user} do | |
| 113 | + # the reported shape: area queries in two cities, which between them cover | |
| 114 | + # most of the country once both are plotted | |
| 115 | + icarus_query(user, "BOS") | |
| 116 | + icarus_query(user, "SFO") | |
| 117 | + | |
| 118 | + {:ok, live, _html} = live(conn, Routes.query_index_path(conn, :index)) | |
| 119 | + html = live |> element("button", "Map View") |> render_click() | |
| 120 | + | |
| 121 | + refute html =~ ~s(type="aircraft") | |
| 122 | + end | |
| 123 | +end |
Parents: a94201e