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.
Fix GBFS import and rebuild the source map view
1e1641e · Gabriel Morell · 2026-08-07 19:14
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