From e7eb57bf492617f2b9be88d46c1cc708a2c17af4 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 12 Dec 2025 16:48:14 +0100 Subject: Improved version of the planner feature --- src/frontend/app/routes/map.tsx | 205 ++++++----- src/frontend/app/routes/planner.tsx | 700 +++++++++++++++++++++++------------- 2 files changed, 581 insertions(+), 324 deletions(-) (limited to 'src/frontend/app/routes') 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(null); + const { searchRoute, origin, setOrigin } = usePlanner(); + // Style state for Map component const [mapStyle, setMapStyle] = useState(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 ( - - - + searchRoute(o, d, time, arriveBy)} + onNavigateToPlanner={() => navigate("/planner")} + clearPickerOnOpen={true} + showLastDestinationWhenCollapsed={false} /> - - - + attributionControl={{ compact: false }} + maxBounds={[REGION_DATA.bounds.sw, REGION_DATA.bounds.ne]} + > + + - + - {selectedStop && ( - setIsSheetOpen(false)} - stop={selectedStop} + - )} - + + + + {selectedStop && ( + setIsSheetOpen(false)} + stop={selectedStop} + /> + )} + + ); } 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([]); - 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 ( -
- -
- { - setQuery(e.target.value); - if (!e.target.value) onChange(null); - }} - placeholder={placeholder} - onFocus={() => setShowResults(true)} - /> - {value && ( - - )} -
- {showResults && results.length > 0 && ( -
    - {results.map((res, idx) => ( -
  • { - onChange(res); - setQuery(res.name || ""); - setShowResults(false); - }} - > -
    {res.name}
    -
    {res.label}
    -
  • - ))} -
- )} -
- ); + 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 (
{durationMinutes} min
+
- {itinerary.legs.map((leg, idx) => ( - - {idx > 0 && } -
- {leg.mode === "WALK" ? "Walk" : leg.routeShortName || leg.mode} -
-
- ))} + {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 ( + + {idx > 0 && } + {isWalk ? ( +
+ + + {legDurationMinutes} {t("estimates.minutes")} + +
+ ) : ( +
+ +
+ )} +
+ ); + })}
-
- Walk: {Math.round(itinerary.walkDistanceMeters)}m + +
+ + {t("planner.walk")}: {formatDistance(walkTotals.meters)} + {walkTotals.minutes + ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}` + : ""} + + + + + {formatCurrency(cashFare)} + + + + {t("planner.card_fare", { amount: cardFare })} + +
); @@ -157,43 +184,112 @@ const ItineraryDetail = ({ itinerary: Itinerary; onClose: () => void; }) => { + const { t } = useTranslation(); const mapRef = useRef(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(DEFAULT_STYLE); + useEffect(() => { - //const styleName = "carto"; const styleName = "openfreemap"; loadStyle(styleName, theme) .then((style) => setMapStyle(style)) @@ -201,13 +297,16 @@ const ItineraryDetail = ({ }, [theme]); return ( -
-
+
+ {/* Map Section */} +
- {/* Markers for start/end/transfers could be added here */} + + {/* Origin marker (red) */} + {origin?.lat && origin?.lon && ( + +
+
+
+
+ )} + + {/* Destination marker (green) */} + {destination?.lat && destination?.lon && ( + +
+
+
+
+ )} + + {/* Stop markers (boarding, alighting, transfer) */} + {stopMarkers.map((stop, idx) => ( + +
+ + ))} + + {/* Intermediate stops (smaller white dots) */} + {itinerary.legs.map((leg, legIdx) => + leg.intermediateStops?.map((stop, stopIdx) => ( + +
+ + )) + )}
- setSheetOpen(false)} - detent="content" - initialSnap={0} - > - - - -

Itinerary Details

-
- {itinerary.legs.map((leg, idx) => ( -
-
+ {/* Details Panel */} +
+
+

+ {t("planner.itinerary_details")} +

+ +
+ {itinerary.legs.map((leg, idx) => ( +
+
+ {leg.mode === "WALK" ? (
- {leg.mode === "WALK" ? "🚶" : "🚌"} -
- {idx < itinerary.legs.length - 1 && ( -
- )} -
-
-
- {leg.mode === "WALK" - ? "Walk" - : `${leg.routeShortName} ${leg.headsign}`} +
-
- {new Date(leg.startTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} - {" - "} - {new Date(leg.endTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} + ) : ( +
+
-
- {leg.mode === "WALK" ? ( + )} + {idx < itinerary.legs.length - 1 && ( +
+ )} +
+
+
+ {leg.mode === "WALK" ? ( + t("planner.walk") + ) : ( + <> - Walk {Math.round(leg.distanceMeters)}m to{" "} - {leg.to?.name} + {leg.headsign || + leg.routeLongName || + leg.routeName || + ""} - ) : ( + + )} +
+
+ {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")} +
+
+ {leg.mode === "WALK" ? ( + + {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; + })(), + })} + + ) : ( + <> - From {leg.from?.name} to {leg.to?.name} + {t("planner.from_to", { + from: leg.from?.name, + to: leg.to?.name, + })} - )} -
+ {leg.intermediateStops && + leg.intermediateStops.length > 0 && ( +
+ + {leg.intermediateStops.length}{" "} + {leg.intermediateStops.length === 1 + ? "stop" + : "stops"} + +
    + {leg.intermediateStops.map((stop, idx) => ( +
  • • {stop.name}
  • + ))} +
+
+ )} + + )}
- ))} -
- - - setSheetOpen(false)} /> - +
+ ))} +
+
+
); }; -// --- 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( 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 ( setSelectedItinerary(null)} + onClose={() => { + setSelectedItinerary(null); + deselectItinerary(); + }} /> ); } - return ( -
-

Route Planner

- - {/* Form */} -
- - - - + // Format search time for display + const searchTimeDisplay = searchTime + ? new Date(searchTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : null; - {error && ( -
- {error} -
- )} -
+ return ( +
+ + searchRoute(origin, destination, time, arriveBy) + } + /> - {/* Results */} {plan && (
-
-

Results

+
+
+

+ {t("planner.results_title")} +

+ {searchTimeDisplay && ( +

+ {arriveBy ? t("planner.arrive_by") : t("planner.depart_at")}{" "} + {searchTimeDisplay} +

+ )} +
{plan.itineraries.length === 0 ? (
😕
-

No routes found

-

- We couldn't find a route for your trip. Try changing the time or - locations. -

+

+ {t("planner.no_routes_found")} +

+

{t("planner.no_routes_message")}

) : (
@@ -403,7 +614,10 @@ export default function PlannerPage() { setSelectedItinerary(itinerary)} + onClick={() => { + selectItinerary(idx); + setSelectedItinerary(itinerary); + }} /> ))}
-- cgit v1.3