From 8b4252dc937d6c937bd718515f03dd48948a1519 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:12:36 +0000 Subject: feat: geolocation hook, map context menu, simplified home planner widget" Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> --- src/frontend/app/components/PlannerOverlay.tsx | 24 +--- src/frontend/app/components/shared/AppMap.tsx | 10 ++ src/frontend/app/contexts/MapContext.tsx | 158 ++++++++++++++++++------- src/frontend/app/hooks/useGeolocation.ts | 59 +++++++++ src/frontend/app/i18n/locales/en-GB.json | 4 +- src/frontend/app/i18n/locales/es-ES.json | 4 +- src/frontend/app/i18n/locales/gl-ES.json | 4 +- src/frontend/app/routes/home.tsx | 108 ++--------------- src/frontend/app/routes/map.tsx | 133 ++++++++++++++++++++- src/frontend/app/routes/planner.tsx | 33 ++---- 10 files changed, 352 insertions(+), 185 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/shared/AppMap.tsx b/src/frontend/app/components/shared/AppMap.tsx index c6eb8ee..d4ad557 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, @@ -200,6 +204,7 @@ export const AppMap = forwardRef( onRotateStart={onRotateStart} onPitchStart={onPitchStart} onLoad={onLoad} + onContextMenu={onContextMenu} > {showNavigation && } {showGeolocate && ( @@ -207,6 +212,11 @@ export const AppMap = forwardRef( 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..195c6e6 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,129 @@ 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 + // 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 (mapState.hasLocationPermission) { + startWatching(); + } + } catch { + if (mapState.hasLocationPermission) { + startWatching(); + } } - } - }, [mapState.hasLocationPermission, mapState.userLocation]); + }; + + init(); + + return () => { + if (watchIdRef.current !== null) { + navigator.geolocation.clearWatch(watchIdRef.current); + watchIdRef.current = null; + } + permissionStatus?.removeEventListener("change", onPermChange); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); 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..572a40c --- /dev/null +++ b/src/frontend/app/hooks/useGeolocation.ts @@ -0,0 +1,59 @@ +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)) { + // Stored as [lat, lng] per codebase convention + 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..17c232f 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -103,7 +103,9 @@ "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" }, "planner": { "where_to": "Where do you want to go?", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 5e65a88..e7d516e 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -103,7 +103,9 @@ "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í" }, "planner": { "where_to": "¿A donde quieres ir?", diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 2c874d8..baa3998 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -103,7 +103,9 @@ "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í" }, "planner": { "where_to": "Onde queres ir?", 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..8149d30 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, X } from "lucide-react"; import type { FilterSpecification } from "maplibre-gl"; -import { useMemo, useRef, useState } from "react"; +import { useRef, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Layer, @@ -53,6 +53,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(); @@ -252,9 +327,13 @@ export default function StopMap() { showGeolocate={true} showTraffic={pickingMode ? false : undefined} interactiveLayerIds={["stops", "stops-label"]} - onClick={onMapClick} + onClick={(e) => { + closeContextMenu(); + onMapClick(e); + }} onDragStart={onMapInteraction} onZoomStart={onMapInteraction} + 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 b7ecaf9..ff13225 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -13,6 +13,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"; @@ -721,6 +722,7 @@ export default function PlannerPage() { setOrigin, setDestination, } = usePlanner(); + const { userLocation } = useGeolocation(); const [selectedItinerary, setSelectedItinerary] = useState( null ); @@ -816,27 +818,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