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 */}
); };