diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-28 23:08:25 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-28 23:10:24 +0100 |
| commit | 120a3c6bddd0fb8d9fa05df4763596956554c025 (patch) | |
| tree | 3ed99935b58b1a269030aa2a638f35c0aa989f55 /src/frontend/app | |
| parent | 9618229477439d1604869aa68fc21d4eae7d8bb1 (diff) | |
Improve planning widget
Diffstat (limited to 'src/frontend/app')
| -rw-r--r-- | src/frontend/app/components/PlannerOverlay.tsx | 108 | ||||
| -rw-r--r-- | src/frontend/app/contexts/PlannerContext.tsx | 381 | ||||
| -rw-r--r-- | src/frontend/app/hooks/usePlanner.ts | 268 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 7 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 7 | ||||
| -rw-r--r-- | src/frontend/app/root.tsx | 5 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 178 |
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" |
