diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/AppContext.tsx | 50 | ||||
| -rw-r--r-- | src/components/GroupedTable.tsx | 2 | ||||
| -rw-r--r-- | src/pages/Settings.tsx | 23 | ||||
| -rw-r--r-- | src/pages/StopList.tsx | 51 |
4 files changed, 98 insertions, 28 deletions
diff --git a/src/AppContext.tsx b/src/AppContext.tsx index a9af208..8b4ffe2 100644 --- a/src/AppContext.tsx +++ b/src/AppContext.tsx @@ -4,6 +4,7 @@ import { LatLngTuple } from 'leaflet'; type Theme = 'light' | 'dark'; type TableStyle = 'regular'|'grouped'; +type MapPositionMode = 'gps' | 'last'; interface MapState { center: LatLngTuple; @@ -27,6 +28,9 @@ interface AppContextProps { setUserLocation: (location: LatLngTuple | null) => void; setLocationPermission: (hasPermission: boolean) => void; updateMapState: (center: LatLngTuple, zoom: number) => void; + + mapPositionMode: MapPositionMode; + setMapPositionMode: (mode: MapPositionMode) => void; } // Coordenadas por defecto centradas en Vigo @@ -74,6 +78,17 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }, [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'); @@ -98,6 +113,37 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { }; }); + // Helper: check if coordinates are within Vigo bounds + function isWithinVigo([lat, lng]: LatLngTuple): boolean { + // 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: LatLngTuple = [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: LatLngTuple) => { setMapState(prev => { const newState = { ...prev, center }; @@ -170,7 +216,9 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { setMapZoom, setUserLocation, setLocationPermission, - updateMapState + updateMapState, + mapPositionMode, + setMapPositionMode }}> {children} </AppContext.Provider> diff --git a/src/components/GroupedTable.tsx b/src/components/GroupedTable.tsx index b4f30a7..58bb5ed 100644 --- a/src/components/GroupedTable.tsx +++ b/src/components/GroupedTable.tsx @@ -46,7 +46,7 @@ export const GroupedTable: React.FC<GroupedTable> = ({ data, dataDate }) => { groupedEstimates[line].map((estimate, idx) => ( <tr key={`${line}-${idx}`}> {idx === 0 && ( - <td rowSpan={groupedEstimates[line].length} style={{ verticalAlign: 'top' }}> + <td rowSpan={groupedEstimates[line].length}> <LineIcon line={line} /> </td> )} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index e4a1a31..1ad15ab 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -2,7 +2,7 @@ import { useApp } from "../AppContext"; import "../styles/Settings.css"; export function Settings() { - const { theme, setTheme, tableStyle, setTableStyle } = useApp(); + const { theme, setTheme, tableStyle, setTableStyle, mapPositionMode, setMapPositionMode } = useApp(); return ( <div className="about-page"> @@ -15,20 +15,25 @@ export function Settings() { <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")} - style={{ backgroundColor: theme === "dark" ? "#333" : "#fff", color: theme === "dark" ? "#fff" : "#000" }}> + <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")} - style={{ backgroundColor: theme === "dark" ? "#333" : "#fff", color: theme === "dark" ? "#fff" : "#000" }}> + <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> @@ -44,16 +49,16 @@ export function Settings() { </section> <h2>Créditos</h2> <p> - <a href="https://github.com/arielcostas/urbanovigo-web" className="about-link" style={{ color: theme === "dark" ? "#bbb" : "#000" }}> + <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" style={{ color: theme === "dark" ? "#bbb" : "#000" }}> + 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" style={{ color: theme === "dark" ? "#bbb" : "#000" }}>datos.vigo.org</a> bajo - licencia <a href="https://opendefinition.org/licenses/odc-by/" style={{ color: theme === "dark" ? "#bbb" : "#000" }}>Open Data Commons Attribution License</a> + 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/pages/StopList.tsx b/src/pages/StopList.tsx index a2269ec..b965456 100644 --- a/src/pages/StopList.tsx +++ b/src/pages/StopList.tsx @@ -1,35 +1,54 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import StopDataProvider, { Stop } from "../data/StopDataProvider"; import StopItem from "../components/StopItem"; import Fuse from "fuse.js"; const placeholders = [ - "Urzaiz", - "Gran Vía", - "Castelao", - "García Barbón", - "Valladares", - "Florida", - "Pizarro", - "Estrada Madrid", - "Sanjurjo Badía" + "Urzaiz", + "Gran Vía", + "Castelao", + "García Barbón", + "Valladares", + "Florida", + "Pizarro", + "Estrada Madrid", + "Sanjurjo Badía" ]; export 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 (data) { - const fuse = new Fuse(data, { keys: ['name'], threshold: 0.3 }); - const results = fuse.search(stopName).map(result => result.item); - setSearchResults(results); + 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(() => { @@ -50,8 +69,6 @@ export function StopList() { if (data === null) return <h1 className="page-title">Loading...</h1> - const randomPlaceholder = placeholders[Math.floor(Math.random() * placeholders.length)]; - return ( <div className="page-container"> <h1 className="page-title">UrbanoVigo Web</h1> |
