aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-24 19:33:49 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-24 19:33:49 +0100
commitcfbb1625e7873264e2ef435cc76fec2b59cf58d8 (patch)
tree092e04e7750064f5ed1bf6aa2ea625c87877e2e8
parent9ed46bea58dbb81ceada2a957fd1db653fb21e52 (diff)
Refactor map components and improve modal functionality
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs21
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs5
-rw-r--r--src/frontend/app/AppContext.tsx9
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx47
-rw-r--r--src/frontend/app/components/TimetableSkeleton.tsx74
-rw-r--r--src/frontend/app/components/UpdateNotification.css114
-rw-r--r--src/frontend/app/components/shared/AppMap.tsx213
-rw-r--r--src/frontend/app/components/stop/StopHelpModal.tsx (renamed from src/frontend/app/components/StopHelpModal.tsx)0
-rw-r--r--src/frontend/app/components/stop/StopMapModal.css (renamed from src/frontend/app/components/StopMapModal.css)0
-rw-r--r--src/frontend/app/components/stop/StopMapModal.tsx (renamed from src/frontend/app/components/StopMapModal.tsx)123
-rw-r--r--src/frontend/app/config/AppConfig.ts1
-rw-r--r--src/frontend/app/contexts/MapContext.tsx41
-rw-r--r--src/frontend/app/contexts/SettingsContext.tsx52
-rw-r--r--src/frontend/app/hooks/useArrivals.ts2
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json3
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json3
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json3
-rw-r--r--src/frontend/app/routes/map.tsx96
-rw-r--r--src/frontend/app/routes/planner.tsx20
-rw-r--r--src/frontend/app/routes/settings.tsx42
-rw-r--r--src/frontend/app/routes/stops-$id.tsx7
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}