Draw a Plani as a map of what is around it

2ed496b · Gabriel Morell · 2026-09-03 17:43

18 files +195 -13
Message
{commit_body(@commit)}

Files changed

modified ?
+0 −0

Binary file — not rendered.

modified ?
+0 −0

Binary file — not rendered.

modified apps/room_sanctum/lib/room_sanctum/workers/plani_worker.ex
+76 −13
@@ -43,7 +43,16 @@ defmodule RoomSanctum.Worker.Plani do
43 43 initial_delay: 500
44 44 )
45 45
46 − {:ok, %{id: opts[:id], plani: nil, data: %{}, queries: [], anchor: nil, anchored_to: :home}}
46 + {:ok,
47 + %{
48 + id: opts[:id],
49 + plani: nil,
50 + data: %{},
51 + queries: [],
52 + places: [],
53 + anchor: nil,
54 + anchored_to: :home
55 + }}
47 56 end
48 57
49 58 defp via_tuple(name), do: {:via, Registry, {@registry, name}}
@@ -112,23 +121,32 @@ defmodule RoomSanctum.Worker.Plani do
112 121 Configuration.list_cfg_sources({:user, state.plani.user_id})
113 122 )
114 123
115 − {data, queries} =
124 + {data, queries, places} =
116 125 sources
117 − |> Enum.reduce({%{}, []}, fn source_id, {data, queries} ->
126 + |> Enum.reduce({%{}, [], []}, fn source_id, {data, queries, places} ->
118 127 case ask(source_id, anchor, state.plani) do
119 128 nil ->
120 − {data, queries}
129 + {data, queries, places}
121 130
122 − {key, results, described} ->
123 − {Map.put(data, key, results), [described | queries]}
131 + {key, results, described, found} ->
132 + {Map.put(data, key, results), [described | queries], found ++ places}
124 133 end
125 134 end)
126 135
127 − {:noreply, %{state | data: data, queries: Enum.reverse(queries), anchor: anchor, anchored_to: anchored_to}}
136 + {:noreply,
137 + %{
138 + state
139 + | data: data,
140 + queries: Enum.reverse(queries),
141 + places: places,
142 + anchor: anchor,
143 + anchored_to: anchored_to
144 + }}
128 145 end
129 146
130 147 def handle_call(:where, _from, state) do
131 − {:reply, %{anchor: state.anchor, anchored_to: state.anchored_to}, state}
148 + {:reply,
149 + %{anchor: state.anchor, anchored_to: state.anchored_to, places: state.places}, state}
132 150 end
133 151
134 152 def handle_call(:return_state, _from, state) do
@@ -163,24 +181,24 @@ defmodule RoomSanctum.Worker.Plani do
163 181
164 182 case source.type do
165 183 :gtfs ->
166 − stops = Storage.nearby_stops(source_id, anchor, plani.limit) |> Enum.map(& &1.stop_id)
184 + stops = Storage.nearby_stops(source_id, anchor, plani.limit)
167 185
168 186 arrivals =
169 187 stops
170 − |> Enum.flat_map(&Storage.get_upcoming_arrivals_for_stop(source_id, &1, 8, :now, source.config.tz))
188 + |> Enum.flat_map(&Storage.get_upcoming_arrivals_for_stop(source_id, &1.stop_id, 8, :now, source.config.tz))
171 189 |> Storage.fix_arrival_times()
172 190 |> Enum.sort_by(& &1.arrival_time)
173 191 |> Enum.take(16)
174 192
175 − {{source_id, :gtfs}, arrivals, described(source)}
193 + {{source_id, :gtfs}, arrivals, described(source), places(stops, source, :stop)}
176 194
177 195 :gbfs ->
178 196 bikes = Storage.free_bikes_near(source_id, anchor, plani.radius)
179 − {{source_id, :gbfs}, bikes, described(source)}
197 + {{source_id, :gbfs}, bikes, described(source), places(bikes, source, :bike)}
180 198
181 199 :aqi ->
182 200 readings = Storage.nearby_aqi_stations(source_id, anchor, plani.limit)
183 − {{source_id, :aqi}, readings, described(source)}
201 + {{source_id, :aqi}, readings, described(source), places(readings, source, :monitor)}
184 202
185 203 other ->
186 204 # Sources with nothing located in them, or nothing near-able yet. Left
@@ -203,4 +221,49 @@ defmodule RoomSanctum.Worker.Plani do
203 221 defp described(source) do
204 222 %{id: source.id, name: source.name, meta: %{}, query: %{}}
205 223 end
224 +
225 + # Where the things it found actually are, for anything drawing a map.
226 + #
227 + # Flattened to one shape rather than kept as the records they came from: a
228 + # stop, a loose bike and an air quality monitor are three different rows in
229 + # three different tables, and all a map wants of any of them is a point, a
230 + # name and which of the three it is.
231 + defp places(records, source, kind) do
232 + records
233 + |> Enum.map(fn record ->
234 + case coordinates(record) do
235 + nil ->
236 + nil
237 +
238 + {lat, lon} ->
239 + %{
240 + lat: lat,
241 + lon: lon,
242 + kind: kind,
243 + name: name_of(record, kind),
244 + source_id: source.id,
245 + source_name: source.name,
246 + tint: source.meta && Map.get(source.meta, :tint)
247 + }
248 + end
249 + end)
250 + |> Enum.reject(&is_nil/1)
251 + end
252 +
253 + # Each table spells its position differently: stops carry the floats they
254 + # were imported with, the rest carry a point.
255 + defp coordinates(%{stop_lat: lat, stop_lon: lon}) when is_number(lat) and is_number(lon),
256 + do: {lat, lon}
257 +
258 + defp coordinates(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon), do: {lat, lon}
259 +
260 + defp coordinates(%{point: %Geo.Point{coordinates: {lon, lat}}}), do: {lat, lon}
261 +
262 + defp coordinates(_record), do: nil
263 +
264 + defp name_of(%{stop_name: name}, :stop) when is_binary(name), do: name
265 + defp name_of(%{name: name}, _kind) when is_binary(name), do: name
266 + defp name_of(%{site_name: name}, :monitor) when is_binary(name), do: name
267 + defp name_of(%{bike_id: id}, :bike), do: id
268 + defp name_of(_record, kind), do: to_string(kind)
206 269 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_plani_live/show.ex
+43 −0
@@ -1,6 +1,8 @@
1 1 defmodule RoomSanctumWeb.PlaniLive.Show do
2 2 use RoomSanctumWeb, :live_view_a
3 3
4 + import RoomSanctumWeb.Components.QueryGeospatialMap
5 +
4 6 alias RoomSanctum.Configuration
5 7
6 8 @impl true
@@ -33,6 +35,47 @@ defmodule RoomSanctumWeb.PlaniLive.Show do
33 35 |> assign(:state, RoomSanctum.Worker.Plani.get_state(id))
34 36 end
35 37
38 + @doc """
39 + Where to centre the map: the anchor, in the shape the map component wants.
40 +
41 + Nil until a worker has resolved one, which the component reads as "fit
42 + whatever markers there are" -- the right answer for a Plani that has not
43 + ticked yet.
44 + """
45 + def focus(%{anchor: %Geo.Point{coordinates: {lon, lat}}}), do: %{lat: lat, lng: lon}
46 + def focus(_), do: nil
47 +
48 + @doc """
49 + The things found near the anchor, as map markers.
50 +
51 + Bikes go on their own layer because the component draws them differently;
52 + everything else is a station, which is the component's generic marker.
53 + """
54 + def markers(%{places: places}, kind) when is_list(places) do
55 + places
56 + |> Enum.filter(&(marker_layer(&1.kind) == kind))
57 + |> Enum.map(&as_marker/1)
58 + end
59 +
60 + def markers(_where, _kind), do: []
61 +
62 + defp marker_layer(:bike), do: :bikes
63 + defp marker_layer(_kind), do: :stations
64 +
65 + defp as_marker(place) do
66 + %{
67 + place: %Geo.Point{coordinates: {place.lon, place.lat}, srid: 4326},
68 + station_id: "#{place.source_id}-#{place.name}",
69 + bike_id: "#{place.source_id}-#{place.name}",
70 + name: place.name,
71 + short_name: place.name,
72 + capacity: 0,
73 + address: place.source_name,
74 + lat: place.lat,
75 + lon: place.lon
76 + }
77 + end
78 +
36 79 @doc "A point as something readable, or nil."
37 80 def coordinates(%{anchor: %Geo.Point{coordinates: {lon, lat}}}) do
38 81 "#{Float.round(lat, 5)}, #{Float.round(lon, 5)}"
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_plani_live/show.html.heex
+24 −0
@@ -3,6 +3,30 @@
3 3
4 4 <h1 class="text-3xl font-bold text-accent mx-4 py-2"><%= @plani.name %></h1>
5 5
6 +<div class="mx-4 py-2">
7 + <div class="card card-compact w-full bg-base-100 shadow-xl">
8 + <div class="card-body">
9 + <h3 class="card-title">
10 + <i class="fas fa-fw fa-map-location-dot"></i> What is around it
11 + </h3>
12 + <p class="text-sm opacity-70">
13 + The anchor and everything found near it. The map follows the anchor, so
14 + a client that is moving redraws this as it goes.
15 + </p>
16 + <.query_geospatial_map
17 + id="plani-map"
18 + queries={[]}
19 + height="420px"
20 + focus={RoomSanctumWeb.PlaniLive.Show.focus(@anchor)}
21 + focus_zoom={15}
22 + stations={RoomSanctumWeb.PlaniLive.Show.markers(@anchor, :stations)}
23 + free_bikes={RoomSanctumWeb.PlaniLive.Show.markers(@anchor, :bikes)}
24 + legend={false}
25 + />
26 + </div>
27 + </div>
28 +</div>
29 +
6 30 <div class="mx-4 py-2 flex flex-wrap">
7 31 <div class="card card-compact w-full md:w-1/2 bg-base-100 shadow-xl m-2">
8 32 <div class="card-body">
modified apps/room_sanctum/test/room_sanctum_web/live/plani_live_test.exs
+52 −0
@@ -92,6 +92,58 @@ defmodule RoomSanctumWeb.PlaniLiveTest do
92 92 assert html =~ "following everything tinted"
93 93 end
94 94
95 + describe "the map" do
96 + alias RoomSanctumWeb.PlaniLive.Show
97 +
98 + test "centres on the anchor" do
99 + where = %{anchor: %Geo.Point{coordinates: {-71.1, 42.4}, srid: 4326}}
100 +
101 + assert Show.focus(where) == %{lat: 42.4, lng: -71.1}
102 + end
103 +
104 + test "has nothing to centre on before the worker has resolved one" do
105 + # Which the component reads as "fit whatever markers there are", the
106 + # right answer for a Plani that has not ticked yet.
107 + assert Show.focus(nil) == nil
108 + assert Show.focus(%{anchor: nil}) == nil
109 + end
110 +
111 + test "puts bikes on their own layer and everything else on the other" do
112 + where = %{
113 + places: [
114 + %{lat: 42.4, lon: -71.1, kind: :stop, name: "Davis", source_id: 1, source_name: "MBTA", tint: nil},
115 + %{lat: 42.5, lon: -71.2, kind: :bike, name: "b-1", source_id: 2, source_name: "Bluebikes", tint: nil},
116 + %{lat: 42.6, lon: -71.3, kind: :monitor, name: "Site", source_id: 3, source_name: "AirNow", tint: nil}
117 + ]
118 + }
119 +
120 + assert Enum.map(Show.markers(where, :bikes), & &1.name) == ["b-1"]
121 + assert Enum.map(Show.markers(where, :stations), & &1.name) == ["Davis", "Site"]
122 + end
123 +
124 + test "gives every marker a point the map can draw" do
125 + where = %{places: [%{lat: 42.4, lon: -71.1, kind: :stop, name: "Davis", source_id: 1, source_name: "MBTA", tint: nil}]}
126 + [marker] = Show.markers(where, :stations)
127 +
128 + # {lon, lat}, as everything geospatial in this app stores it.
129 + assert marker.place.coordinates == {-71.1, 42.4}
130 + assert marker.lat == 42.4
131 + end
132 +
133 + test "draws nothing before the worker has found anything" do
134 + assert Show.markers(nil, :stations) == []
135 + assert Show.markers(%{anchor: nil}, :bikes) == []
136 + end
137 + end
138 +
139 + test "the map is on the page", ctx do
140 + plani = plani(ctx)
141 + {:ok, _live, html} = live(ctx.conn, "/cfg/plani/#{plani.id}")
142 +
143 + assert html =~ "plani-map"
144 + assert html =~ "What is around it"
145 + end
146 +
95 147 test "a tint that is not a tint is refused", ctx do
96 148 assert {:error, changeset} =
97 149 Configuration.create_plani(%{

Parents: 951c85d