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.tsx | 344 +++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 src/frontend/app/components/StopMapModal.tsx (limited to 'src/frontend/app/components/StopMapModal.tsx') 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 */} +
+ +
+
+
+
+
+ +
+ ); +}; -- cgit v1.3