Let an icarus area query cap how many aircraft it shows

12afe8a · Gabriel Morell · 2026-08-12 15:05

18 files +152 -4
Message
{commit_body(@commit)}

Files changed

modified apps/room_icarus/lib/room_icarus/worker.ex
+27 −4
@@ -302,10 +302,7 @@ defmodule RoomIcarus.Worker do
302 302 list
303 303 end
304 304
305 − cached
306 − |> filter_altitude(query)
307 − |> RoomIcarus.Classify.filter(field(query, :classes))
308 − |> Enum.sort_by(&(&1["dst"] || 0))
305 + shape_area(cached, query)
309 306 end
310 307
311 308 result
@@ -373,6 +370,32 @@ defmodule RoomIcarus.Worker do
373 370 end
374 371 end
375 372
373 + @doc """
374 + Applies an area query's filters to a cached aircraft list.
375 +
376 + Altitude and class first, then nearest-first, then the count cap -- so a
377 + limit keeps the closest aircraft the query actually asked for, rather than
378 + trimming the feed before it has been filtered.
379 +
380 + Public so the shaping can be exercised without a running worker or a foci to
381 + resolve.
382 + """
383 + def shape_area(list, query) do
384 + list
385 + |> filter_altitude(query)
386 + |> RoomIcarus.Classify.filter(field(query, :classes))
387 + |> Enum.sort_by(&(&1["dst"] || 0))
388 + |> take_limit(field(query, :limit))
389 + end
390 +
391 + # A blank or unparseable limit means no cap.
392 + defp take_limit(list, limit) do
393 + case numeric(limit) do
394 + n when is_number(n) and n >= 1 -> Enum.take(list, trunc(n))
395 + _ -> list
396 + end
397 + end
398 +
376 399 defp above?(_alt, nil), do: true
377 400 defp above?(alt, min_alt), do: alt >= min_alt
378 401
added apps/room_icarus/test/shape_area_test.exs
+111 −0
@@ -0,0 +1,111 @@
1 +defmodule RoomIcarus.ShapeAreaTest do
2 + @moduledoc """
3 + Shaping an area query's results: altitude and class filters, nearest-first,
4 + then the count cap.
5 +
6 + The cap has to come last. Trimming before the filters would let a limit of 5
7 + return two aircraft, because three of the nearest five were the wrong class.
8 + """
9 + use ExUnit.Case, async: true
10 +
11 + alias RoomIcarus.Worker
12 +
13 + # "dst" is the feed's distance in nautical miles, which is what the sort and
14 + # so the cap key off.
15 + defp plane(hex, dst, attrs \\ %{}) do
16 + Map.merge(%{"hex" => hex, "dst" => dst, "alt_baro" => 10_000}, attrs)
17 + end
18 +
19 + defp hexes(list), do: Enum.map(list, & &1["hex"])
20 +
21 + defp scattered do
22 + [
23 + plane("far", 40),
24 + plane("near", 2),
25 + plane("mid", 20)
26 + ]
27 + end
28 +
29 + describe "the count cap" do
30 + test "keeps the nearest, whatever order the feed sent them in" do
31 + assert hexes(Worker.shape_area(scattered(), %{limit: 2})) == ["near", "mid"]
32 + end
33 +
34 + test "a limit of one leaves the closest" do
35 + assert hexes(Worker.shape_area(scattered(), %{limit: 1})) == ["near"]
36 + end
37 +
38 + test "no limit shows everything, still nearest-first" do
39 + assert hexes(Worker.shape_area(scattered(), %{})) == ["near", "mid", "far"]
40 + assert hexes(Worker.shape_area(scattered(), %{limit: nil})) == ["near", "mid", "far"]
41 + end
42 +
43 + test "a limit larger than the sky is not an error" do
44 + assert length(Worker.shape_area(scattered(), %{limit: 500})) == 3
45 + end
46 +
47 + test "asking for none is treated as no limit, not an empty sky" do
48 + # the form validates limit >= 1, so a zero can only arrive from a hand
49 + # edited query -- showing everything beats silently showing nothing
50 + assert length(Worker.shape_area(scattered(), %{limit: 0})) == 3
51 + assert length(Worker.shape_area(scattered(), %{limit: -3})) == 3
52 + end
53 +
54 + test "a limit arriving as a string still counts" do
55 + # queries come back through the embed as integers, but the worker reads
56 + # string-keyed maps from the cache too
57 + assert hexes(Worker.shape_area(scattered(), %{"limit" => "2"})) == ["near", "mid"]
58 + end
59 +
60 + test "junk in the limit is ignored rather than blanking the map" do
61 + assert length(Worker.shape_area(scattered(), %{limit: "lots"})) == 3
62 + end
63 + end
64 +
65 + describe "the cap runs after the filters, not before" do
66 + test "a limit fills up with aircraft that match the class" do
67 + list = [
68 + plane("mil1", 1, %{"military" => true}),
69 + plane("mil2", 2, %{"military" => true}),
70 + plane("ual", 3, %{"flight" => "UAL558"}),
71 + plane("dal", 4, %{"flight" => "DAL221"})
72 + ]
73 +
74 + # the two nearest are military, so a naive cap-then-filter would return
75 + # nothing at all here
76 + assert hexes(Worker.shape_area(list, %{classes: ["commercial"], limit: 2})) ==
77 + ["ual", "dal"]
78 + end
79 +
80 + test "a limit fills up with aircraft inside the altitude band" do
81 + list = [
82 + plane("low1", 1, %{"alt_baro" => 500}),
83 + plane("low2", 2, %{"alt_baro" => 800}),
84 + plane("high1", 3, %{"alt_baro" => 30_000}),
85 + plane("high2", 4, %{"alt_baro" => 31_000})
86 + ]
87 +
88 + assert hexes(Worker.shape_area(list, %{alt_min: 10_000, limit: 2})) == ["high1", "high2"]
89 + end
90 +
91 + test "an empty sky stays empty" do
92 + assert Worker.shape_area([], %{limit: 5}) == []
93 + end
94 + end
95 +
96 + describe "what the cap does not change" do
97 + test "filters still apply when there is no limit" do
98 + list = [plane("ground", 1, %{"alt_baro" => "ground"}), plane("up", 2)]
99 +
100 + assert hexes(Worker.shape_area(list, %{alt_min: 1_000})) == ["up"]
101 + end
102 +
103 + test "aircraft with no distance sort first and are kept by a limit" do
104 + # "dst" missing reads as 0, so these lead -- worth pinning, since it
105 + # decides what a limit of 1 returns
106 + list = [plane("known", 5), Map.delete(plane("unknown", 0), "dst")]
107 +
108 + assert hexes(Worker.shape_area(list, %{limit: 1})) == ["unknown"]
109 + end
110 + end
111 +end
added apps/room_icarus/test/test_helper.exs
+1 −0
@@ -0,0 +1 @@
1 +ExUnit.start()
modified apps/room_sanctum/lib/room_sanctum/configuration/queries/icarus.ex
+5 −0
@@ -27,6 +27,9 @@ defmodule RoomSanctum.Configuration.Queries.Icarus do
27 27 # Empty means no filtering, so area queries saved before this field existed
28 28 # keep showing everything.
29 29 field :classes, {:array, :string}, default: []
30 + # Blank means no cap, so queries saved before this field existed are
31 + # unchanged. Results are sorted nearest-first, so this keeps the closest.
32 + field :limit, :integer
30 33
31 34 # :flight
32 35 field :flight_number, :string
@@ -48,6 +51,7 @@ defmodule RoomSanctum.Configuration.Queries.Icarus do
48 51 :alt_min,
49 52 :alt_max,
50 53 :classes,
54 + :limit,
51 55 :flight_number,
52 56 :dest,
53 57 :sched_arrival,
@@ -73,6 +77,7 @@ defmodule RoomSanctum.Configuration.Queries.Icarus do
73 77 |> validate_number(:alt_min, greater_than_or_equal_to: 0)
74 78 |> validate_number(:alt_max, greater_than_or_equal_to: 0)
75 79 |> validate_subset(:classes, RoomIcarus.Classify.classes())
80 + |> validate_number(:limit, greater_than_or_equal_to: 1)
76 81 |> validate_altitude_band()
77 82 end
78 83
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/form_component.html.heex
+8 −0
@@ -283,6 +283,14 @@
283 283 </p>
284 284 </div>
285 285
286 + <div class="form-control">
287 + <.input field={icarus_form[:limit]} type="number" label="Most aircraft to show (blank for no limit)" />
288 + <p class="text-xs text-base-content/60 mt-1">
289 + Applied after the filters above, to the nearest aircraft first - a limit of 5 shows the
290 + five closest that match, not the first five the feed happened to return.
291 + </p>
292 + </div>
293 +
286 294 <div class="divider">Flight mode</div>
287 295 <div class="form-control">
288 296 <.input field={icarus_form[:flight_number]} type="text" label="Flight number (e.g. UA 558)" />

Parents: 61d5d49