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 { Layer, 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 { AppMap } from "~/components/shared/AppMap"; import { APP_CONSTANTS } from "~/config/constants"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { type Itinerary } from "~/data/PlannerApi"; import { usePlanner } from "~/hooks/usePlanner"; import "../tailwind-full.css"; export interface ConsolidatedCirculation { line: string; route: string; schedule?: { running: boolean; minutes: number; serviceId: string; tripId: string; shapeId?: string; }; realTime?: { minutes: number; distance: number; }; currentPosition?: { latitude: number; longitude: number; orientationDegrees: number; shapeIndex?: number; }; isPreviousTrip?: boolean; previousTripShapeId?: string; nextStreets?: string[]; } 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", timeZone: "Europe/Madrid", }); const endTime = new Date(itinerary.endTime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Madrid", }); 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); 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")}` : ""} {cashFare === "0.00" ? t("planner.free") : t("planner.fare", { amount: cashFare })} {cardFare === "0.00" ? t("planner.free") : t("planner.fare", { amount: cardFare })}
); }; const ItineraryDetail = ({ itinerary, onClose, }: { itinerary: Itinerary; onClose: () => void; }) => { const { t } = useTranslation(); const mapRef = useRef(null); const { destination: userDestination } = usePlanner(); const [nextArrivals, setNextArrivals] = useState< Record >({}); 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", }, })), }; // Create GeoJSON for all markers const markersGeoJson = useMemo(() => { const features: any[] = []; const origin = itinerary.legs[0]?.from; const destination = itinerary.legs[itinerary.legs.length - 1]?.to; // Origin marker (red) if (origin?.lat && origin?.lon) { features.push({ type: "Feature", geometry: { type: "Point", coordinates: [origin.lon, origin.lat] }, properties: { type: "origin", name: origin.name || "Origin" }, }); } // Destination marker (green) if (destination?.lat && destination?.lon) { features.push({ type: "Feature", geometry: { type: "Point", coordinates: [destination.lon, destination.lat], }, properties: { type: "destination", name: destination.name || "Destination", }, }); } // Collect unique stops with their roles (board, alight, transfer) const stopsMap: Record< string, { lat: number; lon: number; name: string; type: string } > = {}; 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", }; } } } }); // Add stop markers Object.values(stopsMap).forEach((stop) => { features.push({ type: "Feature", geometry: { type: "Point", coordinates: [stop.lon, stop.lat] }, properties: { type: stop.type, name: stop.name }, }); }); // Add intermediate stops itinerary.legs.forEach((leg) => { leg.intermediateStops?.forEach((stop) => { features.push({ type: "Feature", geometry: { type: "Point", coordinates: [stop.lon, stop.lat] }, properties: { type: "intermediate", name: stop.name || "Intermediate stop", }, }); }); }); 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; 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]]) ); }); // Also include markers (origin, destination, transfers, intermediate) so all are visible markersGeoJson.features.forEach((feature: any) => { if ( feature.geometry?.type === "Point" && Array.isArray(feature.geometry.coordinates) ) { const [lng, lat] = feature.geometry.coordinates as [number, number]; bounds.extend([lng, lat]); } }); // Ensure bounds are valid before fitting if (!bounds.isEmpty()) { mapRef.current.fitBounds(bounds, { padding: 80, duration: 1000 }); } } }, 100); return () => clearTimeout(timer); }, [mapRef.current, itinerary]); // Fetch next arrivals for bus legs useEffect(() => { const fetchArrivals = async () => { const arrivalsByStop: Record = {}; for (const leg of itinerary.legs) { if (leg.mode !== "WALK" && leg.from?.stopId) { const stopKey = leg.from.name || leg.from.stopId; if (!arrivalsByStop[stopKey]) { try { const resp = await fetch( `${APP_CONSTANTS.consolidatedCirculationsEndpoint}?stopId=${encodeURIComponent(leg.from.stopCode || leg.from.stopId)}`, { headers: { Accept: "application/json" } } ); if (resp.ok) { const data: ConsolidatedCirculation[] = await resp.json(); arrivalsByStop[stopKey] = data; } } catch (err) { console.warn( `Failed to fetch arrivals for ${leg.from.stopId}:`, err ); } } } } setNextArrivals(arrivalsByStop); }; fetchArrivals(); }, [itinerary]); return (
{/* Map Section */}
{/* All markers as GeoJSON layers */} {/* Outer circle for origin/destination markers */} {/* Inner circle for origin/destination markers */} {/* Stop markers (board, alight, transfer) */} {/* Intermediate stops (smaller white dots) */}
{/* 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", timeZone: "Europe/Madrid", })}{" "} -{" "} {( (new Date(leg.endTime).getTime() - new Date(leg.startTime).getTime()) / 60000 ).toFixed(0)}{" "} {t("estimates.minutes")}
{leg.mode !== "WALK" && leg.from?.stopId && nextArrivals[leg.from.name || leg.from.stopId] && (
{t("planner.next_arrivals", "Next arrivals")}:
{(() => { const currentLine = leg.routeShortName || leg.routeName; const previousLeg = idx > 0 ? itinerary.legs[idx - 1] : null; const previousLine = previousLeg?.mode !== "WALK" ? previousLeg?.routeShortName || previousLeg?.routeName : null; const linesToShow = [currentLine]; if ( previousLine && previousLeg?.to?.stopId === leg.from?.stopId ) { linesToShow.push(previousLine); } return nextArrivals[leg.from.name || leg.from.stopId] .filter((circ) => linesToShow.includes(circ.line)) .slice(0, 3) .map((circ, idx) => { const minutes = circ.realTime?.minutes ?? circ.schedule?.minutes; if (minutes === undefined) return null; return (
{circ.line} {circ.route} {minutes} {t("estimates.minutes")} {circ.realTime && " 🟢"}
); }); })()}
)}
{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(); usePageTitle(t("navbar.planner", "Planificador")); const location = useLocation(); const { plan, searchRoute, clearRoute, searchTime, arriveBy, selectedItineraryIndex, selectItinerary, deselectItinerary, setOrigin, setDestination, } = 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]); } else { setSelectedItinerary(null); } }, [plan, selectedItineraryIndex]); // Intercept back button when viewing itinerary detail useEffect(() => { const handlePopState = (e: PopStateEvent) => { if (selectedItinerary) { e.preventDefault(); setSelectedItinerary(null); deselectItinerary(); window.history.pushState(null, "", window.location.href); } }; if (selectedItinerary) { window.history.pushState(null, "", window.location.href); window.addEventListener("popstate", handlePopState); } return () => { window.removeEventListener("popstate", handlePopState); }; }, [selectedItinerary, deselectItinerary]); if (selectedItinerary) { return ( { setSelectedItinerary(null); deselectItinerary(); }} /> ); } // Format search time for display const searchTimeDisplay = searchTime ? new Date(searchTime).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Madrid", }) : null; return (
searchRoute(origin, destination, time, arriveBy) } cardBackground="bg-transparent" /> {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); }} /> ))}
)}
)}
); }