Make aircraft a layer on the query index, off by default

781a566 · Gabriel Morell · 2026-08-10 22:35

14 files +168 -2
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