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.
Draw a Plani as a map of what is around it
2ed496b · Gabriel Morell · 2026-09-03 17:43
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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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