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.
Add a map to the vision preview, and fix where queries are placed
fc3f047 · Gabriel Morell · 2026-08-10 23:04
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+19
−17
@@ -501,19 +501,20 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 501 | 501 | false |
| 502 | 502 | end |
| 503 | 503 | |
| 504 | − :aqi -> | |
| 505 | − # For AQI, get coordinates from the monitoring site/foci | |
| 506 | − if query.query && Map.has_key?(query.query, :foci_id) do | |
| 507 | − get_aqi_foci_coordinates(query.query.foci_id) | |
| 508 | − else | |
| 509 | − false | |
| 510 | − end | |
| 511 | − | |
| 512 | − _ -> | |
| 513 | − false | |
| 504 | + # Everything else that knows where it is says so with a foci: aqi, | |
| 505 | + # weather, tidal, ephem, pollen, drought and icarus area queries all | |
| 506 | + # carry one, and used to fall through to "not mappable". | |
| 507 | + _ -> | |
| 508 | + foci_coordinates(query) | |
| 514 | 509 | end |
| 515 | 510 | end |
| 516 | 511 | |
| 512 | + defp foci_coordinates(%{query: %{foci_id: foci_id}}) when not is_nil(foci_id) do | |
| 513 | + get_foci_coordinates(foci_id) | |
| 514 | + end | |
| 515 | + | |
| 516 | + defp foci_coordinates(_query), do: false | |
| 517 | + | |
| 517 | 518 | # Get coordinates for GTFS stop |
| 518 | 519 | defp get_gtfs_stop_coordinates(source_id, stop_id) do |
| 519 | 520 | case RoomSanctum.Storage.get_stop_by_id(source_id, stop_id) do |
@@ -575,16 +576,17 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 575 | 576 | end |
| 576 | 577 | |
| 577 | 578 | # Get coordinates for AQI foci |
| 578 | − defp get_aqi_foci_coordinates(foci_id) do | |
| 579 | + # The field is :place. This read :point, and since that was in a guard the | |
| 580 | + # KeyError was swallowed and the clause simply never matched -- so a foci | |
| 581 | + # has never resolved to anywhere, and no aqi query has ever been placed. | |
| 582 | + defp get_foci_coordinates(foci_id) do | |
| 579 | 583 | case RoomSanctum.Storage.get_foci_by_id(foci_id) do |
| 580 | − nil -> false | |
| 581 | − foci when foci.point != nil -> | |
| 582 | − case foci.point do | |
| 583 | − %Geo.Point{coordinates: {lng, lat}} -> {lat, lng} | |
| 584 | − _ -> false | |
| 585 | − end | |
| 584 | + %{place: %Geo.Point{coordinates: {lng, lat}}} -> {lat, lng} | |
| 586 | 585 | _ -> false |
| 587 | 586 | end |
| 587 | + rescue | |
| 588 | + # get_foci_by_id raises rather than returning nil for a foci that is gone. | |
| 589 | + Ecto.NoResultsError -> false | |
| 588 | 590 | end |
| 589 | 591 | |
| 590 | 592 | # Get coordinates from GTFS vehicle positions |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/show.ex
+3
−1
@@ -1,5 +1,6 @@
| 1 | 1 | defmodule RoomSanctumWeb.VisionLive.Show do |
| 2 | 2 | use RoomSanctumWeb, :live_view_a |
| 3 | + import RoomSanctumWeb.Components.QueryGeospatialMap | |
| 3 | 4 | |
| 4 | 5 | alias RoomSanctum.Configuration |
| 5 | 6 |
@@ -42,7 +43,8 @@ defmodule RoomSanctumWeb.VisionLive.Show do
| 42 | 43 | defp do_toggle(state) do |
| 43 | 44 | case state do |
| 44 | 45 | :basic -> :raw |
| 45 | − :raw -> :basic | |
| 46 | + :raw -> :map | |
| 47 | + :map -> :basic | |
| 46 | 48 | end |
| 47 | 49 | end |
| 48 | 50 |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/show.html.heex
+15
−0
@@ -157,6 +157,21 @@
| 157 | 157 | </div> |
| 158 | 158 | </div> |
| 159 | 159 | <% end %> |
| 160 | + <% :map -> %> | |
| 161 | + <%!-- Where this vision's queries are. Anything with a place | |
| 162 | + -- a stop, a dock, a foci -- plots; the rest of the | |
| 163 | + vision is simply not on a map. --%> | |
| 164 | + <%= if @queries == [] do %> | |
| 165 | + <p class="text-sm opacity-60">Nothing to place yet.</p> | |
| 166 | + <% else %> | |
| 167 | + <.query_geospatial_map | |
| 168 | + id="vision-map" | |
| 169 | + queries={@queries} | |
| 170 | + class="" | |
| 171 | + height="420px" | |
| 172 | + legend={false} | |
| 173 | + /> | |
| 174 | + <% end %> | |
| 160 | 175 | <% end %> |
| 161 | 176 | </div> |
| 162 | 177 | </div> |
added
apps/room_sanctum/test/room_sanctum_web/components/query_placement_test.exs
+132
−0
@@ -0,0 +1,132 @@
| 1 | +defmodule RoomSanctumWeb.QueryPlacementTest do | |
| 2 | + @moduledoc """ | |
| 3 | + Where a query sits on a map. | |
| 4 | + | |
| 5 | + No query in this system carries a geom, so a query's position is always | |
| 6 | + derived: a stop's coordinates, a dock's, or the foci it was pointed at. | |
| 7 | + """ | |
| 8 | + use RoomSanctumWeb.ConnCase, async: true | |
| 9 | + | |
| 10 | + import Phoenix.LiveViewTest | |
| 11 | + import RoomSanctumWeb.Components.QueryGeospatialMap | |
| 12 | + | |
| 13 | + alias RoomSanctum.{Accounts, Configuration, Repo} | |
| 14 | + alias RoomSanctum.Storage.GTFS.Stop | |
| 15 | + | |
| 16 | + setup do | |
| 17 | + {:ok, user} = | |
| 18 | + Accounts.register_user(%{ | |
| 19 | + email: "place#{System.unique_integer([:positive])}@example.com", | |
| 20 | + password: "hello world!hello world!" | |
| 21 | + }) | |
| 22 | + | |
| 23 | + {:ok, foci} = | |
| 24 | + Configuration.create_foci(%{ | |
| 25 | + name: "Home", | |
| 26 | + user_id: user.id, | |
| 27 | + place: %Geo.Point{coordinates: {-71.0589, 42.3601}, srid: 4326} | |
| 28 | + }) | |
| 29 | + | |
| 30 | + %{user: user, foci: foci} | |
| 31 | + end | |
| 32 | + | |
| 33 | + defp source(user, type, config) do | |
| 34 | + {:ok, source} = | |
| 35 | + Configuration.create_source(%{ | |
| 36 | + name: "#{type}", notes: "", type: type, enabled: true, | |
| 37 | + user_id: user.id, config: config | |
| 38 | + }) | |
| 39 | + | |
| 40 | + source | |
| 41 | + end | |
| 42 | + | |
| 43 | + defp render_queries(queries) do | |
| 44 | + render_component(&query_geospatial_map/1, queries: queries, id: "t") | |
| 45 | + end | |
| 46 | + | |
| 47 | + test "a foci-based query is placed at its foci", %{user: user, foci: foci} do | |
| 48 | + src = source(user, :ephem, %{"__type__" => "ephem"}) | |
| 49 | + | |
| 50 | + {:ok, query} = | |
| 51 | + Configuration.create_query(%{ | |
| 52 | + name: "Weather at home", notes: "", source_id: src.id, user_id: user.id, | |
| 53 | + query: %{"__type__" => "ephem", "foci_id" => foci.id} | |
| 54 | + }) | |
| 55 | + | |
| 56 | + html = render_queries([Configuration.get_query!(query.id)]) | |
| 57 | + | |
| 58 | + assert html =~ ~s(type="query") | |
| 59 | + assert html =~ ~s(name="Weather at home") | |
| 60 | + assert html =~ ~s(lat="42.3601") | |
| 61 | + end | |
| 62 | + | |
| 63 | + test "an icarus area query is placed at its foci too", %{user: user, foci: foci} do | |
| 64 | + src = source(user, :icarus, %{"__type__" => "icarus"}) | |
| 65 | + | |
| 66 | + query = | |
| 67 | + Repo.insert!(%Configuration.Query{ | |
| 68 | + name: "Overhead", notes: "", source_id: src.id, user_id: user.id, public: true, | |
| 69 | + query: %RoomSanctum.Configuration.Queries.Icarus{ | |
| 70 | + mode: :area, dist: 25, foci_id: foci.id | |
| 71 | + } | |
| 72 | + }) | |
| 73 | + | |
| 74 | + html = render_queries([Configuration.get_query!(query.id)]) | |
| 75 | + | |
| 76 | + assert html =~ ~s(name="Overhead") | |
| 77 | + assert html =~ ~s(lat="42.3601") | |
| 78 | + end | |
| 79 | + | |
| 80 | + test "a stop query is placed at the stop", %{user: user} do | |
| 81 | + src = source(user, :gtfs, %{"__type__" => "gtfs", "url" => "https://e.test/g.zip", "tz" => "UTC"}) | |
| 82 | + Repo.insert!(%Stop{source_id: src.id, stop_id: "s1", stop_name: "Park St", | |
| 83 | + stop_lat: 42.3564, stop_lon: -71.0624}) | |
| 84 | + | |
| 85 | + {:ok, query} = | |
| 86 | + Configuration.create_query(%{ | |
| 87 | + name: "Park St", notes: "", source_id: src.id, user_id: user.id, | |
| 88 | + query: %{"__type__" => "gtfs", "stop" => "s1"} | |
| 89 | + }) | |
| 90 | + | |
| 91 | + html = render_queries([Configuration.get_query!(query.id)]) | |
| 92 | + | |
| 93 | + assert html =~ ~s(lat="42.3564") | |
| 94 | + end | |
| 95 | + | |
| 96 | + test "a query with nowhere to be is left off rather than placed at 0,0", %{user: user} do | |
| 97 | + src = source(user, :bourse, %{"__type__" => "bourse"}) | |
| 98 | + | |
| 99 | + {:ok, query} = | |
| 100 | + Configuration.create_query(%{ | |
| 101 | + name: "AAPL", notes: "", source_id: src.id, user_id: user.id, | |
| 102 | + query: %{"__type__" => "bourse", "symbol" => "AAPL"} | |
| 103 | + }) | |
| 104 | + | |
| 105 | + html = render_queries([Configuration.get_query!(query.id)]) | |
| 106 | + | |
| 107 | + refute html =~ ~s(name="AAPL") | |
| 108 | + refute html =~ ~s(lat="0") | |
| 109 | + end | |
| 110 | + | |
| 111 | + test "placeable and unplaceable queries mix without either breaking", %{user: user, foci: foci} do | |
| 112 | + ephem = source(user, :ephem, %{"__type__" => "ephem"}) | |
| 113 | + markets = source(user, :bourse, %{"__type__" => "bourse"}) | |
| 114 | + | |
| 115 | + {:ok, a} = | |
| 116 | + Configuration.create_query(%{ | |
| 117 | + name: "Weather", notes: "", source_id: ephem.id, user_id: user.id, | |
| 118 | + query: %{"__type__" => "ephem", "foci_id" => foci.id} | |
| 119 | + }) | |
| 120 | + | |
| 121 | + {:ok, b} = | |
| 122 | + Configuration.create_query(%{ | |
| 123 | + name: "Markets", notes: "", source_id: markets.id, user_id: user.id, | |
| 124 | + query: %{"__type__" => "bourse", "symbol" => "AAPL"} | |
| 125 | + }) | |
| 126 | + | |
| 127 | + html = render_queries(Enum.map([a, b], &Configuration.get_query!(&1.id))) | |
| 128 | + | |
| 129 | + assert html =~ ~s(name="Weather") | |
| 130 | + refute html =~ ~s(name="Markets") | |
| 131 | + end | |
| 132 | +end |
added
apps/room_sanctum/test/room_sanctum_web/live/vision_preview_test.exs
+75
−0
@@ -0,0 +1,75 @@
| 1 | +defmodule RoomSanctumWeb.VisionPreviewTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The vision preview cycles through its visualizers. The map is the third: | |
| 4 | + where the vision's queries actually are. | |
| 5 | + """ | |
| 6 | + use RoomSanctumWeb.ConnCase | |
| 7 | + | |
| 8 | + import Phoenix.LiveViewTest | |
| 9 | + | |
| 10 | + alias RoomSanctum.{Accounts, Configuration, Repo} | |
| 11 | + alias RoomSanctum.Storage.GTFS.Stop | |
| 12 | + | |
| 13 | + setup %{conn: conn} do | |
| 14 | + {:ok, user} = | |
| 15 | + Accounts.register_user(%{ | |
| 16 | + email: "vis#{System.unique_integer([:positive])}@example.com", | |
| 17 | + password: "hello world!hello world!" | |
| 18 | + }) | |
| 19 | + | |
| 20 | + {:ok, source} = | |
| 21 | + Configuration.create_source(%{ | |
| 22 | + name: "T", notes: "", type: :gtfs, enabled: true, user_id: user.id, | |
| 23 | + config: %{"__type__" => "gtfs", "url" => "https://e.test/g.zip", "tz" => "UTC"} | |
| 24 | + }) | |
| 25 | + | |
| 26 | + Repo.insert!(%Stop{source_id: source.id, stop_id: "s1", stop_name: "Park St", | |
| 27 | + stop_lat: 42.3564, stop_lon: -71.0624}) | |
| 28 | + | |
| 29 | + {:ok, query} = | |
| 30 | + Configuration.create_query(%{ | |
| 31 | + name: "Park St", notes: "", source_id: source.id, user_id: user.id, | |
| 32 | + query: %{"__type__" => "gtfs", "stop" => "s1"} | |
| 33 | + }) | |
| 34 | + | |
| 35 | + {:ok, vision} = | |
| 36 | + Configuration.create_vision(%{name: "Commute", user_id: user.id, query_ids: [query.id]}) | |
| 37 | + | |
| 38 | + %{conn: log_in_user(conn, user), vision: vision} | |
| 39 | + end | |
| 40 | + | |
| 41 | + test "the preview opens on basic", %{conn: conn, vision: vision} do | |
| 42 | + {:ok, _live, html} = live(conn, Routes.vision_show_path(conn, :show, vision)) | |
| 43 | + | |
| 44 | + assert html =~ ">basic<" | |
| 45 | + end | |
| 46 | + | |
| 47 | + test "basic, then raw, then map, then round again", %{conn: conn, vision: vision} do | |
| 48 | + {:ok, live, _html} = live(conn, Routes.vision_show_path(conn, :show, vision)) | |
| 49 | + | |
| 50 | + assert render_click(live, "toggle-preview-mode", %{}) =~ ">raw<" | |
| 51 | + assert render_click(live, "toggle-preview-mode", %{}) =~ ">map<" | |
| 52 | + assert render_click(live, "toggle-preview-mode", %{}) =~ ">basic<" | |
| 53 | + end | |
| 54 | + | |
| 55 | + test "the map says so rather than drawing an empty world", %{conn: conn, vision: vision} do | |
| 56 | + {:ok, live, _html} = live(conn, Routes.vision_show_path(conn, :show, vision)) | |
| 57 | + | |
| 58 | + render_click(live, "toggle-preview-mode", %{}) | |
| 59 | + map = render_click(live, "toggle-preview-mode", %{}) | |
| 60 | + | |
| 61 | + # nothing has come back from the vision worker yet | |
| 62 | + assert map =~ "Nothing to place yet" | |
| 63 | + refute map =~ ~s(id="vision-map") | |
| 64 | + end | |
| 65 | + | |
| 66 | + test "leaving the map takes it off the page", %{conn: conn, vision: vision} do | |
| 67 | + {:ok, live, _html} = live(conn, Routes.vision_show_path(conn, :show, vision)) | |
| 68 | + | |
| 69 | + render_click(live, "toggle-preview-mode", %{}) | |
| 70 | + render_click(live, "toggle-preview-mode", %{}) | |
| 71 | + back = render_click(live, "toggle-preview-mode", %{}) | |
| 72 | + | |
| 73 | + refute back =~ ~s(id="vision-map") | |
| 74 | + end | |
| 75 | +end |
Parents: b0f58c2