From e7eb57bf492617f2b9be88d46c1cc708a2c17af4 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 12 Dec 2025 16:48:14 +0100 Subject: Improved version of the planner feature --- .../Configuration/AppConfiguration.cs | 4 + .../Services/OtpService.cs | 62 +- .../Types/Otp/OtpModels.cs | 12 + .../Types/Planner/PlannerModels.cs | 9 +- src/frontend/app/components/LineIcon.css | 8 +- src/frontend/app/components/PlannerOverlay.tsx | 525 ++++++++++++++++ src/frontend/app/components/StopMapModal.tsx | 21 +- .../app/components/layout/NavBar.module.css | 2 +- src/frontend/app/components/layout/NavBar.tsx | 10 +- src/frontend/app/data/PlannerApi.ts | 3 + src/frontend/app/hooks/usePlanner.ts | 63 +- src/frontend/app/i18n/locales/en-GB.json | 35 ++ src/frontend/app/i18n/locales/es-ES.json | 35 ++ src/frontend/app/i18n/locales/gl-ES.json | 35 ++ src/frontend/app/root.css | 6 +- src/frontend/app/routes/map.tsx | 205 +++--- src/frontend/app/routes/planner.tsx | 700 ++++++++++++++------- 17 files changed, 1373 insertions(+), 362 deletions(-) create mode 100644 src/frontend/app/components/PlannerOverlay.tsx 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().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 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 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 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 Steps { get; set; } = new(); + + public List 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 = ({ + 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("destination"); + const [pickerQuery, setPickerQuery] = useState(""); + const [remoteResults, setRemoteResults] = useState([]); + const [remoteLoading, setRemoteLoading] = useState(false); + + const [favouriteStops, setFavouriteStops] = useState( + [] + ); + + const pickerInputRef = useRef(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 ( +
+
+ {!expanded ? ( + + ) : ( + <> +
+ +
+ +
+ +
+ +
+ {t("planner.when")} +
+ + + +
+ {timeMode !== "now" && ( +
+ + setTimeValue(e.target.value)} + /> +
+ )} +
+ +
+ +
+ + {error && ( +
+ {error} +
+ )} + + )} +
+ + {pickerOpen && ( +
+ +
+
+ +
    + {pickerField === "origin" && ( +
  • + +
  • + )} + + {filteredFavouriteStops.length > 0 && ( + <> +
  • + {t("planner.favourite_stops")} +
  • + {filteredFavouriteStops.map((r, i) => ( +
  • + +
  • + ))} + + )} + + {(remoteLoading || remoteResults.length > 0) && ( +
  • + {remoteLoading + ? t("planner.searching_ellipsis") + : t("planner.results")} +
  • + )} + {remoteResults.map((r, i) => ( +
  • + +
  • + ))} +
+ + + )} + + ); +}; 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 = ({ 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", }} > - - - + )} 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 @@ -66,16 +66,16 @@ 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(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [searchTime, setSearchTime] = useState(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(null); + const { searchRoute, origin, setOrigin } = usePlanner(); + // Style state for Map component const [mapStyle, setMapStyle] = useState(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 ( - - - + searchRoute(o, d, time, arriveBy)} + onNavigateToPlanner={() => navigate("/planner")} + clearPickerOnOpen={true} + showLastDestinationWhenCollapsed={false} /> - - - + attributionControl={{ compact: false }} + maxBounds={[REGION_DATA.bounds.sw, REGION_DATA.bounds.ne]} + > + + - + - {selectedStop && ( - setIsSheetOpen(false)} - stop={selectedStop} + - )} - + + + + {selectedStop && ( + setIsSheetOpen(false)} + stop={selectedStop} + /> + )} + + ); } 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([]); - 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 ( -
- -
- { - setQuery(e.target.value); - if (!e.target.value) onChange(null); - }} - placeholder={placeholder} - onFocus={() => setShowResults(true)} - /> - {value && ( - - )} -
- {showResults && results.length > 0 && ( -
    - {results.map((res, idx) => ( -
  • { - onChange(res); - setQuery(res.name || ""); - setShowResults(false); - }} - > -
    {res.name}
    -
    {res.label}
    -
  • - ))} -
- )} -
- ); + 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 (
{durationMinutes} min
+
- {itinerary.legs.map((leg, idx) => ( - - {idx > 0 && } -
- {leg.mode === "WALK" ? "Walk" : leg.routeShortName || leg.mode} -
-
- ))} + {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")} + +
+ ) : ( +
+ +
+ )} +
+ ); + })}
-
- Walk: {Math.round(itinerary.walkDistanceMeters)}m + +
+ + {t("planner.walk")}: {formatDistance(walkTotals.meters)} + {walkTotals.minutes + ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}` + : ""} + + + + + {formatCurrency(cashFare)} + + + + {t("planner.card_fare", { amount: cardFare })} + +
); @@ -157,43 +184,112 @@ const ItineraryDetail = ({ itinerary: Itinerary; onClose: () => void; }) => { + const { t } = useTranslation(); const mapRef = useRef(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(DEFAULT_STYLE); + useEffect(() => { - //const styleName = "carto"; const styleName = "openfreemap"; loadStyle(styleName, theme) .then((style) => setMapStyle(style)) @@ -201,13 +297,16 @@ const ItineraryDetail = ({ }, [theme]); return ( -
-
+
+ {/* Map Section */} +
- {/* Markers for start/end/transfers could be added here */} + + {/* Origin marker (red) */} + {origin?.lat && origin?.lon && ( + +
+
+
+
+ )} + + {/* Destination marker (green) */} + {destination?.lat && destination?.lon && ( + +
+
+
+
+ )} + + {/* Stop markers (boarding, alighting, transfer) */} + {stopMarkers.map((stop, idx) => ( + +
+ + ))} + + {/* Intermediate stops (smaller white dots) */} + {itinerary.legs.map((leg, legIdx) => + leg.intermediateStops?.map((stop, stopIdx) => ( + +
+ + )) + )}
- setSheetOpen(false)} - detent="content" - initialSnap={0} - > - - - -

Itinerary Details

-
- {itinerary.legs.map((leg, idx) => ( -
-
+ {/* Details Panel */} +
+
+

+ {t("planner.itinerary_details")} +

+ +
+ {itinerary.legs.map((leg, idx) => ( +
+
+ {leg.mode === "WALK" ? (
- {leg.mode === "WALK" ? "🚶" : "🚌"} -
- {idx < itinerary.legs.length - 1 && ( -
- )} -
-
-
- {leg.mode === "WALK" - ? "Walk" - : `${leg.routeShortName} ${leg.headsign}`} +
-
- {new Date(leg.startTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} - {" - "} - {new Date(leg.endTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - })} + ) : ( +
+
-
- {leg.mode === "WALK" ? ( + )} + {idx < itinerary.legs.length - 1 && ( +
+ )} +
+
+
+ {leg.mode === "WALK" ? ( + t("planner.walk") + ) : ( + <> - Walk {Math.round(leg.distanceMeters)}m to{" "} - {leg.to?.name} + {leg.headsign || + leg.routeLongName || + leg.routeName || + ""} - ) : ( + + )} +
+
+ {new Date(leg.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })}{" "} + -{" "} + {( + (new Date(leg.endTime).getTime() - + new Date(leg.startTime).getTime()) / + 60000 + ).toFixed(0)}{" "} + {t("estimates.minutes")} +
+
+ {leg.mode === "WALK" ? ( + + {t("planner.walk_to", { + distance: Math.round(leg.distanceMeters) + "m", + destination: (() => { + const enteredDest = userDestination?.name || ""; + const finalDest = + enteredDest || + itinerary.legs[itinerary.legs.length - 1]?.to + ?.name || + ""; + const raw = leg.to?.name || finalDest || ""; + const cleaned = raw.trim(); + const placeholder = cleaned.toLowerCase(); + // If OTP provided a generic placeholder, use the user's entered destination + if ( + placeholder === "destination" || + placeholder === "destino" || + placeholder === "destinación" || + placeholder === "destinatario" + ) { + return enteredDest || finalDest; + } + return cleaned || finalDest; + })(), + })} + + ) : ( + <> - From {leg.from?.name} to {leg.to?.name} + {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}
  • + ))} +
+
+ )} + + )}
- ))} -
- - - setSheetOpen(false)} /> - +
+ ))} +
+
+
); }; -// --- 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( 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 ( setSelectedItinerary(null)} + onClose={() => { + setSelectedItinerary(null); + deselectItinerary(); + }} /> ); } - return ( -
-

Route Planner

- - {/* Form */} -
- - - - + // Format search time for display + const searchTimeDisplay = searchTime + ? new Date(searchTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }) + : null; - {error && ( -
- {error} -
- )} -
+ return ( +
+ + searchRoute(origin, destination, time, arriveBy) + } + /> - {/* Results */} {plan && (
-
-

Results

+
+
+

+ {t("planner.results_title")} +

+ {searchTimeDisplay && ( +

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

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

No routes found

-

- We couldn't find a route for your trip. Try changing the time or - locations. -

+

+ {t("planner.no_routes_found")} +

+

{t("planner.no_routes_message")}

) : (
@@ -403,7 +614,10 @@ export default function PlannerPage() { setSelectedItinerary(itinerary)} + onClick={() => { + selectItinerary(idx); + setSelectedItinerary(itinerary); + }} /> ))}
-- cgit v1.3