diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-12 16:48:14 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-12 16:48:46 +0100 |
| commit | e7eb57bf492617f2b9be88d46c1cc708a2c17af4 (patch) | |
| tree | 490e5ade4dc618760d30a8805dd94cc8dc586e2f /src/frontend/app/routes | |
| parent | 2f0fd3f348bb836839f4a72e3af072b56954d878 (diff) | |
Improved version of the planner feature
Diffstat (limited to 'src/frontend/app/routes')
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 205 | ||||
| -rw-r--r-- | src/frontend/app/routes/planner.tsx | 700 |
2 files changed, 581 insertions, 324 deletions
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 182f4ce..3d59efb 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -14,14 +14,19 @@ import Map, { type MapRef, type StyleSpecification, } from "react-map-gl/maplibre"; +import { useNavigate } from "react-router"; +import { PlannerOverlay } from "~/components/PlannerOverlay"; import { StopSheet } from "~/components/StopSummarySheet"; import { REGION_DATA } from "~/config/RegionConfig"; import { usePageTitle } from "~/contexts/PageTitleContext"; +import { usePlanner } from "~/hooks/usePlanner"; import { useApp } from "../AppContext"; +import "../tailwind-full.css"; // Componente principal del mapa export default function StopMap() { const { t } = useTranslation(); + const navigate = useNavigate(); usePageTitle(t("navbar.map", "Mapa")); const [stops, setStops] = useState< GeoJsonFeature< @@ -40,9 +45,38 @@ export default function StopMap() { const { mapState, updateMapState, theme } = useApp(); const mapRef = useRef<MapRef>(null); + const { searchRoute, origin, setOrigin } = usePlanner(); + // Style state for Map component const [mapStyle, setMapStyle] = useState<StyleSpecification>(DEFAULT_STYLE); + // Set default origin to current location on first load (map page) + useEffect(() => { + // On the map page, always default to current location on load, + // overriding any previously used address. The user can change it after. + if (!navigator.geolocation) return; + navigator.geolocation.getCurrentPosition( + async (pos) => { + try { + // Keep display as "Current location" until a search is performed + setOrigin({ + name: t("planner.current_location"), + label: "GPS", + lat: pos.coords.latitude, + lon: pos.coords.longitude, + layer: "current-location", + }); + } catch (_) { + // ignore + } + }, + () => { + // ignore geolocation errors; user can set origin manually + }, + { enableHighAccuracy: true, timeout: 10000 } + ); + }, [setOrigin, t]); + // Handle click events on clusters and individual stops const onMapClick = (e: MapLayerMouseEvent) => { const features = e.features; @@ -182,92 +216,101 @@ export default function StopMap() { }; return ( - <Map - mapStyle={mapStyle} - style={{ width: "100%", height: "100%" }} - interactiveLayerIds={["stops", "stops-label"]} - onClick={onMapClick} - minZoom={11} - scrollZoom - pitch={0} - roll={0} - ref={mapRef} - initialViewState={{ - latitude: getLatitude(mapState.center), - longitude: getLongitude(mapState.center), - zoom: mapState.zoom, - }} - attributionControl={{ compact: false }} - maxBounds={[REGION_DATA.bounds.sw, REGION_DATA.bounds.ne]} - > - <NavigationControl position="top-right" /> - <GeolocateControl - position="top-right" - trackUserLocation={true} - positionOptions={{ enableHighAccuracy: false }} + <div className="relative h-full"> + <PlannerOverlay + onSearch={(o, d, time, arriveBy) => searchRoute(o, d, time, arriveBy)} + onNavigateToPlanner={() => navigate("/planner")} + clearPickerOnOpen={true} + showLastDestinationWhenCollapsed={false} /> - <Source - id="stops-source" - type="geojson" - data={{ type: "FeatureCollection", features: stops }} - /> - - <Layer - id="stops" - type="symbol" - minzoom={11} - source="stops-source" - layout={{ - "icon-image": ["get", "prefix"], - "icon-size": [ - "interpolate", - ["linear"], - ["zoom"], - 13, - 0.7, - 16, - 0.8, - 18, - 1.2, - ], - "icon-allow-overlap": true, - "icon-ignore-placement": true, + <Map + mapStyle={mapStyle} + style={{ width: "100%", height: "100%" }} + interactiveLayerIds={["stops", "stops-label"]} + onClick={onMapClick} + minZoom={11} + scrollZoom + pitch={0} + roll={0} + ref={mapRef} + initialViewState={{ + latitude: getLatitude(mapState.center), + longitude: getLongitude(mapState.center), + zoom: mapState.zoom, }} - /> + attributionControl={{ compact: false }} + maxBounds={[REGION_DATA.bounds.sw, REGION_DATA.bounds.ne]} + > + <NavigationControl position="bottom-right" /> + <GeolocateControl + position="bottom-right" + trackUserLocation={true} + positionOptions={{ enableHighAccuracy: false }} + /> - <Layer - id="stops-label" - type="symbol" - source="stops-source" - minzoom={16} - layout={{ - "text-field": ["get", "name"], - "text-font": ["Noto Sans Bold"], - "text-offset": [0, 3], - "text-anchor": "center", - "text-justify": "center", - "text-size": ["interpolate", ["linear"], ["zoom"], 11, 8, 22, 16], - }} - paint={{ - "text-color": [ - "case", - ["==", ["get", "prefix"], "stop-renfe"], - "#870164", - "#e72b37", - ], - "text-halo-color": "#FFF", - "text-halo-width": 1, - }} - /> + <Source + id="stops-source" + type="geojson" + data={{ type: "FeatureCollection", features: stops }} + /> - {selectedStop && ( - <StopSheet - isOpen={isSheetOpen} - onClose={() => setIsSheetOpen(false)} - stop={selectedStop} + <Layer + id="stops" + type="symbol" + minzoom={11} + source="stops-source" + layout={{ + "icon-image": ["get", "prefix"], + "icon-size": [ + "interpolate", + ["linear"], + ["zoom"], + 13, + 0.7, + 16, + 0.8, + 18, + 1.2, + ], + "icon-allow-overlap": true, + "icon-ignore-placement": true, + }} /> - )} - </Map> + + <Layer + id="stops-label" + type="symbol" + source="stops-source" + minzoom={16} + layout={{ + "text-field": ["get", "name"], + "text-font": ["Noto Sans Bold"], + "text-offset": [0, 3], + "text-anchor": "center", + "text-justify": "center", + "text-size": ["interpolate", ["linear"], ["zoom"], 11, 8, 22, 16], + }} + paint={{ + "text-color": [ + "case", + ["==", ["get", "prefix"], "stop-renfe"], + "#870164", + "#e72b37", + ], + "text-halo-color": "#FFF", + "text-halo-width": 1, + }} + /> + + {selectedStop && ( + <StopSheet + isOpen={isSheetOpen} + onClose={() => setIsSheetOpen(false)} + stop={selectedStop} + /> + )} + </Map> + </div> ); } diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index 094ff8e..b0fc9b1 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -1,102 +1,69 @@ +import { Coins, CreditCard, Footprints } from "lucide-react"; import maplibregl, { type StyleSpecification } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; -import React, { useEffect, useRef, useState } from "react"; -import Map, { Layer, Source, type MapRef } from "react-map-gl/maplibre"; -import { Sheet } from "react-modal-sheet"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; +import { useLocation } from "react-router"; + import { useApp } from "~/AppContext"; +import LineIcon from "~/components/LineIcon"; +import { PlannerOverlay } from "~/components/PlannerOverlay"; import { REGION_DATA } from "~/config/RegionConfig"; -import { - searchPlaces, - type Itinerary, - type PlannerSearchResult, -} from "~/data/PlannerApi"; +import { type Itinerary } from "~/data/PlannerApi"; import { usePlanner } from "~/hooks/usePlanner"; import { DEFAULT_STYLE, loadStyle } from "~/maps/styleloader"; import "../tailwind-full.css"; -// --- Components --- +const FARE_CASH_PER_BUS = 1.63; +const FARE_CARD_PER_BUS = 0.67; -const AutocompleteInput = ({ - label, - value, - onChange, - placeholder, -}: { - label: string; - value: PlannerSearchResult | null; - onChange: (val: PlannerSearchResult | null) => void; - placeholder: string; -}) => { - const [query, setQuery] = useState(value?.name || ""); - const [results, setResults] = useState<PlannerSearchResult[]>([]); - const [showResults, setShowResults] = useState(false); +const formatDistance = (meters: number) => { + const intMeters = Math.round(meters); + if (intMeters >= 1000) return `${(intMeters / 1000).toFixed(1)} km`; + return `${intMeters} m`; +}; - useEffect(() => { - if (value) setQuery(value.name || ""); - }, [value]); +const haversineMeters = (a: [number, number], b: [number, number]) => { + const toRad = (v: number) => (v * Math.PI) / 180; + const R = 6371000; + const dLat = toRad(b[1] - a[1]); + const dLon = toRad(b[0] - a[0]); + const lat1 = toRad(a[1]); + const lat2 = toRad(b[1]); + const sinDLat = Math.sin(dLat / 2); + const sinDLon = Math.sin(dLon / 2); + const h = + sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon; + return 2 * R * Math.asin(Math.sqrt(h)); +}; - useEffect(() => { - const timer = setTimeout(async () => { - if (query.length > 2 && query !== value?.name) { - const res = await searchPlaces(query); - setResults(res); - setShowResults(true); - } else { - setResults([]); +const sumWalkMetrics = (legs: Itinerary["legs"]) => { + let meters = 0; + let minutes = 0; + + legs.forEach((leg) => { + if (leg.mode === "WALK") { + if ( + typeof (leg as any).distanceMeters === "number" && + (leg as any).distanceMeters > 0 + ) { + meters += (leg as any).distanceMeters; + } else if (leg.geometry?.coordinates?.length) { + for (let i = 1; i < leg.geometry.coordinates.length; i++) { + const prev = leg.geometry.coordinates[i - 1] as [number, number]; + const curr = leg.geometry.coordinates[i] as [number, number]; + meters += haversineMeters(prev, curr); + } } - }, 500); - return () => clearTimeout(timer); - }, [query, value]); + const durationMinutes = + (new Date(leg.endTime).getTime() - new Date(leg.startTime).getTime()) / + 60000; + minutes += durationMinutes; + } + }); - return ( - <div className="mb-4 relative"> - <label className="block text-sm font-medium text-gray-700 mb-1"> - {label} - </label> - <div className="flex gap-2"> - <input - type="text" - className="w-full p-2 border rounded shadow-sm" - value={query} - onChange={(e) => { - setQuery(e.target.value); - if (!e.target.value) onChange(null); - }} - placeholder={placeholder} - onFocus={() => setShowResults(true)} - /> - {value && ( - <button - onClick={() => { - setQuery(""); - onChange(null); - }} - className="px-2 text-gray-500" - > - ✕ - </button> - )} - </div> - {showResults && results.length > 0 && ( - <ul className="absolute z-10 w-full bg-white border rounded shadow-lg mt-1 max-h-60 overflow-auto"> - {results.map((res, idx) => ( - <li - key={idx} - className="p-2 hover:bg-gray-100 cursor-pointer border-b last:border-b-0" - onClick={() => { - onChange(res); - setQuery(res.name || ""); - setShowResults(false); - }} - > - <div className="font-medium">{res.name}</div> - <div className="text-xs text-gray-500">{res.label}</div> - </li> - ))} - </ul> - )} - </div> - ); + return { meters, minutes: Math.max(0, Math.round(minutes)) }; }; const ItinerarySummary = ({ @@ -106,6 +73,7 @@ const ItinerarySummary = ({ itinerary: Itinerary; onClick: () => void; }) => { + const { t, i18n } = useTranslation(); const durationMinutes = Math.round(itinerary.durationSeconds / 60); const startTime = new Date(itinerary.startTime).toLocaleTimeString([], { hour: "2-digit", @@ -116,6 +84,26 @@ const ItinerarySummary = ({ minute: "2-digit", }); + const walkTotals = sumWalkMetrics(itinerary.legs); + const busLegsCount = itinerary.legs.filter( + (leg) => leg.mode !== "WALK" + ).length; + const cashFare = ( + itinerary.cashFareEuro ?? busLegsCount * FARE_CASH_PER_BUS + ).toFixed(2); + const cardFare = ( + itinerary.cardFareEuro ?? busLegsCount * FARE_CARD_PER_BUS + ).toFixed(2); + + // Format currency based on locale (ES/GL: "1,50 €", EN: "€1.50") + const formatCurrency = (amount: string) => { + const isSpanishOrGalician = + i18n.language.startsWith("es") || i18n.language.startsWith("gl"); + return isSpanishOrGalician + ? t("planner.cash_fare", { amount }) + : t("planner.cash_fare", { amount }); + }; + return ( <div className="bg-white p-4 rounded-lg shadow mb-3 cursor-pointer hover:bg-gray-50 border border-gray-200" @@ -127,24 +115,63 @@ const ItinerarySummary = ({ </div> <div className="text-gray-600">{durationMinutes} min</div> </div> + <div className="flex items-center gap-2 overflow-x-auto pb-2"> - {itinerary.legs.map((leg, idx) => ( - <React.Fragment key={idx}> - {idx > 0 && <span className="text-gray-400">›</span>} - <div - className={`px-2 py-1 rounded text-sm whitespace-nowrap ${ - leg.mode === "WALK" - ? "bg-gray-200 text-gray-700" - : "bg-blue-600 text-white" - }`} - > - {leg.mode === "WALK" ? "Walk" : leg.routeShortName || leg.mode} - </div> - </React.Fragment> - ))} + {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 + ) + ); + + const isFirstBusLeg = + !isWalk && + itinerary.legs.findIndex((l) => l.mode !== "WALK") === idx; + + return ( + <React.Fragment key={idx}> + {idx > 0 && <span className="text-slate-400">›</span>} + {isWalk ? ( + <div className="flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-800 whitespace-nowrap"> + <Footprints className="w-4 h-4 text-slate-600" /> + <span className="font-semibold"> + {legDurationMinutes} {t("estimates.minutes")} + </span> + </div> + ) : ( + <div className="flex items-center gap-2"> + <LineIcon + line={leg.routeShortName || leg.routeName || leg.mode || ""} + mode="rounded" + /> + </div> + )} + </React.Fragment> + ); + })} </div> - <div className="text-sm text-gray-500 mt-1"> - Walk: {Math.round(itinerary.walkDistanceMeters)}m + + <div className="flex items-center justify-between text-sm text-slate-600 mt-1"> + <span> + {t("planner.walk")}: {formatDistance(walkTotals.meters)} + {walkTotals.minutes + ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}` + : ""} + </span> + <span className="flex items-center gap-3"> + <span className="flex items-center gap-1 font-semibold text-slate-700"> + <Coins className="w-4 h-4" /> + {formatCurrency(cashFare)} + </span> + <span className="flex items-center gap-1 text-slate-600"> + <CreditCard className="w-4 h-4" /> + {t("planner.card_fare", { amount: cardFare })} + </span> + </span> </div> </div> ); @@ -157,43 +184,112 @@ const ItineraryDetail = ({ itinerary: Itinerary; onClose: () => void; }) => { + const { t } = useTranslation(); const mapRef = useRef<MapRef>(null); - const [sheetOpen, setSheetOpen] = useState(true); + const { destination: userDestination } = usePlanner(); - // Prepare GeoJSON for the route const routeGeoJson = { type: "FeatureCollection", features: itinerary.legs.map((leg) => ({ - type: "Feature", + type: "Feature" as const, geometry: { - type: "LineString", + type: "LineString" as const, coordinates: leg.geometry?.coordinates || [], }, properties: { mode: leg.mode, - color: leg.mode === "WALK" ? "#9ca3af" : "#2563eb", // Gray for walk, Blue for transit + color: + leg.mode === "WALK" + ? "#9ca3af" + : leg.routeColor + ? `#${leg.routeColor}` + : "#2563eb", }, })), }; - // Fit bounds on mount + // Collect unique stops with their roles (board, alight, transfer) + const stopMarkers = useMemo(() => { + const stopsMap: Record< + string, + { + lat: number; + lon: number; + name: string; + type: "board" | "alight" | "transfer"; + } + > = {}; + + itinerary.legs.forEach((leg, idx) => { + if (leg.mode !== "WALK") { + // Boarding stop + if (leg.from?.lat && leg.from?.lon) { + const key = `${leg.from.lat},${leg.from.lon}`; + if (!stopsMap[key]) { + const isTransfer = + idx > 0 && itinerary.legs[idx - 1].mode !== "WALK"; + stopsMap[key] = { + lat: leg.from.lat, + lon: leg.from.lon, + name: leg.from.name || "", + type: isTransfer ? "transfer" : "board", + }; + } + } + // Alighting stop + if (leg.to?.lat && leg.to?.lon) { + const key = `${leg.to.lat},${leg.to.lon}`; + if (!stopsMap[key]) { + const isTransfer = + idx < itinerary.legs.length - 1 && + itinerary.legs[idx + 1].mode !== "WALK"; + stopsMap[key] = { + lat: leg.to.lat, + lon: leg.to.lon, + name: leg.to.name || "", + type: isTransfer ? "transfer" : "alight", + }; + } + } + } + }); + + return Object.values(stopsMap); + }, [itinerary]); + + // Get origin and destination coordinates + const origin = itinerary.legs[0]?.from; + const destination = itinerary.legs[itinerary.legs.length - 1]?.to; + useEffect(() => { - if (mapRef.current && itinerary.legs.length > 0) { - const bounds = new maplibregl.LngLatBounds(); - itinerary.legs.forEach((leg) => { - leg.geometry?.coordinates.forEach((coord) => { - bounds.extend([coord[0], coord[1]]); + if (!mapRef.current) return; + + // Small delay to ensure map is fully loaded + const timer = setTimeout(() => { + if (mapRef.current && itinerary.legs.length > 0) { + const bounds = new maplibregl.LngLatBounds(); + + // Add all route coordinates to bounds + itinerary.legs.forEach((leg) => { + leg.geometry?.coordinates.forEach((coord) => + bounds.extend([coord[0], coord[1]]) + ); }); - }); - mapRef.current.fitBounds(bounds, { padding: 50 }); - } + + // Ensure bounds are valid before fitting + if (!bounds.isEmpty()) { + mapRef.current.fitBounds(bounds, { padding: 80, duration: 1000 }); + } + } + }, 100); + + return () => clearTimeout(timer); }, [itinerary]); const { theme } = useApp(); - const [mapStyle, setMapStyle] = useState<StyleSpecification>(DEFAULT_STYLE); + useEffect(() => { - //const styleName = "carto"; const styleName = "openfreemap"; loadStyle(styleName, theme) .then((style) => setMapStyle(style)) @@ -201,13 +297,16 @@ const ItineraryDetail = ({ }, [theme]); return ( - <div className="fixed inset-0 z-50 bg-white flex flex-col"> - <div className="relative flex-1"> + <div className="flex flex-col md:flex-row h-full"> + {/* Map Section */} + <div className="relative h-2/3 md:h-full md:flex-1"> <Map ref={mapRef} initialViewState={{ - longitude: REGION_DATA.defaultCenter.lng, - latitude: REGION_DATA.defaultCenter.lat, + longitude: + origin?.lon || (REGION_DATA.defaultCenter as [number, number])[0], + latitude: + origin?.lat || (REGION_DATA.defaultCenter as [number, number])[1], zoom: 13, }} mapStyle={mapStyle} @@ -217,185 +316,297 @@ const ItineraryDetail = ({ <Layer id="route-line" type="line" - layout={{ - "line-join": "round", - "line-cap": "round", - }} + layout={{ "line-join": "round", "line-cap": "round" }} paint={{ "line-color": ["get", "color"], "line-width": 5, + // Dotted for walking segments, solid for bus segments + "line-dasharray": [ + "case", + ["==", ["get", "mode"], "WALK"], + ["literal", [1, 3]], + ["literal", [1, 0]], + ], }} /> </Source> - {/* Markers for start/end/transfers could be added here */} + + {/* Origin marker (red) */} + {origin?.lat && origin?.lon && ( + <Marker longitude={origin.lon} latitude={origin.lat}> + <div className="w-6 h-6 bg-red-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center"> + <div className="w-2 h-2 bg-white rounded-full"></div> + </div> + </Marker> + )} + + {/* Destination marker (green) */} + {destination?.lat && destination?.lon && ( + <Marker longitude={destination.lon} latitude={destination.lat}> + <div className="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center"> + <div className="w-2 h-2 bg-white rounded-full"></div> + </div> + </Marker> + )} + + {/* Stop markers (boarding, alighting, transfer) */} + {stopMarkers.map((stop, idx) => ( + <Marker key={idx} longitude={stop.lon} latitude={stop.lat}> + <div + className={`w-5 h-5 rounded-full border-2 border-white shadow-md ${ + stop.type === "board" + ? "bg-blue-500" + : stop.type === "alight" + ? "bg-purple-500" + : "bg-orange-500" + }`} + title={`${stop.name} (${stop.type})`} + /> + </Marker> + ))} + + {/* Intermediate stops (smaller white dots) */} + {itinerary.legs.map((leg, legIdx) => + leg.intermediateStops?.map((stop, stopIdx) => ( + <Marker + key={`intermediate-${legIdx}-${stopIdx}`} + longitude={stop.lon} + latitude={stop.lat} + > + <div + className="w-3 h-3 rounded-full border border-gray-400 bg-white shadow-sm" + title={stop.name || "Intermediate stop"} + /> + </Marker> + )) + )} </Map> <button onClick={onClose} - className="absolute top-4 left-4 bg-white p-2 rounded-full shadow z-10" + className="absolute top-4 left-4 bg-white dark:bg-slate-800 p-2 px-4 rounded-lg shadow-lg z-10 font-semibold text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors" > - ← Back + {t("planner.back")} </button> </div> - <Sheet - isOpen={sheetOpen} - onClose={() => setSheetOpen(false)} - detent="content" - initialSnap={0} - > - <Sheet.Container> - <Sheet.Header /> - <Sheet.Content className="px-4 pb-4 overflow-y-auto"> - <h2 className="text-xl font-bold mb-4">Itinerary Details</h2> - <div className="space-y-4"> - {itinerary.legs.map((leg, idx) => ( - <div key={idx} className="flex gap-3"> - <div className="flex flex-col items-center"> + {/* Details Panel */} + <div className="h-1/3 md:h-full md:w-96 lg:w-[28rem] 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"> + {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"> + {leg.mode === "WALK" ? ( <div - className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${ - leg.mode === "WALK" - ? "bg-gray-200 text-gray-700" - : "bg-blue-600 text-white" - }`} + className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shadow-sm" + style={{ backgroundColor: "#e5e7eb", color: "#374151" }} > - {leg.mode === "WALK" ? "🚶" : "🚌"} + <Footprints className="w-4 h-4" /> </div> - {idx < itinerary.legs.length - 1 && ( - <div className="w-0.5 flex-1 bg-gray-300 my-1"></div> - )} - </div> - <div className="flex-1 pb-4"> - <div className="font-bold"> - {leg.mode === "WALK" - ? "Walk" - : `${leg.routeShortName} ${leg.headsign}`} - </div> - <div className="text-sm text-gray-600"> - {new Date(leg.startTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} - {" - "} - {new Date(leg.endTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} + ) : ( + <div + className="shadow-sm" + style={{ transform: "scale(0.9)" }} + > + <LineIcon + line={leg.routeShortName || leg.routeName || ""} + mode="rounded" + /> </div> - <div className="text-sm mt-1"> - {leg.mode === "WALK" ? ( + )} + {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"> + {leg.mode === "WALK" ? ( + t("planner.walk") + ) : ( + <> <span> - Walk {Math.round(leg.distanceMeters)}m to{" "} - {leg.to?.name} + {leg.headsign || + leg.routeLongName || + leg.routeName || + ""} </span> - ) : ( + </> + )} + </div> + <div className="text-sm text-gray-600"> + {new Date(leg.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })}{" "} + -{" "} + {( + (new Date(leg.endTime).getTime() - + new Date(leg.startTime).getTime()) / + 60000 + ).toFixed(0)}{" "} + {t("estimates.minutes")} + </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> + ) : ( + <> <span> - From {leg.from?.name} to {leg.to?.name} + {t("planner.from_to", { + from: leg.from?.name, + to: leg.to?.name, + })} </span> - )} - </div> + {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> + )} + </> + )} </div> </div> - ))} - </div> - </Sheet.Content> - </Sheet.Container> - <Sheet.Backdrop onTap={() => setSheetOpen(false)} /> - </Sheet> + </div> + ))} + </div> + </div> + </div> </div> ); }; -// --- Main Page --- - export default function PlannerPage() { + const { t } = useTranslation(); + const location = useLocation(); const { - origin, - setOrigin, - destination, - setDestination, plan, - loading, - error, searchRoute, clearRoute, + searchTime, + arriveBy, + selectedItineraryIndex, + selectItinerary, + deselectItinerary, } = usePlanner(); - const [selectedItinerary, setSelectedItinerary] = useState<Itinerary | null>( null ); - const handleSearch = () => { - if (origin && destination) { - searchRoute(origin, destination); + // Show previously selected itinerary when plan loads + useEffect(() => { + if ( + plan && + selectedItineraryIndex !== null && + plan.itineraries[selectedItineraryIndex] + ) { + setSelectedItinerary(plan.itineraries[selectedItineraryIndex]); } - }; + }, [plan, selectedItineraryIndex]); + + // When navigating to /planner (even if already on it), reset the active itinerary + useEffect(() => { + setSelectedItinerary(null); + deselectItinerary(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.key]); if (selectedItinerary) { return ( <ItineraryDetail itinerary={selectedItinerary} - onClose={() => setSelectedItinerary(null)} + onClose={() => { + setSelectedItinerary(null); + deselectItinerary(); + }} /> ); } - return ( - <div className="p-4 max-w-md mx-auto pb-20"> - <h1 className="text-2xl font-bold mb-4">Route Planner</h1> - - {/* Form */} - <div className="bg-white p-4 rounded-lg shadow mb-6"> - <AutocompleteInput - label="From" - value={origin} - onChange={setOrigin} - placeholder="Search origin..." - /> - <AutocompleteInput - label="To" - value={destination} - onChange={setDestination} - placeholder="Search destination..." - /> - - <button - onClick={handleSearch} - disabled={!origin || !destination || loading} - className={`w-full py-3 rounded font-bold text-white ${ - !origin || !destination || loading - ? "bg-gray-400" - : "bg-green-600 hover:bg-green-700" - }`} - > - {loading ? "Calculating..." : "Find Route"} - </button> + // Format search time for display + const searchTimeDisplay = searchTime + ? new Date(searchTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : null; - {error && ( - <div className="mt-4 p-3 bg-red-100 text-red-700 rounded"> - {error} - </div> - )} - </div> + return ( + <div className="relative max-w-3xl mx-auto px-4 pt-4 pb-8"> + <PlannerOverlay + forceExpanded + inline + onSearch={(origin, destination, time, arriveBy) => + searchRoute(origin, destination, time, arriveBy) + } + /> - {/* Results */} {plan && ( <div> - <div className="flex justify-between items-center mb-4"> - <h2 className="text-xl font-bold">Results</h2> + <div className="flex justify-between items-center my-4"> + <div> + <h2 className="text-xl font-bold"> + {t("planner.results_title")} + </h2> + {searchTimeDisplay && ( + <p className="text-sm text-gray-600 dark:text-gray-400"> + {arriveBy ? t("planner.arrive_by") : t("planner.depart_at")}{" "} + {searchTimeDisplay} + </p> + )} + </div> <button onClick={clearRoute} className="text-sm text-red-500"> - Clear + {t("planner.clear")} </button> </div> {plan.itineraries.length === 0 ? ( <div className="p-8 text-center bg-gray-50 rounded-lg border border-dashed border-gray-300"> <div className="text-4xl mb-2">😕</div> - <h3 className="text-lg font-bold mb-1">No routes found</h3> - <p className="text-gray-600"> - We couldn't find a route for your trip. Try changing the time or - locations. - </p> + <h3 className="text-lg font-bold mb-1"> + {t("planner.no_routes_found")} + </h3> + <p className="text-gray-600">{t("planner.no_routes_message")}</p> </div> ) : ( <div className="space-y-3"> @@ -403,7 +614,10 @@ export default function PlannerPage() { <ItinerarySummary key={idx} itinerary={itinerary} - onClick={() => setSelectedItinerary(itinerary)} + onClick={() => { + selectItinerary(idx); + setSelectedItinerary(itinerary); + }} /> ))} </div> |
