diff options
Diffstat (limited to 'src/frontend/app/components')
| -rw-r--r-- | src/frontend/app/components/Stops/ConsolidatedCirculationListSkeleton.tsx | 47 | ||||
| -rw-r--r-- | src/frontend/app/components/TimetableSkeleton.tsx | 74 | ||||
| -rw-r--r-- | src/frontend/app/components/UpdateNotification.css | 114 | ||||
| -rw-r--r-- | src/frontend/app/components/shared/AppMap.tsx | 213 | ||||
| -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 |
7 files changed, 253 insertions, 318 deletions
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"> |
