aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-28 23:08:25 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-28 23:10:24 +0100
commit120a3c6bddd0fb8d9fa05df4763596956554c025 (patch)
tree3ed99935b58b1a269030aa2a638f35c0aa989f55 /src
parent9618229477439d1604869aa68fc21d4eae7d8bb1 (diff)
Improve planning widget
Diffstat (limited to 'src')
-rw-r--r--src/frontend/app/components/PlannerOverlay.tsx108
-rw-r--r--src/frontend/app/contexts/PlannerContext.tsx381
-rw-r--r--src/frontend/app/hooks/usePlanner.ts268
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json7
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json7
-rw-r--r--src/frontend/app/root.tsx5
-rw-r--r--src/frontend/app/routes/map.tsx178
7 files changed, 599 insertions, 355 deletions
diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx
index 0320d45..cbb4ac1 100644
--- a/src/frontend/app/components/PlannerOverlay.tsx
+++ b/src/frontend/app/components/PlannerOverlay.tsx
@@ -1,4 +1,4 @@
-import { MapPin } from "lucide-react";
+import { ChevronUp, Map, MapPin } from "lucide-react";
import React, {
useCallback,
useEffect,
@@ -7,6 +7,7 @@ import React, {
useState,
} from "react";
import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router";
import PlaceListItem from "~/components/PlaceListItem";
import {
reverseGeocode,
@@ -45,9 +46,21 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
autoLoad = true,
}) => {
const { t } = useTranslation();
- const { origin, setOrigin, destination, setDestination, loading, error } =
- usePlanner({ autoLoad });
- const [isExpanded, setIsExpanded] = useState(false);
+ const navigate = useNavigate();
+ const {
+ origin,
+ setOrigin,
+ destination,
+ setDestination,
+ loading,
+ error,
+ setPickingMode,
+ isExpanded,
+ setIsExpanded,
+ recentPlaces,
+ addRecentPlace,
+ clearRecentPlaces,
+ } = usePlanner({ autoLoad });
const [originQuery, setOriginQuery] = useState(origin?.name || "");
const [destQuery, setDestQuery] = useState("");
@@ -61,14 +74,6 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
const [favouriteStops, setFavouriteStops] = useState<PlannerSearchResult[]>(
[]
);
- const [recentPlaces, setRecentPlaces] = useState<PlannerSearchResult[]>([]);
- const RECENT_KEY = `recentPlaces`;
- const clearRecentPlaces = useCallback(() => {
- setRecentPlaces([]);
- try {
- localStorage.removeItem(RECENT_KEY);
- } catch {}
- }, []);
const pickerInputRef = useRef<HTMLInputElement | null>(null);
@@ -130,43 +135,6 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
.catch(() => setFavouriteStops([]));
}, []);
- // Load recent places from localStorage
- useEffect(() => {
- try {
- const raw = localStorage.getItem(RECENT_KEY);
- if (raw) {
- const parsed = JSON.parse(raw) as PlannerSearchResult[];
- setRecentPlaces(parsed.slice(0, 20));
- }
- } catch {
- setRecentPlaces([]);
- }
- }, []);
-
- const addRecentPlace = useCallback(
- (p: PlannerSearchResult) => {
- const key = `${p.lat.toFixed(5)},${p.lon.toFixed(5)}`;
- const existing = recentPlaces.filter(
- (rp) => `${rp.lat.toFixed(5)},${rp.lon.toFixed(5)}` !== key
- );
- const updated = [
- {
- name: p.name,
- label: p.label,
- lat: p.lat,
- lon: p.lon,
- layer: p.layer,
- },
- ...existing,
- ].slice(0, 20);
- setRecentPlaces(updated);
- try {
- localStorage.setItem(RECENT_KEY, JSON.stringify(updated));
- } catch {}
- },
- [recentPlaces]
- );
-
const filteredFavouriteStops = useMemo(() => {
const q = pickerQuery.trim().toLowerCase();
if (!q) return favouriteStops;
@@ -350,7 +318,6 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
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">
@@ -364,7 +331,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
<div className="flex items-center gap-2">
<button
type="button"
- className="w-full rounded-lg bg-surface border border-slate-200 dark:border-slate-700 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-150 focus:outline-none focus:border-primary-500 shadow-sm"
+ className="grow rounded-lg bg-surface border border-slate-200 dark:border-slate-700 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-150 focus:outline-none focus:border-primary-500 shadow-sm"
onClick={() => openPicker("origin")}
>
<span
@@ -375,6 +342,16 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
{originQuery || t("planner.origin")}
</span>
</button>
+ {!forceExpanded && (
+ <button
+ type="button"
+ className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"
+ onClick={() => setIsExpanded(false)}
+ aria-label={t("planner.collapse", "Collapse")}
+ >
+ <ChevronUp className="w-5 h-5" />
+ </button>
+ )}
</div>
<div>
@@ -610,6 +587,35 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
</li>
)}
+ <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 transition-colors duration-200"
+ onClick={() => {
+ setPickingMode(pickerField);
+ setPickerOpen(false);
+ navigate("/map");
+ }}
+ >
+ <div className="flex items-center gap-2">
+ <span className="inline-flex items-center justify-center w-4 h-4">
+ <Map className="w-4 h-4 text-slate-600 dark:text-slate-400" />
+ </span>
+ <div>
+ <div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
+ {t("planner.pick_on_map", "Pick on map")}
+ </div>
+ <div className="text-xs text-slate-500 dark:text-slate-400">
+ {t(
+ "planner.pick_on_map_desc",
+ "Select a point visually"
+ )}
+ </div>
+ </div>
+ </div>
+ </button>
+ </li>
+
{(remoteLoading || sortedRemoteResults.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
diff --git a/src/frontend/app/contexts/PlannerContext.tsx b/src/frontend/app/contexts/PlannerContext.tsx
new file mode 100644
index 0000000..8b64a2e
--- /dev/null
+++ b/src/frontend/app/contexts/PlannerContext.tsx
@@ -0,0 +1,381 @@
+import { useQueryClient } from "@tanstack/react-query";
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { type PlannerSearchResult, type RoutePlan } from "../data/PlannerApi";
+import { usePlanQuery } from "../hooks/usePlanQuery";
+
+const STORAGE_KEY = "planner_route_history";
+const RECENT_KEY = "recentPlaces";
+const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
+
+interface StoredRoute {
+ timestamp: number;
+ origin: PlannerSearchResult;
+ destination: PlannerSearchResult;
+ plan?: RoutePlan;
+ searchTime?: Date;
+ arriveBy?: boolean;
+ selectedItineraryIndex?: number;
+}
+
+export type PickingMode = "origin" | "destination" | null;
+
+interface PlannerContextType {
+ origin: PlannerSearchResult | null;
+ setOrigin: (origin: PlannerSearchResult | null) => void;
+ destination: PlannerSearchResult | null;
+ setDestination: (destination: PlannerSearchResult | null) => void;
+ plan: RoutePlan | null;
+ loading: boolean;
+ error: string | null;
+ searchTime: Date | null;
+ setSearchTime: (time: Date | null) => void;
+ arriveBy: boolean;
+ setArriveBy: (arriveBy: boolean) => void;
+ selectedItineraryIndex: number | null;
+ history: StoredRoute[];
+ recentPlaces: PlannerSearchResult[];
+ addRecentPlace: (place: PlannerSearchResult) => void;
+ clearRecentPlaces: () => void;
+ pickingMode: PickingMode;
+ setPickingMode: (mode: PickingMode) => void;
+ isExpanded: boolean;
+ setIsExpanded: (expanded: boolean) => void;
+ searchRoute: (
+ from: PlannerSearchResult,
+ to: PlannerSearchResult,
+ time?: Date,
+ arriveByParam?: boolean
+ ) => Promise<void>;
+ loadRoute: (route: StoredRoute) => void;
+ clearRoute: () => void;
+ selectItinerary: (index: number) => void;
+ deselectItinerary: () => void;
+}
+
+const PlannerContext = createContext<PlannerContextType | undefined>(undefined);
+
+export const PlannerProvider: React.FC<{ children: React.ReactNode }> = ({
+ children,
+}) => {
+ const [origin, setOrigin] = useState<PlannerSearchResult | null>(null);
+ const [destination, setDestination] = useState<PlannerSearchResult | null>(
+ null
+ );
+ const [plan, setPlan] = useState<RoutePlan | null>(null);
+ const [searchTime, setSearchTime] = useState<Date | null>(null);
+ const [arriveBy, setArriveBy] = useState(false);
+ const [selectedItineraryIndex, setSelectedItineraryIndex] = useState<
+ number | null
+ >(null);
+ const [history, setHistory] = useState<StoredRoute[]>([]);
+ const [recentPlaces, setRecentPlaces] = useState<PlannerSearchResult[]>([]);
+ const [pickingMode, setPickingMode] = useState<PickingMode>(null);
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const queryClient = useQueryClient();
+
+ // Load recent places from localStorage
+ useEffect(() => {
+ try {
+ const raw = localStorage.getItem(RECENT_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw) as PlannerSearchResult[];
+ setRecentPlaces(parsed.slice(0, 20));
+ }
+ } catch {
+ setRecentPlaces([]);
+ }
+ }, []);
+
+ const addRecentPlace = useCallback((p: PlannerSearchResult) => {
+ setRecentPlaces((prev) => {
+ const key = `${p.lat.toFixed(5)},${p.lon.toFixed(5)}`;
+ const existing = prev.filter(
+ (rp) => `${rp.lat.toFixed(5)},${rp.lon.toFixed(5)}` !== key
+ );
+ const updated = [
+ {
+ name: p.name,
+ label: p.label,
+ lat: p.lat,
+ lon: p.lon,
+ layer: p.layer,
+ },
+ ...existing,
+ ].slice(0, 20);
+ try {
+ localStorage.setItem(RECENT_KEY, JSON.stringify(updated));
+ } catch {}
+ return updated;
+ });
+ }, []);
+
+ const clearRecentPlaces = useCallback(() => {
+ setRecentPlaces([]);
+ try {
+ localStorage.removeItem(RECENT_KEY);
+ } catch {}
+ }, []);
+
+ const {
+ data: queryPlan,
+ isLoading: queryLoading,
+ error: queryError,
+ isFetching,
+ } = usePlanQuery(
+ origin?.lat,
+ origin?.lon,
+ destination?.lat,
+ destination?.lon,
+ searchTime ?? undefined,
+ arriveBy,
+ !!(origin && destination && searchTime)
+ );
+
+ // Sync query result to local state and storage
+ useEffect(() => {
+ if (queryPlan) {
+ setPlan(queryPlan as any);
+
+ if (origin && destination) {
+ const toStore: StoredRoute = {
+ timestamp: Date.now(),
+ origin,
+ destination,
+ plan: queryPlan as any,
+ searchTime: searchTime ?? new Date(),
+ arriveBy,
+ selectedItineraryIndex: selectedItineraryIndex ?? undefined,
+ };
+
+ setHistory((prev) => {
+ const filtered = prev.filter(
+ (r) =>
+ !(
+ r.origin.lat === origin.lat &&
+ r.origin.lon === origin.lon &&
+ r.destination.lat === destination.lat &&
+ r.destination.lon === destination.lon
+ )
+ );
+ const updated = [toStore, ...filtered].slice(0, 3);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
+ }
+ }
+ }, [
+ queryPlan,
+ origin,
+ destination,
+ searchTime,
+ arriveBy,
+ selectedItineraryIndex,
+ ]);
+
+ // Load from storage on mount
+ useEffect(() => {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ try {
+ const data: StoredRoute[] = JSON.parse(stored);
+ const valid = data.filter((r) => Date.now() - r.timestamp < EXPIRY_MS);
+ setHistory(valid);
+
+ if (valid.length > 0) {
+ const last = valid[0];
+ if (last.plan) {
+ queryClient.setQueryData(
+ [
+ "plan",
+ last.origin.lat,
+ last.origin.lon,
+ last.destination.lat,
+ last.destination.lon,
+ last.searchTime
+ ? new Date(last.searchTime).toISOString()
+ : undefined,
+ last.arriveBy ?? false,
+ ],
+ last.plan
+ );
+ setPlan(last.plan);
+ }
+ setOrigin(last.origin);
+ setDestination(last.destination);
+ setSearchTime(last.searchTime ? new Date(last.searchTime) : null);
+ setArriveBy(last.arriveBy ?? false);
+ setSelectedItineraryIndex(last.selectedItineraryIndex ?? null);
+ }
+ } catch (e) {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ }
+ }, [queryClient]);
+
+ const searchRoute = async (
+ from: PlannerSearchResult,
+ to: PlannerSearchResult,
+ time?: Date,
+ arriveByParam: boolean = false
+ ) => {
+ setOrigin(from);
+ setDestination(to);
+ const finalTime = time ?? new Date();
+ setSearchTime(finalTime);
+ setArriveBy(arriveByParam);
+ setSelectedItineraryIndex(null);
+
+ const toStore: StoredRoute = {
+ timestamp: Date.now(),
+ origin: from,
+ destination: to,
+ searchTime: finalTime,
+ arriveBy: arriveByParam,
+ };
+
+ setHistory((prev) => {
+ const filtered = prev.filter(
+ (r) =>
+ !(
+ r.origin.lat === from.lat &&
+ r.origin.lon === from.lon &&
+ r.destination.lat === to.lat &&
+ r.destination.lon === to.lon
+ )
+ );
+ const updated = [toStore, ...filtered].slice(0, 3);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
+ };
+
+ const loadRoute = useCallback(
+ (route: StoredRoute) => {
+ if (route.plan) {
+ queryClient.setQueryData(
+ [
+ "plan",
+ route.origin.lat,
+ route.origin.lon,
+ route.destination.lat,
+ route.destination.lon,
+ route.searchTime
+ ? new Date(route.searchTime).toISOString()
+ : undefined,
+ route.arriveBy ?? false,
+ ],
+ route.plan
+ );
+ setPlan(route.plan);
+ }
+ setOrigin(route.origin);
+ setDestination(route.destination);
+ setSearchTime(route.searchTime ? new Date(route.searchTime) : null);
+ setArriveBy(route.arriveBy ?? false);
+ setSelectedItineraryIndex(route.selectedItineraryIndex ?? null);
+
+ setHistory((prev) => {
+ const filtered = prev.filter(
+ (r) =>
+ !(
+ r.origin.lat === route.origin.lat &&
+ r.origin.lon === route.origin.lon &&
+ r.destination.lat === route.destination.lat &&
+ r.destination.lon === route.destination.lon
+ )
+ );
+ const updated = [
+ { ...route, timestamp: Date.now() },
+ ...filtered,
+ ].slice(0, 3);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
+ },
+ [queryClient]
+ );
+
+ const clearRoute = useCallback(() => {
+ setPlan(null);
+ setOrigin(null);
+ setDestination(null);
+ setSearchTime(null);
+ setArriveBy(false);
+ setSelectedItineraryIndex(null);
+ setHistory([]);
+ localStorage.removeItem(STORAGE_KEY);
+ }, []);
+
+ const selectItinerary = useCallback((index: number) => {
+ setSelectedItineraryIndex(index);
+ setHistory((prev) => {
+ if (prev.length === 0) return prev;
+ const updated = [...prev];
+ updated[0] = { ...updated[0], selectedItineraryIndex: index };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
+ }, []);
+
+ const deselectItinerary = useCallback(() => {
+ setSelectedItineraryIndex(null);
+ setHistory((prev) => {
+ if (prev.length === 0) return prev;
+ const updated = [...prev];
+ updated[0] = { ...updated[0], selectedItineraryIndex: undefined };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
+ }, []);
+
+ return (
+ <PlannerContext.Provider
+ value={{
+ origin,
+ setOrigin,
+ destination,
+ setDestination,
+ plan,
+ loading: queryLoading || (isFetching && !plan),
+ error: queryError
+ ? "Failed to calculate route. Please try again."
+ : null,
+ searchTime,
+ setSearchTime,
+ arriveBy,
+ setArriveBy,
+ selectedItineraryIndex,
+ history,
+ recentPlaces,
+ addRecentPlace,
+ clearRecentPlaces,
+ pickingMode,
+ setPickingMode,
+ isExpanded,
+ setIsExpanded,
+ searchRoute,
+ loadRoute,
+ clearRoute,
+ selectItinerary,
+ deselectItinerary,
+ }}
+ >
+ {children}
+ </PlannerContext.Provider>
+ );
+};
+
+export const usePlannerContext = () => {
+ const context = useContext(PlannerContext);
+ if (context === undefined) {
+ throw new Error("usePlannerContext must be used within a PlannerProvider");
+ }
+ return context;
+};
diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts
index 445a426..1a2050b 100644
--- a/src/frontend/app/hooks/usePlanner.ts
+++ b/src/frontend/app/hooks/usePlanner.ts
@@ -1,269 +1,5 @@
-import { useQueryClient } from "@tanstack/react-query";
-import { useCallback, useEffect, useState } from "react";
-import { type PlannerSearchResult, type RoutePlan } from "../data/PlannerApi";
-import { usePlanQuery } from "./usePlanQuery";
-
-const STORAGE_KEY = "planner_route_history";
-const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
-
-interface StoredRoute {
- timestamp: number;
- origin: PlannerSearchResult;
- destination: PlannerSearchResult;
- plan?: RoutePlan;
- searchTime?: Date;
- arriveBy?: boolean;
- selectedItineraryIndex?: number;
-}
+import { usePlannerContext } from "../contexts/PlannerContext";
export function usePlanner(options: { autoLoad?: boolean } = {}) {
- const { autoLoad = true } = options;
- const [origin, setOrigin] = useState<PlannerSearchResult | null>(null);
- const [destination, setDestination] = useState<PlannerSearchResult | null>(
- null
- );
- 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);
- const [history, setHistory] = useState<StoredRoute[]>([]);
- const queryClient = useQueryClient();
-
- const {
- data: queryPlan,
- isLoading: queryLoading,
- error: queryError,
- isFetching,
- } = usePlanQuery(
- origin?.lat,
- origin?.lon,
- destination?.lat,
- destination?.lon,
- searchTime ?? undefined,
- arriveBy,
- !!(origin && destination && searchTime)
- );
-
- // Sync query result to local state and storage
- useEffect(() => {
- if (queryPlan) {
- setPlan(queryPlan as any);
-
- if (origin && destination) {
- const toStore: StoredRoute = {
- timestamp: Date.now(),
- origin,
- destination,
- plan: queryPlan as any,
- searchTime: searchTime ?? new Date(),
- arriveBy,
- selectedItineraryIndex: selectedItineraryIndex ?? undefined,
- };
-
- setHistory((prev) => {
- const filtered = prev.filter(
- (r) =>
- !(
- r.origin.lat === origin.lat &&
- r.origin.lon === origin.lon &&
- r.destination.lat === destination.lat &&
- r.destination.lon === destination.lon
- )
- );
- const updated = [toStore, ...filtered].slice(0, 3);
- localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
- return updated;
- });
- }
- }
- }, [
- queryPlan,
- origin,
- destination,
- searchTime,
- arriveBy,
- selectedItineraryIndex,
- ]);
-
- // Load from storage on mount
- useEffect(() => {
- const stored = localStorage.getItem(STORAGE_KEY);
- if (stored) {
- try {
- const data: StoredRoute[] = JSON.parse(stored);
- const valid = data.filter((r) => Date.now() - r.timestamp < EXPIRY_MS);
- setHistory(valid);
-
- if (autoLoad && valid.length > 0) {
- const last = valid[0];
- if (last.plan) {
- queryClient.setQueryData(
- [
- "plan",
- last.origin.lat,
- last.origin.lon,
- last.destination.lat,
- last.destination.lon,
- last.searchTime
- ? new Date(last.searchTime).toISOString()
- : undefined,
- last.arriveBy ?? false,
- ],
- last.plan
- );
- setPlan(last.plan);
- }
- setOrigin(last.origin);
- setDestination(last.destination);
- setSearchTime(last.searchTime ? new Date(last.searchTime) : null);
- setArriveBy(last.arriveBy ?? false);
- setSelectedItineraryIndex(last.selectedItineraryIndex ?? null);
- }
- } catch (e) {
- localStorage.removeItem(STORAGE_KEY);
- }
- }
- }, [autoLoad]);
-
- const searchRoute = async (
- from: PlannerSearchResult,
- to: PlannerSearchResult,
- time?: Date,
- arriveByParam: boolean = false
- ) => {
- setOrigin(from);
- setDestination(to);
- const finalTime = time ?? new Date();
- setSearchTime(finalTime);
- setArriveBy(arriveByParam);
- setSelectedItineraryIndex(null);
-
- // Save to history immediately so other pages can pick it up
- const toStore: StoredRoute = {
- timestamp: Date.now(),
- origin: from,
- destination: to,
- searchTime: finalTime,
- arriveBy: arriveByParam,
- };
-
- setHistory((prev) => {
- const filtered = prev.filter(
- (r) =>
- !(
- r.origin.lat === from.lat &&
- r.origin.lon === from.lon &&
- r.destination.lat === to.lat &&
- r.destination.lon === to.lon
- )
- );
- const updated = [toStore, ...filtered].slice(0, 3);
- localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
- return updated;
- });
- };
-
- const loadRoute = (route: StoredRoute) => {
- if (route.plan) {
- queryClient.setQueryData(
- [
- "plan",
- route.origin.lat,
- route.origin.lon,
- route.destination.lat,
- route.destination.lon,
- route.searchTime
- ? new Date(route.searchTime).toISOString()
- : undefined,
- route.arriveBy ?? false,
- ],
- route.plan
- );
- setPlan(route.plan);
- }
- setOrigin(route.origin);
- setDestination(route.destination);
- setSearchTime(route.searchTime ? new Date(route.searchTime) : null);
- setArriveBy(route.arriveBy ?? false);
- setSelectedItineraryIndex(route.selectedItineraryIndex ?? null);
-
- // Move to top of history
- setHistory((prev) => {
- const filtered = prev.filter(
- (r) =>
- !(
- r.origin.lat === route.origin.lat &&
- r.origin.lon === route.origin.lon &&
- r.destination.lat === route.destination.lat &&
- r.destination.lon === route.destination.lon
- )
- );
- const updated = [{ ...route, timestamp: Date.now() }, ...filtered].slice(
- 0,
- 3
- );
- localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
- return updated;
- });
- };
-
- const clearRoute = () => {
- setPlan(null);
- setOrigin(null);
- setDestination(null);
- setSearchTime(null);
- setArriveBy(false);
- setSelectedItineraryIndex(null);
- setHistory([]);
- localStorage.removeItem(STORAGE_KEY);
- };
-
- const selectItinerary = useCallback((index: number) => {
- setSelectedItineraryIndex(index);
-
- // Update storage
- setHistory((prev) => {
- if (prev.length === 0) return prev;
- const updated = [...prev];
- updated[0] = { ...updated[0], selectedItineraryIndex: index };
- localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
- return updated;
- });
- }, []);
-
- const deselectItinerary = useCallback(() => {
- setSelectedItineraryIndex(null);
-
- // Update storage
- setHistory((prev) => {
- if (prev.length === 0) return prev;
- const updated = [...prev];
- updated[0] = { ...updated[0], selectedItineraryIndex: undefined };
- localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
- return updated;
- });
- }, []);
-
- return {
- origin,
- setOrigin,
- destination,
- setDestination,
- plan,
- loading: queryLoading || (isFetching && !plan),
- error: queryError ? "Failed to calculate route. Please try again." : null,
- searchTime,
- arriveBy,
- selectedItineraryIndex,
- history,
- searchRoute,
- loadRoute,
- clearRoute,
- selectItinerary,
- deselectItinerary,
- };
+ return usePlannerContext();
}
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index 0286332..2a1cb24 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -126,6 +126,13 @@
"searching_ellipsis": "Searching…",
"results": "Results",
"close": "Close",
+ "collapse": "Collapse",
+ "pick_on_map": "Pick on map",
+ "pick_on_map_desc": "Select a point visually",
+ "pick_origin": "Select origin",
+ "pick_destination": "Select destination",
+ "pick_instruction": "Move the map to place the target on the desired location",
+ "confirm_location": "Confirm location",
"results_title": "Results",
"clear": "Clear",
"recent_routes": "Recent routes",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index 9ffc703..d47a784 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -126,6 +126,13 @@
"searching_ellipsis": "Buscando…",
"results": "Resultados",
"close": "Cerrar",
+ "collapse": "Contraer",
+ "pick_on_map": "Elegir en el mapa",
+ "pick_on_map_desc": "Selecciona un punto visualmente",
+ "pick_origin": "Seleccionar origen",
+ "pick_destination": "Seleccionar destino",
+ "pick_instruction": "Mueve el mapa para situar el objetivo en el lugar deseado",
+ "confirm_location": "Confirmar ubicación",
"results_title": "Resultados",
"clear": "Borrar",
"recent_routes": "Rutas recientes",
diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx
index 7b56b2d..87d7a9c 100644
--- a/src/frontend/app/root.tsx
+++ b/src/frontend/app/root.tsx
@@ -21,6 +21,7 @@ maplibregl.addProtocol("pmtiles", pmtiles.tile);
//#endregion
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { PlannerProvider } from "./contexts/PlannerContext";
import "./i18n";
const queryClient = new QueryClient();
@@ -95,7 +96,9 @@ export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AppProvider>
- <AppShell />
+ <PlannerProvider>
+ <AppShell />
+ </PlannerProvider>
</AppProvider>
</QueryClientProvider>
);
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index b8f179c..a651893 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -1,6 +1,4 @@
-import StopDataProvider from "../data/StopDataProvider";
-import "./map.css";
-
+import { Check, X } from "lucide-react";
import type { FilterSpecification } from "maplibre-gl";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -19,8 +17,11 @@ import {
import { PlannerOverlay } from "~/components/PlannerOverlay";
import { AppMap } from "~/components/shared/AppMap";
import { usePageTitle } from "~/contexts/PageTitleContext";
+import { reverseGeocode } from "~/data/PlannerApi";
import { usePlanner } from "~/hooks/usePlanner";
+import StopDataProvider from "../data/StopDataProvider";
import "../tailwind-full.css";
+import "./map.css";
// Componente principal del mapa
export default function StopMap() {
@@ -38,7 +39,52 @@ export default function StopMap() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const mapRef = useRef<MapRef>(null);
- const { searchRoute } = usePlanner({ autoLoad: false });
+ const {
+ searchRoute,
+ pickingMode,
+ setPickingMode,
+ setOrigin,
+ setDestination,
+ addRecentPlace,
+ } = usePlanner({ autoLoad: false });
+
+ const [isConfirming, setIsConfirming] = useState(false);
+
+ const handleConfirmPick = async () => {
+ if (!mapRef.current || !pickingMode) return;
+ const center = mapRef.current.getCenter();
+ setIsConfirming(true);
+
+ try {
+ const result = await reverseGeocode(center.lat, center.lng);
+ const finalResult = {
+ name:
+ result?.name || `${center.lat.toFixed(5)}, ${center.lng.toFixed(5)}`,
+ label: result?.label || "Map location",
+ lat: center.lat,
+ lon: center.lng,
+ layer: "map-pick",
+ };
+
+ if (pickingMode === "origin") {
+ setOrigin(finalResult);
+ } else {
+ setDestination(finalResult);
+ }
+ addRecentPlace(finalResult);
+ setPickingMode(null);
+ } catch (err) {
+ console.error("Failed to reverse geocode:", err);
+ } finally {
+ setIsConfirming(false);
+ }
+ };
+
+ const onMapInteraction = () => {
+ if (!pickingMode) {
+ window.dispatchEvent(new CustomEvent("plannerOverlay:collapse"));
+ }
+ };
const favouriteIds = useMemo(() => StopDataProvider.getFavouriteIds(), []);
@@ -120,22 +166,78 @@ export default function StopMap() {
return (
<div className="relative h-full">
- <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 && (
+ <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 && (
+ <div className="absolute top-4 left-0 right-0 z-20 flex justify-center px-4 pointer-events-none">
+ <div className="bg-white/95 dark:bg-slate-900/90 backdrop-blur p-4 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 w-full max-w-md pointer-events-auto">
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="font-bold text-slate-900 dark:text-slate-100">
+ {pickingMode === "origin"
+ ? t("planner.pick_origin", "Select origin")
+ : t("planner.pick_destination", "Select destination")}
+ </h3>
+ <button
+ onClick={() => setPickingMode(null)}
+ className="p-1 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full transition-colors"
+ >
+ <X className="w-5 h-5 text-slate-500" />
+ </button>
+ </div>
+ <p className="text-sm text-slate-600 dark:text-slate-400 mb-4">
+ {t(
+ "planner.pick_instruction",
+ "Move the map to place the target on the desired location"
+ )}
+ </p>
+ <button
+ onClick={handleConfirmPick}
+ disabled={isConfirming}
+ className="w-full bg-primary-600 hover:bg-primary-700 text-white font-bold py-3 rounded-xl flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
+ >
+ {isConfirming ? (
+ <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
+ ) : (
+ <>
+ <Check className="w-5 h-5" />
+ {t("planner.confirm_location", "Confirm location")}
+ </>
+ )}
+ </button>
+ </div>
+ </div>
+ )}
+
+ {pickingMode && (
+ <div className="absolute inset-0 pointer-events-none z-10 flex items-center justify-center">
+ <div className="relative flex items-center justify-center">
+ {/* Modern discrete target */}
+ <div className="w-1 h-1 bg-primary-600 rounded-full shadow-[0_0_0_4px_rgba(37,99,235,0.1)]" />
+ <div className="absolute w-6 h-[1px] bg-primary-600/30" />
+ <div className="absolute w-[1px] h-6 bg-primary-600/30" />
+ </div>
+ </div>
+ )}
<AppMap
ref={mapRef}
syncState={true}
showNavigation={true}
showGeolocate={true}
+ showTraffic={pickingMode ? false : undefined}
interactiveLayerIds={["stops", "stops-label"]}
onClick={onMapClick}
+ onDragStart={onMapInteraction}
+ onZoomStart={onMapInteraction}
attributionControl={{ compact: false }}
>
<Source
@@ -146,31 +248,33 @@ export default function StopMap() {
maxzoom={20}
/>
- <Layer
- id="stops-favourite-highlight"
- type="circle"
- minzoom={11}
- source="stops-source"
- source-layer="stops"
- filter={["all", stopLayerFilter, favouriteFilter]}
- paint={{
- "circle-color": "#FFD700",
- "circle-radius": [
- "interpolate",
- ["linear"],
- ["zoom"],
- 13,
- 10,
- 16,
- 12,
- 18,
- 16,
- ],
- "circle-opacity": 0.4,
- "circle-stroke-color": "#FFD700",
- "circle-stroke-width": 2,
- }}
- />
+ {!pickingMode && (
+ <Layer
+ id="stops-favourite-highlight"
+ type="circle"
+ minzoom={11}
+ source="stops-source"
+ source-layer="stops"
+ filter={["all", stopLayerFilter, favouriteFilter]}
+ paint={{
+ "circle-color": "#FFD700",
+ "circle-radius": [
+ "interpolate",
+ ["linear"],
+ ["zoom"],
+ 13,
+ 10,
+ 16,
+ 12,
+ 18,
+ 16,
+ ],
+ "circle-opacity": 0.4,
+ "circle-stroke-color": "#FFD700",
+ "circle-stroke-width": 2,
+ }}
+ />
+ )}
<Layer
id="stops"