From ffb8ee87898bffe5fee706abb047133585bb5d0d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 12 Dec 2025 18:23:46 +0100 Subject: feat: enhance OTP service logic, improve planner overlay, and update NavBar styles --- src/frontend/app/routes/map.tsx | 30 +-- src/frontend/app/routes/planner.tsx | 409 +++++++++++++++++++++++++++++------- 2 files changed, 330 insertions(+), 109 deletions(-) (limited to 'src/frontend/app/routes') diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 3d59efb..39fc062 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -45,38 +45,11 @@ export default function StopMap() { const { mapState, updateMapState, theme } = useApp(); const mapRef = useRef(null); - const { searchRoute, origin, setOrigin } = usePlanner(); + const { searchRoute } = usePlanner(); // Style state for Map component const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); - // Set default origin to current location on first load (map page) - useEffect(() => { - // On the map page, always default to current location on load, - // overriding any previously used address. The user can change it after. - if (!navigator.geolocation) return; - navigator.geolocation.getCurrentPosition( - async (pos) => { - try { - // Keep display as "Current location" until a search is performed - setOrigin({ - name: t("planner.current_location"), - label: "GPS", - lat: pos.coords.latitude, - lon: pos.coords.longitude, - layer: "current-location", - }); - } catch (_) { - // ignore - } - }, - () => { - // ignore geolocation errors; user can set origin manually - }, - { enableHighAccuracy: true, timeout: 10000 } - ); - }, [setOrigin, t]); - // Handle click events on clusters and individual stops const onMapClick = (e: MapLayerMouseEvent) => { const features = e.features; @@ -222,6 +195,7 @@ export default function StopMap() { onNavigateToPlanner={() => navigate("/planner")} clearPickerOnOpen={true} showLastDestinationWhenCollapsed={false} + cardBackground="bg-white/95 dark:bg-slate-900/90" />
-
+
{startTime} - {endTime}
-
{durationMinutes} min
+
+ {durationMinutes} min +
@@ -146,7 +174,7 @@ const ItinerarySummary = ({
)} @@ -155,7 +183,7 @@ const ItinerarySummary = ({ })}
-
+
{t("planner.walk")}: {formatDistance(walkTotals.meters)} {walkTotals.minutes @@ -163,11 +191,11 @@ const ItinerarySummary = ({ : ""} - + {formatCurrency(cashFare)} - + {t("planner.card_fare", { amount: cardFare })} @@ -187,6 +215,9 @@ const ItineraryDetail = ({ const { t } = useTranslation(); const mapRef = useRef(null); const { destination: userDestination } = usePlanner(); + const [nextArrivals, setNextArrivals] = useState< + Record + >({}); const routeGeoJson = { type: "FeatureCollection", @@ -208,18 +239,41 @@ const ItineraryDetail = ({ })), }; - // Collect unique stops with their roles (board, alight, transfer) - const stopMarkers = useMemo(() => { + // Create GeoJSON for all markers + const markersGeoJson = useMemo(() => { + const features: any[] = []; + const origin = itinerary.legs[0]?.from; + const destination = itinerary.legs[itinerary.legs.length - 1]?.to; + + // Origin marker (red) + if (origin?.lat && origin?.lon) { + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: [origin.lon, origin.lat] }, + properties: { type: "origin", name: origin.name || "Origin" }, + }); + } + + // Destination marker (green) + if (destination?.lat && destination?.lon) { + features.push({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [destination.lon, destination.lat], + }, + properties: { + type: "destination", + name: destination.name || "Destination", + }, + }); + } + + // Collect unique stops with their roles (board, alight, transfer) const stopsMap: Record< string, - { - lat: number; - lon: number; - name: string; - type: "board" | "alight" | "transfer"; - } + { lat: number; lon: number; name: string; type: string } > = {}; - itinerary.legs.forEach((leg, idx) => { if (leg.mode !== "WALK") { // Boarding stop @@ -254,7 +308,30 @@ const ItineraryDetail = ({ } }); - return Object.values(stopsMap); + // Add stop markers + Object.values(stopsMap).forEach((stop) => { + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: [stop.lon, stop.lat] }, + properties: { type: stop.type, name: stop.name }, + }); + }); + + // Add intermediate stops + itinerary.legs.forEach((leg) => { + leg.intermediateStops?.forEach((stop) => { + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: [stop.lon, stop.lat] }, + properties: { + type: "intermediate", + name: stop.name || "Intermediate stop", + }, + }); + }); + }); + + return { type: "FeatureCollection", features }; }, [itinerary]); // Get origin and destination coordinates @@ -276,6 +353,17 @@ const ItineraryDetail = ({ ); }); + // Also include markers (origin, destination, transfers, intermediate) so all are visible + markersGeoJson.features.forEach((feature: any) => { + if ( + feature.geometry?.type === "Point" && + Array.isArray(feature.geometry.coordinates) + ) { + const [lng, lat] = feature.geometry.coordinates as [number, number]; + bounds.extend([lng, lat]); + } + }); + // Ensure bounds are valid before fitting if (!bounds.isEmpty()) { mapRef.current.fitBounds(bounds, { padding: 80, duration: 1000 }); @@ -284,18 +372,53 @@ const ItineraryDetail = ({ }, 100); return () => clearTimeout(timer); - }, [itinerary]); + }, [mapRef.current, itinerary]); const { theme } = useApp(); const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); useEffect(() => { const styleName = "openfreemap"; - loadStyle(styleName, theme) + loadStyle(styleName, theme, { includeTraffic: false }) .then((style) => setMapStyle(style)) .catch((error) => console.error("Failed to load map style:", error)); }, [theme]); + // Fetch next arrivals for bus legs + useEffect(() => { + const fetchArrivals = async () => { + const arrivalsByStop: Record = {}; + + for (const leg of itinerary.legs) { + if (leg.mode !== "WALK" && leg.from?.stopId) { + const stopKey = leg.from.name || leg.from.stopId; + if (!arrivalsByStop[stopKey]) { + try { + const resp = await fetch( + `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${encodeURIComponent(leg.from.stopCode || leg.from.stopId)}`, + { headers: { Accept: "application/json" } } + ); + + if (resp.ok) { + const data: ConsolidatedCirculation[] = await resp.json(); + arrivalsByStop[stopKey] = data; + } + } catch (err) { + console.warn( + `Failed to fetch arrivals for ${leg.from.stopId}:`, + err + ); + } + } + } + } + + setNextArrivals(arrivalsByStop); + }; + + fetchArrivals(); + }, [itinerary]); + return (
{/* Map Section */} @@ -320,7 +443,6 @@ const ItineraryDetail = ({ paint={{ "line-color": ["get", "color"], "line-width": 5, - // Dotted for walking segments, solid for bus segments "line-dasharray": [ "case", ["==", ["get", "mode"], "WALK"], @@ -331,55 +453,119 @@ const ItineraryDetail = ({ /> - {/* Origin marker (red) */} - {origin?.lat && origin?.lon && ( - -
-
-
-
- )} - - {/* Destination marker (green) */} - {destination?.lat && destination?.lon && ( - -
-
-
-
- )} - - {/* Stop markers (boarding, alighting, transfer) */} - {stopMarkers.map((stop, idx) => ( - -
- - ))} - - {/* Intermediate stops (smaller white dots) */} - {itinerary.legs.map((leg, legIdx) => - leg.intermediateStops?.map((stop, stopIdx) => ( - -
- - )) - )} + {/* All markers as GeoJSON layers */} + + {/* Outer circle for origin/destination markers */} + + {/* Inner circle for origin/destination markers */} + + {/* Stop markers (board, alight, transfer) */} + + {/* Intermediate stops (smaller white dots) */} + +