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.
Tell offerings apart on the tint map, and switch them off
819e9cd · Gabriel Morell · 2026-08-24 06:05
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/assets/css/app.css
+17
−1
@@ -191,4 +191,20 @@
| 191 | 191 | 0 0 102px #fef3c7, |
| 192 | 192 | 0 0 151px #fef3c7; |
| 193 | 193 | font-family: "Caveat"; |
| 194 | −} | |
| 194 | +} | |
| 195 | +/* Legend swatches on the tint map: the same five shapes the canvas draws, so a | |
| 196 | + badge and its markers read as one thing. Drawn with clip-path rather than | |
| 197 | + glyphs because a font may not have a filled hexagon and would silently fall | |
| 198 | + back to a box. currentColor, so the tint class on the badge colours it. */ | |
| 199 | +.shape-swatch { | |
| 200 | + display: inline-block; | |
| 201 | + width: 0.7rem; | |
| 202 | + height: 0.7rem; | |
| 203 | + background-color: currentColor; | |
| 204 | + flex: none; | |
| 205 | +} | |
| 206 | +.shape-circle { border-radius: 9999px; } | |
| 207 | +.shape-square { border-radius: 1px; } | |
| 208 | +.shape-diamond { clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); } | |
| 209 | +.shape-triangle { clip-path: polygon(50% 0%, 100% 90%, 0% 90%); } | |
| 210 | +.shape-hexagon { clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%); } |
modified
apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+58
−1
@@ -26,6 +26,62 @@ template.innerHTML = `
| 26 | 26 | </div> |
| 27 | 27 | ` |
| 28 | 28 | |
| 29 | + | |
| 30 | +// Leaflet draws circles on canvas and nothing else, but a map holding several | |
| 31 | +// offerings needs a third axis after fill (what kind of thing) and outline | |
| 32 | +// (which tint) -- so: shape says whose. | |
| 33 | +// | |
| 34 | +// This reaches into the Canvas renderer's internals (_drawing, _ctx, | |
| 35 | +// _fillStroke) because that is the only way to add a path type; they have been | |
| 36 | +// stable across Leaflet 1.x, and a break shows up as markers that do not draw | |
| 37 | +// rather than as a crash. | |
| 38 | +const SHAPE_POINTS = { | |
| 39 | + square: (x, y, r) => [[x - r, y - r], [x + r, y - r], [x + r, y + r], [x - r, y + r]], | |
| 40 | + diamond: (x, y, r) => [[x, y - r], [x + r, y], [x, y + r], [x - r, y]], | |
| 41 | + triangle: (x, y, r) => [[x, y - r], [x + r, y + r * 0.8], [x - r, y + r * 0.8]], | |
| 42 | + hexagon: (x, y, r) => { | |
| 43 | + const pts = []; | |
| 44 | + for (let i = 0; i < 6; i++) { | |
| 45 | + const a = Math.PI / 6 + (i * Math.PI) / 3; | |
| 46 | + pts.push([x + r * Math.cos(a), y + r * Math.sin(a)]); | |
| 47 | + } | |
| 48 | + return pts; | |
| 49 | + } | |
| 50 | +}; | |
| 51 | + | |
| 52 | +L.Canvas.include({ | |
| 53 | + _updateShapeMarker(layer) { | |
| 54 | + if (!this._drawing || layer._empty()) return; | |
| 55 | + | |
| 56 | + const points = SHAPE_POINTS[layer.options.shape]; | |
| 57 | + if (!points) return this._updateCircle(layer); | |
| 58 | + | |
| 59 | + const p = layer._point; | |
| 60 | + const r = Math.max(Math.round(layer._radius), 1); | |
| 61 | + const ctx = this._ctx; | |
| 62 | + const pts = points(p.x, p.y, r); | |
| 63 | + | |
| 64 | + ctx.beginPath(); | |
| 65 | + ctx.moveTo(pts[0][0], pts[0][1]); | |
| 66 | + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); | |
| 67 | + ctx.closePath(); | |
| 68 | + this._fillStroke(ctx, layer); | |
| 69 | + } | |
| 70 | +}); | |
| 71 | + | |
| 72 | +// Same geometry and hit-testing as a circleMarker -- only the drawing differs -- | |
| 73 | +// so everything else about a marker keeps working. | |
| 74 | +const ShapeMarker = L.CircleMarker.extend({ | |
| 75 | + options: { shape: 'circle' }, | |
| 76 | + | |
| 77 | + _updatePath() { | |
| 78 | + if (!this.options.shape || this.options.shape === 'circle' || !SHAPE_POINTS[this.options.shape]) { | |
| 79 | + return this._renderer._updateCircle(this); | |
| 80 | + } | |
| 81 | + this._renderer._updateShapeMarker(this); | |
| 82 | + } | |
| 83 | +}); | |
| 84 | + | |
| 29 | 85 | class LeafletMap extends HTMLElement { |
| 30 | 86 | constructor() { |
| 31 | 87 | super(); |
@@ -558,8 +614,9 @@ class LeafletMap extends HTMLElement {
| 558 | 614 | }).bindPopup(() => this.createPopupContent(markerEl)); |
| 559 | 615 | } |
| 560 | 616 | |
| 561 | − return L.circleMarker([lat, lng], { | |
| 617 | + return new ShapeMarker([lat, lng], { | |
| 562 | 618 | renderer: this.canvasRenderer, |
| 619 | + shape: markerEl.getAttribute('shape') || 'circle', | |
| 563 | 620 | radius: 7, |
| 564 | 621 | weight: this.hasTint(markerEl) ? 3 : 2, |
| 565 | 622 | color: this.getStrokeColor(markerEl), |
modified
apps/room_sanctum/assets/js/leaflet/leaflet-marker.js
+3
−1
@@ -15,7 +15,7 @@ class LeafletMarker extends HTMLElement {
| 15 | 15 | } |
| 16 | 16 | |
| 17 | 17 | static get observedAttributes() { |
| 18 | − return ['lat', 'lng', 'name', 'type', 'tint', 'bearing', 'route-type', 'aircraft-class', 'vehicle-id', 'route-id', 'selected']; | |
| 18 | + return ['lat', 'lng', 'name', 'type', 'tint', 'shape', 'bearing', 'route-type', 'aircraft-class', 'vehicle-id', 'route-id', 'selected']; | |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | 21 | attributeChangedCallback(name, oldValue, newValue) { |
@@ -41,6 +41,8 @@ class LeafletMarker extends HTMLElement {
| 41 | 41 | name: this.getAttribute('name') || 'Marker', |
| 42 | 42 | type: this.getAttribute('type') || 'query', |
| 43 | 43 | tint: this.getAttribute('tint'), |
| 44 | + // Which offering this marker belongs to, on a map holding several. | |
| 45 | + shape: this.getAttribute('shape'), | |
| 44 | 46 | vehicleId: this.getAttribute('vehicle-id'), |
| 45 | 47 | routeId: this.getAttribute('route-id'), |
| 46 | 48 | selected: this.getAttribute('selected') === 'true' |
modified
apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+6
−0
@@ -212,6 +212,7 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 212 | 212 | name={Map.get(p, :name)} |
| 213 | 213 | type={Map.get(p, :type)} |
| 214 | 214 | tint={Map.get(p, :tint)} |
| 215 | + shape={Map.get(p, :shape)} | |
| 215 | 216 | bearing={Map.get(p, :bearing)} |
| 216 | 217 | route-type={Map.get(p, :route_type)} |
| 217 | 218 | route={Map.get(p, :route)} |
@@ -898,6 +899,11 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 898 | 899 | lat: lat, |
| 899 | 900 | lng: lng, |
| 900 | 901 | tint: source_tint, # Use the source tint passed from the parent |
| 902 | + # Set where one map holds several sources: the tint map gives each | |
| 903 | + # offering its own shape, since fill already means the marker's kind and | |
| 904 | + # the outline already means its tint. Nil everywhere else, which draws a | |
| 905 | + # circle as before. | |
| 906 | + shape: Map.get(station, :shape), | |
| 901 | 907 | icon: "fa-bicycle" |
| 902 | 908 | } |
| 903 | 909 |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/live_layers.ex
+21
−8
@@ -29,10 +29,17 @@ defmodule RoomSanctumWeb.SourceLive.LiveLayers do
| 29 | 29 | names a route and the glyph is chosen from the route's type, so the caller |
| 30 | 30 | would otherwise have to know to look it up separately. |
| 31 | 31 | """ |
| 32 | − def for_source(%{type: :gtfs, id: id}) do | |
| 32 | + def for_source(source, shape \\ nil) | |
| 33 | + | |
| 34 | + def for_source(%{type: :gtfs, id: id}, _shape) do | |
| 33 | 35 | vehicles = |
| 34 | 36 | safely(fn -> RoomGtfs.Worker.get_current_vehicle_positions(id) end, []) |
| 35 | 37 | |> Storage.with_trip_context(id) |
| 38 | + # source_id is what a layer toggle filters on. No shape: a vehicle is drawn | |
| 39 | + # as a route glyph turned to its bearing, and overriding that with the | |
| 40 | + # source's shape would cost more than it says -- stations are what a tint | |
| 41 | + # map has thousands of and what needed telling apart. | |
| 42 | + |> Enum.map(&Map.put(&1, :source_id, id)) | |
| 36 | 43 | |
| 37 | 44 | %{ |
| 38 | 45 | vehicles: vehicles, |
@@ -43,7 +50,7 @@ defmodule RoomSanctumWeb.SourceLive.LiveLayers do
| 43 | 50 | } |
| 44 | 51 | end |
| 45 | 52 | |
| 46 | − def for_source(%{type: :gbfs, id: id}) do | |
| 53 | + def for_source(%{type: :gbfs, id: id}, _shape) do | |
| 47 | 54 | bikes = |
| 48 | 55 | safely(fn -> Storage.list_gbfs_free_bike_status() end, []) |
| 49 | 56 | |> Enum.filter(&(&1.source_id == id)) |
@@ -51,16 +58,22 @@ defmodule RoomSanctumWeb.SourceLive.LiveLayers do
| 51 | 58 | %{vehicles: [], free_bikes: bikes, aircraft: [], route_types: %{}} |
| 52 | 59 | end |
| 53 | 60 | |
| 54 | − def for_source(%{type: :icarus, id: id}) do | |
| 55 | − %{vehicles: [], free_bikes: [], aircraft: aircraft(id), route_types: %{}} | |
| 61 | + def for_source(%{type: :icarus, id: id}, _shape) do | |
| 62 | + aircraft = Enum.map(aircraft(id), &Map.put(&1, "source_id", id)) | |
| 63 | + %{vehicles: [], free_bikes: [], aircraft: aircraft, route_types: %{}} | |
| 56 | 64 | end |
| 57 | 65 | |
| 58 | − def for_source(_source), do: empty() | |
| 66 | + def for_source(_source, _shape), do: empty() | |
| 67 | + | |
| 68 | + @doc """ | |
| 69 | + Merged layers across several sources, ready to hand to the map. | |
| 59 | 70 | |
| 60 | − @doc "Merged layers across several sources, ready to hand to the map." | |
| 61 | − def for_sources(sources) do | |
| 71 | + `shapes` maps a source id to the marker shape its vehicles should be drawn | |
| 72 | + with, so several agencies on one map stay distinguishable. | |
| 73 | + """ | |
| 74 | + def for_sources(sources, shapes \\ %{}) do | |
| 62 | 75 | sources |
| 63 | − |> Enum.map(&for_source/1) | |
| 76 | + |> Enum.map(&for_source(&1, Map.get(shapes, &1.id))) | |
| 64 | 77 | |> Enum.reduce(empty(), fn layer, acc -> |
| 65 | 78 | %{ |
| 66 | 79 | vehicles: acc.vehicles ++ layer.vehicles, |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/map_view.ex
+57
−3
@@ -43,6 +43,13 @@ defmodule RoomSanctumWeb.SourceLive.MapView do
| 43 | 43 | # often than that returns the same feed. |
| 44 | 44 | @refresh :timer.seconds(30) |
| 45 | 45 | |
| 46 | + # Marker shape is what separates one offering from another here: fill colour | |
| 47 | + # already means "what kind of thing this is" and the outline already means | |
| 48 | + # "which tint", so shape is the axis left over. Assigned in the order sources | |
| 49 | + # are listed, and repeating past the end of this list -- which the legend makes | |
| 50 | + # visible, since it shows each source's shape next to its name. | |
| 51 | + @shapes ~w(circle square diamond triangle hexagon) | |
| 52 | + | |
| 46 | 53 | @impl true |
| 47 | 54 | def mount(_params, _session, socket) do |
| 48 | 55 | # Only once connected: the static render would pay for every worker read and |
@@ -56,6 +63,8 @@ defmodule RoomSanctumWeb.SourceLive.MapView do
| 56 | 63 | |> assign(:stations, []) |
| 57 | 64 | |> assign(:station_statuses, []) |
| 58 | 65 | |> assign(:counts, %{}) |
| 66 | + |> assign(:shapes, %{}) | |
| 67 | + |> assign(:hidden, MapSet.new()) | |
| 59 | 68 | |> assign(:max_markers, @max_markers) |
| 60 | 69 | |> assign(:show_aircraft, false) |
| 61 | 70 | |> assign_live(%{vehicles: [], free_bikes: [], aircraft: [], route_types: %{}})} |
@@ -64,7 +73,8 @@ defmodule RoomSanctumWeb.SourceLive.MapView do
| 64 | 73 | @impl true |
| 65 | 74 | def handle_info(:refresh_live, socket) do |
| 66 | 75 | Process.send_after(self(), :refresh_live, @refresh) |
| 67 | − {:noreply, assign_live(socket, LiveLayers.for_sources(socket.assigns.sources))} | |
| 76 | + {:noreply, | |
| 77 | + assign_live(socket, LiveLayers.for_sources(socket.assigns.sources, socket.assigns.shapes))} | |
| 68 | 78 | end |
| 69 | 79 | |
| 70 | 80 | @impl true |
@@ -72,6 +82,29 @@ defmodule RoomSanctumWeb.SourceLive.MapView do
| 72 | 82 | {:noreply, assign(socket, :show_aircraft, not socket.assigns.show_aircraft)} |
| 73 | 83 | end |
| 74 | 84 | |
| 85 | + # Hiding is held here rather than pushed at the map, because the markers a | |
| 86 | + # hidden source contributes should not be rendered at all -- a tint covering | |
| 87 | + # three bus agencies is twenty thousand markers, and turning one off should | |
| 88 | + # make the page lighter, not just quieter. | |
| 89 | + @impl true | |
| 90 | + def handle_event("toggle-source", %{"id" => id}, socket) do | |
| 91 | + id = String.to_integer(id) | |
| 92 | + | |
| 93 | + hidden = | |
| 94 | + if MapSet.member?(socket.assigns.hidden, id) do | |
| 95 | + MapSet.delete(socket.assigns.hidden, id) | |
| 96 | + else | |
| 97 | + MapSet.put(socket.assigns.hidden, id) | |
| 98 | + end | |
| 99 | + | |
| 100 | + {:noreply, assign(socket, :hidden, hidden)} | |
| 101 | + end | |
| 102 | + | |
| 103 | + @impl true | |
| 104 | + def handle_event("show-all-sources", _params, socket) do | |
| 105 | + {:noreply, assign(socket, :hidden, MapSet.new())} | |
| 106 | + end | |
| 107 | + | |
| 75 | 108 | defp assign_live(socket, layers) do |
| 76 | 109 | socket |
| 77 | 110 | |> assign(:vehicles, layers.vehicles) |
@@ -107,25 +140,46 @@ defmodule RoomSanctumWeb.SourceLive.MapView do
| 107 | 140 | # |
| 108 | 141 | # Labelled per source too, because one map holding several of them |
| 109 | 142 | # otherwise gives no way to tell whose stop a marker is. |
| 143 | + shapes = | |
| 144 | + sources | |
| 145 | + |> Enum.with_index() | |
| 146 | + |> Map.new(fn {source, i} -> {source.id, Enum.at(@shapes, rem(i, length(@shapes)))} end) | |
| 147 | + | |
| 110 | 148 | per_source = |
| 111 | 149 | Enum.map(sources, fn source -> |
| 112 | − {source, source |> Stations.for_source() |> Stations.label_with_source(source)} | |
| 150 | + {source, Stations.attach_source(Stations.for_source(source), source, shapes[source.id])} | |
| 113 | 151 | end) |
| 114 | 152 | |
| 115 | 153 | socket |
| 116 | 154 | |> assign(:page_title, "#{String.capitalize(tint)} Offerings Map") |
| 117 | 155 | |> assign(:tint, tint) |
| 118 | 156 | |> assign(:sources, sources) |
| 157 | + |> assign(:shapes, shapes) | |
| 158 | + # A tint change is a different set of offerings, so nothing stays hidden | |
| 159 | + # across one -- an id hidden here would silently hide an unrelated source. | |
| 160 | + |> assign(:hidden, MapSet.new()) | |
| 119 | 161 | |> assign(:stations, Enum.flat_map(per_source, fn {_source, stations} -> stations end)) |
| 120 | 162 | |> assign(:counts, Map.new(per_source, fn {source, stations} -> {source.id, length(stations)} end)) |
| 121 | 163 | |> assign(:station_statuses, Enum.flat_map(sources, &Stations.statuses_for_source/1)) |
| 122 | 164 | |> then(fn s -> |
| 123 | 165 | # The sources just changed, so anything live already assigned describes the |
| 124 | 166 | # tint that was on screen a moment ago. |
| 125 | − if connected?(s), do: assign_live(s, LiveLayers.for_sources(sources)), else: s | |
| 167 | + if connected?(s), do: assign_live(s, LiveLayers.for_sources(sources, shapes)), else: s | |
| 126 | 168 | end) |
| 127 | 169 | end |
| 128 | 170 | |
| 171 | + # Filtered at render rather than by reloading: the station lists are the | |
| 172 | + # expensive part and do not change when a layer is switched off. | |
| 173 | + defp visible(items, hidden, key \\ :source_id) do | |
| 174 | + if MapSet.size(hidden) == 0 do | |
| 175 | + items | |
| 176 | + else | |
| 177 | + Enum.reject(items, fn item -> Map.get(item, key) in hidden end) | |
| 178 | + end | |
| 179 | + end | |
| 180 | + | |
| 181 | + defp hidden?(hidden, source), do: MapSet.member?(hidden, source.id) | |
| 182 | + | |
| 129 | 183 | defp any_aircraft?(sources), do: Enum.any?(sources, &(&1.type == :icarus)) |
| 130 | 184 | |
| 131 | 185 | # Subway trains report a station rather than a coordinate, so they are drawn at |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/map_view.html.heex
+67
−28
@@ -18,37 +18,69 @@
| 18 | 18 | </div> |
| 19 | 19 | </div> |
| 20 | 20 | <% else %> |
| 21 | − <div class="mx-6 my-2 flex flex-wrap gap-2"> | |
| 21 | + <%!-- Each offering is a layer toggle. The shape swatch is the same shape its | |
| 22 | + markers are drawn with, so the legend and the map read as one thing -- | |
| 23 | + and if there are more offerings than shapes the repeat is visible here | |
| 24 | + rather than being a mystery on the map. --%> | |
| 25 | + <div class="mx-6 my-2 flex flex-wrap items-center gap-2"> | |
| 22 | 26 | <%= 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"}> | |
| 27 | + <div class={[ | |
| 28 | + "badge badge-lg gap-2 cursor-pointer select-none border", | |
| 29 | + if(hidden?(@hidden, source), | |
| 30 | + do: "opacity-40 border-base-300 line-through", | |
| 31 | + else: "bg-#{@tint}-100 border-#{@tint}-400 hover:bg-#{@tint}-200") | |
| 32 | + ]} | |
| 33 | + phx-click="toggle-source" | |
| 34 | + phx-value-id={source.id} | |
| 35 | + title={if hidden?(@hidden, source), do: "Show #{source.name}", else: "Hide #{source.name}"}> | |
| 36 | + <span class={"shape-swatch shape-#{Map.get(@shapes, source.id, "circle")} text-#{@tint}-600"}></span> | |
| 25 | 37 | <i class={"fa fa-fw #{icon_code(source.type)}"}></i> |
| 26 | 38 | <%= source.name %> |
| 27 | 39 | <span class="opacity-70 text-xs"> |
| 28 | 40 | <%= cond do %> |
| 29 | − <% !mappable?(source) -> %>no places | |
| 30 | − <% Map.get(@counts, source.id, 0) == 0 -> %>nothing loaded | |
| 31 | − <% true -> %><%= Map.get(@counts, source.id) %> | |
| 41 | + <% !mappable?(source) and not LiveLayers.live?(source) -> %>nothing to draw | |
| 42 | + <% Map.get(@counts, source.id, 0) == 0 and mappable?(source) -> %>nothing loaded | |
| 43 | + <% true -> %><%= Map.get(@counts, source.id, 0) %> | |
| 32 | 44 | <% end %> |
| 33 | 45 | </span> |
| 34 | − </.link> | |
| 46 | + </div> | |
| 35 | 47 | <% end %> |
| 48 | + | |
| 49 | + <button :if={MapSet.size(@hidden) > 0} class="btn btn-xs btn-ghost" phx-click="show-all-sources"> | |
| 50 | + show all | |
| 51 | + </button> | |
| 52 | + | |
| 53 | + <span class="text-xs opacity-60"> | |
| 54 | + click an offering to hide it · | |
| 55 | + <.link navigate={~p"/cfg/offerings"} class="underline">all offerings</.link> | |
| 56 | + </span> | |
| 36 | 57 | </div> |
| 37 | 58 | |
| 38 | − <%= if @stations == [] and @vehicles == [] and @free_bikes == [] and @aircraft == [] do %> | |
| 59 | + <%= if visible(@stations, @hidden) == [] and visible(@vehicles, @hidden) == [] and visible(@free_bikes, @hidden) == [] and visible(@aircraft, @hidden, "source_id") == [] do %> | |
| 39 | 60 | <div class="card card-compact w-full bg-base-100 shadow-xl mx-6 my-2"> |
| 40 | 61 | <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> | |
| 62 | + <%!-- Everything hidden is a different situation from nothing to show, | |
| 63 | + and saying the wrong one sends you looking for a data problem that | |
| 64 | + is really a switched-off layer. --%> | |
| 65 | + <%= if MapSet.size(@hidden) >= length(@sources) do %> | |
| 66 | + <h3 class="card-title italic">Every offering is hidden</h3> | |
| 67 | + <p> | |
| 68 | + All <%= length(@sources) %> of them are switched off above. | |
| 69 | + <button class="link" phx-click="show-all-sources">Show them again</button>. | |
| 70 | + </p> | |
| 71 | + <% else %> | |
| 72 | + <h3 class="card-title italic">Nothing to draw</h3> | |
| 73 | + <p> | |
| 74 | + These offerings have no geography of their own. Only GTFS, GBFS and | |
| 75 | + AirNow sources store places; for the rest, where you want the data is | |
| 76 | + a property of the query rather than of the offering, so their queries | |
| 77 | + are what you would map. | |
| 78 | + </p> | |
| 79 | + <p :if={Enum.any?(@sources, &mappable?(&1))} class="text-sm opacity-70"> | |
| 80 | + Some of these could draw places but have none loaded yet -- run an | |
| 81 | + update from the offering's page first. | |
| 82 | + </p> | |
| 83 | + <% end %> | |
| 52 | 84 | </div> |
| 53 | 85 | </div> |
| 54 | 86 | <% else %> |
@@ -56,12 +88,12 @@
| 56 | 88 | <.query_geospatial_map |
| 57 | 89 | id="tint-map" |
| 58 | 90 | queries={[]} |
| 59 | − stations={@stations} | |
| 91 | + stations={visible(@stations, @hidden)} | |
| 60 | 92 | station_statuses={@station_statuses} |
| 61 | − vehicle_positions={@vehicles} | |
| 93 | + vehicle_positions={visible(@vehicles, @hidden)} | |
| 62 | 94 | route_types={@route_types} |
| 63 | − free_bikes={@free_bikes} | |
| 64 | − aircraft={if @show_aircraft, do: @aircraft, else: []} | |
| 95 | + free_bikes={visible(@free_bikes, @hidden)} | |
| 96 | + aircraft={if @show_aircraft, do: visible(@aircraft, @hidden, "source_id"), else: []} | |
| 65 | 97 | aircraft_event={if any_aircraft?(@sources), do: "toggle-aircraft"} |
| 66 | 98 | show_aircraft={@show_aircraft} |
| 67 | 99 | source_tint={@tint} |
@@ -75,12 +107,19 @@
| 75 | 107 | marker kinds, and what is useful here is knowing whether a layer is |
| 76 | 108 | empty because nothing is running or because no source supplies it. --%> |
| 77 | 109 | <div class="mt-2 text-xs opacity-70 flex flex-wrap gap-3"> |
| 78 | − <span><%= length(@stations) %> places</span> | |
| 79 | − <span :if={@vehicles != []}> | |
| 80 | − <%= length(@vehicles) %> vehicles<%= inferred_note(@vehicles) %> | |
| 110 | + <span><%= length(visible(@stations, @hidden)) %> places</span> | |
| 111 | + <span :if={visible(@vehicles, @hidden) != []}> | |
| 112 | + <%= length(visible(@vehicles, @hidden)) %> vehicles<%= inferred_note(visible(@vehicles, @hidden)) %> | |
| 113 | + </span> | |
| 114 | + <span :if={visible(@free_bikes, @hidden) != []}> | |
| 115 | + <%= length(visible(@free_bikes, @hidden)) %> loose bikes | |
| 116 | + </span> | |
| 117 | + <span :if={@show_aircraft and visible(@aircraft, @hidden, "source_id") != []}> | |
| 118 | + <%= length(visible(@aircraft, @hidden, "source_id")) %> aircraft | |
| 119 | + </span> | |
| 120 | + <span :if={MapSet.size(@hidden) > 0}> | |
| 121 | + <%= MapSet.size(@hidden) %> offering(s) hidden | |
| 81 | 122 | </span> |
| 82 | − <span :if={@free_bikes != []}><%= length(@free_bikes) %> loose bikes</span> | |
| 83 | − <span :if={@aircraft != [] and @show_aircraft}><%= length(@aircraft) %> aircraft</span> | |
| 84 | 123 | <span :if={Enum.any?(@sources, &LiveLayers.live?(&1)) and @vehicles == [] and @free_bikes == []}> |
| 85 | 124 | nothing moving right now |
| 86 | 125 | </span> |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/stations.ex
+24
−6
@@ -57,15 +57,20 @@ defmodule RoomSanctumWeb.SourceLive.Stations do
| 57 | 57 | def statuses_for_source(_source), do: [] |
| 58 | 58 | |
| 59 | 59 | @doc """ |
| 60 | − Names the source in each station's label. | |
| 60 | + Marks each station as belonging to a source: name, id, and marker shape. | |
| 61 | 61 | |
| 62 | − Only wanted where one map holds several sources: on an offering's own map | |
| 62 | + Only wanted where one map holds several sources. On an offering's own map | |
| 63 | 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 | |
| 64 | + its name across four thousand of them is noise; on the tint map it is the | |
| 65 | 65 | only way to tell whose stop you clicked, since the marker element carries no |
| 66 | 66 | source of its own. |
| 67 | + | |
| 68 | + `source_id` is what layer toggles filter on, and `shape` is how the map draws | |
| 69 | + one source's markers differently from another's — the fill colour already | |
| 70 | + means "what kind of thing this is" and the outline already means "which | |
| 71 | + tint", so shape is the axis left for "whose". | |
| 67 | 72 | """ |
| 68 | − def label_with_source(stations, source) do | |
| 73 | + def attach_source(stations, source, shape \\ nil) do | |
| 69 | 74 | Enum.map(stations, fn station -> |
| 70 | 75 | name = Map.get(station, :name) |
| 71 | 76 |
@@ -77,11 +82,24 @@ defmodule RoomSanctumWeb.SourceLive.Stations do
| 77 | 82 | end |
| 78 | 83 | |
| 79 | 84 | # 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) | |
| 85 | + # other two as plain maps, and every one of them has a :name. The two | |
| 86 | + # added keys are read back through Map.get, so a struct not declaring them | |
| 87 | + # is fine -- put/3 on a struct with an unknown key would raise, which is | |
| 88 | + # why they go on as a plain map. | |
| 89 | + station | |
| 90 | + |> plain_map() | |
| 91 | + |> Map.put(:name, labelled) | |
| 92 | + |> Map.put(:source_id, source.id) | |
| 93 | + |> Map.put(:shape, shape) | |
| 82 | 94 | end) |
| 83 | 95 | end |
| 84 | 96 | |
| 97 | + # GBFS stations arrive as Ecto structs, which have a fixed key set -- adding | |
| 98 | + # :source_id or :shape to one raises. The other two kinds are already plain | |
| 99 | + # maps, so this only converts what needs converting. | |
| 100 | + defp plain_map(%_struct{} = station), do: Map.from_struct(station) | |
| 101 | + defp plain_map(station) when is_map(station), do: station | |
| 102 | + | |
| 85 | 103 | # Every key here has to be present even when it is nil: the map component's |
| 86 | 104 | # format_stations/3 reaches for :place directly, so a map without it raises |
| 87 | 105 | # rather than falling through to the lat/lon branch. |
Parents: 8b198ec