From 3227c7bc6bd233c92b1cf54bec689f0582dca547 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 1 Dec 2025 22:25:56 +0100 Subject: refactor: replace StopSheet with StopSummarySheet and update related components - Deleted StopSheet and StopSheetSkeleton components. - Introduced StopSummarySheet and StopSummarySheetSkeleton components. - Updated ConsolidatedCirculationCard to support a reduced view. - Modified ConsolidatedCirculationList to accept a reduced prop. - Adjusted map route to use StopSummarySheet. - Cleaned up CSS styles related to the stop sheet components. - Enhanced error handling and loading states in the new summary sheet. - Updated stop report logic to filter out empty next streets. --- src/frontend/app/components/StopMapSheet.tsx | 575 --------------------------- 1 file changed, 575 deletions(-) delete mode 100644 src/frontend/app/components/StopMapSheet.tsx (limited to 'src/frontend/app/components/StopMapSheet.tsx') 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 = ({ - stop, - circulations -}) => { - 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>({}); - - 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 ( -
- {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 = getLineColour(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 = getLineColour(c.line); - const showLabel = zoom >= 13; - const labelGap = gaps[idx] ?? baseGap; - return ( - -
- - - - {showLabel && ( -
- {c.line} -
- )} -
-
- ); - }); - })()} - - )} - -
- -
- -
- -
- OpenFreeMap © OpenMapTiles data from - - OpenStreetMap - -
-
-
- ); -}; -- cgit v1.3