diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-11-19 23:54:49 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-11-19 23:54:49 +0100 |
| commit | f030f1806255c66b86689489d24f8f5ad9b832ce (patch) | |
| tree | a776e6a6670b50bb43609633cdbd1fe9857b8065 /src/frontend/app/components/StopMapModal.tsx | |
| parent | 3ebb062e99dbd8a63d5642d67ba4be753e61a34d (diff) | |
feat: Implement StopMapModal component for displaying bus stop locations with live tracking; enhance styles and add interaction features
Diffstat (limited to 'src/frontend/app/components/StopMapModal.tsx')
| -rw-r--r-- | src/frontend/app/components/StopMapModal.tsx | 344 |
1 files changed, 344 insertions, 0 deletions
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> + ); +}; |
