From e7eb57bf492617f2b9be88d46c1cc708a2c17af4 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 12 Dec 2025 16:48:14 +0100 Subject: Improved version of the planner feature --- src/frontend/app/components/LineIcon.css | 8 +- src/frontend/app/components/PlannerOverlay.tsx | 525 +++++++++++++++++++++ src/frontend/app/components/StopMapModal.tsx | 21 +- .../app/components/layout/NavBar.module.css | 2 +- src/frontend/app/components/layout/NavBar.tsx | 10 +- 5 files changed, 539 insertions(+), 27 deletions(-) create mode 100644 src/frontend/app/components/PlannerOverlay.tsx (limited to 'src/frontend/app/components') diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index 6492d39..448b5fd 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -128,17 +128,17 @@ .line-icon-rounded { display: block; - width: 5ch; - height: 5ch; + width: 4.25ch; + height: 4.25ch; box-sizing: border-box; background-color: var(--line-colour); color: var(--line-text-colour); - padding: 1.75ch 1ch; + padding: 1.4ch 0.8ch; text-align: center; border-radius: 50%; - font: 600 14px / 1 monospace; + font: 600 13px / 1 monospace; letter-spacing: 0.05em; text-wrap: nowrap; } diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx new file mode 100644 index 0000000..622884e --- /dev/null +++ b/src/frontend/app/components/PlannerOverlay.tsx @@ -0,0 +1,525 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + reverseGeocode, + searchPlaces, + type PlannerSearchResult, +} from "~/data/PlannerApi"; +import StopDataProvider from "~/data/StopDataProvider"; +import { usePlanner } from "~/hooks/usePlanner"; + +interface PlannerOverlayProps { + onSearch: ( + origin: PlannerSearchResult, + destination: PlannerSearchResult, + time?: Date, + arriveBy?: boolean + ) => void; + onNavigateToPlanner?: () => void; + forceExpanded?: boolean; + inline?: boolean; + clearPickerOnOpen?: boolean; + showLastDestinationWhenCollapsed?: boolean; +} + +export const PlannerOverlay: React.FC = ({ + onSearch, + onNavigateToPlanner, + forceExpanded, + inline, + clearPickerOnOpen = false, + showLastDestinationWhenCollapsed = true, +}) => { + const { t } = useTranslation(); + const { origin, setOrigin, destination, setDestination, loading, error } = + usePlanner(); + const [isExpanded, setIsExpanded] = useState(false); + const [originQuery, setOriginQuery] = useState(origin?.name || ""); + const [destQuery, setDestQuery] = useState(""); + + type PickerField = "origin" | "destination"; + const [pickerOpen, setPickerOpen] = useState(false); + const [pickerField, setPickerField] = useState("destination"); + const [pickerQuery, setPickerQuery] = useState(""); + const [remoteResults, setRemoteResults] = useState([]); + const [remoteLoading, setRemoteLoading] = useState(false); + + const [favouriteStops, setFavouriteStops] = useState( + [] + ); + + const pickerInputRef = useRef(null); + + const [locationLoading, setLocationLoading] = useState(false); + const [timeMode, setTimeMode] = useState<"now" | "depart" | "arrive">("now"); + const [timeValue, setTimeValue] = useState(""); + const [dateOffset, setDateOffset] = useState(0); // 0 = today, 1 = tomorrow, etc. + + const canSubmit = useMemo( + () => Boolean(origin && destination) && !loading, + [origin, destination, loading] + ); + + useEffect(() => { + setOriginQuery( + origin?.layer === "current-location" + ? t("planner.current_location") + : origin?.name || "" + ); + }, [origin, t]); + useEffect(() => { + setDestQuery(destination?.name || ""); + }, [destination]); + + useEffect(() => { + // Load favourites once; used as local suggestions in the picker. + StopDataProvider.getStops() + .then((stops) => + stops + .filter((s) => s.favourite && s.latitude && s.longitude) + .map( + (s) => + ({ + name: StopDataProvider.getDisplayName(s), + label: s.stopId, + lat: s.latitude as number, + lon: s.longitude as number, + layer: "favourite-stop", + }) satisfies PlannerSearchResult + ) + ) + .then((mapped) => setFavouriteStops(mapped)) + .catch(() => setFavouriteStops([])); + }, []); + + const filteredFavouriteStops = useMemo(() => { + const q = pickerQuery.trim().toLowerCase(); + if (!q) return favouriteStops; + return favouriteStops.filter( + (s) => + (s.name || "").toLowerCase().includes(q) || + (s.label || "").toLowerCase().includes(q) + ); + }, [favouriteStops, pickerQuery]); + + const openPicker = (field: PickerField) => { + setPickerField(field); + setPickerQuery( + clearPickerOnOpen ? "" : field === "origin" ? originQuery : destQuery + ); + setPickerOpen(true); + }; + + const applyPickedResult = (result: PlannerSearchResult) => { + if (pickerField === "origin") { + setOrigin(result); + setOriginQuery(result.name || ""); + } else { + setDestination(result); + setDestQuery(result.name || ""); + } + setPickerOpen(false); + }; + + const setOriginFromCurrentLocation = () => { + if (!navigator.geolocation) return; + setLocationLoading(true); + navigator.geolocation.getCurrentPosition( + async (pos) => { + try { + const rev = await reverseGeocode( + pos.coords.latitude, + pos.coords.longitude + ); + const picked: PlannerSearchResult = { + name: rev?.name || "Ubicación actual", + label: rev?.label || "GPS", + lat: pos.coords.latitude, + lon: pos.coords.longitude, + layer: "current-location", + }; + setOrigin(picked); + setOriginQuery(picked.name || ""); + setPickerOpen(false); + } finally { + setLocationLoading(false); + } + }, + () => setLocationLoading(false), + { enableHighAccuracy: true, timeout: 10000 } + ); + }; + + useEffect(() => { + if (!pickerOpen) return; + // Focus the modal input on open. + const t = setTimeout(() => pickerInputRef.current?.focus(), 0); + return () => clearTimeout(t); + }, [pickerOpen]); + + useEffect(() => { + if (!pickerOpen) return; + const q = pickerQuery.trim(); + if (q.length < 3) { + setRemoteResults([]); + setRemoteLoading(false); + return; + } + + let cancelled = false; + setRemoteLoading(true); + const t = setTimeout(async () => { + try { + const results = await searchPlaces(q); + if (!cancelled) setRemoteResults(results); + } finally { + if (!cancelled) setRemoteLoading(false); + } + }, 250); + + return () => { + cancelled = true; + clearTimeout(t); + }; + }, [pickerOpen, pickerQuery]); + + // Allow external triggers (e.g., map movements) to collapse the widget, unless forced expanded + useEffect(() => { + if (forceExpanded) return; + const handler = () => setIsExpanded(false); + window.addEventListener("plannerOverlay:collapse", handler); + return () => window.removeEventListener("plannerOverlay:collapse", handler); + }, [forceExpanded]); + + // Derive expanded state + const expanded = forceExpanded ?? isExpanded; + + const wrapperClass = inline + ? "w-full" + : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center"; + + const cardClass = inline + ? "pointer-events-auto w-full overflow-hidden rounded-xl bg-white dark:bg-slate-900 px-2 flex flex-col gap-3" + : "pointer-events-auto w-[min(640px,calc(100%-16px))] px-2 py-1 flex flex-col gap-3 m-4 overflow-hidden rounded-xl border border-slate-200/80 dark:border-slate-700/70 bg-white/95 dark:bg-slate-900/90 shadow-2xl backdrop-blur"; + + return ( +
+
+ {!expanded ? ( + + ) : ( + <> +
+ +
+ +
+ +
+ +
+ {t("planner.when")} +
+ + + +
+ {timeMode !== "now" && ( +
+ + setTimeValue(e.target.value)} + /> +
+ )} +
+ +
+ +
+ + {error && ( +
+ {error} +
+ )} + + )} +
+ + {pickerOpen && ( +
+ +
+
+ +
    + {pickerField === "origin" && ( +
  • + +
  • + )} + + {filteredFavouriteStops.length > 0 && ( + <> +
  • + {t("planner.favourite_stops")} +
  • + {filteredFavouriteStops.map((r, i) => ( +
  • + +
  • + ))} + + )} + + {(remoteLoading || remoteResults.length > 0) && ( +
  • + {remoteLoading + ? t("planner.searching_ellipsis") + : t("planner.results")} +
  • + )} + {remoteResults.map((r, i) => ( +
  • + +
  • + ))} +
+ + + )} + + ); +}; diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx index 1cb6d88..bddf512 100644 --- a/src/frontend/app/components/StopMapModal.tsx +++ b/src/frontend/app/components/StopMapModal.tsx @@ -9,6 +9,7 @@ import React, { import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; import { Sheet } from "react-modal-sheet"; import { useApp } from "~/AppContext"; +import LineIcon from "~/components/LineIcon"; import { REGION_DATA } from "~/config/RegionConfig"; import { getLineColour } from "~/data/LineColors"; import type { Stop } from "~/data/StopDataProvider"; @@ -517,26 +518,12 @@ export const StopMapModal: React.FC = ({ flexDirection: "column", alignItems: "center", gap: 6, - transform: `rotate(${selectedBus.currentPosition.orientationDegrees}deg)`, + filter: "drop-shadow(0 2px 4px rgba(0,0,0,0.3))", + transform: "scale(0.85)", transformOrigin: "center center", }} > - - - + )} diff --git a/src/frontend/app/components/layout/NavBar.module.css b/src/frontend/app/components/layout/NavBar.module.css index 6b46459..ddace40 100644 --- a/src/frontend/app/components/layout/NavBar.module.css +++ b/src/frontend/app/components/layout/NavBar.module.css @@ -7,7 +7,7 @@ background-color: var(--background-color); border-top: 1px solid var(--border-color); - max-width: 500px; + max-width: 48rem; margin-inline: auto; } diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx index 150755f..69b3a63 100644 --- a/src/frontend/app/components/layout/NavBar.tsx +++ b/src/frontend/app/components/layout/NavBar.tsx @@ -66,16 +66,16 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { ); }, }, - { - name: t("navbar.lines", "Líneas"), - icon: Route, - path: "/lines", - }, { name: t("navbar.planner", "Planificador"), icon: Navigation2, path: "/planner", }, + { + name: t("navbar.lines", "Líneas"), + icon: Route, + path: "/lines", + }, ]; return ( -- cgit v1.3