import maplibregl from "maplibre-gl"; import React, { useEffect, useMemo, useRef, useState } from "react"; import Map, { AttributionControl, Marker, type MapRef, } from "react-map-gl/maplibre"; import { useApp } from "~/AppContext"; import { getLineColor } from "~/data/LineColors"; import type { RegionId } from "~/data/RegionConfig"; import type { Stop } from "~/data/StopDataProvider"; import { loadStyle } from "~/maps/styleloader"; import "./StopMapSheet.css"; export interface Position { latitude: number; longitude: number; orientationDegrees: number; } export interface ConsolidatedCirculationForMap { line: string; route: string; currentPosition?: Position; } interface StopMapProps { stop: Stop; circulations: ConsolidatedCirculationForMap[]; region: RegionId; } export const StopMap: React.FC = ({ stop, circulations, region, }) => { const { theme } = useApp(); const [styleSpec, setStyleSpec] = useState(null); const mapRef = useRef(null); const hasFitBounds = useRef(false); const [userPosition, setUserPosition] = useState<{ latitude: number; longitude: number; accuracy?: number; } | null>(null); const geoWatchId = useRef(null); const [zoom, setZoom] = useState(16); const [moveTick, setMoveTick] = useState(0); type Pt = { lat: number; lon: number }; const haversineKm = (a: Pt, b: Pt) => { const R = 6371; const dLat = ((b.lat - a.lat) * Math.PI) / 180; const dLon = ((b.lon - a.lon) * Math.PI) / 180; const lat1 = (a.lat * Math.PI) / 180; const lat2 = (b.lat * Math.PI) / 180; const sinDLat = Math.sin(dLat / 2); const sinDLon = Math.sin(dLon / 2); const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon; return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); }; const computeFocusPoints = (): Pt[] => { const buses: Pt[] = []; for (const c of busPositions) { if (c.currentPosition) buses.push({ lat: c.currentPosition.latitude, lon: c.currentPosition.longitude, }); } const stopPt = stop.latitude && stop.longitude ? { lat: stop.latitude, lon: stop.longitude } : null; const userPt = userPosition ? { lat: userPosition.latitude, lon: userPosition.longitude } : null; if (buses.length === 0 && !stopPt && !userPt) return []; // Choose anchor for proximity: stop > user > average of buses let anchor: Pt | null = stopPt || userPt || null; if (!anchor && buses.length) { let lat = 0, lon = 0; for (const b of buses) { lat += b.lat; lon += b.lon; } anchor = { lat: lat / buses.length, lon: lon / buses.length }; } const nearBuses = buses .map((p) => ({ p, d: anchor ? haversineKm(anchor, p) : 0 })) .sort((a, b) => a.d - b.d) .slice(0, 8) // take closest N .filter((x) => x.d <= 8) // within 8km .map((x) => x.p); const pts: Pt[] = []; if (stopPt) pts.push(stopPt); pts.push(...nearBuses); if (userPt) { // include user if not too far from anchor const includeUser = anchor ? haversineKm(anchor, userPt) <= 10 : true; if (includeUser) pts.push(userPt); } // Fallback: if no buses survived, at least return stop or user if (pts.length === 0) { if (stopPt) return [stopPt]; if (userPt) return [userPt]; } return pts; }; useEffect(() => { let mounted = true; loadStyle("openfreemap", theme) .then((style) => { if (mounted) setStyleSpec(style); }) .catch((err) => console.error("Failed to load map style", err)); return () => { mounted = false; }; }, [theme]); // Geolocation: request immediately without blocking UI; update when available. useEffect(() => { if (!("geolocation" in navigator)) return; try { navigator.geolocation.getCurrentPosition( (pos) => { setUserPosition({ latitude: pos.coords.latitude, longitude: pos.coords.longitude, accuracy: pos.coords.accuracy, }); }, () => {}, { enableHighAccuracy: true, maximumAge: 15000, timeout: 5000 } ); geoWatchId.current = navigator.geolocation.watchPosition( (pos) => { setUserPosition({ latitude: pos.coords.latitude, longitude: pos.coords.longitude, accuracy: pos.coords.accuracy, }); }, () => {}, { enableHighAccuracy: true, maximumAge: 30000, timeout: 10000 } ); } catch {} return () => { if (geoWatchId.current != null && "geolocation" in navigator) { try { navigator.geolocation.clearWatch(geoWatchId.current); } catch {} } }; }, []); const center = useMemo(() => { if (stop.latitude && stop.longitude) { return { latitude: stop.latitude, longitude: stop.longitude }; } // fallback to first available bus position const pos = circulations.find((c) => c.currentPosition)?.currentPosition; return pos ? { latitude: pos.latitude, longitude: pos.longitude } : { latitude: 42.2406, longitude: -8.7207 }; // Vigo approx fallback }, [stop.latitude, stop.longitude, circulations]); const busPositions = useMemo( () => circulations.filter((c) => !!c.currentPosition), [circulations] ); // Fit bounds to stop + buses, with ~1km padding each side, with a modest animation // Only fit bounds on the first load, not on subsequent updates useEffect(() => { if (!styleSpec || !mapRef.current || hasFitBounds.current) return; const points = computeFocusPoints(); 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); // Determine predominant bus quadrant relative to stop to bias padding. const padding: | number | { top: number; right: number; bottom: number; left: number } = 24; // If the diagonal is huge (likely outliers sneaked in), clamp via zoom fallback try { if (points.length === 1) { const only = points[0]; mapRef.current .getMap() .jumpTo({ center: [only.lon, only.lat], zoom: 16 }); } else { mapRef.current.fitBounds(bounds, { padding: padding as any, duration: 700, maxZoom: 17, } as any); } hasFitBounds.current = true; } catch {} }, [styleSpec, stop.latitude, stop.longitude, busPositions, userPosition]); const handleCenter = () => { if (!mapRef.current) return; const pts = computeFocusPoints(); if (pts.length === 0) return; let minLat = pts[0].lat, maxLat = pts[0].lat, minLon = pts[0].lon, maxLon = pts[0].lon; for (const p of pts) { 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); const padding: | number | { top: number; right: number; bottom: number; left: number } = 24; try { if (pts.length === 1) { const only = pts[0]; mapRef.current .getMap() .easeTo({ center: [only.lon, only.lat], zoom: 16, duration: 450 }); } else { mapRef.current.fitBounds(bounds, { padding: padding as any, duration: 500, maxZoom: 17, } as any); } } catch {} }; return (
{styleSpec && ( { setZoom(e.viewState.zoom); setMoveTick((t) => (t + 1) % 1000000); }} > {/* Compact attribution (closed by default) */} {/* Stop marker (center) */} {stop.latitude && stop.longitude && (
)} {/* User position marker (if available) */} {userPosition && (
)} {/* Bus markers with heading and dynamic label spacing */} {(() => { const map = mapRef.current?.getMap(); const baseGap = 6; const thresholdPx = 22; const gaps: number[] = new Array(busPositions.length).fill(baseGap); if (map && zoom >= 14.5 && busPositions.length > 1) { const pts = busPositions.map((c) => c.currentPosition ? map.project([ c.currentPosition.longitude, c.currentPosition.latitude, ]) : null ); for (let i = 0; i < pts.length; i++) { const pi = pts[i]; if (!pi) continue; let close = 0; for (let j = 0; j < pts.length; j++) { if (i === j) continue; const pj = pts[j]; if (!pj) continue; const dx = pi.x - pj.x; const dy = pi.y - pj.y; if (dx * dx + dy * dy <= thresholdPx * thresholdPx) close++; } gaps[i] = baseGap + Math.min(3, close) * 10; } } return busPositions.map((c, idx) => { const p = c.currentPosition!; const lineColor = getLineColor(region, c.line); const showLabel = zoom >= 13; const labelGap = gaps[idx] ?? baseGap; return (
{showLabel && (
{c.line}
)}
); }); })()} )} {/* Floating controls */}
); };