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/planner.tsx | 409 +++++++++++++++++++++++++++++------- 1 file changed, 328 insertions(+), 81 deletions(-) (limited to 'src/frontend/app/routes/planner.tsx') diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index c44a672..0f52fef 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -3,18 +3,44 @@ import maplibregl, { type StyleSpecification } from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre"; +import Map, { Layer, Source, type MapRef } from "react-map-gl/maplibre"; import { useLocation } from "react-router"; import { useApp } from "~/AppContext"; import LineIcon from "~/components/LineIcon"; import { PlannerOverlay } from "~/components/PlannerOverlay"; import { REGION_DATA } from "~/config/RegionConfig"; +import { usePageTitle } from "~/contexts/PageTitleContext"; import { type Itinerary } from "~/data/PlannerApi"; import { usePlanner } from "~/hooks/usePlanner"; import { DEFAULT_STYLE, loadStyle } from "~/maps/styleloader"; import "../tailwind-full.css"; +export interface ConsolidatedCirculation { + line: string; + route: string; + schedule?: { + running: boolean; + minutes: number; + serviceId: string; + tripId: string; + shapeId?: string; + }; + realTime?: { + minutes: number; + distance: number; + }; + currentPosition?: { + latitude: number; + longitude: number; + orientationDegrees: number; + shapeIndex?: number; + }; + isPreviousTrip?: boolean; + previousTripShapeId?: string; + nextStreets?: string[]; +} + const FARE_CASH_PER_BUS = 1.63; const FARE_CARD_PER_BUS = 0.67; @@ -106,14 +132,16 @@ const ItinerarySummary = ({ return (
-
+
{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) */} + +