diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-24 19:33:49 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-24 19:33:49 +0100 |
| commit | cfbb1625e7873264e2ef435cc76fec2b59cf58d8 (patch) | |
| tree | 092e04e7750064f5ed1bf6aa2ea625c87877e2e8 /src | |
| parent | 9ed46bea58dbb81ceada2a957fd1db653fb21e52 (diff) | |
Refactor map components and improve modal functionality
Diffstat (limited to 'src')
21 files changed, 378 insertions, 498 deletions
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<string?>.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/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 ( - <SkeletonTheme - baseColor="var(--skeleton-base)" - highlightColor="var(--skeleton-highlight)" - > - <> - <div className="consolidated-circulation-caption"> - <Skeleton width="60%" style={{ maxWidth: "300px" }} /> - </div> - - {[1, 2, 3, 4, 5].map((i) => ( - <div - key={i} - className="consolidated-circulation-card" - style={{ marginBottom: "0.75rem" }} - > - <div className="card-row main"> - <div className="line-info"> - <Skeleton width={40} height={28} borderRadius={4} /> - </div> - - <div className="route-info"> - <Skeleton width="80%" height={18} /> - </div> - - <div className="eta-badge"> - <Skeleton width={50} height={40} borderRadius={12} /> - </div> - </div> - - <div className="card-row meta"> - <Skeleton width={90} height={20} borderRadius={999} /> - <Skeleton width={70} height={20} borderRadius={999} /> - <Skeleton width={60} height={20} borderRadius={999} /> - </div> - </div> - ))} - </> - </SkeletonTheme> - ); -}; 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<TimetableSkeletonProps> = ({ - rows = 4, -}) => { - const { t } = useTranslation(); - - return ( - <SkeletonTheme - baseColor="var(--skeleton-base)" - highlightColor="var(--skeleton-highlight)" - > - <div className="timetable-container"> - <div className="timetable-caption"> - <Skeleton width="250px" height="1.1rem" /> - </div> - - <div className="timetable-cards"> - {Array.from({ length: rows }, (_, index) => ( - <div key={`timetable-skeleton-${index}`} className="timetable-card"> - <div className="card-header"> - <div className="line-info"> - <Skeleton - width="40px" - height="24px" - style={{ borderRadius: "4px" }} - /> - </div> - - <div className="destination-info"> - <Skeleton width="120px" height="0.95rem" /> - </div> - - <div className="time-info"> - <Skeleton - width="60px" - height="1.1rem" - style={{ fontFamily: "monospace" }} - /> - </div> - </div> - - <div className="card-body"> - <div className="route-streets"> - <Skeleton - width="50px" - height="0.8rem" - style={{ - display: "inline-block", - borderRadius: "3px", - marginRight: "0.5rem", - }} - /> - <Skeleton - width="200px" - height="0.85rem" - style={{ display: "inline-block" }} - /> - </div> - </div> - </div> - ))} - </div> - </div> - </SkeletonTheme> - ); -}; 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<MapRef, AppMapProps>( + ( + { + 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<MapRef>(null); + const [mapStyle, setMapStyle] = useState<StyleSpecification>(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 ( + <Map + ref={mapRef} + mapLib={maplibregl as any} + mapStyle={mapStyle} + style={{ width: "100%", height: "100%", ...style }} + initialViewState={viewState} + maxBounds={maxBounds || undefined} + attributionControl={attributionControl} + interactiveLayerIds={interactiveLayerIds} + onClick={onClick} + onMove={onMove} + onDragStart={onDragStart} + onZoomStart={onZoomStart} + onRotateStart={onRotateStart} + onPitchStart={onPitchStart} + onLoad={onLoad} + > + {showNavigation && <NavigationControl position="bottom-right" />} + {showGeolocate && ( + <GeolocateControl + position="bottom-right" + trackUserLocation={true} + positionOptions={{ enableHighAccuracy: false }} + /> + )} + {children} + </Map> + ); + } +); + +AppMap.displayName = "AppMap"; diff --git a/src/frontend/app/components/StopHelpModal.tsx b/src/frontend/app/components/stop/StopHelpModal.tsx index e8157ab..e8157ab 100644 --- a/src/frontend/app/components/StopHelpModal.tsx +++ b/src/frontend/app/components/stop/StopHelpModal.tsx diff --git a/src/frontend/app/components/StopMapModal.css b/src/frontend/app/components/stop/StopMapModal.css index f024b38..f024b38 100644 --- a/src/frontend/app/components/StopMapModal.css +++ b/src/frontend/app/components/stop/StopMapModal.css diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/stop/StopMapModal.tsx index d218af4..2e091b1 100644 --- a/src/frontend/app/components/StopMapModal.tsx +++ b/src/frontend/app/components/stop/StopMapModal.tsx @@ -1,4 +1,3 @@ -import maplibregl from "maplibre-gl"; import React, { useCallback, useEffect, @@ -6,13 +5,13 @@ import React, { useRef, useState, } from "react"; -import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; +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 { loadStyle } from "~/maps/styleloader"; import "./StopMapModal.css"; export interface Position { @@ -52,7 +51,6 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({ selectedCirculationId, }) => { const { theme } = useApp(); - const [styleSpec, setStyleSpec] = useState<any | null>(null); const mapRef = useRef<MapRef | null>(null); const hasFitBounds = useRef(false); const userInteracted = useRef(false); @@ -296,19 +294,6 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({ } 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) { @@ -327,35 +312,11 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({ // 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); + if (!mapRef.current || !isOpen) return; handleCenter(); hasFitBounds.current = true; - - return () => { - if (mapRef.current) { - const map = mapRef.current.getMap(); - if (map) { - map.off("styleimagemissing", handleStyleImageMissing); - } - } - }; - }, [styleSpec, stop, selectedBus, isOpen, handleCenter]); + }, [stop, selectedBus, isOpen, handleCenter]); // Reset bounds when modal opens/closes useEffect(() => { @@ -398,45 +359,42 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({ <div className="stop-map-modal"> {/* Map Container */} <div className="stop-map-modal__map-container"> - {styleSpec && ( - <Map - mapLib={maplibregl as any} - initialViewState={{ - latitude: center.latitude, - longitude: center.longitude, - zoom: 16, - }} - style={{ width: "100%", height: "50vh" }} - mapStyle={styleSpec} - attributionControl={{ - compact: false, - customAttribution: - "Concello de Vigo & Viguesa de Transportes SL", - }} - ref={mapRef} - interactive={true} - onMove={(e) => { - if (e.originalEvent) { - userInteracted.current = true; - } - }} - onDragStart={() => { - userInteracted.current = true; - }} - onZoomStart={() => { - userInteracted.current = true; - }} - onRotateStart={() => { + <AppMap + ref={mapRef} + initialViewState={{ + latitude: center.latitude, + longitude: center.longitude, + zoom: 16, + }} + style={{ width: "100%", height: "50vh" }} + showTraffic={false} + attributionControl={{ + compact: false, + customAttribution: + "Concello de Vigo & Viguesa de Transportes SL", + }} + onMove={(e) => { + if (e.originalEvent) { userInteracted.current = true; - }} - onPitchStart={() => { - userInteracted.current = true; - }} - onLoad={() => { - handleCenter(); - }} - > - {/* Previous Shape Layer */} + } + }} + onDragStart={() => { + userInteracted.current = true; + }} + onZoomStart={() => { + userInteracted.current = true; + }} + onRotateStart={() => { + userInteracted.current = true; + }} + onPitchStart={() => { + userInteracted.current = true; + }} + onLoad={() => { + handleCenter(); + }} + > + {/* Previous Shape Layer */} {previousShapeData && selectedBus && ( <Source id="prev-route-shape" @@ -610,8 +568,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({ </div> </Marker> )} - </Map> - )} + </AppMap> {/* Floating controls */} <div className="map-modal-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<string, { center: LngLatLike; zoom: number }>; 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<MapContextProps | undefined>(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 }) => { <MapContext.Provider value={{ mapState, - setMapCenter, - setMapZoom, setUserLocation, setLocationPermission, updateMapState, diff --git a/src/frontend/app/contexts/SettingsContext.tsx b/src/frontend/app/contexts/SettingsContext.tsx index d66ee52..6a64b67 100644 --- a/src/frontend/app/contexts/SettingsContext.tsx +++ b/src/frontend/app/contexts/SettingsContext.tsx @@ -5,10 +5,9 @@ import { useState, type ReactNode, } from "react"; -import { APP_CONFIG } from "~/config/AppConfig"; +import { APP_CONFIG } from "../config/appConfig"; export type Theme = "light" | "dark" | "system"; -export type TableStyle = "regular" | "grouped" | "experimental_consolidated"; export type MapPositionMode = "gps" | "last"; interface SettingsContextProps { @@ -19,6 +18,11 @@ interface SettingsContextProps { mapPositionMode: MapPositionMode; setMapPositionMode: (mode: MapPositionMode) => void; resolvedTheme: "light" | "dark"; + + showTraffic: boolean; + setShowTraffic: (show: boolean) => void; + showCameras: boolean; + setShowCameras: (show: boolean) => void; } const SettingsContext = createContext<SettingsContextProps | undefined>( @@ -104,26 +108,6 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => { }, [theme]); //#endregion - //#region Table Style - const [tableStyle, setTableStyle] = useState<TableStyle>(() => { - 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<MapPositionMode>( () => { @@ -139,6 +123,26 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => { }, [mapPositionMode]); //#endregion + //#region Map Layers + const [showTraffic, setShowTraffic] = useState<boolean>(() => { + const saved = localStorage.getItem("showTraffic"); + return saved !== null ? saved === "true" : true; + }); + + const [showCameras, setShowCameras] = useState<boolean>(() => { + 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 ( <SettingsContext.Provider value={{ @@ -148,6 +152,10 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => { 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<MapRef>(null); const { searchRoute } = usePlanner(); - // Style state for Map component - const [mapStyle, setMapStyle] = useState<StyleSpecification>(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" /> - <Map - mapStyle={mapStyle} - style={{ width: "100%", height: "100%" }} + <AppMap + ref={mapRef} + syncState={true} + showNavigation={true} + showGeolocate={true} interactiveLayerIds={["stops", "stops-label"]} onClick={onMapClick} - minZoom={5} - scrollZoom - pitch={0} - roll={0} - ref={mapRef} - initialViewState={{ - latitude: getLatitude(mapState.center), - longitude: getLongitude(mapState.center), - zoom: mapState.zoom, - }} attributionControl={{ compact: false }} - maxBounds={[APP_CONSTANTS.bounds.sw, APP_CONSTANTS.bounds.ne]} > - <NavigationControl position="bottom-right" /> - <GeolocateControl - position="bottom-right" - trackUserLocation={true} - positionOptions={{ enableHighAccuracy: false }} - /> - <Source id="stops-source" type="vector" @@ -284,7 +204,7 @@ export default function StopMap() { stop={selectedStop} /> )} - </Map> + </AppMap> </div> ); } 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<StyleSpecification>(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 = ({ <div className="flex flex-col md:flex-row h-full"> {/* Map Section */} <div className="relative h-2/3 md:h-full md:flex-1"> - <Map + <AppMap ref={mapRef} initialViewState={{ longitude: @@ -431,7 +421,7 @@ const ItineraryDetail = ({ (APP_CONSTANTS.defaultCenter as [number, number])[1], zoom: 13, }} - mapStyle={mapStyle} + showTraffic={false} attributionControl={false} > <Source id="route" type="geojson" data={routeGeoJson as any}> @@ -565,7 +555,7 @@ const ItineraryDetail = ({ }} /> </Source> - </Map> + </AppMap> <button onClick={onClose} diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx index c615844..f51b2e9 100644 --- a/src/frontend/app/routes/settings.tsx +++ b/src/frontend/app/routes/settings.tsx @@ -7,7 +7,16 @@ import "../tailwind-full.css"; export default function Settings() { const { t, i18n } = useTranslation(); usePageTitle(t("navbar.settings", "Ajustes")); - const { theme, setTheme, mapPositionMode, setMapPositionMode } = useApp(); + const { + theme, + setTheme, + mapPositionMode, + setMapPositionMode, + showTraffic, + setShowTraffic, + showCameras, + setShowCameras, + } = useApp(); const THEMES = [ { @@ -83,6 +92,37 @@ export default function Settings() { </select> </section> + {/* Map Layers */} + <section className="mb-8"> + <h2 className="text-xl font-semibold mb-4 text-text"> + {t("about.map_layers", "Capas del mapa")} + </h2> + <div className="space-y-4"> + <label className="flex items-center justify-between p-4 rounded-lg border border-border bg-surface cursor-pointer hover:bg-surface/50 transition-colors"> + <span className="text-text font-medium"> + {t("about.show_traffic", "Mostrar tráfico")} + </span> + <input + type="checkbox" + checked={showTraffic} + onChange={(e) => setShowTraffic(e.target.checked)} + className="w-5 h-5 rounded border-border text-primary focus:ring-primary/50" + /> + </label> + <label className="flex items-center justify-between p-4 rounded-lg border border-border bg-surface cursor-pointer hover:bg-surface/50 transition-colors"> + <span className="text-text font-medium"> + {t("about.show_cameras", "Mostrar cámaras")} + </span> + <input + type="checkbox" + checked={showCameras} + onChange={(e) => setShowCameras(e.target.checked)} + className="w-5 h-5 rounded border-border text-primary focus:ring-primary/50" + /> + </label> + </div> + </section> + {/* Language Selection */} <section> <label diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index 9147302..7aba9f2 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -8,9 +8,8 @@ import { ArrivalList } from "~/components/arrivals/ArrivalList"; import { ErrorDisplay } from "~/components/ErrorDisplay"; import LineIcon from "~/components/LineIcon"; import { PullToRefresh } from "~/components/PullToRefresh"; -import { StopHelpModal } from "~/components/StopHelpModal"; -import { StopMapModal } from "~/components/StopMapModal"; -import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton"; +import { StopHelpModal } from "~/components/stop/StopHelpModal"; +import { StopMapModal } from "~/components/stop/StopMapModal"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useAutoRefresh } from "~/hooks/useAutoRefresh"; import StopDataProvider from "../data/StopDataProvider"; @@ -163,7 +162,7 @@ export default function Estimates() { <div className="estimates-list-container"> {dataLoading ? ( - <ConsolidatedCirculationListSkeleton /> + <>{/*TODO: New loading skeleton*/}</> ) : dataError ? ( <ErrorDisplay error={dataError} |
