aboutsummaryrefslogtreecommitdiff
path: root/src/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend')
-rw-r--r--src/frontend/app/components/LineIcon.css8
-rw-r--r--src/frontend/app/components/PlannerOverlay.tsx525
-rw-r--r--src/frontend/app/components/StopMapModal.tsx21
-rw-r--r--src/frontend/app/components/layout/NavBar.module.css2
-rw-r--r--src/frontend/app/components/layout/NavBar.tsx10
-rw-r--r--src/frontend/app/data/PlannerApi.ts3
-rw-r--r--src/frontend/app/hooks/usePlanner.ts63
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json35
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json35
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json35
-rw-r--r--src/frontend/app/root.css6
-rw-r--r--src/frontend/app/routes/map.tsx205
-rw-r--r--src/frontend/app/routes/planner.tsx700
13 files changed, 1293 insertions, 355 deletions
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>