From cfbb1625e7873264e2ef435cc76fec2b59cf58d8 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Wed, 24 Dec 2025 19:33:49 +0100 Subject: Refactor map components and improve modal functionality --- src/frontend/app/components/StopHelpModal.tsx | 151 ----- src/frontend/app/components/StopMapModal.css | 58 -- src/frontend/app/components/StopMapModal.tsx | 659 --------------------- .../Stops/ConsolidatedCirculationListSkeleton.tsx | 47 -- src/frontend/app/components/TimetableSkeleton.tsx | 74 --- src/frontend/app/components/UpdateNotification.css | 114 ---- src/frontend/app/components/shared/AppMap.tsx | 213 +++++++ src/frontend/app/components/stop/StopHelpModal.tsx | 151 +++++ src/frontend/app/components/stop/StopMapModal.css | 58 ++ src/frontend/app/components/stop/StopMapModal.tsx | 616 +++++++++++++++++++ 10 files changed, 1038 insertions(+), 1103 deletions(-) delete mode 100644 src/frontend/app/components/StopHelpModal.tsx delete mode 100644 src/frontend/app/components/StopMapModal.css delete mode 100644 src/frontend/app/components/StopMapModal.tsx delete mode 100644 src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx delete mode 100644 src/frontend/app/components/TimetableSkeleton.tsx delete mode 100644 src/frontend/app/components/UpdateNotification.css create mode 100644 src/frontend/app/components/shared/AppMap.tsx create mode 100644 src/frontend/app/components/stop/StopHelpModal.tsx create mode 100644 src/frontend/app/components/stop/StopMapModal.css create mode 100644 src/frontend/app/components/stop/StopMapModal.tsx (limited to 'src/frontend/app/components') diff --git a/src/frontend/app/components/StopHelpModal.tsx b/src/frontend/app/components/StopHelpModal.tsx deleted file mode 100644 index e8157ab..0000000 --- a/src/frontend/app/components/StopHelpModal.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { AlertTriangle, Clock, LocateIcon } from "lucide-react"; -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Sheet } from "react-modal-sheet"; - -interface StopHelpModalProps { - isOpen: boolean; - onClose: () => void; -} - -export const StopHelpModal: React.FC = ({ - isOpen, - onClose, -}) => { - const { t } = useTranslation(); - - return ( - - - - -
-
-

{t("stop_help.title")}

- -
-
-
- -
-
-

- {t("stop_help.realtime_ok")} -

-

- {t("stop_help.realtime_ok_desc")} -

-
-
- -
-
- -
-
-

- {t("stop_help.realtime_warning")} -

-

- {t("stop_help.realtime_warning_desc")} -

-
-
- -
-
- -
-
-

- {t("stop_help.scheduled")} -

-

- {t("stop_help.scheduled_desc")} -

-
-
- -
-
- -
-
-

- {t("stop_help.gps")} -

-

- {t("stop_help.gps_desc")} -

-
-
-
-
- -
-

- {t("stop_help.punctuality")} -

- -
-
- - {t("stop_help.punctuality_ontime_label", "En hora")} - -

- {t("stop_help.punctuality_ontime")} -

-
- -
- - {t("stop_help.punctuality_early_label", "Adelanto")} - -

- {t("stop_help.punctuality_early")} -

-
- -
- - {t("stop_help.punctuality_late_label", "Retraso")} - -

- {t("stop_help.punctuality_late")} -

-
-
-
- -
-

- {t("stop_help.gps_quality")} -

- -
-
- - {t("stop_help.gps_reliable_label", "GPS fiable")} - -

- {t("stop_help.gps_reliable")} -

-
- -
- - {t("stop_help.gps_imprecise_label", "GPS impreciso")} - -

- {t("stop_help.gps_imprecise")} -

-
-
-
-
-
-
- -
- ); -}; diff --git a/src/frontend/app/components/StopMapModal.css b/src/frontend/app/components/StopMapModal.css deleted file mode 100644 index f024b38..0000000 --- a/src/frontend/app/components/StopMapModal.css +++ /dev/null @@ -1,58 +0,0 @@ -/* Stop map modal container */ -.stop-map-modal { - display: flex; - flex-direction: column; - height: 100%; - background: var(--background-color); -} - -.stop-map-modal__map-container { - width: 100%; - height: 100%; - position: relative; - flex-shrink: 0; -} - -/* Map floating controls */ -.map-modal-controls { - position: absolute; - left: 8px; - top: 8px; - display: flex; - gap: 8px; - z-index: 2; -} - -.center-btn { - appearance: none; - border: 1px solid rgba(0, 0, 0, 0.15); - background: color-mix( - in oklab, - var(--background-color, #fff) 85%, - transparent - ); - color: var(--text-primary, #111); - padding: 6px; - border-radius: 6px; - font-size: 12px; - line-height: 1; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; -} - -.center-btn:hover { - background: color-mix( - in oklab, - var(--background-color, #fff) 75%, - transparent - ); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); -} - -.center-btn:active { - transform: scale(0.95); -} diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx deleted file mode 100644 index d218af4..0000000 --- a/src/frontend/app/components/StopMapModal.tsx +++ /dev/null @@ -1,659 +0,0 @@ -import maplibregl from "maplibre-gl"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; -import { Sheet } from "react-modal-sheet"; -import { useApp } from "~/AppContext"; -import { APP_CONSTANTS } from "~/config/constants"; -import { getLineColour } 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; - shapeIndex?: number; -} - -export interface ConsolidatedCirculationForMap { - id: string; - line: string; - route: string; - currentPosition?: Position; - stopShapeIndex?: number; - isPreviousTrip?: boolean; - previousTripShapeId?: string | null; - schedule?: { - shapeId?: string | null; - }; - shape?: any; -} - -interface StopMapModalProps { - stop: Stop; - circulations: ConsolidatedCirculationForMap[]; - isOpen: boolean; - onClose: () => void; - selectedCirculationId?: string; -} - -export const StopMapModal: React.FC = ({ - stop, - circulations, - isOpen, - onClose, - selectedCirculationId, -}) => { - const { theme } = useApp(); - const [styleSpec, setStyleSpec] = useState(null); - const mapRef = useRef(null); - const hasFitBounds = useRef(false); - const userInteracted = useRef(false); - const [shapeData, setShapeData] = useState(null); - const [previousShapeData, setPreviousShapeData] = useState(null); - - // Filter circulations that have GPS coordinates - const busesWithPosition = useMemo( - () => circulations.filter((c) => !!c.currentPosition), - [circulations] - ); - - // Use selectedCirculationId if provided, otherwise use first bus with position - const selectedBus = useMemo(() => { - if (selectedCirculationId !== undefined) { - const circulation = circulations.find( - (c) => c.id === selectedCirculationId - ); - if (circulation) { - return circulation; - } - } - // Fallback to first bus with position - return busesWithPosition.length > 0 ? busesWithPosition[0] : null; - }, [selectedCirculationId, 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; - if (userInteracted.current) return; - - const points: { lat: number; lon: number }[] = []; - - const getStopsFromFeatureCollection = (data: any) => { - if (!data || data.type !== "FeatureCollection" || !data.features) - return []; - return data.features.filter((f: any) => f.properties?.type === "stop"); - }; - - const findClosestStopIndex = ( - stops: any[], - pos: { lat: number; lon: number } - ) => { - let minDst = Infinity; - let index = -1; - stops.forEach((s: any, idx: number) => { - const [lon, lat] = s.geometry.coordinates; - const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2); - if (dst < minDst) { - minDst = dst; - index = idx; - } - }); - return index; - }; - - const findClosestPointIndex = ( - coords: number[][], - pos: { lat: number; lon: number } - ) => { - let minDst = Infinity; - let index = -1; - coords.forEach((c, idx) => { - const [lon, lat] = c; - const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2); - if (dst < minDst) { - minDst = dst; - index = idx; - } - }); - return index; - }; - - const addShapePoints = (data: any, isPrevious: boolean) => { - if (!data) return; - - if (data.type === "FeatureCollection") { - const stops = getStopsFromFeatureCollection(data); - if (stops.length === 0) return; - - let startIdx = 0; - let endIdx = stops.length - 1; - - const currentPos = selectedBus?.currentPosition; - const userStopPos = - stop.latitude && stop.longitude - ? { lat: stop.latitude, lon: stop.longitude } - : null; - - if (isPrevious) { - // Previous trip: Start from Bus, End at last stop - if (currentPos) { - const busIdx = findClosestStopIndex(stops, { - lat: currentPos.latitude, - lon: currentPos.longitude, - }); - if (busIdx !== -1) startIdx = busIdx; - } - } else { - // Current trip: Start from Bus (if not previous), End at User Stop - if (!previousShapeData && currentPos) { - const busIdx = findClosestStopIndex(stops, { - lat: currentPos.latitude, - lon: currentPos.longitude, - }); - if (busIdx !== -1) startIdx = busIdx; - } - - if (userStopPos) { - let userIdx = -1; - // Try name match - if (stop.name) { - userIdx = stops.findIndex( - (s: any) => s.properties?.name === stop.name - ); - } - // Fallback to coords - if (userIdx === -1) { - userIdx = findClosestStopIndex(stops, userStopPos); - } - if (userIdx !== -1) endIdx = userIdx; - } - } - - // Add stops in range - if (startIdx <= endIdx) { - for (let i = startIdx; i <= endIdx; i++) { - const [lon, lat] = stops[i].geometry.coordinates; - points.push({ lat, lon }); - } - } - return; - } - - const coords = data?.geometry?.coordinates; - if (!coords) return; - - let startIdx = 0; - let endIdx = coords.length - 1; - let foundIndices = false; - - if (data.properties?.busPoint && data.properties?.stopPoint) { - startIdx = data.properties.busPoint.index; - endIdx = data.properties.stopPoint.index; - foundIndices = true; - } else { - // Fallback: find closest points on the line - if (selectedBus?.currentPosition) { - const busIdx = findClosestPointIndex(coords, { - lat: selectedBus.currentPosition.latitude, - lon: selectedBus.currentPosition.longitude, - }); - if (busIdx !== -1) startIdx = busIdx; - } - if (stop.latitude && stop.longitude) { - const stopIdx = findClosestPointIndex(coords, { - lat: stop.latitude, - lon: stop.longitude, - }); - if (stopIdx !== -1) endIdx = stopIdx; - } - } - - const start = Math.min(startIdx, endIdx); - const end = Math.max(startIdx, endIdx); - - for (let i = start; i <= end; i++) { - points.push({ lat: coords[i][1], lon: coords[i][0] }); - } - }; - - addShapePoints(previousShapeData, true); - addShapePoints(shapeData, false); - - if (points.length === 0) { - 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, - }); - } - } else { - // Ensure bus and stop are always included if available, to prevent cutting them off - if (selectedBus?.currentPosition) { - points.push({ - lat: selectedBus.currentPosition.latitude, - lon: selectedBus.currentPosition.longitude, - }); - } - if (stop.latitude && stop.longitude) { - points.push({ lat: stop.latitude, lon: stop.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.getMap().fitBounds(bounds, { - padding: 80, - duration: 500, - maxZoom: 17, - } as any); - } - } catch {} - }, [stop, selectedBus, shapeData, previousShapeData]); - - // 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 || !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; - userInteracted.current = false; - setShapeData(null); - setPreviousShapeData(null); - } - }, [isOpen]); - - // Fetch shape for selected bus - useEffect(() => { - if (!isOpen || !selectedBus) { - setShapeData(null); - setPreviousShapeData(null); - return; - } - - if (selectedBus.shape) { - setShapeData(selectedBus.shape); - setPreviousShapeData(null); - handleCenter(); - return; - } - - setShapeData(null); - setPreviousShapeData(null); - }, [isOpen, selectedBus]); - - if (!selectedBus && busesWithPosition.length === 0) { - return null; // Don't render if no buses with GPS coordinates and no selected bus - } - - return ( - - - - -
- {/* Map Container */} -
- {styleSpec && ( - { - if (e.originalEvent) { - userInteracted.current = true; - } - }} - onDragStart={() => { - userInteracted.current = true; - }} - onZoomStart={() => { - userInteracted.current = true; - }} - onRotateStart={() => { - userInteracted.current = true; - }} - onPitchStart={() => { - userInteracted.current = true; - }} - onLoad={() => { - handleCenter(); - }} - > - {/* Previous Shape Layer */} - {previousShapeData && selectedBus && ( - - {/* 1. Black border */} - - {/* 2. White background */} - - {/* 3. Colored dashes */} - - - )} - - {/* Shape Layer */} - {shapeData && selectedBus && ( - - - - - {/* Stops Layer */} - - - )} - - {/* Stop marker */} - {stop.latitude && stop.longitude && ( - -
- - - - - - - - - - -
-
- )} - - {/* Selected bus marker */} - {selectedBus?.currentPosition && ( - -
- - - -
-
- )} -
- )} - - {/* Floating controls */} -
- -
-
-
-
-
- -
- ); -}; diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx deleted file mode 100644 index c99b883..0000000 --- a/src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from "react"; -import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; -import "react-loading-skeleton/dist/skeleton.css"; -import "./ConsolidatedCirculationList.css"; - -export const ConsolidatedCirculationListSkeleton: React.FC = () => { - return ( - - <> -
- -
- - {[1, 2, 3, 4, 5].map((i) => ( -
-
-
- -
- -
- -
- -
- -
-
- -
- - - -
-
- ))} - -
- ); -}; diff --git a/src/frontend/app/components/TimetableSkeleton.tsx b/src/frontend/app/components/TimetableSkeleton.tsx deleted file mode 100644 index 2d4fc29..0000000 --- a/src/frontend/app/components/TimetableSkeleton.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react"; -import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; -import "react-loading-skeleton/dist/skeleton.css"; -import { useTranslation } from "react-i18next"; - -interface TimetableSkeletonProps { - rows?: number; -} - -export const TimetableSkeleton: React.FC = ({ - rows = 4, -}) => { - const { t } = useTranslation(); - - return ( - -
-
- -
- -
- {Array.from({ length: rows }, (_, index) => ( -
-
-
- -
- -
- -
- -
- -
-
- -
-
- - -
-
-
- ))} -
-
-
- ); -}; diff --git a/src/frontend/app/components/UpdateNotification.css b/src/frontend/app/components/UpdateNotification.css deleted file mode 100644 index 6183194..0000000 --- a/src/frontend/app/components/UpdateNotification.css +++ /dev/null @@ -1,114 +0,0 @@ -.update-notification { - position: fixed; - top: 0; - left: 0; - right: 0; - z-index: 9999; - background-color: var(--button-background-color); - color: white; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - animation: slideDown 0.3s ease-out; -} - -@keyframes slideDown { - from { - transform: translateY(-100%); - } - to { - transform: translateY(0); - } -} - -.update-content { - display: flex; - align-items: center; - padding: 12px 16px; - gap: 12px; - max-width: 100%; -} - -.update-icon { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - background-color: rgba(255, 255, 255, 0.2); - border-radius: 50%; -} - -.update-text { - flex: 1; - min-width: 0; -} - -.update-title { - font-size: 0.9rem; - font-weight: 600; - margin-bottom: 2px; -} - -.update-description { - font-size: 0.8rem; - opacity: 0.9; - line-height: 1.2; -} - -.update-actions { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; -} - -.update-button { - background: rgba(255, 255, 255, 0.2); - border: 1px solid rgba(255, 255, 255, 0.3); - color: white; - padding: 6px 12px; - border-radius: 6px; - font-size: 0.8rem; - font-weight: 500; - cursor: pointer; - transition: background-color 0.2s ease; -} - -.update-button:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.3); -} - -.update-button:disabled { - opacity: 0.7; - cursor: not-allowed; -} - -.update-dismiss { - background: none; - border: none; - color: white; - padding: 6px; - border-radius: 4px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.2s ease; -} - -.update-dismiss:hover { - background: rgba(255, 255, 255, 0.2); -} - -@media (min-width: 768px) { - .update-content { - max-width: 768px; - margin: 0 auto; - } -} - -@media (min-width: 1024px) { - .update-content { - max-width: 1024px; - } -} diff --git a/src/frontend/app/components/shared/AppMap.tsx b/src/frontend/app/components/shared/AppMap.tsx new file mode 100644 index 0000000..adf860d --- /dev/null +++ b/src/frontend/app/components/shared/AppMap.tsx @@ -0,0 +1,213 @@ +import maplibregl from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import Map, { + GeolocateControl, + NavigationControl, + type MapLayerMouseEvent, + type MapRef, + type StyleSpecification, +} from "react-map-gl/maplibre"; +import { useLocation } from "react-router"; +import { useApp } from "~/AppContext"; +import { APP_CONSTANTS } from "~/config/constants"; +import { DEFAULT_STYLE, loadStyle } from "~/maps/styleloader"; + +interface AppMapProps { + children?: React.ReactNode; + showTraffic?: boolean; + showCameras?: boolean; + syncState?: boolean; + interactiveLayerIds?: string[]; + onClick?: (e: MapLayerMouseEvent) => void; + initialViewState?: { + latitude: number; + longitude: number; + zoom: number; + }; + style?: React.CSSProperties; + maxBounds?: [number, number, number, number] | null; + attributionControl?: boolean | any; + showNavigation?: boolean; + showGeolocate?: boolean; + onMove?: (e: any) => void; + onDragStart?: () => void; + onZoomStart?: () => void; + onRotateStart?: () => void; + onPitchStart?: () => void; + onLoad?: () => void; +} + +export const AppMap = forwardRef( + ( + { + children, + showTraffic: propShowTraffic, + showCameras: propShowCameras, + syncState = false, + interactiveLayerIds, + onClick, + initialViewState, + style, + maxBounds = [ + (APP_CONSTANTS.bounds.sw as [number, number])[0], + (APP_CONSTANTS.bounds.sw as [number, number])[1], + (APP_CONSTANTS.bounds.ne as [number, number])[0], + (APP_CONSTANTS.bounds.ne as [number, number])[1], + ], + attributionControl = false, + showNavigation = false, + showGeolocate = false, + onMove, + onDragStart, + onZoomStart, + onRotateStart, + onPitchStart, + onLoad, + }, + ref + ) => { + const { + theme, + mapState, + updateMapState, + showTraffic: settingsShowTraffic, + showCameras: settingsShowCameras, + mapPositionMode, + } = useApp(); + const mapRef = useRef(null); + const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); + const location = useLocation(); + const path = location.pathname; + + // Use prop if provided, otherwise use settings + const showTraffic = + propShowTraffic !== undefined ? propShowTraffic : settingsShowTraffic; + const showCameras = + propShowCameras !== undefined ? propShowCameras : settingsShowCameras; + + useImperativeHandle(ref, () => mapRef.current!); + + useEffect(() => { + loadStyle("openfreemap", theme, { includeTraffic: showTraffic }) + .then((style) => setMapStyle(style)) + .catch((error) => console.error("Failed to load map style:", error)); + }, [theme, showTraffic]); + + useEffect(() => { + const handleMapChange = () => { + if (!syncState || !mapRef.current) return; + const map = mapRef.current.getMap(); + if (!map) return; + const center = map.getCenter(); + const zoom = map.getZoom(); + updateMapState([center.lat, center.lng], zoom, path); + }; + + const handleStyleImageMissing = (e: any) => { + if (!mapRef.current) return; + const map = mapRef.current.getMap(); + if (!map || map.hasImage(e.id)) return; + + if (e.id.startsWith("stop-")) { + console.warn(`Missing icon image: ${e.id}`); + } + + map.addImage(e.id, { + width: 1, + height: 1, + data: new Uint8Array(4), + }); + }; + + if (mapRef.current) { + const map = mapRef.current.getMap(); + if (map) { + map.on("moveend", handleMapChange); + map.on("styleimagemissing", handleStyleImageMissing); + } + } + + return () => { + if (mapRef.current) { + const map = mapRef.current.getMap(); + if (map) { + map.off("moveend", handleMapChange); + map.off("styleimagemissing", handleStyleImageMissing); + } + } + }; + }, [syncState, updateMapState]); + + const getLatitude = (center: any) => + Array.isArray(center) ? center[0] : center.lat; + const getLongitude = (center: any) => + Array.isArray(center) ? center[1] : center.lng; + + const viewState = useMemo(() => { + if (initialViewState) return initialViewState; + + if (mapPositionMode === "gps" && mapState.userLocation) { + return { + latitude: getLatitude(mapState.userLocation), + longitude: getLongitude(mapState.userLocation), + zoom: 16, + }; + } + + const pathState = mapState.paths[path]; + if (pathState) { + return { + latitude: getLatitude(pathState.center), + longitude: getLongitude(pathState.center), + zoom: pathState.zoom, + }; + } + + return { + latitude: getLatitude(APP_CONSTANTS.defaultCenter), + longitude: getLongitude(APP_CONSTANTS.defaultCenter), + zoom: APP_CONSTANTS.defaultZoom, + }; + }, [initialViewState, mapPositionMode, mapState, path]); + + return ( + + {showNavigation && } + {showGeolocate && ( + + )} + {children} + + ); + } +); + +AppMap.displayName = "AppMap"; diff --git a/src/frontend/app/components/stop/StopHelpModal.tsx b/src/frontend/app/components/stop/StopHelpModal.tsx new file mode 100644 index 0000000..e8157ab --- /dev/null +++ b/src/frontend/app/components/stop/StopHelpModal.tsx @@ -0,0 +1,151 @@ +import { AlertTriangle, Clock, LocateIcon } from "lucide-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Sheet } from "react-modal-sheet"; + +interface StopHelpModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const StopHelpModal: React.FC = ({ + isOpen, + onClose, +}) => { + const { t } = useTranslation(); + + return ( + + + + +
+
+

{t("stop_help.title")}

+ +
+
+
+ +
+
+

+ {t("stop_help.realtime_ok")} +

+

+ {t("stop_help.realtime_ok_desc")} +

+
+
+ +
+
+ +
+
+

+ {t("stop_help.realtime_warning")} +

+

+ {t("stop_help.realtime_warning_desc")} +

+
+
+ +
+
+ +
+
+

+ {t("stop_help.scheduled")} +

+

+ {t("stop_help.scheduled_desc")} +

+
+
+ +
+
+ +
+
+

+ {t("stop_help.gps")} +

+

+ {t("stop_help.gps_desc")} +

+
+
+
+
+ +
+

+ {t("stop_help.punctuality")} +

+ +
+
+ + {t("stop_help.punctuality_ontime_label", "En hora")} + +

+ {t("stop_help.punctuality_ontime")} +

+
+ +
+ + {t("stop_help.punctuality_early_label", "Adelanto")} + +

+ {t("stop_help.punctuality_early")} +

+
+ +
+ + {t("stop_help.punctuality_late_label", "Retraso")} + +

+ {t("stop_help.punctuality_late")} +

+
+
+
+ +
+

+ {t("stop_help.gps_quality")} +

+ +
+
+ + {t("stop_help.gps_reliable_label", "GPS fiable")} + +

+ {t("stop_help.gps_reliable")} +

+
+ +
+ + {t("stop_help.gps_imprecise_label", "GPS impreciso")} + +

+ {t("stop_help.gps_imprecise")} +

+
+
+
+
+
+
+ +
+ ); +}; diff --git a/src/frontend/app/components/stop/StopMapModal.css b/src/frontend/app/components/stop/StopMapModal.css new file mode 100644 index 0000000..f024b38 --- /dev/null +++ b/src/frontend/app/components/stop/StopMapModal.css @@ -0,0 +1,58 @@ +/* Stop map modal container */ +.stop-map-modal { + display: flex; + flex-direction: column; + height: 100%; + background: var(--background-color); +} + +.stop-map-modal__map-container { + width: 100%; + height: 100%; + position: relative; + flex-shrink: 0; +} + +/* Map floating controls */ +.map-modal-controls { + position: absolute; + left: 8px; + top: 8px; + display: flex; + gap: 8px; + z-index: 2; +} + +.center-btn { + appearance: none; + border: 1px solid rgba(0, 0, 0, 0.15); + background: color-mix( + in oklab, + var(--background-color, #fff) 85%, + transparent + ); + color: var(--text-primary, #111); + padding: 6px; + border-radius: 6px; + font-size: 12px; + line-height: 1; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.center-btn:hover { + background: color-mix( + in oklab, + var(--background-color, #fff) 75%, + transparent + ); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.center-btn:active { + transform: scale(0.95); +} diff --git a/src/frontend/app/components/stop/StopMapModal.tsx b/src/frontend/app/components/stop/StopMapModal.tsx new file mode 100644 index 0000000..2e091b1 --- /dev/null +++ b/src/frontend/app/components/stop/StopMapModal.tsx @@ -0,0 +1,616 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; +import { Sheet } from "react-modal-sheet"; +import { useApp } from "~/AppContext"; +import { AppMap } from "~/components/shared/AppMap"; +import { APP_CONSTANTS } from "~/config/constants"; +import { getLineColour } from "~/data/LineColors"; +import type { Stop } from "~/data/StopDataProvider"; +import "./StopMapModal.css"; + +export interface Position { + latitude: number; + longitude: number; + orientationDegrees: number; + shapeIndex?: number; +} + +export interface ConsolidatedCirculationForMap { + id: string; + line: string; + route: string; + currentPosition?: Position; + stopShapeIndex?: number; + isPreviousTrip?: boolean; + previousTripShapeId?: string | null; + schedule?: { + shapeId?: string | null; + }; + shape?: any; +} + +interface StopMapModalProps { + stop: Stop; + circulations: ConsolidatedCirculationForMap[]; + isOpen: boolean; + onClose: () => void; + selectedCirculationId?: string; +} + +export const StopMapModal: React.FC = ({ + stop, + circulations, + isOpen, + onClose, + selectedCirculationId, +}) => { + const { theme } = useApp(); + const mapRef = useRef(null); + const hasFitBounds = useRef(false); + const userInteracted = useRef(false); + const [shapeData, setShapeData] = useState(null); + const [previousShapeData, setPreviousShapeData] = useState(null); + + // Filter circulations that have GPS coordinates + const busesWithPosition = useMemo( + () => circulations.filter((c) => !!c.currentPosition), + [circulations] + ); + + // Use selectedCirculationId if provided, otherwise use first bus with position + const selectedBus = useMemo(() => { + if (selectedCirculationId !== undefined) { + const circulation = circulations.find( + (c) => c.id === selectedCirculationId + ); + if (circulation) { + return circulation; + } + } + // Fallback to first bus with position + return busesWithPosition.length > 0 ? busesWithPosition[0] : null; + }, [selectedCirculationId, 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; + if (userInteracted.current) return; + + const points: { lat: number; lon: number }[] = []; + + const getStopsFromFeatureCollection = (data: any) => { + if (!data || data.type !== "FeatureCollection" || !data.features) + return []; + return data.features.filter((f: any) => f.properties?.type === "stop"); + }; + + const findClosestStopIndex = ( + stops: any[], + pos: { lat: number; lon: number } + ) => { + let minDst = Infinity; + let index = -1; + stops.forEach((s: any, idx: number) => { + const [lon, lat] = s.geometry.coordinates; + const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2); + if (dst < minDst) { + minDst = dst; + index = idx; + } + }); + return index; + }; + + const findClosestPointIndex = ( + coords: number[][], + pos: { lat: number; lon: number } + ) => { + let minDst = Infinity; + let index = -1; + coords.forEach((c, idx) => { + const [lon, lat] = c; + const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2); + if (dst < minDst) { + minDst = dst; + index = idx; + } + }); + return index; + }; + + const addShapePoints = (data: any, isPrevious: boolean) => { + if (!data) return; + + if (data.type === "FeatureCollection") { + const stops = getStopsFromFeatureCollection(data); + if (stops.length === 0) return; + + let startIdx = 0; + let endIdx = stops.length - 1; + + const currentPos = selectedBus?.currentPosition; + const userStopPos = + stop.latitude && stop.longitude + ? { lat: stop.latitude, lon: stop.longitude } + : null; + + if (isPrevious) { + // Previous trip: Start from Bus, End at last stop + if (currentPos) { + const busIdx = findClosestStopIndex(stops, { + lat: currentPos.latitude, + lon: currentPos.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + } else { + // Current trip: Start from Bus (if not previous), End at User Stop + if (!previousShapeData && currentPos) { + const busIdx = findClosestStopIndex(stops, { + lat: currentPos.latitude, + lon: currentPos.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + + if (userStopPos) { + let userIdx = -1; + // Try name match + if (stop.name) { + userIdx = stops.findIndex( + (s: any) => s.properties?.name === stop.name + ); + } + // Fallback to coords + if (userIdx === -1) { + userIdx = findClosestStopIndex(stops, userStopPos); + } + if (userIdx !== -1) endIdx = userIdx; + } + } + + // Add stops in range + if (startIdx <= endIdx) { + for (let i = startIdx; i <= endIdx; i++) { + const [lon, lat] = stops[i].geometry.coordinates; + points.push({ lat, lon }); + } + } + return; + } + + const coords = data?.geometry?.coordinates; + if (!coords) return; + + let startIdx = 0; + let endIdx = coords.length - 1; + let foundIndices = false; + + if (data.properties?.busPoint && data.properties?.stopPoint) { + startIdx = data.properties.busPoint.index; + endIdx = data.properties.stopPoint.index; + foundIndices = true; + } else { + // Fallback: find closest points on the line + if (selectedBus?.currentPosition) { + const busIdx = findClosestPointIndex(coords, { + lat: selectedBus.currentPosition.latitude, + lon: selectedBus.currentPosition.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + if (stop.latitude && stop.longitude) { + const stopIdx = findClosestPointIndex(coords, { + lat: stop.latitude, + lon: stop.longitude, + }); + if (stopIdx !== -1) endIdx = stopIdx; + } + } + + const start = Math.min(startIdx, endIdx); + const end = Math.max(startIdx, endIdx); + + for (let i = start; i <= end; i++) { + points.push({ lat: coords[i][1], lon: coords[i][0] }); + } + }; + + addShapePoints(previousShapeData, true); + addShapePoints(shapeData, false); + + if (points.length === 0) { + 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, + }); + } + } else { + // Ensure bus and stop are always included if available, to prevent cutting them off + if (selectedBus?.currentPosition) { + points.push({ + lat: selectedBus.currentPosition.latitude, + lon: selectedBus.currentPosition.longitude, + }); + } + if (stop.latitude && stop.longitude) { + points.push({ lat: stop.latitude, lon: stop.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.getMap().fitBounds(bounds, { + padding: 80, + duration: 500, + maxZoom: 17, + } as any); + } + } catch {} + }, [stop, selectedBus, shapeData, previousShapeData]); + + // 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 (!mapRef.current || !isOpen) return; + + handleCenter(); + hasFitBounds.current = true; + }, [stop, selectedBus, isOpen, handleCenter]); + + // Reset bounds when modal opens/closes + useEffect(() => { + if (!isOpen) { + hasFitBounds.current = false; + userInteracted.current = false; + setShapeData(null); + setPreviousShapeData(null); + } + }, [isOpen]); + + // Fetch shape for selected bus + useEffect(() => { + if (!isOpen || !selectedBus) { + setShapeData(null); + setPreviousShapeData(null); + return; + } + + if (selectedBus.shape) { + setShapeData(selectedBus.shape); + setPreviousShapeData(null); + handleCenter(); + return; + } + + setShapeData(null); + setPreviousShapeData(null); + }, [isOpen, selectedBus]); + + if (!selectedBus && busesWithPosition.length === 0) { + return null; // Don't render if no buses with GPS coordinates and no selected bus + } + + return ( + + + + +
+ {/* Map Container */} +
+ { + if (e.originalEvent) { + userInteracted.current = true; + } + }} + onDragStart={() => { + userInteracted.current = true; + }} + onZoomStart={() => { + userInteracted.current = true; + }} + onRotateStart={() => { + userInteracted.current = true; + }} + onPitchStart={() => { + userInteracted.current = true; + }} + onLoad={() => { + handleCenter(); + }} + > + {/* Previous Shape Layer */} + {previousShapeData && selectedBus && ( + + {/* 1. Black border */} + + {/* 2. White background */} + + {/* 3. Colored dashes */} + + + )} + + {/* Shape Layer */} + {shapeData && selectedBus && ( + + + + + {/* Stops Layer */} + + + )} + + {/* Stop marker */} + {stop.latitude && stop.longitude && ( + +
+ + + + + + + + + + +
+
+ )} + + {/* Selected bus marker */} + {selectedBus?.currentPosition && ( + +
+ + + +
+
+ )} +
+ + {/* Floating controls */} +
+ +
+
+
+
+
+ +
+ ); +}; -- cgit v1.3