Map every offering of one tint at /cfg/offerings/map/:tint

5844f5c · Gabriel Morell · 2026-08-24 05:19

12 files +311 -57
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/index.html.heex
+13 −3
@@ -103,10 +103,20 @@
103 103 <span class="text-slate-500"></span>
104 104 <span class="text-stone-950"></span>
105 105
106 − <!-- Available tints - clickable filters -->
106 + <!-- Available tints - clickable filters, each with a link to its map -->
107 107 <%= for tint <- @available_tints do %>
108 − <span class="cursor-pointer mx-1" phx-click="set-tint" phx-value-tint={tint}>
109 − <i class={"fa-solid fa-circle text-#{tint}-500 hover:text-#{tint}-700 #{if @tint == tint, do: "text-lg", else: ""}"}></i>
108 + <span class="inline-flex items-center mx-1">
109 + <span class="cursor-pointer" phx-click="set-tint" phx-value-tint={tint}>
110 + <i class={"fa-solid fa-circle text-#{tint}-500 hover:text-#{tint}-700 #{if @tint == tint, do: "text-lg", else: ""}"}></i>
111 + </span>
112 + <%!-- The dot filters this table; the pin leaves for the map of
113 + everything wearing that colour. Two actions on one glyph
114 + would make one of them undiscoverable. --%>
115 + <.link navigate={~p"/cfg/offerings/map/#{tint}"}
116 + title={"Map every #{tint} offering"}
117 + class={"ml-0.5 #{if @tint == tint, do: "", else: "opacity-40 hover:opacity-100"}"}>
118 + <i class={"fa-solid fa-location-dot fa-2xs text-#{tint}-600"}></i>
119 + </.link>
110 120 </span>
111 121 <% end %>
112 122 </th>
added apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/map_view.ex
+97 −0
@@ -0,0 +1,97 @@
1 +defmodule RoomSanctumWeb.SourceLive.MapView do
2 + @moduledoc """
3 + Every source of one tint, on one map: `/cfg/offerings/map/orange`.
4 +
5 + The offerings index already treats tint as a grouping -- the coloured dots
6 + filter the table by it -- and this is that grouping taken to the map. Tint is
7 + the only handle the app gives you for "these sources belong together", so it
8 + is the one thing worth having a map of.
9 +
10 + ## Named `MapView`, not `Map`
11 +
12 + `RoomSanctumWeb.SourceLive.Map` would be the obvious name and is a trap: an
13 + alias of `Map` shadows `Elixir.Map` inside the module, so `Map.get/2` in this
14 + file or any file aliasing it would resolve here instead. The map component
15 + calls `Map.get` on every marker.
16 +
17 + ## Sources that draw nothing
18 +
19 + Only GTFS, GBFS and AirNow sources store geography; see
20 + `RoomSanctumWeb.SourceLive.Stations`. A tint applied only to, say, weather
21 + and package sources has nothing to plot, so the page says which sources it
22 + matched and which of them had places, rather than showing an empty map and
23 + leaving the reason to be guessed at.
24 + """
25 +
26 + use RoomSanctumWeb, :live_view_a
27 + import RoomSanctumWeb.Components.QueryGeospatialMap
28 +
29 + alias RoomSanctum.Configuration
30 + alias RoomSanctumWeb.SourceLive.Stations
31 +
32 + # A backstop, not a budget. An offering's own map draws MBTA's ten thousand
33 + # stops with no cap at all, and orange alone is three bus agencies and some
34 + # twenty-three thousand -- so a ceiling low enough to bite on a real tint
35 + # would be degrading the thing this page exists to do. Canvas circle markers
36 + # share one renderer and cope with that; this is here so a tint that grows
37 + # to something absurd fails as a truncated map, which the component says out
38 + # loud, rather than as a wedged browser.
39 + @max_markers 50_000
40 +
41 + @impl true
42 + def mount(_params, _session, socket) do
43 + {:ok,
44 + socket
45 + |> assign(:tint, nil)
46 + |> assign(:sources, [])
47 + |> assign(:stations, [])
48 + |> assign(:station_statuses, [])
49 + |> assign(:counts, %{})
50 + |> assign(:max_markers, @max_markers)}
51 + end
52 +
53 + @impl true
54 + def handle_params(%{"tint" => tint}, _url, socket) do
55 + if RoomSanctum.Tints.valid?(tint) do
56 + {:noreply, load_tint(socket, tint)}
57 + else
58 + # An unknown tint has no stylesheet behind it, so every `bg-#{tint}-500`
59 + # on the page would render unstyled. Refuse it at the door.
60 + {:noreply,
61 + socket
62 + |> put_flash(:error, "No such tint: #{tint}")
63 + |> push_navigate(to: ~p"/cfg/offerings")}
64 + end
65 + end
66 +
67 + defp load_tint(socket, tint) do
68 + sources =
69 + Configuration.list_cfg_sources({:user, socket.assigns.current_user.id})
70 + |> Enum.filter(&(&1.meta && &1.meta.tint == tint))
71 + |> Enum.sort_by(& &1.name)
72 +
73 + # Loaded once per source and kept alongside it. The sidebar wants a count
74 + # per source and the map wants one flat list; deriving both from the same
75 + # pass is the difference between reading every stops table once and reading
76 + # it twice.
77 + #
78 + # Labelled per source too, because one map holding several of them
79 + # otherwise gives no way to tell whose stop a marker is.
80 + per_source =
81 + Enum.map(sources, fn source ->
82 + {source, source |> Stations.for_source() |> Stations.label_with_source(source)}
83 + end)
84 +
85 + socket
86 + |> assign(:page_title, "#{String.capitalize(tint)} Offerings Map")
87 + |> assign(:tint, tint)
88 + |> assign(:sources, sources)
89 + |> assign(:stations, Enum.flat_map(per_source, fn {_source, stations} -> stations end))
90 + |> assign(:counts, Map.new(per_source, fn {source, stations} -> {source.id, length(stations)} end))
91 + |> assign(:station_statuses, Enum.flat_map(sources, &Stations.statuses_for_source/1))
92 + end
93 +
94 + defp mappable?(source), do: Stations.mappable?(source)
95 +
96 + defp icon_code(source_type), do: RoomSanctumWeb.IconHelpers.icon_code(source_type)
97 +end
added apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/map_view.html.heex
+69 −0
@@ -0,0 +1,69 @@
1 +<div class="flex items-center justify-between mx-4">
2 + <h1 class="text-3xl font-bold text-accent bg-secondary-300 py-2">
3 + <i class={"fa-solid fa-circle text-#{@tint}-500 fa-sm"}></i>
4 + <%= String.capitalize(@tint) %> Offerings
5 + </h1>
6 +
7 + <.itb icon="fa-arrow-left" to={~p"/cfg/offerings"} what="All Offerings"></.itb>
8 +</div>
9 +
10 +<%= if @sources == [] do %>
11 + <div class="card card-compact w-full bg-base-100 shadow-xl mx-6 my-2">
12 + <div class="card-body">
13 + <h3 class="card-title italic">Nothing tinted <%= @tint %></h3>
14 + <p>
15 + No offering of yours carries this tint. Tint one on its edit form and it
16 + will appear here.
17 + </p>
18 + </div>
19 + </div>
20 +<% else %>
21 + <div class="mx-6 my-2 flex flex-wrap gap-2">
22 + <%= for source <- @sources do %>
23 + <.link navigate={~p"/cfg/offerings/#{source.id}"}
24 + class={"badge badge-lg gap-2 bg-#{@tint}-100 border-#{@tint}-400 hover:bg-#{@tint}-200"}>
25 + <i class={"fa fa-fw #{icon_code(source.type)}"}></i>
26 + <%= source.name %>
27 + <span class="opacity-70 text-xs">
28 + <%= cond do %>
29 + <% !mappable?(source) -> %>no places
30 + <% Map.get(@counts, source.id, 0) == 0 -> %>nothing loaded
31 + <% true -> %><%= Map.get(@counts, source.id) %>
32 + <% end %>
33 + </span>
34 + </.link>
35 + <% end %>
36 + </div>
37 +
38 + <%= if @stations == [] do %>
39 + <div class="card card-compact w-full bg-base-100 shadow-xl mx-6 my-2">
40 + <div class="card-body">
41 + <h3 class="card-title italic">Nothing to draw</h3>
42 + <p>
43 + These offerings have no geography of their own. Only GTFS, GBFS and
44 + AirNow sources store places; for the rest, where you want the data is
45 + a property of the query rather than of the offering, so their queries
46 + are what you would map.
47 + </p>
48 + <p :if={Enum.any?(@sources, &mappable?(&1))} class="text-sm opacity-70">
49 + Some of these could draw places but have none loaded yet -- run an
50 + update from the offering's page first.
51 + </p>
52 + </div>
53 + </div>
54 + <% else %>
55 + <div class="px-6">
56 + <.query_geospatial_map
57 + id="tint-map"
58 + queries={[]}
59 + stations={@stations}
60 + station_statuses={@station_statuses}
61 + source_tint={@tint}
62 + selected_tint={@tint}
63 + max_markers={@max_markers}
64 + class=""
65 + height="700px"
66 + />
67 + </div>
68 + <% end %>
69 +<% end %>
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+3 −54
@@ -4,6 +4,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
4 4
5 5 alias RoomSanctum.Configuration
6 6 alias RoomSanctum.Storage
7 + alias RoomSanctumWeb.SourceLive.Stations
7 8
8 9 @impl true
9 10 def mount(_params, _session, socket) do
@@ -72,21 +73,10 @@ defmodule RoomSanctumWeb.SourceLive.Show do
72 73 _ -> []
73 74 end
74 75
75 − # Stations for the map. GTFS stops carry stop_lat/stop_lon/stop_name where
76 − # GBFS uses lat/lon/name, so they are normalised to one shape rather than
77 − # teaching the map component two vocabularies.
78 − stations = case socket.assigns.source.type do
79 − :gbfs -> Storage.list_gbfs_station_information(socket.assigns.source_id)
80 − :gtfs -> socket.assigns.source_id |> Storage.list_stops() |> Enum.map(&stop_as_station/1)
81 − :aqi -> socket.assigns.source_id |> Storage.list_aqi_stations() |> Enum.map(&site_as_station/1)
82 − _ -> []
83 − end
76 + stations = Stations.for_source(socket.assigns.source)
84 77
85 78 # Add station status for GBFS sources
86 − station_statuses = case socket.assigns.source.type do
87 − :gbfs -> Storage.list_gbfs_station_status(socket.assigns.source_id)
88 − _ -> []
89 − end
79 + station_statuses = Stations.statuses_for_source(socket.assigns.source)
90 80
91 81 # Service alerts, for a GTFS source that has an alert feed configured.
92 82 alerts = current_alerts(socket.assigns.source, queries)
@@ -321,47 +311,6 @@ defmodule RoomSanctumWeb.SourceLive.Show do
321 311
322 312 defp current_aircraft(_type, _source_id), do: []
323 313
324 − # format_stations/3 reaches for :place directly, so every key it touches has
325 − # to be present -- a plain map without :place raises rather than falling
326 − # through to the lat/lon branch.
327 − defp stop_as_station(stop) do
328 − %{
329 − place: nil,
330 − station_id: stop.stop_id,
331 − name: stop.stop_name,
332 − short_name: stop.stop_code,
333 − capacity: 0,
334 − address: stop.stop_address,
335 − lat: stop.stop_lat,
336 − lon: stop.stop_lon
337 − }
338 − end
339 −
340 − # AirNow replaces its observations every hour, so a reading and the station
341 − # that made it are one record. The name carries the current index, since that
342 − # is what you want off a marker without opening it.
343 − defp site_as_station(observation) do
344 − %{
345 − place: observation.point,
346 − station_id: observation.aqsid,
347 − name: station_label(observation),
348 − short_name: observation.aqsid,
349 − capacity: 0,
350 − address: observation.reporting_areas |> List.wrap() |> List.first(),
351 − lat: observation.lat,
352 − lon: observation.lon
353 − }
354 − end
355 −
356 − defp station_label(observation) do
357 − name = observation.site_name || observation.aqsid
358 −
359 − case RoomSanctum.Storage.AirNow.HourlyObsData.overall_aqi(observation) do
360 − nil -> name
361 − {value, pollutant} -> "#{name} - AQI #{value} (#{pollutant})"
362 − end
363 − end
364 −
365 314 # GTFS queries key on :stop, GBFS on :stop_id, AirNow on :aqsid.
366 315 defp station_id_of(%{query: nil}), do: nil
367 316
added apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/stations.ex
+125 −0
@@ -0,0 +1,125 @@
1 +defmodule RoomSanctumWeb.SourceLive.Stations do
2 + @moduledoc """
3 + One shape for the places a source knows about.
4 +
5 + GTFS stops carry `stop_lat`/`stop_lon`/`stop_name`, GBFS stations use
6 + `lat`/`lon`/`name`, and an AirNow record is both a reading and the site that
7 + made it. The map component is taught one vocabulary rather than three.
8 +
9 + This lives apart from the LiveViews because two of them need it: an
10 + offering's own map draws one source, and the tint map draws every source
11 + sharing a colour. Held privately in either, the normalisation would be
12 + copied, and a copy of "what counts as a place" is the kind of thing that
13 + drifts silently -- one map gaining a field or a filter the other does not.
14 +
15 + ## What has no places
16 +
17 + Only `:gtfs`, `:gbfs` and `:aqi` store geography of their own. A weather,
18 + tidal or ephem source has no coordinates anywhere in its config; where you
19 + want the weather is a property of the *query*, not of the source. So
20 + `for_source/1` returns `[]` for them, and that is an answer rather than a
21 + gap -- see `mappable?/1` for asking before drawing.
22 + """
23 +
24 + alias RoomSanctum.Storage
25 +
26 + @mappable [:gtfs, :gbfs, :aqi]
27 +
28 + @doc "Whether a source has geography of its own to draw."
29 + def mappable?(%{type: type}), do: type in @mappable
30 + def mappable?(_), do: false
31 +
32 + @doc "The source types that can appear on a map at all."
33 + def mappable_types, do: @mappable
34 +
35 + @doc """
36 + Every place a source knows about, normalised.
37 +
38 + Returns `[]` for a source type that stores no geography.
39 + """
40 + def for_source(%{type: :gbfs, id: id}), do: Storage.list_gbfs_station_information(id)
41 +
42 + def for_source(%{type: :gtfs, id: id}),
43 + do: id |> Storage.list_stops() |> Enum.map(&stop_as_station/1)
44 +
45 + def for_source(%{type: :aqi, id: id}),
46 + do: id |> Storage.list_aqi_stations() |> Enum.map(&site_as_station/1)
47 +
48 + def for_source(_source), do: []
49 +
50 + @doc """
51 + Live dock counts, which only GBFS has.
52 +
53 + Kept separate because the map component takes statuses as their own list and
54 + joins them to stations on `station_id`.
55 + """
56 + def statuses_for_source(%{type: :gbfs, id: id}), do: Storage.list_gbfs_station_status(id)
57 + def statuses_for_source(_source), do: []
58 +
59 + @doc """
60 + Names the source in each station's label.
61 +
62 + Only wanted where one map holds several sources: on an offering's own map
63 + every marker belongs to the source you are already looking at, and repeating
64 + its name on all four thousand of them is noise. On the tint map it is the
65 + only way to tell whose stop you clicked, since the marker element carries no
66 + source of its own.
67 + """
68 + def label_with_source(stations, source) do
69 + Enum.map(stations, fn station ->
70 + name = Map.get(station, :name)
71 +
72 + labelled =
73 + case name do
74 + nil -> source.name
75 + "" -> source.name
76 + n -> "#{n} · #{source.name}"
77 + end
78 +
79 + # Map.put over both shapes: GBFS stations arrive as Ecto structs and the
80 + # other two as plain maps, and every one of them has a :name.
81 + Map.put(station, :name, labelled)
82 + end)
83 + end
84 +
85 + # Every key here has to be present even when it is nil: the map component's
86 + # format_stations/3 reaches for :place directly, so a map without it raises
87 + # rather than falling through to the lat/lon branch.
88 + defp stop_as_station(stop) do
89 + %{
90 + place: nil,
91 + station_id: stop.stop_id,
92 + name: stop.stop_name,
93 + short_name: stop.stop_code,
94 + capacity: 0,
95 + address: stop.stop_address,
96 + lat: stop.stop_lat,
97 + lon: stop.stop_lon
98 + }
99 + end
100 +
101 + # AirNow replaces its observations every hour, so a reading and the station
102 + # that made it are one record. The name carries the current index, since that
103 + # is what you want off a marker without opening it.
104 + defp site_as_station(observation) do
105 + %{
106 + place: observation.point,
107 + station_id: observation.aqsid,
108 + name: station_label(observation),
109 + short_name: observation.aqsid,
110 + capacity: 0,
111 + address: observation.reporting_areas |> List.wrap() |> List.first(),
112 + lat: observation.lat,
113 + lon: observation.lon
114 + }
115 + end
116 +
117 + defp station_label(observation) do
118 + name = observation.site_name || observation.aqsid
119 +
120 + case RoomSanctum.Storage.AirNow.HourlyObsData.overall_aqi(observation) do
121 + nil -> name
122 + {value, pollutant} -> "#{name} - AQI #{value} (#{pollutant})"
123 + end
124 + end
125 +end
modified apps/room_sanctum/lib/room_sanctum_web/router.ex
+4 −0
@@ -55,6 +55,10 @@ defmodule RoomSanctumWeb.Router do
55 55 live "/cfg/offerings", SourceLive.Index, :index
56 56 live "/cfg/offerings/new", SourceLive.Index, :new
57 57 live "/cfg/offerings/import", SourceLive.Index, :import
58 + # Ahead of the `:id` routes. Nothing below actually collides -- none of them
59 + # is three segments ending in a free value -- but "map" reading as an id is
60 + # exactly the kind of thing a later route would introduce quietly.
61 + live "/cfg/offerings/map/:tint", SourceLive.MapView, :index
58 62 live "/cfg/offerings/:id/edit", SourceLive.Index, :edit
59 63 live "/cfg/offerings/:id", SourceLive.Show, :show
60 64 live "/cfg/offerings/:id/show/edit", SourceLive.Show, :edit

Parents: 3ba048f