From ece17875d4e454423f55f0623a456c0433ecd502 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 13 Mar 2026 17:12:12 +0100 Subject: 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. --- src/frontend/app/components/PlannerOverlay.tsx | 24 +- src/frontend/app/components/layout/NavBar.tsx | 7 +- src/frontend/app/components/shared/AppMap.tsx | 30 +- src/frontend/app/contexts/MapContext.tsx | 159 ++++-- src/frontend/app/hooks/useGeolocation.ts | 62 +++ src/frontend/app/i18n/locales/en-GB.json | 12 +- src/frontend/app/i18n/locales/es-ES.json | 12 +- src/frontend/app/i18n/locales/gl-ES.json | 12 +- src/frontend/app/routes/home.tsx | 108 +---- src/frontend/app/routes/map.tsx | 311 +++++++++++- src/frontend/app/routes/planner.tsx | 637 ++++++++++++++++--------- 11 files changed, 943 insertions(+), 431 deletions(-) create mode 100644 src/frontend/app/hooks/useGeolocation.ts 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ { 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"; @@ -52,6 +52,11 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { ); }, }, + { + name: t("navbar.planner", "Planificador"), + icon: Navigation, + path: "/planner", + }, { name: t("navbar.routes", "Rutas"), icon: Route, 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( @@ -72,6 +73,7 @@ export const AppMap = forwardRef( onRotateStart, onPitchStart, onLoad, + onContextMenu, }, ref ) => { @@ -79,6 +81,8 @@ export const AppMap = forwardRef( theme, mapState, updateMapState, + setUserLocation, + setLocationPermission, showTraffic: settingsShowTraffic, showCameras: settingsShowCameras, mapPositionMode, @@ -159,14 +163,9 @@ export const AppMap = forwardRef( 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( }; } + 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( onRotateStart={onRotateStart} onPitchStart={onPitchStart} onLoad={onLoad} + onContextMenu={onContextMenu} > {showNavigation && } {showGeolocate && ( { + 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(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(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 ( { 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(null); const [loading, setLoading] = useState(true); const [searchResults, setSearchResults] = useState(null); const [favouriteStops, setFavouriteStops] = useState([]); const [recentStops, setRecentStops] = useState([]); - const [userLocation, setUserLocation] = useState<{ - latitude: number; - longitude: number; - } | null>(null); const searchTimeout = useRef(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() {
{/* Planner Section */}
-
- -
-
- - - {t("planner.where_to", "¿A dónde quieres ir?")} - -
-
- ↓ -
-
-
- - { - searchRoute(origin, destination, time, arriveBy); - }} - onNavigateToPlanner={() => navigate("/planner")} - /> -
+ {history.length > 0 && (
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; +} + +function MapSearchBar({ mapRef }: MapSearchBarProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [query, setQuery] = useState(mapSearchState.query); + const [results, setResults] = useState( + mapSearchState.results + ); + const [showResults, setShowResults] = useState( + mapSearchState.results.length > 0 + ); + const [loading, setLoading] = useState(false); + const containerRef = useRef(null); + const inputRef = useRef(null); + const debounceRef = useRef(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 ( +
+
+ {/* Search input */} +
+ + handleQueryChange(e.target.value)} + onFocus={() => { + if (results.length > 0) setShowResults(true); + }} + /> + {loading ? ( +
+ ) : query ? ( + + ) : null} +
+ + {/* Results dropdown */} + {showResults && results.length > 0 && ( +
+
+ {results.map((place, i) => ( + + ))} +
+
+ )} +
+
+ ); +} + // Componente principal del mapa export default function StopMap() { const { t } = useTranslation(); @@ -43,7 +199,6 @@ export default function StopMap() { const mapRef = useRef(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(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 (
- {!pickingMode && ( - searchRoute(o, d, time, arriveBy)} - onNavigateToPlanner={() => navigate("/planner")} - clearPickerOnOpen={true} - showLastDestinationWhenCollapsed={false} - cardBackground="bg-white/95 dark:bg-slate-900/90" - autoLoad={false} - /> - )} + {!pickingMode && } {pickingMode && (
@@ -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 }} > )} + + {contextMenu && ( + <> + {/* Dismiss backdrop */} +
+ {/* Context menu */} +
+ +
+ +
+ + )}
); } 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 = ({
- {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 ( - - {idx > 0 && } - {isWalk ? ( -
- - - {formatDuration(legDurationMinutes, t)} - -
- ) : ( -
- -
- )} -
- ); - })} + return ( + + {idx > 0 && } + {isWalk ? ( +
+ + + {formatDuration(legDurationMinutes, t)} + +
+ ) : ( +
+ +
+ )} +
+ ); + })}
@@ -211,6 +228,40 @@ const ItineraryDetail = ({ const [nextArrivals, setNextArrivals] = useState< Record >({}); + const [selectedLegIndex, setSelectedLegIndex] = useState(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 (
{/* Map Section */} -
+
+ +
+ {layoutOptions.map((option) => { + const Icon = option.icon; + const isActive = layoutMode === option.id; + return ( + + ); + })} +
{/* Details Panel */} -
+

{t("planner.itinerary_details")}

- {itinerary.legs.map((leg, idx) => ( -
-
- {leg.mode === "WALK" ? ( -
- -
- ) : ( - - )} - {idx < itinerary.legs.length - 1 && ( -
- )} -
-
-
+ {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 ( +
+
{leg.mode === "WALK" ? ( - t("planner.walk") - ) : ( -
- - {t("planner.direction")} - - - {leg.headsign || - leg.routeLongName || - leg.routeName || - ""} - +
+
+ ) : ( + )} -
-
- - {new Date(leg.startTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - timeZone: "Europe/Madrid", - })}{" "} - - - - {formatDuration( - Math.round( - (new Date(leg.endTime).getTime() - - new Date(leg.startTime).getTime()) / - 60000 - ), - t - )} - - - {formatDistance(leg.distanceMeters)} - {leg.agencyName && ( - <> - - {leg.agencyName} - + {idx < itinerary.legs.length - 1 && ( +
)}
- {leg.mode !== "WALK" && - leg.from?.stopId && - leg.to?.stopId && - nextArrivals[`${leg.from.stopId}::${leg.to.stopId}`] && ( -
-
- {t("planner.next_arrivals", "Next arrivals")}: +
-
- ))} + ); + })}
@@ -707,6 +888,7 @@ export default function PlannerPage() { setOrigin, setDestination, } = usePlanner(); + const { userLocation } = useGeolocation(); const [selectedItinerary, setSelectedItinerary] = useState( 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" -- cgit v1.3