From cfbb1625e7873264e2ef435cc76fec2b59cf58d8 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Wed, 24 Dec 2025 19:33:49 +0100 Subject: Refactor map components and improve modal functionality --- src/frontend/app/components/stop/StopMapModal.tsx | 616 ++++++++++++++++++++++ 1 file changed, 616 insertions(+) create mode 100644 src/frontend/app/components/stop/StopMapModal.tsx (limited to 'src/frontend/app/components/stop/StopMapModal.tsx') diff --git a/src/frontend/app/components/stop/StopMapModal.tsx b/src/frontend/app/components/stop/StopMapModal.tsx new file mode 100644 index 0000000..2e091b1 --- /dev/null +++ b/src/frontend/app/components/stop/StopMapModal.tsx @@ -0,0 +1,616 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; +import { Sheet } from "react-modal-sheet"; +import { useApp } from "~/AppContext"; +import { AppMap } from "~/components/shared/AppMap"; +import { APP_CONSTANTS } from "~/config/constants"; +import { getLineColour } from "~/data/LineColors"; +import type { Stop } from "~/data/StopDataProvider"; +import "./StopMapModal.css"; + +export interface Position { + latitude: number; + longitude: number; + orientationDegrees: number; + shapeIndex?: number; +} + +export interface ConsolidatedCirculationForMap { + id: string; + line: string; + route: string; + currentPosition?: Position; + stopShapeIndex?: number; + isPreviousTrip?: boolean; + previousTripShapeId?: string | null; + schedule?: { + shapeId?: string | null; + }; + shape?: any; +} + +interface StopMapModalProps { + stop: Stop; + circulations: ConsolidatedCirculationForMap[]; + isOpen: boolean; + onClose: () => void; + selectedCirculationId?: string; +} + +export const StopMapModal: React.FC = ({ + stop, + circulations, + isOpen, + onClose, + selectedCirculationId, +}) => { + const { theme } = useApp(); + const mapRef = useRef(null); + const hasFitBounds = useRef(false); + const userInteracted = useRef(false); + const [shapeData, setShapeData] = useState(null); + const [previousShapeData, setPreviousShapeData] = useState(null); + + // Filter circulations that have GPS coordinates + const busesWithPosition = useMemo( + () => circulations.filter((c) => !!c.currentPosition), + [circulations] + ); + + // Use selectedCirculationId if provided, otherwise use first bus with position + const selectedBus = useMemo(() => { + if (selectedCirculationId !== undefined) { + const circulation = circulations.find( + (c) => c.id === selectedCirculationId + ); + if (circulation) { + return circulation; + } + } + // Fallback to first bus with position + return busesWithPosition.length > 0 ? busesWithPosition[0] : null; + }, [selectedCirculationId, 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; + if (userInteracted.current) return; + + const points: { lat: number; lon: number }[] = []; + + const getStopsFromFeatureCollection = (data: any) => { + if (!data || data.type !== "FeatureCollection" || !data.features) + return []; + return data.features.filter((f: any) => f.properties?.type === "stop"); + }; + + const findClosestStopIndex = ( + stops: any[], + pos: { lat: number; lon: number } + ) => { + let minDst = Infinity; + let index = -1; + stops.forEach((s: any, idx: number) => { + const [lon, lat] = s.geometry.coordinates; + const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2); + if (dst < minDst) { + minDst = dst; + index = idx; + } + }); + return index; + }; + + const findClosestPointIndex = ( + coords: number[][], + pos: { lat: number; lon: number } + ) => { + let minDst = Infinity; + let index = -1; + coords.forEach((c, idx) => { + const [lon, lat] = c; + const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2); + if (dst < minDst) { + minDst = dst; + index = idx; + } + }); + return index; + }; + + const addShapePoints = (data: any, isPrevious: boolean) => { + if (!data) return; + + if (data.type === "FeatureCollection") { + const stops = getStopsFromFeatureCollection(data); + if (stops.length === 0) return; + + let startIdx = 0; + let endIdx = stops.length - 1; + + const currentPos = selectedBus?.currentPosition; + const userStopPos = + stop.latitude && stop.longitude + ? { lat: stop.latitude, lon: stop.longitude } + : null; + + if (isPrevious) { + // Previous trip: Start from Bus, End at last stop + if (currentPos) { + const busIdx = findClosestStopIndex(stops, { + lat: currentPos.latitude, + lon: currentPos.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + } else { + // Current trip: Start from Bus (if not previous), End at User Stop + if (!previousShapeData && currentPos) { + const busIdx = findClosestStopIndex(stops, { + lat: currentPos.latitude, + lon: currentPos.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + + if (userStopPos) { + let userIdx = -1; + // Try name match + if (stop.name) { + userIdx = stops.findIndex( + (s: any) => s.properties?.name === stop.name + ); + } + // Fallback to coords + if (userIdx === -1) { + userIdx = findClosestStopIndex(stops, userStopPos); + } + if (userIdx !== -1) endIdx = userIdx; + } + } + + // Add stops in range + if (startIdx <= endIdx) { + for (let i = startIdx; i <= endIdx; i++) { + const [lon, lat] = stops[i].geometry.coordinates; + points.push({ lat, lon }); + } + } + return; + } + + const coords = data?.geometry?.coordinates; + if (!coords) return; + + let startIdx = 0; + let endIdx = coords.length - 1; + let foundIndices = false; + + if (data.properties?.busPoint && data.properties?.stopPoint) { + startIdx = data.properties.busPoint.index; + endIdx = data.properties.stopPoint.index; + foundIndices = true; + } else { + // Fallback: find closest points on the line + if (selectedBus?.currentPosition) { + const busIdx = findClosestPointIndex(coords, { + lat: selectedBus.currentPosition.latitude, + lon: selectedBus.currentPosition.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + if (stop.latitude && stop.longitude) { + const stopIdx = findClosestPointIndex(coords, { + lat: stop.latitude, + lon: stop.longitude, + }); + if (stopIdx !== -1) endIdx = stopIdx; + } + } + + const start = Math.min(startIdx, endIdx); + const end = Math.max(startIdx, endIdx); + + for (let i = start; i <= end; i++) { + points.push({ lat: coords[i][1], lon: coords[i][0] }); + } + }; + + addShapePoints(previousShapeData, true); + addShapePoints(shapeData, false); + + if (points.length === 0) { + 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, + }); + } + } else { + // Ensure bus and stop are always included if available, to prevent cutting them off + if (selectedBus?.currentPosition) { + points.push({ + lat: selectedBus.currentPosition.latitude, + lon: selectedBus.currentPosition.longitude, + }); + } + if (stop.latitude && stop.longitude) { + points.push({ lat: stop.latitude, lon: stop.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.getMap().fitBounds(bounds, { + padding: 80, + duration: 500, + maxZoom: 17, + } as any); + } + } catch {} + }, [stop, selectedBus, shapeData, previousShapeData]); + + // 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 (!mapRef.current || !isOpen) return; + + handleCenter(); + hasFitBounds.current = true; + }, [stop, selectedBus, isOpen, handleCenter]); + + // Reset bounds when modal opens/closes + useEffect(() => { + if (!isOpen) { + hasFitBounds.current = false; + userInteracted.current = false; + setShapeData(null); + setPreviousShapeData(null); + } + }, [isOpen]); + + // Fetch shape for selected bus + useEffect(() => { + if (!isOpen || !selectedBus) { + setShapeData(null); + setPreviousShapeData(null); + return; + } + + if (selectedBus.shape) { + setShapeData(selectedBus.shape); + setPreviousShapeData(null); + handleCenter(); + return; + } + + setShapeData(null); + setPreviousShapeData(null); + }, [isOpen, selectedBus]); + + if (!selectedBus && busesWithPosition.length === 0) { + return null; // Don't render if no buses with GPS coordinates and no selected bus + } + + return ( + + + + +
+ {/* Map Container */} +
+ { + if (e.originalEvent) { + userInteracted.current = true; + } + }} + onDragStart={() => { + userInteracted.current = true; + }} + onZoomStart={() => { + userInteracted.current = true; + }} + onRotateStart={() => { + userInteracted.current = true; + }} + onPitchStart={() => { + userInteracted.current = true; + }} + onLoad={() => { + handleCenter(); + }} + > + {/* Previous Shape Layer */} + {previousShapeData && selectedBus && ( + + {/* 1. Black border */} + + {/* 2. White background */} + + {/* 3. Colored dashes */} + + + )} + + {/* Shape Layer */} + {shapeData && selectedBus && ( + + + + + {/* Stops Layer */} + + + )} + + {/* Stop marker */} + {stop.latitude && stop.longitude && ( + +
+ + + + + + + + + + +
+
+ )} + + {/* Selected bus marker */} + {selectedBus?.currentPosition && ( + +
+ + + +
+
+ )} +
+ + {/* Floating controls */} +
+ +
+
+
+
+
+ +
+ ); +}; -- cgit v1.3