summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-03-13 17:12:12 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-03-13 17:12:12 +0100
commitece17875d4e454423f55f0623a456c0433ecd502 (patch)
tree732c0432cbf32757344c51b8c01bb18e83e9c0c0
parent5c670f1b4a237b7a5197dfcf94de92095da95463 (diff)
feat: integrate geolocation functionality and enhance map interactions
- Added useGeolocation hook to manage user location and permissions. - Updated PlannerOverlay to utilize geolocation for setting origin. - Enhanced NavBar with a new planner route. - Introduced context menu for map interactions to set routes from current location. - Improved search functionality in the map with a dedicated search bar. - Updated localization files with new strings for routing and search features.
-rw-r--r--src/frontend/app/components/PlannerOverlay.tsx24
-rw-r--r--src/frontend/app/components/layout/NavBar.tsx7
-rw-r--r--src/frontend/app/components/shared/AppMap.tsx30
-rw-r--r--src/frontend/app/contexts/MapContext.tsx159
-rw-r--r--src/frontend/app/hooks/useGeolocation.ts62
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json12
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json12
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json12
-rw-r--r--src/frontend/app/routes/home.tsx108
-rw-r--r--src/frontend/app/routes/map.tsx311
-rw-r--r--src/frontend/app/routes/planner.tsx637
11 files changed, 943 insertions, 431 deletions
diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx
index facf6f9..d953c2e 100644
--- a/src/frontend/app/components/PlannerOverlay.tsx
+++ b/src/frontend/app/components/PlannerOverlay.tsx
@@ -15,6 +15,7 @@ import {
type PlannerSearchResult,
} from "~/data/PlannerApi";
import StopDataProvider from "~/data/StopDataProvider";
+import { useGeolocation } from "~/hooks/useGeolocation";
import { usePlanner } from "~/hooks/usePlanner";
interface PlannerOverlayProps {
@@ -30,7 +31,6 @@ interface PlannerOverlayProps {
clearPickerOnOpen?: boolean;
showLastDestinationWhenCollapsed?: boolean;
cardBackground?: string;
- userLocation?: { latitude: number; longitude: number } | null;
autoLoad?: boolean;
}
@@ -42,11 +42,11 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
clearPickerOnOpen = false,
showLastDestinationWhenCollapsed = true,
cardBackground,
- userLocation,
autoLoad = true,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
+ const { userLocation, requestLocation } = useGeolocation();
const {
origin,
setOrigin,
@@ -173,22 +173,12 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
const setOriginFromCurrentLocation = useCallback(
(closePicker: boolean = true) => {
- console.log(
- "[PlannerOverlay] setOriginFromCurrentLocation called, closePicker:",
- closePicker
- );
if (!navigator.geolocation) {
- console.warn("[PlannerOverlay] Geolocation not available");
return;
}
setLocationLoading(true);
navigator.geolocation.getCurrentPosition(
async (pos) => {
- console.log(
- "[PlannerOverlay] Geolocation success:",
- pos.coords.latitude,
- pos.coords.longitude
- );
try {
// Set immediately using raw coordinates; refine later if reverse geocode works.
const initial: PlannerSearchResult = {
@@ -198,16 +188,16 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
lon: pos.coords.longitude,
layer: "current-location",
};
- console.log("[PlannerOverlay] Setting initial origin:", initial);
setOrigin(initial);
setOriginQuery(initial.name || "");
+ // Share location with global context so other consumers benefit
+ requestLocation();
try {
const rev = await reverseGeocode(
pos.coords.latitude,
pos.coords.longitude
);
- console.log("[PlannerOverlay] Reverse geocode result:", rev);
if (rev) {
const refined: PlannerSearchResult = {
...initial,
@@ -215,10 +205,6 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
label: rev.label || initial.label,
layer: "current-location",
};
- console.log(
- "[PlannerOverlay] Setting refined origin:",
- refined
- );
setOrigin(refined);
setOriginQuery(refined.name || "");
}
@@ -238,7 +224,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
{ enableHighAccuracy: false, maximumAge: 60000, timeout: 10000 }
);
},
- [setOrigin, t]
+ [setOrigin, t, requestLocation]
);
useEffect(() => {
diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx
index 5822ce7..e66c388 100644
--- a/src/frontend/app/components/layout/NavBar.tsx
+++ b/src/frontend/app/components/layout/NavBar.tsx
@@ -1,4 +1,4 @@
-import { Home, Map, Route } from "lucide-react";
+import { Home, Map, Navigation, Route } from "lucide-react";
import type { LngLatLike } from "maplibre-gl";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useNavigate } from "react-router";
@@ -53,6 +53,11 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) {
},
},
{
+ name: t("navbar.planner", "Planificador"),
+ icon: Navigation,
+ path: "/planner",
+ },
+ {
name: t("navbar.routes", "Rutas"),
icon: Route,
path: "/routes",
diff --git a/src/frontend/app/components/shared/AppMap.tsx b/src/frontend/app/components/shared/AppMap.tsx
index c6eb8ee..f4c8658 100644
--- a/src/frontend/app/components/shared/AppMap.tsx
+++ b/src/frontend/app/components/shared/AppMap.tsx
@@ -44,6 +44,7 @@ interface AppMapProps {
onRotateStart?: () => void;
onPitchStart?: () => void;
onLoad?: () => void;
+ onContextMenu?: (e: MapLayerMouseEvent) => void;
}
export const AppMap = forwardRef<MapRef, AppMapProps>(
@@ -72,6 +73,7 @@ export const AppMap = forwardRef<MapRef, AppMapProps>(
onRotateStart,
onPitchStart,
onLoad,
+ onContextMenu,
},
ref
) => {
@@ -79,6 +81,8 @@ export const AppMap = forwardRef<MapRef, AppMapProps>(
theme,
mapState,
updateMapState,
+ setUserLocation,
+ setLocationPermission,
showTraffic: settingsShowTraffic,
showCameras: settingsShowCameras,
mapPositionMode,
@@ -159,14 +163,9 @@ export const AppMap = forwardRef<MapRef, AppMapProps>(
const viewState = useMemo(() => {
if (initialViewState) return initialViewState;
- if (mapPositionMode === "gps" && mapState.userLocation) {
- return {
- latitude: getLatitude(mapState.userLocation),
- longitude: getLongitude(mapState.userLocation),
- zoom: 16,
- };
- }
-
+ // Prefer the last saved position for this path so navigation doesn't
+ // reset the map viewport. GPS mode is only used as a fallback when the
+ // user has never visited this path before.
const pathState = mapState.paths[path];
if (pathState) {
return {
@@ -176,6 +175,14 @@ export const AppMap = forwardRef<MapRef, AppMapProps>(
};
}
+ if (mapPositionMode === "gps" && mapState.userLocation) {
+ return {
+ latitude: getLatitude(mapState.userLocation),
+ longitude: getLongitude(mapState.userLocation),
+ zoom: 16,
+ };
+ }
+
return {
latitude: getLatitude(APP_CONSTANTS.defaultCenter),
longitude: getLongitude(APP_CONSTANTS.defaultCenter),
@@ -200,13 +207,18 @@ export const AppMap = forwardRef<MapRef, AppMapProps>(
onRotateStart={onRotateStart}
onPitchStart={onPitchStart}
onLoad={onLoad}
+ onContextMenu={onContextMenu}
>
{showNavigation && <NavigationControl position="bottom-right" />}
{showGeolocate && (
<GeolocateControl
position="bottom-right"
- trackUserLocation={true}
positionOptions={{ enableHighAccuracy: false }}
+ onGeolocate={(e) => {
+ const { latitude, longitude } = e.coords;
+ setUserLocation([latitude, longitude]);
+ setLocationPermission(true);
+ }}
/>
)}
{children}
diff --git a/src/frontend/app/contexts/MapContext.tsx b/src/frontend/app/contexts/MapContext.tsx
index f888f34..75851f4 100644
--- a/src/frontend/app/contexts/MapContext.tsx
+++ b/src/frontend/app/contexts/MapContext.tsx
@@ -1,8 +1,10 @@
import { type LngLatLike } from "maplibre-gl";
import {
createContext,
+ useCallback,
useContext,
useEffect,
+ useRef,
useState,
type ReactNode,
} from "react";
@@ -18,6 +20,7 @@ interface MapContextProps {
setUserLocation: (location: LngLatLike | null) => void;
setLocationPermission: (hasPermission: boolean) => void;
updateMapState: (center: LngLatLike, zoom: number, path: string) => void;
+ requestLocation: () => void;
}
const MapContext = createContext<MapContextProps | undefined>(undefined);
@@ -28,9 +31,6 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
if (savedMapState) {
try {
const parsed = JSON.parse(savedMapState);
- // Validate that the saved center is valid if needed, or just trust it.
- // We might want to ensure we have a fallback if the region changed while the app was closed?
- // But for now, let's stick to the existing logic.
return {
paths: parsed.paths || {},
userLocation: parsed.userLocation || null,
@@ -47,58 +47,130 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
};
});
- const setUserLocation = (userLocation: LngLatLike | null) => {
+ const watchIdRef = useRef<number | null>(null);
+
+ const setUserLocation = useCallback((userLocation: LngLatLike | null) => {
setMapState((prev) => {
const newState = { ...prev, userLocation };
localStorage.setItem("mapState", JSON.stringify(newState));
return newState;
});
- };
+ }, []);
- const setLocationPermission = (hasLocationPermission: boolean) => {
- setMapState((prev) => {
- const newState = { ...prev, hasLocationPermission };
- localStorage.setItem("mapState", JSON.stringify(newState));
- return newState;
- });
- };
+ const setLocationPermission = useCallback(
+ (hasLocationPermission: boolean) => {
+ setMapState((prev) => {
+ const newState = { ...prev, hasLocationPermission };
+ localStorage.setItem("mapState", JSON.stringify(newState));
+ return newState;
+ });
+ },
+ []
+ );
- const updateMapState = (center: LngLatLike, zoom: number, path: string) => {
- setMapState((prev) => {
- const newState = {
- ...prev,
- paths: {
- ...prev.paths,
- [path]: { center, zoom },
- },
- };
- localStorage.setItem("mapState", JSON.stringify(newState));
- return newState;
- });
- };
+ const updateMapState = useCallback(
+ (center: LngLatLike, zoom: number, path: string) => {
+ setMapState((prev) => {
+ const newState = {
+ ...prev,
+ paths: {
+ ...prev.paths,
+ [path]: { center, zoom },
+ },
+ };
+ localStorage.setItem("mapState", JSON.stringify(newState));
+ return newState;
+ });
+ },
+ []
+ );
+
+ const startWatching = useCallback(() => {
+ if (!navigator.geolocation || watchIdRef.current !== null) return;
+ watchIdRef.current = navigator.geolocation.watchPosition(
+ (position) => {
+ const { latitude, longitude } = position.coords;
+ setUserLocation([latitude, longitude]);
+ setLocationPermission(true);
+ },
+ (error) => {
+ if (error.code === GeolocationPositionError.PERMISSION_DENIED) {
+ setLocationPermission(false);
+ }
+ },
+ { enableHighAccuracy: false, maximumAge: 30000, timeout: 15000 }
+ );
+ }, [setUserLocation, setLocationPermission]);
+
+ const requestLocation = useCallback(() => {
+ if (typeof window === "undefined" || !("geolocation" in navigator)) return;
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ setUserLocation([pos.coords.latitude, pos.coords.longitude]);
+ setLocationPermission(true);
+ startWatching();
+ },
+ () => {
+ setLocationPermission(false);
+ },
+ { enableHighAccuracy: false, maximumAge: 60000, timeout: 10000 }
+ );
+ }, [setUserLocation, setLocationPermission, startWatching]);
- // Try to get user location on load if permission was granted
+ const hasPermissionRef = useRef(mapState.hasLocationPermission);
+
+ // On mount: subscribe to permission changes and auto-start watching if already granted
useEffect(() => {
- if (mapState.hasLocationPermission && !mapState.userLocation) {
- if (navigator.geolocation) {
- navigator.geolocation.getCurrentPosition(
- (position) => {
- const { latitude, longitude } = position.coords;
- setUserLocation([latitude, longitude]);
- },
- (error) => {
- console.error("Error getting location:", error);
+ if (typeof window === "undefined" || !("geolocation" in navigator)) return;
+
+ let permissionStatus: PermissionStatus | null = null;
+
+ const onPermChange = () => {
+ if (permissionStatus?.state === "granted") {
+ setLocationPermission(true);
+ startWatching();
+ } else if (permissionStatus?.state === "denied") {
+ setLocationPermission(false);
+ if (watchIdRef.current !== null) {
+ navigator.geolocation.clearWatch(watchIdRef.current);
+ watchIdRef.current = null;
+ }
+ }
+ };
+
+ const init = async () => {
+ try {
+ if (navigator.permissions?.query) {
+ permissionStatus = await navigator.permissions.query({
+ name: "geolocation",
+ });
+ if (permissionStatus.state === "granted") {
+ setLocationPermission(true);
+ startWatching();
+ } else if (permissionStatus.state === "denied") {
setLocationPermission(false);
- },
- {
- enableHighAccuracy: true,
- maximumAge: Infinity,
- timeout: 10000,
}
- );
+ permissionStatus.addEventListener("change", onPermChange);
+ } else if (hasPermissionRef.current) {
+ startWatching();
+ }
+ } catch {
+ if (hasPermissionRef.current) {
+ startWatching();
+ }
}
- }
- }, [mapState.hasLocationPermission, mapState.userLocation]);
+ };
+
+ init();
+
+ return () => {
+ if (watchIdRef.current !== null) {
+ navigator.geolocation.clearWatch(watchIdRef.current);
+ watchIdRef.current = null;
+ }
+ permissionStatus?.removeEventListener("change", onPermChange);
+ };
+ }, [startWatching, setLocationPermission]);
return (
<MapContext.Provider
@@ -107,6 +179,7 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
setUserLocation,
setLocationPermission,
updateMapState,
+ requestLocation,
}}
>
{children}
diff --git a/src/frontend/app/hooks/useGeolocation.ts b/src/frontend/app/hooks/useGeolocation.ts
new file mode 100644
index 0000000..878420b
--- /dev/null
+++ b/src/frontend/app/hooks/useGeolocation.ts
@@ -0,0 +1,62 @@
+import { useCallback } from "react";
+import { useMap } from "../contexts/MapContext";
+import type { LngLatLike } from "maplibre-gl";
+
+export interface UseGeolocationResult {
+ userLocation: { latitude: number; longitude: number } | null;
+ hasLocationPermission: boolean;
+ requestLocation: () => void;
+}
+
+function lngLatToCoords(
+ loc: LngLatLike
+): { latitude: number; longitude: number } {
+ if (Array.isArray(loc)) {
+ // This codebase stores location as [latitude, longitude] (not the standard
+ // MapLibre [lng, lat] GeoJSON order). See MapContext.tsx where arrays are
+ // set as [position.coords.latitude, position.coords.longitude], and AppMap.tsx
+ // where getLatitude(center) returns center[0].
+ return { latitude: loc[0], longitude: loc[1] };
+ }
+ if ("lat" in loc) {
+ return {
+ latitude: loc.lat,
+ longitude: "lng" in loc ? (loc as any).lng : (loc as any).lon,
+ };
+ }
+ return { latitude: 0, longitude: 0 };
+}
+
+/**
+ * Provides the current user location from the global MapContext.
+ * Location updates are driven by the MapContext's watchPosition subscription
+ * (started automatically when geolocation permission is granted).
+ *
+ * Call `requestLocation()` to prompt the user for permission and start tracking.
+ */
+export function useGeolocation(): UseGeolocationResult {
+ const { mapState, setUserLocation, setLocationPermission } = useMap();
+
+ const requestLocation = useCallback(() => {
+ if (typeof window === "undefined" || !("geolocation" in navigator)) return;
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ setUserLocation([pos.coords.latitude, pos.coords.longitude]);
+ setLocationPermission(true);
+ },
+ () => {
+ setLocationPermission(false);
+ },
+ { enableHighAccuracy: false, maximumAge: 60000, timeout: 10000 }
+ );
+ }, [setUserLocation, setLocationPermission]);
+
+ const rawLoc = mapState.userLocation;
+ const userLocation = rawLoc ? lngLatToCoords(rawLoc) : null;
+
+ return {
+ userLocation,
+ hasLocationPermission: mapState.hasLocationPermission,
+ requestLocation,
+ };
+}
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index 1987d28..25a7e7b 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -103,7 +103,11 @@
"popup_title": "Stop",
"lines": "Lines",
"view_all_estimates": "View all estimates",
- "select_nearby_stop": "Select stop"
+ "select_nearby_stop": "Select stop",
+ "route_from_here": "Route from here",
+ "route_to_here": "Route to here",
+ "search_placeholder": "Search for a place…",
+ "plan_trip": "Plan a trip"
},
"planner": {
"where_to": "Where do you want to go?",
@@ -149,7 +153,11 @@
"fare": "€{{amount}}",
"free": "Free",
"urban_traffic_warning": "Possible transit restriction",
- "urban_traffic_warning_desc": "Both stops on this leg are within {{municipality}}, which has its own urban transport. It's likely you are not allowed to do this trip with Xunta services."
+ "urban_traffic_warning_desc": "Both stops on this leg are within {{municipality}}, which has its own urban transport. It's likely you are not allowed to do this trip with Xunta services.",
+ "next_arrivals": "Next arrivals",
+ "next_arrival": "Next",
+ "intermediate_stops_one": "1 stop",
+ "intermediate_stops": "{{count}} stops"
},
"common": {
"loading": "Loading...",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index 5e65a88..a97534d 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -103,7 +103,11 @@
"popup_title": "Parada",
"lines": "Líneas",
"view_all_estimates": "Detalles",
- "select_nearby_stop": "Seleccionar parada"
+ "select_nearby_stop": "Seleccionar parada",
+ "route_from_here": "Ruta desde aquí",
+ "route_to_here": "Ruta hasta aquí",
+ "search_placeholder": "Buscar un lugar…",
+ "plan_trip": "Planificar ruta"
},
"planner": {
"where_to": "¿A donde quieres ir?",
@@ -149,7 +153,11 @@
"fare": "{{amount}} €",
"free": "Gratuito",
"urban_traffic_warning": "Posible restricción de tráfico",
- "urban_traffic_warning_desc": "Las dos paradas de este tramo están en {{municipality}}, que dispone de transporte urbano propio. Es probable que no puedas utilizar los servicios de la Xunta en este trayecto."
+ "urban_traffic_warning_desc": "Las dos paradas de este tramo están en {{municipality}}, que dispone de transporte urbano propio. Es probable que no puedas utilizar los servicios de la Xunta en este trayecto.",
+ "next_arrivals": "Próximas llegadas",
+ "next_arrival": "Próximo",
+ "intermediate_stops_one": "1 parada",
+ "intermediate_stops": "{{count}} paradas"
},
"common": {
"loading": "Cargando...",
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index 2c874d8..36a1c66 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -103,7 +103,11 @@
"popup_title": "Parada",
"lines": "Liñas",
"view_all_estimates": "Ver todas as estimacións",
- "select_nearby_stop": "Seleccionar parada"
+ "select_nearby_stop": "Seleccionar parada",
+ "route_from_here": "Ruta desde aquí",
+ "route_to_here": "Ruta ata aquí",
+ "search_placeholder": "Buscar un lugar…",
+ "plan_trip": "Planificar ruta"
},
"planner": {
"where_to": "Onde queres ir?",
@@ -149,7 +153,11 @@
"fare": "{{amount}} €",
"free": "Gratuíto",
"urban_traffic_warning": "Posible restrición de tráfico",
- "urban_traffic_warning_desc": "As dúas paradas deste tramo están en {{municipality}}, que dispón de transporte urbano propio. É probable que non poidas utilizar os servizos da Xunta neste traxecto."
+ "urban_traffic_warning_desc": "As dúas paradas deste tramo están en {{municipality}}, que dispón de transporte urbano propio. É probable que non poidas utilizar os servizos da Xunta neste traxecto.",
+ "next_arrivals": "Próximas chegadas",
+ "next_arrival": "Próximo",
+ "intermediate_stops_one": "1 parada",
+ "intermediate_stops": "{{count}} paradas"
},
"common": {
"loading": "Cargando...",
diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx
index 45d7ddf..e71c788 100644
--- a/src/frontend/app/routes/home.tsx
+++ b/src/frontend/app/routes/home.tsx
@@ -1,8 +1,7 @@
-import { Clock, History, Star } from "lucide-react";
+import { Clock, History, MapPin, Star } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
-import { PlannerOverlay } from "~/components/PlannerOverlay";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { usePlanner } from "~/hooks/usePlanner";
import StopItem from "../components/StopItem";
@@ -13,16 +12,12 @@ export default function StopList() {
const { t } = useTranslation();
usePageTitle(t("navbar.stops", "Paradas"));
const navigate = useNavigate();
- const { history, searchRoute, loadRoute } = usePlanner({ autoLoad: false });
+ const { history, loadRoute } = usePlanner({ autoLoad: false });
const [data, setData] = useState<Stop[] | null>(null);
const [loading, setLoading] = useState(true);
const [searchResults, setSearchResults] = useState<Stop[] | null>(null);
const [favouriteStops, setFavouriteStops] = useState<Stop[]>([]);
const [recentStops, setRecentStops] = useState<Stop[]>([]);
- const [userLocation, setUserLocation] = useState<{
- latitude: number;
- longitude: number;
- } | null>(null);
const searchTimeout = useRef<NodeJS.Timeout | null>(null);
const randomPlaceholder = useMemo(
@@ -30,68 +25,6 @@ export default function StopList() {
[t]
);
- const requestUserLocation = useCallback(() => {
- if (typeof window === "undefined" || !("geolocation" in navigator)) {
- return;
- }
-
- navigator.geolocation.getCurrentPosition(
- (position) => {
- setUserLocation({
- latitude: position.coords.latitude,
- longitude: position.coords.longitude,
- });
- },
- (error) => {
- console.warn("Unable to obtain user location", error);
- },
- {
- enableHighAccuracy: false,
- maximumAge: Infinity,
- timeout: 10000,
- }
- );
- }, []);
-
- useEffect(() => {
- if (typeof window === "undefined" || !("geolocation" in navigator)) {
- return;
- }
-
- let permissionStatus: PermissionStatus | null = null;
-
- const handlePermissionChange = () => {
- if (permissionStatus?.state === "granted") {
- requestUserLocation();
- }
- };
-
- const checkPermission = async () => {
- try {
- if (navigator.permissions?.query) {
- permissionStatus = await navigator.permissions.query({
- name: "geolocation",
- });
- if (permissionStatus.state === "granted") {
- requestUserLocation();
- }
- permissionStatus.addEventListener("change", handlePermissionChange);
- } else {
- requestUserLocation();
- }
- } catch (error) {
- console.warn("Geolocation permission check failed", error);
- requestUserLocation();
- }
- };
-
- checkPermission();
-
- return () => {
- permissionStatus?.removeEventListener("change", handlePermissionChange);
- };
- }, [requestUserLocation]);
-
// Load stops from network
const loadStops = useCallback(async () => {
try {
@@ -164,33 +97,16 @@ export default function StopList() {
<div className="flex flex-col gap-4 py-4 pb-8">
{/* Planner Section */}
<div className="w-full px-4">
- <details className="group bg-surface border border-slate-200 dark:border-slate-700 shadow-sm">
- <summary className="list-none cursor-pointer focus:outline-none">
- <div className="flex items-center justify-between p-3 rounded-xl group-open:mb-3 transition-all">
- <div className="flex items-center gap-3">
- <History className="w-5 h-5 text-primary-600 dark:text-primary-400" />
- <span className="font-semibold text-text">
- {t("planner.where_to", "¿A dónde quieres ir?")}
- </span>
- </div>
- <div className="text-muted group-open:rotate-180 transition-transform">
- ↓
- </div>
- </div>
- </summary>
-
- <PlannerOverlay
- inline
- forceExpanded
- cardBackground="bg-transparent"
- userLocation={userLocation}
- autoLoad={false}
- onSearch={(origin, destination, time, arriveBy) => {
- searchRoute(origin, destination, time, arriveBy);
- }}
- onNavigateToPlanner={() => navigate("/planner")}
- />
- </details>
+ <button
+ type="button"
+ onClick={() => navigate("/planner")}
+ className="w-full flex items-center gap-3 p-3 rounded-xl bg-surface border border-slate-200 dark:border-slate-700 shadow-sm hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors text-left"
+ >
+ <MapPin className="w-5 h-5 text-primary-600 dark:text-primary-400 shrink-0" />
+ <span className="font-semibold text-text">
+ {t("planner.where_to", "¿A dónde quieres ir?")}
+ </span>
+ </button>
{history.length > 0 && (
<div className="mt-3 flex flex-col gap-2">
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index f54f6cf..efc97e4 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -1,6 +1,6 @@
-import { Check, MapPin, X } from "lucide-react";
+import { Check, MapPin, Navigation, Search, X } from "lucide-react";
import type { FilterSpecification } from "maplibre-gl";
-import { useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Layer,
@@ -14,15 +14,171 @@ import {
StopSummarySheet,
type StopSheetProps,
} from "~/components/map/StopSummarySheet";
-import { PlannerOverlay } from "~/components/PlannerOverlay";
import { AppMap } from "~/components/shared/AppMap";
import { usePageTitle } from "~/contexts/PageTitleContext";
-import { reverseGeocode } from "~/data/PlannerApi";
+import {
+ reverseGeocode,
+ searchPlaces,
+ type PlannerSearchResult,
+} from "~/data/PlannerApi";
import { usePlanner } from "~/hooks/usePlanner";
import StopDataProvider from "../data/StopDataProvider";
import "../tailwind-full.css";
import "./map.css";
+// Module-level: keeps search query + results alive across SPA navigation
+const mapSearchState: { query: string; results: PlannerSearchResult[] } = {
+ query: "",
+ results: [],
+};
+
+interface MapSearchBarProps {
+ mapRef: React.RefObject<MapRef | null>;
+}
+
+function MapSearchBar({ mapRef }: MapSearchBarProps) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const [query, setQuery] = useState(mapSearchState.query);
+ const [results, setResults] = useState<PlannerSearchResult[]>(
+ mapSearchState.results
+ );
+ const [showResults, setShowResults] = useState(
+ mapSearchState.results.length > 0
+ );
+ const [loading, setLoading] = useState(false);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const debounceRef = useRef<NodeJS.Timeout | null>(null);
+
+ // Close dropdown when clicking/tapping outside the search container
+ useEffect(() => {
+ const onPointerDown = (e: PointerEvent) => {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(e.target as Node)
+ ) {
+ setShowResults(false);
+ }
+ };
+ document.addEventListener("pointerdown", onPointerDown);
+ return () => document.removeEventListener("pointerdown", onPointerDown);
+ }, []);
+
+ const handleQueryChange = (q: string) => {
+ setQuery(q);
+ mapSearchState.query = q;
+
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+
+ if (q.trim().length < 2) {
+ // Hide stale results when the query is cleared or too short
+ setResults([]);
+ mapSearchState.results = [];
+ setShowResults(false);
+ return;
+ }
+
+ debounceRef.current = setTimeout(async () => {
+ setLoading(true);
+ try {
+ const res = await searchPlaces(q.trim());
+ setResults(res);
+ mapSearchState.results = res;
+ setShowResults(true);
+ } catch {
+ // keep old results on network error
+ } finally {
+ setLoading(false);
+ }
+ }, 300);
+ };
+
+ const handleSelect = (place: PlannerSearchResult) => {
+ const map = mapRef.current;
+ if (map) {
+ map.flyTo({ center: [place.lon, place.lat], zoom: 15, duration: 800 });
+ }
+ // Keep results visible so user can pick another without retyping
+ };
+
+ const handleClear = () => {
+ setQuery("");
+ mapSearchState.query = "";
+ setResults([]);
+ mapSearchState.results = [];
+ setShowResults(false);
+ inputRef.current?.focus();
+ };
+
+ return (
+ <div className="absolute top-4 left-0 right-0 z-20 flex justify-center px-4 pointer-events-none">
+ <div
+ ref={containerRef}
+ className="pointer-events-auto w-full max-w-md flex flex-col gap-1"
+ >
+ {/* Search input */}
+ <div className="flex items-center gap-2 bg-white/95 dark:bg-slate-900/90 backdrop-blur rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 px-3">
+ <Search className="w-4 h-4 text-slate-400 shrink-0" />
+ <input
+ ref={inputRef}
+ type="text"
+ className="flex-1 py-3 bg-transparent text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-500 focus:outline-none"
+ placeholder={t("map.search_placeholder", "Buscar un lugar…")}
+ value={query}
+ onChange={(e) => handleQueryChange(e.target.value)}
+ onFocus={() => {
+ if (results.length > 0) setShowResults(true);
+ }}
+ />
+ {loading ? (
+ <div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin shrink-0" />
+ ) : query ? (
+ <button
+ onPointerDown={(e) => {
+ // Prevent input blur before clear fires
+ e.preventDefault();
+ handleClear();
+ }}
+ className="shrink-0 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"
+ aria-label={t("planner.clear", "Clear")}
+ >
+ <X className="w-4 h-4" />
+ </button>
+ ) : null}
+ </div>
+
+ {/* Results dropdown */}
+ {showResults && results.length > 0 && (
+ <div className="bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
+ <div className="max-h-60 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-800">
+ {results.map((place, i) => (
+ <button
+ key={`${place.lat}-${place.lon}-${i}`}
+ className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors text-sm"
+ onClick={() => handleSelect(place)}
+ >
+ <MapPin className="w-4 h-4 text-primary-600 shrink-0 mt-0.5" />
+ <div className="min-w-0">
+ <div className="font-medium text-slate-900 dark:text-slate-100 truncate">
+ {place.name}
+ </div>
+ {place.label && place.label !== place.name && (
+ <div className="text-xs text-slate-500 dark:text-slate-400 truncate">
+ {place.label}
+ </div>
+ )}
+ </div>
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
// Componente principal del mapa
export default function StopMap() {
const { t } = useTranslation();
@@ -43,7 +199,6 @@ export default function StopMap() {
const mapRef = useRef<MapRef>(null);
const {
- searchRoute,
pickingMode,
setPickingMode,
setOrigin,
@@ -53,6 +208,81 @@ export default function StopMap() {
const [isConfirming, setIsConfirming] = useState(false);
+ // Context menu state (right-click / long-press)
+ interface ContextMenuState {
+ x: number;
+ y: number;
+ lat: number;
+ lng: number;
+ }
+ const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
+ const [contextMenuLoading, setContextMenuLoading] = useState<
+ "origin" | "destination" | null
+ >(null);
+
+ const handleContextMenu = (e: MapLayerMouseEvent) => {
+ if (pickingMode) return;
+ e.preventDefault?.();
+ setContextMenu({
+ x: e.point.x,
+ y: e.point.y,
+ lat: e.lngLat.lat,
+ lng: e.lngLat.lng,
+ });
+ };
+
+ const closeContextMenu = () => setContextMenu(null);
+
+ const handleRouteFromHere = async () => {
+ if (!contextMenu) return;
+ setContextMenuLoading("origin");
+ try {
+ const result = await reverseGeocode(contextMenu.lat, contextMenu.lng);
+ const place = {
+ name:
+ result?.name ||
+ `${contextMenu.lat.toFixed(5)}, ${contextMenu.lng.toFixed(5)}`,
+ label: result?.label || "Map location",
+ lat: contextMenu.lat,
+ lon: contextMenu.lng,
+ layer: "map-pick",
+ };
+ setOrigin(place);
+ addRecentPlace(place);
+ closeContextMenu();
+ navigate("/planner");
+ } catch {
+ closeContextMenu();
+ } finally {
+ setContextMenuLoading(null);
+ }
+ };
+
+ const handleRouteToHere = async () => {
+ if (!contextMenu) return;
+ setContextMenuLoading("destination");
+ try {
+ const result = await reverseGeocode(contextMenu.lat, contextMenu.lng);
+ const place = {
+ name:
+ result?.name ||
+ `${contextMenu.lat.toFixed(5)}, ${contextMenu.lng.toFixed(5)}`,
+ label: result?.label || "Map location",
+ lat: contextMenu.lat,
+ lon: contextMenu.lng,
+ layer: "map-pick",
+ };
+ setDestination(place);
+ addRecentPlace(place);
+ closeContextMenu();
+ navigate("/planner");
+ } catch {
+ closeContextMenu();
+ } finally {
+ setContextMenuLoading(null);
+ }
+ };
+
const handleConfirmPick = async () => {
if (!mapRef.current || !pickingMode) return;
const center = mapRef.current.getCenter();
@@ -76,6 +306,7 @@ export default function StopMap() {
}
addRecentPlace(finalResult);
setPickingMode(null);
+ navigate("/planner");
} catch (err) {
console.error("Failed to reverse geocode:", err);
} finally {
@@ -83,12 +314,6 @@ export default function StopMap() {
}
};
- const onMapInteraction = () => {
- if (!pickingMode) {
- window.dispatchEvent(new CustomEvent("plannerOverlay:collapse"));
- }
- };
-
const favouriteIds = useMemo(() => StopDataProvider.getFavouriteIds(), []);
const favouriteFilter = useMemo(() => {
@@ -183,16 +408,7 @@ export default function StopMap() {
return (
<div className="relative h-full">
- {!pickingMode && (
- <PlannerOverlay
- onSearch={(o, d, time, arriveBy) => searchRoute(o, d, time, arriveBy)}
- onNavigateToPlanner={() => navigate("/planner")}
- clearPickerOnOpen={true}
- showLastDestinationWhenCollapsed={false}
- cardBackground="bg-white/95 dark:bg-slate-900/90"
- autoLoad={false}
- />
- )}
+ {!pickingMode && <MapSearchBar mapRef={mapRef} />}
{pickingMode && (
<div className="absolute top-4 left-0 right-0 z-20 flex justify-center px-4 pointer-events-none">
@@ -252,9 +468,11 @@ export default function StopMap() {
showGeolocate={true}
showTraffic={pickingMode ? false : undefined}
interactiveLayerIds={["stops", "stops-label"]}
- onClick={onMapClick}
- onDragStart={onMapInteraction}
- onZoomStart={onMapInteraction}
+ onClick={(e) => {
+ closeContextMenu();
+ onMapClick(e);
+ }}
+ onContextMenu={handleContextMenu}
attributionControl={{ compact: false }}
>
<Source
@@ -440,6 +658,51 @@ export default function StopMap() {
</div>
)}
</AppMap>
+
+ {contextMenu && (
+ <>
+ {/* Dismiss backdrop */}
+ <div className="absolute inset-0 z-30" onClick={closeContextMenu} />
+ {/* Context menu */}
+ <div
+ className="absolute z-40 min-w-[180px] rounded-xl bg-white dark:bg-slate-900 shadow-2xl border border-slate-200 dark:border-slate-700 overflow-hidden"
+ style={{
+ left: Math.min(contextMenu.x, window.innerWidth - 200),
+ top: Math.min(contextMenu.y, window.innerHeight - 120),
+ }}
+ >
+ <button
+ className="w-full flex items-center gap-3 px-4 py-3 text-sm text-left text-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors disabled:opacity-50"
+ onClick={handleRouteFromHere}
+ disabled={contextMenuLoading !== null}
+ >
+ {contextMenuLoading === "origin" ? (
+ <div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
+ ) : (
+ <Navigation className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" />
+ )}
+ <span className="font-medium">
+ {t("map.route_from_here", "Ruta desde aquí")}
+ </span>
+ </button>
+ <div className="h-px bg-slate-100 dark:bg-slate-800" />
+ <button
+ className="w-full flex items-center gap-3 px-4 py-3 text-sm text-left text-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors disabled:opacity-50"
+ onClick={handleRouteToHere}
+ disabled={contextMenuLoading !== null}
+ >
+ {contextMenuLoading === "destination" ? (
+ <div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
+ ) : (
+ <MapPin className="w-4 h-4 text-primary-600 dark:text-primary-400 shrink-0" />
+ )}
+ <span className="font-medium">
+ {t("map.route_to_here", "Ruta hasta aquí")}
+ </span>
+ </button>
+ </div>
+ </>
+ )}
</div>
);
}
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx
index 4038ef7..c2fc648 100644
--- a/src/frontend/app/routes/planner.tsx
+++ b/src/frontend/app/routes/planner.tsx
@@ -1,4 +1,12 @@
-import { AlertTriangle, Coins, CreditCard, Footprints } from "lucide-react";
+import {
+ AlertTriangle,
+ Coins,
+ CreditCard,
+ Footprints,
+ LayoutGrid,
+ List,
+ Map as MapIcon,
+} from "lucide-react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import React, { useEffect, useMemo, useRef, useState } from "react";
@@ -14,6 +22,7 @@ import { AppMap } from "~/components/shared/AppMap";
import { APP_CONSTANTS } from "~/config/constants";
import { useBackButton, usePageTitle } from "~/contexts/PageTitleContext";
import { type Itinerary } from "~/data/PlannerApi";
+import { useGeolocation } from "~/hooks/useGeolocation";
import { usePlanner } from "~/hooks/usePlanner";
import "../tailwind-full.css";
@@ -45,6 +54,14 @@ const haversineMeters = (a: [number, number], b: [number, number]) => {
return 2 * R * Math.asin(Math.sqrt(h));
};
+const shouldSkipWalkLeg = (leg: Itinerary["legs"][number]): boolean => {
+ if (leg.mode !== "WALK") return false;
+ const durationMinutes =
+ (new Date(leg.endTime).getTime() - new Date(leg.startTime).getTime()) /
+ 60000;
+ return durationMinutes <= 2 || leg.distanceMeters < 50;
+};
+
const sumWalkMetrics = (legs: Itinerary["legs"]) => {
let meters = 0;
let minutes = 0;
@@ -129,44 +146,44 @@ const ItinerarySummary = ({
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-2">
- {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
- )
- );
+ {itinerary.legs
+ .filter((leg) => !shouldSkipWalkLeg(leg))
+ .map((leg, idx) => {
+ const isWalk = leg.mode === "WALK";
+ const legDurationMinutes = Math.max(
+ 1,
+ Math.round(
+ (new Date(leg.endTime).getTime() -
+ new Date(leg.startTime).getTime()) /
+ 60000
+ )
+ );
- const isFirstBusLeg =
- !isWalk &&
- itinerary.legs.findIndex((l) => l.mode !== "WALK") === idx;
-
- return (
- <React.Fragment key={idx}>
- {idx > 0 && <span className="text-muted/50">›</span>}
- {isWalk ? (
- <div className="flex items-center gap-2 rounded-full bg-surface px-3 py-1.5 text-sm text-text whitespace-nowrap border border-border">
- <Footprints className="w-4 h-4 text-muted" />
- <span className="font-semibold">
- {formatDuration(legDurationMinutes, t)}
- </span>
- </div>
- ) : (
- <div className="flex items-center gap-2">
- <RouteIcon
- line={leg.routeShortName || leg.routeName || leg.mode || ""}
- mode="pill"
- colour={leg.routeColor || undefined}
- textColour={leg.routeTextColor || undefined}
- />
- </div>
- )}
- </React.Fragment>
- );
- })}
+ return (
+ <React.Fragment key={idx}>
+ {idx > 0 && <span className="text-muted/50">›</span>}
+ {isWalk ? (
+ <div className="flex items-center gap-2 rounded-full bg-surface px-3 py-1.5 text-sm text-text whitespace-nowrap border border-border">
+ <Footprints className="w-4 h-4 text-muted" />
+ <span className="font-semibold">
+ {formatDuration(legDurationMinutes, t)}
+ </span>
+ </div>
+ ) : (
+ <div className="flex items-center gap-2">
+ <RouteIcon
+ line={
+ leg.routeShortName || leg.routeName || leg.mode || ""
+ }
+ mode="pill"
+ colour={leg.routeColor || ""}
+ textColour={leg.routeTextColor || ""}
+ />
+ </div>
+ )}
+ </React.Fragment>
+ );
+ })}
</div>
<div className="flex items-center justify-between text-sm text-muted mt-1">
@@ -211,6 +228,40 @@ const ItineraryDetail = ({
const [nextArrivals, setNextArrivals] = useState<
Record<string, StopEstimatesResponse>
>({});
+ const [selectedLegIndex, setSelectedLegIndex] = useState<number | null>(null);
+ const [layoutMode, setLayoutMode] = useState<"balanced" | "map" | "list">(
+ "balanced"
+ );
+
+ const focusLegOnMap = (leg: Itinerary["legs"][number]) => {
+ if (!mapRef.current) return;
+
+ const bounds = new maplibregl.LngLatBounds();
+ leg.geometry?.coordinates?.forEach((coord) =>
+ bounds.extend([coord[0], coord[1]])
+ );
+
+ if (leg.from?.lon && leg.from?.lat) {
+ bounds.extend([leg.from.lon, leg.from.lat]);
+ }
+
+ if (leg.to?.lon && leg.to?.lat) {
+ bounds.extend([leg.to.lon, leg.to.lat]);
+ }
+
+ if (!bounds.isEmpty()) {
+ mapRef.current.fitBounds(bounds, { padding: 90, duration: 800 });
+ return;
+ }
+
+ if (leg.from?.lon && leg.from?.lat) {
+ mapRef.current.flyTo({
+ center: [leg.from.lon, leg.from.lat],
+ zoom: 15,
+ duration: 800,
+ });
+ }
+ };
const routeGeoJson = {
type: "FeatureCollection",
@@ -283,10 +334,41 @@ const ItineraryDetail = ({
return { type: "FeatureCollection", features };
}, [itinerary]);
- // Get origin and destination coordinates
const origin = itinerary.legs[0]?.from;
const destination = itinerary.legs[itinerary.legs.length - 1]?.to;
+ const mapHeightClass =
+ layoutMode === "map"
+ ? "h-[78%]"
+ : layoutMode === "list"
+ ? "h-[35%]"
+ : "h-[50%]";
+
+ const detailHeightClass =
+ layoutMode === "map"
+ ? "h-[22%]"
+ : layoutMode === "list"
+ ? "h-[65%]"
+ : "h-[50%]";
+
+ const layoutOptions = [
+ {
+ id: "map",
+ label: t("routes.layout_map", "Mapa"),
+ icon: MapIcon,
+ },
+ {
+ id: "balanced",
+ label: t("routes.layout_balanced", "Equilibrada"),
+ icon: LayoutGrid,
+ },
+ {
+ id: "list",
+ label: t("routes.layout_list", "Paradas"),
+ icon: List,
+ },
+ ] as const;
+
useEffect(() => {
if (!mapRef.current) return;
@@ -362,7 +444,7 @@ const ItineraryDetail = ({
return (
<div className="flex flex-col md:flex-row h-full">
{/* Map Section */}
- <div className="relative h-2/3 md:h-full md:flex-1">
+ <div className={`${mapHeightClass} relative md:h-full md:flex-1`}>
<AppMap
ref={mapRef}
initialViewState={{
@@ -465,7 +547,7 @@ const ItineraryDetail = ({
]}
layout={{
"text-field": ["get", "index"],
- "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
+ "text-font": ["Noto Sans Bold"],
"text-size": [
"interpolate",
["linear"],
@@ -485,204 +567,303 @@ const ItineraryDetail = ({
/>
</Source>
</AppMap>
+
+ <div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-full border border-border bg-background/90 p-1 shadow-sm backdrop-blur">
+ {layoutOptions.map((option) => {
+ const Icon = option.icon;
+ const isActive = layoutMode === option.id;
+ return (
+ <button
+ key={option.id}
+ type="button"
+ onClick={() => setLayoutMode(option.id)}
+ className={`h-8 w-8 rounded-full flex items-center justify-center transition-colors ${
+ isActive
+ ? "bg-primary text-white"
+ : "text-muted hover:text-text"
+ }`}
+ aria-label={option.label}
+ title={option.label}
+ >
+ <Icon size={16} />
+ </button>
+ );
+ })}
+ </div>
</div>
{/* Details Panel */}
- <div className="h-1/3 md:h-full md:w-96 lg:w-lg 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={`${detailHeightClass} md:h-full md:w-96 lg:w-lg 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 text-slate-900 dark:text-slate-100">
{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 w-20 shrink-0">
- {leg.mode === "WALK" ? (
- <div
- className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shadow-sm"
- style={{ backgroundColor: "#e5e7eb", color: "#374151" }}
- >
- <Footprints className="w-4 h-4" />
- </div>
- ) : (
- <RouteIcon
- line={leg.routeShortName || leg.routeName || ""}
- mode="rounded"
- colour={leg.routeColor || undefined}
- textColour={leg.routeTextColor || undefined}
- />
- )}
- {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">
+ {itinerary.legs.map((leg, idx) => {
+ const arrivalsForLeg =
+ leg.mode !== "WALK" && leg.from?.stopId && leg.to?.stopId
+ ? (
+ nextArrivals[`${leg.from.stopId}::${leg.to.stopId}`]
+ ?.arrivals ?? []
+ )
+ .map((arrival) => ({
+ arrival,
+ minutes: arrival.estimate.minutes,
+ delay: arrival.delay,
+ }))
+ .slice(0, 4)
+ : [];
+
+ const legDestinationLabel = (() => {
+ if (leg.mode !== "WALK") {
+ return (
+ leg.to?.name || t("planner.unknown_stop", "Unknown stop")
+ );
+ }
+
+ const enteredDest = userDestination?.name || "";
+ const finalDest =
+ enteredDest ||
+ itinerary.legs[itinerary.legs.length - 1]?.to?.name ||
+ "";
+ const raw = leg.to?.name || finalDest || "";
+ const cleaned = raw.trim();
+ const placeholder = cleaned.toLowerCase();
+
+ if (
+ placeholder === "destination" ||
+ placeholder === "destino" ||
+ placeholder === "destinación" ||
+ placeholder === "destinatario"
+ ) {
+ return enteredDest || finalDest;
+ }
+
+ return cleaned || finalDest;
+ })();
+
+ return (
+ <div key={idx} className="flex gap-3 mb-3">
+ <div className="flex flex-col items-center w-12 shrink-0 pt-1">
{leg.mode === "WALK" ? (
- t("planner.walk")
- ) : (
- <div className="flex flex-col">
- <span className="text-[10px] uppercase text-muted font-bold leading-none mb-1">
- {t("planner.direction")}
- </span>
- <span className="leading-tight">
- {leg.headsign ||
- leg.routeLongName ||
- leg.routeName ||
- ""}
- </span>
+ <div
+ className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shadow-sm"
+ style={{ backgroundColor: "#e5e7eb", color: "#374151" }}
+ >
+ <Footprints className="w-4 h-4" />
</div>
+ ) : (
+ <RouteIcon
+ line={leg.routeShortName || leg.routeName || ""}
+ mode="rounded"
+ colour={leg.routeColor || ""}
+ textColour={leg.routeTextColor || ""}
+ />
)}
- </div>
- <div className="text-sm text-gray-600 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-1 mt-1">
- <span>
- {new Date(leg.startTime).toLocaleTimeString([], {
- hour: "2-digit",
- minute: "2-digit",
- timeZone: "Europe/Madrid",
- })}{" "}
- </span>
- <span>•</span>
- <span>
- {formatDuration(
- Math.round(
- (new Date(leg.endTime).getTime() -
- new Date(leg.startTime).getTime()) /
- 60000
- ),
- t
- )}
- </span>
- <span>•</span>
- <span>{formatDistance(leg.distanceMeters)}</span>
- {leg.agencyName && (
- <>
- <span>•</span>
- <span className="italic">{leg.agencyName}</span>
- </>
+ {idx < itinerary.legs.length - 1 && (
+ <div className="w-0.5 flex-1 bg-gray-300 dark:bg-gray-600 my-1 min-h-6"></div>
)}
</div>
- {leg.mode !== "WALK" &&
- leg.from?.stopId &&
- leg.to?.stopId &&
- nextArrivals[`${leg.from.stopId}::${leg.to.stopId}`] && (
- <div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
- <div className="font-semibold mb-1">
- {t("planner.next_arrivals", "Next arrivals")}:
+ <button
+ type="button"
+ onClick={() => {
+ setSelectedLegIndex(idx);
+ focusLegOnMap(leg);
+ }}
+ className={`flex-1 rounded-xl border p-3 text-left transition-colors ${
+ selectedLegIndex === idx
+ ? "border-primary bg-primary/5"
+ : "border-border bg-surface hover:border-primary/50"
+ }`}
+ >
+ <div className="font-bold flex items-center gap-2">
+ {leg.mode === "WALK" ? (
+ t("planner.walk")
+ ) : (
+ <div className="flex flex-col">
+ <span className="text-[10px] uppercase text-muted font-bold leading-none mb-1">
+ {t("planner.direction")}
+ </span>
+ <span className="leading-tight">
+ {leg.headsign ||
+ leg.routeLongName ||
+ leg.routeName ||
+ ""}
+ </span>
</div>
- {nextArrivals[
- `${leg.from.stopId}::${leg.to.stopId}`
- ].arrivals
- .slice(0, 3)
- .map((arrival, i) => (
- <div
- key={`${arrival.tripId}-${i}`}
- className="flex items-center gap-2 py-0.5"
- >
- <span className="font-semibold text-primary-600 dark:text-primary-400">
- {formatDuration(arrival.estimate.minutes, t)}
- </span>
- {arrival.estimate.precision !== "scheduled" && (
- <span className="text-green-600 dark:text-green-400">
- 🟢
- </span>
- )}
- {arrival.delay?.minutes !== undefined &&
- arrival.delay.minutes !== 0 && (
+ )}
+ </div>
+ <div className="text-sm text-gray-600 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-1 mt-1">
+ <span>
+ {new Date(leg.startTime).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZone: "Europe/Madrid",
+ })}{" "}
+ </span>
+ <span>•</span>
+ <span>
+ {formatDuration(
+ Math.round(
+ (new Date(leg.endTime).getTime() -
+ new Date(leg.startTime).getTime()) /
+ 60000
+ ),
+ t
+ )}
+ </span>
+ <span>•</span>
+ <span>{formatDistance(leg.distanceMeters)}</span>
+ {leg.agencyName && (
+ <>
+ <span>•</span>
+ <span className="italic">{leg.agencyName}</span>
+ </>
+ )}
+ </div>
+ {leg.mode !== "WALK" && arrivalsForLeg.length > 0 && (
+ <div className="mt-2">
+ <div className="text-[10px] uppercase tracking-wide text-muted mb-1">
+ {t("planner.next_arrivals", "Next arrivals")}
+ </div>
+ <div className="flex items-center justify-between gap-2 rounded-lg border border-green-500/30 bg-green-500/10 px-2.5 py-2">
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-green-700 dark:text-green-300">
+ {t("planner.next_arrival", "Next")}
+ </span>
+ <span className="inline-flex min-w-16 items-center justify-center rounded-xl bg-green-600 px-3 py-1.5 text-base font-bold leading-none text-white">
+ {arrivalsForLeg[0].minutes}′
+ {arrivalsForLeg[0].delay?.minutes
+ ? arrivalsForLeg[0].delay.minutes > 0
+ ? ` (R${Math.abs(arrivalsForLeg[0].delay.minutes)})`
+ : ` (A${Math.abs(arrivalsForLeg[0].delay.minutes)})`
+ : ""}
+ </span>
+ </div>
+
+ {arrivalsForLeg.length > 1 && (
+ <div className="mt-2 flex flex-wrap justify-end gap-1">
+ {arrivalsForLeg
+ .slice(1)
+ .map(
+ ({ arrival, minutes, delay }, arrivalIdx) => (
<span
- className={
- arrival.delay.minutes > 0
- ? "text-red-500"
- : "text-green-500"
- }
+ key={`${arrival.tripId}-${arrivalIdx}`}
+ className="text-[11px] px-2 py-0.5 bg-primary/10 text-primary rounded"
>
- {arrival.delay.minutes > 0
- ? `+${arrival.delay.minutes}′`
- : `${arrival.delay.minutes}′`}
+ {minutes}′
+ {delay?.minutes
+ ? delay.minutes > 0
+ ? ` (R${Math.abs(delay.minutes)})`
+ : ` (A${Math.abs(delay.minutes)})`
+ : ""}
</span>
- )}
- </div>
- ))}
+ )
+ )}
+ </div>
+ )}
</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>
- ) : (
- <>
+ <div className="text-sm mt-2">
+ {leg.mode === "WALK" ? (
<span>
- {t("planner.from_to", {
- from: leg.from?.name,
- to: leg.to?.name,
+ {t("planner.walk_to", {
+ distance: Math.round(leg.distanceMeters) + "m",
+ destination: legDestinationLabel,
})}
</span>
- {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>
- )}
- {(() => {
- const municipality = getUrbanMunicipalityWarning(leg);
- if (!municipality) return null;
- return (
- <div className="mt-2 flex items-start gap-2 rounded-md bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 px-3 py-2 text-xs text-yellow-800 dark:text-yellow-200">
- <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-yellow-600 dark:text-yellow-400" />
- <div>
- <div className="font-semibold">
- {t("planner.urban_traffic_warning")}
- </div>
- <div>
- {t("planner.urban_traffic_warning_desc", {
- municipality,
+ ) : (
+ <>
+ <span>
+ {t("planner.from_to", {
+ from: leg.from?.name,
+ to: leg.to?.name,
+ })}
+ </span>
+
+ {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">
+ {t("planner.intermediate_stops", {
+ count: leg.intermediateStops.length,
})}
+ </summary>
+ <ul className="mt-1 text-xs space-y-0.5">
+ {/* Boarding stop */}
+ <li className="flex items-center gap-1.5 py-0.5 px-1.5 rounded bg-primary/8 font-semibold text-primary">
+ <span className="w-1.5 h-1.5 rounded-full bg-primary inline-block shrink-0" />
+ <span className="flex-1">
+ {leg.from?.name}
+ </span>
+ {leg.from?.stopCode && (
+ <span className="text-[10px] text-primary/60 shrink-0">
+ {leg.from.stopCode}
+ </span>
+ )}
+ </li>
+ {/* Intermediate stops */}
+ {leg.intermediateStops.map((stop, sIdx) => (
+ <li
+ key={sIdx}
+ className="flex items-center gap-1.5 py-0.5 px-1.5 text-gray-500 dark:text-gray-400"
+ >
+ <span className="w-1 h-1 rounded-full bg-gray-400 dark:bg-gray-500 inline-block shrink-0 ml-0.5" />
+ <span className="flex-1">
+ {stop.name}
+ </span>
+ {stop.stopCode && (
+ <span className="text-[10px] text-gray-400 dark:text-gray-500 shrink-0">
+ {stop.stopCode}
+ </span>
+ )}
+ </li>
+ ))}
+ {/* Alighting stop */}
+ <li className="flex items-center gap-1.5 py-0.5 px-1.5 rounded bg-primary/8 font-semibold text-primary">
+ <span className="w-1.5 h-1.5 rounded-full bg-primary inline-block shrink-0" />
+ <span className="flex-1">
+ {leg.to?.name}
+ </span>
+ {leg.to?.stopCode && (
+ <span className="text-[10px] text-primary/60 shrink-0">
+ {leg.to.stopCode}
+ </span>
+ )}
+ </li>
+ </ul>
+ </details>
+ )}
+
+ {(() => {
+ const municipality =
+ getUrbanMunicipalityWarning(leg);
+ if (!municipality) return null;
+ return (
+ <div className="mt-2 flex items-start gap-2 rounded-md bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 px-3 py-2 text-xs text-yellow-800 dark:text-yellow-200">
+ <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-yellow-600 dark:text-yellow-400" />
+ <div>
+ <div className="font-semibold">
+ {t("planner.urban_traffic_warning")}
+ </div>
+ <div>
+ {t("planner.urban_traffic_warning_desc", {
+ municipality,
+ })}
+ </div>
</div>
</div>
- </div>
- );
- })()}
- </>
- )}
- </div>
+ );
+ })()}
+ </>
+ )}
+ </div>
+ </button>
</div>
- </div>
- ))}
+ );
+ })}
</div>
</div>
</div>
@@ -707,6 +888,7 @@ export default function PlannerPage() {
setOrigin,
setDestination,
} = usePlanner();
+ const { userLocation } = useGeolocation();
const [selectedItinerary, setSelectedItinerary] = useState<Itinerary | null>(
null
);
@@ -802,27 +984,16 @@ export default function PlannerPage() {
onClick={() => {
clearRoute();
setDestination(null);
- if (navigator.geolocation) {
- navigator.geolocation.getCurrentPosition(
- async (pos) => {
- const initial = {
- name: t("planner.current_location"),
- label: "GPS",
- lat: pos.coords.latitude,
- lon: pos.coords.longitude,
- layer: "current-location",
- } as any;
- setOrigin(initial);
- },
- () => {
- // If geolocation fails, just keep origin empty
- },
- {
- enableHighAccuracy: true,
- timeout: 10000,
- maximumAge: 60 * 60 * 1000, // 1 hour in milliseconds
- }
- );
+ if (userLocation) {
+ setOrigin({
+ name: t("planner.current_location"),
+ label: "GPS",
+ lat: userLocation.latitude,
+ lon: userLocation.longitude,
+ layer: "current-location",
+ });
+ } else {
+ setOrigin(null);
}
}}
className="text-sm text-red-500"