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/routes/map.tsx | 311 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 287 insertions(+), 24 deletions(-) (limited to 'src/frontend/app/routes/map.tsx') 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 */} +
+ +
+ +
+ + )}
); } -- cgit v1.3