diff options
10 files changed, 559 insertions, 17 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs index cd75f90..0591230 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs @@ -272,6 +272,7 @@ public class VigoController : ControllerBase { Line = estimate.Line, Route = estimate.Route, + NextStreets = [.. closestCirculation.NextStreets], Schedule = new ScheduleData { Running = isRunning, diff --git a/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs b/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs index 3806241..a21aa60 100644 --- a/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs +++ b/src/Costasdev.Busurbano.Backend/Types/ConsolidatedCirculation.cs @@ -8,6 +8,7 @@ public class ConsolidatedCirculation public ScheduleData? Schedule { get; set; } public RealTimeData? RealTime { get; set; } public Position? CurrentPosition { get; set; } + public string[] NextStreets { get; set; } = []; } public class RealTimeData diff --git a/src/frontend/app/components/StopMapModal.css b/src/frontend/app/components/StopMapModal.css new file mode 100644 index 0000000..f024b38 --- /dev/null +++ b/src/frontend/app/components/StopMapModal.css @@ -0,0 +1,58 @@ +/* Stop map modal container */ +.stop-map-modal { + display: flex; + flex-direction: column; + height: 100%; + background: var(--background-color); +} + +.stop-map-modal__map-container { + width: 100%; + height: 100%; + position: relative; + flex-shrink: 0; +} + +/* Map floating controls */ +.map-modal-controls { + position: absolute; + left: 8px; + top: 8px; + display: flex; + gap: 8px; + z-index: 2; +} + +.center-btn { + appearance: none; + border: 1px solid rgba(0, 0, 0, 0.15); + background: color-mix( + in oklab, + var(--background-color, #fff) 85%, + transparent + ); + color: var(--text-primary, #111); + padding: 6px; + border-radius: 6px; + font-size: 12px; + line-height: 1; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.center-btn:hover { + background: color-mix( + in oklab, + var(--background-color, #fff) 75%, + transparent + ); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.center-btn:active { + transform: scale(0.95); +} diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx new file mode 100644 index 0000000..1799f74 --- /dev/null +++ b/src/frontend/app/components/StopMapModal.tsx @@ -0,0 +1,344 @@ +import maplibregl from "maplibre-gl"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Map, { + Marker, + type MapRef +} from "react-map-gl/maplibre"; +import { Sheet } from "react-modal-sheet"; +import { useApp } from "~/AppContext"; +import type { RegionId } from "~/config/RegionConfig"; +import { getLineColor } from "~/data/LineColors"; +import type { Stop } from "~/data/StopDataProvider"; +import { loadStyle } from "~/maps/styleloader"; +import "./StopMapModal.css"; + +export interface Position { + latitude: number; + longitude: number; + orientationDegrees: number; +} + +export interface ConsolidatedCirculationForMap { + line: string; + route: string; + currentPosition?: Position; +} + +interface StopMapModalProps { + stop: Stop; + circulations: ConsolidatedCirculationForMap[]; + region: RegionId; + isOpen: boolean; + onClose: () => void; + selectedCirculationIndex?: number; +} + +export const StopMapModal: React.FC<StopMapModalProps> = ({ + stop, + circulations, + region, + isOpen, + onClose, + selectedCirculationIndex, +}) => { + const { theme } = useApp(); + const [styleSpec, setStyleSpec] = useState<any | null>(null); + const mapRef = useRef<MapRef | null>(null); + const hasFitBounds = useRef(false); + + // Filter circulations that have GPS coordinates + const busesWithPosition = useMemo( + () => circulations.filter((c) => !!c.currentPosition), + [circulations] + ); + + // Use selectedCirculationIndex if provided, otherwise use first bus with position + const selectedBus = useMemo(() => { + if (selectedCirculationIndex !== undefined) { + const circulation = circulations[selectedCirculationIndex]; + if (circulation?.currentPosition) { + return circulation; + } + } + // Fallback to first bus with position + return busesWithPosition.length > 0 ? busesWithPosition[0] : null; + }, [selectedCirculationIndex, circulations, busesWithPosition]); + + const center = useMemo(() => { + if (selectedBus?.currentPosition) { + return { + latitude: selectedBus.currentPosition.latitude, + longitude: selectedBus.currentPosition.longitude, + }; + } + if (stop.latitude && stop.longitude) { + return { latitude: stop.latitude, longitude: stop.longitude }; + } + return { latitude: 42.2406, longitude: -8.7207 }; // Vigo approx fallback + }, [selectedBus, stop.latitude, stop.longitude]); + + const handleCenter = useCallback(() => { + if (!mapRef.current) return; + const points: { lat: number; lon: number }[] = []; + + if (stop.latitude && stop.longitude) { + points.push({ lat: stop.latitude, lon: stop.longitude }); + } + + if (selectedBus?.currentPosition) { + points.push({ + lat: selectedBus.currentPosition.latitude, + lon: selectedBus.currentPosition.longitude, + }); + } + + if (points.length === 0) return; + + let minLat = points[0].lat, + maxLat = points[0].lat, + minLon = points[0].lon, + maxLon = points[0].lon; + for (const p of points) { + if (p.lat < minLat) minLat = p.lat; + if (p.lat > maxLat) maxLat = p.lat; + if (p.lon < minLon) minLon = p.lon; + if (p.lon > maxLon) maxLon = p.lon; + } + + const sw = [minLon, minLat] as [number, number]; + const ne = [maxLon, maxLat] as [number, number]; + const bounds = new maplibregl.LngLatBounds(sw, ne); + + try { + if (points.length === 1) { + const only = points[0]; + mapRef.current + .getMap() + .easeTo({ center: [only.lon, only.lat], zoom: 16, duration: 450 }); + } else { + mapRef.current.fitBounds(bounds, { + padding: 24, + duration: 500, + maxZoom: 17, + } as any); + } + } catch {} + }, [stop, selectedBus]); + + // Load style without traffic layers for the stop map + useEffect(() => { + let mounted = true; + loadStyle("openfreemap", theme, { includeTraffic: false }) + .then((style) => { + if (mounted) setStyleSpec(style); + }) + .catch((err) => console.error("Failed to load map style", err)); + return () => { + mounted = false; + }; + }, [theme]); + + // Resize map and fit bounds when modal opens + useEffect(() => { + if (isOpen && mapRef.current) { + const timer = setTimeout(() => { + const map = mapRef.current?.getMap(); + if (map) { + map.resize(); + // Trigger fit bounds logic again + hasFitBounds.current = false; + handleCenter(); + } + }, 300); // Wait for sheet animation + return () => clearTimeout(timer); + } + }, [isOpen, handleCenter]); + + // Fit bounds on initial load + useEffect(() => { + if (!styleSpec || !mapRef.current || hasFitBounds.current || !isOpen) + return; + + const map = mapRef.current.getMap(); + + // Handle missing sprite images to suppress console warnings + const handleStyleImageMissing = (e: any) => { + if (!map || map.hasImage(e.id)) return; + map.addImage(e.id, { + width: 1, + height: 1, + data: new Uint8Array(4), + }); + }; + + map.on("styleimagemissing", handleStyleImageMissing); + + handleCenter(); + hasFitBounds.current = true; + + return () => { + if (mapRef.current) { + const map = mapRef.current.getMap(); + if (map) { + map.off("styleimagemissing", handleStyleImageMissing); + } + } + }; + }, [styleSpec, stop, selectedBus, isOpen, handleCenter]); + + // Reset bounds when modal opens/closes + useEffect(() => { + if (!isOpen) { + hasFitBounds.current = false; + } + }, [isOpen]); + + if (busesWithPosition.length === 0) { + return null; // Don't render if no buses with GPS coordinates + } + + return ( + <Sheet + isOpen={isOpen} + onClose={onClose} + detent="content" + > + <Sheet.Container style={{ backgroundColor: "var(--background-color)" }}> + <Sheet.Header /> + <Sheet.Content disableDrag={true}> + <div className="stop-map-modal"> + {/* Map Container */} + <div className="stop-map-modal__map-container"> + {styleSpec && ( + <Map + mapLib={maplibregl as any} + initialViewState={{ + latitude: center.latitude, + longitude: center.longitude, + zoom: 16, + }} + style={{ width: "100%", height: "320px" }} + mapStyle={styleSpec} + attributionControl={false} + ref={mapRef} + interactive={true} + > + {/* Stop marker */} + {stop.latitude && stop.longitude && ( + <Marker + longitude={stop.longitude} + latitude={stop.latitude} + anchor="bottom" + > + <div title={`Stop ${stop.stopId}`}> + <svg width="28" height="36" viewBox="0 0 28 36"> + <defs> + <filter + id="drop-stop" + x="-20%" + y="-20%" + width="140%" + height="140%" + > + <feDropShadow + dx="0" + dy="1" + stdDeviation="1" + floodOpacity={0.35} + /> + </filter> + </defs> + <path + d="M14 0C6.82 0 1 5.82 1 13c0 8.5 11 23 13 23s13-14.5 13-23C27 5.82 21.18 0 14 0z" + fill="#1976d2" + stroke="#fff" + strokeWidth="2" + filter="url(#drop-stop)" + /> + <circle cx="14" cy="13" r="5" fill="#fff" /> + <circle cx="14" cy="13" r="3" fill="#1976d2" /> + </svg> + </div> + </Marker> + )} + + {/* Selected bus marker */} + {selectedBus?.currentPosition && ( + <Marker + longitude={selectedBus.currentPosition.longitude} + latitude={selectedBus.currentPosition.latitude} + anchor="center" + > + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 6, + transform: `rotate(${selectedBus.currentPosition.orientationDegrees}deg)`, + transformOrigin: "center center", + }} + > + <svg + width="24" + height="24" + viewBox="0 0 24 24" + style={{ + filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.3))", + }} + > + <path + d="M12 2 L22 22 L12 17 L2 22 Z" + fill={getLineColor(region, selectedBus.line).background} + stroke="#fff" + strokeWidth="2" + strokeLinejoin="round" + /> + </svg> + </div> + </Marker> + )} + </Map> + )} + + {/* Floating controls */} + <div className="map-modal-controls"> + <button + type="button" + aria-label="Center" + className="center-btn" + onClick={handleCenter} + title="Center view" + > + <svg + width="20" + height="20" + viewBox="0 0 24 24" + aria-hidden="true" + > + <circle cx="12" cy="12" r="3" fill="currentColor" /> + <path + d="M12 2v3M12 19v3M2 12h3M19 12h3" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + /> + <circle + cx="12" + cy="12" + r="8" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + /> + </svg> + </button> + </div> + </div> + </div> + </Sheet.Content> + </Sheet.Container> + <Sheet.Backdrop onClick={onClose} /> + </Sheet> + ); +}; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx index f725b8c..47fa56b 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx @@ -9,6 +9,7 @@ import "./ConsolidatedCirculationList.css"; interface ConsolidatedCirculationCardProps { estimate: ConsolidatedCirculation; regionConfig: RegionConfig; + onMapClick?: () => void; } // Utility function to parse service ID and get the turn number @@ -70,7 +71,7 @@ const parseServiceId = (serviceId: string): string => { export const ConsolidatedCirculationCard: React.FC< ConsolidatedCirculationCardProps -> = ({ estimate, regionConfig }) => { +> = ({ estimate, regionConfig, onMapClick }) => { const { t } = useTranslation(); const absoluteArrivalTime = (minutes: number) => { @@ -168,8 +169,19 @@ export const ConsolidatedCirculationCard: React.FC< return chips; }, [delayChip, estimate.schedule, estimate.realTime]); + // Check if bus has GPS position (live tracking) + const hasGpsPosition = !!estimate.currentPosition; + return ( - <div className="consolidated-circulation-card"> + <button + className={`consolidated-circulation-card ${ + hasGpsPosition ? "has-gps" : "no-gps" + }`} + onClick={onMapClick} + type="button" + disabled={!hasGpsPosition} + aria-label={`${hasGpsPosition ? "View" : "No GPS data for"} ${estimate.line} to ${estimate.route}${hasGpsPosition ? " on map" : ""}`} + > <div className="card-row main"> <div className="line-info"> <LineIcon line={estimate.line} region={regionConfig.id} rounded /> @@ -183,6 +195,11 @@ export const ConsolidatedCirculationCard: React.FC< <span className="eta-unit">{etaUnit}</span> </div> </div> + {hasGpsPosition && ( + <div className="gps-indicator" title="Live GPS tracking"> + <span className="gps-pulse" /> + </div> + )} </div> {metaChips.length > 0 && ( <div className="card-row meta"> @@ -196,6 +213,6 @@ export const ConsolidatedCirculationCard: React.FC< ))} </div> )} - </div> + </button> ); }; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css index 7e757fb..680a511 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css @@ -13,6 +13,7 @@ } .consolidated-circulation-card { + all: unset; flex: 0 0 auto; display: flex; flex-direction: column; @@ -21,11 +22,34 @@ border-radius: 12px; border: 1px solid var(--border-color); padding: 0.65rem 0.85rem; - transition: box-shadow 0.2s ease; + transition: all 0.2s ease; } -.consolidated-circulation-card:hover { +.consolidated-circulation-card.has-gps { + cursor: pointer; +} + +.consolidated-circulation-card.no-gps { + cursor: not-allowed; + opacity: 0.7; +} + +.consolidated-circulation-card.has-gps:hover { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); + border-color: var(--button-background-color); + background-color: color-mix( + in oklab, + var(--button-background-color) 5%, + var(--message-background-color) + ); +} + +.consolidated-circulation-card.has-gps:active { + transform: scale(0.98); +} + +.consolidated-circulation-card:disabled { + pointer-events: none; } @@ -149,6 +173,39 @@ color: #1d4ed8; } +/* GPS Indicator */ +.gps-indicator { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + position: relative; +} + +.gps-pulse { + position: absolute; + width: 8px; + height: 8px; + background: #22c55e; + border-radius: 50%; + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); + animation: gpsPulse 2s ease-in-out infinite; +} + +@keyframes gpsPulse { + 0% { + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); + } + 50% { + box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.1); + } + 100% { + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2); + } +} + @media (max-width: 480px) { .consolidated-circulation-card { padding: 0.65rem 0.75rem; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx index 047dfd4..4c2916a 100644 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx +++ b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx @@ -9,12 +9,14 @@ interface RegularTableProps { data: ConsolidatedCirculation[]; dataDate: Date | null; regionConfig: RegionConfig; + onCirculationClick?: (estimate: ConsolidatedCirculation, index: number) => void; } export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({ data, dataDate, regionConfig, + onCirculationClick, }) => { const { t } = useTranslation(); @@ -43,6 +45,7 @@ export const ConsolidatedCirculationList: React.FC<RegularTableProps> = ({ key={idx} estimate={estimate} regionConfig={regionConfig} + onMapClick={() => onCirculationClick?.(estimate, idx)} /> ))} </> diff --git a/src/frontend/app/maps/styleloader.ts b/src/frontend/app/maps/styleloader.ts index 43118a0..8109e0b 100644 --- a/src/frontend/app/maps/styleloader.ts +++ b/src/frontend/app/maps/styleloader.ts @@ -1,10 +1,17 @@ import type { StyleSpecification } from "react-map-gl/maplibre"; import type { Theme } from "~/AppContext"; +export interface StyleLoaderOptions { + includeTraffic?: boolean; +} + export async function loadStyle( styleName: string, - colorScheme: Theme + colorScheme: Theme, + options?: StyleLoaderOptions ): Promise<StyleSpecification> { + const { includeTraffic = true } = options || {}; + if (colorScheme == "system") { const isDarkMode = window.matchMedia( "(prefers-color-scheme: dark)" @@ -21,6 +28,15 @@ export async function loadStyle( } const style = await resp.json(); + + // Remove traffic layers if not requested + if (!includeTraffic) { + style.layers = (style.layers || []).filter( + (layer: any) => !layer.id?.startsWith("vigo_traffic") + ); + delete style.sources?.vigo_traffic; + } + return style as StyleSpecification; } @@ -33,6 +49,14 @@ export async function loadStyle( const style = await resp.json(); + // Remove traffic layers if not requested + if (!includeTraffic) { + style.layers = (style.layers || []).filter( + (layer: any) => !layer.id?.startsWith("vigo_traffic") + ); + delete style.sources?.vigo_traffic; + } + const baseUrl = window.location.origin; const spritePath = style.sprite; diff --git a/src/frontend/app/routes/stops-$id.css b/src/frontend/app/routes/stops-$id.css index 9ecac16..782d9a1 100644 --- a/src/frontend/app/routes/stops-$id.css +++ b/src/frontend/app/routes/stops-$id.css @@ -63,6 +63,30 @@ flex-shrink: 0; } +.stops-header > div:first-child { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.star-icon, +.edit-icon { + cursor: pointer; + transition: all 0.2s ease; + color: var(--text-secondary); +} + +.star-icon:hover, +.edit-icon:hover { + color: var(--text-primary); + transform: scale(1.1); +} + +.star-icon.active { + color: #fbbf24; + fill: #fbbf24; +} + .manual-refresh-button { display: flex; align-items: center; diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index f340009..a2b2da3 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -5,7 +5,7 @@ import { useParams } from "react-router"; import { ErrorDisplay } from "~/components/ErrorDisplay"; import LineIcon from "~/components/LineIcon"; import { StopAlert } from "~/components/StopAlert"; -import { StopMap } from "~/components/StopMapSheet"; +import { StopMapModal } from "~/components/StopMapModal"; import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList"; import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton"; import { type RegionId, getRegionConfig } from "~/config/RegionConfig"; @@ -77,6 +77,10 @@ export default function Estimates() { const [favourited, setFavourited] = useState(false); const [isManualRefreshing, setIsManualRefreshing] = useState(false); + const [isMapModalOpen, setIsMapModalOpen] = useState(false); + const [selectedCirculationIdx, setSelectedCirculationIdx] = useState< + number | undefined + >(undefined); const { region } = useApp(); const regionConfig = getRegionConfig(region); @@ -195,15 +199,16 @@ export default function Estimates() { <div className="page-container stops-page"> <div className="stops-header"> <div> - <Star - className={`star-icon ${favourited ? "active" : ""}`} - onClick={toggleFavourite} - width={20} - /> - <Edit2 - className="edit-icon" - onClick={handleRename} - width={20} /> + <Star + className={`star-icon ${favourited ? "active" : ""}`} + onClick={toggleFavourite} + width={20} + /> + <Edit2 + className="edit-icon" + onClick={handleRename} + width={20} + /> </div> <button @@ -247,12 +252,17 @@ export default function Estimates() { data={data} dataDate={dataDate} regionConfig={regionConfig} + onCirculationClick={(estimate, idx) => { + setSelectedCirculationIdx(idx); + setIsMapModalOpen(true); + }} /> ) : null} </div> + {/* Map Modal - only render if we have stop data */} {stopData && ( - <StopMap + <StopMapModal stop={stopData} region={region} circulations={(data ?? []).map((c) => ({ @@ -260,6 +270,9 @@ export default function Estimates() { route: c.route, currentPosition: c.currentPosition, }))} + isOpen={isMapModalOpen} + onClose={() => setIsMapModalOpen(false)} + selectedCirculationIndex={selectedCirculationIdx} /> )} </div> |
