diff options
Diffstat (limited to 'src/frontend/app/routes/planner.tsx')
| -rw-r--r-- | src/frontend/app/routes/planner.tsx | 256 |
1 files changed, 103 insertions, 153 deletions
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index 5968bc2..b71d211 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -6,12 +6,13 @@ import { useTranslation } from "react-i18next"; import { Layer, Source, type MapRef } from "react-map-gl/maplibre"; import { useLocation } from "react-router"; -import { type ConsolidatedCirculation, type Itinerary } from "~/api/schema"; +import { type ConsolidatedCirculation } from "~/api/schema"; import LineIcon from "~/components/LineIcon"; import { PlannerOverlay } from "~/components/PlannerOverlay"; import { AppMap } from "~/components/shared/AppMap"; import { APP_CONSTANTS } from "~/config/constants"; import { usePageTitle } from "~/contexts/PageTitleContext"; +import { type Itinerary } from "~/data/PlannerApi"; import { usePlanner } from "~/hooks/usePlanner"; import "../tailwind-full.css"; @@ -21,6 +22,14 @@ const formatDistance = (meters: number) => { return `${rounded} m`; }; +const formatDuration = (minutes: number, t: any) => { + if (minutes < 60) return `${minutes} ${t("estimates.minutes")}`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (m === 0) return `${h}h`; + return `${h}h ${m}min`; +}; + const haversineMeters = (a: [number, number], b: [number, number]) => { const toRad = (v: number) => (v * Math.PI) / 180; const R = 6371000; @@ -84,11 +93,8 @@ const ItinerarySummary = ({ }); const walkTotals = sumWalkMetrics(itinerary.legs); - const busLegsCount = itinerary.legs.filter( - (leg) => leg.mode !== "WALK" - ).length; - const cashFare = (itinerary.cashFareEuro ?? 0).toFixed(2); - const cardFare = (itinerary.cardFareEuro ?? 0).toFixed(2); + const cashFare = (itinerary.cashFare ?? 0).toFixed(2); + const cardFare = (itinerary.cardFare ?? 0).toFixed(2); return ( <div @@ -99,7 +105,7 @@ const ItinerarySummary = ({ <div className="font-bold text-lg text-text"> {startTime} - {endTime} </div> - <div className="text-muted">{durationMinutes} min</div> + <div className="text-muted">{formatDuration(durationMinutes, t)}</div> </div> <div className="flex items-center gap-2 overflow-x-auto pb-2"> @@ -125,7 +131,7 @@ const ItinerarySummary = ({ <div className="flex items-center gap-2 rounded-full bg-surface px-3 py-1.5 text-sm text-text whitespace-nowrap border border-border"> <Footprints className="w-4 h-4 text-muted" /> <span className="font-semibold"> - {legDurationMinutes} {t("estimates.minutes")} + {formatDuration(legDurationMinutes, t)} </span> </div> ) : ( @@ -147,7 +153,7 @@ const ItinerarySummary = ({ <span> {t("planner.walk")}: {formatDistance(walkTotals.meters)} {walkTotals.minutes - ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}` + ? ` • ${formatDuration(walkTotals.minutes, t)}` : ""} </span> <span className="flex items-center gap-3"> @@ -156,12 +162,14 @@ const ItinerarySummary = ({ {cashFare === "0.00" ? t("planner.free") : t("planner.fare", { amount: cashFare })} + {itinerary.cashFareIsTotal ? "" : "++"} </span> <span className="flex items-center gap-1 text-muted"> <CreditCard className="w-4 h-4" /> {cardFare === "0.00" ? t("planner.free") : t("planner.fare", { amount: cardFare })} + {itinerary.cashFareIsTotal ? "" : "++"} </span> </span> </div> @@ -206,83 +214,39 @@ const ItineraryDetail = ({ // 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: string } - > = {}; + // Add points for each leg transition itinerary.legs.forEach((leg, idx) => { - if (leg.mode !== "WALK") { - // Boarding stop - if (leg.from?.lat && leg.from?.lon) { - const key = `${leg.from.lat},${leg.from.lon}`; - if (!stopsMap[key]) { - const isTransfer = - idx > 0 && itinerary.legs[idx - 1].mode !== "WALK"; - stopsMap[key] = { - lat: leg.from.lat, - lon: leg.from.lon, - name: leg.from.name || "", - type: isTransfer ? "transfer" : "board", - }; - } - } - // Alighting stop - if (leg.to?.lat && leg.to?.lon) { - const key = `${leg.to.lat},${leg.to.lon}`; - if (!stopsMap[key]) { - const isTransfer = - idx < itinerary.legs.length - 1 && - itinerary.legs[idx + 1].mode !== "WALK"; - stopsMap[key] = { - lat: leg.to.lat, - lon: leg.to.lon, - name: leg.to.name || "", - type: isTransfer ? "transfer" : "alight", - }; - } - } + // Add "from" point of the leg + if (leg.from?.lat && leg.from?.lon) { + features.push({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [leg.from.lon, leg.from.lat], + }, + properties: { + type: idx === 0 ? "origin" : "transfer", + name: leg.from.name || "", + index: idx.toString(), + }, + }); } - }); - // 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 }, - }); - }); + // If it's the last leg, also add the "to" point + if (idx === itinerary.legs.length - 1 && leg.to?.lat && leg.to?.lon) { + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: [leg.to.lon, leg.to.lat] }, + properties: { + type: "destination", + name: leg.to.name || "", + index: (idx + 1).toString(), + }, + }); + } - // Add intermediate stops - itinerary.legs.forEach((leg) => { + // Add intermediate stops leg.intermediateStops?.forEach((stop) => { features.push({ type: "Feature", @@ -389,7 +353,9 @@ const ItineraryDetail = ({ zoom: 13, }} showTraffic={false} - attributionControl={false} + showGeolocate={true} + showNavigation={true} + attributionControl={true} > <Source id="route" type="geojson" data={routeGeoJson as any}> <Layer @@ -411,69 +377,36 @@ const ItineraryDetail = ({ {/* All markers as GeoJSON layers */} <Source id="markers" type="geojson" data={markersGeoJson as any}> - {/* Outer circle for origin/destination markers */} + {/* Intermediate stops (smaller white dots) - rendered first to be at the bottom */} <Layer - id="markers-outer" - type="circle" - filter={[ - "in", - ["get", "type"], - ["literal", ["origin", "destination"]], - ]} - paint={{ - "circle-radius": [ - "interpolate", - ["linear"], - ["zoom"], - 10, - 6, - 16, - 8, - 20, - 10, - ], - "circle-color": [ - "case", - ["==", ["get", "type"], "origin"], - "#dc2626", - "#16a34a", - ], - "circle-stroke-width": 2, - "circle-stroke-color": "#ffffff", - }} - /> - {/* Inner circle for origin/destination markers */} - <Layer - id="markers-inner" + id="markers-intermediate" type="circle" - filter={[ - "in", - ["get", "type"], - ["literal", ["origin", "destination"]], - ]} + filter={["==", ["get", "type"], "intermediate"]} paint={{ "circle-radius": [ "interpolate", ["linear"], ["zoom"], 10, - 2, - 16, 3, + 16, + 5, 20, - 4, + 7, ], "circle-color": "#ffffff", + "circle-stroke-width": 1.5, + "circle-stroke-color": "#6b7280", }} /> - {/* Stop markers (board, alight, transfer) */} + {/* Outer circle for all numbered markers */} <Layer - id="markers-stops" + id="markers-outer" type="circle" filter={[ "in", ["get", "type"], - ["literal", ["board", "alight", "transfer"]], + ["literal", ["origin", "destination", "transfer"]], ]} paint={{ "circle-radius": [ @@ -481,44 +414,51 @@ const ItineraryDetail = ({ ["linear"], ["zoom"], 10, - 4, + 8, 16, - 6, + 10, 20, - 7, + 12, ], "circle-color": [ "case", - ["==", ["get", "type"], "board"], + ["==", ["get", "type"], "origin"], + "#dc2626", + ["==", ["get", "type"], "destination"], + "#16a34a", "#3b82f6", - ["==", ["get", "type"], "alight"], - "#a855f7", - "#f97316", ], "circle-stroke-width": 2, "circle-stroke-color": "#ffffff", }} /> - {/* Intermediate stops (smaller white dots) */} + {/* Numbers for markers */} <Layer - id="markers-intermediate" - type="circle" - filter={["==", ["get", "type"], "intermediate"]} - paint={{ - "circle-radius": [ + id="markers-labels" + type="symbol" + filter={[ + "in", + ["get", "type"], + ["literal", ["origin", "destination", "transfer"]], + ]} + layout={{ + "text-field": ["get", "index"], + "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"], + "text-size": [ "interpolate", ["linear"], ["zoom"], 10, - 2, + 8, 16, - 3, + 10, 20, - 4, + 12, ], - "circle-color": "#ffffff", - "circle-stroke-width": 1, - "circle-stroke-color": "#9ca3af", + "text-allow-overlap": true, + }} + paint={{ + "text-color": "#ffffff", }} /> </Source> @@ -590,12 +530,14 @@ const ItineraryDetail = ({ </span> <span>•</span> <span> - {( - (new Date(leg.endTime).getTime() - - new Date(leg.startTime).getTime()) / - 60000 - ).toFixed(0)}{" "} - {t("estimates.minutes")} + {formatDuration( + Math.round( + (new Date(leg.endTime).getTime() - + new Date(leg.startTime).getTime()) / + 60000 + ), + t + )} </span> <span>•</span> <span>{formatDistance(leg.distanceMeters)}</span> @@ -654,8 +596,8 @@ const ItineraryDetail = ({ <span className="flex-1 truncate"> {circ.route} </span> - <span className="font-semibold text-emerald-600 dark:text-emerald-400"> - {minutes} {t("estimates.minutes")} + <span className="font-semibold text-primary-600 dark:text-primary-400"> + {formatDuration(minutes, t)} {circ.realTime && " 🟢"} </span> </div> @@ -735,6 +677,7 @@ export default function PlannerPage() { const location = useLocation(); const { plan, + loading, searchRoute, clearRoute, searchTime, @@ -815,6 +758,13 @@ export default function PlannerPage() { cardBackground="bg-transparent" /> + {loading && !plan && ( + <div className="flex flex-col items-center justify-center py-12"> + <div className="w-8 h-8 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mb-4"></div> + <p className="text-muted">{t("planner.searching")}</p> + </div> + )} + {plan && ( <div> <div className="flex justify-between items-center my-4"> |
