From 48ec0aae80a200d7eb50639ff4c4ca8ae564f29b Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sun, 28 Dec 2025 22:24:26 +0100 Subject: Implement displaying routes with dynamic data from OTP --- src/frontend/app/api/schema.ts | 46 +++++ src/frontend/app/api/transit.ts | 39 ++++ src/frontend/app/components/layout/Header.css | 1 + src/frontend/app/components/layout/NavBar.tsx | 4 +- src/frontend/app/i18n/locales/en-GB.json | 15 +- src/frontend/app/i18n/locales/es-ES.json | 15 +- src/frontend/app/i18n/locales/gl-ES.json | 15 +- src/frontend/app/routes.tsx | 3 +- src/frontend/app/routes/lines.tsx | 40 ---- src/frontend/app/routes/routes-$id.tsx | 269 ++++++++++++++++++++++++++ src/frontend/app/routes/routes.tsx | 76 ++++++++ 11 files changed, 477 insertions(+), 46 deletions(-) create mode 100644 src/frontend/app/api/transit.ts delete mode 100644 src/frontend/app/routes/lines.tsx create mode 100644 src/frontend/app/routes/routes-$id.tsx create mode 100644 src/frontend/app/routes/routes.tsx (limited to 'src/frontend') diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index 63f4368..f7f0a39 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -70,6 +70,52 @@ export type Position = z.infer; export type Arrival = z.infer; export type StopArrivalsResponse = z.infer; +// Transit Routes +export const RouteSchema = z.object({ + id: z.string(), + shortName: z.string().nullable(), + longName: z.string().nullable(), + color: z.string().nullable(), + textColor: z.string().nullable(), + sortOrder: z.number().nullable(), + agencyName: z.string().nullable().optional(), + tripCount: z.number(), +}); + +export const PatternStopSchema = z.object({ + id: z.string(), + code: z.string().nullable(), + name: z.string(), + lat: z.number(), + lon: z.number(), + scheduledDepartures: z.array(z.number()), +}); + +export const PatternSchema = z.object({ + id: z.string(), + name: z.string().nullable(), + headsign: z.string().nullable(), + directionId: z.number(), + code: z.string().nullable(), + semanticHash: z.string().nullable(), + tripCount: z.number(), + geometry: z.array(z.array(z.number())).nullable(), + stops: z.array(PatternStopSchema), +}); + +export const RouteDetailsSchema = z.object({ + shortName: z.string().nullable(), + longName: z.string().nullable(), + color: z.string().nullable(), + textColor: z.string().nullable(), + patterns: z.array(PatternSchema), +}); + +export type Route = z.infer; +export type PatternStop = z.infer; +export type Pattern = z.infer; +export type RouteDetails = z.infer; + // Consolidated Circulation (Legacy/Alternative API) export const ConsolidatedCirculationSchema = z.object({ line: z.string(), diff --git a/src/frontend/app/api/transit.ts b/src/frontend/app/api/transit.ts new file mode 100644 index 0000000..317271a --- /dev/null +++ b/src/frontend/app/api/transit.ts @@ -0,0 +1,39 @@ +import { + RouteDetailsSchema, + RouteSchema, + type Route, + type RouteDetails, +} from "./schema"; + +export const fetchRoutes = async (feeds: string[] = []): Promise => { + const params = new URLSearchParams(); + feeds.forEach((f) => params.append("feeds", f)); + + const resp = await fetch(`/api/transit/routes?${params.toString()}`, { + headers: { + Accept: "application/json", + }, + }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + const data = await resp.json(); + return RouteSchema.array().parse(data); +}; + +export const fetchRouteDetails = async (id: string): Promise => { + const resp = await fetch(`/api/transit/routes/${encodeURIComponent(id)}`, { + headers: { + Accept: "application/json", + }, + }); + + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + + const data = await resp.json(); + return RouteDetailsSchema.parse(data); +}; diff --git a/src/frontend/app/components/layout/Header.css b/src/frontend/app/components/layout/Header.css index 4ff492e..0bba747 100644 --- a/src/frontend/app/components/layout/Header.css +++ b/src/frontend/app/components/layout/Header.css @@ -35,6 +35,7 @@ .app-header__title { font-size: 1.25rem; font-weight: 600; + line-height: 1.05; margin: 0; color: var(--text-color); } diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx index fab47e0..57e2f9d 100644 --- a/src/frontend/app/components/layout/NavBar.tsx +++ b/src/frontend/app/components/layout/NavBar.tsx @@ -70,9 +70,9 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { }, }, { - name: t("navbar.lines", "Líneas"), + name: t("navbar.routes", "Rutas"), icon: Route, - path: "/lines", + path: "/routes", }, ]; diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 91c836a..0286332 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -150,9 +150,22 @@ "home": "Home", "map": "Map", "planner": "Planner", - "lines": "Lines", + "routes": "Routes", "favourites": "Favourites" }, + "routes": { + "description": "Below is a list of urban bus routes with their respective paths.", + "details": "Route details", + "not_found": "Route not found", + "direction_outbound": "Outbound", + "direction_inbound": "Inbound", + "stops": "Stops", + "unknown_agency": "Others", + "trip_count": "{{count}} trips today", + "trip_count_one": "1 trip today", + "trip_count_short": "({{count}} trips)", + "trip_count_short_one": "(1 trip)" + }, "favourites": { "title": "Favourites", "empty": "You don't have any favourite stops yet.", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 526ab2f..9ffc703 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -150,9 +150,22 @@ "home": "Inicio", "map": "Mapa", "planner": "Planificador", - "lines": "Líneas", + "routes": "Rutas", "favourites": "Favoritos" }, + "routes": { + "description": "A continuación se muestra una lista de las rutas de autobús urbano con sus respectivos trayectos.", + "details": "Detalles de ruta", + "not_found": "Ruta no encontrada", + "direction_outbound": "Ida", + "direction_inbound": "Vuelta", + "stops": "Paradas", + "unknown_agency": "Otros", + "trip_count": "{{count}} viajes hoy", + "trip_count_one": "1 viaje hoy", + "trip_count_short": "({{count}} viajes)", + "trip_count_short_one": "(1 viaje)" + }, "favourites": { "title": "Favoritos", "empty": "Aún no tienes paradas favoritas.", diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index eec7ab9..e86088e 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -150,9 +150,22 @@ "home": "Inicio", "map": "Mapa", "planner": "Planificador", - "lines": "Liñas", + "routes": "Rutas", "favourites": "Favoritos" }, + "routes": { + "description": "A continuación móstrase unha lista das rutas de autobús urbano cos seus respectivos traxectos.", + "details": "Detalles de ruta", + "not_found": "Ruta non atopada", + "direction_outbound": "Ida", + "direction_inbound": "Volta", + "stops": "Paradas", + "unknown_agency": "Outros", + "trip_count": "{{count}} viaxes hoxe", + "trip_count_one": "1 viaxe hoxe", + "trip_count_short": "({{count}} viaxes)", + "trip_count_short_one": "(1 viaxe)" + }, "favourites": { "title": "Favoritos", "empty": "Aínda non tes paradas favoritas.", diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx index 052eb83..8e98734 100644 --- a/src/frontend/app/routes.tsx +++ b/src/frontend/app/routes.tsx @@ -3,7 +3,8 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), route("/map", "routes/map.tsx"), - route("/lines", "routes/lines.tsx"), + route("/routes", "routes/routes.tsx"), + route("/routes/:id", "routes/routes-$id.tsx"), route("/stops", "routes/stops.tsx"), route("/stops/:id", "routes/stops-$id.tsx"), route("/settings", "routes/settings.tsx"), diff --git a/src/frontend/app/routes/lines.tsx b/src/frontend/app/routes/lines.tsx deleted file mode 100644 index 900c543..0000000 --- a/src/frontend/app/routes/lines.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useTranslation } from "react-i18next"; -import LineIcon from "~/components/LineIcon"; -import { usePageTitle } from "~/contexts/PageTitleContext"; -import { VIGO_LINES } from "~/data/LinesData"; -import "../tailwind-full.css"; - -export default function LinesPage() { - const { t } = useTranslation(); - usePageTitle(t("navbar.lines", "Líneas")); - - return ( -
-

- {t( - "lines.description", - "A continuación se muestra una lista de las líneas de autobús urbano de Vigo con sus respectivas rutas y enlaces a los horarios oficiales." - )} -

- -
- {VIGO_LINES.map((line) => ( - - -
-

- {line.routeName} -

-
-
- ))} -
-
- ); -} diff --git a/src/frontend/app/routes/routes-$id.tsx b/src/frontend/app/routes/routes-$id.tsx new file mode 100644 index 0000000..8dd7e1c --- /dev/null +++ b/src/frontend/app/routes/routes-$id.tsx @@ -0,0 +1,269 @@ +import { useQuery } from "@tanstack/react-query"; +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Layer, Source, type MapRef } from "react-map-gl/maplibre"; +import { useParams } from "react-router"; +import { fetchRouteDetails } from "~/api/transit"; +import { AppMap } from "~/components/shared/AppMap"; +import { usePageTitle } from "~/contexts/PageTitleContext"; +import "../tailwind-full.css"; + +export default function RouteDetailsPage() { + const { id } = useParams(); + const { t } = useTranslation(); + const [selectedPatternId, setSelectedPatternId] = useState( + null + ); + const [selectedStopId, setSelectedStopId] = useState(null); + const mapRef = useRef(null); + const stopRefs = useRef>({}); + + const { data: route, isLoading } = useQuery({ + queryKey: ["route", id], + queryFn: () => fetchRouteDetails(id!), + enabled: !!id, + }); + + usePageTitle( + route?.shortName + ? `${route.shortName} - ${route.longName}` + : t("routes.details", "Detalles de ruta") + ); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!route) { + return ( +
{t("routes.not_found", "Línea no encontrada")}
+ ); + } + + const activePatterns = route.patterns.filter((p) => p.tripCount > 0); + + const patternsByDirection = activePatterns.reduce( + (acc, pattern) => { + const dir = pattern.directionId; + if (!acc[dir]) acc[dir] = []; + acc[dir].push(pattern); + return acc; + }, + {} as Record + ); + + const selectedPattern = + activePatterns.find((p) => p.id === selectedPatternId) || activePatterns[0]; + + const handleStopClick = ( + stopId: string, + lat: number, + lon: number, + scroll = true + ) => { + setSelectedStopId(stopId); + mapRef.current?.flyTo({ + center: [lon, lat], + zoom: 16, + duration: 1000, + }); + + if (scroll) { + stopRefs.current[stopId]?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }; + + const geojson: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: selectedPattern?.geometry + ? [ + { + type: "Feature", + geometry: { + type: "LineString", + coordinates: selectedPattern.geometry, + }, + properties: {}, + }, + ] + : [], + }; + + const stopsGeojson: GeoJSON.FeatureCollection = { + type: "FeatureCollection", + features: + selectedPattern?.stops.map((stop) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [stop.lon, stop.lat], + }, + properties: { + id: stop.id, + name: stop.name, + code: stop.code, + lat: stop.lat, + lon: stop.lon, + }, + })) || [], + }; + + return ( +
+
+ +
+ +
+
+
+ { + const feature = e.features?.[0]; + if (feature && feature.layer.id === "stop-circles") { + const { id, lat, lon } = feature.properties; + handleStopClick(id, lat, lon, true); + } + }} + > + {selectedPattern?.geometry && ( + + + + )} + + + + +
+ +
+

+ {t("routes.stops", "Paradas")} +

+
+ {selectedPattern?.stops.map((stop, idx) => ( +
{ + stopRefs.current[stop.id] = el; + }} + onClick={() => + handleStopClick(stop.id, stop.lat, stop.lon, false) + } + className={`flex items-start gap-4 p-3 rounded-lg border transition-colors cursor-pointer ${ + selectedStopId === stop.id + ? "bg-primary/5 border-primary" + : "bg-surface border-border hover:border-primary/50" + }`} + > +
+
+ {idx < selectedPattern.stops.length - 1 && ( +
+ )} +
+
+

+ {stop.name} + {stop.code && ( + + {stop.code} + + )} +

+ + {selectedStopId === stop.id && + stop.scheduledDepartures.length > 0 && ( +
+ {stop.scheduledDepartures.map((dep, i) => ( + + {Math.floor(dep / 3600) + .toString() + .padStart(2, "0")} + : + {Math.floor((dep % 3600) / 60) + .toString() + .padStart(2, "0")} + + ))} +
+ )} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/frontend/app/routes/routes.tsx b/src/frontend/app/routes/routes.tsx new file mode 100644 index 0000000..2c11168 --- /dev/null +++ b/src/frontend/app/routes/routes.tsx @@ -0,0 +1,76 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { fetchRoutes } from "~/api/transit"; +import LineIcon from "~/components/LineIcon"; +import { usePageTitle } from "~/contexts/PageTitleContext"; +import "../tailwind-full.css"; + +export default function RoutesPage() { + const { t } = useTranslation(); + usePageTitle(t("navbar.routes", "Rutas")); + + const { data: routes, isLoading } = useQuery({ + queryKey: ["routes"], + queryFn: () => fetchRoutes(["santiago", "vitrasa", "coruna", "feve"]), + }); + + const routesByAgency = routes?.reduce( + (acc, route) => { + const agency = route.agencyName || t("routes.unknown_agency", "Otros"); + if (!acc[agency]) acc[agency] = []; + acc[agency].push(route); + return acc; + }, + {} as Record + ); + + return ( +
+

+ {t( + "routes.description", + "A continuación se muestra una lista de las rutas de autobús urbano con sus respectivos trayectos." + )} +

+ + {isLoading && ( +
+
+
+ )} + +
+ {routesByAgency && + Object.entries(routesByAgency).map(([agency, agencyRoutes]) => ( +
+

+ {agency} +

+
+ {agencyRoutes.map((route) => ( + + +
+

+ {route.longName} +

+
+ + ))} +
+
+ ))} +
+
+ ); +} -- cgit v1.3