Fix GBFS import and rebuild the source map view

1e1641e · Gabriel Morell · 2026-08-07 19:14

20 files +375 -102
Message
{commit_body(@commit)}

Files changed

modified apps/room_gbfs/lib/room_gbfs/gbfs_worker.ex
+22 −7
@@ -96,7 +96,10 @@ defmodule RoomGbfs.Worker do
96 96 dt = NaiveDateTime.local_now()
97 97 Logger.info("GBFS::#{id} retrieving #{url}")
98 98
99 − case HTTPoison.get(url) do
99 + # Operators move hosts and publish 301s from the old address -- Baywheels
100 + # now redirects to gbfs.lyftbikes.com -- so redirects have to be followed or
101 + # the body is an empty redirect page.
102 + case HTTPoison.get(url, [], follow_redirect: true) do
100 103 {:ok, result} ->
101 104 case result.body
102 105 |> Poison.decode(keys: :atoms) do
@@ -325,14 +328,26 @@ defmodule RoomGbfs.Worker do
325 328 Logger.info("GBFS::#{state.id} updating static info ")
326 329 bcast(state.id, :downloading, 1, @tfh)
327 330
328 − case HTTPoison.get(cfg.config.url) do
331 + case HTTPoison.get(cfg.config.url, [], follow_redirect: true) do
329 332 {:ok, result} ->
333 + # decode! here took the whole worker down when the body was not JSON,
334 + # which is exactly what an unfollowed redirect hands you.
330 335 json_body =
331 − result.body
332 − |> Poison.decode!()
336 + case Poison.decode(result.body) do
337 + {:ok, decoded} ->
338 + decoded
333 339
334 − if json_body["data"]
335 − |> Map.has_key?(cfg.config.lang) do
340 + {:error, _} ->
341 + Logger.error(
342 + "GBFS::#{state.id} discovery returned non-JSON (HTTP #{result.status_code}) from #{cfg.config.url}"
343 + )
344 +
345 + %{}
346 + end
347 +
348 + if json_body["data"] != nil and
349 + json_body["data"]
350 + |> Map.has_key?(cfg.config.lang) do
336 351 bcast(state.id, :parsing, 2, @tfh)
337 352
338 353 json_body["data"][cfg.config.lang]["feeds"]
@@ -422,7 +437,7 @@ defmodule RoomGbfs.Worker do
422 437 Logger.info("GBFS::#{state.id} updating realtime info ")
423 438 bcast(state.id, :downloading, 1, 5)
424 439
425 − case HTTPoison.get(cfg.config.url) do
440 + case HTTPoison.get(cfg.config.url, [], follow_redirect: true) do
426 441 {:ok, result} ->
427 442 case result.body |> Poison.decode() do
428 443 {:ok, json_body} ->
modified apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+123 −23
@@ -1,8 +1,14 @@
1 1 import L from 'leaflet'
2 +// Loaded as text (see --loader:.css=text) so it can be injected into the shadow
3 +// root. The map renders inside a shadow DOM, which stylesheets in the main
4 +// document cannot reach -- without this Leaflet's tiles have no positioning
5 +// rules and lay themselves out as a grid of loose images.
6 +import leafletCss from 'leaflet/dist/leaflet.css'
2 7
3 8 const template = document.createElement('template');
4 9 template.innerHTML = `
5 10 <style>
11 + ${leafletCss}
6 12 :host {
7 13 display: block;
8 14 width: 100%;
@@ -78,6 +84,17 @@ class LeafletMap extends HTMLElement {
78 84 this.setupStreamingHandlers();
79 85 }
80 86
87 + // Leaflet measures its container once, at init. Inside a flex column
88 + // that width is not final yet, so without this the panes keep the
89 + // stale size and spill outside the element's box.
90 + this.resizeObserver = new ResizeObserver(() => {
91 + if (this.map) this.map.invalidateSize({ animate: false });
92 + });
93 + this.resizeObserver.observe(this.mapElement);
94 +
95 + // One more pass after layout settles, for the first paint.
96 + requestAnimationFrame(() => this.map && this.map.invalidateSize({ animate: false }));
97 +
81 98 console.log('Leaflet map initialized with Web Components');
82 99 }
83 100
@@ -113,6 +130,9 @@ class LeafletMap extends HTMLElement {
113 130 if (this.observer) {
114 131 this.observer.disconnect();
115 132 }
133 + if (this.resizeObserver) {
134 + this.resizeObserver.disconnect();
135 + }
116 136 if (this.map) {
117 137 this.map.remove();
118 138 }
@@ -148,6 +168,7 @@ class LeafletMap extends HTMLElement {
148 168 layer = this.vehiclesLayer;
149 169 break;
150 170 case 'free-bike':
171 + case 'free_bike':
151 172 leafletMarker = this.createFreeBikeMarker(lat, lng, markerEl);
152 173 layer = this.freeBikesLayer;
153 174 break;
@@ -173,6 +194,21 @@ class LeafletMap extends HTMLElement {
173 194 markerEl.click();
174 195 });
175 196
197 + // The popup's DOM only exists once opened, so bind the action then.
198 + leafletMarker.on('popupopen', (e) => {
199 + const btn = e.popup.getElement()?.querySelector('[data-add-query-btn]');
200 + if (!btn || btn.dataset.bound) return;
201 + btn.dataset.bound = '1';
202 + btn.addEventListener('click', (ev) => {
203 + ev.stopPropagation();
204 + // Target the hidden child, not the marker itself -- the map
205 + // forwards marker clicks, which would fire this on any click.
206 + const target = markerEl.querySelector('[data-add-query-target]');
207 + if (target) target.click();
208 + leafletMarker.closePopup();
209 + });
210 + });
211 +
176 212 // Watch for icon updates
177 213 const iconEl = markerEl.querySelector('leaflet-icon');
178 214 if (iconEl) {
@@ -198,17 +234,11 @@ class LeafletMap extends HTMLElement {
198 234 }
199 235
200 236 createOptimizedQueryMarker(lat, lng, markerEl) {
201 − // High-performance canvas-based circle marker for queries
202 − const color = this.getMarkerColor(markerEl);
203 −
204 − return L.circleMarker([lat, lng], {
205 − renderer: this.canvasRenderer,
206 − radius: 4,
207 − weight: 1,
208 − color: '#ffffff',
209 − fillColor: color,
210 − fillOpacity: 0.8,
211 − fill: true,
237 + // A star, not a circle: unmistakable against the docks and bikes, and
238 + // there are few enough queries that a DOM icon costs nothing.
239 + return L.marker([lat, lng], {
240 + icon: this.createStarIcon(this.getMarkerColor(markerEl), this.getStrokeColor(markerEl), 22),
241 + keyboard: false,
212 242 }).bindPopup(() => this.createPopupContent(markerEl));
213 243 }
214 244
@@ -237,9 +267,9 @@ class LeafletMap extends HTMLElement {
237 267
238 268 return L.circleMarker([lat, lng], {
239 269 renderer: this.canvasRenderer,
240 − radius: 6,
241 − weight: 2,
242 − color: '#ffffff',
270 + radius: 4,
271 + weight: this.hasTint(markerEl) ? 2 : 1,
272 + color: this.getStrokeColor(markerEl),
243 273 fillColor: color,
244 274 fillOpacity: 0.9,
245 275 fill: true,
@@ -247,16 +277,23 @@ class LeafletMap extends HTMLElement {
247 277 }
248 278
249 279 createStationMarker(lat, lng, markerEl) {
250 − // Medium markers for stations
251 280 const color = this.getMarkerColor(markerEl);
252 −
281 +
282 + // Already queried -> star, so it matches the query markers it is one of.
283 + if (this.markerHasQuery(markerEl)) {
284 + return L.marker([lat, lng], {
285 + icon: this.createStarIcon(color, this.getStrokeColor(markerEl), 20),
286 + keyboard: false,
287 + }).bindPopup(() => this.createPopupContent(markerEl));
288 + }
289 +
253 290 return L.circleMarker([lat, lng], {
254 291 renderer: this.canvasRenderer,
255 − radius: 8,
256 − weight: 2,
257 − color: '#ffffff',
292 + radius: 7,
293 + weight: this.hasTint(markerEl) ? 3 : 2,
294 + color: this.getStrokeColor(markerEl),
258 295 fillColor: color,
259 − fillOpacity: 0.8,
296 + fillOpacity: 0.85,
260 297 fill: true,
261 298 }).bindPopup(() => this.createPopupContent(markerEl));
262 299 }
@@ -277,8 +314,57 @@ class LeafletMap extends HTMLElement {
277 314 leafletMarker.setIcon(newIcon);
278 315 }
279 316
317 + // Fill says *what* the marker is; the outline says which tint it belongs to.
318 + // Previously tint won outright, so a source with a tint painted its queries
319 + // and its stations the same colour and the type was invisible.
280 320 getMarkerColor(markerEl) {
281 − const tint = markerEl.getAttribute('tint');
321 + // A dock that already has a query reads as a query, not a dock.
322 + if (this.markerHasQuery(markerEl)) return '#f59e0b'; // amber
323 +
324 + // Indigo vs sky was indistinguishable; these are far apart.
325 + const byType = {
326 + 'query': '#f59e0b', // amber
327 + 'station': '#2563eb', // blue
328 + 'free_bike': '#16a34a', // green
329 + 'free-bike': '#16a34a',
330 + 'vehicle': '#dc2626', // red
331 + };
332 +
333 + return byType[markerEl.getAttribute('type') || 'query'] || '#6b7280';
334 + }
335 +
336 + // Outline: the tint if one is set, otherwise white so the marker still
337 + // reads against the map.
338 + getStrokeColor(markerEl) {
339 + return this.tintColor(markerEl.getAttribute('tint')) || '#ffffff';
340 + }
341 +
342 + markerHasQuery(markerEl) {
343 + return markerEl.hasAttribute('data-has-query');
344 + }
345 +
346 + // Inline SVG rather than a Font Awesome glyph: the map lives in a shadow
347 + // root, which stylesheets in the main document cannot reach.
348 + createStarIcon(fill, stroke, size) {
349 + const points = '12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26';
350 +
351 + return L.divIcon({
352 + className: 'leaflet-star-icon',
353 + html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
354 + <polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"
355 + stroke-linejoin="round" />
356 + </svg>`,
357 + iconSize: [size, size],
358 + iconAnchor: [size / 2, size / 2],
359 + popupAnchor: [0, -size / 2],
360 + });
361 + }
362 +
363 + hasTint(markerEl) {
364 + return this.tintColor(markerEl.getAttribute('tint')) !== null;
365 + }
366 +
367 + tintColor(tint) {
282 368 const colorMap = {
283 369 'red': '#ef4444',
284 370 'orange': '#f97316',
@@ -297,9 +383,11 @@ class LeafletMap extends HTMLElement {
297 383 'fuchsia': '#d946ef',
298 384 'pink': '#ec4899',
299 385 'rose': '#f43f5e',
386 + 'stone': '#78716c',
387 + 'slate': '#64748b',
300 388 };
301 −
302 − return colorMap[tint] || '#6b7280';
389 +
390 + return colorMap[tint] || null;
303 391 }
304 392
305 393 createPopupContent(markerEl) {
@@ -332,6 +420,18 @@ class LeafletMap extends HTMLElement {
332 420 }
333 421
334 422 content += `</div>`;
423 + // Opt-in: the marker element carries phx-click, so routing the button
424 + // through markerEl.click() reuses the forwarding that already exists
425 + // rather than reaching into LiveView from here.
426 + if (markerEl.hasAttribute('data-add-query')) {
427 + content += `
428 + <button type="button" data-add-query-btn
429 + class="mt-2 w-full text-xs font-semibold rounded px-2 py-1 bg-indigo-600 text-white hover:bg-indigo-700">
430 + + query
431 + </button>
432 + `;
433 + }
434 +
335 435 return content;
336 436 }
337 437
modified apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+151 −1
@@ -16,6 +16,19 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
16 16 attr :selected_tint, :string, default: nil
17 17 attr :class, :string, default: ""
18 18 attr :height, :string, default: "500px"
19 +
20 + attr :max_markers, :integer,
21 + default: nil,
22 + doc: "Optional cap on rendered markers. Nil shows everything, which is the default."
23 +
24 + attr :queried_station_ids, :list,
25 + default: [],
26 + doc: "Station ids that already have a query; those markers render as queries and lose the add action."
27 +
28 + attr :add_query_event, :string,
29 + default: nil,
30 + doc: "When set, station markers get a '+ query' action in their popup that pushes this event."
31 +
19 32 attr :vehicle_positions, :list, default: []
20 33 attr :free_bikes, :list, default: []
21 34 attr :stations, :list, default: []
@@ -29,14 +42,94 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
29 42 |> assign(:map_vehicles, format_vehicle_positions(assigns.vehicle_positions))
30 43 |> assign(:map_free_bikes, format_free_bikes(assigns.free_bikes))
31 44 |> assign(:map_stations, format_stations(assigns.stations, Map.get(assigns, :station_statuses, []), Map.get(assigns, :source_tint, nil)))
45 + groups = [assigns.map_queries, assigns.map_vehicles, assigns.map_free_bikes, assigns.map_stations]
46 + all_points = Enum.concat(groups)
47 + # Centre on everything, even any points a cap would drop.
48 + {centre_lat, centre_lng, zoom} = view_for(all_points)
49 +
50 + # Every marker type is a canvas circleMarker sharing one renderer, so the
51 + # whole system draws without trouble; the cap is opt-in rather than default.
52 + queried = MapSet.new(Map.get(assigns, :queried_station_ids) || [], &to_string/1)
53 + limit = Map.get(assigns, :max_markers)
54 + shown_points = if is_nil(limit), do: all_points, else: balanced_take(groups, limit)
55 +
56 + assigns =
57 + assigns
58 + |> assign(:all_points, shown_points)
59 + |> assign(:queried, queried)
60 + # Counts as assigns: inside ~H, @foo means assigns.foo, so a module
61 + # attribute referenced in the template reads as nil.
62 + |> assign(:shown, length(shown_points))
63 + |> assign(:total, length(all_points))
64 + |> assign(:centre_lat, centre_lat)
65 + |> assign(:centre_lng, centre_lng)
66 + |> assign(:zoom, zoom)
67 + |> assign(:truncated, length(all_points) - length(shown_points))
68 +
32 69 ~H"""
33 70 <div class={"query-geospatial-map w-full #{@class}"}>
71 + <%!-- No phx-update here on purpose: the element patches lat/lng/zoom through
72 + attributeChangedCallback and markers through a MutationObserver, so
73 + LiveView should morph it in place rather than replace it. --%>
74 + <div class="mt-4" id="geospatial-map-wrap">
75 + <leaflet-map
76 + id="geospatial-map"
77 + class="w-full block rounded-lg overflow-hidden"
78 + style={"height: #{@height}"}
79 + lat={@centre_lat}
80 + lng={@centre_lng}
81 + zoom={@zoom}
82 + >
83 + <%= for p <- @all_points do %>
84 + <%!-- Prefixed: LiveView rejects a numeric DOM id, and queries carry a
85 + bare integer id while the other formatters emit strings. --%>
86 + <leaflet-marker
87 + lat={p.lat}
88 + lng={p.lng}
89 + name={Map.get(p, :name)}
90 + type={Map.get(p, :type)}
91 + tint={Map.get(p, :tint)}
92 + id={"map-marker-#{p.id}"}
93 + data-has-query={
94 + if Map.get(p, :type) == "station" &&
95 + MapSet.member?(@queried, to_string(Map.get(p, :station_id))),
96 + do: "1"
97 + }
98 + data-add-query={
99 + if @add_query_event && Map.get(p, :type) == "station" &&
100 + !MapSet.member?(@queried, to_string(Map.get(p, :station_id))),
101 + do: "1"
102 + }
103 + >
104 + <%!-- The map forwards every marker click to the marker element, so
105 + a phx-click there fired on any click. This hidden child is
106 + activated only by the popup's "+ query" button. --%>
107 + <span
108 + :if={
109 + @add_query_event && Map.get(p, :type) == "station" &&
110 + !MapSet.member?(@queried, to_string(Map.get(p, :station_id)))
111 + }
112 + data-add-query-target
113 + hidden
114 + phx-click={@add_query_event}
115 + phx-value-station-id={Map.get(p, :station_id)}
116 + phx-value-name={Map.get(p, :name)}
117 + ></span>
118 + </leaflet-marker>
119 + <% end %>
120 + </leaflet-map>
121 + <%= if @truncated > 0 do %>
122 + <p class="text-xs text-base-content/60 mt-1">
123 + Showing <%= @shown %> of <%= @total %> points.
124 + </p>
125 + <% end %>
126 + </div>
34 127 <div class="mt-4 bg-base-100 border border-base-300 rounded-lg p-4">
35 128 <div class="flex items-center justify-between mb-3">
36 129 <h3 class="text-sm font-semibold text-base-content">
37 130 Map Legend
38 131 <span class="ml-2 text-xs text-base-content/60">
39 − (<%= length(@map_queries) %> queries with location data<%= if length(@map_free_bikes) > 0, do: ", #{length(@map_free_bikes)} free bikes", else: "" %>)
132 + (<%= length(@map_queries) %> queries with location data<%= if length(@map_stations) > 0, do: ", #{length(@map_stations)} stations", else: "" %><%= if length(@map_free_bikes) > 0, do: ", #{length(@map_free_bikes)} free bikes", else: "" %>)
40 133 </span>
41 134 </h3>
42 135
@@ -80,6 +173,57 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
80 173 """
81 174 end
82 175
176 + @doc false
177 + # Straight concatenation plus Enum.take drops whole categories: with 570 free
178 + # bikes ahead of 635 stations, a 400 cap kept zero stations. Round-robin so
179 + # every kind is represented, and a short list (queries) is never crowded out.
180 + def balanced_take(groups, limit) do
181 + groups
182 + |> Enum.reject(&(&1 == []))
183 + |> interleave()
184 + |> Enum.take(limit)
185 + end
186 +
187 + defp interleave([]), do: []
188 +
189 + defp interleave(lists) do
190 + {heads, rests} =
191 + Enum.reduce(lists, {[], []}, fn
192 + [h | t], {hs, rs} -> {[h | hs], if(t == [], do: rs, else: [t | rs])}
193 + [], acc -> acc
194 + end)
195 +
196 + Enum.reverse(heads) ++ interleave(Enum.reverse(rests))
197 + end
198 +
199 + # Mean of the points, with the zoom backed off as their spread grows. Falls
200 + # back to a continental view when there is nothing to show yet.
201 + defp view_for([]), do: {39.8283, -98.5795, 4}
202 +
203 + defp view_for(points) do
204 + lats = points |> Enum.map(& &1.lat) |> Enum.filter(&is_number/1)
205 + lngs = points |> Enum.map(& &1.lng) |> Enum.filter(&is_number/1)
206 +
207 + if lats == [] or lngs == [] do
208 + {39.8283, -98.5795, 4}
209 + else
210 + n = length(lats)
211 + spread = max(Enum.max(lats) - Enum.min(lats), Enum.max(lngs) - Enum.min(lngs))
212 +
213 + zoom =
214 + cond do
215 + spread > 5.0 -> 6
216 + spread > 1.0 -> 9
217 + spread > 0.5 -> 10
218 + spread > 0.25 -> 11
219 + spread > 0.1 -> 12
220 + true -> 13
221 + end
222 +
223 + {Enum.sum(lats) / n, Enum.sum(lngs) / n, zoom}
224 + end
225 + end
226 +
83 227 # Helper function to get queries that have geospatial data
84 228 defp get_mappable_queries(queries) do
85 229 # Debug log all incoming queries
@@ -138,6 +282,9 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
138 282
139 283 %{
140 284 id: query.id,
285 + # Explicit, so the marker carries a type attribute. Without it the colour
286 + # lookup fell through to the neutral grey rather than the query colour.
287 + type: "query",
141 288 name: query.name,
142 289 source_name: query.source.name,
143 290 source_type: query.source.type,
@@ -382,6 +529,9 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
382 529 %{
383 530 id: "bike_#{bike.bike_id}",
384 531 type: "free_bike",
532 + # Without a name the popup falls back to the literal word "Marker".
533 + # Bikes have no name upstream, so build a readable one from the id.
534 + name: "Bike #{String.slice(to_string(bike.bike_id), 0, 8)}",
385 535 bike_id: bike.bike_id,
386 536 lat: lat,
387 537 lng: lng,
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+71 −29
@@ -25,12 +25,6 @@ defmodule RoomSanctumWeb.SourceLive.Show do
25 25 |> assign(:tester_selected, nil)
26 26 |> assign(:tester_selected_name, nil)
27 27 |> assign(:tester_selected_data, %{})
28 − |> assign(:tester_foci_distance, 100)
29 − |> assign(:tester_foci_coords, {42.3736, -71.1097})
30 − |> assign(:tester_foci_coords_lat, 42.3736)
31 − |> assign(:tester_foci_coords_lon, -71.1097)
32 − |> assign(:tester_foci, {})
33 − |> assign(:tester_foci_vehicles, [])
34 28 |> assign(:vehicle_positions, [])
35 29 |> assign(:free_bikes, [])
36 30 |> assign(:stations, [])
@@ -60,9 +54,12 @@ defmodule RoomSanctumWeb.SourceLive.Show do
60 54 _ -> []
61 55 end
62 56
63 − # Add station information for GBFS sources
57 + # Stations for the map. GTFS stops carry stop_lat/stop_lon/stop_name where
58 + # GBFS uses lat/lon/name, so they are normalised to one shape rather than
59 + # teaching the map component two vocabularies.
64 60 stations = case socket.assigns.source.type do
65 61 :gbfs -> Storage.list_gbfs_station_information(socket.assigns.source_id)
62 + :gtfs -> socket.assigns.source_id |> Storage.list_stops() |> Enum.map(&stop_as_station/1)
66 63 _ -> []
67 64 end
68 65
@@ -133,11 +130,77 @@ defmodule RoomSanctumWeb.SourceLive.Show do
133 130 }
134 131 end
135 132
133 + # format_stations/3 reaches for :place directly, so every key it touches has
134 + # to be present -- a plain map without :place raises rather than falling
135 + # through to the lat/lon branch.
136 + defp stop_as_station(stop) do
137 + %{
138 + place: nil,
139 + station_id: stop.stop_id,
140 + name: stop.stop_name,
141 + short_name: stop.stop_code,
142 + capacity: 0,
143 + address: stop.stop_address,
144 + lat: stop.stop_lat,
145 + lon: stop.stop_lon
146 + }
147 + end
148 +
149 + # GTFS queries key on :stop, GBFS on :stop_id.
150 + defp station_id_of(%{query: nil}), do: nil
151 + defp station_id_of(%{query: q}), do: Map.get(q, :stop_id) || Map.get(q, :stop)
152 + defp station_id_of(_), do: nil
153 +
136 154 defp page_title(:show), do: "Offering Detail"
137 155 defp page_title(:edit), do: "Modify Offering"
138 156
139 157
140 158
159 + # Create a query straight from a station marker's popup. Named after the
160 + # station so the query is recognisable without opening it.
161 + @impl true
162 + def handle_event("map-add-query", %{"station-id" => station_id} = params, socket) do
163 + name = Map.get(params, "name") || "Station #{station_id}"
164 +
165 + if station_id in queried_station_ids(socket) do
166 + {:noreply, put_flash(socket, :error, "#{name} already has a query")}
167 + else
168 + add_station_query(socket, station_id, name)
169 + end
170 + end
171 +
172 + # The station ids this source already has queries for, so the map can grey
173 + # them out and a second click cannot stack a duplicate.
174 + defp queried_station_ids(socket) do
175 + socket.assigns.queries
176 + |> Enum.map(&station_id_of/1)
177 + |> Enum.reject(&is_nil/1)
178 + |> Enum.map(&to_string/1)
179 + end
180 +
181 + # GTFS names the field :stop, GBFS :stop_id.
182 + defp station_query_for(:gtfs, id), do: %{"__type__" => "gtfs", "stop" => id}
183 + defp station_query_for(type, id), do: %{"__type__" => to_string(type), "stop_id" => id}
184 +
185 + defp add_station_query(socket, station_id, name) do
186 + case Configuration.create_query(%{
187 + user_id: socket.assigns.current_user.id,
188 + source_id: socket.assigns.source.id,
189 + name: name,
190 + query: station_query_for(socket.assigns.source.type, station_id),
191 + public: true
192 + }) do
193 + {:ok, _} ->
194 + {:noreply,
195 + socket
196 + |> assign(:queries, Configuration.get_queries(:source, socket.assigns.source_id))
197 + |> put_flash(:info, "Added query #{name}")}
198 +
199 + {:error, _} ->
200 + {:noreply, put_flash(socket, :error, "Could not add a query for #{name}")}
201 + end
202 + end
203 +
141 204 # --- bourse symbol builder -------------------------------------------
142 205
143 206 @impl true
@@ -606,8 +669,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
606 669
607 670 @impl true
608 671 def handle_event("add-tester", _params, socket) do
609 − foci = Configuration.list_focis({:user,socket.assigns.current_user.id})
610 − {:noreply, socket |> assign(:tester, !socket.assigns.tester) |> assign(:tester_foci, foci)}
672 + {:noreply, socket |> assign(:tester, !socket.assigns.tester)}
611 673 end
612 674
613 675 @impl true
@@ -727,20 +789,6 @@ defmodule RoomSanctumWeb.SourceLive.Show do
727 789 end
728 790
729 791 @impl true
730 − def handle_event("select-foci", %{"foci" => foci, "distance" => distance} = data, socket) do
731 − IO.inspect(data)
732 − [lat, lon] = foci |> String.split(",") |> Enum.map(&String.to_float/1)
733 − point = %Geo.Point{coordinates: {lat, lon}, srid: 4326}
734 − vehicles = Storage.find_free_bikes_around_point(socket.assigns.source_id, point, distance |> String.to_integer) |> IO.inspect
735 − {:noreply, socket
736 − |> assign(:tester_foci_distance, distance)
737 − |> assign(:tester_foci_coords, {lat, lon})
738 − |> assign(:tester_foci_coords_lat, lat)
739 − |> assign(:tester_foci_coords_lon, lon)
740 − |> assign(:tester_foci_vehicles, vehicles)
741 − }
742 − end
743 −
744 792 # Paste a tracking number, or the text of a shipping email, to register it by
745 793 # hand -- the same extraction the mail queue runs, without waiting for a poll.
746 794 def handle_event("submit-pkg", %{"submit" => %{"query" => query}}, socket) do
@@ -999,10 +1047,6 @@ defmodule RoomSanctumWeb.SourceLive.Show do
999 1047 %{data: condensed, id: id, type: type}
1000 1048 end
1001 1049
1002 − defp pt_for_form({lat, lon}) do
1003 − "#{lat},#{lon}"
1004 − end
1005 −
1006 1050 defp getlatlng(coords) do
1007 1051 coords |> Tuple.to_list() |> Poison.encode!()
1008 1052 end
@@ -1049,6 +1093,4 @@ defmodule RoomSanctumWeb.SourceLive.Show do
1049 1093 "/assets/img/marker.png"
1050 1094 end
1051 1095
1052 − def get_first({a, _b}), do: a
1053 − def get_second({_a, b}), do: b
1054 1096 end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+7 −41
@@ -364,6 +364,8 @@ gl_available_projects={@gitlab_available_projects}
364 364 selected_tint={@tint}
365 365 class=""
366 366 height="600px"
367 + add_query_event="map-add-query"
368 + queried_station_ids={Enum.map(@queries, fn q -> q.query && (Map.get(q.query, :stop_id) || Map.get(q.query, :stop)) end)}
367 369 />
368 370 </div>
369 371 <% else %>
@@ -438,7 +440,7 @@ gl_available_projects={@gitlab_available_projects}
438 440 </ul>
439 441 </div>
440 442 </div>
441 − <div class="flex flex-col gap-2">
443 + <div class="flex flex-col gap-2 w-full xl:w-1/2 min-w-0">
442 444 <div class="text-xl">Selected: <%= @tester_selected_name %></div>
443 445 <div class="text-xs">id: <%= @tester_selected %></div>
444 446 <div class="">
@@ -455,8 +457,10 @@ gl_available_projects={@gitlab_available_projects}
455 457 </div>
456 458 </div>
457 459 <% :gbfs -> %>
458 − <div class="flex justify-between gap-4">
459 − <div class="flex flex-col gap-2">
460 + <%!-- min-w-0 so these can shrink; flex items default to min-width:auto
461 + and otherwise refuse to go below their content width. --%>
462 + <div class="flex flex-col xl:flex-row justify-between gap-4">
463 + <div class="flex flex-col gap-2 w-full xl:w-1/2 min-w-0">
460 464 <div class="">
461 465 <div class="flex flex-col w-full border-opacity-50">
462 466 <div class="grid h-20 card bg-base-300 rounded-box place-items-center">
@@ -472,21 +476,6 @@ gl_available_projects={@gitlab_available_projects}
472 476 </fieldset>
473 477 </form>
474 478 </div>
475 − <div class="divider">OR</div>
476 − <div class="grid h-20 card bg-base-300 rounded-box place-items-center">
477 − <form phx-change="select-foci">
478 − <label for="foci">Select Foci:</label>
479 − <select name="foci" id="foci">
480 − <%= for option <- @tester_foci do %>
481 − <option value={ option.place.coordinates |> pt_for_form
482 − }><%= option.name %></option>
483 − <% end %>
484 − </select>
485 − <label for="distance">Distance <%= @tester_foci_distance %></label>
486 − <input type="range" min="0" max="250" value="100" class="range"
487 − name="distance"/>
488 − </form>
489 − </div>
490 479 </div>
491 480 </div>
492 481 <div class="">
@@ -506,25 +495,6 @@ gl_available_projects={@gitlab_available_projects}
506 495 </ul>
507 496 </div>
508 497 </div>
509 − <div class="flex flex-col gap-2 h-full" id="foci_map" phx-update="replace">
510 − <leaflet-map style="height: 400px; width: 600px" lat={@tester_foci_coords_lat}
511 − lng={@tester_foci_coords_lon} zoom="13" class="h-full">
512 − <%= for marker <- @tester_foci_vehicles do %>
513 − <leaflet-marker
514 − lat={ marker.point.coordinates |> get_second }
515 − lng={ marker.point.coordinates |> get_first }
516 − selected={false}
517 − phx-click="toggle_marker"
518 − phx-value-id={ marker.id }>
519 − <leaflet-icon
520 − icon-url={ get_icon_url(false) }
521 − width="64"
522 − height="64">
523 − </leaflet-icon>
524 − </leaflet-marker>
525 − <% end %>
526 − </leaflet-map>
527 − </div>
528 498 <div class="flex flex-col gap-2">
529 499 <div class="text-xl">Selected: <%= @tester_selected_name %></div>
530 500 <div class="text-xs">id: <%= @tester_selected %></div>
@@ -537,10 +507,6 @@ gl_available_projects={@gitlab_available_projects}
537 507 -waiting on data-
538 508 <% end %>
539 509 </div>
540 − <div class="">
541 − brrr <br/>
542 − brrr <br/>brrr <br/>brrr <br/>brrr <br/>brrr <br/>brrr <br/>
543 − </div>
544 510 </div>
545 511 </div>
546 512 <% _default -> %>
modified config/config.exs
+1 −1
@@ -62,7 +62,7 @@ config :esbuild,
62 62 version: "0.14.0",
63 63 default: [
64 64 args:
65 − ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
65 + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --loader:.css=text),
66 66 cd: Path.expand("../apps/room_sanctum/assets", __DIR__),
67 67 env: %{
68 68 "NODE_PATH" => Path.expand("../deps", __DIR__)

Parents: 1775e59