From f030f1806255c66b86689489d24f8f5ad9b832ce Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Wed, 19 Nov 2025 23:54:49 +0100 Subject: feat: Implement StopMapModal component for displaying bus stop locations with live tracking; enhance styles and add interaction features --- src/frontend/app/components/StopMapModal.css | 58 ++++ src/frontend/app/components/StopMapModal.tsx | 344 +++++++++++++++++++++ .../Stops/ConsolidatedCirculationCard.tsx | 23 +- .../Stops/ConsolidatedCirculationList.css | 61 +++- .../Stops/ConsolidatedCirculationList.tsx | 3 + 5 files changed, 484 insertions(+), 5 deletions(-) create mode 100644 src/frontend/app/components/StopMapModal.css create mode 100644 src/frontend/app/components/StopMapModal.tsx (limited to 'src/frontend/app/components') 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 = ({ + stop, + circulations, + region, + isOpen, + onClose, + selectedCirculationIndex, +}) => { + const { theme } = useApp(); + const [styleSpec, setStyleSpec] = useState(null); + const mapRef = useRef(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 ( + + + + +
+ {/* Map Container */} +
+ {styleSpec && ( + + {/* Stop marker */} + {stop.latitude && stop.longitude && ( + +
+ + + + + + + + + + +
+
+ )} + + {/* Selected bus marker */} + {selectedBus?.currentPosition && ( + +
+ + + +
+
+ )} +
+ )} + + {/* Floating controls */} +
+ +
+
+
+
+
+ +
+ ); +}; 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 ( -
+
{metaChips.length > 0 && (
@@ -196,6 +213,6 @@ export const ConsolidatedCirculationCard: React.FC< ))}
)} - + ); }; 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 = ({ data, dataDate, regionConfig, + onCirculationClick, }) => { const { t } = useTranslation(); @@ -43,6 +45,7 @@ export const ConsolidatedCirculationList: React.FC = ({ key={idx} estimate={estimate} regionConfig={regionConfig} + onMapClick={() => onCirculationClick?.(estimate, idx)} /> ))} -- cgit v1.3