Add a map to the vision preview, and fix where queries are placed

fc3f047 · Gabriel Morell · 2026-08-10 23:04

16 files +244 -18
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