Tell offerings apart on the tint map, and switch them off

819e9cd · Gabriel Morell · 2026-08-24 06:05

19 files +252 -47
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 &middot;
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