diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-13 17:12:12 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-13 17:12:12 +0100 |
| commit | ece17875d4e454423f55f0623a456c0433ecd502 (patch) | |
| tree | 732c0432cbf32757344c51b8c01bb18e83e9c0c0 | |
| parent | 5c670f1b4a237b7a5197dfcf94de92095da95463 (diff) | |
feat: integrate geolocation functionality and enhance map interactions
- Added useGeolocation hook to manage user location and permissions.
- Updated PlannerOverlay to utilize geolocation for setting origin.
- Enhanced NavBar with a new planner route.
- Introduced context menu for map interactions to set routes from current location.
- Improved search functionality in the map with a dedicated search bar.
- Updated localization files with new strings for routing and search features.
| -rw-r--r-- | src/frontend/app/components/PlannerOverlay.tsx | 24 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/NavBar.tsx | 7 | ||||
| -rw-r--r-- | src/frontend/app/components/shared/AppMap.tsx | 30 | ||||
| -rw-r--r-- | src/frontend/app/contexts/MapContext.tsx | 159 | ||||
| -rw-r--r-- | src/frontend/app/hooks/useGeolocation.ts | 62 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 12 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 12 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 12 | ||||
| -rw-r--r-- | src/frontend/app/routes/home.tsx | 108 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 311 | ||||
| -rw-r--r-- | src/frontend/app/routes/planner.tsx | 637 |
11 files changed, 943 insertions, 431 deletions
diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx index facf6f9..d953c2e 100644 --- a/src/frontend/app/components/PlannerOverlay.tsx +++ b/src/frontend/app/components/PlannerOverlay.tsx @@ -15,6 +15,7 @@ import { type PlannerSearchResult, } from "~/data/PlannerApi"; import StopDataProvider from "~/data/StopDataProvider"; +import { useGeolocation } from "~/hooks/useGeolocation"; import { usePlanner } from "~/hooks/usePlanner"; interface PlannerOverlayProps { @@ -30,7 +31,6 @@ interface PlannerOverlayProps { clearPickerOnOpen?: boolean; showLastDestinationWhenCollapsed?: boolean; cardBackground?: string; - userLocation?: { latitude: number; longitude: number } | null; autoLoad?: boolean; } @@ -42,11 +42,11 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ clearPickerOnOpen = false, showLastDestinationWhenCollapsed = true, cardBackground, - userLocation, autoLoad = true, }) => { const { t } = useTranslation(); const navigate = useNavigate(); + const { userLocation, requestLocation } = useGeolocation(); const { origin, setOrigin, @@ -173,22 +173,12 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ const setOriginFromCurrentLocation = useCallback( (closePicker: boolean = true) => { - console.log( - "[PlannerOverlay] setOriginFromCurrentLocation called, closePicker:", - closePicker - ); if (!navigator.geolocation) { - console.warn("[PlannerOverlay] Geolocation not available"); return; } setLocationLoading(true); navigator.geolocation.getCurrentPosition( async (pos) => { - console.log( - "[PlannerOverlay] Geolocation success:", - pos.coords.latitude, - pos.coords.longitude - ); try { // Set immediately using raw coordinates; refine later if reverse geocode works. const initial: PlannerSearchResult = { @@ -198,16 +188,16 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ lon: pos.coords.longitude, layer: "current-location", }; - console.log("[PlannerOverlay] Setting initial origin:", initial); setOrigin(initial); setOriginQuery(initial.name || ""); + // Share location with global context so other consumers benefit + requestLocation(); try { const rev = await reverseGeocode( pos.coords.latitude, pos.coords.longitude ); - console.log("[PlannerOverlay] Reverse geocode result:", rev); if (rev) { const refined: PlannerSearchResult = { ...initial, @@ -215,10 +205,6 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ label: rev.label || initial.label, layer: "current-location", }; - console.log( - "[PlannerOverlay] Setting refined origin:", - refined - ); setOrigin(refined); setOriginQuery(refined.name || ""); } @@ -238,7 +224,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ { enableHighAccuracy: false, maximumAge: 60000, timeout: 10000 } ); }, - [setOrigin, t] + [setOrigin, t, requestLocation] ); useEffect(() => { diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx index 5822ce7..e66c388 100644 --- a/src/frontend/app/components/layout/NavBar.tsx +++ b/src/frontend/app/components/layout/NavBar.tsx @@ -1,4 +1,4 @@ -import { Home, Map, Route } from "lucide-react"; +import { Home, Map, Navigation, Route } from "lucide-react"; import type { LngLatLike } from "maplibre-gl"; import { useTranslation } from "react-i18next"; import { Link, useLocation, useNavigate } from "react-router"; @@ -53,6 +53,11 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { }, }, { + name: t("navbar.planner", "Planificador"), + icon: Navigation, + path: "/planner", + }, + { name: t("navbar.routes", "Rutas"), icon: Route, path: "/routes", diff --git a/src/frontend/app/components/shared/AppMap.tsx b/src/frontend/app/components/shared/AppMap.tsx index c6eb8ee..f4c8658 100644 --- a/src/frontend/app/components/shared/AppMap.tsx +++ b/src/frontend/app/components/shared/AppMap.tsx @@ -44,6 +44,7 @@ interface AppMapProps { onRotateStart?: () => void; onPitchStart?: () => void; onLoad?: () => void; + onContextMenu?: (e: MapLayerMouseEvent) => void; } export const AppMap = forwardRef<MapRef, AppMapProps>( @@ -72,6 +73,7 @@ export const AppMap = forwardRef<MapRef, AppMapProps>( onRotateStart, onPitchStart, onLoad, + onContextMenu, }, ref ) => { @@ -79,6 +81,8 @@ export const AppMap = forwardRef<MapRef, AppMapProps>( theme, mapState, updateMapState, + setUserLocation, + setLocationPermission, showTraffic: settingsShowTraffic, showCameras: settingsShowCameras, mapPositionMode, @@ -159,14 +163,9 @@ export const AppMap = forwardRef<MapRef, AppMapProps>( const viewState = useMemo(() => { if (initialViewState) return initialViewState; - if (mapPositionMode === "gps" && mapState.userLocation) { - return { - latitude: getLatitude(mapState.userLocation), - longitude: getLongitude(mapState.userLocation), - zoom: 16, - }; - } - + // Prefer the last saved position for this path so navigation doesn't + // reset the map viewport. GPS mode is only used as a fallback when the + // user has never visited this path before. const pathState = mapState.paths[path]; if (pathState) { return { @@ -176,6 +175,14 @@ export const AppMap = forwardRef<MapRef, AppMapProps>( }; } + if (mapPositionMode === "gps" && mapState.userLocation) { + return { + latitude: getLatitude(mapState.userLocation), + longitude: getLongitude(mapState.userLocation), + zoom: 16, + }; + } + return { latitude: getLatitude(APP_CONSTANTS.defaultCenter), longitude: getLongitude(APP_CONSTANTS.defaultCenter), @@ -200,13 +207,18 @@ export const AppMap = forwardRef<MapRef, AppMapProps>( onRotateStart={onRotateStart} onPitchStart={onPitchStart} onLoad={onLoad} + onContextMenu={onContextMenu} > {showNavigation && <NavigationControl position="bottom-right" />} {showGeolocate && ( <GeolocateControl position="bottom-right" - trackUserLocation={true} positionOptions={{ enableHighAccuracy: false }} + onGeolocate={(e) => { + const { latitude, longitude } = e.coords; + setUserLocation([latitude, longitude]); + setLocationPermission(true); + }} /> )} {children} diff --git a/src/frontend/app/contexts/MapContext.tsx b/src/frontend/app/contexts/MapContext.tsx index f888f34..75851f4 100644 --- a/src/frontend/app/contexts/MapContext.tsx +++ b/src/frontend/app/contexts/MapContext.tsx @@ -1,8 +1,10 @@ import { type LngLatLike } from "maplibre-gl"; import { createContext, + useCallback, useContext, useEffect, + useRef, useState, type ReactNode, } from "react"; @@ -18,6 +20,7 @@ interface MapContextProps { setUserLocation: (location: LngLatLike | null) => void; setLocationPermission: (hasPermission: boolean) => void; updateMapState: (center: LngLatLike, zoom: number, path: string) => void; + requestLocation: () => void; } const MapContext = createContext<MapContextProps | undefined>(undefined); @@ -28,9 +31,6 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { if (savedMapState) { try { const parsed = JSON.parse(savedMapState); - // Validate that the saved center is valid if needed, or just trust it. - // 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 { paths: parsed.paths || {}, userLocation: parsed.userLocation || null, @@ -47,58 +47,130 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { }; }); - const setUserLocation = (userLocation: LngLatLike | null) => { + const watchIdRef = useRef<number | null>(null); + + const setUserLocation = useCallback((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 setLocationPermission = useCallback( + (hasLocationPermission: boolean) => { + setMapState((prev) => { + const newState = { ...prev, hasLocationPermission }; + localStorage.setItem("mapState", JSON.stringify(newState)); + return newState; + }); + }, + [] + ); - const updateMapState = (center: LngLatLike, zoom: number, path: string) => { - setMapState((prev) => { - const newState = { - ...prev, - paths: { - ...prev.paths, - [path]: { center, zoom }, - }, - }; - localStorage.setItem("mapState", JSON.stringify(newState)); - return newState; - }); - }; + const updateMapState = useCallback( + (center: LngLatLike, zoom: number, path: string) => { + setMapState((prev) => { + const newState = { + ...prev, + paths: { + ...prev.paths, + [path]: { center, zoom }, + }, + }; + localStorage.setItem("mapState", JSON.stringify(newState)); + return newState; + }); + }, + [] + ); + + const startWatching = useCallback(() => { + if (!navigator.geolocation || watchIdRef.current !== null) return; + watchIdRef.current = navigator.geolocation.watchPosition( + (position) => { + const { latitude, longitude } = position.coords; + setUserLocation([latitude, longitude]); + setLocationPermission(true); + }, + (error) => { + if (error.code === GeolocationPositionError.PERMISSION_DENIED) { + setLocationPermission(false); + } + }, + { enableHighAccuracy: false, maximumAge: 30000, timeout: 15000 } + ); + }, [setUserLocation, setLocationPermission]); + + const requestLocation = useCallback(() => { + if (typeof window === "undefined" || !("geolocation" in navigator)) return; + navigator.geolocation.getCurrentPosition( + (pos) => { + setUserLocation([pos.coords.latitude, pos.coords.longitude]); + setLocationPermission(true); + startWatching(); + }, + () => { + setLocationPermission(false); + }, + { enableHighAccuracy: false, maximumAge: 60000, timeout: 10000 } + ); + }, [setUserLocation, setLocationPermission, startWatching]); - // Try to get user location on load if permission was granted + const hasPermissionRef = useRef(mapState.hasLocationPermission); + + // On mount: subscribe to permission changes and auto-start watching if already granted 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); + if (typeof window === "undefined" || !("geolocation" in navigator)) return; + + let permissionStatus: PermissionStatus | null = null; + + const onPermChange = () => { + if (permissionStatus?.state === "granted") { + setLocationPermission(true); + startWatching(); + } else if (permissionStatus?.state === "denied") { + setLocationPermission(false); + if (watchIdRef.current !== null) { + navigator.geolocation.clearWatch(watchIdRef.current); + watchIdRef.current = null; + } + } + }; + + const init = async () => { + try { + if (navigator.permissions?.query) { + permissionStatus = await navigator.permissions.query({ + name: "geolocation", + }); + if (permissionStatus.state === "granted") { + setLocationPermission(true); + startWatching(); + } else if (permissionStatus.state === "denied") { setLocationPermission(false); - }, - { - enableHighAccuracy: true, - maximumAge: Infinity, - timeout: 10000, } - ); + permissionStatus.addEventListener("change", onPermChange); + } else if (hasPermissionRef.current) { + startWatching(); + } + } catch { + if (hasPermissionRef.current) { + startWatching(); + } } - } - }, [mapState.hasLocationPermission, mapState.userLocation]); + }; + + init(); + + return () => { + if (watchIdRef.current !== null) { + navigator.geolocation.clearWatch(watchIdRef.current); + watchIdRef.current = null; + } + permissionStatus?.removeEventListener("change", onPermChange); + }; + }, [startWatching, setLocationPermission]); return ( <MapContext.Provider @@ -107,6 +179,7 @@ export const MapProvider = ({ children }: { children: ReactNode }) => { setUserLocation, setLocationPermission, updateMapState, + requestLocation, }} > {children} diff --git a/src/frontend/app/hooks/useGeolocation.ts b/src/frontend/app/hooks/useGeolocation.ts new file mode 100644 index 0000000..878420b --- /dev/null +++ b/src/frontend/app/hooks/useGeolocation.ts @@ -0,0 +1,62 @@ +import { useCallback } from "react"; +import { useMap } from "../contexts/MapContext"; +import type { LngLatLike } from "maplibre-gl"; + +export interface UseGeolocationResult { + userLocation: { latitude: number; longitude: number } | null; + hasLocationPermission: boolean; + requestLocation: () => void; +} + +function lngLatToCoords( + loc: LngLatLike +): { latitude: number; longitude: number } { + if (Array.isArray(loc)) { + // This codebase stores location as [latitude, longitude] (not the standard + // MapLibre [lng, lat] GeoJSON order). See MapContext.tsx where arrays are + // set as [position.coords.latitude, position.coords.longitude], and AppMap.tsx + // where getLatitude(center) returns center[0]. + return { latitude: loc[0], longitude: loc[1] }; + } + if ("lat" in loc) { + return { + latitude: loc.lat, + longitude: "lng" in loc ? (loc as any).lng : (loc as any).lon, + }; + } + return { latitude: 0, longitude: 0 }; +} + +/** + * Provides the current user location from the global MapContext. + * Location updates are driven by the MapContext's watchPosition subscription + * (started automatically when geolocation permission is granted). + * + * Call `requestLocation()` to prompt the user for permission and start tracking. + */ +export function useGeolocation(): UseGeolocationResult { + const { mapState, setUserLocation, setLocationPermission } = useMap(); + + const requestLocation = useCallback(() => { + if (typeof window === "undefined" || !("geolocation" in navigator)) return; + navigator.geolocation.getCurrentPosition( + (pos) => { + setUserLocation([pos.coords.latitude, pos.coords.longitude]); + setLocationPermission(true); + }, + () => { + setLocationPermission(false); + }, + { enableHighAccuracy: false, maximumAge: 60000, timeout: 10000 } + ); + }, [setUserLocation, setLocationPermission]); + + const rawLoc = mapState.userLocation; + const userLocation = rawLoc ? lngLatToCoords(rawLoc) : null; + + return { + userLocation, + hasLocationPermission: mapState.hasLocationPermission, + requestLocation, + }; +} diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 1987d28..25a7e7b 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -103,7 +103,11 @@ "popup_title": "Stop", "lines": "Lines", "view_all_estimates": "View all estimates", - "select_nearby_stop": "Select stop" + "select_nearby_stop": "Select stop", + "route_from_here": "Route from here", + "route_to_here": "Route to here", + "search_placeholder": "Search for a place…", + "plan_trip": "Plan a trip" }, "planner": { "where_to": "Where do you want to go?", @@ -149,7 +153,11 @@ "fare": "€{{amount}}", "free": "Free", "urban_traffic_warning": "Possible transit restriction", - "urban_traffic_warning_desc": "Both stops on this leg are within {{municipality}}, which has its own urban transport. It's likely you are not allowed to do this trip with Xunta services." + "urban_traffic_warning_desc": "Both stops on this leg are within {{municipality}}, which has its own urban transport. It's likely you are not allowed to do this trip with Xunta services.", + "next_arrivals": "Next arrivals", + "next_arrival": "Next", + "intermediate_stops_one": "1 stop", + "intermediate_stops": "{{count}} stops" }, "common": { "loading": "Loading...", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 5e65a88..a97534d 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -103,7 +103,11 @@ "popup_title": "Parada", "lines": "Líneas", "view_all_estimates": "Detalles", - "select_nearby_stop": "Seleccionar parada" + "select_nearby_stop": "Seleccionar parada", + "route_from_here": "Ruta desde aquí", + "route_to_here": "Ruta hasta aquí", + "search_placeholder": "Buscar un lugar…", + "plan_trip": "Planificar ruta" }, "planner": { "where_to": "¿A donde quieres ir?", @@ -149,7 +153,11 @@ "fare": "{{amount}} €", "free": "Gratuito", "urban_traffic_warning": "Posible restricción de tráfico", - "urban_traffic_warning_desc": "Las dos paradas de este tramo están en {{municipality}}, que dispone de transporte urbano propio. Es probable que no puedas utilizar los servicios de la Xunta en este trayecto." + "urban_traffic_warning_desc": "Las dos paradas de este tramo están en {{municipality}}, que dispone de transporte urbano propio. Es probable que no puedas utilizar los servicios de la Xunta en este trayecto.", + "next_arrivals": "Próximas llegadas", + "next_arrival": "Próximo", + "intermediate_stops_one": "1 parada", + "intermediate_stops": "{{count}} paradas" }, "common": { "loading": "Cargando...", diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 2c874d8..36a1c66 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -103,7 +103,11 @@ "popup_title": "Parada", "lines": "Liñas", "view_all_estimates": "Ver todas as estimacións", - "select_nearby_stop": "Seleccionar parada" + "select_nearby_stop": "Seleccionar parada", + "route_from_here": "Ruta desde aquí", + "route_to_here": "Ruta ata aquí", + "search_placeholder": "Buscar un lugar…", + "plan_trip": "Planificar ruta" }, "planner": { "where_to": "Onde queres ir?", @@ -149,7 +153,11 @@ "fare": "{{amount}} €", "free": "Gratuíto", "urban_traffic_warning": "Posible restrición de tráfico", - "urban_traffic_warning_desc": "As dúas paradas deste tramo están en {{municipality}}, que dispón de transporte urbano propio. É probable que non poidas utilizar os servizos da Xunta neste traxecto." + "urban_traffic_warning_desc": "As dúas paradas deste tramo están en {{municipality}}, que dispón de transporte urbano propio. É probable que non poidas utilizar os servizos da Xunta neste traxecto.", + "next_arrivals": "Próximas chegadas", + "next_arrival": "Próximo", + "intermediate_stops_one": "1 parada", + "intermediate_stops": "{{count}} paradas" }, "common": { "loading": "Cargando...", diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx index 45d7ddf..e71c788 100644 --- a/src/frontend/app/routes/home.tsx +++ b/src/frontend/app/routes/home.tsx @@ -1,8 +1,7 @@ -import { Clock, History, Star } from "lucide-react"; +import { Clock, History, MapPin, Star } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router"; -import { PlannerOverlay } from "~/components/PlannerOverlay"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { usePlanner } from "~/hooks/usePlanner"; import StopItem from "../components/StopItem"; @@ -13,16 +12,12 @@ export default function StopList() { const { t } = useTranslation(); usePageTitle(t("navbar.stops", "Paradas")); const navigate = useNavigate(); - const { history, searchRoute, loadRoute } = usePlanner({ autoLoad: false }); + const { history, loadRoute } = usePlanner({ autoLoad: false }); const [data, setData] = useState<Stop[] | null>(null); const [loading, setLoading] = useState(true); const [searchResults, setSearchResults] = useState<Stop[] | null>(null); const [favouriteStops, setFavouriteStops] = useState<Stop[]>([]); const [recentStops, setRecentStops] = useState<Stop[]>([]); - const [userLocation, setUserLocation] = useState<{ - latitude: number; - longitude: number; - } | null>(null); const searchTimeout = useRef<NodeJS.Timeout | null>(null); const randomPlaceholder = useMemo( @@ -30,68 +25,6 @@ export default function StopList() { [t] ); - const requestUserLocation = useCallback(() => { - if (typeof window === "undefined" || !("geolocation" in navigator)) { - return; - } - - navigator.geolocation.getCurrentPosition( - (position) => { - setUserLocation({ - latitude: position.coords.latitude, - longitude: position.coords.longitude, - }); - }, - (error) => { - console.warn("Unable to obtain user location", error); - }, - { - enableHighAccuracy: false, - maximumAge: Infinity, - timeout: 10000, - } - ); - }, []); - - useEffect(() => { - if (typeof window === "undefined" || !("geolocation" in navigator)) { - return; - } - - let permissionStatus: PermissionStatus | null = null; - - const handlePermissionChange = () => { - if (permissionStatus?.state === "granted") { - requestUserLocation(); - } - }; - - const checkPermission = async () => { - try { - if (navigator.permissions?.query) { - permissionStatus = await navigator.permissions.query({ - name: "geolocation", - }); - if (permissionStatus.state === "granted") { - requestUserLocation(); - } - permissionStatus.addEventListener("change", handlePermissionChange); - } else { - requestUserLocation(); - } - } catch (error) { - console.warn("Geolocation permission check failed", error); - requestUserLocation(); - } - }; - - checkPermission(); - - return () => { - permissionStatus?.removeEventListener("change", handlePermissionChange); - }; - }, [requestUserLocation]); - // Load stops from network const loadStops = useCallback(async () => { try { @@ -164,33 +97,16 @@ export default function StopList() { <div className="flex flex-col gap-4 py-4 pb-8"> {/* Planner Section */} <div className="w-full px-4"> - <details className="group bg-surface border border-slate-200 dark:border-slate-700 shadow-sm"> - <summary className="list-none cursor-pointer focus:outline-none"> - <div className="flex items-center justify-between p-3 rounded-xl group-open:mb-3 transition-all"> - <div className="flex items-center gap-3"> - <History className="w-5 h-5 text-primary-600 dark:text-primary-400" /> - <span className="font-semibold text-text"> - {t("planner.where_to", "¿A dónde quieres ir?")} - </span> - </div> - <div className="text-muted group-open:rotate-180 transition-transform"> - ↓ - </div> - </div> - </summary> - - <PlannerOverlay - inline - forceExpanded - cardBackground="bg-transparent" - userLocation={userLocation} - autoLoad={false} - onSearch={(origin, destination, time, arriveBy) => { - searchRoute(origin, destination, time, arriveBy); - }} - onNavigateToPlanner={() => navigate("/planner")} - /> - </details> + <button + type="button" + onClick={() => navigate("/planner")} + className="w-full flex items-center gap-3 p-3 rounded-xl bg-surface border border-slate-200 dark:border-slate-700 shadow-sm hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors text-left" + > + <MapPin className="w-5 h-5 text-primary-600 dark:text-primary-400 shrink-0" /> + <span className="font-semibold text-text"> + {t("planner.where_to", "¿A dónde quieres ir?")} + </span> + </button> {history.length > 0 && ( <div className="mt-3 flex flex-col gap-2"> diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index f54f6cf..efc97e4 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -1,6 +1,6 @@ -import { Check, MapPin, X } from "lucide-react"; +import { Check, MapPin, Navigation, Search, X } from "lucide-react"; import type { FilterSpecification } from "maplibre-gl"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Layer, @@ -14,15 +14,171 @@ import { StopSummarySheet, type StopSheetProps, } from "~/components/map/StopSummarySheet"; -import { PlannerOverlay } from "~/components/PlannerOverlay"; import { AppMap } from "~/components/shared/AppMap"; import { usePageTitle } from "~/contexts/PageTitleContext"; -import { reverseGeocode } from "~/data/PlannerApi"; +import { + reverseGeocode, + searchPlaces, + type PlannerSearchResult, +} from "~/data/PlannerApi"; import { usePlanner } from "~/hooks/usePlanner"; import StopDataProvider from "../data/StopDataProvider"; import "../tailwind-full.css"; import "./map.css"; +// Module-level: keeps search query + results alive across SPA navigation +const mapSearchState: { query: string; results: PlannerSearchResult[] } = { + query: "", + results: [], +}; + +interface MapSearchBarProps { + mapRef: React.RefObject<MapRef | null>; +} + +function MapSearchBar({ mapRef }: MapSearchBarProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [query, setQuery] = useState(mapSearchState.query); + const [results, setResults] = useState<PlannerSearchResult[]>( + mapSearchState.results + ); + const [showResults, setShowResults] = useState( + mapSearchState.results.length > 0 + ); + const [loading, setLoading] = useState(false); + const containerRef = useRef<HTMLDivElement>(null); + const inputRef = useRef<HTMLInputElement>(null); + const debounceRef = useRef<NodeJS.Timeout | null>(null); + + // Close dropdown when clicking/tapping outside the search container + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setShowResults(false); + } + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, []); + + const handleQueryChange = (q: string) => { + setQuery(q); + mapSearchState.query = q; + + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (q.trim().length < 2) { + // Hide stale results when the query is cleared or too short + setResults([]); + mapSearchState.results = []; + setShowResults(false); + return; + } + + debounceRef.current = setTimeout(async () => { + setLoading(true); + try { + const res = await searchPlaces(q.trim()); + setResults(res); + mapSearchState.results = res; + setShowResults(true); + } catch { + // keep old results on network error + } finally { + setLoading(false); + } + }, 300); + }; + + const handleSelect = (place: PlannerSearchResult) => { + const map = mapRef.current; + if (map) { + map.flyTo({ center: [place.lon, place.lat], zoom: 15, duration: 800 }); + } + // Keep results visible so user can pick another without retyping + }; + + const handleClear = () => { + setQuery(""); + mapSearchState.query = ""; + setResults([]); + mapSearchState.results = []; + setShowResults(false); + inputRef.current?.focus(); + }; + + return ( + <div className="absolute top-4 left-0 right-0 z-20 flex justify-center px-4 pointer-events-none"> + <div + ref={containerRef} + className="pointer-events-auto w-full max-w-md flex flex-col gap-1" + > + {/* Search input */} + <div className="flex items-center gap-2 bg-white/95 dark:bg-slate-900/90 backdrop-blur rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 px-3"> + <Search className="w-4 h-4 text-slate-400 shrink-0" /> + <input + ref={inputRef} + type="text" + className="flex-1 py-3 bg-transparent text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-500 focus:outline-none" + placeholder={t("map.search_placeholder", "Buscar un lugar…")} + value={query} + onChange={(e) => handleQueryChange(e.target.value)} + onFocus={() => { + if (results.length > 0) setShowResults(true); + }} + /> + {loading ? ( + <div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin shrink-0" /> + ) : query ? ( + <button + onPointerDown={(e) => { + // Prevent input blur before clear fires + e.preventDefault(); + handleClear(); + }} + className="shrink-0 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors" + aria-label={t("planner.clear", "Clear")} + > + <X className="w-4 h-4" /> + </button> + ) : null} + </div> + + {/* Results dropdown */} + {showResults && results.length > 0 && ( + <div className="bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 overflow-hidden"> + <div className="max-h-60 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-800"> + {results.map((place, i) => ( + <button + key={`${place.lat}-${place.lon}-${i}`} + className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors text-sm" + onClick={() => handleSelect(place)} + > + <MapPin className="w-4 h-4 text-primary-600 shrink-0 mt-0.5" /> + <div className="min-w-0"> + <div className="font-medium text-slate-900 dark:text-slate-100 truncate"> + {place.name} + </div> + {place.label && place.label !== place.name && ( + <div className="text-xs text-slate-500 dark:text-slate-400 truncate"> + {place.label} + </div> + )} + </div> + </button> + ))} + </div> + </div> + )} + </div> + </div> + ); +} + // Componente principal del mapa export default function StopMap() { const { t } = useTranslation(); @@ -43,7 +199,6 @@ export default function StopMap() { const mapRef = useRef<MapRef>(null); const { - searchRoute, pickingMode, setPickingMode, setOrigin, @@ -53,6 +208,81 @@ export default function StopMap() { const [isConfirming, setIsConfirming] = useState(false); + // Context menu state (right-click / long-press) + interface ContextMenuState { + x: number; + y: number; + lat: number; + lng: number; + } + const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); + const [contextMenuLoading, setContextMenuLoading] = useState< + "origin" | "destination" | null + >(null); + + const handleContextMenu = (e: MapLayerMouseEvent) => { + if (pickingMode) return; + e.preventDefault?.(); + setContextMenu({ + x: e.point.x, + y: e.point.y, + lat: e.lngLat.lat, + lng: e.lngLat.lng, + }); + }; + + const closeContextMenu = () => setContextMenu(null); + + const handleRouteFromHere = async () => { + if (!contextMenu) return; + setContextMenuLoading("origin"); + try { + const result = await reverseGeocode(contextMenu.lat, contextMenu.lng); + const place = { + name: + result?.name || + `${contextMenu.lat.toFixed(5)}, ${contextMenu.lng.toFixed(5)}`, + label: result?.label || "Map location", + lat: contextMenu.lat, + lon: contextMenu.lng, + layer: "map-pick", + }; + setOrigin(place); + addRecentPlace(place); + closeContextMenu(); + navigate("/planner"); + } catch { + closeContextMenu(); + } finally { + setContextMenuLoading(null); + } + }; + + const handleRouteToHere = async () => { + if (!contextMenu) return; + setContextMenuLoading("destination"); + try { + const result = await reverseGeocode(contextMenu.lat, contextMenu.lng); + const place = { + name: + result?.name || + `${contextMenu.lat.toFixed(5)}, ${contextMenu.lng.toFixed(5)}`, + label: result?.label || "Map location", + lat: contextMenu.lat, + lon: contextMenu.lng, + layer: "map-pick", + }; + setDestination(place); + addRecentPlace(place); + closeContextMenu(); + navigate("/planner"); + } catch { + closeContextMenu(); + } finally { + setContextMenuLoading(null); + } + }; + const handleConfirmPick = async () => { if (!mapRef.current || !pickingMode) return; const center = mapRef.current.getCenter(); @@ -76,6 +306,7 @@ export default function StopMap() { } addRecentPlace(finalResult); setPickingMode(null); + navigate("/planner"); } catch (err) { console.error("Failed to reverse geocode:", err); } finally { @@ -83,12 +314,6 @@ export default function StopMap() { } }; - const onMapInteraction = () => { - if (!pickingMode) { - window.dispatchEvent(new CustomEvent("plannerOverlay:collapse")); - } - }; - const favouriteIds = useMemo(() => StopDataProvider.getFavouriteIds(), []); const favouriteFilter = useMemo(() => { @@ -183,16 +408,7 @@ export default function StopMap() { return ( <div className="relative h-full"> - {!pickingMode && ( - <PlannerOverlay - onSearch={(o, d, time, arriveBy) => searchRoute(o, d, time, arriveBy)} - onNavigateToPlanner={() => navigate("/planner")} - clearPickerOnOpen={true} - showLastDestinationWhenCollapsed={false} - cardBackground="bg-white/95 dark:bg-slate-900/90" - autoLoad={false} - /> - )} + {!pickingMode && <MapSearchBar mapRef={mapRef} />} {pickingMode && ( <div className="absolute top-4 left-0 right-0 z-20 flex justify-center px-4 pointer-events-none"> @@ -252,9 +468,11 @@ export default function StopMap() { showGeolocate={true} showTraffic={pickingMode ? false : undefined} interactiveLayerIds={["stops", "stops-label"]} - onClick={onMapClick} - onDragStart={onMapInteraction} - onZoomStart={onMapInteraction} + onClick={(e) => { + closeContextMenu(); + onMapClick(e); + }} + onContextMenu={handleContextMenu} attributionControl={{ compact: false }} > <Source @@ -440,6 +658,51 @@ export default function StopMap() { </div> )} </AppMap> + + {contextMenu && ( + <> + {/* Dismiss backdrop */} + <div className="absolute inset-0 z-30" onClick={closeContextMenu} /> + {/* Context menu */} + <div + className="absolute z-40 min-w-[180px] rounded-xl bg-white dark:bg-slate-900 shadow-2xl border border-slate-200 dark:border-slate-700 overflow-hidden" + style={{ + left: Math.min(contextMenu.x, window.innerWidth - 200), + top: Math.min(contextMenu.y, window.innerHeight - 120), + }} + > + <button + className="w-full flex items-center gap-3 px-4 py-3 text-sm text-left text-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors disabled:opacity-50" + onClick={handleRouteFromHere} + disabled={contextMenuLoading !== null} + > + {contextMenuLoading === "origin" ? ( + <div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" /> + ) : ( + <Navigation className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" /> + )} + <span className="font-medium"> + {t("map.route_from_here", "Ruta desde aquí")} + </span> + </button> + <div className="h-px bg-slate-100 dark:bg-slate-800" /> + <button + className="w-full flex items-center gap-3 px-4 py-3 text-sm text-left text-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors disabled:opacity-50" + onClick={handleRouteToHere} + disabled={contextMenuLoading !== null} + > + {contextMenuLoading === "destination" ? ( + <div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" /> + ) : ( + <MapPin className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" /> + )} + <span className="font-medium"> + {t("map.route_to_here", "Ruta hasta aquí")} + </span> + </button> + </div> + </> + )} </div> ); } diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index 4038ef7..c2fc648 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -1,4 +1,12 @@ -import { AlertTriangle, Coins, CreditCard, Footprints } from "lucide-react"; +import { + AlertTriangle, + Coins, + CreditCard, + Footprints, + LayoutGrid, + List, + Map as MapIcon, +} from "lucide-react"; import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import React, { useEffect, useMemo, useRef, useState } from "react"; @@ -14,6 +22,7 @@ import { AppMap } from "~/components/shared/AppMap"; import { APP_CONSTANTS } from "~/config/constants"; import { useBackButton, usePageTitle } from "~/contexts/PageTitleContext"; import { type Itinerary } from "~/data/PlannerApi"; +import { useGeolocation } from "~/hooks/useGeolocation"; import { usePlanner } from "~/hooks/usePlanner"; import "../tailwind-full.css"; @@ -45,6 +54,14 @@ const haversineMeters = (a: [number, number], b: [number, number]) => { return 2 * R * Math.asin(Math.sqrt(h)); }; +const shouldSkipWalkLeg = (leg: Itinerary["legs"][number]): boolean => { + if (leg.mode !== "WALK") return false; + const durationMinutes = + (new Date(leg.endTime).getTime() - new Date(leg.startTime).getTime()) / + 60000; + return durationMinutes <= 2 || leg.distanceMeters < 50; +}; + const sumWalkMetrics = (legs: Itinerary["legs"]) => { let meters = 0; let minutes = 0; @@ -129,44 +146,44 @@ const ItinerarySummary = ({ </div> <div className="flex items-center gap-2 overflow-x-auto pb-2"> - {itinerary.legs.map((leg, idx) => { - const isWalk = leg.mode === "WALK"; - const legDurationMinutes = Math.max( - 1, - Math.round( - (new Date(leg.endTime).getTime() - - new Date(leg.startTime).getTime()) / - 60000 - ) - ); + {itinerary.legs + .filter((leg) => !shouldSkipWalkLeg(leg)) + .map((leg, idx) => { + const isWalk = leg.mode === "WALK"; + const legDurationMinutes = Math.max( + 1, + Math.round( + (new Date(leg.endTime).getTime() - + new Date(leg.startTime).getTime()) / + 60000 + ) + ); - const isFirstBusLeg = - !isWalk && - itinerary.legs.findIndex((l) => l.mode !== "WALK") === idx; - - return ( - <React.Fragment key={idx}> - {idx > 0 && <span className="text-muted/50">›</span>} - {isWalk ? ( - <div className="flex items-center gap-2 rounded-full bg-surface px-3 py-1.5 text-sm text-text whitespace-nowrap border border-border"> - <Footprints className="w-4 h-4 text-muted" /> - <span className="font-semibold"> - {formatDuration(legDurationMinutes, t)} - </span> - </div> - ) : ( - <div className="flex items-center gap-2"> - <RouteIcon - line={leg.routeShortName || leg.routeName || leg.mode || ""} - mode="pill" - colour={leg.routeColor || undefined} - textColour={leg.routeTextColor || undefined} - /> - </div> - )} - </React.Fragment> - ); - })} + return ( + <React.Fragment key={idx}> + {idx > 0 && <span className="text-muted/50">›</span>} + {isWalk ? ( + <div className="flex items-center gap-2 rounded-full bg-surface px-3 py-1.5 text-sm text-text whitespace-nowrap border border-border"> + <Footprints className="w-4 h-4 text-muted" /> + <span className="font-semibold"> + {formatDuration(legDurationMinutes, t)} + </span> + </div> + ) : ( + <div className="flex items-center gap-2"> + <RouteIcon + line={ + leg.routeShortName || leg.routeName || leg.mode || "" + } + mode="pill" + colour={leg.routeColor || ""} + textColour={leg.routeTextColor || ""} + /> + </div> + )} + </React.Fragment> + ); + })} </div> <div className="flex items-center justify-between text-sm text-muted mt-1"> @@ -211,6 +228,40 @@ const ItineraryDetail = ({ const [nextArrivals, setNextArrivals] = useState< Record<string, StopEstimatesResponse> >({}); + const [selectedLegIndex, setSelectedLegIndex] = useState<number | null>(null); + const [layoutMode, setLayoutMode] = useState<"balanced" | "map" | "list">( + "balanced" + ); + + const focusLegOnMap = (leg: Itinerary["legs"][number]) => { + if (!mapRef.current) return; + + const bounds = new maplibregl.LngLatBounds(); + leg.geometry?.coordinates?.forEach((coord) => + bounds.extend([coord[0], coord[1]]) + ); + + if (leg.from?.lon && leg.from?.lat) { + bounds.extend([leg.from.lon, leg.from.lat]); + } + + if (leg.to?.lon && leg.to?.lat) { + bounds.extend([leg.to.lon, leg.to.lat]); + } + + if (!bounds.isEmpty()) { + mapRef.current.fitBounds(bounds, { padding: 90, duration: 800 }); + return; + } + + if (leg.from?.lon && leg.from?.lat) { + mapRef.current.flyTo({ + center: [leg.from.lon, leg.from.lat], + zoom: 15, + duration: 800, + }); + } + }; const routeGeoJson = { type: "FeatureCollection", @@ -283,10 +334,41 @@ const ItineraryDetail = ({ return { type: "FeatureCollection", features }; }, [itinerary]); - // Get origin and destination coordinates const origin = itinerary.legs[0]?.from; const destination = itinerary.legs[itinerary.legs.length - 1]?.to; + const mapHeightClass = + layoutMode === "map" + ? "h-[78%]" + : layoutMode === "list" + ? "h-[35%]" + : "h-[50%]"; + + const detailHeightClass = + layoutMode === "map" + ? "h-[22%]" + : layoutMode === "list" + ? "h-[65%]" + : "h-[50%]"; + + const layoutOptions = [ + { + id: "map", + label: t("routes.layout_map", "Mapa"), + icon: MapIcon, + }, + { + id: "balanced", + label: t("routes.layout_balanced", "Equilibrada"), + icon: LayoutGrid, + }, + { + id: "list", + label: t("routes.layout_list", "Paradas"), + icon: List, + }, + ] as const; + useEffect(() => { if (!mapRef.current) return; @@ -362,7 +444,7 @@ const ItineraryDetail = ({ return ( <div className="flex flex-col md:flex-row h-full"> {/* Map Section */} - <div className="relative h-2/3 md:h-full md:flex-1"> + <div className={`${mapHeightClass} relative md:h-full md:flex-1`}> <AppMap ref={mapRef} initialViewState={{ @@ -465,7 +547,7 @@ const ItineraryDetail = ({ ]} layout={{ "text-field": ["get", "index"], - "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"], + "text-font": ["Noto Sans Bold"], "text-size": [ "interpolate", ["linear"], @@ -485,204 +567,303 @@ const ItineraryDetail = ({ /> </Source> </AppMap> + + <div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-full border border-border bg-background/90 p-1 shadow-sm backdrop-blur"> + {layoutOptions.map((option) => { + const Icon = option.icon; + const isActive = layoutMode === option.id; + return ( + <button + key={option.id} + type="button" + onClick={() => setLayoutMode(option.id)} + className={`h-8 w-8 rounded-full flex items-center justify-center transition-colors ${ + isActive + ? "bg-primary text-white" + : "text-muted hover:text-text" + }`} + aria-label={option.label} + title={option.label} + > + <Icon size={16} /> + </button> + ); + })} + </div> </div> {/* Details Panel */} - <div className="h-1/3 md:h-full md:w-96 lg:w-lg overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700"> + <div + className={`${detailHeightClass} md:h-full md:w-96 lg:w-lg overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700`} + > <div className="px-4 py-4"> <h2 className="text-xl font-bold mb-4 text-slate-900 dark:text-slate-100"> {t("planner.itinerary_details")} </h2> <div> - {itinerary.legs.map((leg, idx) => ( - <div key={idx} className="flex gap-3"> - <div className="flex flex-col items-center w-20 shrink-0"> - {leg.mode === "WALK" ? ( - <div - className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shadow-sm" - style={{ backgroundColor: "#e5e7eb", color: "#374151" }} - > - <Footprints className="w-4 h-4" /> - </div> - ) : ( - <RouteIcon - line={leg.routeShortName || leg.routeName || ""} - mode="rounded" - colour={leg.routeColor || undefined} - textColour={leg.routeTextColor || undefined} - /> - )} - {idx < itinerary.legs.length - 1 && ( - <div className="w-0.5 flex-1 bg-gray-300 dark:bg-gray-600 my-1"></div> - )} - </div> - <div className="flex-1 pb-4"> - <div className="font-bold flex items-center gap-2"> + {itinerary.legs.map((leg, idx) => { + const arrivalsForLeg = + leg.mode !== "WALK" && leg.from?.stopId && leg.to?.stopId + ? ( + nextArrivals[`${leg.from.stopId}::${leg.to.stopId}`] + ?.arrivals ?? [] + ) + .map((arrival) => ({ + arrival, + minutes: arrival.estimate.minutes, + delay: arrival.delay, + })) + .slice(0, 4) + : []; + + const legDestinationLabel = (() => { + if (leg.mode !== "WALK") { + return ( + leg.to?.name || t("planner.unknown_stop", "Unknown stop") + ); + } + + const enteredDest = userDestination?.name || ""; + const finalDest = + enteredDest || + itinerary.legs[itinerary.legs.length - 1]?.to?.name || + ""; + const raw = leg.to?.name || finalDest || ""; + const cleaned = raw.trim(); + const placeholder = cleaned.toLowerCase(); + + if ( + placeholder === "destination" || + placeholder === "destino" || + placeholder === "destinación" || + placeholder === "destinatario" + ) { + return enteredDest || finalDest; + } + + return cleaned || finalDest; + })(); + + return ( + <div key={idx} className="flex gap-3 mb-3"> + <div className="flex flex-col items-center w-12 shrink-0 pt-1"> {leg.mode === "WALK" ? ( - t("planner.walk") - ) : ( - <div className="flex flex-col"> - <span className="text-[10px] uppercase text-muted font-bold leading-none mb-1"> - {t("planner.direction")} - </span> - <span className="leading-tight"> - {leg.headsign || - leg.routeLongName || - leg.routeName || - ""} - </span> + <div + className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shadow-sm" + style={{ backgroundColor: "#e5e7eb", color: "#374151" }} + > + <Footprints className="w-4 h-4" /> </div> + ) : ( + <RouteIcon + line={leg.routeShortName || leg.routeName || ""} + mode="rounded" + colour={leg.routeColor || ""} + textColour={leg.routeTextColor || ""} + /> )} - </div> - <div className="text-sm text-gray-600 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-1 mt-1"> - <span> - {new Date(leg.startTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - timeZone: "Europe/Madrid", - })}{" "} - </span> - <span>•</span> - <span> - {formatDuration( - Math.round( - (new Date(leg.endTime).getTime() - - new Date(leg.startTime).getTime()) / - 60000 - ), - t - )} - </span> - <span>•</span> - <span>{formatDistance(leg.distanceMeters)}</span> - {leg.agencyName && ( - <> - <span>•</span> - <span className="italic">{leg.agencyName}</span> - </> + {idx < itinerary.legs.length - 1 && ( + <div className="w-0.5 flex-1 bg-gray-300 dark:bg-gray-600 my-1 min-h-6"></div> )} </div> - {leg.mode !== "WALK" && - leg.from?.stopId && - leg.to?.stopId && - nextArrivals[`${leg.from.stopId}::${leg.to.stopId}`] && ( - <div className="mt-2 text-xs text-gray-600 dark:text-gray-400"> - <div className="font-semibold mb-1"> - {t("planner.next_arrivals", "Next arrivals")}: + <button + type="button" + onClick={() => { + setSelectedLegIndex(idx); + focusLegOnMap(leg); + }} + className={`flex-1 rounded-xl border p-3 text-left transition-colors ${ + selectedLegIndex === idx + ? "border-primary bg-primary/5" + : "border-border bg-surface hover:border-primary/50" + }`} + > + <div className="font-bold flex items-center gap-2"> + {leg.mode === "WALK" ? ( + t("planner.walk") + ) : ( + <div className="flex flex-col"> + <span className="text-[10px] uppercase text-muted font-bold leading-none mb-1"> + {t("planner.direction")} + </span> + <span className="leading-tight"> + {leg.headsign || + leg.routeLongName || + leg.routeName || + ""} + </span> </div> - {nextArrivals[ - `${leg.from.stopId}::${leg.to.stopId}` - ].arrivals - .slice(0, 3) - .map((arrival, i) => ( - <div - key={`${arrival.tripId}-${i}`} - className="flex items-center gap-2 py-0.5" - > - <span className="font-semibold text-primary-600 dark:text-primary-400"> - {formatDuration(arrival.estimate.minutes, t)} - </span> - {arrival.estimate.precision !== "scheduled" && ( - <span className="text-green-600 dark:text-green-400"> - 🟢 - </span> - )} - {arrival.delay?.minutes !== undefined && - arrival.delay.minutes !== 0 && ( + )} + </div> + <div className="text-sm text-gray-600 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-1 mt-1"> + <span> + {new Date(leg.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + timeZone: "Europe/Madrid", + })}{" "} + </span> + <span>•</span> + <span> + {formatDuration( + Math.round( + (new Date(leg.endTime).getTime() - + new Date(leg.startTime).getTime()) / + 60000 + ), + t + )} + </span> + <span>•</span> + <span>{formatDistance(leg.distanceMeters)}</span> + {leg.agencyName && ( + <> + <span>•</span> + <span className="italic">{leg.agencyName}</span> + </> + )} + </div> + {leg.mode !== "WALK" && arrivalsForLeg.length > 0 && ( + <div className="mt-2"> + <div className="text-[10px] uppercase tracking-wide text-muted mb-1"> + {t("planner.next_arrivals", "Next arrivals")} + </div> + <div className="flex items-center justify-between gap-2 rounded-lg border border-green-500/30 bg-green-500/10 px-2.5 py-2"> + <span className="text-[11px] font-semibold uppercase tracking-wide text-green-700 dark:text-green-300"> + {t("planner.next_arrival", "Next")} + </span> + <span className="inline-flex min-w-16 items-center justify-center rounded-xl bg-green-600 px-3 py-1.5 text-base font-bold leading-none text-white"> + {arrivalsForLeg[0].minutes}′ + {arrivalsForLeg[0].delay?.minutes + ? arrivalsForLeg[0].delay.minutes > 0 + ? ` (R${Math.abs(arrivalsForLeg[0].delay.minutes)})` + : ` (A${Math.abs(arrivalsForLeg[0].delay.minutes)})` + : ""} + </span> + </div> + + {arrivalsForLeg.length > 1 && ( + <div className="mt-2 flex flex-wrap justify-end gap-1"> + {arrivalsForLeg + .slice(1) + .map( + ({ arrival, minutes, delay }, arrivalIdx) => ( <span - className={ - arrival.delay.minutes > 0 - ? "text-red-500" - : "text-green-500" - } + key={`${arrival.tripId}-${arrivalIdx}`} + className="text-[11px] px-2 py-0.5 bg-primary/10 text-primary rounded" > - {arrival.delay.minutes > 0 - ? `+${arrival.delay.minutes}′` - : `${arrival.delay.minutes}′`} + {minutes}′ + {delay?.minutes + ? delay.minutes > 0 + ? ` (R${Math.abs(delay.minutes)})` + : ` (A${Math.abs(delay.minutes)})` + : ""} </span> - )} - </div> - ))} + ) + )} + </div> + )} </div> )} - <div className="text-sm mt-1"> - {leg.mode === "WALK" ? ( - <span> - {t("planner.walk_to", { - distance: Math.round(leg.distanceMeters) + "m", - destination: (() => { - const enteredDest = userDestination?.name || ""; - const finalDest = - enteredDest || - itinerary.legs[itinerary.legs.length - 1]?.to - ?.name || - ""; - const raw = leg.to?.name || finalDest || ""; - const cleaned = raw.trim(); - const placeholder = cleaned.toLowerCase(); - // If OTP provided a generic placeholder, use the user's entered destination - if ( - placeholder === "destination" || - placeholder === "destino" || - placeholder === "destinación" || - placeholder === "destinatario" - ) { - return enteredDest || finalDest; - } - return cleaned || finalDest; - })(), - })} - </span> - ) : ( - <> + <div className="text-sm mt-2"> + {leg.mode === "WALK" ? ( <span> - {t("planner.from_to", { - from: leg.from?.name, - to: leg.to?.name, + {t("planner.walk_to", { + distance: Math.round(leg.distanceMeters) + "m", + destination: legDestinationLabel, })} </span> - {leg.intermediateStops && - leg.intermediateStops.length > 0 && ( - <details className="mt-2"> - <summary className="text-xs text-gray-600 dark:text-gray-400 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200"> - {leg.intermediateStops.length}{" "} - {leg.intermediateStops.length === 1 - ? "stop" - : "stops"} - </summary> - <ul className="mt-1 ml-4 text-xs text-gray-500 dark:text-gray-400 space-y-0.5"> - {leg.intermediateStops.map((stop, idx) => ( - <li key={idx}>• {stop.name}</li> - ))} - </ul> - </details> - )} - {(() => { - const municipality = getUrbanMunicipalityWarning(leg); - if (!municipality) return null; - return ( - <div className="mt-2 flex items-start gap-2 rounded-md bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 px-3 py-2 text-xs text-yellow-800 dark:text-yellow-200"> - <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-yellow-600 dark:text-yellow-400" /> - <div> - <div className="font-semibold"> - {t("planner.urban_traffic_warning")} - </div> - <div> - {t("planner.urban_traffic_warning_desc", { - municipality, + ) : ( + <> + <span> + {t("planner.from_to", { + from: leg.from?.name, + to: leg.to?.name, + })} + </span> + + {leg.intermediateStops && + leg.intermediateStops.length > 0 && ( + <details className="mt-2"> + <summary className="text-xs text-gray-600 dark:text-gray-400 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200"> + {t("planner.intermediate_stops", { + count: leg.intermediateStops.length, })} + </summary> + <ul className="mt-1 text-xs space-y-0.5"> + {/* Boarding stop */} + <li className="flex items-center gap-1.5 py-0.5 px-1.5 rounded bg-primary/8 font-semibold text-primary"> + <span className="w-1.5 h-1.5 rounded-full bg-primary inline-block shrink-0" /> + <span className="flex-1"> + {leg.from?.name} + </span> + {leg.from?.stopCode && ( + <span className="text-[10px] text-primary/60 shrink-0"> + {leg.from.stopCode} + </span> + )} + </li> + {/* Intermediate stops */} + {leg.intermediateStops.map((stop, sIdx) => ( + <li + key={sIdx} + className="flex items-center gap-1.5 py-0.5 px-1.5 text-gray-500 dark:text-gray-400" + > + <span className="w-1 h-1 rounded-full bg-gray-400 dark:bg-gray-500 inline-block shrink-0 ml-0.5" /> + <span className="flex-1"> + {stop.name} + </span> + {stop.stopCode && ( + <span className="text-[10px] text-gray-400 dark:text-gray-500 shrink-0"> + {stop.stopCode} + </span> + )} + </li> + ))} + {/* Alighting stop */} + <li className="flex items-center gap-1.5 py-0.5 px-1.5 rounded bg-primary/8 font-semibold text-primary"> + <span className="w-1.5 h-1.5 rounded-full bg-primary inline-block shrink-0" /> + <span className="flex-1"> + {leg.to?.name} + </span> + {leg.to?.stopCode && ( + <span className="text-[10px] text-primary/60 shrink-0"> + {leg.to.stopCode} + </span> + )} + </li> + </ul> + </details> + )} + + {(() => { + const municipality = + getUrbanMunicipalityWarning(leg); + if (!municipality) return null; + return ( + <div className="mt-2 flex items-start gap-2 rounded-md bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 px-3 py-2 text-xs text-yellow-800 dark:text-yellow-200"> + <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-yellow-600 dark:text-yellow-400" /> + <div> + <div className="font-semibold"> + {t("planner.urban_traffic_warning")} + </div> + <div> + {t("planner.urban_traffic_warning_desc", { + municipality, + })} + </div> </div> </div> - </div> - ); - })()} - </> - )} - </div> + ); + })()} + </> + )} + </div> + </button> </div> - </div> - ))} + ); + })} </div> </div> </div> @@ -707,6 +888,7 @@ export default function PlannerPage() { setOrigin, setDestination, } = usePlanner(); + const { userLocation } = useGeolocation(); const [selectedItinerary, setSelectedItinerary] = useState<Itinerary | null>( null ); @@ -802,27 +984,16 @@ export default function PlannerPage() { onClick={() => { clearRoute(); setDestination(null); - if (navigator.geolocation) { - navigator.geolocation.getCurrentPosition( - async (pos) => { - const initial = { - name: t("planner.current_location"), - label: "GPS", - lat: pos.coords.latitude, - lon: pos.coords.longitude, - layer: "current-location", - } as any; - setOrigin(initial); - }, - () => { - // If geolocation fails, just keep origin empty - }, - { - enableHighAccuracy: true, - timeout: 10000, - maximumAge: 60 * 60 * 1000, // 1 hour in milliseconds - } - ); + if (userLocation) { + setOrigin({ + name: t("planner.current_location"), + label: "GPS", + lat: userLocation.latitude, + lon: userLocation.longitude, + layer: "current-location", + }); + } else { + setOrigin(null); } }} className="text-sm text-red-500" |
