a lot of map

18ad607 · gmorell · 2025-09-15 15:48

25 files +1634 -30

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>&emsp;<%= 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