diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-06-24 13:29:50 +0200 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-06-24 13:29:50 +0200 |
| commit | 894e67863dbb89a4819e825fcdf7117021082b2a (patch) | |
| tree | fb544ef7fa99ff86489717e793595f503783bb72 /src/frontend/app | |
| parent | 7dd9ea97a2f34a35e80c28d59d046f839eb6c60b (diff) | |
Replace leaflet for maplibre, use react-router in framework mode
Diffstat (limited to 'src/frontend/app')
24 files changed, 2412 insertions, 0 deletions
diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx new file mode 100644 index 0000000..7ca85bd --- /dev/null +++ b/src/frontend/app/AppContext.tsx @@ -0,0 +1,243 @@ +/* eslint-disable react-refresh/only-export-components */ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { type LngLatLike } from 'maplibre-gl'; + +type Theme = 'light' | 'dark'; +type TableStyle = 'regular'|'grouped'; +type MapPositionMode = 'gps' | 'last'; + +interface MapState { + center: LngLatLike; + zoom: number; + userLocation: LngLatLike | null; + hasLocationPermission: boolean; +} + +interface AppContextProps { + theme: Theme; + setTheme: React.Dispatch<React.SetStateAction<Theme>>; + toggleTheme: () => void; + + tableStyle: TableStyle; + setTableStyle: React.Dispatch<React.SetStateAction<TableStyle>>; + toggleTableStyle: () => void; + + 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; + + mapPositionMode: MapPositionMode; + setMapPositionMode: (mode: MapPositionMode) => void; +} + +// Coordenadas por defecto centradas en Vigo +const DEFAULT_CENTER: LngLatLike = [42.229188855975046, -8.72246955783102]; +const DEFAULT_ZOOM = 14; + +const AppContext = createContext<AppContextProps | undefined>(undefined); + +export const AppProvider = ({ children }: { children: ReactNode }) => { + //#region Theme + const [theme, setTheme] = useState<Theme>(() => { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme) { + return savedTheme as Theme; + } + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + return prefersDark ? 'dark' : 'light'; + }); + + const toggleTheme = () => { + setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light')); + }; + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + }, [theme]); + //#endregion + + //#region Table Style + const [tableStyle, setTableStyle] = useState<TableStyle>(() => { + const savedTableStyle = localStorage.getItem('tableStyle'); + if (savedTableStyle) { + return savedTableStyle as TableStyle; + } + return 'regular'; + }); + + const toggleTableStyle = () => { + setTableStyle((prevTableStyle) => (prevTableStyle === 'regular' ? 'grouped' : 'regular')); + } + + useEffect(() => { + localStorage.setItem('tableStyle', tableStyle); + }, [tableStyle]); + //#endregion + + //#region Map Position Mode + const [mapPositionMode, setMapPositionMode] = useState<MapPositionMode>(() => { + const saved = localStorage.getItem('mapPositionMode'); + return saved === 'last' ? 'last' : 'gps'; + }); + + useEffect(() => { + localStorage.setItem('mapPositionMode', mapPositionMode); + }, [mapPositionMode]); + //#endregion + + //#region Map State + const [mapState, setMapState] = useState<MapState>(() => { + const savedMapState = localStorage.getItem('mapState'); + if (savedMapState) { + try { + const parsed = JSON.parse(savedMapState); + return { + center: parsed.center || DEFAULT_CENTER, + zoom: parsed.zoom || DEFAULT_ZOOM, + userLocation: parsed.userLocation || null, + hasLocationPermission: parsed.hasLocationPermission || false + }; + } catch (e) { + console.error('Error parsing saved map state', e); + } + } + return { + center: DEFAULT_CENTER, + zoom: DEFAULT_ZOOM, + userLocation: null, + hasLocationPermission: false + }; + }); + + // Helper: check if coordinates are within Vigo bounds + function isWithinVigo(lngLat: LngLatLike): boolean { + let lng: number, lat: number; + if (Array.isArray(lngLat)) { + [lng, lat] = lngLat; + } else if ('lng' in lngLat && 'lat' in lngLat) { + lng = lngLat.lng; + lat = lngLat.lat; + } else { + return false; + } + // Rough bounding box for Vigo + return lat >= 42.18 && lat <= 42.30 && lng >= -8.78 && lng <= -8.65; + } + + // On app load, if mapPositionMode is 'gps', try to get GPS and set map center + useEffect(() => { + if (mapPositionMode === 'gps') { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + const { latitude, longitude } = position.coords; + const coords: LngLatLike = [latitude, longitude]; + if (isWithinVigo(coords)) { + setMapState(prev => { + const newState = { ...prev, center: coords, zoom: 16, userLocation: coords }; + localStorage.setItem('mapState', JSON.stringify(newState)); + return newState; + }); + } + }, + () => { + // Ignore error, fallback to last + } + ); + } + } + // If 'last', do nothing (already loaded from localStorage) + }, [mapPositionMode]); + + 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 }; + localStorage.setItem('mapState', JSON.stringify(newState)); + return newState; + }); + }; + + const setLocationPermission = (hasLocationPermission: boolean) => { + setMapState(prev => { + const newState = { ...prev, hasLocationPermission }; + localStorage.setItem('mapState', JSON.stringify(newState)); + return newState; + }); + }; + + const updateMapState = (center: LngLatLike, zoom: number) => { + setMapState(prev => { + const newState = { ...prev, center, zoom }; + localStorage.setItem('mapState', JSON.stringify(newState)); + return newState; + }); + }; + //#endregion + + // Tratar de obtener la ubicación del usuario cuando se carga la aplicación si ya se había concedido permiso antes + useEffect(() => { + if (mapState.hasLocationPermission && !mapState.userLocation) { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position) => { + const { latitude, longitude } = position.coords; + setUserLocation([latitude, longitude]); + }, + (error) => { + console.error('Error getting location:', error); + setLocationPermission(false); + } + ); + } + } + }, [mapState.hasLocationPermission, mapState.userLocation]); + + return ( + <AppContext.Provider value={{ + theme, + setTheme, + toggleTheme, + tableStyle, + setTableStyle, + toggleTableStyle, + mapState, + setMapCenter, + setMapZoom, + setUserLocation, + setLocationPermission, + updateMapState, + mapPositionMode, + setMapPositionMode + }}> + {children} + </AppContext.Provider> + ); +}; + +export const useApp = () => { + const context = useContext(AppContext); + if (!context) { + throw new Error('useApp must be used within a AppProvider'); + } + return context; +}; diff --git a/src/frontend/app/ErrorBoundary.tsx b/src/frontend/app/ErrorBoundary.tsx new file mode 100644 index 0000000..5c877b7 --- /dev/null +++ b/src/frontend/app/ErrorBoundary.tsx @@ -0,0 +1,46 @@ +import React, { Component, type ReactNode } from 'react'; + +interface ErrorBoundaryProps { + children: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null + }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { + hasError: true, + error + }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("Uncaught error:", error, errorInfo); + } + + render() { + if (this.state.hasError) { + return <> + <h1>Something went wrong.</h1> + <pre> + {this.state.error?.stack} + </pre> + </>; + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/frontend/app/components/GroupedTable.tsx b/src/frontend/app/components/GroupedTable.tsx new file mode 100644 index 0000000..3a16d89 --- /dev/null +++ b/src/frontend/app/components/GroupedTable.tsx @@ -0,0 +1,74 @@ +import { type StopDetails } from "../routes/estimates-$id"; +import LineIcon from "./LineIcon"; + +interface GroupedTable { + data: StopDetails; + dataDate: Date | null; +} + +export const GroupedTable: React.FC<GroupedTable> = ({ data, dataDate }) => { + const formatDistance = (meters: number) => { + if (meters > 1024) { + return `${(meters / 1000).toFixed(1)} km`; + } else { + return `${meters} m`; + } + } + + const groupedEstimates = data.estimates.reduce((acc, estimate) => { + if (!acc[estimate.line]) { + acc[estimate.line] = []; + } + acc[estimate.line].push(estimate); + return acc; + }, {} as Record<string, typeof data.estimates>); + + const sortedLines = Object.keys(groupedEstimates).sort((a, b) => { + const firstArrivalA = groupedEstimates[a][0].minutes; + const firstArrivalB = groupedEstimates[b][0].minutes; + return firstArrivalA - firstArrivalB; + }); + + return <table className="table"> + <caption>Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}</caption> + + <thead> + <tr> + <th>Línea</th> + <th>Ruta</th> + <th>Llegada</th> + <th>Distancia</th> + </tr> + </thead> + + <tbody> + {sortedLines.map((line) => ( + groupedEstimates[line].map((estimate, idx) => ( + <tr key={`${line}-${idx}`}> + {idx === 0 && ( + <td rowSpan={groupedEstimates[line].length}> + <LineIcon line={line} /> + </td> + )} + <td>{estimate.route}</td> + <td>{`${estimate.minutes} min`}</td> + <td> + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : "No disponible" + } + </td> + </tr> + )) + ))} + </tbody> + + {data?.estimates.length === 0 && ( + <tfoot> + <tr> + <td colSpan={4}>No hay estimaciones disponibles</td> + </tr> + </tfoot> + )} + </table> +} diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css new file mode 100644 index 0000000..e7e8949 --- /dev/null +++ b/src/frontend/app/components/LineIcon.css @@ -0,0 +1,239 @@ +:root { + --line-c1: rgb(237, 71, 19); + --line-c3d: rgb(255, 204, 0); + --line-c3i: rgb(255, 204, 0); + --line-l4a: rgb(0, 153, 0); + --line-l4c: rgb(0, 153, 0); + --line-l5a: rgb(0, 176, 240); + --line-l5b: rgb(0, 176, 240); + --line-l6: rgb(204, 51, 153); + --line-l7: rgb(150, 220, 153); + --line-l9b: rgb(244, 202, 140); + --line-l10: rgb(153, 51, 0); + --line-l11: rgb(226, 0, 38); + --line-l12a: rgb(106, 150, 190); + --line-l12b: rgb(106, 150, 190); + --line-l13: rgb(0, 176, 240); + --line-l14: rgb(129, 142, 126); + --line-l15a: rgb(216, 168, 206); + --line-l15b: rgb(216, 168, 206); + --line-l15c: rgb(216, 168, 168); + --line-l16: rgb(129, 142, 126); + --line-l17: rgb(214, 245, 31); + --line-l18a: rgb(212, 80, 168); + --line-l18b: rgb(0, 0, 0); + --line-l18h: rgb(0, 0, 0); + --line-l23: rgb(0, 70, 210); + --line-l24: rgb(191, 191, 191); + --line-l25: rgb(172, 100, 4); + --line-l27: rgb(112, 74, 42); + --line-l28: rgb(176, 189, 254); + --line-l29: rgb(248, 184, 90); + --line-l31: rgb(255, 255, 0); + --line-a: rgb(119, 41, 143); + --line-h: rgb(0, 96, 168); + --line-h1: rgb(0, 96, 168); + --line-h2: rgb(0, 96, 168); + --line-h3: rgb(0, 96, 168); + --line-lzd: rgb(61, 78, 167); + --line-n1: rgb(191, 191, 191); + --line-n4: rgb(102, 51, 102); + --line-psa1: rgb(0, 153, 0); + --line-psa4: rgb(0, 153, 0); + --line-ptl: rgb(150, 220, 153); + --line-turistico: rgb(102, 51, 102); + --line-u1: rgb(172, 100, 4); + --line-u2: rgb(172, 100, 4); +} + +.line-icon { + display: inline-block; + padding: 0.25rem 0.5rem; + margin-right: 0.5rem; + border-bottom: 3px solid; + font-size: 0.9rem; + font-weight: 600; + text-transform: uppercase; + color: inherit; + /* Prevent color change on hover */ +} + +.line-c1 { + border-color: var(--line-c1); +} + +.line-c3d { + border-color: var(--line-c3d); +} + +.line-c3i { + border-color: var(--line-c3i); +} + +.line-l4a { + border-color: var(--line-l4a); +} + +.line-l4c { + border-color: var(--line-l4c); +} + +.line-l5a { + border-color: var(--line-l5a); +} + +.line-l5b { + border-color: var(--line-l5b); +} + +.line-l6 { + border-color: var(--line-l6); +} + +.line-l7 { + border-color: var(--line-l7); +} + +.line-l9b { + border-color: var(--line-l9b); +} + +.line-l10 { + border-color: var(--line-l10); +} + +.line-l11 { + border-color: var(--line-l11); +} + +.line-l12a { + border-color: var(--line-l12a); +} + +.line-l12b { + border-color: var(--line-l12b); +} + +.line-l13 { + border-color: var(--line-l13); +} + +.line-l14 { + border-color: var(--line-l14); +} + +.line-l15a { + border-color: var(--line-l15a); +} + +.line-l15b { + border-color: var(--line-l15b); +} + +.line-l15c { + border-color: var(--line-l15c); +} + +.line-l16 { + border-color: var(--line-l16); +} + +.line-l17 { + border-color: var(--line-l17); +} + +.line-l18a { + border-color: var(--line-l18a); +} + +.line-l18b { + border-color: var(--line-l18b); +} + +.line-l18h { + border-color: var(--line-l18h); +} + +.line-l23 { + border-color: var(--line-l23); +} + +.line-l24 { + border-color: var(--line-l24); +} + +.line-l25 { + border-color: var(--line-l25); +} + +.line-l27 { + border-color: var(--line-l27); +} + +.line-l28 { + border-color: var(--line-l28); +} + +.line-l29 { + border-color: var(--line-l29); +} + +.line-l31 { + border-color: var(--line-l31); +} + +.line-a { + border-color: var(--line-a); +} + +.line-h { + border-color: var(--line-h); +} + +.line-h1 { + border-color: var(--line-h1); +} + +.line-h2 { + border-color: var(--line-h2); +} + +.line-h3 { + border-color: var(--line-h3); +} + +.line-lzd { + border-color: var(--line-lzd); +} + +.line-n1 { + border-color: var(--line-n1); +} + +.line-n4 { + border-color: var(--line-n4); +} + +.line-psa1 { + border-color: var(--line-psa1); +} + +.line-psa4 { + border-color: var(--line-psa4); +} + +.line-ptl { + border-color: var(--line-ptl); +} + +.line-turistico { + border-color: var(--line-turistico); +} + +.line-u1 { + border-color: var(--line-u1); +} + +.line-u2 { + border-color: var(--line-u2); +}
\ No newline at end of file diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx new file mode 100644 index 0000000..291b444 --- /dev/null +++ b/src/frontend/app/components/LineIcon.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import './LineIcon.css'; + +interface LineIconProps { + line: string; +} + +const LineIcon: React.FC<LineIconProps> = ({ line }) => { + const formattedLine = /^[a-zA-Z]/.test(line) ? line : `L${line}`; + return ( + <span className={`line-icon line-${formattedLine.toLowerCase()}`}> + {formattedLine} + </span> + ); +}; + +export default LineIcon; diff --git a/src/frontend/app/components/RegularTable.tsx b/src/frontend/app/components/RegularTable.tsx new file mode 100644 index 0000000..75b598b --- /dev/null +++ b/src/frontend/app/components/RegularTable.tsx @@ -0,0 +1,70 @@ +import { type StopDetails } from "../routes/estimates-$id"; +import LineIcon from "./LineIcon"; + +interface RegularTableProps { + data: StopDetails; + dataDate: Date | null; +} + +export const RegularTable: React.FC<RegularTableProps> = ({ data, dataDate }) => { + + const absoluteArrivalTime = (minutes: number) => { + const now = new Date() + const arrival = new Date(now.getTime() + minutes * 60000) + return Intl.DateTimeFormat(navigator.language, { + hour: '2-digit', + minute: '2-digit' + }).format(arrival) + } + + const formatDistance = (meters: number) => { + if (meters > 1024) { + return `${(meters / 1000).toFixed(1)} km`; + } else { + return `${meters} m`; + } + } + + return <table className="table"> + <caption>Estimaciones de llegadas a las {dataDate?.toLocaleTimeString()}</caption> + + <thead> + <tr> + <th>Línea</th> + <th>Ruta</th> + <th>Llegada</th> + <th>Distancia</th> + </tr> + </thead> + + <tbody> + {data.estimates + .sort((a, b) => a.minutes - b.minutes) + .map((estimate, idx) => ( + <tr key={idx}> + <td><LineIcon line={estimate.line} /></td> + <td>{estimate.route}</td> + <td> + {estimate.minutes > 15 + ? absoluteArrivalTime(estimate.minutes) + : `${estimate.minutes} min`} + </td> + <td> + {estimate.meters > -1 + ? formatDistance(estimate.meters) + : "No disponible" + } + </td> + </tr> + ))} + </tbody> + + {data?.estimates.length === 0 && ( + <tfoot> + <tr> + <td colSpan={4}>No hay estimaciones disponibles</td> + </tr> + </tfoot> + )} + </table> +} diff --git a/src/frontend/app/components/StopItem.css b/src/frontend/app/components/StopItem.css new file mode 100644 index 0000000..9feb2d1 --- /dev/null +++ b/src/frontend/app/components/StopItem.css @@ -0,0 +1,54 @@ +/* Stop Item Styling */ + +.stop-notes { + font-size: 0.85rem; + font-style: italic; + color: #666; + margin: 2px 0; +} + +.stop-amenities { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.amenity-tag { + font-size: 0.75rem; + background-color: #e8f4f8; + color: #0078d4; + border-radius: 4px; + padding: 2px 6px; + display: inline-block; +} + +/* Different colors for different amenity types */ +.amenity-tag[data-amenity="shelter"] { + background-color: #e3f1df; + color: #107c41; +} + +.amenity-tag[data-amenity="bench"] { + background-color: #f0e8fc; + color: #5c2e91; +} + +.amenity-tag[data-amenity="real-time display"] { + background-color: #fff4ce; + color: #986f0b; +} + +/* When there are alternate names available, show an indicator */ +.has-alternate-names { + position: relative; +} + +.has-alternate-names::after { + content: "⋯"; + position: absolute; + right: -15px; + top: 0; + color: #0078d4; + font-weight: bold; +}
\ No newline at end of file diff --git a/src/frontend/app/components/StopItem.tsx b/src/frontend/app/components/StopItem.tsx new file mode 100644 index 0000000..29370b7 --- /dev/null +++ b/src/frontend/app/components/StopItem.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router'; +import StopDataProvider, { type Stop } from '../data/StopDataProvider'; +import LineIcon from './LineIcon'; + +interface StopItemProps { + stop: Stop; +} + +const StopItem: React.FC<StopItemProps> = ({ stop }) => { + + return ( + <li className="list-item"> + <Link className="list-item-link" to={`/estimates/${stop.stopId}`}> + {stop.favourite && <span className="favourite-icon">★</span>} ({stop.stopId}) {StopDataProvider.getDisplayName(stop)} + <div className="line-icons"> + {stop.lines?.map(line => <LineIcon key={line} line={line} />)} + </div> + + </Link> + </li> + ); +}; + +export default StopItem; diff --git a/src/frontend/app/controls/LocateControl.ts b/src/frontend/app/controls/LocateControl.ts new file mode 100644 index 0000000..26effa5 --- /dev/null +++ b/src/frontend/app/controls/LocateControl.ts @@ -0,0 +1,67 @@ +import { createControlComponent } from '@react-leaflet/core'; +import { LocateControl as LeafletLocateControl, type LocateOptions } from 'leaflet.locatecontrol'; +import "leaflet.locatecontrol/dist/L.Control.Locate.min.css"; +import { useEffect } from 'react'; +import { useMap } from 'react-leaflet'; +import { useApp } from '../AppContext'; + +interface EnhancedLocateControlProps { + options?: LocateOptions; +} + +// Componente que usa el contexto para manejar la localización +export const EnhancedLocateControl = (props: EnhancedLocateControlProps) => { + const map = useMap(); + const { mapState, setUserLocation, setLocationPermission } = useApp(); + + useEffect(() => { + // Configuración por defecto del control de localización + const defaultOptions: LocateOptions = { + position: 'topright', + strings: { + title: 'Mostrar mi ubicación', + }, + flyTo: true, + onLocationError: (err) => { + console.error('Error en la localización:', err); + setLocationPermission(false); + }, + returnToPrevBounds: true, + showPopup: false, + }; + + // Combinamos las opciones por defecto con las personalizadas + const options = { ...defaultOptions, ...props.options }; + + // Creamos la instancia del control + const locateControl = new LeafletLocateControl(options); + + // Añadimos el control al mapa + locateControl.addTo(map); + + // Si tenemos permiso de ubicación y ya conocemos la ubicación del usuario, + // podemos activarla automáticamente + if (mapState.hasLocationPermission && mapState.userLocation) { + // Esperamos a que el mapa esté listo + setTimeout(() => { + try { + locateControl.start(); + } catch (e) { + console.error('Error al iniciar la localización automática', e); + } + }, 1000); + } + + return () => { + // Limpieza al desmontar el componente + locateControl.remove(); + }; + }, [map, mapState.hasLocationPermission, mapState.userLocation, props.options, setLocationPermission, setUserLocation]); + + return null; +}; + +// Exportamos también el control base por compatibilidad +export const LocateControl = createControlComponent( + (props) => new LeafletLocateControl(props) +); diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts new file mode 100644 index 0000000..0c1e46e --- /dev/null +++ b/src/frontend/app/data/StopDataProvider.ts @@ -0,0 +1,160 @@ +export interface CachedStopList { + timestamp: number; + data: Stop[]; +} + +export type StopName = { + original: string; + intersect?: string; +} + +export interface Stop { + stopId: number; + name: StopName; + latitude?: number; + longitude?: number; + lines: string[]; + favourite?: boolean; +} + +// In-memory cache and lookup map +let cachedStops: Stop[] | null = null; +let stopsMap: Record<number, Stop> = {}; +// Custom names loaded from localStorage +let customNames: Record<number, string> = {}; + +// Initialize cachedStops and customNames once +async function initStops() { + if (!cachedStops) { + const response = await fetch('/stops.json'); + const stops = await response.json() as Stop[]; + // build array and map + stopsMap = {}; + cachedStops = stops.map(stop => { + const entry = { ...stop, favourite: false } as Stop; + stopsMap[stop.stopId] = entry; + return entry; + }); + // load custom names + const rawCustom = localStorage.getItem('customStopNames'); + if (rawCustom) customNames = JSON.parse(rawCustom) as Record<number, string>; + } +} + +async function getStops(): Promise<Stop[]> { + await initStops(); + // update favourites + const rawFav = localStorage.getItem('favouriteStops'); + const favouriteStops = rawFav ? JSON.parse(rawFav) as number[] : []; + cachedStops!.forEach(stop => stop.favourite = favouriteStops.includes(stop.stopId)); + return cachedStops!; +} + +// New: get single stop by id +async function getStopById(stopId: number): Promise<Stop | undefined> { + await initStops(); + const stop = stopsMap[stopId]; + if (stop) { + const rawFav = localStorage.getItem('favouriteStops'); + const favouriteStops = rawFav ? JSON.parse(rawFav) as number[] : []; + stop.favourite = favouriteStops.includes(stopId); + } + return stop; +} + +// Updated display name to include custom names +function getDisplayName(stop: Stop): string { + if (customNames[stop.stopId]) return customNames[stop.stopId]; + const nameObj = stop.name; + return nameObj.intersect || nameObj.original; +} + +// New: set or remove custom names +function setCustomName(stopId: number, label: string) { + customNames[stopId] = label; + localStorage.setItem('customStopNames', JSON.stringify(customNames)); +} + +function removeCustomName(stopId: number) { + delete customNames[stopId]; + localStorage.setItem('customStopNames', JSON.stringify(customNames)); +} + +// New: get custom label for a stop +function getCustomName(stopId: number): string | undefined { + return customNames[stopId]; +} + +function addFavourite(stopId: number) { + const rawFavouriteStops = localStorage.getItem('favouriteStops'); + let favouriteStops: number[] = []; + if (rawFavouriteStops) { + favouriteStops = JSON.parse(rawFavouriteStops) as number[]; + } + + if (!favouriteStops.includes(stopId)) { + favouriteStops.push(stopId); + localStorage.setItem('favouriteStops', JSON.stringify(favouriteStops)); + } +} + +function removeFavourite(stopId: number) { + const rawFavouriteStops = localStorage.getItem('favouriteStops'); + let favouriteStops: number[] = []; + if (rawFavouriteStops) { + favouriteStops = JSON.parse(rawFavouriteStops) as number[]; + } + + const newFavouriteStops = favouriteStops.filter(id => id !== stopId); + localStorage.setItem('favouriteStops', JSON.stringify(newFavouriteStops)); +} + +function isFavourite(stopId: number): boolean { + const rawFavouriteStops = localStorage.getItem('favouriteStops'); + if (rawFavouriteStops) { + const favouriteStops = JSON.parse(rawFavouriteStops) as number[]; + return favouriteStops.includes(stopId); + } + return false; +} + +const RECENT_STOPS_LIMIT = 10; + +function pushRecent(stopId: number) { + const rawRecentStops = localStorage.getItem('recentStops'); + let recentStops: Set<number> = new Set(); + if (rawRecentStops) { + recentStops = new Set(JSON.parse(rawRecentStops) as number[]); + } + + recentStops.add(stopId); + if (recentStops.size > RECENT_STOPS_LIMIT) { + const iterator = recentStops.values(); + const val = iterator.next().value as number; + recentStops.delete(val); + } + + localStorage.setItem('recentStops', JSON.stringify(Array.from(recentStops))); +} + +function getRecent(): number[] { + const rawRecentStops = localStorage.getItem('recentStops'); + if (rawRecentStops) { + return JSON.parse(rawRecentStops) as number[]; + } + return []; +} + +export default { + getStops, + getStopById, + getCustomName, + getDisplayName, + setCustomName, + removeCustomName, + addFavourite, + removeFavourite, + isFavourite, + pushRecent, + getRecent +}; diff --git a/src/frontend/app/maps/styleloader.ts b/src/frontend/app/maps/styleloader.ts new file mode 100644 index 0000000..f00aacc --- /dev/null +++ b/src/frontend/app/maps/styleloader.ts @@ -0,0 +1,49 @@ +import type { StyleSpecification } from "react-map-gl/maplibre"; + +export async function loadStyle(styleName: string, colorScheme: string): Promise<StyleSpecification> { + const stylePath = `/maps/styles/${styleName}-${colorScheme}.json`; + const resp = await fetch(stylePath); + + if (!resp.ok) { + throw new Error(`Failed to load style: ${stylePath}`); + } + + const style = await resp.json(); + + const baseUrl = window.location.origin; + const spritePath = style.sprite; + + // Handle both string and array cases for spritePath + if (Array.isArray(spritePath)) { + // For array format, update each sprite object's URL to be absolute + style.sprite = spritePath.map(spriteObj => { + const isAbsoluteUrl = spriteObj.url.startsWith("http://") || spriteObj.url.startsWith("https://"); + if (isAbsoluteUrl) { + return spriteObj; + } + + return { + ...spriteObj, + url: `${baseUrl}${spriteObj.url}` + }; + }); + } else if (typeof spritePath === "string") { + if (!spritePath.startsWith("http://") && !spritePath.startsWith("https://")) { + style.sprite = `${baseUrl}${spritePath}`; + } + } + + // Detect on each source if it the 'tiles' URLs are relative and convert them to absolute URLs + for (const sourceKey in style.sources) { + const source = style.sources[sourceKey]; + for (const tileKey in source.tiles) { + const tileUrl = source.tiles[tileKey]; + const isAbsoluteUrl = tileUrl.startsWith("http://") || tileUrl.startsWith("https://"); + if (!isAbsoluteUrl) { + source.tiles[tileKey] = `${baseUrl}${tileUrl}`; + } + } + } + + return style as StyleSpecification; +} diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css new file mode 100644 index 0000000..689b48e --- /dev/null +++ b/src/frontend/app/root.css @@ -0,0 +1,126 @@ +:root { + --colour-scheme: light; + --background-color: #ffffff; + --text-color: #333333; + --subtitle-color: #444444; + --border-color: #eeeeee; + --button-background-color: #007bff; + --button-hover-background-color: #0069d9; + --button-disabled-background-color: #cccccc; + --star-color: #ffcc00; + --message-background-color: #f8f9fa; + + font-family: 'Roboto Variable', Roboto, Arial, sans-serif; +} + +[data-theme='dark'] { + --colour-scheme: dark; + --background-color: #121212; + --text-color: #ffffff; + --subtitle-color: #bbbbbb; + --border-color: #444444; + --button-background-color: #1e88e5; + --button-hover-background-color: #1565c0; + --button-disabled-background-color: #555555; + --star-color: #ffcc00; + --message-background-color: #333333; +} + +body { + color-scheme: var(--colour-scheme, light); + + margin: 0; + padding: 0; + box-sizing: border-box; + + background-color: var(--background-color); + + display: flex; + flex-direction: column; + height: 100vh; + width: 100%; + overflow: hidden; +} + +.main-content { + flex: 1; + overflow: auto; +} + +.navigation-bar { + display: flex; + justify-content: space-around; + align-items: center; + padding: 0.5rem 0; + + background-color: var(--background-color); + border-top: 1px solid var(--border-color); +} + +.navigation-bar__link { + flex: 1 0; + display: flex; + flex-direction: column; + align-items: center; + text-decoration: none; + color: var(--text-color); + padding: .25rem 0; + border-radius: .5rem; +} + +.navigation-bar__link svg { + width: 1.75rem; + height: 1.75rem; + margin-bottom: 5px; + fill: none; + stroke-width: 2; +} + +.navigation-bar__link span { + font-size: 14px; + line-height: 1; +} + +.navigation-bar__link.active { + color: var(--button-background-color); +} + +.theme-toggle { + background: none; + border: none; + cursor: pointer; + color: inherit; + display: flex; + align-items: center; + justify-content: center; + padding: 8px; +} + +.theme-toggle:hover { + color: var(--button-hover-background-color); +} + +.page-container { + max-width: 100%; + padding: 0 16px; + background-color: var(--background-color); + color: var(--text-color); +} + +@media (min-width: 768px) { + .page-container { + width: 90%; + max-width: 768px; + margin: 0 auto; + } + + .page-title { + font-size: 2.2rem; + } +} + +@media (min-width: 1024px) { + .page-container { + max-width: 1024px; + } +} diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx new file mode 100644 index 0000000..d90dba0 --- /dev/null +++ b/src/frontend/app/root.tsx @@ -0,0 +1,161 @@ +import { + isRouteErrorResponse, + Link, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration +} from "react-router"; + +import type { Route } from "./+types/root"; +import "@fontsource-variable/roboto"; +import "./root.css"; + +//#region Maplibre setup +import "maplibre-theme/icons.default.css"; +import "maplibre-theme/modern.css"; +import { Protocol } from "pmtiles"; +import maplibregl from "maplibre-gl"; +import { AppProvider } from "./AppContext"; +import { Map, MapPin, Settings } from "lucide-react"; +const pmtiles = new Protocol(); +maplibregl.addProtocol("pmtiles", pmtiles.tile); +//#endregion + +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js') + .then((registration) => { + console.log('Service Worker registered with scope:', registration.scope); + }) + .catch((error) => { + console.error('Service Worker registration failed:', error); + }); +} + +export const links: Route.LinksFunction = () => []; + +export function HydrateFallback() { + return "Loading..."; +} + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + <html lang="es"> + <head> + <meta charSet="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + + <link rel="icon" type="image/jpg" href="/logo-512.jpg" /> + <link rel="icon" href="/favicon.ico" sizes="64x64" /> + <link rel="apple-touch-icon" href="/logo-512.jpg" sizes="512x512" /> + <meta name="theme-color" content="#007bff" /> + + <link rel="canonical" href="https://urbanovigo.costas.dev/" /> + + <meta name="description" content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España." /> + <meta name="keywords" content="Vigo, autobús, urbano, parada, tiempo, llegada, transporte, público, España" /> + <meta name="author" content="Ariel Costas Guerrero" /> + + <meta property="og:title" content="UrbanoVigo Web" /> + <meta property="og:type" content="website" /> + <meta property="og:url" content="https://urbanovigo.costas.dev/" /> + <meta property="og:image" content="https://urbanovigo.costas.dev/logo-512.jpg" /> + <meta property="og:description" content="Aplicación web para encontrar paradas y tiempos de llegada de los autobuses urbanos de Vigo, España." /> + + <link rel="manifest" href="/manifest.webmanifest" /> + + <meta name="robots" content="noindex, nofollow" /> + <meta name="googlebot" content="noindex, nofollow" /> + + <title>Busurbano</title> + <Meta /> + <Links /> + </head> + <body> + {children} + <ScrollRestoration /> + <Scripts /> + </body> + </html> + ); +} + +export default function App() { + const navItems = [ + { + name: 'Paradas', + icon: MapPin, + path: '/stops' + }, + { + name: 'Mapa', + icon: Map, + path: '/map' + }, + { + name: 'Ajustes', + icon: Settings, + path: '/settings' + } + ]; + + return ( + <AppProvider> + <main className="main-content"> + <Outlet /> + </main> + <footer> + <nav className="navigation-bar"> + {navItems.map(item => { + const Icon = item.icon; + const isActive = location.pathname.startsWith(item.path); + + return ( + <Link + key={item.name} + to={item.path} + className={`navigation-bar__link ${isActive ? 'active' : ''}`} + > + <Icon size={24} /> + <span>{item.name}</span> + </Link> + ); + })} + </nav> + </footer> + </AppProvider> + + + + ); +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = "Oops!"; + let details = "An unexpected error occurred."; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? "404" : "Error"; + details = + error.status === 404 + ? "The requested page could not be found." + : error.statusText || details; + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message; + stack = error.stack; + } + + return ( + <main> + <h1>{message}</h1> + <p>{details}</p> + {stack && ( + <pre> + <code>{stack}</code> + </pre> + )} + </main> + ); +} diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx new file mode 100644 index 0000000..1bca5e8 --- /dev/null +++ b/src/frontend/app/routes.tsx @@ -0,0 +1,9 @@ +import { type RouteConfig, index, route } from "@react-router/dev/routes"; + +export default [ + index("routes/index.tsx"), + route("/stops", "routes/stoplist.tsx"), + route("/map", "routes/map.tsx"), + route("/estimates/:id", "routes/estimates-$id.tsx"), + route("/settings", "routes/settings.tsx") +] satisfies RouteConfig; diff --git a/src/frontend/app/routes/estimates-$id.css b/src/frontend/app/routes/estimates-$id.css new file mode 100644 index 0000000..86ca09b --- /dev/null +++ b/src/frontend/app/routes/estimates-$id.css @@ -0,0 +1,105 @@ +.table-responsive { + overflow-x: auto; + margin-bottom: 1.5rem; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table caption { + margin-bottom: 0.5rem; + font-weight: 500; +} + +.table th, +.table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid #eee; +} + +.table th { + border-bottom: 2px solid #ddd; +} + +.table tfoot td { + text-align: center; +} + +/* Estimates page specific styles */ +.estimates-header { + display: flex; + align-items: center; + margin-bottom: 1rem; +} + +.estimates-stop-id { + font-size: 1rem; + color: var(--subtitle-color); + margin-left: 0.5rem; +} + +.estimates-arrival { + color: #28a745; + font-weight: 500; +} + +.estimates-delayed { + color: #dc3545; +} + +.button-group { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.button { + padding: 0.75rem 1rem; + background-color: var(--button-background-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + text-align: center; + text-decoration: none; + display: inline-block; +} + +.button:hover { + background-color: var(--button-hover-background-color); +} + +.button:disabled { + background-color: var(--button-disabled-background-color); + cursor: not-allowed; +} + +.star-icon { + margin-right: 0.5rem; + color: #ccc; + fill: none; +} + +.star-icon.active { + color: var(--star-color); + /* Yellow color for active star */ + fill: var(--star-color); +} + +/* Pencil (edit) icon next to header */ +.edit-icon { + margin-right: 0.5rem; + color: #ccc; + cursor: pointer; + stroke-width: 2px; +} + +.edit-icon:hover { + color: var(--star-color); +}
\ No newline at end of file diff --git a/src/frontend/app/routes/estimates-$id.tsx b/src/frontend/app/routes/estimates-$id.tsx new file mode 100644 index 0000000..761a8d4 --- /dev/null +++ b/src/frontend/app/routes/estimates-$id.tsx @@ -0,0 +1,103 @@ +import { type JSX, useEffect, useState } from "react"; +import { useParams } from "react-router"; +import StopDataProvider from "../data/StopDataProvider"; +import { Star, Edit2 } from 'lucide-react'; +import "./estimates-$id.css"; +import { RegularTable } from "../components/RegularTable"; +import { useApp } from "../AppContext"; +import { GroupedTable } from "../components/GroupedTable"; + +export interface StopDetails { + stop: { + id: number; + name: string; + latitude: number; + longitude: number; + } + estimates: { + line: string; + route: string; + minutes: number; + meters: number; + }[] +} + +const loadData = async (stopId: string) => { + const resp = await fetch(`/api/GetStopEstimates?id=${stopId}`, { + headers: { + 'Accept': 'application/json', + } + }); + return await resp.json(); +}; + +export default function Estimates() { + const params = useParams(); + const stopIdNum = parseInt(params.id ?? ""); + const [customName, setCustomName] = useState<string | undefined>(undefined); + const [data, setData] = useState<StopDetails | null>(null); + const [dataDate, setDataDate] = useState<Date | null>(null); + const [favourited, setFavourited] = useState(false); + const { tableStyle } = useApp(); + + useEffect(() => { + loadData(params.id!) + .then((body: StopDetails) => { + setData(body); + setDataDate(new Date()); + setCustomName(StopDataProvider.getCustomName(stopIdNum)); + }) + + + StopDataProvider.pushRecent(parseInt(params.id ?? "")); + + setFavourited( + StopDataProvider.isFavourite(parseInt(params.id ?? "")) + ); + }, [params.id]); + + + const toggleFavourite = () => { + if (favourited) { + StopDataProvider.removeFavourite(stopIdNum); + setFavourited(false); + } else { + StopDataProvider.addFavourite(stopIdNum); + setFavourited(true); + } + } + + const handleRename = () => { + const current = customName ?? data?.stop.name; + const input = window.prompt('Custom name for this stop:', current); + if (input === null) return; // cancelled + const trimmed = input.trim(); + if (trimmed === '') { + StopDataProvider.removeCustomName(stopIdNum); + setCustomName(undefined); + } else { + StopDataProvider.setCustomName(stopIdNum, trimmed); + setCustomName(trimmed); + } + }; + + if (data === null) return <h1 className="page-title">Cargando datos en tiempo real...</h1> + + return ( + <div className="page-container"> + <div className="estimates-header"> + <h1 className="page-title"> + <Star className={`star-icon ${favourited ? 'active' : ''}`} onClick={toggleFavourite} /> + <Edit2 className="edit-icon" onClick={handleRename} /> + {(customName ?? data.stop.name)} <span className="estimates-stop-id">({data.stop.id})</span> + </h1> + </div> + + <div className="table-responsive"> + {tableStyle === 'grouped' ? + <GroupedTable data={data} dataDate={dataDate} /> : + <RegularTable data={data} dataDate={dataDate} />} + </div> + </div> + ) +} diff --git a/src/frontend/app/routes/index.tsx b/src/frontend/app/routes/index.tsx new file mode 100644 index 0000000..7c8ab40 --- /dev/null +++ b/src/frontend/app/routes/index.tsx @@ -0,0 +1,5 @@ +import { Navigate, redirect, type LoaderFunction } from "react-router"; + +export default function Index() { + return <Navigate to={"/stops"} replace />; +} diff --git a/src/frontend/app/routes/map.css b/src/frontend/app/routes/map.css new file mode 100644 index 0000000..3af112a --- /dev/null +++ b/src/frontend/app/routes/map.css @@ -0,0 +1,86 @@ +/* Map page specific styles */ +.map-container { + height: calc(100vh - 140px); + margin: -16px; + margin-bottom: 1rem; + position: relative; +} + +/* Fullscreen map styles */ +.fullscreen-container { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + padding: 0; + margin: 0; + max-width: none; + overflow: hidden; +} + +.fullscreen-map { + width: 100%; + height: 100%; +} + +.fullscreen-loading { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; + font-size: 1.8rem; + font-weight: 600; + color: var(--text-color); +} + +/* Map marker and popup styles */ +.stop-marker { + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease-in-out; +} + +.stop-marker:hover { + transform: scale(1.2); +} + +.maplibregl-popup { + max-width: 250px; +} + +.maplibregl-popup-content { + padding: 12px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.popup-line-icons { + display: flex; + flex-wrap: wrap; + margin: 6px 0; + gap: 5px; +} + +.popup-line { + display: inline-block; + background-color: var(--button-background-color); + color: white; + padding: 2px 6px; + margin-right: 4px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; +} + +.popup-link { + display: block; + margin-top: 8px; + color: var(--button-background-color); + text-decoration: none; + font-weight: 500; +} + +.popup-link:hover { + text-decoration: underline; +}
\ No newline at end of file diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx new file mode 100644 index 0000000..a938148 --- /dev/null +++ b/src/frontend/app/routes/map.tsx @@ -0,0 +1,167 @@ +import StopDataProvider from "../data/StopDataProvider"; +import './map.css'; + +import { useEffect, useRef, useState } from 'react'; +import { useApp } from "../AppContext"; +import Map, { AttributionControl, GeolocateControl, Layer, NavigationControl, Popup, Source, type MapRef, type MapLayerMouseEvent, type StyleSpecification } from "react-map-gl/maplibre"; +import { loadStyle } from "app/maps/styleloader"; +import type { Feature as GeoJsonFeature, Point } from 'geojson'; +import LineIcon from "~/components/LineIcon"; +import { Link } from "react-router"; + +// Default minimal fallback style before dynamic loading +const defaultStyle: StyleSpecification = { + version: 8, + glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`, + sprite: `${window.location.origin}/maps/spritesheet/sprite`, + sources: {}, + layers: [] +}; + +// Componente principal del mapa +export default function StopMap() { + const [stops, setStops] = useState<GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[]>([]); + const [popupInfo, setPopupInfo] = useState<any>(null); + const { mapState, updateMapState, theme } = useApp(); + const mapRef = useRef<MapRef>(null); + const [mapStyleKey, setMapStyleKey] = useState<string>("light"); + + // Style state for Map component + const [mapStyle, setMapStyle] = useState<StyleSpecification>(defaultStyle); + + // Handle click events on clusters and individual stops + const onMapClick = (e: MapLayerMouseEvent) => { + const features = e.features; + if (!features || features.length === 0) return; + const feature = features[0]; + const props: any = feature.properties; + + handlePointClick(feature); + }; + + useEffect(() => { + StopDataProvider.getStops().then(data => { + const features: GeoJsonFeature<Point, { stopId: number; name: string; lines: string[] }>[] = data.map(s => ({ + type: "Feature", + geometry: { type: "Point", coordinates: [s.longitude as number, s.latitude as number] }, + properties: { stopId: s.stopId, name: s.name.original, lines: s.lines } + })); + setStops(features); + }); + }, []); + + useEffect(() => { + const styleName = "carto"; + loadStyle(styleName, theme) + .then(style => setMapStyle(style)) + .catch(error => console.error("Failed to load map style:", error)); + }, [mapStyleKey, 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); + }; + + if (mapRef.current) { + const map = mapRef.current.getMap(); + if (map) { + map.on('moveend', handleMapChange); + } + } + + return () => { + if (mapRef.current) { + const map = mapRef.current.getMap(); + if (map) { + map.off('moveend', handleMapChange); + } + } + }; + }, [mapRef.current]); + + const getLatitude = (center: any) => Array.isArray(center) ? center[0] : center.lat; + const getLongitude = (center: any) => Array.isArray(center) ? center[1] : center.lng; + + const handlePointClick = (feature: any) => { + const props: any = feature.properties; + // fetch full stop to get lines array + StopDataProvider.getStopById(props.stopId).then(stop => { + if (!stop) return; + setPopupInfo({ + geometry: feature.geometry, + properties: { + stopId: stop.stopId, + name: stop.name.original, + lines: stop.lines + } + }); + }); + }; + + return ( + <Map + mapStyle={mapStyle} + style={{ width: '100%', height: '100%' }} + interactiveLayerIds={["stops"]} + onClick={onMapClick} + minZoom={11} + scrollZoom + pitch={0} + roll={0} + ref={mapRef} + initialViewState={{ + latitude: getLatitude(mapState.center), + longitude: getLongitude(mapState.center), + zoom: mapState.zoom, + }} + attributionControl={false} + > + <NavigationControl position="top-right" /> + <GeolocateControl position="top-right" trackUserLocation={true} /> + <AttributionControl position="bottom-right" compact={false} /> + + <Source + id="stops-source" + type="geojson" + data={{ type: "FeatureCollection", features: stops }} + /> + + <Layer + id="stops" + type="symbol" + source="stops-source" + layout={{ + "icon-image": "stop", + "icon-size": ["interpolate", ["linear"], ["zoom"], 11, 0.4, 16, 0.8], + "icon-allow-overlap": true, + "icon-ignore-placement": true, + }} + /> + + {popupInfo && ( + <Popup + latitude={popupInfo.geometry.coordinates[1]} + longitude={popupInfo.geometry.coordinates[0]} + onClose={() => setPopupInfo(null)} + > + <div> + <h3>{popupInfo.properties.name}</h3> + <div> + {popupInfo.properties.lines.map((line: string) => ( + <LineIcon line={line} key={line} /> + ))} + </div> + <Link to={`/estimates/${popupInfo.properties.stopId}`} className="popup-link"> + Ver parada + </Link> + </div> + </Popup> + )} + </Map> + ); +} diff --git a/src/frontend/app/routes/settings.css b/src/frontend/app/routes/settings.css new file mode 100644 index 0000000..8c612d3 --- /dev/null +++ b/src/frontend/app/routes/settings.css @@ -0,0 +1,94 @@ +/* About page specific styles */ +.about-page { + text-align: center; + padding: 1rem; +} + +.about-version { + color: var(--subtitle-color); + font-size: 0.9rem; + margin-top: 2rem; +} + +.about-description { + margin-top: 1rem; + line-height: 1.6; +} + +.settings-section { + margin-bottom: 2em; + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: var(--message-background-color); + text-align: left; +} + +.settings-section h2 { + margin-bottom: 1em; +} + +.settings-content { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 1em; +} + +.settings-content-inline { + display: flex; + align-items: center; + margin-bottom: 1em; +} + +.settings-section .form-button { + margin-bottom: 1em; + padding: 0.75rem 1.5rem; + font-size: 1.1rem; +} + +.settings-section .form-select-inline { + margin-left: 0.5em; + padding: 0.5rem; + font-size: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.settings-section .form-label-inline { + font-weight: 500; +} + +.settings-section .form-label { + display: block; + margin-bottom: 0.5em; + font-weight: 500; +} + +.settings-section .form-description { + margin-top: 0.5em; + font-size: 0.9rem; + color: var(--subtitle-color); +} + +.settings-section .form-details { + margin-top: 0.5em; + font-size: 0.9rem; + color: var(--subtitle-color); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.5rem; +} + +.settings-section .form-details summary { + cursor: pointer; + font-weight: 500; +} + +.settings-section .form-details p { + margin-top: 0.5em; +} + +.settings-section p { + margin-top: 0.5em; +} diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx new file mode 100644 index 0000000..b5e91f1 --- /dev/null +++ b/src/frontend/app/routes/settings.tsx @@ -0,0 +1,65 @@ +import { useApp } from "../AppContext"; +import "./settings.css"; + +export default function Settings() { + const { theme, setTheme, tableStyle, setTableStyle, mapPositionMode, setMapPositionMode } = useApp(); + + return ( + <div className="page-container"> + <h1 className="page-title">Sobre UrbanoVigo Web</h1> + <p className="about-description"> + Aplicación web para encontrar paradas y tiempos de llegada de los autobuses + urbanos de Vigo, España. + </p> + <section className="settings-section"> + <h2>Ajustes</h2> + <div className="settings-content-inline"> + <label htmlFor="theme" className="form-label-inline">Modo:</label> + <select id="theme" className="form-select-inline" value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}> + <option value="light">Claro</option> + <option value="dark">Oscuro</option> + </select> + </div> + <div className="settings-content-inline"> + <label htmlFor="tableStyle" className="form-label-inline">Estilo de tabla:</label> + <select id="tableStyle" className="form-select-inline" value={tableStyle} onChange={(e) => setTableStyle(e.target.value as "regular" | "grouped")}> + <option value="regular">Mostrar por orden</option> + <option value="grouped">Agrupar por línea</option> + </select> + </div> + <div className="settings-content-inline"> + <label htmlFor="mapPositionMode" className="form-label-inline">Posición del mapa:</label> + <select id="mapPositionMode" className="form-select-inline" value={mapPositionMode} onChange={e => setMapPositionMode(e.target.value as 'gps' | 'last')}> + <option value="gps">Posición GPS</option> + <option value="last">Donde lo dejé</option> + </select> + </div> + <details className="form-details"> + <summary>¿Qué significa esto?</summary> + <p> + La tabla de horarios puede mostrarse de dos formas: + </p> + <dl> + <dt>Mostrar por orden</dt> + <dd>Las paradas se muestran en el orden en que se visitan. Aplicaciones como Infobus (Vitrasa) usan este estilo.</dd> + <dt>Agrupar por línea</dt> + <dd>Las paradas se agrupan por la línea de autobús. Aplicaciones como iTranvias (A Coruña) o Moovit (más o menos) usan este estilo.</dd> + </dl> + </details> + </section> + <h2>Créditos</h2> + <p> + <a href="https://github.com/arielcostas/urbanovigo-web" className="about-link" rel="nofollow noreferrer noopener"> + Código en GitHub + </a> - + Desarrollado por <a href="https://www.costas.dev" className="about-link" rel="nofollow noreferrer noopener"> + Ariel Costas + </a> + </p> + <p> + Datos obtenidos de <a href="https://datos.vigo.org" className="about-link" rel="nofollow noreferrer noopener">datos.vigo.org</a> bajo + licencia <a href="https://opendefinition.org/licenses/odc-by/" className="about-link" rel="nofollow noreferrer noopener">Open Data Commons Attribution License</a> + </p> + </div> + ) +} diff --git a/src/frontend/app/routes/stoplist.css b/src/frontend/app/routes/stoplist.css new file mode 100644 index 0000000..d65e048 --- /dev/null +++ b/src/frontend/app/routes/stoplist.css @@ -0,0 +1,310 @@ +/* Common page styles */ +.page-title { + font-size: 1.8rem; + margin-bottom: 1rem; + font-weight: 600; + color: var(--text-color); +} + +.page-subtitle { + font-size: 1.4rem; + margin-top: 1.5rem; + margin-bottom: 0.75rem; + font-weight: 500; + color: var(--subtitle-color); +} + +/* Form styles */ +.search-form { + margin-bottom: 1.5rem; +} + +.form-group { + margin-bottom: 1rem; + display: flex; + flex-direction: column; +} + +.form-label { + font-size: 0.9rem; + margin-bottom: 0.25rem; + font-weight: 500; +} + +.form-input { + padding: 0.75rem; + font-size: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.form-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 1rem; + + padding: 0.75rem 1rem; + background-color: var(--button-background-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + width: 100%; + margin-top: 0.5rem; +} + +.form-button:hover { + background-color: var(--button-hover-background-color); +} + +/* List styles */ +.list-container { + margin-bottom: 1.5rem; +} + +.list { + list-style: none; + padding: 0; + margin: 0; +} + +.list-item { + padding: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.list-item-link { + display: block; + color: var(--text-color); + text-decoration: none; + font-size: 1.1rem; /* Increased font size for stop name */ +} + +.list-item-link:hover { + color: var(--button-background-color); +} + +.list-item-link:hover .line-icon { + color: var(--text-color); +} + +.distance-info { + font-size: 0.9rem; + color: var(--subtitle-color); +} + +/* Message styles */ +.message { + padding: 1rem; + background-color: var(--message-background-color); + border-radius: 8px; + margin-bottom: 1rem; +} + +/* About page specific styles */ +.about-page { + text-align: center; + padding: 1rem; +} + +.about-version { + color: var(--subtitle-color); + font-size: 0.9rem; + margin-top: 2rem; +} + +.about-description { + margin-top: 1rem; + line-height: 1.6; +} + +/* Map page specific styles */ +.map-container { + height: calc(100vh - 140px); + margin: -16px; + margin-bottom: 1rem; + position: relative; +} + +/* Fullscreen map styles */ +.fullscreen-container { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + padding: 0; + margin: 0; + max-width: none; + overflow: hidden; +} + +.fullscreen-map { + width: 100%; + height: 100%; +} + +.fullscreen-loading { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; + font-size: 1.8rem; + font-weight: 600; + color: var(--text-color); +} + +/* Map marker and popup styles */ +.stop-marker { + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease-in-out; +} + +.stop-marker:hover { + transform: scale(1.2); +} + +.maplibregl-popup { + max-width: 250px; +} + +.maplibregl-popup-content { + padding: 12px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.popup-line-icons { + display: flex; + flex-wrap: wrap; + margin: 6px 0; + gap: 5px; +} + +.popup-line { + display: inline-block; + background-color: var(--button-background-color); + color: white; + padding: 2px 6px; + margin-right: 4px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; +} + +.popup-link { + display: block; + margin-top: 8px; + color: var(--button-background-color); + text-decoration: none; + font-weight: 500; +} + +.popup-link:hover { + text-decoration: underline; +} + +/* Estimates page specific styles */ +.estimates-header { + display: flex; + align-items: center; + margin-bottom: 1rem; +} + +.estimates-stop-id { + font-size: 1rem; + color: var(--subtitle-color); + margin-left: 0.5rem; +} + +.estimates-arrival { + color: #28a745; + font-weight: 500; +} + +.estimates-delayed { + color: #dc3545; +} + +.button-group { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.button { + padding: 0.75rem 1rem; + background-color: var(--button-background-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + text-align: center; + text-decoration: none; + display: inline-block; +} + +.button:hover { + background-color: var(--button-hover-background-color); +} + +.button:disabled { + background-color: var(--button-disabled-background-color); + cursor: not-allowed; +} + +.star-icon { + margin-right: 0.5rem; + color: #ccc; + fill: none; +} + +.star-icon.active { + color: var(--star-color); /* Yellow color for active star */ + fill: var(--star-color); +} + +/* Tablet and larger breakpoint */ +@media (min-width: 768px) { + .search-form { + display: flex; + align-items: flex-end; + gap: 1rem; + } + + .form-group { + flex: 1; + margin-bottom: 0; + } + + .form-button { + width: auto; + margin-top: 0; + } + + .list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + } + + .list-item { + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 0; + } +} + +/* Desktop breakpoint */ +@media (min-width: 1024px) { + .list { + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + } +} diff --git a/src/frontend/app/routes/stoplist.tsx b/src/frontend/app/routes/stoplist.tsx new file mode 100644 index 0000000..ff1da71 --- /dev/null +++ b/src/frontend/app/routes/stoplist.tsx @@ -0,0 +1,136 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import StopDataProvider, { type Stop } from "../data/StopDataProvider"; +import StopItem from "../components/StopItem"; +import Fuse from "fuse.js"; +import './stoplist.css'; + +const placeholders = [ + "Urzaiz", + "Gran Vía", + "Castelao", + "García Barbón", + "Valladares", + "Florida", + "Pizarro", + "Estrada Madrid", + "Sanjurjo Badía" +]; + +export default function StopList() { + const [data, setData] = useState<Stop[] | null>(null) + const [searchResults, setSearchResults] = useState<Stop[] | null>(null); + const searchTimeout = useRef<NodeJS.Timeout | null>(null); + + const randomPlaceholder = useMemo(() => placeholders[Math.floor(Math.random() * placeholders.length)], []); + const fuse = useMemo(() => new Fuse(data || [], { threshold: 0.3, keys: ['name.original'] }), [data]); + + useEffect(() => { + StopDataProvider.getStops().then((stops: Stop[]) => setData(stops)) + }, []); + + const handleStopSearch = (event: React.ChangeEvent<HTMLInputElement>) => { + const stopName = event.target.value || ""; + + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); + } + + searchTimeout.current = setTimeout(() => { + if (stopName.length === 0) { + setSearchResults(null); + return; + } + + if (!data) { + console.error("No data available for search"); + return; + } + + const results = fuse.search(stopName); + const items = results.map(result => result.item); + setSearchResults(items); + }, 300); + } + + const favouritedStops = useMemo(() => { + return data?.filter(stop => stop.favourite) ?? [] + }, [data]) + + const recentStops = useMemo(() => { + // no recent items if data not loaded + if (!data) return null; + const recentIds = StopDataProvider.getRecent(); + if (recentIds.length === 0) return null; + // map and filter out missing entries + const stopsList = recentIds + .map(id => data.find(stop => stop.stopId === id)) + .filter((s): s is Stop => Boolean(s)); + return stopsList.reverse(); + }, [data]); + + if (data === null) return <h1 className="page-title">Loading...</h1> + + return ( + <div className="page-container"> + <h1 className="page-title">UrbanoVigo Web</h1> + + <form className="search-form"> + <div className="form-group"> + <label className="form-label" htmlFor="stopName"> + Buscar paradas + </label> + <input className="form-input" type="text" placeholder={randomPlaceholder} id="stopName" onChange={handleStopSearch} /> + </div> + </form> + + {searchResults && searchResults.length > 0 && ( + <div className="list-container"> + <h2 className="page-subtitle">Resultados de la búsqueda</h2> + <ul className="list"> + {searchResults.map((stop: Stop) => ( + <StopItem key={stop.stopId} stop={stop} /> + ))} + </ul> + </div> + )} + + <div className="list-container"> + <h2 className="page-subtitle">Paradas favoritas</h2> + + {favouritedStops?.length === 0 && ( + <p className="message"> + Accede a una parada y márcala como favorita para verla aquí. + </p> + )} + + <ul className="list"> + {favouritedStops?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => ( + <StopItem key={stop.stopId} stop={stop} /> + ))} + </ul> + </div> + + {recentStops && recentStops.length > 0 && ( + <div className="list-container"> + <h2 className="page-subtitle">Recientes</h2> + + <ul className="list"> + {recentStops.map((stop: Stop) => ( + <StopItem key={stop.stopId} stop={stop} /> + ))} + </ul> + </div> + )} + + <div className="list-container"> + <h2 className="page-subtitle">Paradas</h2> + + <ul className="list"> + {data?.sort((a, b) => a.stopId - b.stopId).map((stop: Stop) => ( + <StopItem key={stop.stopId} stop={stop} /> + ))} + </ul> + </div> + </div> + ) +} diff --git a/src/frontend/app/vite-env.d.ts b/src/frontend/app/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/frontend/app/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> |
