diff options
Diffstat (limited to 'src/frontend/app')
| -rw-r--r-- | src/frontend/app/api/planner.ts | 34 | ||||
| -rw-r--r-- | src/frontend/app/api/schema.ts | 69 | ||||
| -rw-r--r-- | src/frontend/app/components/LineIcon.css | 12 | ||||
| -rw-r--r-- | src/frontend/app/hooks/usePlanQuery.ts | 29 | ||||
| -rw-r--r-- | src/frontend/app/hooks/usePlanner.ts | 93 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 6 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 2 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 6 | ||||
| -rw-r--r-- | src/frontend/app/routes/planner.tsx | 106 |
9 files changed, 243 insertions, 114 deletions
diff --git a/src/frontend/app/api/planner.ts b/src/frontend/app/api/planner.ts new file mode 100644 index 0000000..86f44f0 --- /dev/null +++ b/src/frontend/app/api/planner.ts @@ -0,0 +1,34 @@ +import { RoutePlanSchema, type RoutePlan } from "./schema"; + +export const fetchPlan = async ( + fromLat: number, + fromLon: number, + toLat: number, + toLon: number, + time?: Date, + arriveBy: boolean = false +): Promise<RoutePlan> => { + let url = `/api/planner/plan?fromLat=${fromLat}&fromLon=${fromLon}&toLat=${toLat}&toLon=${toLon}&arriveBy=${arriveBy}`; + if (time) { + url += `&time=${time.toISOString()}`; + } + + const resp = await fetch(url, { + headers: { + Accept: "application/json", + }, + }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + const data = await resp.json(); + try { + return RoutePlanSchema.parse(data); + } catch (e) { + console.error("Zod parsing failed for route plan:", e); + console.log("Received data:", data); + throw e; + } +}; diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index 9cc5bd4..05f3a87 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -8,7 +8,7 @@ export const RouteInfoSchema = z.object({ export const HeadsignInfoSchema = z.object({ badge: z.string().optional().nullable(), - destination: z.string(), + destination: z.string().nullable(), marquee: z.string().optional().nullable(), }); @@ -108,3 +108,70 @@ export const ConsolidatedCirculationSchema = z.object({ export type ConsolidatedCirculation = z.infer< typeof ConsolidatedCirculationSchema >; + +// Route Planner +export const PlannerPlaceSchema = z.object({ + name: z.string().optional().nullable(), + lat: z.number(), + lon: z.number(), + stopId: z.string().optional().nullable(), + stopCode: z.string().optional().nullable(), +}); + +export const PlannerGeometrySchema = z.object({ + type: z.string(), + coordinates: z.array(z.array(z.number())), +}); + +export const PlannerStepSchema = z.object({ + distanceMeters: z.number(), + relativeDirection: z.string().optional().nullable(), + absoluteDirection: z.string().optional().nullable(), + streetName: z.string().optional().nullable(), + lat: z.number(), + lon: z.number(), +}); + +export const PlannerLegSchema = z.object({ + mode: z.string().optional().nullable(), + routeName: z.string().optional().nullable(), + routeShortName: z.string().optional().nullable(), + routeLongName: z.string().optional().nullable(), + routeColor: z.string().optional().nullable(), + routeTextColor: z.string().optional().nullable(), + headsign: z.string().optional().nullable(), + agencyName: z.string().optional().nullable(), + from: PlannerPlaceSchema.optional().nullable(), + to: PlannerPlaceSchema.optional().nullable(), + startTime: z.string(), + endTime: z.string(), + distanceMeters: z.number(), + geometry: PlannerGeometrySchema.optional().nullable(), + steps: z.array(PlannerStepSchema), + intermediateStops: z.array(PlannerPlaceSchema), +}); + +export const ItinerarySchema = z.object({ + durationSeconds: z.number(), + startTime: z.string(), + endTime: z.string(), + walkDistanceMeters: z.number(), + walkTimeSeconds: z.number(), + transitTimeSeconds: z.number(), + waitingTimeSeconds: z.number(), + legs: z.array(PlannerLegSchema), + cashFareEuro: z.number().optional().nullable(), + cardFareEuro: z.number().optional().nullable(), +}); + +export const RoutePlanSchema = z.object({ + itineraries: z.array(ItinerarySchema), + timeOffsetSeconds: z.number().optional().nullable(), +}); + +export type PlannerPlace = z.infer<typeof PlannerPlaceSchema>; +export type PlannerGeometry = z.infer<typeof PlannerGeometrySchema>; +export type PlannerStep = z.infer<typeof PlannerStepSchema>; +export type PlannerLeg = z.infer<typeof PlannerLegSchema>; +export type Itinerary = z.infer<typeof ItinerarySchema>; +export type RoutePlan = z.infer<typeof RoutePlanSchema>; diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css index 448b5fd..a8a413c 100644 --- a/src/frontend/app/components/LineIcon.css +++ b/src/frontend/app/components/LineIcon.css @@ -127,18 +127,20 @@ } .line-icon-rounded { - display: block; - width: 4.25ch; + display: flex; + align-items: center; + justify-content: center; + min-width: 4.25ch; height: 4.25ch; box-sizing: border-box; background-color: var(--line-colour); color: var(--line-text-colour); - padding: 1.4ch 0.8ch; + padding: 0 0.8ch; text-align: center; - border-radius: 50%; + border-radius: 2.125ch; font: 600 13px / 1 monospace; letter-spacing: 0.05em; - text-wrap: nowrap; + white-space: nowrap; } diff --git a/src/frontend/app/hooks/usePlanQuery.ts b/src/frontend/app/hooks/usePlanQuery.ts new file mode 100644 index 0000000..103f5f4 --- /dev/null +++ b/src/frontend/app/hooks/usePlanQuery.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchPlan } from "../api/planner"; + +export const usePlanQuery = ( + fromLat: number | undefined, + fromLon: number | undefined, + toLat: number | undefined, + toLon: number | undefined, + time?: Date, + arriveBy: boolean = false, + enabled: boolean = true +) => { + return useQuery({ + queryKey: [ + "plan", + fromLat, + fromLon, + toLat, + toLon, + time?.toISOString(), + arriveBy, + ], + queryFn: () => + fetchPlan(fromLat!, fromLon!, toLat!, toLon!, time, arriveBy), + enabled: !!(fromLat && fromLon && toLat && toLon) && enabled, + staleTime: 60000, // 1 minute + retry: false, + }); +}; diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts index 6123f8a..a28167a 100644 --- a/src/frontend/app/hooks/usePlanner.ts +++ b/src/frontend/app/hooks/usePlanner.ts @@ -1,9 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import { - type PlannerSearchResult, - type RoutePlan, - planRoute, -} from "../data/PlannerApi"; +import { type PlannerSearchResult, type RoutePlan } from "../data/PlannerApi"; +import { usePlanQuery } from "./usePlanQuery"; const STORAGE_KEY = "planner_last_route"; const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours @@ -32,6 +29,48 @@ export function usePlanner() { number | null >(null); + const { + data: queryPlan, + isLoading: queryLoading, + error: queryError, + isFetching, + } = usePlanQuery( + origin?.lat, + origin?.lon, + destination?.lat, + destination?.lon, + searchTime ?? undefined, + arriveBy, + !!(origin && destination) + ); + + // Sync query result to local state and storage + useEffect(() => { + if (queryPlan) { + setPlan(queryPlan as any); // Cast because of slight type differences if any, but they should match now + + if (origin && destination) { + const toStore: StoredRoute = { + timestamp: Date.now(), + origin, + destination, + plan: queryPlan as any, + searchTime: searchTime ?? new Date(), + arriveBy, + selectedItineraryIndex: selectedItineraryIndex ?? undefined, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); + } + } + }, [ + queryPlan, + origin, + destination, + searchTime, + arriveBy, + selectedItineraryIndex, + ]); + // Load from storage on mount useEffect(() => { const stored = localStorage.getItem(STORAGE_KEY); @@ -60,41 +99,11 @@ export function usePlanner() { time?: Date, arriveByParam: boolean = false ) => { - setLoading(true); - setError(null); - try { - const result = await planRoute( - from.lat, - from.lon, - to.lat, - to.lon, - time, - arriveByParam - ); - setPlan(result); - setOrigin(from); - setDestination(to); - setSearchTime(time ?? new Date()); - setArriveBy(arriveByParam); - setSelectedItineraryIndex(null); // Reset when doing new search - - // Save to storage - const toStore: StoredRoute = { - timestamp: Date.now(), - origin: from, - destination: to, - plan: result, - searchTime: time ?? new Date(), - arriveBy: arriveByParam, - selectedItineraryIndex: undefined, - }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); - } catch (err) { - setError("Failed to calculate route. Please try again."); - setPlan(null); - } finally { - setLoading(false); - } + setOrigin(from); + setDestination(to); + setSearchTime(time ?? new Date()); + setArriveBy(arriveByParam); + setSelectedItineraryIndex(null); }; const clearRoute = () => { @@ -145,8 +154,8 @@ export function usePlanner() { destination, setDestination, plan, - loading, - error, + loading: queryLoading || (isFetching && !plan), + error: queryError ? "Failed to calculate route. Please try again." : null, searchTime, arriveBy, selectedItineraryIndex, diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index a8f3f52..2c58ebe 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -134,9 +134,11 @@ "walk_to": "Walk {{distance}} to {{destination}}", "from_to": "From {{from}} to {{to}}", "itinerary_details": "Itinerary Details", + "direction": "Direction", + "operator": "Operator", "back": "← Back", - "cash_fare": "€{{amount}}", - "card_fare": "€{{amount}}" + "fare": "€{{amount}}", + "free": "Free" }, "common": { "loading": "Loading...", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 2bffac9..298733e 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -134,6 +134,8 @@ "walk_to": "Caminar {{distance}} hasta {{destination}}", "from_to": "De {{from}} a {{to}}", "itinerary_details": "Detalles del itinerario", + "direction": "Dirección", + "operator": "Operador", "back": "← Atrás", "fare": "{{amount}} €", "free": "Gratuito" diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 5086feb..833279f 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -134,9 +134,11 @@ "walk_to": "Camiñar {{distance}} ata {{destination}}", "from_to": "De {{from}} a {{to}}", "itinerary_details": "Detalles do itinerario", + "direction": "Dirección", + "operator": "Operador", "back": "← Atrás", - "cash_fare": "{{amount}} €", - "card_fare": "{{amount}} €" + "fare": "{{amount}} €", + "free": "Gratuíto" }, "common": { "loading": "Cargando...", diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx index e99cb03..44488c8 100644 --- a/src/frontend/app/routes/planner.tsx +++ b/src/frontend/app/routes/planner.tsx @@ -1,53 +1,24 @@ import { Coins, CreditCard, Footprints } from "lucide-react"; -import maplibregl, { type StyleSpecification } from "maplibre-gl"; +import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Layer, Source, type MapRef } from "react-map-gl/maplibre"; import { useLocation } from "react-router"; -import { useApp } from "~/AppContext"; +import { type ConsolidatedCirculation, type Itinerary } 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"; -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; - const formatDistance = (meters: number) => { - const intMeters = Math.round(meters); - if (intMeters >= 1000) return `${(intMeters / 1000).toFixed(1)} km`; - return `${intMeters} m`; + if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; + const rounded = Math.round(meters / 100) * 100; + return `${rounded} m`; }; const haversineMeters = (a: [number, number], b: [number, number]) => { @@ -116,12 +87,8 @@ const ItinerarySummary = ({ const busLegsCount = itinerary.legs.filter( (leg) => leg.mode !== "WALK" ).length; - const cashFare = ( - itinerary.cashFareEuro ?? busLegsCount * FARE_CASH_PER_BUS - ).toFixed(2); - const cardFare = ( - itinerary.cardFareEuro ?? busLegsCount * FARE_CARD_PER_BUS - ).toFixed(2); + const cashFare = (itinerary.cashFareEuro ?? 0).toFixed(2); + const cardFare = (itinerary.cardFareEuro ?? 0).toFixed(2); return ( <div @@ -132,9 +99,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">{durationMinutes} min</div> </div> <div className="flex items-center gap-2 overflow-x-auto pb-2"> @@ -168,6 +133,8 @@ const ItinerarySummary = ({ <LineIcon line={leg.routeShortName || leg.routeName || leg.mode || ""} mode="pill" + colour={leg.routeColor || undefined} + textColour={leg.routeTextColor || undefined} /> </div> )} @@ -180,7 +147,7 @@ const ItinerarySummary = ({ <span> {t("planner.walk")}: {formatDistance(walkTotals.meters)} {walkTotals.minutes - ? ` • ${walkTotals.minutes} {t("estimates.minutes")}` + ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}` : ""} </span> <span className="flex items-center gap-3"> @@ -566,7 +533,7 @@ const ItineraryDetail = ({ </div> {/* Details Panel */} - <div className="h-1/3 md:h-full md:w-96 lg:w-md overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700"> + <div className="h-1/3 md:h-full md:w-96 lg:w-lg overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700"> <div className="px-4 py-4"> <h2 className="text-xl font-bold mb-4 text-slate-900 dark:text-slate-100"> {t("planner.itinerary_details")} @@ -575,7 +542,7 @@ const ItineraryDetail = ({ <div> {itinerary.legs.map((leg, idx) => ( <div key={idx} className="flex gap-3"> - <div className="flex flex-col items-center"> + <div className="flex flex-col items-center w-20 shrink-0"> {leg.mode === "WALK" ? ( <div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shadow-sm" @@ -587,6 +554,8 @@ const ItineraryDetail = ({ <LineIcon line={leg.routeShortName || leg.routeName || ""} mode="rounded" + colour={leg.routeColor || undefined} + textColour={leg.routeTextColor || undefined} /> )} {idx < itinerary.legs.length - 1 && ( @@ -598,29 +567,42 @@ const ItineraryDetail = ({ {leg.mode === "WALK" ? ( t("planner.walk") ) : ( - <> - <span> + <div className="flex flex-col"> + <span className="text-[10px] uppercase text-muted font-bold leading-none mb-1"> + {t("planner.direction")} + </span> + <span className="leading-tight"> {leg.headsign || leg.routeLongName || leg.routeName || ""} </span> - </> + </div> )} </div> - <div className="text-sm text-gray-600 dark:text-gray-400"> - {new Date(leg.startTime).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - timeZone: "Europe/Madrid", - })}{" "} - -{" "} - {( - (new Date(leg.endTime).getTime() - - new Date(leg.startTime).getTime()) / - 60000 - ).toFixed(0)}{" "} - {t("estimates.minutes")} + <div className="text-sm text-gray-600 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-1 mt-1"> + <span> + {new Date(leg.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + timeZone: "Europe/Madrid", + })}{" "} + -{" "} + {( + (new Date(leg.endTime).getTime() - + new Date(leg.startTime).getTime()) / + 60000 + ).toFixed(0)}{" "} + {t("estimates.minutes")} + </span> + <span>•</span> + <span>{formatDistance(leg.distanceMeters)}</span> + {leg.agencyName && ( + <> + <span>•</span> + <span className="italic">{leg.agencyName}</span> + </> + )} </div> {leg.mode !== "WALK" && leg.from?.stopId && |
