diff options
Diffstat (limited to 'src/frontend/app/components/StopMapSheet.tsx')
| -rw-r--r-- | src/frontend/app/components/StopMapSheet.tsx | 575 |
1 files changed, 0 insertions, 575 deletions
diff --git a/src/frontend/app/components/StopMapSheet.tsx b/src/frontend/app/components/StopMapSheet.tsx deleted file mode 100644 index b00ca1c..0000000 --- a/src/frontend/app/components/StopMapSheet.tsx +++ /dev/null @@ -1,575 +0,0 @@ -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 { REGION_DATA } from "~/config/RegionConfig"; -import { getLineColour } 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[]; -} - -export const StopMap: React.FC<StopMapProps> = ({ - stop, - circulations -}) => { - const { theme } = useApp(); - const [styleSpec, setStyleSpec] = useState<any | null>(null); - const mapRef = useRef<MapRef | null>(null); - const [userPosition, setUserPosition] = useState<{ - latitude: number; - longitude: number; - accuracy?: number; - } | null>(null); - const geoWatchId = useRef<number | null>(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<number>(0); - const [showAttribution, setShowAttribution] = useState(false); - const [shapes, setShapes] = useState<Record<string, any>>({}); - - useEffect(() => { - circulations.forEach((c) => { - if ( - c.schedule?.shapeId && - c.currentPosition?.shapeIndex !== undefined && - REGION_DATA.shapeEndpoint - ) { - const key = `${c.schedule.shapeId}_${c.currentPosition.shapeIndex}`; - if (!shapes[key]) { - let url = `${REGION_DATA.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, 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 ( - <div className="stop-map-container"> - {styleSpec && ( - <Map - mapLib={maplibregl as any} - {...viewState} - style={{ width: "100%", height: "100%" }} - mapStyle={styleSpec} - attributionControl={false} - ref={mapRef} - onLoad={handleMapLoad} - onMove={(e) => { - 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 = getLineColour(c.line); - - return ( - <Source - key={idx} - id={`shape-${idx}`} - type="geojson" - data={shapeData} - > - <Layer - id={`layer-border-${idx}`} - type="line" - paint={{ - "line-color": "#000000", - "line-width": 6, - }} - /> - <Layer - id={`layer-inner-${idx}`} - type="line" - paint={{ - "line-color": lineColor.background, - "line-width": 4, - }} - /> - </Source> - ); - })} - - {/* Stop marker (center) */} - {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" - 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)" - /> - <circle cx="14" cy="13" r="5" fill="#fff" /> - <circle cx="14" cy="13" r="3" fill="#1976d2" /> - </svg> - </div> - </Marker> - )} - - {/* User position marker (if available) */} - {userPosition && ( - <Marker - longitude={userPosition.longitude} - latitude={userPosition.latitude} - anchor="center" - > - <div className="user-dot" title="Your location"> - <div className="user-dot__pulse" /> - <div className="user-dot__core" /> - </div> - </Marker> - )} - - {/* 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 = getLineColour(c.line); - const showLabel = zoom >= 13; - const labelGap = gaps[idx] ?? baseGap; - return ( - <Marker - key={idx} - longitude={p.longitude} - latitude={p.latitude} - anchor="center" - > - <div - style={{ - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: labelGap, - transform: `rotate(${p.orientationDegrees}deg)`, - transformOrigin: "center center", - }} - > - <svg - width="20" - height="20" - viewBox="0 0 24 24" - style={{ - filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.3))", - }} - > - <path - d="M12 2 L20 22 L12 18 L4 22 Z" - fill={lineColor.background} - stroke="#fff" - strokeWidth="1.5" - /> - </svg> - {showLabel && ( - <div - style={{ - background: lineColor.background, - color: lineColor.text, - padding: "2px 4px", - borderRadius: 4, - fontSize: 10, - fontWeight: 700, - lineHeight: 1, - border: "1px solid #fff", - boxShadow: "0 1px 2px rgba(0,0,0,0.3)", - transform: `rotate(${-p.orientationDegrees}deg)`, - pointerEvents: "none", - zIndex: 0, - }} - > - {c.line} - </div> - )} - </div> - </Marker> - ); - }); - })()} - </Map> - )} - - <div className="map-floating-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 className={`map-attribution ${showAttribution ? "open" : ""}`}> - <button - type="button" - aria-label="Mostrar atribución del mapa" - aria-expanded={showAttribution} - onClick={() => setShowAttribution((open) => !open)} - className="map-attribution__toggle" - > - i - </button> - <div className="map-attribution__panel"> - <span>OpenFreeMap © OpenMapTiles data from </span> - <a - href="https://www.openstreetmap.org/copyright" - target="_blank" - rel="noreferrer" - > - OpenStreetMap - </a> - </div> - </div> - </div> - ); -}; |
