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"; import { useTranslation } from "react-i18next"; import { Layer, Source, type MapRef } from "react-map-gl/maplibre"; import { useLocation } from "react-router"; import { fetchEstimates } from "~/api/arrivals"; import { type StopEstimatesResponse } from "~/api/schema"; import { PlannerOverlay } from "~/components/PlannerOverlay"; import RouteIcon from "~/components/RouteIcon"; 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"; const formatDistance = (meters: number) => { if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; const rounded = Math.round(meters / 100) * 100; return `${rounded} m`; }; const formatDuration = (minutes: number, t: any) => { if (minutes < 60) return `${minutes} ${t("estimates.minutes")}`; const h = Math.floor(minutes / 60); const m = minutes % 60; if (m === 0) return `${h}h`; return `${h}h ${m}min`; }; 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 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; 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 URBAN_MUNICIPALITIES: Record = { "15030": "A Coruña", "27028": "Lugo", "32054": "Ourense", "15078": "Santiago de Compostela", "36057": "Vigo", }; const getUrbanMunicipalityWarning = ( leg: Itinerary["legs"][number] ): string | null => { if (leg.feedId !== "xunta") return null; const fromMunicipality = leg.from?.zoneId?.substring(0, 5); const toMunicipality = leg.to?.zoneId?.substring(0, 5); if (!fromMunicipality || !toMunicipality) return null; if (fromMunicipality !== toMunicipality) return null; return URBAN_MUNICIPALITIES[fromMunicipality] ?? null; }; 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 cashFare = (itinerary.cashFare ?? 0).toFixed(2); const cardFare = (itinerary.cardFare ?? 0).toFixed(2); return (
{startTime} - {endTime}
{formatDuration(durationMinutes, t)}
{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 ) ); return ( {idx > 0 && } {isWalk ? (
{formatDuration(legDurationMinutes, t)}
) : (
)}
); })}
{t("planner.walk")}: {formatDistance(walkTotals.meters)} {walkTotals.minutes ? ` • ${formatDuration(walkTotals.minutes, t)}` : ""} {cashFare === "0.00" ? t("planner.free") : t("planner.fare", { amount: cashFare })} {itinerary.cashFareIsTotal ? "" : "++"} {cardFare === "0.00" ? t("planner.free") : t("planner.fare", { amount: cardFare })} {itinerary.cashFareIsTotal ? "" : "++"}
); }; const ItineraryDetail = ({ itinerary, onClose, }: { itinerary: Itinerary; onClose: () => void; }) => { const { t } = useTranslation(); useBackButton({ onBack: onClose }); const mapRef = useRef(null); const { destination: userDestination } = usePlanner(); const [nextArrivals, setNextArrivals] = useState< Record >({}); const [selectedLegIndex, setSelectedLegIndex] = useState(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", 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[] = []; // Add points for each leg transition itinerary.legs.forEach((leg, idx) => { // Add "from" point of the leg if (leg.from?.lat && leg.from?.lon) { features.push({ type: "Feature", geometry: { type: "Point", coordinates: [leg.from.lon, leg.from.lat], }, properties: { type: idx === 0 ? "origin" : "transfer", name: leg.from.name || "", index: idx.toString(), }, }); } // If it's the last leg, also add the "to" point if (idx === itinerary.legs.length - 1 && leg.to?.lat && leg.to?.lon) { features.push({ type: "Feature", geometry: { type: "Point", coordinates: [leg.to.lon, leg.to.lat] }, properties: { type: "destination", name: leg.to.name || "", index: (idx + 1).toString(), }, }); } // Add intermediate stops 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]); 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; // 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 fetchArrivalsForLegs = async () => { const arrivalsByLeg: Record = {}; for (const leg of itinerary.legs) { if ( leg.mode !== "WALK" && leg.from?.stopId && leg.to?.stopId && leg.routeId ) { const legKey = `${leg.from.stopId}::${leg.to.stopId}`; if (!arrivalsByLeg[legKey]) { try { arrivalsByLeg[legKey] = await fetchEstimates( leg.from.stopId, leg.routeId, leg.to.stopId ); } catch (err) { console.warn( `Failed to fetch estimates for leg ${leg.from.stopId} -> ${leg.to.stopId}:`, err ); } } } } setNextArrivals(arrivalsByLeg); }; fetchArrivalsForLegs(); }, [itinerary]); return (
{/* Map Section */}
{/* All markers as GeoJSON layers */} {/* Intermediate stops (smaller white dots) - rendered first to be at the bottom */} {/* Outer circle for all numbered markers */} {/* Numbers for markers */}
{layoutOptions.map((option) => { const Icon = option.icon; const isActive = layoutMode === option.id; return ( ); })}
{/* Details Panel */}

{t("planner.itinerary_details")}

{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 (
{leg.mode === "WALK" ? (
) : ( )} {idx < itinerary.legs.length - 1 && (
)}
); })}
); }; export default function PlannerPage() { const { t } = useTranslation(); () => usePageTitle(t("navbar.planner", "Planificador")); const location = useLocation(); const { plan, loading, searchRoute, clearRoute, searchTime, arriveBy, selectedItineraryIndex, selectItinerary, deselectItinerary, setOrigin, setDestination, } = usePlanner(); const { userLocation } = useGeolocation(); 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" /> {loading && !plan && (

{t("planner.searching")}

)} {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); }} /> ))}
)}
)}
); }