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, 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 { usePageTitle } from "~/contexts/PageTitleContext";
import { type Itinerary } from "~/data/PlannerApi";
import { usePlanner } from "~/hooks/usePlanner";
import { DEFAULT_STYLE, loadStyle } from "~/maps/styleloader";
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]);
const { theme } = useApp();
const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE);
useEffect(() => {
const styleName = "openfreemap";
loadStyle(styleName, theme, { includeTraffic: false })
.then((style) => setMapStyle(style))
.catch((error) => console.error("Failed to load map style:", error));
}, [theme]);
// 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(
`${REGION_DATA.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 */}
{/* 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);
}}
/>
))}
)}
)}
);
}