From 3a1a1e6dc2f6f0abceac5da0cfb530fdb45fc6f5 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 12 Dec 2025 10:24:43 +0100 Subject: Initial ultra-ñapa implementation of OTP integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/app/components/layout/AppShell.css | 27 +- src/frontend/app/components/layout/AppShell.tsx | 3 - .../app/components/layout/NavBar.module.css | 3 + src/frontend/app/components/layout/NavBar.tsx | 7 +- src/frontend/app/config/RegionConfig.ts | 5 +- src/frontend/app/data/PlannerApi.ts | 96 +++++ src/frontend/app/hooks/usePlanner.ts | 101 +++++ src/frontend/app/maps/styleloader.ts | 8 + src/frontend/app/routes.tsx | 1 + src/frontend/app/routes/map.tsx | 16 +- src/frontend/app/routes/planner.tsx | 415 +++++++++++++++++++++ 11 files changed, 644 insertions(+), 38 deletions(-) create mode 100644 src/frontend/app/data/PlannerApi.ts create mode 100644 src/frontend/app/hooks/usePlanner.ts create mode 100644 src/frontend/app/routes/planner.tsx (limited to 'src/frontend') diff --git a/src/frontend/app/components/layout/AppShell.css b/src/frontend/app/components/layout/AppShell.css index eee678c..17aae8c 100644 --- a/src/frontend/app/components/layout/AppShell.css +++ b/src/frontend/app/components/layout/AppShell.css @@ -14,20 +14,12 @@ .app-shell__body { display: flex; + flex-direction: column; flex: 1; overflow: hidden; position: relative; } -.app-shell__sidebar { - display: none; /* Hidden on mobile */ - width: 80px; - border-right: 1px solid var(--border-color); - background: var(--background-color); - flex-shrink: 0; - z-index: 5; -} - .app-shell__main { flex: 1; overflow: auto; @@ -37,17 +29,12 @@ .app-shell__bottom-nav { flex-shrink: 0; - display: block; /* Visible on mobile */ + display: block; z-index: 10; -} - -/* Desktop styles */ -@media (min-width: 768px) { - .app-shell__sidebar { - display: block; - } - .app-shell__bottom-nav { - display: none; - } + position: sticky; + bottom: 0; + width: 100%; + background: var(--background-color); + border-top: 1px solid var(--border-color); } diff --git a/src/frontend/app/components/layout/AppShell.tsx b/src/frontend/app/components/layout/AppShell.tsx index 08aee59..afc19f3 100644 --- a/src/frontend/app/components/layout/AppShell.tsx +++ b/src/frontend/app/components/layout/AppShell.tsx @@ -24,9 +24,6 @@ const AppShellContent: React.FC = () => { /> setIsDrawerOpen(false)} />
-
diff --git a/src/frontend/app/components/layout/NavBar.module.css b/src/frontend/app/components/layout/NavBar.module.css index 504b93b..6b46459 100644 --- a/src/frontend/app/components/layout/NavBar.module.css +++ b/src/frontend/app/components/layout/NavBar.module.css @@ -6,6 +6,9 @@ background-color: var(--background-color); border-top: 1px solid var(--border-color); + + max-width: 500px; + margin-inline: auto; } .vertical { diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx index 40591c4..150755f 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, Navigation2, Route } from "lucide-react"; import type { LngLatLike } from "maplibre-gl"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router"; @@ -71,6 +71,11 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { icon: Route, path: "/lines", }, + { + name: t("navbar.planner", "Planificador"), + icon: Navigation2, + path: "/planner", + }, ]; return ( diff --git a/src/frontend/app/config/RegionConfig.ts b/src/frontend/app/config/RegionConfig.ts index 75da06d..d595b3f 100644 --- a/src/frontend/app/config/RegionConfig.ts +++ b/src/frontend/app/config/RegionConfig.ts @@ -28,7 +28,10 @@ export const REGION_DATA: RegionData = { consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations", timetableEndpoint: "/api/vigo/GetStopTimetable", shapeEndpoint: "/api/vigo/GetShape", - defaultCenter: [42.229188855975046, -8.72246955783102] as LngLatLike, + defaultCenter: { + lat: 42.229188855975046, + lng: -8.72246955783102, + } as LngLatLike, bounds: { sw: [-8.951059, 42.098923] as LngLatLike, ne: [-8.447748, 42.3496] as LngLatLike, diff --git a/src/frontend/app/data/PlannerApi.ts b/src/frontend/app/data/PlannerApi.ts new file mode 100644 index 0000000..db47dcc --- /dev/null +++ b/src/frontend/app/data/PlannerApi.ts @@ -0,0 +1,96 @@ +export interface PlannerSearchResult { + name?: string; + label?: string; + lat: number; + lon: number; + layer?: string; +} + +export interface RoutePlan { + itineraries: Itinerary[]; +} + +export interface Itinerary { + durationSeconds: number; + startTime: string; + endTime: string; + walkDistanceMeters: number; + walkTimeSeconds: number; + transitTimeSeconds: number; + waitingTimeSeconds: number; + legs: Leg[]; +} + +export interface Leg { + mode?: string; + routeName?: string; + routeShortName?: string; + routeLongName?: string; + headsign?: string; + agencyName?: string; + from?: PlannerPlace; + to?: PlannerPlace; + startTime: string; + endTime: string; + distanceMeters: number; + geometry?: PlannerGeometry; + steps: Step[]; +} + +export interface PlannerPlace { + name?: string; + lat: number; + lon: number; + stopId?: string; + stopCode?: string; +} + +export interface PlannerGeometry { + type: string; + coordinates: number[][]; +} + +export interface Step { + distanceMeters: number; + relativeDirection?: string; + absoluteDirection?: string; + streetName?: string; + lat: number; + lon: number; +} + +export async function searchPlaces( + query: string +): Promise { + const response = await fetch( + `/api/planner/autocomplete?query=${encodeURIComponent(query)}` + ); + if (!response.ok) return []; + return response.json(); +} + +export async function reverseGeocode( + lat: number, + lon: number +): Promise { + const response = await fetch(`/api/planner/reverse?lat=${lat}&lon=${lon}`); + if (!response.ok) return null; + return response.json(); +} + +export async function planRoute( + fromLat: number, + fromLon: number, + toLat: number, + toLon: number, + time?: Date, + arriveBy: boolean = false +): Promise { + let url = `/api/planner/plan?fromLat=${fromLat}&fromLon=${fromLon}&toLat=${toLat}&toLon=${toLon}&arriveBy=${arriveBy}`; + if (time) { + url += `&time=${time.toISOString()}`; + } + const response = await fetch(url); + if (!response.ok) throw new Error("Failed to plan route"); + return response.json(); +} diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts new file mode 100644 index 0000000..1572896 --- /dev/null +++ b/src/frontend/app/hooks/usePlanner.ts @@ -0,0 +1,101 @@ +import { useEffect, useState } from "react"; +import { + type PlannerSearchResult, + type RoutePlan, + planRoute, +} from "../data/PlannerApi"; + +const STORAGE_KEY = "planner_last_route"; +const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours + +interface StoredRoute { + timestamp: number; + origin: PlannerSearchResult; + destination: PlannerSearchResult; + plan: RoutePlan; +} + +export function usePlanner() { + const [origin, setOrigin] = useState(null); + const [destination, setDestination] = useState( + null + ); + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Load from storage on mount + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const data: StoredRoute = JSON.parse(stored); + if (Date.now() - data.timestamp < EXPIRY_MS) { + setOrigin(data.origin); + setDestination(data.destination); + setPlan(data.plan); + } else { + localStorage.removeItem(STORAGE_KEY); + } + } catch (e) { + localStorage.removeItem(STORAGE_KEY); + } + } + }, []); + + const searchRoute = async ( + from: PlannerSearchResult, + to: PlannerSearchResult, + time?: Date, + arriveBy: boolean = false + ) => { + setLoading(true); + setError(null); + try { + const result = await planRoute( + from.lat, + from.lon, + to.lat, + to.lon, + time, + arriveBy + ); + setPlan(result); + setOrigin(from); + setDestination(to); + + // Save to storage + const toStore: StoredRoute = { + timestamp: Date.now(), + origin: from, + destination: to, + plan: result, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); + } catch (err) { + setError("Failed to calculate route. Please try again."); + setPlan(null); + } finally { + setLoading(false); + } + }; + + const clearRoute = () => { + setPlan(null); + setOrigin(null); + setDestination(null); + localStorage.removeItem(STORAGE_KEY); + }; + + return { + origin, + setOrigin, + destination, + setDestination, + plan, + loading, + error, + searchRoute, + clearRoute, + }; +} diff --git a/src/frontend/app/maps/styleloader.ts b/src/frontend/app/maps/styleloader.ts index 8109e0b..7d90116 100644 --- a/src/frontend/app/maps/styleloader.ts +++ b/src/frontend/app/maps/styleloader.ts @@ -5,6 +5,14 @@ export interface StyleLoaderOptions { includeTraffic?: boolean; } +export const DEFAULT_STYLE: StyleSpecification = { + version: 8, + glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`, + sprite: `${window.location.origin}/maps/spritesheet/sprite`, + sources: {}, + layers: [], +}; + export async function loadStyle( styleName: string, colorScheme: Theme, diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx index 16d0da7..052eb83 100644 --- a/src/frontend/app/routes.tsx +++ b/src/frontend/app/routes.tsx @@ -9,4 +9,5 @@ export default [ route("/settings", "routes/settings.tsx"), route("/about", "routes/about.tsx"), route("/favourites", "routes/favourites.tsx"), + route("/planner", "routes/planner.tsx"), ] satisfies RouteConfig; diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 187e9f2..182f4ce 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -1,7 +1,7 @@ import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import "./map.css"; -import { loadStyle } from "app/maps/styleloader"; +import { DEFAULT_STYLE, loadStyle } from "app/maps/styleloader"; import type { Feature as GeoJsonFeature, Point } from "geojson"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -19,15 +19,6 @@ import { REGION_DATA } from "~/config/RegionConfig"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useApp } from "../AppContext"; -// Default minimal fallback style before dynamic loading -const defaultStyle: StyleSpecification = { - version: 8, - glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`, - sprite: `${window.location.origin}/maps/spritesheet/sprite`, - sources: {}, - layers: [], -}; - // Componente principal del mapa export default function StopMap() { const { t } = useTranslation(); @@ -48,10 +39,9 @@ export default function StopMap() { const [isSheetOpen, setIsSheetOpen] = useState(false); const { mapState, updateMapState, theme } = useApp(); const mapRef = useRef(null); - const [mapStyleKey, setMapStyleKey] = useState("light"); // Style state for Map component - const [mapStyle, setMapStyle] = useState(defaultStyle); + const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); // Handle click events on clusters and individual stops const onMapClick = (e: MapLayerMouseEvent) => { @@ -111,7 +101,7 @@ export default function StopMap() { loadStyle(styleName, theme) .then((style) => setMapStyle(style)) .catch((error) => console.error("Failed to load map style:", error)); - }, [mapStyleKey, theme]); + }, [theme]); useEffect(() => { const handleMapChange = () => { diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx new file mode 100644 index 0000000..094ff8e --- /dev/null +++ b/src/frontend/app/routes/planner.tsx @@ -0,0 +1,415 @@ +import maplibregl, { type StyleSpecification } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import React, { useEffect, useRef, useState } from "react"; +import Map, { Layer, Source, type MapRef } from "react-map-gl/maplibre"; +import { Sheet } from "react-modal-sheet"; +import { useApp } from "~/AppContext"; +import { REGION_DATA } from "~/config/RegionConfig"; +import { + searchPlaces, + type Itinerary, + type PlannerSearchResult, +} from "~/data/PlannerApi"; +import { usePlanner } from "~/hooks/usePlanner"; +import { DEFAULT_STYLE, loadStyle } from "~/maps/styleloader"; +import "../tailwind-full.css"; + +// --- Components --- + +const AutocompleteInput = ({ + label, + value, + onChange, + placeholder, +}: { + label: string; + value: PlannerSearchResult | null; + onChange: (val: PlannerSearchResult | null) => void; + placeholder: string; +}) => { + const [query, setQuery] = useState(value?.name || ""); + const [results, setResults] = useState([]); + const [showResults, setShowResults] = useState(false); + + useEffect(() => { + if (value) setQuery(value.name || ""); + }, [value]); + + useEffect(() => { + const timer = setTimeout(async () => { + if (query.length > 2 && query !== value?.name) { + const res = await searchPlaces(query); + setResults(res); + setShowResults(true); + } else { + setResults([]); + } + }, 500); + return () => clearTimeout(timer); + }, [query, value]); + + return ( +
+ +
+ { + setQuery(e.target.value); + if (!e.target.value) onChange(null); + }} + placeholder={placeholder} + onFocus={() => setShowResults(true)} + /> + {value && ( + + )} +
+ {showResults && results.length > 0 && ( +
    + {results.map((res, idx) => ( +
  • { + onChange(res); + setQuery(res.name || ""); + setShowResults(false); + }} + > +
    {res.name}
    +
    {res.label}
    +
  • + ))} +
+ )} +
+ ); +}; + +const ItinerarySummary = ({ + itinerary, + onClick, +}: { + itinerary: Itinerary; + onClick: () => void; +}) => { + const durationMinutes = Math.round(itinerary.durationSeconds / 60); + const startTime = new Date(itinerary.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + const endTime = new Date(itinerary.endTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + + return ( +
+
+
+ {startTime} - {endTime} +
+
{durationMinutes} min
+
+
+ {itinerary.legs.map((leg, idx) => ( + + {idx > 0 && } +
+ {leg.mode === "WALK" ? "Walk" : leg.routeShortName || leg.mode} +
+
+ ))} +
+
+ Walk: {Math.round(itinerary.walkDistanceMeters)}m +
+
+ ); +}; + +const ItineraryDetail = ({ + itinerary, + onClose, +}: { + itinerary: Itinerary; + onClose: () => void; +}) => { + const mapRef = useRef(null); + const [sheetOpen, setSheetOpen] = useState(true); + + // Prepare GeoJSON for the route + const routeGeoJson = { + type: "FeatureCollection", + features: itinerary.legs.map((leg) => ({ + type: "Feature", + geometry: { + type: "LineString", + coordinates: leg.geometry?.coordinates || [], + }, + properties: { + mode: leg.mode, + color: leg.mode === "WALK" ? "#9ca3af" : "#2563eb", // Gray for walk, Blue for transit + }, + })), + }; + + // Fit bounds on mount + useEffect(() => { + if (mapRef.current && itinerary.legs.length > 0) { + const bounds = new maplibregl.LngLatBounds(); + itinerary.legs.forEach((leg) => { + leg.geometry?.coordinates.forEach((coord) => { + bounds.extend([coord[0], coord[1]]); + }); + }); + mapRef.current.fitBounds(bounds, { padding: 50 }); + } + }, [itinerary]); + + const { theme } = useApp(); + + const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); + useEffect(() => { + //const styleName = "carto"; + const styleName = "openfreemap"; + loadStyle(styleName, theme) + .then((style) => setMapStyle(style)) + .catch((error) => console.error("Failed to load map style:", error)); + }, [theme]); + + return ( +
+
+ + + + + {/* Markers for start/end/transfers could be added here */} + + + +
+ + setSheetOpen(false)} + detent="content" + initialSnap={0} + > + + + +

Itinerary Details

+
+ {itinerary.legs.map((leg, idx) => ( +
+
+
+ {leg.mode === "WALK" ? "🚶" : "🚌"} +
+ {idx < itinerary.legs.length - 1 && ( +
+ )} +
+
+
+ {leg.mode === "WALK" + ? "Walk" + : `${leg.routeShortName} ${leg.headsign}`} +
+
+ {new Date(leg.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + {" - "} + {new Date(leg.endTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+
+ {leg.mode === "WALK" ? ( + + Walk {Math.round(leg.distanceMeters)}m to{" "} + {leg.to?.name} + + ) : ( + + From {leg.from?.name} to {leg.to?.name} + + )} +
+
+
+ ))} +
+
+
+ setSheetOpen(false)} /> +
+
+ ); +}; + +// --- Main Page --- + +export default function PlannerPage() { + const { + origin, + setOrigin, + destination, + setDestination, + plan, + loading, + error, + searchRoute, + clearRoute, + } = usePlanner(); + + const [selectedItinerary, setSelectedItinerary] = useState( + null + ); + + const handleSearch = () => { + if (origin && destination) { + searchRoute(origin, destination); + } + }; + + if (selectedItinerary) { + return ( + setSelectedItinerary(null)} + /> + ); + } + + return ( +
+

Route Planner

+ + {/* Form */} +
+ + + + + + {error && ( +
+ {error} +
+ )} +
+ + {/* Results */} + {plan && ( +
+
+

Results

+ +
+ + {plan.itineraries.length === 0 ? ( +
+
😕
+

No routes found

+

+ We couldn't find a route for your trip. Try changing the time or + locations. +

+
+ ) : ( +
+ {plan.itineraries.map((itinerary, idx) => ( + setSelectedItinerary(itinerary)} + /> + ))} +
+ )} +
+ )} +
+ ); +} -- cgit v1.3