diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-12 16:48:14 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-12 16:48:46 +0100 |
| commit | e7eb57bf492617f2b9be88d46c1cc708a2c17af4 (patch) | |
| tree | 490e5ade4dc618760d30a8805dd94cc8dc586e2f | |
| parent | 2f0fd3f348bb836839f4a72e3af072b56954d878 (diff) | |
Improved version of the planner feature
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs | 4 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Services/OtpService.cs | 62 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs | 12 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs | 9 | ||||
| -rw-r--r-- | src/frontend/app/components/LineIcon.css | 8 | ||||
| -rw-r--r-- | src/frontend/app/components/PlannerOverlay.tsx | 525 | ||||
| -rw-r--r-- | src/frontend/app/components/StopMapModal.tsx | 21 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/NavBar.module.css | 2 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/NavBar.tsx | 10 | ||||
| -rw-r--r-- | src/frontend/app/data/PlannerApi.ts | 3 | ||||
| -rw-r--r-- | src/frontend/app/hooks/usePlanner.ts | 63 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 35 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 35 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 35 | ||||
| -rw-r--r-- | src/frontend/app/root.css | 6 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 205 | ||||
| -rw-r--r-- | src/frontend/app/routes/planner.tsx | 700 |
17 files changed, 1373 insertions, 362 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs index a61fdb6..3224515 100644 --- a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs +++ b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs @@ -13,4 +13,8 @@ public class AppConfiguration public int MaxWalkDistance { get; set; } = 1000; public int MaxWalkTime { get; set; } = 20; public int NumItineraries { get; set; } = 4; + + // Fare Configuration + public double FareCashPerBus { get; set; } = 1.63; + public double FareCardPerBus { get; set; } = 0.67; } diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs index 4c22ff5..77eddd3 100644 --- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs @@ -141,14 +141,37 @@ public class OtpService private RoutePlan MapToRoutePlan(OtpPlan otpPlan) { + // Compute time offset: OTP's "date" field is the server time in millis + var otpServerTime = DateTimeOffset.FromUnixTimeMilliseconds(otpPlan.Date); + var now = DateTimeOffset.Now; + var timeOffsetSeconds = (long)(otpServerTime - now).TotalSeconds; + return new RoutePlan { - Itineraries = otpPlan.Itineraries.Select(MapItinerary).ToList() + Itineraries = otpPlan.Itineraries.Select(MapItinerary).ToList(), + TimeOffsetSeconds = timeOffsetSeconds }; } private Itinerary MapItinerary(OtpItinerary otpItinerary) { + var legs = otpItinerary.Legs.Select(MapLeg).ToList(); + var busLegs = legs.Where(leg => leg.Mode != null && leg.Mode.ToUpper() != "WALK"); + + var cashFareEuro = busLegs.Count() * _config.FareCashPerBus; + + int cardTicketsRequired = 0; + DateTime? lastTicketPurchased = null; + + foreach (var leg in busLegs) + { + if (lastTicketPurchased == null || (leg.StartTime - lastTicketPurchased.Value).TotalMinutes > 45) + { + cardTicketsRequired++; + lastTicketPurchased = leg.StartTime; + } + } + return new Itinerary { DurationSeconds = otpItinerary.Duration, @@ -158,7 +181,9 @@ public class OtpService WalkTimeSeconds = otpItinerary.WalkTime, TransitTimeSeconds = otpItinerary.TransitTime, WaitingTimeSeconds = otpItinerary.WaitingTime, - Legs = otpItinerary.Legs.Select(MapLeg).ToList() + Legs = legs, + CashFareEuro = cashFareEuro, + CardFareEuro = cardTicketsRequired * _config.FareCardPerBus }; } @@ -172,12 +197,16 @@ public class OtpService RouteLongName = otpLeg.RouteLongName, Headsign = otpLeg.Headsign, AgencyName = otpLeg.AgencyName, + RouteColor = otpLeg.RouteColor, + RouteTextColor = otpLeg.RouteTextColor, From = MapPlace(otpLeg.From), To = MapPlace(otpLeg.To), StartTime = DateTimeOffset.FromUnixTimeMilliseconds(otpLeg.StartTime).LocalDateTime, EndTime = DateTimeOffset.FromUnixTimeMilliseconds(otpLeg.EndTime).LocalDateTime, + DistanceMeters = otpLeg.Distance, Geometry = DecodePolyline(otpLeg.LegGeometry?.Points), - Steps = otpLeg.Steps.Select(MapStep).ToList() + Steps = otpLeg.Steps.Select(MapStep).ToList(), + IntermediateStops = otpLeg.IntermediateStops.Select(MapPlace).Where(p => p != null).Cast<PlannerPlace>().ToList() }; } @@ -186,14 +215,37 @@ public class OtpService if (otpPlace == null) return null; return new PlannerPlace { - Name = otpPlace.Name, + Name = CorrectStopName(otpPlace.Name), Lat = otpPlace.Lat, Lon = otpPlace.Lon, StopId = otpPlace.StopId, // Use string directly - StopCode = otpPlace.StopCode + StopCode = CorrectStopCode(otpPlace.StopCode) }; } + private string CorrectStopCode(string? stopId) + { + if (string.IsNullOrEmpty(stopId)) return stopId ?? string.Empty; + + var sb = new StringBuilder(); + foreach (var c in stopId) + { + if (char.IsNumber(c)) + { + sb.Append(c); + } + } + + return int.Parse(sb.ToString()).ToString(); + } + + private string CorrectStopName(string? stopName) + { + if (string.IsNullOrEmpty(stopName)) return stopName ?? string.Empty; + + return stopName!.Replace(" ", ", ").Replace("\"", ""); + } + private Step MapStep(OtpWalkStep otpStep) { return new Step diff --git a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs index 3d3de17..93c4d8b 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs @@ -102,6 +102,18 @@ public class OtpLeg [JsonPropertyName("headsign")] public string? Headsign { get; set; } + + [JsonPropertyName("distance")] + public double Distance { get; set; } + + [JsonPropertyName("routeColor")] + public string? RouteColor { get; set; } + + [JsonPropertyName("routeTextColor")] + public string? RouteTextColor { get; set; } + + [JsonPropertyName("intermediateStops")] + public List<OtpPlace> IntermediateStops { get; set; } = new(); } public class OtpPlace diff --git a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs index 30e5e2d..c31d12a 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs @@ -1,10 +1,9 @@ -using System.Text.Json.Serialization; - namespace Costasdev.Busurbano.Backend.Types.Planner; public class RoutePlan { public List<Itinerary> Itineraries { get; set; } = new(); + public long? TimeOffsetSeconds { get; set; } } public class Itinerary @@ -17,6 +16,8 @@ public class Itinerary public double TransitTimeSeconds { get; set; } public double WaitingTimeSeconds { get; set; } public List<Leg> Legs { get; set; } = new(); + public double? CashFareEuro { get; set; } + public double? CardFareEuro { get; set; } } public class Leg @@ -27,6 +28,8 @@ public class Leg public string? RouteLongName { get; set; } public string? Headsign { get; set; } public string? AgencyName { get; set; } + public string? RouteColor { get; set; } + public string? RouteTextColor { get; set; } public PlannerPlace? From { get; set; } public PlannerPlace? To { get; set; } public DateTime StartTime { get; set; } @@ -37,6 +40,8 @@ public class Leg public PlannerGeometry? Geometry { get; set; } public List<Step> Steps { get; set; } = new(); + + public List<PlannerPlace> IntermediateStops { get; set; } = new(); } public class PlannerPlace diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index 6492d39..448b5fd 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -128,17 +128,17 @@ .line-icon-rounded { display: block; - width: 5ch; - height: 5ch; + width: 4.25ch; + height: 4.25ch; box-sizing: border-box; background-color: var(--line-colour); color: var(--line-text-colour); - padding: 1.75ch 1ch; + padding: 1.4ch 0.8ch; text-align: center; border-radius: 50%; - font: 600 14px / 1 monospace; + font: 600 13px / 1 monospace; letter-spacing: 0.05em; text-wrap: nowrap; } diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx new file mode 100644 index 0000000..622884e --- /dev/null +++ b/src/frontend/app/components/PlannerOverlay.tsx @@ -0,0 +1,525 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + reverseGeocode, + searchPlaces, + type PlannerSearchResult, +} from "~/data/PlannerApi"; +import StopDataProvider from "~/data/StopDataProvider"; +import { usePlanner } from "~/hooks/usePlanner"; + +interface PlannerOverlayProps { + onSearch: ( + origin: PlannerSearchResult, + destination: PlannerSearchResult, + time?: Date, + arriveBy?: boolean + ) => void; + onNavigateToPlanner?: () => void; + forceExpanded?: boolean; + inline?: boolean; + clearPickerOnOpen?: boolean; + showLastDestinationWhenCollapsed?: boolean; +} + +export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({ + onSearch, + onNavigateToPlanner, + forceExpanded, + inline, + clearPickerOnOpen = false, + showLastDestinationWhenCollapsed = true, +}) => { + const { t } = useTranslation(); + const { origin, setOrigin, destination, setDestination, loading, error } = + usePlanner(); + const [isExpanded, setIsExpanded] = useState(false); + const [originQuery, setOriginQuery] = useState(origin?.name || ""); + const [destQuery, setDestQuery] = useState(""); + + type PickerField = "origin" | "destination"; + const [pickerOpen, setPickerOpen] = useState(false); + const [pickerField, setPickerField] = useState<PickerField>("destination"); + const [pickerQuery, setPickerQuery] = useState(""); + const [remoteResults, setRemoteResults] = useState<PlannerSearchResult[]>([]); + const [remoteLoading, setRemoteLoading] = useState(false); + + const [favouriteStops, setFavouriteStops] = useState<PlannerSearchResult[]>( + [] + ); + + const pickerInputRef = useRef<HTMLInputElement | null>(null); + + const [locationLoading, setLocationLoading] = useState(false); + const [timeMode, setTimeMode] = useState<"now" | "depart" | "arrive">("now"); + const [timeValue, setTimeValue] = useState(""); + const [dateOffset, setDateOffset] = useState(0); // 0 = today, 1 = tomorrow, etc. + + const canSubmit = useMemo( + () => Boolean(origin && destination) && !loading, + [origin, destination, loading] + ); + + useEffect(() => { + setOriginQuery( + origin?.layer === "current-location" + ? t("planner.current_location") + : origin?.name || "" + ); + }, [origin, t]); + useEffect(() => { + setDestQuery(destination?.name || ""); + }, [destination]); + + useEffect(() => { + // Load favourites once; used as local suggestions in the picker. + StopDataProvider.getStops() + .then((stops) => + stops + .filter((s) => s.favourite && s.latitude && s.longitude) + .map( + (s) => + ({ + name: StopDataProvider.getDisplayName(s), + label: s.stopId, + lat: s.latitude as number, + lon: s.longitude as number, + layer: "favourite-stop", + }) satisfies PlannerSearchResult + ) + ) + .then((mapped) => setFavouriteStops(mapped)) + .catch(() => setFavouriteStops([])); + }, []); + + const filteredFavouriteStops = useMemo(() => { + const q = pickerQuery.trim().toLowerCase(); + if (!q) return favouriteStops; + return favouriteStops.filter( + (s) => + (s.name || "").toLowerCase().includes(q) || + (s.label || "").toLowerCase().includes(q) + ); + }, [favouriteStops, pickerQuery]); + + const openPicker = (field: PickerField) => { + setPickerField(field); + setPickerQuery( + clearPickerOnOpen ? "" : field === "origin" ? originQuery : destQuery + ); + setPickerOpen(true); + }; + + const applyPickedResult = (result: PlannerSearchResult) => { + if (pickerField === "origin") { + setOrigin(result); + setOriginQuery(result.name || ""); + } else { + setDestination(result); + setDestQuery(result.name || ""); + } + setPickerOpen(false); + }; + + const setOriginFromCurrentLocation = () => { + if (!navigator.geolocation) return; + setLocationLoading(true); + navigator.geolocation.getCurrentPosition( + async (pos) => { + try { + const rev = await reverseGeocode( + pos.coords.latitude, + pos.coords.longitude + ); + const picked: PlannerSearchResult = { + name: rev?.name || "Ubicación actual", + label: rev?.label || "GPS", + lat: pos.coords.latitude, + lon: pos.coords.longitude, + layer: "current-location", + }; + setOrigin(picked); + setOriginQuery(picked.name || ""); + setPickerOpen(false); + } finally { + setLocationLoading(false); + } + }, + () => setLocationLoading(false), + { enableHighAccuracy: true, timeout: 10000 } + ); + }; + + useEffect(() => { + if (!pickerOpen) return; + // Focus the modal input on open. + const t = setTimeout(() => pickerInputRef.current?.focus(), 0); + return () => clearTimeout(t); + }, [pickerOpen]); + + useEffect(() => { + if (!pickerOpen) return; + const q = pickerQuery.trim(); + if (q.length < 3) { + setRemoteResults([]); + setRemoteLoading(false); + return; + } + + let cancelled = false; + setRemoteLoading(true); + const t = setTimeout(async () => { + try { + const results = await searchPlaces(q); + if (!cancelled) setRemoteResults(results); + } finally { + if (!cancelled) setRemoteLoading(false); + } + }, 250); + + return () => { + cancelled = true; + clearTimeout(t); + }; + }, [pickerOpen, pickerQuery]); + + // Allow external triggers (e.g., map movements) to collapse the widget, unless forced expanded + useEffect(() => { + if (forceExpanded) return; + const handler = () => setIsExpanded(false); + window.addEventListener("plannerOverlay:collapse", handler); + return () => window.removeEventListener("plannerOverlay:collapse", handler); + }, [forceExpanded]); + + // Derive expanded state + const expanded = forceExpanded ?? isExpanded; + + const wrapperClass = inline + ? "w-full" + : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center"; + + const cardClass = inline + ? "pointer-events-auto w-full overflow-hidden rounded-xl bg-white dark:bg-slate-900 px-2 flex flex-col gap-3" + : "pointer-events-auto w-[min(640px,calc(100%-16px))] px-2 py-1 flex flex-col gap-3 m-4 overflow-hidden rounded-xl border border-slate-200/80 dark:border-slate-700/70 bg-white/95 dark:bg-slate-900/90 shadow-2xl backdrop-blur"; + + return ( + <div className={wrapperClass}> + <div className={cardClass}> + {!expanded ? ( + <button + type="button" + className="block w-full px-2 py-1 text-left hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-200" + onClick={() => { + setIsExpanded(true); + openPicker("destination"); + }} + > + <div className="text-small font-semibold text-slate-900 dark:text-slate-100"> + {showLastDestinationWhenCollapsed && destQuery + ? destQuery + : t("planner.where_to")} + </div> + </button> + ) : ( + <> + <div className="flex items-center gap-"> + <button + type="button" + className="w-full rounded-2xl bg-slate-100 dark:bg-slate-800 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-200/80 dark:hover:bg-slate-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500" + onClick={() => openPicker("origin")} + > + <span + className={ + originQuery ? "" : "text-slate-500 dark:text-slate-400" + } + > + {originQuery || t("planner.origin")} + </span> + </button> + </div> + + <div> + <button + type="button" + className="w-full rounded-2xl bg-slate-100 dark:bg-slate-800 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-200/80 dark:hover:bg-slate-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500" + onClick={() => openPicker("destination")} + > + <span + className={ + destQuery ? "" : "text-slate-500 dark:text-slate-400" + } + > + {destQuery || t("planner.destination")} + </span> + </button> + </div> + + <div className="flex flex-wrap items-center gap-2 text-sm text-slate-700 dark:text-slate-200"> + <span className="font-semibold">{t("planner.when")}</span> + <div className="flex gap-1 rounded-2xl bg-slate-100 dark:bg-slate-800 p-1"> + <button + type="button" + className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${ + timeMode === "now" + ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow" + : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700" + }`} + onClick={() => setTimeMode("now")} + > + {t("planner.now")} + </button> + <button + type="button" + className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${ + timeMode === "depart" + ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow" + : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700" + }`} + onClick={() => setTimeMode("depart")} + > + {t("planner.depart_at")} + </button> + <button + type="button" + className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${ + timeMode === "arrive" + ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow" + : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700" + }`} + onClick={() => setTimeMode("arrive")} + > + {t("planner.arrive_by")} + </button> + </div> + {timeMode !== "now" && ( + <div className="flex gap-2 w-full"> + <select + className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 grow" + value={dateOffset} + onChange={(e) => setDateOffset(Number(e.target.value))} + > + {Array.from({ length: 7 }, (_, i) => { + const date = new Date(); + date.setDate(date.getDate() + i); + const label = + i === 0 + ? "Hoy" + : i === 1 + ? "Mañana" + : date.toLocaleDateString("es-ES", { + weekday: "short", + day: "numeric", + month: "short", + }); + return ( + <option key={i} value={i}> + {label} + </option> + ); + })} + </select> + <input + type="time" + className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 grow" + value={timeValue} + onChange={(e) => setTimeValue(e.target.value)} + /> + </div> + )} + </div> + + <div> + <button + className="w-full rounded-lg bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-700 dark:hover:bg-emerald-800 px-2 py-2 text-sm font-semibold text-white disabled:bg-slate-300 dark:disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200 focus:outline-none" + disabled={!canSubmit} + onClick={async () => { + if (origin && destination) { + let time: Date | undefined; + if (timeMode === "now") { + // For SERP, reflect the actual generation time + time = new Date(); + } else if (timeValue) { + const targetDate = new Date(); + targetDate.setDate(targetDate.getDate() + dateOffset); + const [hours, minutes] = timeValue.split(":").map(Number); + targetDate.setHours(hours, minutes, 0, 0); + time = targetDate; + } + + onSearch(origin, destination, time, timeMode === "arrive"); + + // After search, if origin was current location, switch to reverse-geocoded address + if ( + origin.layer === "current-location" && + origin.lat && + origin.lon + ) { + try { + const rev = await reverseGeocode( + origin.lat, + origin.lon + ); + const updated = { + ...origin, + name: rev?.name || origin.name, + label: rev?.label || origin.label, + layer: "geocoded-location", + } as PlannerSearchResult; + setOrigin(updated); + } catch { + // ignore reverse geocode errors + } + } + + onNavigateToPlanner?.(); + } + }} + type="button" + > + {loading ? t("planner.searching") : t("planner.search_route")} + </button> + </div> + + {error && ( + <div className="mx-3 mb-3 rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-300"> + {error} + </div> + )} + </> + )} + </div> + + {pickerOpen && ( + <div className="pointer-events-auto fixed inset-0 z-50 flex justify-center items-start p-4"> + <button + type="button" + className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" + aria-label={t("planner.close")} + onClick={() => setPickerOpen(false)} + /> + + <div className="relative w-[min(640px,calc(100%-12px))] overflow-hidden rounded-lg bg-white dark:bg-slate-900 shadow-2xl border border-slate-200 dark:border-slate-700"> + <div className="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 px-4 py-3 bg-slate-50 dark:bg-slate-800/80"> + <div className="text-base font-semibold text-slate-900 dark:text-slate-100"> + {pickerField === "origin" + ? t("planner.select_origin") + : t("planner.select_destination")} + </div> + </div> + + <div className="p-4"> + <div className="relative"> + <input + ref={pickerInputRef} + className="w-full pr-12 px-4 py-3 text-base border border-slate-300 dark:border-slate-600 rounded-2xl bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-500 dark:placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200" + placeholder={ + pickerField === "origin" + ? t("planner.search_origin") + : t("planner.search_destination") + } + value={pickerQuery} + onChange={(e) => setPickerQuery(e.target.value)} + /> + <button + type="button" + aria-label={t("planner.confirm")} + className="absolute right-2 top-1/2 -translate-y-1/2 rounded-xl px-3 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold" + onClick={() => { + const pick = remoteResults[0] || filteredFavouriteStops[0]; + if (pick) applyPickedResult(pick); + else setPickerOpen(false); + }} + > + {t("planner.confirm")} + </button> + </div> + </div> + + <ul className="max-h-[70vh] overflow-auto list-none m-0 p-0"> + {pickerField === "origin" && ( + <li className="border-t border-slate-100 dark:border-slate-700"> + <button + type="button" + className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-50 transition-colors duration-200" + onClick={setOriginFromCurrentLocation} + disabled={locationLoading} + > + <div> + <div className="text-sm font-semibold text-slate-900 dark:text-slate-100"> + {t("planner.current_location")} + </div> + <div className="text-xs text-slate-500 dark:text-slate-400"> + {t("planner.gps")} + </div> + </div> + <div className="text-lg text-slate-600 dark:text-slate-400"> + {locationLoading ? "…" : "📍"} + </div> + </button> + </li> + )} + + {filteredFavouriteStops.length > 0 && ( + <> + <li className="border-t border-slate-100 dark:border-slate-700 px-4 py-2 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-800/70"> + {t("planner.favourite_stops")} + </li> + {filteredFavouriteStops.map((r, i) => ( + <li + key={`fav-${i}`} + className="border-t border-slate-100 dark:border-slate-700" + > + <button + type="button" + className="w-full px-4 py-3 text-left hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-200" + onClick={() => applyPickedResult(r)} + > + <div className="text-sm font-semibold text-slate-900 dark:text-slate-100"> + {r.name} + </div> + {r.label && ( + <div className="text-xs text-slate-500 dark:text-slate-400"> + {r.label} + </div> + )} + </button> + </li> + ))} + </> + )} + + {(remoteLoading || remoteResults.length > 0) && ( + <li className="border-t border-slate-100 dark:border-slate-700 px-4 py-2 text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-800/70"> + {remoteLoading + ? t("planner.searching_ellipsis") + : t("planner.results")} + </li> + )} + {remoteResults.map((r, i) => ( + <li + key={`remote-${i}`} + className="border-t border-slate-100 dark:border-slate-700" + > + <button + type="button" + className="w-full px-4 py-3 text-left hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-200" + onClick={() => applyPickedResult(r)} + > + <div className="text-sm font-semibold text-slate-900 dark:text-slate-100"> + {r.name} + </div> + {r.label && ( + <div className="text-xs text-slate-500 dark:text-slate-400"> + {r.label} + </div> + )} + </button> + </li> + ))} + </ul> + </div> + </div> + )} + </div> + ); +}; diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx index 1cb6d88..bddf512 100644 --- a/src/frontend/app/components/StopMapModal.tsx +++ b/src/frontend/app/components/StopMapModal.tsx @@ -9,6 +9,7 @@ import React, { import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; import { Sheet } from "react-modal-sheet"; import { useApp } from "~/AppContext"; +import LineIcon from "~/components/LineIcon"; import { REGION_DATA } from "~/config/RegionConfig"; import { getLineColour } from "~/data/LineColors"; import type { Stop } from "~/data/StopDataProvider"; @@ -517,26 +518,12 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({ flexDirection: "column", alignItems: "center", gap: 6, - transform: `rotate(${selectedBus.currentPosition.orientationDegrees}deg)`, + filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.3))", + transform: "scale(0.85)", transformOrigin: "center center", }} > - <svg - width="24" - height="24" - viewBox="0 0 24 24" - style={{ - filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.3))", - }} - > - <path - d="M12 2 L22 22 L12 17 L2 22 Z" - fill={getLineColour(selectedBus.line).background} - stroke="#000" - strokeWidth="2" - strokeLinejoin="round" - /> - </svg> + <LineIcon line={selectedBus.line} mode="rounded" /> </div> </Marker> )} diff --git a/src/frontend/app/components/layout/NavBar.module.css b/src/frontend/app/components/layout/NavBar.module.css index 6b46459..ddace40 100644 --- a/src/frontend/app/components/layout/NavBar.module.css +++ b/src/frontend/app/components/layout/NavBar.module.css @@ -7,7 +7,7 @@ background-color: var(--background-color); border-top: 1px solid var(--border-color); - max-width: 500px; + max-width: 48rem; margin-inline: auto; } diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx index 150755f..69b3a63 100644 --- a/src/frontend/app/components/layout/NavBar.tsx +++ b/src/frontend/app/components/layout/NavBar.tsx @@ -67,15 +67,15 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { }, }, { - name: t("navbar.lines", "Líneas"), - icon: Route, - path: "/lines", - }, - { name: t("navbar.planner", "Planificador"), icon: Navigation2, path: "/planner", }, + { + name: t("navbar.lines", "Líneas"), + icon: Route, + path: "/lines", + }, ]; return ( diff --git a/src/frontend/app/data/PlannerApi.ts b/src/frontend/app/data/PlannerApi.ts index db47dcc..df504ad 100644 --- a/src/frontend/app/data/PlannerApi.ts +++ b/src/frontend/app/data/PlannerApi.ts @@ -8,6 +8,7 @@ export interface PlannerSearchResult { export interface RoutePlan { itineraries: Itinerary[]; + timeOffsetSeconds?: number; } export interface Itinerary { @@ -19,6 +20,8 @@ export interface Itinerary { transitTimeSeconds: number; waitingTimeSeconds: number; legs: Leg[]; + cashFareEuro?: number; + cardFareEuro?: number; } export interface Leg { diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts index 1572896..8a2959a 100644 --- a/src/frontend/app/hooks/usePlanner.ts +++ b/src/frontend/app/hooks/usePlanner.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { type PlannerSearchResult, type RoutePlan, @@ -13,6 +13,9 @@ interface StoredRoute { origin: PlannerSearchResult; destination: PlannerSearchResult; plan: RoutePlan; + searchTime?: Date; + arriveBy?: boolean; + selectedItineraryIndex?: number; } export function usePlanner() { @@ -23,6 +26,11 @@ export function usePlanner() { const [plan, setPlan] = useState<RoutePlan | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); + const [searchTime, setSearchTime] = useState<Date | null>(null); + const [arriveBy, setArriveBy] = useState(false); + const [selectedItineraryIndex, setSelectedItineraryIndex] = useState< + number | null + >(null); // Load from storage on mount useEffect(() => { @@ -34,6 +42,9 @@ export function usePlanner() { setOrigin(data.origin); setDestination(data.destination); setPlan(data.plan); + setSearchTime(data.searchTime ? new Date(data.searchTime) : null); + setArriveBy(data.arriveBy ?? false); + setSelectedItineraryIndex(data.selectedItineraryIndex ?? null); } else { localStorage.removeItem(STORAGE_KEY); } @@ -47,7 +58,7 @@ export function usePlanner() { from: PlannerSearchResult, to: PlannerSearchResult, time?: Date, - arriveBy: boolean = false + arriveByParam: boolean = false ) => { setLoading(true); setError(null); @@ -58,11 +69,14 @@ export function usePlanner() { to.lat, to.lon, time, - arriveBy + arriveByParam ); setPlan(result); setOrigin(from); setDestination(to); + setSearchTime(time ?? new Date()); + setArriveBy(arriveByParam); + setSelectedItineraryIndex(null); // Reset when doing new search // Save to storage const toStore: StoredRoute = { @@ -70,6 +84,9 @@ export function usePlanner() { origin: from, destination: to, plan: result, + searchTime: time ?? new Date(), + arriveBy: arriveByParam, + selectedItineraryIndex: null, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); } catch (err) { @@ -84,9 +101,44 @@ export function usePlanner() { setPlan(null); setOrigin(null); setDestination(null); + setSearchTime(null); + setArriveBy(false); + setSelectedItineraryIndex(null); localStorage.removeItem(STORAGE_KEY); }; + const selectItinerary = useCallback((index: number) => { + setSelectedItineraryIndex(index); + + // Update storage + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const data: StoredRoute = JSON.parse(stored); + data.selectedItineraryIndex = index; + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch (e) { + // Ignore + } + } + }, []); + + const deselectItinerary = useCallback(() => { + setSelectedItineraryIndex(null); + + // Update storage + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const data: StoredRoute = JSON.parse(stored); + data.selectedItineraryIndex = null; + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch (e) { + // Ignore + } + } + }, []); + return { origin, setOrigin, @@ -95,7 +147,12 @@ export function usePlanner() { plan, loading, error, + searchTime, + arriveBy, + selectedItineraryIndex, searchRoute, clearRoute, + selectItinerary, + deselectItinerary, }; } diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 3bbf820..9138e4b 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -98,6 +98,40 @@ "lines": "Lines", "view_all_estimates": "View all estimates" }, + "planner": { + "where_to": "Where do you want to go?", + "origin": "Origin", + "destination": "Destination", + "when": "When", + "now": "Now", + "depart_at": "Depart at", + "arrive_by": "Arrive by", + "search_route": "Search route", + "searching": "Searching…", + "select_origin": "Select origin", + "select_destination": "Select destination", + "search_origin": "Search origin", + "search_destination": "Search destination", + "confirm": "→", + "current_location": "Current location", + "using_gps": "Using GPS…", + "gps": "GPS", + "favourite_stops": "★ Favourites", + "searching_ellipsis": "Searching…", + "results": "Results", + "close": "Close", + "results_title": "Results", + "clear": "Clear", + "no_routes_found": "No routes found", + "no_routes_message": "We couldn't find a route for your trip. Try changing the time or locations.", + "walk": "Walk", + "walk_to": "Walk {{distance}} to {{destination}}", + "from_to": "From {{from}} to {{to}}", + "itinerary_details": "Itinerary Details", + "back": "← Back", + "cash_fare": "€{{amount}}", + "card_fare": "€{{amount}}" + }, "common": { "loading": "Loading...", "error": "An unexpected error occurred.", @@ -106,6 +140,7 @@ "navbar": { "home": "Home", "map": "Map", + "planner": "Planner", "lines": "Lines" }, "lines": { diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index e5fa0ad..d7c94ea 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -98,6 +98,40 @@ "lines": "Líneas", "view_all_estimates": "Ver todas las estimaciones" }, + "planner": { + "where_to": "¿Adónde quieres ir?", + "origin": "Origen", + "destination": "Destino", + "when": "Cuándo", + "now": "Ahora", + "depart_at": "Salir a", + "arrive_by": "Llegar a", + "search_route": "Buscar ruta", + "searching": "Buscando…", + "select_origin": "Seleccionar origen", + "select_destination": "Seleccionar destino", + "search_origin": "Buscar origen", + "search_destination": "Buscar destino", + "confirm": "→", + "current_location": "Ubicación actual", + "using_gps": "Usando GPS…", + "gps": "GPS", + "favourite_stops": "★ Favoritas", + "searching_ellipsis": "Buscando…", + "results": "Resultados", + "close": "Cerrar", + "results_title": "Results", + "clear": "Clear", + "no_routes_found": "No se encontraron rutas", + "no_routes_message": "No pudimos encontrar una ruta para tu viaje. Intenta cambiar la hora o las ubicaciones.", + "walk": "Caminar", + "walk_to": "Caminar {{distance}} hasta {{destination}}", + "from_to": "De {{from}} a {{to}}", + "itinerary_details": "Detalles del itinerario", + "back": "← Atrás", + "cash_fare": "{{amount}} €", + "card_fare": "{{amount}} €" + }, "common": { "loading": "Cargando...", "error": "Ha ocurrido un error inesperado.", @@ -106,6 +140,7 @@ "navbar": { "home": "Inicio", "map": "Mapa", + "planner": "Planificador", "lines": "Líneas" }, "lines": { diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index f41e38c..0d91efb 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -98,6 +98,40 @@ "lines": "Liñas", "view_all_estimates": "Ver todas as estimacións" }, + "planner": { + "where_to": "¿Onde queres ir?", + "origin": "Orixe", + "destination": "Destino", + "when": "Cando", + "now": "Agora", + "depart_at": "Saír a", + "arrive_by": "Chegar a", + "search_route": "Buscar ruta", + "searching": "Buscando…", + "select_origin": "Seleccionar orixe", + "select_destination": "Seleccionar destino", + "search_origin": "Buscar orixe", + "search_destination": "Buscar destino", + "confirm": "→", + "current_location": "Ubicación actual", + "using_gps": "Usando GPS…", + "gps": "GPS", + "favourite_stops": "★ Favoritas", + "searching_ellipsis": "Buscando…", + "results": "Resultados", + "close": "Pechar", + "results_title": "Resultados", + "clear": "Limpar", + "no_routes_found": "Non se atoparon rutas", + "no_routes_message": "Non puidemos atopar unha ruta para a túa viaxe. Intenta cambiar a hora ou as ubicacións.", + "walk": "Camiñar", + "walk_to": "Camiñar {{distance}} ata {{destination}}", + "from_to": "De {{from}} a {{to}}", + "itinerary_details": "Detalles do itinerario", + "back": "← Atrás", + "cash_fare": "{{amount}} €", + "card_fare": "{{amount}} €" + }, "common": { "loading": "Cargando...", "error": "Produciuse un erro inesperado.", @@ -106,6 +140,7 @@ "navbar": { "home": "Inicio", "map": "Mapa", + "planner": "Planificador", "lines": "Liñas" }, "lines": { diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css index 367fa29..c6d9058 100644 --- a/src/frontend/app/root.css +++ b/src/frontend/app/root.css @@ -183,7 +183,7 @@ body { @media (min-width: 1024px) { .page-container { - max-width: 1024px; + max-width: 48rem; } } @@ -191,3 +191,7 @@ body { font-size: 0.9em; color: var(--ml-c-link-2); } + +.maplibregl-ctrl-icon:before { + display: none; +} 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<MapRef>(null); + const { searchRoute, origin, setOrigin } = usePlanner(); + // Style state for Map component const [mapStyle, setMapStyle] = useState<StyleSpecification>(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 ( - <Map - mapStyle={mapStyle} - style={{ width: "100%", height: "100%" }} - interactiveLayerIds={["stops", "stops-label"]} - onClick={onMapClick} - minZoom={11} - scrollZoom - pitch={0} - roll={0} - ref={mapRef} - initialViewState={{ - latitude: getLatitude(mapState.center), - longitude: getLongitude(mapState.center), - zoom: mapState.zoom, - }} - attributionControl={{ compact: false }} - maxBounds={[REGION_DATA.bounds.sw, REGION_DATA.bounds.ne]} - > - <NavigationControl position="top-right" /> - <GeolocateControl - position="top-right" - trackUserLocation={true} - positionOptions={{ enableHighAccuracy: false }} + <div className="relative h-full"> + <PlannerOverlay + onSearch={(o, d, time, arriveBy) => searchRoute(o, d, time, arriveBy)} + onNavigateToPlanner={() => navigate("/planner")} + clearPickerOnOpen={true} + showLastDestinationWhenCollapsed={false} /> - <Source - id="stops-source" - type="geojson" - data={{ type: "FeatureCollection", features: stops }} - /> - - <Layer - id="stops" - type="symbol" - minzoom={11} - source="stops-source" - layout={{ - "icon-image": ["get", "prefix"], - "icon-size": [ - "interpolate", - ["linear"], - ["zoom"], - 13, - 0.7, - 16, - 0.8, - 18, - 1.2, - ], - "icon-allow-overlap": true, - "icon-ignore-placement": true, + <Map + mapStyle={mapStyle} + style={{ width: "100%", height: "100%" }} + interactiveLayerIds={["stops", "stops-label"]} + onClick={onMapClick} + minZoom={11} + scrollZoom + pitch={0} + roll={0} + ref={mapRef} + initialViewState={{ + latitude: getLatitude(mapState.center), + longitude: getLongitude(mapState.center), + zoom: mapState.zoom, }} - /> + attributionControl={{ compact: false }} + maxBounds={[REGION_DATA.bounds.sw, REGION_DATA.bounds.ne]} + > + <NavigationControl position="bottom-right" /> + <GeolocateControl + position="bottom-right" + trackUserLocation={true} + positionOptions={{ enableHighAccuracy: false }} + /> - <Layer - id="stops-label" - type="symbol" - source="stops-source" - minzoom={16} - layout={{ - "text-field": ["get", "name"], - "text-font": ["Noto Sans Bold"], - "text-offset": [0, 3], - "text-anchor": "center", - "text-justify": "center", - "text-size": ["interpolate", ["linear"], ["zoom"], 11, 8, 22, 16], - }} - paint={{ - "text-color": [ - "case", - ["==", ["get", "prefix"], "stop-renfe"], - "#870164", - "#e72b37", - ], - "text-halo-color": "#FFF", - "text-halo-width": 1, - }} - /> + <Source + id="stops-source" + type="geojson" + data={{ type: "FeatureCollection", features: stops }} + /> - {selectedStop && ( - <StopSheet - isOpen={isSheetOpen} - onClose={() => setIsSheetOpen(false)} - stop={selectedStop} + <Layer + id="stops" + type="symbol" + minzoom={11} + source="stops-source" + layout={{ + "icon-image": ["get", "prefix"], + "icon-size": [ + "interpolate", + ["linear"], + ["zoom"], + 13, + 0.7, + 16, + 0.8, + 18, + 1.2, + ], + "icon-allow-overlap": true, + "icon-ignore-placement": true, + }} /> - )} - </Map> + + <Layer + id="stops-label" + type="symbol" + source="stops-source" + minzoom={16} + layout={{ + "text-field": ["get", "name"], + "text-font": ["Noto Sans Bold"], + "text-offset": [0, 3], + "text-anchor": "center", + "text-justify": "center", + "text-size": ["interpolate", ["linear"], ["zoom"], 11, 8, 22, 16], + }} + paint={{ + "text-color": [ + "case", + ["==", ["get", "prefix"], "stop-renfe"], + "#870164", + "#e72b37", + ], + "text-halo-color": "#FFF", + "text-halo-width": 1, + }} + /> + + {selectedStop && ( + <StopSheet + isOpen={isSheetOpen} + onClose={() => setIsSheetOpen(false)} + stop={selectedStop} + /> + )} + </Map> + </div> ); } 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<PlannerSearchResult[]>([]); - 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 ( - <div className="mb-4 relative"> - <label className="block text-sm font-medium text-gray-700 mb-1"> - {label} - </label> - <div className="flex gap-2"> - <input - type="text" - className="w-full p-2 border rounded shadow-sm" - value={query} - onChange={(e) => { - setQuery(e.target.value); - if (!e.target.value) onChange(null); - }} - placeholder={placeholder} - onFocus={() => setShowResults(true)} - /> - {value && ( - <button - onClick={() => { - setQuery(""); - onChange(null); - }} - className="px-2 text-gray-500" - > - ✕ - </button> - )} - </div> - {showResults && results.length > 0 && ( - <ul className="absolute z-10 w-full bg-white border rounded shadow-lg mt-1 max-h-60 overflow-auto"> - {results.map((res, idx) => ( - <li - key={idx} - className="p-2 hover:bg-gray-100 cursor-pointer border-b last:border-b-0" - onClick={() => { - onChange(res); - setQuery(res.name || ""); - setShowResults(false); - }} - > - <div className="font-medium">{res.name}</div> - <div className="text-xs text-gray-500">{res.label}</div> - </li> - ))} - </ul> - )} - </div> - ); + 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 ( <div className="bg-white p-4 rounded-lg shadow mb-3 cursor-pointer hover:bg-gray-50 border border-gray-200" @@ -127,24 +115,63 @@ const ItinerarySummary = ({ </div> <div className="text-gray-600">{durationMinutes} min</div> </div> + <div className="flex items-center gap-2 overflow-x-auto pb-2"> - {itinerary.legs.map((leg, idx) => ( - <React.Fragment key={idx}> - {idx > 0 && <span className="text-gray-400">›</span>} - <div - className={`px-2 py-1 rounded text-sm whitespace-nowrap ${ - leg.mode === "WALK" - ? "bg-gray-200 text-gray-700" - : "bg-blue-600 text-white" - }`} - > - {leg.mode === "WALK" ? "Walk" : leg.routeShortName || leg.mode} - </div> - </React.Fragment> - ))} + {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 ( + <React.Fragment key={idx}> + {idx > 0 && <span className="text-slate-400">›</span>} + {isWalk ? ( + <div className="flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-800 whitespace-nowrap"> + <Footprints className="w-4 h-4 text-slate-600" /> + <span className="font-semibold"> + {legDurationMinutes} {t("estimates.minutes")} + </span> + </div> + ) : ( + <div className="flex items-center gap-2"> + <LineIcon + line={leg.routeShortName || leg.routeName || leg.mode || ""} + mode="rounded" + /> + </div> + )} + </React.Fragment> + ); + })} </div> - <div className="text-sm text-gray-500 mt-1"> - Walk: {Math.round(itinerary.walkDistanceMeters)}m + + <div className="flex items-center justify-between text-sm text-slate-600 mt-1"> + <span> + {t("planner.walk")}: {formatDistance(walkTotals.meters)} + {walkTotals.minutes + ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}` + : ""} + </span> + <span className="flex items-center gap-3"> + <span className="flex items-center gap-1 font-semibold text-slate-700"> + <Coins className="w-4 h-4" /> + {formatCurrency(cashFare)} + </span> + <span className="flex items-center gap-1 text-slate-600"> + <CreditCard className="w-4 h-4" /> + {t("planner.card_fare", { amount: cardFare })} + </span> + </span> </div> </div> ); @@ -157,43 +184,112 @@ const ItineraryDetail = ({ itinerary: Itinerary; onClose: () => void; }) => { + const { t } = useTranslation(); const mapRef = useRef<MapRef>(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<StyleSpecification>(DEFAULT_STYLE); + useEffect(() => { - //const styleName = "carto"; const styleName = "openfreemap"; loadStyle(styleName, theme) .then((style) => setMapStyle(style)) @@ -201,13 +297,16 @@ const ItineraryDetail = ({ }, [theme]); return ( - <div className="fixed inset-0 z-50 bg-white flex flex-col"> - <div className="relative flex-1"> + <div className="flex flex-col md:flex-row h-full"> + {/* Map Section */} + <div className="relative h-2/3 md:h-full md:flex-1"> <Map ref={mapRef} initialViewState={{ - longitude: REGION_DATA.defaultCenter.lng, - latitude: REGION_DATA.defaultCenter.lat, + longitude: + origin?.lon || (REGION_DATA.defaultCenter as [number, number])[0], + latitude: + origin?.lat || (REGION_DATA.defaultCenter as [number, number])[1], zoom: 13, }} mapStyle={mapStyle} @@ -217,185 +316,297 @@ const ItineraryDetail = ({ <Layer id="route-line" type="line" - layout={{ - "line-join": "round", - "line-cap": "round", - }} + layout={{ "line-join": "round", "line-cap": "round" }} paint={{ "line-color": ["get", "color"], "line-width": 5, + // Dotted for walking segments, solid for bus segments + "line-dasharray": [ + "case", + ["==", ["get", "mode"], "WALK"], + ["literal", [1, 3]], + ["literal", [1, 0]], + ], }} /> </Source> - {/* Markers for start/end/transfers could be added here */} + + {/* Origin marker (red) */} + {origin?.lat && origin?.lon && ( + <Marker longitude={origin.lon} latitude={origin.lat}> + <div className="w-6 h-6 bg-red-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center"> + <div className="w-2 h-2 bg-white rounded-full"></div> + </div> + </Marker> + )} + + {/* Destination marker (green) */} + {destination?.lat && destination?.lon && ( + <Marker longitude={destination.lon} latitude={destination.lat}> + <div className="w-6 h-6 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center"> + <div className="w-2 h-2 bg-white rounded-full"></div> + </div> + </Marker> + )} + + {/* Stop markers (boarding, alighting, transfer) */} + {stopMarkers.map((stop, idx) => ( + <Marker key={idx} longitude={stop.lon} latitude={stop.lat}> + <div + className={`w-5 h-5 rounded-full border-2 border-white shadow-md ${ + stop.type === "board" + ? "bg-blue-500" + : stop.type === "alight" + ? "bg-purple-500" + : "bg-orange-500" + }`} + title={`${stop.name} (${stop.type})`} + /> + </Marker> + ))} + + {/* Intermediate stops (smaller white dots) */} + {itinerary.legs.map((leg, legIdx) => + leg.intermediateStops?.map((stop, stopIdx) => ( + <Marker + key={`intermediate-${legIdx}-${stopIdx}`} + longitude={stop.lon} + latitude={stop.lat} + > + <div + className="w-3 h-3 rounded-full border border-gray-400 bg-white shadow-sm" + title={stop.name || "Intermediate stop"} + /> + </Marker> + )) + )} </Map> <button onClick={onClose} - className="absolute top-4 left-4 bg-white p-2 rounded-full shadow z-10" + className="absolute top-4 left-4 bg-white dark:bg-slate-800 p-2 px-4 rounded-lg shadow-lg z-10 font-semibold text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors" > - ← Back + {t("planner.back")} </button> </div> - <Sheet - isOpen={sheetOpen} - onClose={() => setSheetOpen(false)} - detent="content" - initialSnap={0} - > - <Sheet.Container> - <Sheet.Header /> - <Sheet.Content className="px-4 pb-4 overflow-y-auto"> - <h2 className="text-xl font-bold mb-4">Itinerary Details</h2> - <div className="space-y-4"> - {itinerary.legs.map((leg, idx) => ( - <div key={idx} className="flex gap-3"> - <div className="flex flex-col items-center"> + {/* Details Panel */} + <div className="h-1/3 md:h-full md:w-96 lg:w-[28rem] overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700"> + <div className="px-4 py-4"> + <h2 className="text-xl font-bold mb-4"> + {t("planner.itinerary_details")} + </h2> + + <div> + {itinerary.legs.map((leg, idx) => ( + <div key={idx} className="flex gap-3"> + <div className="flex flex-col items-center"> + {leg.mode === "WALK" ? ( <div - className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${ - leg.mode === "WALK" - ? "bg-gray-200 text-gray-700" - : "bg-blue-600 text-white" - }`} + className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shadow-sm" + style={{ backgroundColor: "#e5e7eb", color: "#374151" }} > - {leg.mode === "WALK" ? "🚶" : "🚌"} + <Footprints className="w-4 h-4" /> </div> - {idx < itinerary.legs.length - 1 && ( - <div className="w-0.5 flex-1 bg-gray-300 my-1"></div> - )} - </div> - <div className="flex-1 pb-4"> - <div className="font-bold"> - {leg.mode === "WALK" - ? "Walk" - : `${leg.routeShortName} ${leg.headsign}`} - </div> - <div className="text-sm text-gray-600"> - {new Date(leg.startTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} - {" - "} - {new Date(leg.endTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} + ) : ( + <div + className="shadow-sm" + style={{ transform: "scale(0.9)" }} + > + <LineIcon + line={leg.routeShortName || leg.routeName || ""} + mode="rounded" + /> </div> - <div className="text-sm mt-1"> - {leg.mode === "WALK" ? ( + )} + {idx < itinerary.legs.length - 1 && ( + <div className="w-0.5 flex-1 bg-gray-300 dark:bg-gray-600 my-1"></div> + )} + </div> + <div className="flex-1 pb-4"> + <div className="font-bold flex items-center gap-2"> + {leg.mode === "WALK" ? ( + t("planner.walk") + ) : ( + <> <span> - Walk {Math.round(leg.distanceMeters)}m to{" "} - {leg.to?.name} + {leg.headsign || + leg.routeLongName || + leg.routeName || + ""} </span> - ) : ( + </> + )} + </div> + <div className="text-sm text-gray-600"> + {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")} + </div> + <div className="text-sm mt-1"> + {leg.mode === "WALK" ? ( + <span> + {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; + })(), + })} + </span> + ) : ( + <> <span> - From {leg.from?.name} to {leg.to?.name} + {t("planner.from_to", { + from: leg.from?.name, + to: leg.to?.name, + })} </span> - )} - </div> + {leg.intermediateStops && + leg.intermediateStops.length > 0 && ( + <details className="mt-2"> + <summary className="text-xs text-gray-600 dark:text-gray-400 cursor-pointer hover:text-gray-800 dark:hover:text-gray-200"> + {leg.intermediateStops.length}{" "} + {leg.intermediateStops.length === 1 + ? "stop" + : "stops"} + </summary> + <ul className="mt-1 ml-4 text-xs text-gray-500 dark:text-gray-400 space-y-0.5"> + {leg.intermediateStops.map((stop, idx) => ( + <li key={idx}>• {stop.name}</li> + ))} + </ul> + </details> + )} + </> + )} </div> </div> - ))} - </div> - </Sheet.Content> - </Sheet.Container> - <Sheet.Backdrop onTap={() => setSheetOpen(false)} /> - </Sheet> + </div> + ))} + </div> + </div> + </div> </div> ); }; -// --- 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<Itinerary | null>( 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 ( <ItineraryDetail itinerary={selectedItinerary} - onClose={() => setSelectedItinerary(null)} + onClose={() => { + setSelectedItinerary(null); + deselectItinerary(); + }} /> ); } - return ( - <div className="p-4 max-w-md mx-auto pb-20"> - <h1 className="text-2xl font-bold mb-4">Route Planner</h1> - - {/* Form */} - <div className="bg-white p-4 rounded-lg shadow mb-6"> - <AutocompleteInput - label="From" - value={origin} - onChange={setOrigin} - placeholder="Search origin..." - /> - <AutocompleteInput - label="To" - value={destination} - onChange={setDestination} - placeholder="Search destination..." - /> - - <button - onClick={handleSearch} - disabled={!origin || !destination || loading} - className={`w-full py-3 rounded font-bold text-white ${ - !origin || !destination || loading - ? "bg-gray-400" - : "bg-green-600 hover:bg-green-700" - }`} - > - {loading ? "Calculating..." : "Find Route"} - </button> + // Format search time for display + const searchTimeDisplay = searchTime + ? new Date(searchTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : null; - {error && ( - <div className="mt-4 p-3 bg-red-100 text-red-700 rounded"> - {error} - </div> - )} - </div> + return ( + <div className="relative max-w-3xl mx-auto px-4 pt-4 pb-8"> + <PlannerOverlay + forceExpanded + inline + onSearch={(origin, destination, time, arriveBy) => + searchRoute(origin, destination, time, arriveBy) + } + /> - {/* Results */} {plan && ( <div> - <div className="flex justify-between items-center mb-4"> - <h2 className="text-xl font-bold">Results</h2> + <div className="flex justify-between items-center my-4"> + <div> + <h2 className="text-xl font-bold"> + {t("planner.results_title")} + </h2> + {searchTimeDisplay && ( + <p className="text-sm text-gray-600 dark:text-gray-400"> + {arriveBy ? t("planner.arrive_by") : t("planner.depart_at")}{" "} + {searchTimeDisplay} + </p> + )} + </div> <button onClick={clearRoute} className="text-sm text-red-500"> - Clear + {t("planner.clear")} </button> </div> {plan.itineraries.length === 0 ? ( <div className="p-8 text-center bg-gray-50 rounded-lg border border-dashed border-gray-300"> <div className="text-4xl mb-2">😕</div> - <h3 className="text-lg font-bold mb-1">No routes found</h3> - <p className="text-gray-600"> - We couldn't find a route for your trip. Try changing the time or - locations. - </p> + <h3 className="text-lg font-bold mb-1"> + {t("planner.no_routes_found")} + </h3> + <p className="text-gray-600">{t("planner.no_routes_message")}</p> </div> ) : ( <div className="space-y-3"> @@ -403,7 +614,10 @@ export default function PlannerPage() { <ItinerarySummary key={idx} itinerary={itinerary} - onClick={() => setSelectedItinerary(itinerary)} + onClick={() => { + selectItinerary(idx); + setSelectedItinerary(itinerary); + }} /> ))} </div> |
