import { Coins, CreditCard, Footprints } from "lucide-react"; import maplibregl, { type StyleSpecification } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; 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 { type Itinerary } from "~/data/PlannerApi"; import { usePlanner } from "~/hooks/usePlanner"; import { DEFAULT_STYLE, loadStyle } from "~/maps/styleloader"; import "../tailwind-full.css"; const FARE_CASH_PER_BUS = 1.63; const FARE_CARD_PER_BUS = 0.67; const formatDistance = (meters: number) => { const intMeters = Math.round(meters); if (intMeters >= 1000) return `${(intMeters / 1000).toFixed(1)} km`; return `${intMeters} m`; }; 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)); }; 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); } } const durationMinutes = (new Date(leg.endTime).getTime() - new Date(leg.startTime).getTime()) / 60000; minutes += durationMinutes; } }); return { meters, minutes: Math.max(0, Math.round(minutes)) }; }; const ItinerarySummary = ({ itinerary, onClick, }: { 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", minute: "2-digit", }); const endTime = new Date(itinerary.endTime).toLocaleTimeString([], { hour: "2-digit", 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 (
{startTime} - {endTime}
{durationMinutes} min
{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")}
) : (
)}
); })}
{t("planner.walk")}: {formatDistance(walkTotals.meters)} {walkTotals.minutes ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}` : ""} {formatCurrency(cashFare)} {t("planner.card_fare", { amount: cardFare })}
); }; const ItineraryDetail = ({ itinerary, onClose, }: { itinerary: Itinerary; onClose: () => void; }) => { const { t } = useTranslation(); const mapRef = useRef(null); const { destination: userDestination } = usePlanner(); const routeGeoJson = { type: "FeatureCollection", features: itinerary.legs.map((leg) => ({ type: "Feature" as const, geometry: { type: "LineString" as const, coordinates: leg.geometry?.coordinates || [], }, properties: { mode: leg.mode, color: leg.mode === "WALK" ? "#9ca3af" : leg.routeColor ? `#${leg.routeColor}` : "#2563eb", }, })), }; // 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) 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]]) ); }); // 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 = "openfreemap"; loadStyle(styleName, theme) .then((style) => setMapStyle(style)) .catch((error) => console.error("Failed to load map style:", error)); }, [theme]); return (
{/* Map Section */}
{/* 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) => (
)) )}
{/* Details Panel */}

{t("planner.itinerary_details")}

{itinerary.legs.map((leg, idx) => (
{leg.mode === "WALK" ? (
) : (
)} {idx < itinerary.legs.length - 1 && (
)}
{leg.mode === "WALK" ? ( t("planner.walk") ) : ( <> {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; })(), })} ) : ( <> {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}
  • ))}
)} )}
))}
); }; export default function PlannerPage() { const { t } = useTranslation(); const location = useLocation(); const { plan, searchRoute, clearRoute, searchTime, arriveBy, selectedItineraryIndex, selectItinerary, deselectItinerary, } = usePlanner(); const [selectedItinerary, setSelectedItinerary] = useState( null ); // 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); deselectItinerary(); }} /> ); } // Format search time for display const searchTimeDisplay = searchTime ? new Date(searchTime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }) : null; return (
searchRoute(origin, destination, time, arriveBy) } /> {plan && (

{t("planner.results_title")}

{searchTimeDisplay && (

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

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

{t("planner.no_routes_found")}

{t("planner.no_routes_message")}

) : (
{plan.itineraries.map((itinerary, idx) => ( { selectItinerary(idx); setSelectedItinerary(itinerary); }} /> ))}
)}
)}
); }