import maplibregl from "maplibre-gl"; import React, { useEffect, useMemo, useRef, useState } from "react"; import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; import { useApp } from "~/AppContext"; import { getRegionConfig, type RegionId } from "~/config/RegionConfig"; import { getLineColor } from "~/data/LineColors"; import type { Stop } from "~/data/StopDataProvider"; import { loadStyle } from "~/maps/styleloader"; import "./StopMapSheet.css"; export interface Position { latitude: number; longitude: number; orientationDegrees: number; shapeIndex?: number; } export interface ConsolidatedCirculationForMap { line: string; route: string; currentPosition?: Position; stopShapeIndex?: number; schedule?: { shapeId?: string; }; } 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 [userPosition, setUserPosition] = useState<{ latitude: number; longitude: number; accuracy?: number; } | null>(null); const geoWatchId = useRef(null); const [viewState, setViewState] = useState(() => { let latitude = 42.2406; let longitude = -8.7207; if (stop.latitude && stop.longitude) { latitude = stop.latitude; longitude = stop.longitude; } else { const pos = circulations.find((c) => c.currentPosition)?.currentPosition; if (pos) { latitude = pos.latitude; longitude = pos.longitude; } } return { latitude, longitude, zoom: 16, }; }); const { zoom } = viewState; const hasFitted = useRef(false); const [moveTick, setMoveTick] = useState(0); const [showAttribution, setShowAttribution] = useState(false); const [shapes, setShapes] = useState>({}); const regionConfig = getRegionConfig(region); useEffect(() => { circulations.forEach((c) => { if ( c.schedule?.shapeId && c.currentPosition?.shapeIndex !== undefined && regionConfig.shapeEndpoint ) { const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`; if (!shapes[key]) { let url = `${regionConfig.shapeEndpoint}?shapeId=${c.schedule.shapeId}&busShapeIndex=${c.currentPosition.shapeIndex}`; if (c.stopShapeIndex !== undefined) { url += `&stopShapeIndex=${c.stopShapeIndex}`; } else { url += `&stopLat=${stop.latitude}&stopLon=${stop.longitude}`; } fetch(url) .then((res) => { if (res.ok) return res.json(); return null; }) .then((data) => { if (data) { setShapes((prev) => ({ ...prev, [key]: data })); } }) .catch((err) => console.error("Failed to load shape", err)); } } }); }, [circulations, regionConfig.shapeEndpoint, shapes]); 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 busPositions = useMemo( () => circulations.filter((c) => !!c.currentPosition), [circulations] ); const handleMapLoad = (e: any) => { if (hasFitted.current) return; hasFitted.current = true; const map = e.target; // Handle missing sprite images to suppress console warnings const handleStyleImageMissing = (e: any) => { if (!map || map.hasImage(e.id)) return; // Add a transparent 1x1 placeholder map.addImage(e.id, { width: 1, height: 1, data: new Uint8Array(4), }); }; map.on("styleimagemissing", handleStyleImageMissing); 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]; map.jumpTo({ center: [only.lon, only.lat], zoom: 16 }); } else { map.fitBounds(bounds, { padding: padding as any, duration: 700, maxZoom: 17, } as any); } } catch {} }; 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 && ( { setViewState(e.viewState); setMoveTick((t) => (t + 1) % 1000000); }} > {/* Shapes */} {circulations.map((c, idx) => { if ( !c.schedule?.shapeId || c.currentPosition?.shapeIndex === undefined ) return null; const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`; const shapeData = shapes[key]; if (!shapeData) return null; const lineColor = getLineColor(region, c.line); return ( ); })} {/* 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}
)}
); }); })()} )}
OpenFreeMap © OpenMapTiles data from OpenStreetMap
); };