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.
a lot of map
18ad607 · gmorell · 2025-09-15 15:48
Files changed
modified
apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+127
−3
@@ -58,6 +58,18 @@ defmodule RoomGtfs.Worker do
| 58 | 58 | |> GenServer.call({:query_realtime, trips, stop}, 30_000) |
| 59 | 59 | end |
| 60 | 60 | |
| 61 | + def get_current_vehicle_positions(name) do | |
| 62 | + "gtfs-rt#{name}" | |
| 63 | + |> via_tuple | |
| 64 | + |> GenServer.call(:query_vehicle_positions, 30_000) | |
| 65 | + end | |
| 66 | + | |
| 67 | + def get_current_vehicle_positions(name, trips) do | |
| 68 | + "gtfs-rt#{name}" | |
| 69 | + |> via_tuple | |
| 70 | + |> GenServer.call({:query_vehicle_positions, trips}, 30_000) | |
| 71 | + end | |
| 72 | + | |
| 61 | 73 | def query_stop(id, query) do |
| 62 | 74 | inst = Configuration.get_source!(id) |
| 63 | 75 | res = Storage.get_upcoming_arrivals_for_stop(id, query.stop) |> Storage.fix_arrival_times |
@@ -197,8 +209,20 @@ defmodule RoomGtfs.Worker do
| 197 | 209 | {:reply, r, state} |
| 198 | 210 | end |
| 199 | 211 | |
| 200 | − def handle_call(_msg, _from, state) do | |
| 201 | − {:reply, :ok, state} | |
| 212 | + def handle_call({:query_vehicle_positions, trips}, _from, state) do | |
| 213 | + # Return vehicle positions for specific trips | |
| 214 | + r = | |
| 215 | + try do | |
| 216 | + GenServer.call(state.child_rt, {:query_vehicle_positions, trips}) | |
| 217 | + catch | |
| 218 | + :exit, _ -> | |
| 219 | + IO.puts("timeout? exit? (vehicle positions)") | |
| 220 | + [] | |
| 221 | + after | |
| 222 | + [] | |
| 223 | + end | |
| 224 | + | |
| 225 | + {:reply, r, state} | |
| 202 | 226 | end |
| 203 | 227 | |
| 204 | 228 | defp via_tuple(name), do: {:via, Registry, {@registry, name}} |
@@ -270,6 +294,32 @@ defmodule RoomGtfs.Worker.RT do
| 270 | 294 | |
| 271 | 295 | defp via_tuple(name), do: {:via, Registry, {@registry, name}} |
| 272 | 296 | |
| 297 | + # Helper function to extract vehicle positions from protobuf data | |
| 298 | + defp extract_vehicle_positions(data_vp) do | |
| 299 | + case data_vp do | |
| 300 | + nil -> | |
| 301 | + [] | |
| 302 | + | |
| 303 | + %{entity: entities} -> | |
| 304 | + entities | |
| 305 | + |> Enum.filter(fn entity -> entity.vehicle && entity.vehicle.position end) | |
| 306 | + |> Enum.map(fn entity -> | |
| 307 | + %{ | |
| 308 | + vehicle_id: entity.vehicle.vehicle.id, | |
| 309 | + trip_id: if(entity.vehicle.trip, do: entity.vehicle.trip.trip_id, else: nil), | |
| 310 | + route_id: if(entity.vehicle.trip, do: entity.vehicle.trip.route_id, else: nil), | |
| 311 | + latitude: entity.vehicle.position.latitude, | |
| 312 | + longitude: entity.vehicle.position.longitude, | |
| 313 | + bearing: entity.vehicle.position.bearing, | |
| 314 | + timestamp: entity.vehicle.timestamp | |
| 315 | + } | |
| 316 | + end) | |
| 317 | + | |
| 318 | + _ -> | |
| 319 | + [] | |
| 320 | + end | |
| 321 | + end | |
| 322 | + | |
| 273 | 323 | def fetch_rt_url(url) do |
| 274 | 324 | case HTTPoison.get(url, [], follow_redirect: true) do |
| 275 | 325 | {:ok, result} -> {:ok, try do |
@@ -337,7 +387,26 @@ defmodule RoomGtfs.Worker.RT do
| 337 | 387 | val -> |
| 338 | 388 | case fetch_rt_url(val) do |
| 339 | 389 | {:ok, data_vp} -> |
| 340 | − state |> Map.put(:rt_vp, data_vp) | |
| 390 | + new_state = state |> Map.put(:rt_vp, data_vp) | |
| 391 | + | |
| 392 | + # Broadcast vehicle position updates | |
| 393 | + vehicles = extract_vehicle_positions(data_vp) | |
| 394 | + | |
| 395 | + # Broadcast to specific source channel for source page | |
| 396 | + Phoenix.PubSub.broadcast( | |
| 397 | + RoomSanctum.PubSub, | |
| 398 | + "gtfs_vehicle_positions:#{state.id}", | |
| 399 | + {:vehicle_positions_updated, state.id, vehicles} | |
| 400 | + ) | |
| 401 | + | |
| 402 | + # Also broadcast to general channel for query pages | |
| 403 | + Phoenix.PubSub.broadcast( | |
| 404 | + RoomSanctum.PubSub, | |
| 405 | + "gtfs_vehicle_positions", | |
| 406 | + {:vehicle_positions_updated, vehicles} | |
| 407 | + ) | |
| 408 | + | |
| 409 | + new_state | |
| 341 | 410 | |
| 342 | 411 | {:error, error} -> |
| 343 | 412 | Logger.info( |
@@ -381,6 +450,61 @@ defmodule RoomGtfs.Worker.RT do
| 381 | 450 | {:reply, relevant_trips, state} |
| 382 | 451 | end |
| 383 | 452 | end |
| 453 | + | |
| 454 | + def handle_call(:query_vehicle_positions, _from, state) do | |
| 455 | + # Return all vehicle positions | |
| 456 | + case state.rt_vp do | |
| 457 | + nil -> | |
| 458 | + {:reply, [], state} | |
| 459 | + | |
| 460 | + _otherwise -> | |
| 461 | + vehicles = state.rt_vp.entity | |
| 462 | + |> Enum.filter(fn entity -> entity.vehicle && entity.vehicle.position end) | |
| 463 | + |> Enum.map(fn entity -> | |
| 464 | + %{ | |
| 465 | + vehicle_id: entity.vehicle.vehicle.id, | |
| 466 | + trip_id: if(entity.vehicle.trip, do: entity.vehicle.trip.trip_id, else: nil), | |
| 467 | + route_id: if(entity.vehicle.trip, do: entity.vehicle.trip.route_id, else: nil), | |
| 468 | + latitude: entity.vehicle.position.latitude, | |
| 469 | + longitude: entity.vehicle.position.longitude, | |
| 470 | + bearing: entity.vehicle.position.bearing, | |
| 471 | + timestamp: entity.vehicle.timestamp | |
| 472 | + } | |
| 473 | + end) | |
| 474 | + | |
| 475 | + {:reply, vehicles, state} | |
| 476 | + end | |
| 477 | + end | |
| 478 | + | |
| 479 | + def handle_call({:query_vehicle_positions, trips}, _from, state) do | |
| 480 | + # Return vehicle positions for specific trips | |
| 481 | + case state.rt_vp do | |
| 482 | + nil -> | |
| 483 | + {:reply, [], state} | |
| 484 | + | |
| 485 | + _otherwise -> | |
| 486 | + vehicles = state.rt_vp.entity | |
| 487 | + |> Enum.filter(fn entity -> | |
| 488 | + entity.vehicle && | |
| 489 | + entity.vehicle.position && | |
| 490 | + entity.vehicle.trip && | |
| 491 | + Enum.member?(trips, entity.vehicle.trip.trip_id) | |
| 492 | + end) | |
| 493 | + |> Enum.map(fn entity -> | |
| 494 | + %{ | |
| 495 | + vehicle_id: entity.vehicle.vehicle.id, | |
| 496 | + trip_id: entity.vehicle.trip.trip_id, | |
| 497 | + route_id: entity.vehicle.trip.route_id, | |
| 498 | + latitude: entity.vehicle.position.latitude, | |
| 499 | + longitude: entity.vehicle.position.longitude, | |
| 500 | + bearing: entity.vehicle.position.bearing, | |
| 501 | + timestamp: entity.vehicle.timestamp | |
| 502 | + } | |
| 503 | + end) | |
| 504 | + | |
| 505 | + {:reply, vehicles, state} | |
| 506 | + end | |
| 507 | + end | |
| 384 | 508 | end |
| 385 | 509 | |
| 386 | 510 | defmodule RoomGtfs.Worker.Static do |
added
apps/room_sanctum/assets/js/hooks/leaflet_map.js
+829
−0
@@ -0,0 +1,829 @@
| 1 | +/** | |
| 2 | + * Leaflet Map Hook for Phoenix LiveView | |
| 3 | + * Displays queries with geospatial data and real-time vehicle positions on an interactive map | |
| 4 | + */ | |
| 5 | + | |
| 6 | +const LeafletMap = { | |
| 7 | + mounted() { | |
| 8 | + this.isInitialized = false; | |
| 9 | + this.isInitializing = false; | |
| 10 | + this.isUpdating = false; | |
| 11 | + this.vehicleMarkers = new Map(); // Track vehicle markers for smooth updates | |
| 12 | + | |
| 13 | + // Ensure the container has proper dimensions before initializing | |
| 14 | + setTimeout(() => { | |
| 15 | + this.initializeMap(); | |
| 16 | + this.updateMarkers(); | |
| 17 | + }, 100); | |
| 18 | + }, | |
| 19 | + | |
| 20 | + updated() { | |
| 21 | + // Only update if map is already initialized and we're not currently initializing | |
| 22 | + if (this.map && this.isInitialized && !this.isUpdating) { | |
| 23 | + // Debounce updates to prevent flashing | |
| 24 | + if (this.updateTimeout) { | |
| 25 | + clearTimeout(this.updateTimeout); | |
| 26 | + } | |
| 27 | + this.updateTimeout = setTimeout(() => { | |
| 28 | + this.isUpdating = true; | |
| 29 | + this.map.invalidateSize(); | |
| 30 | + this.updateMarkers(); | |
| 31 | + this.isUpdating = false; | |
| 32 | + }, 150); | |
| 33 | + } else if (!this.map && !this.isInitializing) { | |
| 34 | + // Only initialize if we don't already have a map and aren't currently initializing | |
| 35 | + this.isInitializing = true; | |
| 36 | + setTimeout(() => { | |
| 37 | + this.initializeMap(); | |
| 38 | + this.updateMarkers(); | |
| 39 | + this.isInitializing = false; | |
| 40 | + }, 100); | |
| 41 | + } | |
| 42 | + }, | |
| 43 | + | |
| 44 | + destroyed() { | |
| 45 | + if (this.map) { | |
| 46 | + this.map.remove(); | |
| 47 | + } | |
| 48 | + }, | |
| 49 | + | |
| 50 | + initializeMap() { | |
| 51 | + // Only clear if we don't already have a map instance | |
| 52 | + if (this.map) { | |
| 53 | + this.map.remove(); | |
| 54 | + this.map = null; | |
| 55 | + } | |
| 56 | + | |
| 57 | + // Create a dedicated map container div instead of clearing everything | |
| 58 | + const mapContainer = document.createElement('div'); | |
| 59 | + mapContainer.style.cssText = 'width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 10;'; | |
| 60 | + | |
| 61 | + // Hide the loading state by setting it behind the map | |
| 62 | + const loadingDiv = this.el.querySelector('.flex.items-center.justify-center'); | |
| 63 | + if (loadingDiv) { | |
| 64 | + loadingDiv.style.zIndex = '1'; | |
| 65 | + } | |
| 66 | + | |
| 67 | + // Add the map container on top | |
| 68 | + this.el.appendChild(mapContainer); | |
| 69 | + | |
| 70 | + // Initialize the Leaflet map in the new container | |
| 71 | + this.map = L.map(mapContainer, { | |
| 72 | + center: [39.8283, -98.5795], // Center of USA as default | |
| 73 | + zoom: 4, | |
| 74 | + zoomControl: true, | |
| 75 | + scrollWheelZoom: true | |
| 76 | + }); | |
| 77 | + | |
| 78 | + // Add OpenStreetMap tiles | |
| 79 | + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |
| 80 | + attribution: '© OpenStreetMap contributors', | |
| 81 | + maxZoom: 18 | |
| 82 | + }).addTo(this.map); | |
| 83 | + | |
| 84 | + // Initialize separate layer groups for queries, vehicles, free bikes, and stations | |
| 85 | + this.queriesLayer = L.layerGroup().addTo(this.map); | |
| 86 | + this.vehiclesLayer = L.layerGroup().addTo(this.map); | |
| 87 | + this.freeBikesLayer = L.layerGroup().addTo(this.map); | |
| 88 | + this.stationsLayer = L.layerGroup().addTo(this.map); | |
| 89 | + | |
| 90 | + // Mark as initialized | |
| 91 | + this.isInitialized = true; | |
| 92 | + }, | |
| 93 | + | |
| 94 | + updateMarkers() { | |
| 95 | + // Skip if map isn't properly initialized | |
| 96 | + if (!this.map || !this.isInitialized || !this.queriesLayer || !this.vehiclesLayer || !this.freeBikesLayer) { | |
| 97 | + console.log('Skipping updateMarkers - map not ready'); | |
| 98 | + return; | |
| 99 | + } | |
| 100 | + | |
| 101 | + // Get data from data attributes with error handling | |
| 102 | + let queriesData, vehiclesData, freeBikesData, stationsData, selectedTint; | |
| 103 | + try { | |
| 104 | + queriesData = JSON.parse(this.el.dataset.queries || '[]'); | |
| 105 | + vehiclesData = JSON.parse(this.el.dataset.vehicles || '[]'); | |
| 106 | + freeBikesData = JSON.parse(this.el.dataset.freeBikes || '[]'); | |
| 107 | + stationsData = JSON.parse(this.el.dataset.stations || '[]'); | |
| 108 | + selectedTint = this.el.dataset.selectedTint; | |
| 109 | + } catch (e) { | |
| 110 | + console.error('Error parsing map data:', e); | |
| 111 | + return; | |
| 112 | + } | |
| 113 | + | |
| 114 | + console.log('updateMarkers called with:', { | |
| 115 | + queries: queriesData.length, | |
| 116 | + vehicles: vehiclesData.length, | |
| 117 | + freeBikes: freeBikesData.length, | |
| 118 | + stations: stationsData.length, | |
| 119 | + selectedTint | |
| 120 | + }); | |
| 121 | + | |
| 122 | + // Debug station data structure | |
| 123 | + if (stationsData.length > 0) { | |
| 124 | + console.log('First station data sample:', stationsData[0]); | |
| 125 | + } | |
| 126 | + | |
| 127 | + // Update query markers | |
| 128 | + this.updateQueryMarkers(queriesData, selectedTint); | |
| 129 | + | |
| 130 | + // Update vehicle markers with smooth transitions | |
| 131 | + this.updateVehicleMarkers(vehiclesData); | |
| 132 | + | |
| 133 | + // Update free bike markers | |
| 134 | + this.updateFreeBikeMarkers(freeBikesData); | |
| 135 | + | |
| 136 | + // Update station markers | |
| 137 | + this.updateStationMarkers(stationsData); | |
| 138 | + }, | |
| 139 | + | |
| 140 | + updateQueryMarkers(queriesData, selectedTint) { | |
| 141 | + // Clear existing query markers first | |
| 142 | + if (this.queriesLayer) { | |
| 143 | + this.queriesLayer.clearLayers(); | |
| 144 | + } | |
| 145 | + | |
| 146 | + if (queriesData.length === 0) { | |
| 147 | + console.log('No queries data found'); | |
| 148 | + return; | |
| 149 | + } | |
| 150 | + | |
| 151 | + // Filter queries by selected tint if specified | |
| 152 | + const filteredQueries = selectedTint ? | |
| 153 | + queriesData.filter(q => q.tint === selectedTint) : | |
| 154 | + queriesData; | |
| 155 | + | |
| 156 | + console.log('filteredQueries:', filteredQueries.length); | |
| 157 | + | |
| 158 | + // Create bounds for auto-fitting the map | |
| 159 | + const group = new L.featureGroup(); | |
| 160 | + | |
| 161 | + // Add markers for each query | |
| 162 | + filteredQueries.forEach(query => { | |
| 163 | + if (query.lat && query.lng && query.lat !== 0 && query.lng !== 0) { | |
| 164 | + const marker = this.createQueryMarker(query); | |
| 165 | + this.queriesLayer.addLayer(marker); | |
| 166 | + group.addLayer(marker); | |
| 167 | + } | |
| 168 | + }); | |
| 169 | + | |
| 170 | + console.log('Query markers created:', group.getLayers().length); | |
| 171 | + | |
| 172 | + // Fit map to show all markers if we have any (and no vehicles to also consider) | |
| 173 | + const vehiclesData = JSON.parse(this.el.dataset.vehicles || '[]'); | |
| 174 | + const hasVehicles = vehiclesData.length > 0; | |
| 175 | + | |
| 176 | + if (group.getLayers().length > 0 && !hasVehicles) { | |
| 177 | + try { | |
| 178 | + this.map.fitBounds(group.getBounds(), { | |
| 179 | + padding: [20, 20], | |
| 180 | + maxZoom: 15 | |
| 181 | + }); | |
| 182 | + } catch (e) { | |
| 183 | + console.warn('Error fitting map bounds:', e); | |
| 184 | + } | |
| 185 | + } | |
| 186 | + }, | |
| 187 | + | |
| 188 | + updateVehicleMarkers(vehiclesData) { | |
| 189 | + if (vehiclesData.length === 0) { | |
| 190 | + // Clear all vehicle markers if no vehicles | |
| 191 | + this.vehiclesLayer.clearLayers(); | |
| 192 | + this.vehicleMarkers.clear(); | |
| 193 | + return; | |
| 194 | + } | |
| 195 | + | |
| 196 | + console.log('Updating vehicle markers:', vehiclesData.length); | |
| 197 | + | |
| 198 | + // Keep track of current vehicle IDs | |
| 199 | + const currentVehicleIds = new Set(vehiclesData.map(v => v.id)); | |
| 200 | + | |
| 201 | + // Remove markers for vehicles that no longer exist | |
| 202 | + for (const [vehicleId, marker] of this.vehicleMarkers.entries()) { | |
| 203 | + if (!currentVehicleIds.has(vehicleId)) { | |
| 204 | + this.vehiclesLayer.removeLayer(marker); | |
| 205 | + this.vehicleMarkers.delete(vehicleId); | |
| 206 | + } | |
| 207 | + } | |
| 208 | + | |
| 209 | + // Create bounds that include both queries and vehicles | |
| 210 | + const allMarkers = new L.featureGroup(); | |
| 211 | + | |
| 212 | + // Add existing query markers to bounds | |
| 213 | + this.queriesLayer.eachLayer(layer => allMarkers.addLayer(layer)); | |
| 214 | + | |
| 215 | + // Update or create vehicle markers | |
| 216 | + vehiclesData.forEach(vehicle => { | |
| 217 | + if (vehicle.lat && vehicle.lng) { | |
| 218 | + const existingMarker = this.vehicleMarkers.get(vehicle.id); | |
| 219 | + | |
| 220 | + if (existingMarker) { | |
| 221 | + // Update existing marker position with smooth animation | |
| 222 | + this.animateVehicleMarker(existingMarker, vehicle); | |
| 223 | + } else { | |
| 224 | + // Create new marker | |
| 225 | + const newMarker = this.createVehicleMarker(vehicle); | |
| 226 | + this.vehiclesLayer.addLayer(newMarker); | |
| 227 | + this.vehicleMarkers.set(vehicle.id, newMarker); | |
| 228 | + } | |
| 229 | + | |
| 230 | + // Add to bounds group | |
| 231 | + const markerToAdd = this.vehicleMarkers.get(vehicle.id); | |
| 232 | + if (markerToAdd) { | |
| 233 | + allMarkers.addLayer(markerToAdd); | |
| 234 | + } | |
| 235 | + } | |
| 236 | + }); | |
| 237 | + | |
| 238 | + // Fit map to show all markers (queries + vehicles) on first load | |
| 239 | + if (allMarkers.getLayers().length > 0 && !this.hasSetInitialBounds) { | |
| 240 | + this.map.fitBounds(allMarkers.getBounds(), { | |
| 241 | + padding: [20, 20], | |
| 242 | + maxZoom: 15 | |
| 243 | + }); | |
| 244 | + this.hasSetInitialBounds = true; | |
| 245 | + } | |
| 246 | + }, | |
| 247 | + | |
| 248 | + updateFreeBikeMarkers(freeBikesData) { | |
| 249 | + // Clear existing free bike markers | |
| 250 | + if (this.freeBikesLayer) { | |
| 251 | + this.freeBikesLayer.clearLayers(); | |
| 252 | + } | |
| 253 | + | |
| 254 | + if (freeBikesData.length === 0) { | |
| 255 | + console.log('No free bikes data found'); | |
| 256 | + return; | |
| 257 | + } | |
| 258 | + | |
| 259 | + console.log('Updating free bike markers:', freeBikesData.length); | |
| 260 | + | |
| 261 | + // Create bounds that include queries, vehicles, and free bikes | |
| 262 | + const allMarkers = new L.featureGroup(); | |
| 263 | + | |
| 264 | + // Add existing query and vehicle markers to bounds | |
| 265 | + this.queriesLayer.eachLayer(layer => allMarkers.addLayer(layer)); | |
| 266 | + this.vehiclesLayer.eachLayer(layer => allMarkers.addLayer(layer)); | |
| 267 | + | |
| 268 | + // Add markers for each free bike | |
| 269 | + freeBikesData.forEach(bike => { | |
| 270 | + if (bike.lat && bike.lng && bike.lat !== 0 && bike.lng !== 0) { | |
| 271 | + const marker = this.createFreeBikeMarker(bike); | |
| 272 | + this.freeBikesLayer.addLayer(marker); | |
| 273 | + allMarkers.addLayer(marker); | |
| 274 | + } | |
| 275 | + }); | |
| 276 | + | |
| 277 | + console.log('Free bike markers created:', freeBikesData.length); | |
| 278 | + | |
| 279 | + // Fit map to show all markers (queries + vehicles + bikes) on first load | |
| 280 | + if (allMarkers.getLayers().length > 0 && !this.hasSetInitialBounds) { | |
| 281 | + this.map.fitBounds(allMarkers.getBounds(), { | |
| 282 | + padding: [20, 20], | |
| 283 | + maxZoom: 15 | |
| 284 | + }); | |
| 285 | + this.hasSetInitialBounds = true; | |
| 286 | + } | |
| 287 | + }, | |
| 288 | + | |
| 289 | + updateStationMarkers(stationsData) { | |
| 290 | + // Clear existing station markers | |
| 291 | + if (this.stationsLayer) { | |
| 292 | + this.stationsLayer.clearLayers(); | |
| 293 | + } | |
| 294 | + | |
| 295 | + if (stationsData.length === 0) { | |
| 296 | + console.log('No stations data found'); | |
| 297 | + return; | |
| 298 | + } | |
| 299 | + | |
| 300 | + console.log('Updating station markers:', stationsData.length); | |
| 301 | + | |
| 302 | + // Create bounds that include all markers | |
| 303 | + const allMarkers = new L.featureGroup(); | |
| 304 | + | |
| 305 | + // Add existing markers to bounds | |
| 306 | + this.queriesLayer.eachLayer(layer => allMarkers.addLayer(layer)); | |
| 307 | + this.vehiclesLayer.eachLayer(layer => allMarkers.addLayer(layer)); | |
| 308 | + this.freeBikesLayer.eachLayer(layer => allMarkers.addLayer(layer)); | |
| 309 | + | |
| 310 | + // Add markers for each station | |
| 311 | + stationsData.forEach(station => { | |
| 312 | + if (station.lat && station.lng && station.lat !== 0 && station.lng !== 0) { | |
| 313 | + const marker = this.createStationMarker(station); | |
| 314 | + this.stationsLayer.addLayer(marker); | |
| 315 | + allMarkers.addLayer(marker); | |
| 316 | + } | |
| 317 | + }); | |
| 318 | + | |
| 319 | + console.log('Station markers created:', stationsData.length); | |
| 320 | + | |
| 321 | + // Fit map to show all markers on first load | |
| 322 | + if (allMarkers.getLayers().length > 0 && !this.hasSetInitialBounds) { | |
| 323 | + this.map.fitBounds(allMarkers.getBounds(), { | |
| 324 | + padding: [20, 20], | |
| 325 | + maxZoom: 15 | |
| 326 | + }); | |
| 327 | + this.hasSetInitialBounds = true; | |
| 328 | + } | |
| 329 | + }, | |
| 330 | + | |
| 331 | + animateVehicleMarker(marker, vehicle) { | |
| 332 | + // Smooth transition to new position | |
| 333 | + const currentPos = marker.getLatLng(); | |
| 334 | + const newPos = L.latLng(vehicle.lat, vehicle.lng); | |
| 335 | + | |
| 336 | + // Only animate if position actually changed | |
| 337 | + if (currentPos.lat !== newPos.lat || currentPos.lng !== newPos.lng) { | |
| 338 | + // Use Leaflet's built-in smooth panning | |
| 339 | + marker.setLatLng(newPos); | |
| 340 | + | |
| 341 | + // Update bearing/rotation if provided | |
| 342 | + if (vehicle.bearing !== null && vehicle.bearing !== undefined) { | |
| 343 | + this.updateVehicleBearing(marker, vehicle.bearing); | |
| 344 | + } | |
| 345 | + | |
| 346 | + // Update popup content with latest data | |
| 347 | + const popupContent = this.createVehiclePopupContent(vehicle); | |
| 348 | + marker.getPopup().setContent(popupContent); | |
| 349 | + } | |
| 350 | + }, | |
| 351 | + | |
| 352 | + updateVehicleBearing(marker, bearing) { | |
| 353 | + // Rotate the vehicle icon to show direction | |
| 354 | + const iconElement = marker.getElement(); | |
| 355 | + if (iconElement) { | |
| 356 | + const vehicleIcon = iconElement.querySelector('.vehicle-icon'); | |
| 357 | + if (vehicleIcon) { | |
| 358 | + vehicleIcon.style.transform = `rotate(${bearing}deg)`; | |
| 359 | + } | |
| 360 | + } | |
| 361 | + }, | |
| 362 | + | |
| 363 | + createQueryMarker(query) { | |
| 364 | + // Create custom icon based on source type and tint | |
| 365 | + const icon = this.createCustomIcon(query); | |
| 366 | + | |
| 367 | + // Create marker | |
| 368 | + const marker = L.marker([query.lat, query.lng], { icon }); | |
| 369 | + | |
| 370 | + // Create popup content | |
| 371 | + const popupContent = this.createPopupContent(query); | |
| 372 | + marker.bindPopup(popupContent); | |
| 373 | + | |
| 374 | + // Add click handler for query navigation | |
| 375 | + marker.on('click', () => { | |
| 376 | + // You can emit events back to LiveView if needed | |
| 377 | + // this.pushEvent('marker_clicked', { query_id: query.id }); | |
| 378 | + }); | |
| 379 | + | |
| 380 | + return marker; | |
| 381 | + }, | |
| 382 | + | |
| 383 | + createVehicleMarker(vehicle) { | |
| 384 | + // Create custom vehicle icon | |
| 385 | + const icon = this.createVehicleIcon(vehicle); | |
| 386 | + | |
| 387 | + // Create marker | |
| 388 | + const marker = L.marker([vehicle.lat, vehicle.lng], { icon }); | |
| 389 | + | |
| 390 | + // Create popup content | |
| 391 | + const popupContent = this.createVehiclePopupContent(vehicle); | |
| 392 | + marker.bindPopup(popupContent); | |
| 393 | + | |
| 394 | + return marker; | |
| 395 | + }, | |
| 396 | + | |
| 397 | + createFreeBikeMarker(bike) { | |
| 398 | + // Create custom free bike icon | |
| 399 | + const icon = this.createFreeBikeIcon(bike); | |
| 400 | + | |
| 401 | + // Create marker | |
| 402 | + const marker = L.marker([bike.lat, bike.lng], { icon }); | |
| 403 | + | |
| 404 | + // Create popup content | |
| 405 | + const popupContent = this.createFreeBikePopupContent(bike); | |
| 406 | + marker.bindPopup(popupContent); | |
| 407 | + | |
| 408 | + return marker; | |
| 409 | + }, | |
| 410 | + | |
| 411 | + createStationMarker(station) { | |
| 412 | + // Create custom station icon | |
| 413 | + const icon = this.createStationIcon(station); | |
| 414 | + | |
| 415 | + // Create marker | |
| 416 | + const marker = L.marker([station.lat, station.lng], { icon }); | |
| 417 | + | |
| 418 | + // Create popup content | |
| 419 | + const popupContent = this.createStationPopupContent(station); | |
| 420 | + marker.bindPopup(popupContent); | |
| 421 | + | |
| 422 | + return marker; | |
| 423 | + }, | |
| 424 | + | |
| 425 | + createVehicleIcon(vehicle) { | |
| 426 | + // Use orange/red colors for vehicles to distinguish from query markers | |
| 427 | + const vehicleColor = '#FF6B35'; // Orange-red for vehicles | |
| 428 | + const bearing = vehicle.bearing || 0; | |
| 429 | + | |
| 430 | + // Create HTML for the vehicle icon with rotation | |
| 431 | + const iconHtml = ` | |
| 432 | + <div class="vehicle-marker" style=" | |
| 433 | + background-color: ${vehicleColor}; | |
| 434 | + width: 32px; | |
| 435 | + height: 32px; | |
| 436 | + border-radius: 8px; | |
| 437 | + border: 2px solid white; | |
| 438 | + box-shadow: 0 2px 6px rgba(0,0,0,0.4); | |
| 439 | + display: flex; | |
| 440 | + align-items: center; | |
| 441 | + justify-content: center; | |
| 442 | + color: white; | |
| 443 | + font-size: 14px; | |
| 444 | + position: relative; | |
| 445 | + transform: rotate(${bearing}deg); | |
| 446 | + transition: transform 0.3s ease; | |
| 447 | + "> | |
| 448 | + <i class="fas ${vehicle.icon} vehicle-icon" style="transform: rotate(${-bearing}deg);"></i> | |
| 449 | + <div style=" | |
| 450 | + position: absolute; | |
| 451 | + top: -2px; | |
| 452 | + right: -2px; | |
| 453 | + width: 8px; | |
| 454 | + height: 8px; | |
| 455 | + background: #22c55e; | |
| 456 | + border-radius: 50%; | |
| 457 | + border: 1px solid white; | |
| 458 | + "></div> | |
| 459 | + </div> | |
| 460 | + `; | |
| 461 | + | |
| 462 | + return L.divIcon({ | |
| 463 | + html: iconHtml, | |
| 464 | + iconSize: [32, 32], | |
| 465 | + iconAnchor: [16, 16], | |
| 466 | + popupAnchor: [0, -16], | |
| 467 | + className: 'custom-vehicle-icon' | |
| 468 | + }); | |
| 469 | + }, | |
| 470 | + | |
| 471 | + createVehiclePopupContent(vehicle) { | |
| 472 | + const timestamp = vehicle.timestamp ? | |
| 473 | + new Date(vehicle.timestamp * 1000).toLocaleTimeString() : 'Unknown'; | |
| 474 | + | |
| 475 | + return ` | |
| 476 | + <div class="p-2 min-w-48"> | |
| 477 | + <div class="flex items-center gap-2 mb-2"> | |
| 478 | + <i class="fas ${vehicle.icon} text-orange-600"></i> | |
| 479 | + <h3 class="font-semibold text-sm">Vehicle ${vehicle.vehicle_id}</h3> | |
| 480 | + <span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800"> | |
| 481 | + <div class="w-2 h-2 bg-green-400 rounded-full mr-1"></div> | |
| 482 | + Live | |
| 483 | + </span> | |
| 484 | + </div> | |
| 485 | + | |
| 486 | + ${vehicle.trip_id ? ` | |
| 487 | + <div class="text-xs text-gray-600 mb-1"> | |
| 488 | + <strong>Trip:</strong> ${vehicle.trip_id} | |
| 489 | + </div> | |
| 490 | + ` : ''} | |
| 491 | + | |
| 492 | + ${vehicle.route_id ? ` | |
| 493 | + <div class="text-xs text-gray-600 mb-1"> | |
| 494 | + <strong>Route:</strong> ${vehicle.route_id} | |
| 495 | + </div> | |
| 496 | + ` : ''} | |
| 497 | + | |
| 498 | + <div class="text-xs text-gray-600 mb-1"> | |
| 499 | + <strong>Last Update:</strong> ${timestamp} | |
| 500 | + </div> | |
| 501 | + | |
| 502 | + ${vehicle.bearing !== null && vehicle.bearing !== undefined ? ` | |
| 503 | + <div class="text-xs text-gray-600 mb-2"> | |
| 504 | + <strong>Heading:</strong> ${Math.round(vehicle.bearing)}° | |
| 505 | + </div> | |
| 506 | + ` : ''} | |
| 507 | + | |
| 508 | + <div class="text-xs text-gray-500"> | |
| 509 | + <i class="fa-solid fa-location-dot mr-1"></i> | |
| 510 | + ${vehicle.lat.toFixed(4)}, ${vehicle.lng.toFixed(4)} | |
| 511 | + </div> | |
| 512 | + </div> | |
| 513 | + `; | |
| 514 | + }, | |
| 515 | + | |
| 516 | + createCustomIcon(query) { | |
| 517 | + // Get color based on tint or default | |
| 518 | + const tintColor = query.tint ? this.getTintColor(query.tint) : '#6B7280'; | |
| 519 | + | |
| 520 | + // Create HTML for the icon | |
| 521 | + const iconHtml = ` | |
| 522 | + <div class="custom-marker" style=" | |
| 523 | + background-color: ${tintColor}; | |
| 524 | + width: 30px; | |
| 525 | + height: 30px; | |
| 526 | + border-radius: 50%; | |
| 527 | + border: 3px solid white; | |
| 528 | + box-shadow: 0 2px 4px rgba(0,0,0,0.3); | |
| 529 | + display: flex; | |
| 530 | + align-items: center; | |
| 531 | + justify-content: center; | |
| 532 | + color: white; | |
| 533 | + font-size: 12px; | |
| 534 | + "> | |
| 535 | + <i class="fas ${query.icon}"></i> | |
| 536 | + </div> | |
| 537 | + `; | |
| 538 | + | |
| 539 | + return L.divIcon({ | |
| 540 | + html: iconHtml, | |
| 541 | + iconSize: [30, 30], | |
| 542 | + iconAnchor: [15, 15], | |
| 543 | + popupAnchor: [0, -15], | |
| 544 | + className: 'custom-div-icon' | |
| 545 | + }); | |
| 546 | + }, | |
| 547 | + | |
| 548 | + createFreeBikeIcon(bike) { | |
| 549 | + // Use green colors for available bikes, yellow for reserved, gray for disabled | |
| 550 | + let bikeColor = '#22C55E'; // Green for available | |
| 551 | + if (bike.is_disabled) { | |
| 552 | + bikeColor = '#6B7280'; // Gray for disabled | |
| 553 | + } else if (bike.is_reserved) { | |
| 554 | + bikeColor = '#F59E0B'; // Yellow for reserved | |
| 555 | + } | |
| 556 | + | |
| 557 | + // Create battery indicator if battery info is available | |
| 558 | + const batteryIndicator = bike.battery_icon && bike.battery_color ? ` | |
| 559 | + <div style=" | |
| 560 | + position: absolute; | |
| 561 | + top: -4px; | |
| 562 | + right: -4px; | |
| 563 | + width: 12px; | |
| 564 | + height: 12px; | |
| 565 | + background: white; | |
| 566 | + border-radius: 50%; | |
| 567 | + display: flex; | |
| 568 | + align-items: center; | |
| 569 | + justify-content: center; | |
| 570 | + box-shadow: 0 1px 2px rgba(0,0,0,0.2); | |
| 571 | + "> | |
| 572 | + <i class="fas ${bike.battery_icon}" style=" | |
| 573 | + font-size: 6px; | |
| 574 | + color: ${bike.battery_color}; | |
| 575 | + "></i> | |
| 576 | + </div> | |
| 577 | + ` : ''; | |
| 578 | + | |
| 579 | + // Create status indicator for disabled/reserved | |
| 580 | + const statusIndicator = (bike.is_disabled || bike.is_reserved) ? ` | |
| 581 | + <div style=" | |
| 582 | + position: absolute; | |
| 583 | + bottom: -2px; | |
| 584 | + right: -2px; | |
| 585 | + width: 8px; | |
| 586 | + height: 8px; | |
| 587 | + background: ${bike.is_disabled ? '#EF4444' : '#F59E0B'}; | |
| 588 | + border-radius: 50%; | |
| 589 | + border: 1px solid white; | |
| 590 | + box-shadow: 0 1px 2px rgba(0,0,0,0.2); | |
| 591 | + "></div> | |
| 592 | + ` : ''; | |
| 593 | + | |
| 594 | + // Create HTML for the bike icon | |
| 595 | + const iconHtml = ` | |
| 596 | + <div class="bike-marker" style=" | |
| 597 | + background-color: ${bikeColor}; | |
| 598 | + width: 20px; | |
| 599 | + height: 20px; | |
| 600 | + border-radius: 50%; | |
| 601 | + border: 2px solid white; | |
| 602 | + box-shadow: 0 2px 4px rgba(0,0,0,0.3); | |
| 603 | + display: flex; | |
| 604 | + align-items: center; | |
| 605 | + justify-content: center; | |
| 606 | + color: white; | |
| 607 | + font-size: 8px; | |
| 608 | + position: relative; | |
| 609 | + "> | |
| 610 | + <i class="fas ${bike.icon}"></i> | |
| 611 | + ${batteryIndicator} | |
| 612 | + ${statusIndicator} | |
| 613 | + </div> | |
| 614 | + `; | |
| 615 | + | |
| 616 | + return L.divIcon({ | |
| 617 | + html: iconHtml, | |
| 618 | + iconSize: [20, 20], | |
| 619 | + iconAnchor: [10, 10], | |
| 620 | + popupAnchor: [0, -10], | |
| 621 | + className: 'custom-bike-icon' | |
| 622 | + }); | |
| 623 | + }, | |
| 624 | + | |
| 625 | + createFreeBikePopupContent(bike) { | |
| 626 | + const statusBadge = bike.is_disabled ? | |
| 627 | + '<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Disabled</span>' : | |
| 628 | + bike.is_reserved ? | |
| 629 | + '<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Reserved</span>' : | |
| 630 | + '<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">Available</span>'; | |
| 631 | + | |
| 632 | + // Create battery display if available | |
| 633 | + const batteryDisplay = bike.battery_level !== null && bike.battery_level !== undefined ? ` | |
| 634 | + <div class="text-xs text-gray-600 mb-1"> | |
| 635 | + <i class="fas ${bike.battery_icon}" style="color: ${bike.battery_color};"></i> | |
| 636 | + <strong>Battery:</strong> ${bike.battery_level.toFixed(1)}% | |
| 637 | + </div> | |
| 638 | + ` : ''; | |
| 639 | + | |
| 640 | + return ` | |
| 641 | + <div class="p-2 min-w-48"> | |
| 642 | + <div class="flex items-center gap-2 mb-2"> | |
| 643 | + <i class="fas ${bike.icon} text-green-600"></i> | |
| 644 | + <h3 class="font-semibold text-sm">Bike ${bike.bike_id}</h3> | |
| 645 | + ${statusBadge} | |
| 646 | + </div> | |
| 647 | + | |
| 648 | + ${bike.vehicle_type_id ? ` | |
| 649 | + <div class="text-xs text-gray-600 mb-1"> | |
| 650 | + <strong>Type:</strong> ${bike.vehicle_type_id} | |
| 651 | + </div> | |
| 652 | + ` : ''} | |
| 653 | + | |
| 654 | + ${batteryDisplay} | |
| 655 | + | |
| 656 | + ${bike.current_range_meters ? ` | |
| 657 | + <div class="text-xs text-gray-600 mb-1"> | |
| 658 | + <strong>Range:</strong> ${bike.current_range_meters}m | |
| 659 | + </div> | |
| 660 | + ` : ''} | |
| 661 | + | |
| 662 | + <div class="text-xs text-gray-500"> | |
| 663 | + <i class="fa-solid fa-location-dot mr-1"></i> | |
| 664 | + ${bike.lat.toFixed(4)}, ${bike.lng.toFixed(4)} | |
| 665 | + </div> | |
| 666 | + </div> | |
| 667 | + `; | |
| 668 | + }, | |
| 669 | + | |
| 670 | + createStationIcon(station) { | |
| 671 | + // Get the tint color for the station | |
| 672 | + const tintColor = station.tint ? this.getTintColor(station.tint) : '#4338ca'; | |
| 673 | + | |
| 674 | + const iconHtml = ` | |
| 675 | + <div style=" | |
| 676 | + width: 26px; | |
| 677 | + height: 26px; | |
| 678 | + border-radius: 50%; | |
| 679 | + background-color: ${tintColor}; | |
| 680 | + border: 2px solid #fff; | |
| 681 | + box-shadow: 0 2px 4px rgba(0,0,0,0.2); | |
| 682 | + display: flex; | |
| 683 | + align-items: center; | |
| 684 | + justify-content: center; | |
| 685 | + color: white; | |
| 686 | + font-size: 12px; | |
| 687 | + position: relative; | |
| 688 | + "> | |
| 689 | + <i class="fas fa-bicycle"></i> | |
| 690 | + </div> | |
| 691 | + `; | |
| 692 | + | |
| 693 | + return L.divIcon({ | |
| 694 | + html: iconHtml, | |
| 695 | + iconSize: [26, 26], | |
| 696 | + iconAnchor: [13, 13], | |
| 697 | + popupAnchor: [0, -13], | |
| 698 | + className: 'custom-station-icon' | |
| 699 | + }); | |
| 700 | + }, | |
| 701 | + | |
| 702 | + createStationPopupContent(station) { | |
| 703 | + return ` | |
| 704 | + <div class="p-2 min-w-48"> | |
| 705 | + <div class="flex items-center gap-2 mb-2"> | |
| 706 | + <i class="fas fa-bicycle text-indigo-600"></i> | |
| 707 | + <h3 class="font-semibold text-sm">${station.name || 'Station'}</h3> | |
| 708 | + </div> | |
| 709 | + | |
| 710 | + ${station.short_name ? ` | |
| 711 | + <div class="text-xs text-gray-600 mb-1"> | |
| 712 | + <strong>ID:</strong> ${station.short_name} | |
| 713 | + </div> | |
| 714 | + ` : ''} | |
| 715 | + | |
| 716 | + ${station.address ? ` | |
| 717 | + <div class="text-xs text-gray-600 mb-1"> | |
| 718 | + <strong>Address:</strong> ${station.address} | |
| 719 | + </div> | |
| 720 | + ` : ''} | |
| 721 | + | |
| 722 | + ${station.capacity !== null && station.capacity !== undefined ? ` | |
| 723 | + <div class="text-xs text-gray-600 mb-1"> | |
| 724 | + <strong>Capacity:</strong> ${station.capacity} bikes | |
| 725 | + </div> | |
| 726 | + ` : ''} | |
| 727 | + | |
| 728 | + ${station.num_bikes_available !== null && station.num_bikes_available !== undefined ? ` | |
| 729 | + <div class="text-xs text-gray-600 mb-1"> | |
| 730 | + <strong>Regular Bikes Available:</strong> ${station.num_bikes_available} | |
| 731 | + </div> | |
| 732 | + ` : ''} | |
| 733 | + | |
| 734 | + ${station.num_ebikes_available !== null && station.num_ebikes_available !== undefined ? ` | |
| 735 | + <div class="text-xs text-gray-600 mb-1"> | |
| 736 | + <strong>E-bikes Available:</strong> ${station.num_ebikes_available} | |
| 737 | + </div> | |
| 738 | + ` : ''} | |
| 739 | + | |
| 740 | + ${station.num_docks_available !== null && station.num_docks_available !== undefined ? ` | |
| 741 | + <div class="text-xs text-gray-600 mb-1"> | |
| 742 | + <strong>Docks Available:</strong> ${station.num_docks_available} | |
| 743 | + </div> | |
| 744 | + ` : ''} | |
| 745 | + | |
| 746 | + ${station.is_installed !== null && station.is_installed !== undefined ? ` | |
| 747 | + <div class="text-xs text-gray-600 mb-1"> | |
| 748 | + <strong>Status:</strong> ${station.is_installed ? 'Installed' : 'Not Installed'} | |
| 749 | + ${station.is_renting !== null ? (station.is_renting ? ' • Renting' : ' • Not Renting') : ''} | |
| 750 | + ${station.is_returning !== null ? (station.is_returning ? ' • Returning' : ' • Not Returning') : ''} | |
| 751 | + </div> | |
| 752 | + ` : ''} | |
| 753 | + | |
| 754 | + <div class="text-xs text-gray-500"> | |
| 755 | + <i class="fa-solid fa-location-dot mr-1"></i> | |
| 756 | + ${station.lat.toFixed(4)}, ${station.lng.toFixed(4)} | |
| 757 | + </div> | |
| 758 | + </div> | |
| 759 | + `; | |
| 760 | + }, | |
| 761 | + | |
| 762 | + createPopupContent(query) { | |
| 763 | + const tintBadge = query.tint ? | |
| 764 | + `<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium" style="background-color: ${this.getTintColor(query.tint)}20; color: ${this.getTintColor(query.tint)};"> | |
| 765 | + <i class="fa-solid fa-circle mr-1" style="color: ${this.getTintColor(query.tint)};"></i> | |
| 766 | + ${query.tint} | |
| 767 | + </span>` : ''; | |
| 768 | + | |
| 769 | + return ` | |
| 770 | + <div class="p-2 min-w-48"> | |
| 771 | + <div class="flex items-center gap-2 mb-2"> | |
| 772 | + <i class="fas ${query.icon} text-gray-600"></i> | |
| 773 | + <h3 class="font-semibold text-sm">${query.name}</h3> | |
| 774 | + </div> | |
| 775 | + | |
| 776 | + <div class="text-xs text-gray-600 mb-2"> | |
| 777 | + <strong>Source:</strong> ${query.source_name} | |
| 778 | + </div> | |
| 779 | + | |
| 780 | + ${query.notes ? ` | |
| 781 | + <div class="text-xs text-gray-600 mb-2"> | |
| 782 | + <strong>Notes:</strong> ${query.notes} | |
| 783 | + </div> | |
| 784 | + ` : ''} | |
| 785 | + | |
| 786 | + ${tintBadge ? ` | |
| 787 | + <div class="mb-2"> | |
| 788 | + ${tintBadge} | |
| 789 | + </div> | |
| 790 | + ` : ''} | |
| 791 | + | |
| 792 | + <div class="text-xs text-gray-500"> | |
| 793 | + <i class="fa-solid fa-location-dot mr-1"></i> | |
| 794 | + ${query.lat.toFixed(4)}, ${query.lng.toFixed(4)} | |
| 795 | + </div> | |
| 796 | + </div> | |
| 797 | + `; | |
| 798 | + }, | |
| 799 | + | |
| 800 | + getTintColor(tint) { | |
| 801 | + // Map tint names to colors - adjust based on your Tailwind color scheme | |
| 802 | + const colorMap = { | |
| 803 | + 'amber': '#F59E0B', | |
| 804 | + 'lime': '#84CC16', | |
| 805 | + 'emerald': '#10B981', | |
| 806 | + 'sky': '#0EA5E9', | |
| 807 | + 'violet': '#8B5CF6', | |
| 808 | + 'fuchsia': '#D946EF', | |
| 809 | + 'rose': '#F43F5E', | |
| 810 | + 'stone': '#78716C', | |
| 811 | + 'slate': '#64748B', | |
| 812 | + 'red': '#EF4444', | |
| 813 | + 'orange': '#F97316', | |
| 814 | + 'yellow': '#EAB308', | |
| 815 | + 'green': '#22C55E', | |
| 816 | + 'teal': '#14B8A6', | |
| 817 | + 'cyan': '#06B6D4', | |
| 818 | + 'blue': '#3B82F6', | |
| 819 | + 'indigo': '#6366F1', | |
| 820 | + 'purple': '#A855F7', | |
| 821 | + 'pink': '#EC4899', | |
| 822 | + 'gray': '#6B7280' | |
| 823 | + }; | |
| 824 | + | |
| 825 | + return colorMap[tint] || '#6B7280'; | |
| 826 | + } | |
| 827 | +}; | |
| 828 | + | |
| 829 | +export default LeafletMap; |
modified
apps/room_sanctum/lib/room_sanctum/storage.ex
+28
−0
@@ -891,6 +891,20 @@ defmodule RoomSanctum.Storage do
| 891 | 891 | Trip.changeset(trip, attrs) |
| 892 | 892 | end |
| 893 | 893 | |
| 894 | + # Get trips that serve a specific stop | |
| 895 | + def get_trips_for_stop(source_id, stop_id) do | |
| 896 | + from(st in StopTime, | |
| 897 | + join: t in Trip, | |
| 898 | + on: st.trip_id == t.trip_id, | |
| 899 | + where: st.source_id == ^source_id and | |
| 900 | + t.source_id == ^source_id and | |
| 901 | + st.stop_id == ^stop_id, | |
| 902 | + select: t, | |
| 903 | + distinct: true | |
| 904 | + ) | |
| 905 | + |> Repo.all() | |
| 906 | + end | |
| 907 | + | |
| 894 | 908 | alias RoomSanctum.Configuration, as: Cfg |
| 895 | 909 | |
| 896 | 910 | defp stringify(val) when is_integer(val) do |
@@ -1257,6 +1271,13 @@ defmodule RoomSanctum.Storage do
| 1257 | 1271 | Repo.all(StationInfo) |
| 1258 | 1272 | end |
| 1259 | 1273 | |
| 1274 | + def list_gbfs_station_information(source_id) do | |
| 1275 | + from(s in StationInfo, | |
| 1276 | + where: s.source_id == ^source_id | |
| 1277 | + ) | |
| 1278 | + |> Repo.all() | |
| 1279 | + end | |
| 1280 | + | |
| 1260 | 1281 | def list_gbfs_station_information(source_id, search_term) do |
| 1261 | 1282 | from(p in StationInfo, |
| 1262 | 1283 | where: |
@@ -1381,6 +1402,13 @@ defmodule RoomSanctum.Storage do
| 1381 | 1402 | Repo.all(StationStatus) |
| 1382 | 1403 | end |
| 1383 | 1404 | |
| 1405 | + def list_gbfs_station_status(source_id) do | |
| 1406 | + from(s in StationStatus, | |
| 1407 | + where: s.source_id == ^source_id | |
| 1408 | + ) | |
| 1409 | + |> Repo.all() | |
| 1410 | + end | |
| 1411 | + | |
| 1384 | 1412 | @doc """ |
| 1385 | 1413 | Gets a single station_status. |
| 1386 | 1414 |
modified
apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+321
−22
@@ -15,9 +15,19 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 15 | 15 | attr :selected_tint, :string, default: nil |
| 16 | 16 | attr :class, :string, default: "" |
| 17 | 17 | attr :height, :string, default: "500px" |
| 18 | + attr :vehicle_positions, :list, default: [] | |
| 19 | + attr :free_bikes, :list, default: [] | |
| 20 | + attr :stations, :list, default: [] | |
| 21 | + attr :station_statuses, :list, default: [] | |
| 22 | + attr :source_tint, :string, default: nil | |
| 18 | 23 | |
| 19 | 24 | def query_geospatial_map(assigns) do |
| 20 | − assigns = assign(assigns, :map_queries, get_mappable_queries(assigns.queries)) | |
| 25 | + assigns = | |
| 26 | + assigns | |
| 27 | + |> assign(:map_queries, get_mappable_queries(assigns.queries)) | |
| 28 | + |> assign(:map_vehicles, format_vehicle_positions(assigns.vehicle_positions)) | |
| 29 | + |> assign(:map_free_bikes, format_free_bikes(assigns.free_bikes)) | |
| 30 | + |> assign(:map_stations, format_stations(assigns.stations, Map.get(assigns, :station_statuses, []), Map.get(assigns, :source_tint, nil))) | |
| 21 | 31 | |
| 22 | 32 | ~H""" |
| 23 | 33 | <div class={"query-geospatial-map w-full #{@class}"}> |
@@ -28,24 +38,27 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 28 | 38 | style={"height: #{@height}; width: 100%; max-width: 100%; position: relative; box-sizing: border-box;"} |
| 29 | 39 | phx-hook="LeafletMap" |
| 30 | 40 | data-queries={Jason.encode!(@map_queries)} |
| 41 | + data-vehicles={Jason.encode!(@map_vehicles)} | |
| 42 | + data-free-bikes={Jason.encode!(@map_free_bikes)} | |
| 43 | + data-stations={Jason.encode!(@map_stations)} | |
| 31 | 44 | data-selected-tint={@selected_tint} |
| 32 | 45 | > |
| 33 | 46 | <!-- Loading state --> |
| 34 | − <div class="flex items-center justify-center h-full bg-gray-50"> | |
| 47 | + <div class="flex items-center justify-center h-full bg-base-200"> | |
| 35 | 48 | <div class="text-center"> |
| 36 | − <i class="fa-solid fa-map text-4xl text-gray-400 mb-2"></i> | |
| 37 | − <p class="text-gray-500">Loading map...</p> | |
| 49 | + <i class="fa-solid fa-map text-4xl text-base-content/60 mb-2"></i> | |
| 50 | + <p class="text-base-content/70">Loading map...</p> | |
| 38 | 51 | </div> |
| 39 | 52 | </div> |
| 40 | 53 | </div> |
| 41 | 54 | |
| 42 | 55 | <!-- Map legend/info --> |
| 43 | − <div class="mt-4 bg-white border border-gray-200 rounded-lg p-4"> | |
| 56 | + <div class="mt-4 bg-base-100 border border-base-300 rounded-lg p-4"> | |
| 44 | 57 | <div class="flex items-center justify-between mb-3"> |
| 45 | − <h3 class="text-sm font-semibold text-gray-700"> | |
| 58 | + <h3 class="text-sm font-semibold text-base-content"> | |
| 46 | 59 | Map Legend |
| 47 | − <span class="ml-2 text-xs text-gray-500"> | |
| 48 | − (<%= length(@map_queries) %> queries with location data) | |
| 60 | + <span class="ml-2 text-xs text-base-content/60"> | |
| 61 | + (<%= length(@map_queries) %> queries with location data<%= if length(@map_free_bikes) > 0, do: ", #{length(@map_free_bikes)} free bikes", else: "" %>) | |
| 49 | 62 | </span> |
| 50 | 63 | </h3> |
| 51 | 64 |
@@ -60,14 +73,21 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 60 | 73 | <div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2 text-xs"> |
| 61 | 74 | <%= for type <- get_unique_types(@map_queries) do %> |
| 62 | 75 | <div class="flex items-center"> |
| 63 | − <i class={"fas fa-fw #{get_icon(type)} mr-1 text-gray-600"}></i> | |
| 64 | − <span class="capitalize text-gray-700"><%= type %></span> | |
| 76 | + <i class={"fas fa-fw #{get_icon(type)} mr-1 text-base-content/70"}></i> | |
| 77 | + <span class="capitalize text-base-content/80"><%= type %></span> | |
| 78 | + </div> | |
| 79 | + <% end %> | |
| 80 | + | |
| 81 | + <%= if length(@map_free_bikes) > 0 do %> | |
| 82 | + <div class="flex items-center"> | |
| 83 | + <i class="fas fa-fw fa-bicycle mr-1 text-green-600"></i> | |
| 84 | + <span class="capitalize text-base-content/80">Free Bikes</span> | |
| 65 | 85 | </div> |
| 66 | 86 | <% end %> |
| 67 | 87 | </div> |
| 68 | 88 | |
| 69 | − <%= if length(@map_queries) == 0 do %> | |
| 70 | − <div class="text-center py-4 text-gray-500"> | |
| 89 | + <%= if length(@map_queries) == 0 and length(@map_free_bikes) == 0 do %> | |
| 90 | + <div class="text-center py-4 text-base-content/60"> | |
| 71 | 91 | <i class="fa-solid fa-map-location-dot text-2xl mb-2"></i> |
| 72 | 92 | <p>No queries with location data found</p> |
| 73 | 93 | <%= if @selected_tint do %> |
@@ -194,19 +214,28 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 194 | 214 | defp extract_from_query_data(query) do |
| 195 | 215 | case query.source.type do |
| 196 | 216 | :gtfs -> |
| 197 | − # For GTFS, get coordinates from the stop being queried | |
| 198 | − if query.query && Map.has_key?(query.query, :stop) do | |
| 199 | − get_gtfs_stop_coordinates(query.source.id, query.query.stop) | |
| 200 | − else | |
| 201 | − false | |
| 217 | + # For GTFS, get coordinates from the stop being queried or vehicle positions | |
| 218 | + cond do | |
| 219 | + query.query && Map.has_key?(query.query, :stop) -> | |
| 220 | + get_gtfs_stop_coordinates(query.source.id, query.query.stop) | |
| 221 | + | |
| 222 | + # Check if this is a vehicle position query | |
| 223 | + query.source.config && Map.get(query.source.config, :url_rt_vp) -> | |
| 224 | + get_gtfs_vehicle_coordinates(query.source.id, query) | |
| 225 | + | |
| 226 | + true -> | |
| 227 | + false | |
| 202 | 228 | end |
| 203 | 229 | |
| 204 | 230 | :gbfs -> |
| 205 | − # For GBFS, get coordinates from the station being queried | |
| 206 | − if query.query && Map.has_key?(query.query, :stop_id) do | |
| 207 | − get_gbfs_station_coordinates(query.source.id, query.query.stop_id) | |
| 208 | − else | |
| 209 | − false | |
| 231 | + # For GBFS, get coordinates from the station or area-based free bike query | |
| 232 | + cond do | |
| 233 | + query.query && Map.has_key?(query.query, :stop_id) -> | |
| 234 | + get_gbfs_station_coordinates(query.source.id, query.query.stop_id) | |
| 235 | + query.query && (Map.has_key?(query.query, :radius) || Map.has_key?(query.query, :point)) -> | |
| 236 | + get_gbfs_free_bikes_in_area_coordinates(query.source.id, query.query) | |
| 237 | + true -> | |
| 238 | + false | |
| 210 | 239 | end |
| 211 | 240 | |
| 212 | 241 | :aqi -> |
@@ -252,6 +281,36 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 252 | 281 | end |
| 253 | 282 | end |
| 254 | 283 | |
| 284 | + # Get coordinates for GBFS free bikes in area | |
| 285 | + defp get_gbfs_free_bikes_in_area_coordinates(source_id, query_params) do | |
| 286 | + # Extract point and radius from query parameters | |
| 287 | + {point, radius} = case query_params do | |
| 288 | + %{point: %Geo.Point{} = geo_point, radius: radius} -> | |
| 289 | + {geo_point, radius} | |
| 290 | + %{lat: lat, lng: lng, radius: radius} when is_number(lat) and is_number(lng) -> | |
| 291 | + {%Geo.Point{coordinates: {lng, lat}}, radius} | |
| 292 | + %{latitude: lat, longitude: lng, radius: radius} when is_number(lat) and is_number(lng) -> | |
| 293 | + {%Geo.Point{coordinates: {lng, lat}}, radius} | |
| 294 | + _ -> | |
| 295 | + {nil, nil} | |
| 296 | + end | |
| 297 | + | |
| 298 | + case {point, radius} do | |
| 299 | + {%Geo.Point{} = geo_point, radius} when is_number(radius) -> | |
| 300 | + # Find bikes in the area using existing Storage function | |
| 301 | + case RoomSanctum.Storage.find_free_bikes_around_point(source_id, geo_point, radius) do | |
| 302 | + [] -> | |
| 303 | + false | |
| 304 | + bikes when is_list(bikes) -> | |
| 305 | + # Return the center point of the query area | |
| 306 | + %Geo.Point{coordinates: {lng, lat}} = geo_point | |
| 307 | + {lat, lng} | |
| 308 | + end | |
| 309 | + _ -> | |
| 310 | + false | |
| 311 | + end | |
| 312 | + end | |
| 313 | + | |
| 255 | 314 | # Get coordinates for AQI foci |
| 256 | 315 | defp get_aqi_foci_coordinates(foci_id) do |
| 257 | 316 | case RoomSanctum.Storage.get_foci_by_id(foci_id) do |
@@ -265,6 +324,246 @@ defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
| 265 | 324 | end |
| 266 | 325 | end |
| 267 | 326 | |
| 327 | + # Get coordinates from GTFS vehicle positions | |
| 328 | + defp get_gtfs_vehicle_coordinates(source_id, query) do | |
| 329 | + try do | |
| 330 | + case RoomGtfs.Worker.get_current_vehicle_positions(source_id) do | |
| 331 | + [] -> | |
| 332 | + false | |
| 333 | + | |
| 334 | + vehicles when is_list(vehicles) -> | |
| 335 | + # Return the first vehicle position found, or average if multiple | |
| 336 | + case List.first(vehicles) do | |
| 337 | + %{latitude: lat, longitude: lng} when lat != nil and lng != nil -> | |
| 338 | + {lat, lng} | |
| 339 | + _ -> | |
| 340 | + false | |
| 341 | + end | |
| 342 | + | |
| 343 | + _ -> | |
| 344 | + false | |
| 345 | + end | |
| 346 | + rescue | |
| 347 | + _ -> | |
| 348 | + false | |
| 349 | + catch | |
| 350 | + _ -> | |
| 351 | + false | |
| 352 | + end | |
| 353 | + end | |
| 354 | + | |
| 355 | + # Format vehicle positions for map display | |
| 356 | + defp format_vehicle_positions(vehicle_positions) do | |
| 357 | + vehicle_positions | |
| 358 | + |> Enum.filter(fn vehicle -> | |
| 359 | + vehicle.latitude != nil && vehicle.longitude != nil | |
| 360 | + end) | |
| 361 | + |> Enum.map(fn vehicle -> | |
| 362 | + %{ | |
| 363 | + id: "vehicle_#{vehicle.vehicle_id}", | |
| 364 | + type: "vehicle", | |
| 365 | + vehicle_id: vehicle.vehicle_id, | |
| 366 | + trip_id: vehicle.trip_id, | |
| 367 | + route_id: vehicle.route_id, | |
| 368 | + lat: vehicle.latitude, | |
| 369 | + lng: vehicle.longitude, | |
| 370 | + bearing: vehicle.bearing, | |
| 371 | + timestamp: vehicle.timestamp, | |
| 372 | + icon: "fa-bus" # or different icon based on vehicle type | |
| 373 | + } | |
| 374 | + end) | |
| 375 | + end | |
| 376 | + | |
| 377 | + # Format free bikes for map display | |
| 378 | + defp format_free_bikes(free_bikes) do | |
| 379 | + free_bikes | |
| 380 | + |> Enum.filter(fn bike -> | |
| 381 | + (bike.point != nil) || (bike.lat != nil && bike.lon != nil) | |
| 382 | + end) | |
| 383 | + |> Enum.map(fn bike -> | |
| 384 | + {lat, lng} = cond do | |
| 385 | + bike.point != nil -> | |
| 386 | + case bike.point do | |
| 387 | + %Geo.Point{coordinates: {lng, lat}} -> {lat, lng} | |
| 388 | + _ -> {0, 0} | |
| 389 | + end | |
| 390 | + bike.lat != nil && bike.lon != nil -> | |
| 391 | + {bike.lat, bike.lon} | |
| 392 | + true -> {0, 0} | |
| 393 | + end | |
| 394 | + | |
| 395 | + # Extract additional fields with defaults | |
| 396 | + is_disabled = Map.get(bike, :is_disabled, false) | |
| 397 | + is_reserved = Map.get(bike, :is_reserved, false) | |
| 398 | + current_range_meters = Map.get(bike, :current_range_meters, nil) | |
| 399 | + vehicle_type_id = Map.get(bike, :vehicle_type_id, nil) | |
| 400 | + | |
| 401 | + # Calculate battery info if range is available | |
| 402 | + {battery_level, battery_icon, battery_color} = get_battery_info(current_range_meters) | |
| 403 | + | |
| 404 | + %{ | |
| 405 | + id: "bike_#{bike.bike_id}", | |
| 406 | + type: "free_bike", | |
| 407 | + bike_id: bike.bike_id, | |
| 408 | + lat: lat, | |
| 409 | + lng: lng, | |
| 410 | + is_disabled: is_disabled, | |
| 411 | + is_reserved: is_reserved, | |
| 412 | + vehicle_type_id: vehicle_type_id, | |
| 413 | + current_range_meters: current_range_meters, | |
| 414 | + battery_level: battery_level, | |
| 415 | + battery_icon: battery_icon, | |
| 416 | + battery_color: battery_color, | |
| 417 | + icon: "fa-bicycle" # bike icon | |
| 418 | + } | |
| 419 | + end) | |
| 420 | + end | |
| 421 | + | |
| 422 | + @doc """ | |
| 423 | + Calculate battery information from current_range_meters. | |
| 424 | + | |
| 425 | + Divides range by 45000 to get approximate battery level percentage, | |
| 426 | + then returns appropriate FontAwesome battery icon and color. | |
| 427 | + | |
| 428 | + Returns {battery_level_percent, icon_class, color} | |
| 429 | + """ | |
| 430 | + def get_battery_info(current_range_meters) when is_number(current_range_meters) do | |
| 431 | + battery_level = (current_range_meters / 45000.0 * 100) |> Float.round(1) | |
| 432 | + | |
| 433 | + {icon, color} = cond do | |
| 434 | + battery_level < 10 -> {"fa-battery-empty", "black"} | |
| 435 | + battery_level < 30 -> {"fa-battery-quarter", "red"} | |
| 436 | + battery_level < 50 -> {"fa-battery-half", "orange"} | |
| 437 | + battery_level < 80 -> {"fa-battery-three-quarters", "yellow"} | |
| 438 | + true -> {"fa-battery-full", "green"} | |
| 439 | + end | |
| 440 | + | |
| 441 | + {battery_level, icon, color} | |
| 442 | + end | |
| 443 | + | |
| 444 | + def get_battery_info(_), do: {nil, nil, nil} | |
| 445 | + | |
| 446 | + @doc """ | |
| 447 | + Get battery icon class for a given battery level percentage. | |
| 448 | + | |
| 449 | + ## Examples | |
| 450 | + | |
| 451 | + iex> get_battery_icon(5.0) | |
| 452 | + "fa-battery-empty" | |
| 453 | + | |
| 454 | + iex> get_battery_icon(25.0) | |
| 455 | + "fa-battery-quarter" | |
| 456 | + | |
| 457 | + iex> get_battery_icon(45.0) | |
| 458 | + "fa-battery-half" | |
| 459 | + | |
| 460 | + iex> get_battery_icon(75.0) | |
| 461 | + "fa-battery-three-quarters" | |
| 462 | + | |
| 463 | + iex> get_battery_icon(95.0) | |
| 464 | + "fa-battery-full" | |
| 465 | + """ | |
| 466 | + def get_battery_icon(battery_level) when is_number(battery_level) do | |
| 467 | + cond do | |
| 468 | + battery_level < 10 -> "fa-battery-empty" | |
| 469 | + battery_level < 30 -> "fa-battery-quarter" | |
| 470 | + battery_level < 50 -> "fa-battery-half" | |
| 471 | + battery_level < 80 -> "fa-battery-three-quarters" | |
| 472 | + true -> "fa-battery-full" | |
| 473 | + end | |
| 474 | + end | |
| 475 | + | |
| 476 | + def get_battery_icon(_), do: nil | |
| 477 | + | |
| 478 | + @doc """ | |
| 479 | + Get battery color for a given battery level percentage. | |
| 480 | + | |
| 481 | + ## Examples | |
| 482 | + | |
| 483 | + iex> get_battery_color(5.0) | |
| 484 | + "black" | |
| 485 | + | |
| 486 | + iex> get_battery_color(25.0) | |
| 487 | + "red" | |
| 488 | + | |
| 489 | + iex> get_battery_color(45.0) | |
| 490 | + "orange" | |
| 491 | + | |
| 492 | + iex> get_battery_color(75.0) | |
| 493 | + "yellow" | |
| 494 | + | |
| 495 | + iex> get_battery_color(95.0) | |
| 496 | + "green" | |
| 497 | + """ | |
| 498 | + def get_battery_color(battery_level) when is_number(battery_level) do | |
| 499 | + cond do | |
| 500 | + battery_level < 10 -> "black" | |
| 501 | + battery_level < 30 -> "red" | |
| 502 | + battery_level < 50 -> "orange" | |
| 503 | + battery_level < 80 -> "yellow" | |
| 504 | + true -> "green" | |
| 505 | + end | |
| 506 | + end | |
| 507 | + | |
| 508 | + def get_battery_color(_), do: nil | |
| 509 | + | |
| 510 | + # Format stations for map display | |
| 511 | + defp format_stations(stations, station_statuses \\ [], source_tint \\ nil) do | |
| 512 | + # Create a map of station status by station_id for quick lookup | |
| 513 | + status_map = Enum.reduce(station_statuses, %{}, fn status, acc -> | |
| 514 | + Map.put(acc, status.station_id, status) | |
| 515 | + end) | |
| 516 | + | |
| 517 | + stations | |
| 518 | + |> Enum.filter(fn station -> | |
| 519 | + (station.place != nil) || (station.lat != nil && station.lon != nil) | |
| 520 | + end) | |
| 521 | + |> Enum.map(fn station -> | |
| 522 | + {lat, lng} = cond do | |
| 523 | + station.place != nil -> | |
| 524 | + case station.place do | |
| 525 | + %Geo.Point{coordinates: {lng, lat}} -> {lat, lng} | |
| 526 | + _ -> {0, 0} | |
| 527 | + end | |
| 528 | + station.lat != nil && station.lon != nil -> | |
| 529 | + {station.lat, station.lon} | |
| 530 | + true -> {0, 0} | |
| 531 | + end | |
| 532 | + | |
| 533 | + # Get associated station status | |
| 534 | + status = Map.get(status_map, station.station_id, nil) | |
| 535 | + | |
| 536 | + station_data = %{ | |
| 537 | + id: "station_#{station.station_id}", | |
| 538 | + type: "station", | |
| 539 | + station_id: station.station_id, | |
| 540 | + name: Map.get(station, :name, "Unknown Station"), | |
| 541 | + short_name: Map.get(station, :short_name, ""), | |
| 542 | + capacity: Map.get(station, :capacity, 0), | |
| 543 | + address: Map.get(station, :address, ""), | |
| 544 | + lat: lat, | |
| 545 | + lng: lng, | |
| 546 | + tint: source_tint, # Use the source tint passed from the parent | |
| 547 | + icon: "fa-bicycle" | |
| 548 | + } | |
| 549 | + | |
| 550 | + # Add station status fields if available | |
| 551 | + if status do | |
| 552 | + station_data | |
| 553 | + |> Map.put(:num_bikes_available, status.num_bikes_available) | |
| 554 | + |> Map.put(:num_ebikes_available, status.num_ebikes_available) | |
| 555 | + |> Map.put(:num_docks_available, status.num_docks_available) | |
| 556 | + |> Map.put(:is_installed, status.is_installed) | |
| 557 | + |> Map.put(:is_renting, status.is_renting) | |
| 558 | + |> Map.put(:is_returning, status.is_returning) | |
| 559 | + |> Map.put(:station_status, status.station_status) | |
| 560 | + |> Map.put(:last_reported, status.last_reported) | |
| 561 | + else | |
| 562 | + station_data | |
| 563 | + end | |
| 564 | + end) | |
| 565 | + end | |
| 566 | + | |
| 268 | 567 | # Get unique source types for the legend |
| 269 | 568 | defp get_unique_types(queries) do |
| 270 | 569 | queries |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/index.ex
+125
−0
@@ -10,6 +10,12 @@ defmodule RoomSanctumWeb.QueryLive.Index do
| 10 | 10 | queries = list_cfg_queries(socket.assigns.current_user.id) |
| 11 | 11 | available_tints = get_available_tints(queries) |
| 12 | 12 | |
| 13 | + # Subscribe to vehicle position updates for all GTFS sources | |
| 14 | + if connected?(socket) do | |
| 15 | + Phoenix.PubSub.subscribe(RoomSanctum.PubSub, "gtfs_vehicle_positions") | |
| 16 | + Process.send_after(self(), :update_vehicle_positions, 1000) | |
| 17 | + end | |
| 18 | + | |
| 13 | 19 | {:ok, |
| 14 | 20 | socket |
| 15 | 21 | |> assign(:show_info, false) |
@@ -17,6 +23,7 @@ defmodule RoomSanctumWeb.QueryLive.Index do
| 17 | 23 | |> assign(:view_mode, :table) |
| 18 | 24 | |> assign(:available_tints, available_tints) |
| 19 | 25 | |> assign(:queries, queries) |
| 26 | + |> assign(:vehicle_positions, []) | |
| 20 | 27 | |> stream(:cfg_queries, queries)} |
| 21 | 28 | end |
| 22 | 29 |
@@ -55,6 +62,26 @@ defmodule RoomSanctumWeb.QueryLive.Index do
| 55 | 62 | |> stream_insert(:cfg_queries, query)} |
| 56 | 63 | end |
| 57 | 64 | |
| 65 | + def handle_info({:vehicle_positions_updated, vehicles}, socket) do | |
| 66 | + IO.inspect("Query Index: Received vehicle positions: #{length(vehicles)}") | |
| 67 | + # Filter vehicles to only show those relevant to current queries | |
| 68 | + filtered_vehicles = filter_vehicles_for_queries(vehicles, socket.assigns.queries) | |
| 69 | + IO.inspect("Query Index: Filtered to: #{length(filtered_vehicles)}") | |
| 70 | + {:noreply, socket |> assign(:vehicle_positions, filtered_vehicles)} | |
| 71 | + end | |
| 72 | + | |
| 73 | + def handle_info(:update_vehicle_positions, socket) do | |
| 74 | + # Fetch current vehicle positions from all GTFS sources | |
| 75 | + IO.inspect("Query Index: Timer update triggered") | |
| 76 | + vehicle_positions = get_all_vehicle_positions(socket.assigns.queries) | |
| 77 | + IO.inspect("Query Index: Timer fallback found #{length(vehicle_positions)} vehicles") | |
| 78 | + | |
| 79 | + # Schedule next update | |
| 80 | + Process.send_after(self(), :update_vehicle_positions, 30_000) # Every 30 seconds | |
| 81 | + | |
| 82 | + {:noreply, socket |> assign(:vehicle_positions, vehicle_positions)} | |
| 83 | + end | |
| 84 | + | |
| 58 | 85 | @impl true |
| 59 | 86 | def handle_event("delete", %{"id" => id}, socket) do |
| 60 | 87 | query = Configuration.get_query!(id) |
@@ -128,6 +155,104 @@ defmodule RoomSanctumWeb.QueryLive.Index do
| 128 | 155 | |> Enum.sort() |
| 129 | 156 | end |
| 130 | 157 | |
| 158 | + # Helper function to filter vehicles based on query relevance | |
| 159 | + defp filter_vehicles_for_queries(vehicles, queries) do | |
| 160 | + IO.inspect("=== VEHICLE FILTERING DEBUG ===") | |
| 161 | + IO.inspect("Input vehicles count: #{length(vehicles)}") | |
| 162 | + IO.inspect("Input queries count: #{length(queries)}") | |
| 163 | + | |
| 164 | + # Get all trip IDs and route IDs from GTFS queries | |
| 165 | + gtfs_queries = queries |> Enum.filter(fn query -> query.source.type == :gtfs end) | |
| 166 | + IO.inspect("GTFS queries count: #{length(gtfs_queries)}") | |
| 167 | + | |
| 168 | + {trip_ids, route_ids, stop_ids} = gtfs_queries | |
| 169 | + |> Enum.reduce({[], [], []}, fn query, {trips, routes, stops} -> | |
| 170 | + IO.puts("Processing query: #{query.name} with query data: #{inspect(query.query)}") | |
| 171 | + case query.query do | |
| 172 | + %{stop: stop_id} when is_binary(stop_id) -> | |
| 173 | + IO.puts("Found stop query: #{stop_id}") | |
| 174 | + stop_trips = RoomSanctum.Storage.get_trips_for_stop(query.source.id, stop_id) | |
| 175 | + trip_ids = Enum.map(stop_trips, & &1.trip_id) | |
| 176 | + IO.puts("Found #{length(trip_ids)} trips for stop #{stop_id} (first 5: #{Enum.take(trip_ids, 5) |> inspect})") | |
| 177 | + {trips ++ trip_ids, routes, [stop_id | stops]} | |
| 178 | + %{routes: route_ids} when is_list(route_ids) -> | |
| 179 | + IO.puts("Found route query: #{inspect(route_ids)}") | |
| 180 | + {trips, routes ++ route_ids, stops} | |
| 181 | + _ -> | |
| 182 | + IO.puts("Unhandled query type: #{inspect(query.query)}") | |
| 183 | + {trips, routes, stops} | |
| 184 | + end | |
| 185 | + end) | |
| 186 | + | |
| 187 | + IO.inspect("Final filtering criteria:") | |
| 188 | + IO.inspect("Trip IDs count: #{length(trip_ids)} (first 5: #{Enum.take(trip_ids, 5)})") | |
| 189 | + IO.inspect("Route IDs count: #{length(route_ids)} (#{inspect(route_ids)})") | |
| 190 | + IO.inspect("Stop IDs count: #{length(stop_ids)} (#{inspect(stop_ids)})") | |
| 191 | + | |
| 192 | + # Sample a few vehicles to see their structure | |
| 193 | + sample_vehicles = Enum.take(vehicles, 3) | |
| 194 | + IO.inspect("Sample vehicle structures:") | |
| 195 | + Enum.each(sample_vehicles, fn vehicle -> | |
| 196 | + IO.inspect("Vehicle: trip_id=#{vehicle.trip_id}, route_id=#{vehicle.route_id}, vehicle_id=#{vehicle.vehicle_id}") | |
| 197 | + end) | |
| 198 | + | |
| 199 | + # Filter vehicles that match any of the relevant trips or routes | |
| 200 | + filtered_vehicles = vehicles | |
| 201 | + |> Enum.filter(fn vehicle -> | |
| 202 | + trip_match = vehicle.trip_id && Enum.member?(trip_ids, vehicle.trip_id) | |
| 203 | + route_match = vehicle.route_id && Enum.member?(route_ids, vehicle.route_id) | |
| 204 | + | |
| 205 | + if trip_match || route_match do | |
| 206 | + IO.inspect("Vehicle MATCHED: #{vehicle.vehicle_id} (trip: #{vehicle.trip_id}, route: #{vehicle.route_id})") | |
| 207 | + end | |
| 208 | + | |
| 209 | + trip_match || route_match | |
| 210 | + end) | |
| 211 | + | |
| 212 | + IO.inspect("=== FILTERING RESULT: #{length(filtered_vehicles)} vehicles matched ===") | |
| 213 | + filtered_vehicles | |
| 214 | + end | |
| 215 | + | |
| 216 | + # Helper function to get vehicle positions from all GTFS sources | |
| 217 | + defp get_all_vehicle_positions(queries) do | |
| 218 | + IO.inspect("=== GET ALL VEHICLE POSITIONS DEBUG ===") | |
| 219 | + | |
| 220 | + source_ids = queries | |
| 221 | + |> Enum.filter(fn query -> query.source.type == :gtfs end) | |
| 222 | + |> Enum.map(fn query -> query.source.id end) | |
| 223 | + |> Enum.uniq() | |
| 224 | + | |
| 225 | + IO.inspect("Found GTFS source IDs: #{inspect(source_ids)}") | |
| 226 | + | |
| 227 | + all_vehicles = source_ids | |
| 228 | + |> Enum.flat_map(fn source_id -> | |
| 229 | + IO.inspect("Fetching vehicles from source #{source_id}") | |
| 230 | + try do | |
| 231 | + case RoomGtfs.Worker.get_current_vehicle_positions(source_id) do | |
| 232 | + vehicles when is_list(vehicles) -> | |
| 233 | + IO.inspect("Source #{source_id} returned #{length(vehicles)} vehicles") | |
| 234 | + vehicles | |
| 235 | + other -> | |
| 236 | + IO.inspect("Source #{source_id} returned unexpected result: #{inspect(other)}") | |
| 237 | + [] | |
| 238 | + end | |
| 239 | + rescue | |
| 240 | + e -> | |
| 241 | + IO.inspect("Error fetching from source #{source_id}: #{inspect(e)}") | |
| 242 | + [] | |
| 243 | + catch | |
| 244 | + e -> | |
| 245 | + IO.inspect("Caught error fetching from source #{source_id}: #{inspect(e)}") | |
| 246 | + [] | |
| 247 | + end | |
| 248 | + end) | |
| 249 | + | |
| 250 | + IO.inspect("Total vehicles before filtering: #{length(all_vehicles)}") | |
| 251 | + filtered_vehicles = all_vehicles |> filter_vehicles_for_queries(queries) | |
| 252 | + IO.inspect("=== GET ALL VEHICLE POSITIONS COMPLETE: #{length(filtered_vehicles)} vehicles ===") | |
| 253 | + filtered_vehicles | |
| 254 | + end | |
| 255 | + | |
| 131 | 256 | def get_icon(type) do |
| 132 | 257 | RoomSanctumWeb.IconHelpers.icon(type) |
| 133 | 258 | end |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/index.html.heex
+1
−0
@@ -43,6 +43,7 @@
| 43 | 43 | <div class="px-6"> |
| 44 | 44 | <.query_geospatial_map |
| 45 | 45 | queries={@queries} |
| 46 | + vehicle_positions={@vehicle_positions} | |
| 46 | 47 | selected_tint={@tint} |
| 47 | 48 | class="" |
| 48 | 49 | height="600px" |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+102
−1
@@ -9,7 +9,17 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 9 | 9 | def mount(_params, _session, socket) do |
| 10 | 10 | if connected?(socket), do: Process.send_after(self(), :update_sec, 200) |
| 11 | 11 | if connected?(socket), do: Process.send_after(self(), :update, 1000) |
| 12 | − {:ok, socket |> assign(:preview, []) |> assign(:preview_mode, :basic)} | |
| 12 | + | |
| 13 | + # Subscribe to vehicle position updates for showing live vehicles | |
| 14 | + if connected?(socket) do | |
| 15 | + Phoenix.PubSub.subscribe(RoomSanctum.PubSub, "gtfs_vehicle_positions") | |
| 16 | + Process.send_after(self(), :update_vehicle_positions, 1000) | |
| 17 | + end | |
| 18 | + | |
| 19 | + {:ok, socket | |
| 20 | + |> assign(:preview, []) | |
| 21 | + |> assign(:preview_mode, :basic) | |
| 22 | + |> assign(:vehicle_positions, [])} | |
| 13 | 23 | end |
| 14 | 24 | |
| 15 | 25 | @impl true |
@@ -96,6 +106,24 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 96 | 106 | {:noreply, socket |> assign(:visions, visions) |> assign(:avail_visions, nv)} |
| 97 | 107 | end |
| 98 | 108 | |
| 109 | + # Handle vehicle position updates | |
| 110 | + def handle_info({:vehicle_positions_updated, vehicles}, socket) do | |
| 111 | + # Filter vehicles to only show those relevant to current query | |
| 112 | + filtered_vehicles = filter_vehicles_for_single_query(vehicles, socket.assigns.query) | |
| 113 | + IO.inspect("Query Show: Filtered #{length(vehicles)} to #{length(filtered_vehicles)} vehicles for stop") | |
| 114 | + {:noreply, socket |> assign(:vehicle_positions, filtered_vehicles)} | |
| 115 | + end | |
| 116 | + | |
| 117 | + def handle_info(:update_vehicle_positions, socket) do | |
| 118 | + # Fetch and filter vehicle positions for this specific query | |
| 119 | + if socket.assigns.query.source.type == :gtfs do | |
| 120 | + vehicles = get_vehicle_positions_for_query(socket.assigns.query) | |
| 121 | + {:noreply, socket |> assign(:vehicle_positions, vehicles)} | |
| 122 | + else | |
| 123 | + {:noreply, socket} | |
| 124 | + end | |
| 125 | + end | |
| 126 | + | |
| 99 | 127 | def handle_event("toggle-sel", _params, socket) do |
| 100 | 128 | {:noreply, socket |> assign(:avail_sel, !socket.assigns.avail_sel)} |
| 101 | 129 | end |
@@ -146,4 +174,77 @@ defmodule RoomSanctumWeb.QueryLive.Show do
| 146 | 174 | _otherwise -> "fa-solid fa-box fa-fw" |
| 147 | 175 | end |
| 148 | 176 | end |
| 177 | + | |
| 178 | + # Helper function to filter vehicles for a single query | |
| 179 | + defp filter_vehicles_for_single_query(vehicles, query) do | |
| 180 | + IO.inspect("=== SINGLE QUERY FILTERING DEBUG ===") | |
| 181 | + IO.inspect("Input vehicles count: #{length(vehicles)}") | |
| 182 | + IO.inspect("Query: #{query.name} (#{query.source.type})") | |
| 183 | + IO.inspect("Query data: #{inspect(query.query)}") | |
| 184 | + | |
| 185 | + result = case query.query do | |
| 186 | + %{stop: stop_id} when is_binary(stop_id) -> | |
| 187 | + IO.inspect("Processing stop query for stop_id: #{stop_id}") | |
| 188 | + # Get trips that serve this stop | |
| 189 | + try do | |
| 190 | + stop_trips = RoomSanctum.Storage.get_trips_for_stop(query.source.id, stop_id) | |
| 191 | + trip_ids = Enum.map(stop_trips, & &1.trip_id) | |
| 192 | + IO.inspect("Found #{length(trip_ids)} trips serving stop #{stop_id}: #{Enum.take(trip_ids, 10)}") | |
| 193 | + | |
| 194 | + # Sample some vehicles | |
| 195 | + sample_vehicles = Enum.take(vehicles, 5) | |
| 196 | + IO.inspect("Sample vehicle trip_ids: #{Enum.map(sample_vehicles, & &1.trip_id)}") | |
| 197 | + | |
| 198 | + filtered = vehicles | |
| 199 | + |> Enum.filter(fn vehicle -> | |
| 200 | + match = vehicle.trip_id && Enum.member?(trip_ids, vehicle.trip_id) | |
| 201 | + if match do | |
| 202 | + IO.inspect("MATCHED vehicle: #{vehicle.vehicle_id} on trip #{vehicle.trip_id}") | |
| 203 | + end | |
| 204 | + match | |
| 205 | + end) | |
| 206 | + | |
| 207 | + IO.inspect("Stop filtering result: #{length(filtered)} vehicles matched") | |
| 208 | + filtered | |
| 209 | + rescue | |
| 210 | + e -> | |
| 211 | + IO.inspect("Error getting trips for stop #{stop_id}: #{inspect(e)}") | |
| 212 | + [] | |
| 213 | + end | |
| 214 | + %{routes: route_ids} when is_list(route_ids) -> | |
| 215 | + IO.inspect("Processing route query for route_ids: #{inspect(route_ids)}") | |
| 216 | + filtered = vehicles | |
| 217 | + |> Enum.filter(fn vehicle -> | |
| 218 | + match = vehicle.route_id && vehicle.route_id in route_ids | |
| 219 | + if match do | |
| 220 | + IO.inspect("MATCHED vehicle: #{vehicle.vehicle_id} on route #{vehicle.route_id}") | |
| 221 | + end | |
| 222 | + match | |
| 223 | + end) | |
| 224 | + IO.inspect("Route filtering result: #{length(filtered)} vehicles matched") | |
| 225 | + filtered | |
| 226 | + _ -> | |
| 227 | + IO.inspect("Unhandled query type: #{inspect(query.query)}") | |
| 228 | + [] | |
| 229 | + end | |
| 230 | + | |
| 231 | + IO.inspect("=== SINGLE QUERY FILTERING COMPLETE: #{length(result)} vehicles ===") | |
| 232 | + result | |
| 233 | + end | |
| 234 | + | |
| 235 | + # Helper function to get vehicle positions for a specific query | |
| 236 | + defp get_vehicle_positions_for_query(query) do | |
| 237 | + try do | |
| 238 | + case RoomGtfs.Worker.get_current_vehicle_positions(query.source.id) do | |
| 239 | + vehicles when is_list(vehicles) -> | |
| 240 | + filter_vehicles_for_single_query(vehicles, query) | |
| 241 | + _ -> | |
| 242 | + [] | |
| 243 | + end | |
| 244 | + rescue | |
| 245 | + _ -> [] | |
| 246 | + catch | |
| 247 | + _ -> [] | |
| 248 | + end | |
| 249 | + end | |
| 149 | 250 | end |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.html.heex
+1
−0
@@ -115,6 +115,7 @@
| 115 | 115 | <div style="width: 100%; max-width: 100%; overflow: hidden;"> |
| 116 | 116 | <.query_geospatial_map |
| 117 | 117 | queries={[@query]} |
| 118 | + vehicle_positions={@vehicle_positions} | |
| 118 | 119 | selected_tint={if @query.meta && @query.meta.tint, do: @query.meta.tint, else: nil} |
| 119 | 120 | class="" |
| 120 | 121 | height="350px" |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+74
−2
@@ -1,5 +1,6 @@
| 1 | 1 | defmodule RoomSanctumWeb.SourceLive.Show do |
| 2 | 2 | use RoomSanctumWeb, :live_view_a |
| 3 | + import RoomSanctumWeb.Components.QueryGeospatialMap | |
| 3 | 4 | |
| 4 | 5 | alias RoomSanctum.Configuration |
| 5 | 6 | alias RoomSanctum.Storage |
@@ -30,6 +31,10 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 30 | 31 | |> assign(:tester_foci_coords_lon, -71.1097) |
| 31 | 32 | |> assign(:tester_foci, {}) |
| 32 | 33 | |> assign(:tester_foci_vehicles, []) |
| 34 | + |> assign(:vehicle_positions, []) | |
| 35 | + |> assign(:free_bikes, []) | |
| 36 | + |> assign(:stations, []) | |
| 37 | + |> assign(:view_mode, :system) | |
| 33 | 38 | } |
| 34 | 39 | end |
| 35 | 40 |
@@ -39,9 +44,39 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 39 | 44 | queries = Configuration.get_queries(:source, socket.assigns.source_id) |
| 40 | 45 | available_tints = get_available_tints(queries) |
| 41 | 46 | |
| 47 | + # Add free bikes for GBFS sources | |
| 48 | + free_bikes = case socket.assigns.source.type do | |
| 49 | + :gbfs -> Storage.list_gbfs_free_bike_status() | |
| 50 | + |> Enum.filter(&(&1.source_id == socket.assigns.source_id)) | |
| 51 | + _ -> [] | |
| 52 | + end | |
| 53 | + | |
| 54 | + # Add station information for GBFS sources | |
| 55 | + stations = case socket.assigns.source.type do | |
| 56 | + :gbfs -> Storage.list_gbfs_station_information(socket.assigns.source_id) | |
| 57 | + _ -> [] | |
| 58 | + end | |
| 59 | + | |
| 60 | + # Add station status for GBFS sources | |
| 61 | + station_statuses = case socket.assigns.source.type do | |
| 62 | + :gbfs -> Storage.list_gbfs_station_status(socket.assigns.source_id) | |
| 63 | + _ -> [] | |
| 64 | + end | |
| 65 | + | |
| 66 | + # Extract source tint for stations | |
| 67 | + source_tint = if socket.assigns.source.meta && socket.assigns.source.meta.tint do | |
| 68 | + socket.assigns.source.meta.tint | |
| 69 | + else | |
| 70 | + nil | |
| 71 | + end | |
| 72 | + | |
| 42 | 73 | {:noreply, socket |
| 43 | 74 | |> assign(:queries, queries) |
| 44 | − |> assign(:available_tints, available_tints)} | |
| 75 | + |> assign(:available_tints, available_tints) | |
| 76 | + |> assign(:free_bikes, free_bikes) | |
| 77 | + |> assign(:stations, stations) | |
| 78 | + |> assign(:station_statuses, station_statuses) | |
| 79 | + |> assign(:source_tint, source_tint)} | |
| 45 | 80 | end |
| 46 | 81 | |
| 47 | 82 | @impl true |
@@ -64,8 +99,12 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 64 | 99 | def handle_params(%{"id" => id}, _, socket) do |
| 65 | 100 | source = Configuration.get_source!(id) |> IO.inspect |
| 66 | 101 | |
| 102 | + # Subscribe to relevant PubSub channels | |
| 67 | 103 | case source.type do |
| 68 | − :gtfs -> Phoenix.PubSub.subscribe(RoomSanctum.PubSub, "gtfs") | |
| 104 | + :gtfs -> | |
| 105 | + Phoenix.PubSub.subscribe(RoomSanctum.PubSub, "gtfs") | |
| 106 | + Phoenix.PubSub.subscribe(RoomSanctum.PubSub, "gtfs_vehicle_positions:#{id}") | |
| 107 | + if connected?(socket), do: Process.send_after(self(), :update_vehicle_positions, 1000) | |
| 69 | 108 | :gbfs -> Phoenix.PubSub.subscribe(RoomSanctum.PubSub, "gbfs") |
| 70 | 109 | :aqi -> Phoenix.PubSub.subscribe(RoomSanctum.PubSub, "aqi") |
| 71 | 110 | :calendar -> Phoenix.PubSub.subscribe(RoomSanctum.PubSub, "ical") |
@@ -233,6 +272,15 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 233 | 272 | end |
| 234 | 273 | end |
| 235 | 274 | |
| 275 | + @impl true | |
| 276 | + def handle_event("toggle-view", _params, socket) do | |
| 277 | + new_mode = case socket.assigns.view_mode do | |
| 278 | + :system -> :detail | |
| 279 | + :detail -> :system | |
| 280 | + end | |
| 281 | + {:noreply, socket |> assign(:view_mode, new_mode)} | |
| 282 | + end | |
| 283 | + | |
| 236 | 284 | defp percent(num, denom) do |
| 237 | 285 | (num / denom * 100) |
| 238 | 286 | |> Float.floor() |
@@ -269,6 +317,30 @@ defmodule RoomSanctumWeb.SourceLive.Show do
| 269 | 317 | {:noreply, socket |> assign(:source, source) |> put_flash(:info, "Completed")} |
| 270 | 318 | end |
| 271 | 319 | |
| 320 | + # Handle vehicle position updates for source-specific display | |
| 321 | + @impl true | |
| 322 | + def handle_info({:vehicle_positions_updated, source_id, vehicles}, socket) do | |
| 323 | + if socket.assigns.source_id == source_id do | |
| 324 | + {:noreply, socket |> assign(:vehicle_positions, vehicles)} | |
| 325 | + else | |
| 326 | + {:noreply, socket} | |
| 327 | + end | |
| 328 | + end | |
| 329 | + | |
| 330 | + # Fallback timer for vehicle position updates | |
| 331 | + @impl true | |
| 332 | + def handle_info(:update_vehicle_positions, socket) do | |
| 333 | + Process.send_after(self(), :update_vehicle_positions, 30000) | |
| 334 | + | |
| 335 | + case socket.assigns.source.type do | |
| 336 | + :gtfs -> | |
| 337 | + vehicles = RoomGtfs.Worker.get_current_vehicle_positions(socket.assigns.source_id) | |
| 338 | + {:noreply, socket |> assign(:vehicle_positions, vehicles)} | |
| 339 | + _ -> | |
| 340 | + {:noreply, socket} | |
| 341 | + end | |
| 342 | + end | |
| 343 | + | |
| 272 | 344 | |
| 273 | 345 | @impl true |
| 274 | 346 | def handle_info({:gtfs, id, file, complete, total} = info, socket) do |
modified
apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+26
−2
@@ -224,6 +224,13 @@ current_user={@current_user}
| 224 | 224 | <h3 class="card-title flex justify-between"> |
| 225 | 225 | <div class="flex items-center gap-2"> |
| 226 | 226 | Queries |
| 227 | + <button | |
| 228 | + class="btn btn-sm btn-outline ml-4 text-xs" | |
| 229 | + phx-click="toggle-view" | |
| 230 | + > | |
| 231 | + <i class={"fa-solid #{if @view_mode == :system, do: "fa-map", else: "fa-table"} mr-1"}></i> | |
| 232 | + <%= if @view_mode == :system, do: "Map View", else: "System View" %> | |
| 233 | + </button> | |
| 227 | 234 | |
| 228 | 235 | <!-- Available tints - clickable filters --> |
| 229 | 236 | <span class="text-amber-500"></span> |
@@ -250,7 +257,23 @@ current_user={@current_user}
| 250 | 257 | <% _default -> %> |
| 251 | 258 | <% end %> |
| 252 | 259 | </h3> |
| 253 | − <div class="flex flex-wrap gap-2"> | |
| 260 | + | |
| 261 | + <%= if @view_mode == :detail do %> | |
| 262 | + <div class="px-6"> | |
| 263 | + <.query_geospatial_map | |
| 264 | + queries={filter_queries_by_tint(@queries, @tint)} | |
| 265 | + vehicle_positions={@vehicle_positions} | |
| 266 | + free_bikes={@free_bikes} | |
| 267 | + stations={@stations} | |
| 268 | + station_statuses={@station_statuses} | |
| 269 | + source_tint={@source_tint} | |
| 270 | + selected_tint={@tint} | |
| 271 | + class="" | |
| 272 | + height="600px" | |
| 273 | + /> | |
| 274 | + </div> | |
| 275 | + <% else %> | |
| 276 | + <div class="flex flex-wrap gap-2"> | |
| 254 | 277 | <%= for q <- filter_queries_by_tint(@queries, @tint) do %> |
| 255 | 278 | <%= live_patch to: Routes.query_show_path(@socket, :show, q.id), class: "btn btn-primary float-left" do %> |
| 256 | 279 | <%= cond do %> |
@@ -273,7 +296,8 @@ current_user={@current_user}
| 273 | 296 | <i class={"fas #{get_icon(q.source.type)}"}></i> <%= q.name %> |
| 274 | 297 | <% end %> |
| 275 | 298 | <% end %> |
| 276 | − </div> | |
| 299 | + </div> | |
| 300 | + <% end %> | |
| 277 | 301 | </div> |
| 278 | 302 | </div> |
| 279 | 303 | </div> |
Parents: 556557f