import { Check, MapPin, Navigation, Search, X } from "lucide-react"; import type { FilterSpecification } from "maplibre-gl"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Layer, Source, type MapLayerMouseEvent, type MapRef, } from "react-map-gl/maplibre"; import { useNavigate } from "react-router"; import { useApp } from "~/AppContext"; import { StopSummarySheet, type StopSheetProps, } from "~/components/map/StopSummarySheet"; import { AppMap } from "~/components/shared/AppMap"; import { usePageTitle } from "~/contexts/PageTitleContext"; 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(); const { showBusStops: showCitybusStops, showCoachStops: showIntercityBusStops, showTrainStops, } = useApp(); const navigate = useNavigate(); usePageTitle(t("navbar.map", "Mapa")); const [selectedStop, setSelectedStop] = useState< StopSheetProps["stop"] | null >(null); const [isSheetOpen, setIsSheetOpen] = useState(false); const [disambiguationStops, setDisambiguationStops] = useState< Array >([]); const mapRef = useRef(null); const { pickingMode, setPickingMode, setOrigin, setDestination, addRecentPlace, } = usePlanner({ autoLoad: false }); 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(); setIsConfirming(true); try { const result = await reverseGeocode(center.lat, center.lng); const finalResult = { name: result?.name || `${center.lat.toFixed(5)}, ${center.lng.toFixed(5)}`, label: result?.label || "Map location", lat: center.lat, lon: center.lng, layer: "map-pick", }; if (pickingMode === "origin") { setOrigin(finalResult); } else { setDestination(finalResult); } addRecentPlace(finalResult); setPickingMode(null); navigate("/planner"); } catch (err) { console.error("Failed to reverse geocode:", err); } finally { setIsConfirming(false); } }; const favouriteIds = useMemo(() => StopDataProvider.getFavouriteIds(), []); const favouriteFilter = useMemo(() => { if (favouriteIds.length === 0) return ["boolean", false]; return ["match", ["get", "id"], favouriteIds, true, false]; }, [favouriteIds]); // Handle click events on clusters and individual stops const onMapClick = (e: MapLayerMouseEvent) => { const features = e.features; if (!features || features.length === 0) { console.debug( "No features found on map click. Position:", e.lngLat, "Point:", e.point ); return; } // Collect only stop-layer features with valid properties const stopFeatures = features.filter( (f) => f.layer?.id?.startsWith("stops") && f.properties?.id ); if (stopFeatures.length === 0) return; if (stopFeatures.length === 1) { // Single unambiguous stop – open the sheet directly handlePointClick(stopFeatures[0]); return; } // Multiple overlapping stops – deduplicate by stop id and ask the user const seen = new Set(); const candidates: Array = []; for (const f of stopFeatures) { const id: string = f.properties!.id; if (!seen.has(id)) { seen.add(id); candidates.push({ stopId: id, stopCode: f.properties!.code, name: f.properties!.name || "Unknown Stop", }); } } if (candidates.length === 1) { // After deduplication only one stop remains setSelectedStop(candidates[0]); setIsSheetOpen(true); } else { setDisambiguationStops(candidates); } }; const stopLayerFilter = useMemo(() => { const filter: any[] = ["any", ["==", ["get", "transitKind"], "unknown"]]; if (showCitybusStops) { filter.push(["==", ["get", "transitKind"], "bus"]); } if (showIntercityBusStops) { filter.push(["==", ["get", "transitKind"], "coach"]); } if (showTrainStops) { filter.push(["==", ["get", "transitKind"], "train"]); } return filter as FilterSpecification; }, [showCitybusStops, showIntercityBusStops, showTrainStops]); const handlePointClick = (feature: any) => { const props: { id: string; code: string; name: string; routes: string; } = feature.properties; // TODO: Move ID to constant, improve type checking if (!props || feature.layer.id.startsWith("stops") === false) { console.warn("Invalid feature properties:", props); return; } setSelectedStop({ stopId: props.id, stopCode: props.code, name: props.name || "Unknown Stop", }); setIsSheetOpen(true); }; return (
{!pickingMode && } {pickingMode && (

{pickingMode === "origin" ? t("planner.pick_origin", "Select origin") : t("planner.pick_destination", "Select destination")}

{t( "planner.pick_instruction", "Move the map to place the target on the desired location" )}

)} {pickingMode && (
{/* Modern discrete target */}
)} { closeContextMenu(); onMapClick(e); }} onContextMenu={handleContextMenu} attributionControl={{ compact: false }} > {!pickingMode && ( )} {selectedStop && ( setIsSheetOpen(false)} stop={selectedStop} /> )} {disambiguationStops.length > 1 && (

{t("map.select_nearby_stop", "Seleccionar parada")}

    {disambiguationStops.map((stop) => (
  • ))}
)}
{contextMenu && ( <> {/* Dismiss backdrop */}
{/* Context menu */}
)}
); }