diff options
Diffstat (limited to 'src/frontend/app/routes')
| -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 |
3 files changed, 703 insertions, 353 deletions
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" |
