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 --- .../Controllers/ArrivalsController.cs | 21 +- .../Processors/VitrasaRealTimeProcessor.cs | 5 +- src/frontend/app/AppContext.tsx | 9 +- 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 +++++++++++++++++++ src/frontend/app/config/AppConfig.ts | 1 - src/frontend/app/contexts/MapContext.tsx | 41 +- src/frontend/app/contexts/SettingsContext.tsx | 52 +- src/frontend/app/hooks/useArrivals.ts | 2 +- src/frontend/app/i18n/locales/en-GB.json | 3 + src/frontend/app/i18n/locales/es-ES.json | 3 + src/frontend/app/i18n/locales/gl-ES.json | 3 + src/frontend/app/routes/map.tsx | 96 +-- src/frontend/app/routes/planner.tsx | 20 +- src/frontend/app/routes/settings.tsx | 42 +- src/frontend/app/routes/stops-$id.tsx | 7 +- 24 files changed, 1163 insertions(+), 1283 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 diff --git a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs index 61a003e..6b90f20 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs @@ -1,6 +1,7 @@ using System.Net; using Costasdev.Busurbano.Backend.GraphClient; using Costasdev.Busurbano.Backend.GraphClient.App; +using Costasdev.Busurbano.Backend.Helpers; using Costasdev.Busurbano.Backend.Services; using Costasdev.Busurbano.Backend.Types; using Costasdev.Busurbano.Backend.Types.Arrivals; @@ -140,13 +141,19 @@ public partial class ArrivalsController : ControllerBase Latitude = stop.Lat, Longitude = stop.Lon }, - Routes = stop.Routes.Select(r => new RouteInfo - { - GtfsId = r.GtfsId, - ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""), - Colour = r.Color ?? "FFFFFF", - TextColour = r.TextColor ?? "000000" - }).ToList(), + Routes = stop.Routes + .OrderBy( + r => r.ShortName, + Comparer.Create(SortingHelper.SortRouteShortNames) + ) + .Select(r => new RouteInfo + { + GtfsId = r.GtfsId, + ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""), + Colour = r.Color ?? "FFFFFF", + TextColour = r.TextColor ?? "000000" + }) + .ToList(), Arrivals = arrivals }); } diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs index a16425f..7a1d6ea 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs @@ -120,10 +120,7 @@ public class VitrasaRealTimeProcessor : AbstractRealTimeProcessor // Calculate delay badge var delayMinutes = estimate.Minutes - scheduledMinutes; - if (delayMinutes != 0) - { - arrival.Delay = new DelayBadge { Minutes = delayMinutes }; - } + arrival.Delay = new DelayBadge { Minutes = delayMinutes }; // Prefer real-time headsign if available and different if (!string.IsNullOrWhiteSpace(estimate.Route)) diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx index 8ff7de2..283bc22 100644 --- a/src/frontend/app/AppContext.tsx +++ b/src/frontend/app/AppContext.tsx @@ -1,17 +1,15 @@ /* eslint-disable react-refresh/only-export-components */ import { type ReactNode } from "react"; -import { type RegionId } from "./config/constants"; import { MapProvider, useMap } from "./contexts/MapContext"; import { SettingsProvider, useSettings, type MapPositionMode, - type TableStyle, type Theme, } from "./contexts/SettingsContext"; // Re-export types for compatibility -export type { MapPositionMode, RegionId, TableStyle, Theme }; +export type { MapPositionMode, Theme }; // Combined hook for backward compatibility export const useApp = () => { @@ -21,11 +19,6 @@ export const useApp = () => { return { ...settings, ...map, - // Mock region support for now since we only have one region - region: "vigo" as RegionId, - setRegion: (region: RegionId) => { - console.log("Set region", region); - }, }; }; 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 */} +
+ +
+
+
+
+
+ +
+ ); +}; diff --git a/src/frontend/app/config/AppConfig.ts b/src/frontend/app/config/AppConfig.ts index 523343e..dbb1abd 100644 --- a/src/frontend/app/config/AppConfig.ts +++ b/src/frontend/app/config/AppConfig.ts @@ -1,5 +1,4 @@ export const APP_CONFIG = { defaultTheme: "system" as const, - defaultTableStyle: "regular" as const, defaultMapPositionMode: "gps" as const, }; diff --git a/src/frontend/app/contexts/MapContext.tsx b/src/frontend/app/contexts/MapContext.tsx index db1392c..5fdf676 100644 --- a/src/frontend/app/contexts/MapContext.tsx +++ b/src/frontend/app/contexts/MapContext.tsx @@ -9,19 +9,16 @@ import { import { APP_CONSTANTS } from "~/config/constants"; interface MapState { - center: LngLatLike; - zoom: number; + paths: Record; userLocation: LngLatLike | null; hasLocationPermission: boolean; } interface MapContextProps { mapState: MapState; - setMapCenter: (center: LngLatLike) => void; - setMapZoom: (zoom: number) => void; setUserLocation: (location: LngLatLike | null) => void; setLocationPermission: (hasPermission: boolean) => void; - updateMapState: (center: LngLatLike, zoom: number) => void; + updateMapState: (center: LngLatLike, zoom: number, path: string) => void; } const MapContext = createContext(undefined); @@ -36,8 +33,7 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { // We might want to ensure we have a fallback if the region changed while the app was closed? // But for now, let's stick to the existing logic. return { - center: parsed.center || APP_CONSTANTS.defaultCenter, - zoom: parsed.zoom || APP_CONSTANTS.defaultZoom, + paths: parsed.paths || {}, userLocation: parsed.userLocation || null, hasLocationPermission: parsed.hasLocationPermission || false, }; @@ -46,29 +42,12 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { } } return { - center: APP_CONSTANTS.defaultCenter, - zoom: APP_CONSTANTS.defaultZoom, + paths: {}, userLocation: null, hasLocationPermission: false, }; }); - const setMapCenter = (center: LngLatLike) => { - setMapState((prev) => { - const newState = { ...prev, center }; - localStorage.setItem("mapState", JSON.stringify(newState)); - return newState; - }); - }; - - const setMapZoom = (zoom: number) => { - setMapState((prev) => { - const newState = { ...prev, zoom }; - localStorage.setItem("mapState", JSON.stringify(newState)); - return newState; - }); - }; - const setUserLocation = (userLocation: LngLatLike | null) => { setMapState((prev) => { const newState = { ...prev, userLocation }; @@ -85,9 +64,15 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { }); }; - const updateMapState = (center: LngLatLike, zoom: number) => { + const updateMapState = (center: LngLatLike, zoom: number, path: string) => { setMapState((prev) => { - const newState = { ...prev, center, zoom }; + const newState = { + ...prev, + paths: { + ...prev.paths, + [path]: { center, zoom }, + }, + }; localStorage.setItem("mapState", JSON.stringify(newState)); return newState; }); @@ -115,8 +100,6 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { void; resolvedTheme: "light" | "dark"; + + showTraffic: boolean; + setShowTraffic: (show: boolean) => void; + showCameras: boolean; + setShowCameras: (show: boolean) => void; } const SettingsContext = createContext( @@ -104,26 +108,6 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => { }, [theme]); //#endregion - //#region Table Style - const [tableStyle, setTableStyle] = useState(() => { - const savedTableStyle = localStorage.getItem("tableStyle"); - if (savedTableStyle) { - return savedTableStyle as TableStyle; - } - return APP_CONFIG.defaultTableStyle; - }); - - const toggleTableStyle = () => { - setTableStyle((prevTableStyle) => - prevTableStyle === "regular" ? "grouped" : "regular" - ); - }; - - useEffect(() => { - localStorage.setItem("tableStyle", tableStyle); - }, [tableStyle]); - //#endregion - //#region Map Position Mode const [mapPositionMode, setMapPositionMode] = useState( () => { @@ -139,6 +123,26 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => { }, [mapPositionMode]); //#endregion + //#region Map Layers + const [showTraffic, setShowTraffic] = useState(() => { + const saved = localStorage.getItem("showTraffic"); + return saved !== null ? saved === "true" : true; + }); + + const [showCameras, setShowCameras] = useState(() => { + const saved = localStorage.getItem("showCameras"); + return saved !== null ? saved === "true" : false; + }); + + useEffect(() => { + localStorage.setItem("showTraffic", showTraffic.toString()); + }, [showTraffic]); + + useEffect(() => { + localStorage.setItem("showCameras", showCameras.toString()); + }, [showCameras]); + //#endregion + return ( { mapPositionMode, setMapPositionMode, resolvedTheme, + showTraffic, + setShowTraffic, + showCameras, + setShowCameras, }} > {children} diff --git a/src/frontend/app/hooks/useArrivals.ts b/src/frontend/app/hooks/useArrivals.ts index 4b0d331..e86a0bf 100644 --- a/src/frontend/app/hooks/useArrivals.ts +++ b/src/frontend/app/hooks/useArrivals.ts @@ -10,7 +10,7 @@ export const useStopArrivals = ( queryKey: ["arrivals", stopId, reduced], queryFn: () => fetchArrivals(stopId, reduced), enabled: !!stopId && enabled, - refetchInterval: 30000, // Refresh every 30 seconds + refetchInterval: 15000, // Refresh every 15 seconds retry: false, // Disable retries to see errors immediately }); }; diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 819329e..bd51aa4 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -26,6 +26,9 @@ "map_position_mode": "Map position:", "map_position_gps": "GPS position", "map_position_last": "Where I left it", + "map_layers": "Map layers", + "show_traffic": "Show traffic", + "show_cameras": "Show cameras", "language": "Language" }, "stoplist": { diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 1e67073..c20d660 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -26,6 +26,9 @@ "map_position_mode": "Posición del mapa:", "map_position_gps": "Posición GPS", "map_position_last": "Donde lo dejé", + "map_layers": "Capas del mapa", + "show_traffic": "Mostrar tráfico", + "show_cameras": "Mostrar cámaras", "language": "Idioma" }, "stoplist": { diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 4148bfa..e7068e8 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -26,6 +26,9 @@ "map_position_mode": "Posición do mapa:", "map_position_gps": "Posición GPS", "map_position_last": "Onde o deixei", + "map_layers": "Capas do mapa", + "show_traffic": "Amosar tráfico", + "show_cameras": "Amosar cámaras", "language": "Idioma" }, "stoplist": { diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 517549b..45dd935 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -1,20 +1,17 @@ import StopDataProvider from "../data/StopDataProvider"; import "./map.css"; -import { DEFAULT_STYLE, loadStyle } from "app/maps/styleloader"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import Map, { - GeolocateControl, +import { Layer, - NavigationControl, Source, type MapLayerMouseEvent, type MapRef, - type StyleSpecification, } from "react-map-gl/maplibre"; import { useNavigate } from "react-router"; import { PlannerOverlay } from "~/components/PlannerOverlay"; +import { AppMap } from "~/components/shared/AppMap"; import { StopSummarySheet, type StopSheetProps, @@ -34,14 +31,10 @@ export default function StopMap() { StopSheetProps["stop"] | null >(null); const [isSheetOpen, setIsSheetOpen] = useState(false); - const { mapState, updateMapState, theme } = useApp(); const mapRef = useRef(null); const { searchRoute } = usePlanner(); - // Style state for Map component - const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); - // Handle click events on clusters and individual stops const onMapClick = (e: MapLayerMouseEvent) => { const features = e.features; @@ -59,63 +52,6 @@ export default function StopMap() { handlePointClick(feature); }; - useEffect(() => { - //const styleName = "carto"; - const styleName = "openfreemap"; - loadStyle(styleName, theme) - .then((style) => setMapStyle(style)) - .catch((error) => console.error("Failed to load map style:", error)); - }, [theme]); - - useEffect(() => { - const handleMapChange = () => { - if (!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); - }; - - const handleStyleImageMissing = (e: any) => { - // Suppress warnings for missing sprite images from base style - // This prevents console noise from OpenFreeMap's missing icons - if (!mapRef.current) return; - const map = mapRef.current.getMap(); - if (!map || map.hasImage(e.id)) return; - - // Log warning for our own icons if they are missing - if (e.id.startsWith("stop-")) { - console.warn(`Missing icon image: ${e.id}`); - } - - // Add a transparent 1x1 placeholder to prevent repeated warnings - 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); - } - } - }; - }, [mapRef.current]); - const getLatitude = (center: any) => Array.isArray(center) ? center[0] : center.lat; const getLongitude = (center: any) => @@ -166,31 +102,15 @@ export default function StopMap() { cardBackground="bg-white/95 dark:bg-slate-900/90" /> - - - - )} - + ); } diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index 3d0f703..e99cb03 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -3,17 +3,17 @@ import maplibregl, { type StyleSpecification } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import Map, { Layer, Source, type MapRef } from "react-map-gl/maplibre"; +import { Layer, Source, type MapRef } from "react-map-gl/maplibre"; import { useLocation } from "react-router"; import { useApp } from "~/AppContext"; import LineIcon from "~/components/LineIcon"; import { PlannerOverlay } from "~/components/PlannerOverlay"; +import { AppMap } from "~/components/shared/AppMap"; import { APP_CONSTANTS } from "~/config/constants"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { type Itinerary } from "~/data/PlannerApi"; import { usePlanner } from "~/hooks/usePlanner"; -import { DEFAULT_STYLE, loadStyle } from "~/maps/styleloader"; import "../tailwind-full.css"; export interface ConsolidatedCirculation { @@ -371,16 +371,6 @@ const ItineraryDetail = ({ return () => clearTimeout(timer); }, [mapRef.current, itinerary]); - const { theme } = useApp(); - const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); - - useEffect(() => { - const styleName = "openfreemap"; - loadStyle(styleName, theme, { includeTraffic: false }) - .then((style) => setMapStyle(style)) - .catch((error) => console.error("Failed to load map style:", error)); - }, [theme]); - // Fetch next arrivals for bus legs useEffect(() => { const fetchArrivals = async () => { @@ -420,7 +410,7 @@ const ItineraryDetail = ({
{/* Map Section */}
- @@ -565,7 +555,7 @@ const ItineraryDetail = ({ }} /> - +