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.
Let an icarus area query cap how many aircraft it shows
12afe8a · Gabriel Morell · 2026-08-12 15:05
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