aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-28 22:24:26 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-28 22:25:01 +0100
commit48ec0aae80a200d7eb50639ff4c4ca8ae564f29b (patch)
tree8cf2a2a02a49d8295985d90679c33c5bc8375818
parentb2ddc0ef449ccbe7f0d33e539ccdfc1baef04e2c (diff)
Implement displaying routes with dynamic data from OTP
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/TransitController.cs127
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/OtpService.cs59
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Transit/RouteDtos.cs45
-rw-r--r--src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/RouteDetailsContent.cs103
-rw-r--r--src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs66
-rw-r--r--src/frontend/app/api/schema.ts46
-rw-r--r--src/frontend/app/api/transit.ts39
-rw-r--r--src/frontend/app/components/layout/Header.css1
-rw-r--r--src/frontend/app/components/layout/NavBar.tsx4
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json15
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json15
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json15
-rw-r--r--src/frontend/app/routes.tsx3
-rw-r--r--src/frontend/app/routes/lines.tsx40
-rw-r--r--src/frontend/app/routes/routes-$id.tsx269
-rw-r--r--src/frontend/app/routes/routes.tsx76
16 files changed, 877 insertions, 46 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Controllers/TransitController.cs b/src/Costasdev.Busurbano.Backend/Controllers/TransitController.cs
new file mode 100644
index 0000000..b519ea7
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Controllers/TransitController.cs
@@ -0,0 +1,127 @@
+using Costasdev.Busurbano.Backend.Configuration;
+using Costasdev.Busurbano.Backend.Helpers;
+using Costasdev.Busurbano.Backend.Services;
+using Costasdev.Busurbano.Backend.Types.Transit;
+using Costasdev.Busurbano.Sources.OpenTripPlannerGql;
+using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Costasdev.Busurbano.Backend.Controllers;
+
+[ApiController]
+[Route("api/transit")]
+public class TransitController : ControllerBase
+{
+ private readonly ILogger<TransitController> _logger;
+ private readonly OtpService _otpService;
+ private readonly AppConfiguration _config;
+ private readonly HttpClient _httpClient;
+ private readonly IMemoryCache _cache;
+
+ public TransitController(
+ ILogger<TransitController> logger,
+ OtpService otpService,
+ IOptions<AppConfiguration> config,
+ HttpClient httpClient,
+ IMemoryCache cache
+ )
+ {
+ _logger = logger;
+ _otpService = otpService;
+ _config = config.Value;
+ _httpClient = httpClient;
+ _cache = cache;
+ }
+
+ [HttpGet("routes")]
+ public async Task<ActionResult<List<RouteDto>>> GetRoutes([FromQuery] string[] feeds)
+ {
+ if (feeds.Length == 0)
+ {
+ feeds = ["santiago", "vitrasa", "coruna", "feve"];
+ }
+
+ var serviceDate = DateTime.Now.ToString("yyyy-MM-dd");
+ var cacheKey = $"routes_{string.Join("_", feeds)}_{serviceDate}";
+ if (_cache.TryGetValue(cacheKey, out List<RouteDto>? cachedRoutes))
+ {
+ return Ok(cachedRoutes);
+ }
+
+ try
+ {
+ var query = RoutesListContent.Query(new RoutesListContent.Args(feeds, serviceDate));
+ var response = await SendOtpQueryAsync<RoutesListResponse>(query);
+
+ if (response?.Data == null)
+ {
+ return StatusCode(500, "Failed to fetch routes from OTP.");
+ }
+
+ var routes = response.Data.Routes
+ .Select(_otpService.MapRoute)
+ .Where(r => r.TripCount > 0)
+ .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames))
+ .ToList();
+
+ _cache.Set(cacheKey, routes, TimeSpan.FromHours(1));
+
+ return Ok(routes);
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Error fetching routes");
+ return StatusCode(500, "An error occurred while fetching routes.");
+ }
+ }
+
+ [HttpGet("routes/{id}")]
+ public async Task<ActionResult<RouteDetailsDto>> GetRouteDetails(string id)
+ {
+ var serviceDate = DateTime.Now.ToString("yyyy-MM-dd");
+ var cacheKey = $"route_details_{id}_{serviceDate}";
+
+ if (_cache.TryGetValue(cacheKey, out RouteDetailsDto? cachedDetails))
+ {
+ return Ok(cachedDetails);
+ }
+
+ try
+ {
+ var query = RouteDetailsContent.Query(new RouteDetailsContent.Args(id, serviceDate));
+ var response = await SendOtpQueryAsync<RouteDetailsResponse>(query);
+
+ if (response?.Data?.Route == null)
+ {
+ return NotFound();
+ }
+
+ var details = _otpService.MapRouteDetails(response.Data.Route);
+ _cache.Set(cacheKey, details, TimeSpan.FromHours(1));
+
+ return Ok(details);
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Error fetching route details for {Id}", id);
+ return StatusCode(500, "An error occurred while fetching route details.");
+ }
+ }
+
+ private async Task<GraphClientResponse<T>?> SendOtpQueryAsync<T>(string query) where T : AbstractGraphResponse
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1");
+ request.Content = JsonContent.Create(new GraphClientRequest { Query = query });
+
+ var response = await _httpClient.SendAsync(request);
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogError("OTP query failed with status {StatusCode}", response.StatusCode);
+ return null;
+ }
+
+ return await response.Content.ReadFromJsonAsync<GraphClientResponse<T>>();
+ }
+}
diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs
index 704139d..37f7e91 100644
--- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs
+++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs
@@ -3,6 +3,7 @@ using Costasdev.Busurbano.Backend.Configuration;
using Costasdev.Busurbano.Backend.Helpers;
using Costasdev.Busurbano.Backend.Types.Otp;
using Costasdev.Busurbano.Backend.Types.Planner;
+using Costasdev.Busurbano.Backend.Types.Transit;
using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
@@ -30,6 +31,64 @@ public class OtpService
_feedService = feedService;
}
+ public RouteDto MapRoute(RoutesListResponse.RouteItem route)
+ {
+ var feedId = route.GtfsId.Split(':')[0];
+ return new RouteDto
+ {
+ Id = route.GtfsId,
+ ShortName = _feedService.NormalizeRouteShortName(feedId, route.ShortName ?? string.Empty),
+ LongName = route.LongName,
+ Color = route.Color,
+ TextColor = route.TextColor,
+ SortOrder = route.SortOrder,
+ AgencyName = route.Agency?.Name,
+ TripCount = route.Patterns.Sum(p => p.TripsForDate.Count)
+ };
+ }
+
+ public RouteDetailsDto MapRouteDetails(RouteDetailsResponse.RouteItem route)
+ {
+ var feedId = route.GtfsId?.Split(':')[0] ?? "unknown";
+ return new RouteDetailsDto
+ {
+ ShortName = _feedService.NormalizeRouteShortName(feedId, route.ShortName ?? string.Empty),
+ LongName = route.LongName,
+ Color = route.Color,
+ TextColor = route.TextColor,
+ Patterns = route.Patterns.Select(MapPattern).ToList()
+ };
+ }
+
+ private PatternDto MapPattern(RouteDetailsResponse.PatternItem pattern)
+ {
+ var feedId = pattern.Id.Split(':')[0];
+ return new PatternDto
+ {
+ Id = pattern.Id,
+ Name = pattern.Name,
+ Headsign = pattern.Headsign,
+ DirectionId = pattern.DirectionId,
+ Code = pattern.Code,
+ SemanticHash = pattern.SemanticHash,
+ TripCount = pattern.TripsForDate.Count,
+ Geometry = DecodePolyline(pattern.PatternGeometry?.Points)?.Coordinates,
+ Stops = pattern.Stops.Select((s, i) => new PatternStopDto
+ {
+ Id = s.GtfsId,
+ Code = _feedService.NormalizeStopCode(feedId, s.Code ?? string.Empty),
+ Name = _feedService.NormalizeStopName(feedId, s.Name),
+ Lat = s.Lat,
+ Lon = s.Lon,
+ ScheduledDepartures = pattern.TripsForDate
+ .Select(t => t.Stoptimes.ElementAtOrDefault(i)?.ScheduledDeparture ?? -1)
+ .Where(d => d != -1)
+ .OrderBy(d => d)
+ .ToList()
+ }).ToList()
+ };
+ }
+
private Leg MapLeg(OtpLeg otpLeg)
{
return new Leg
diff --git a/src/Costasdev.Busurbano.Backend/Types/Transit/RouteDtos.cs b/src/Costasdev.Busurbano.Backend/Types/Transit/RouteDtos.cs
new file mode 100644
index 0000000..f647b5b
--- /dev/null
+++ b/src/Costasdev.Busurbano.Backend/Types/Transit/RouteDtos.cs
@@ -0,0 +1,45 @@
+namespace Costasdev.Busurbano.Backend.Types.Transit;
+
+public class RouteDto
+{
+ public required string Id { get; set; }
+ public string? ShortName { get; set; }
+ public string? LongName { get; set; }
+ public string? Color { get; set; }
+ public string? TextColor { get; set; }
+ public int? SortOrder { get; set; }
+ public string? AgencyName { get; set; }
+ public int TripCount { get; set; }
+}
+
+public class RouteDetailsDto
+{
+ public string? ShortName { get; set; }
+ public string? LongName { get; set; }
+ public string? Color { get; set; }
+ public string? TextColor { get; set; }
+ public List<PatternDto> Patterns { get; set; } = [];
+}
+
+public class PatternDto
+{
+ public required string Id { get; set; }
+ public string? Name { get; set; }
+ public string? Headsign { get; set; }
+ public int DirectionId { get; set; }
+ public string? Code { get; set; }
+ public string? SemanticHash { get; set; }
+ public int TripCount { get; set; }
+ public List<List<double>>? Geometry { get; set; }
+ public List<PatternStopDto> Stops { get; set; } = [];
+}
+
+public class PatternStopDto
+{
+ public required string Id { get; set; }
+ public string? Code { get; set; }
+ public required string Name { get; set; }
+ public double Lat { get; set; }
+ public double Lon { get; set; }
+ public List<int> ScheduledDepartures { get; set; } = [];
+}
diff --git a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/RouteDetailsContent.cs b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/RouteDetailsContent.cs
new file mode 100644
index 0000000..8683bfd
--- /dev/null
+++ b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/RouteDetailsContent.cs
@@ -0,0 +1,103 @@
+using System.Globalization;
+using System.Text.Json.Serialization;
+
+namespace Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries;
+
+public class RouteDetailsContent : IGraphRequest<RouteDetailsContent.Args>
+{
+ public record Args(string Id, string ServiceDate);
+
+ public static string Query(Args args)
+ {
+ return string.Create(CultureInfo.InvariantCulture, $$"""
+ query Query {
+ route(id: "{{args.Id}}") {
+ gtfsId
+ shortName
+ longName
+ color
+ textColor
+
+ patterns {
+ id
+ name
+ headsign
+ directionId
+ code
+ semanticHash
+
+ patternGeometry {
+ points
+ }
+
+ stops {
+ gtfsId
+ code
+ name
+ lat
+ lon
+ }
+
+ tripsForDate(serviceDate: "{{args.ServiceDate}}") {
+ stoptimes {
+ scheduledDeparture
+ }
+ }
+ }
+ }
+ }
+ """);
+ }
+}
+
+public class RouteDetailsResponse : AbstractGraphResponse
+{
+ [JsonPropertyName("route")] public RouteItem? Route { get; set; }
+
+ public class RouteItem
+ {
+ [JsonPropertyName("gtfsId")] public string? GtfsId { get; set; }
+ [JsonPropertyName("shortName")] public string? ShortName { get; set; }
+ [JsonPropertyName("longName")] public string? LongName { get; set; }
+ [JsonPropertyName("color")] public string? Color { get; set; }
+ [JsonPropertyName("textColor")] public string? TextColor { get; set; }
+ [JsonPropertyName("patterns")] public List<PatternItem> Patterns { get; set; } = [];
+ }
+
+ public class PatternItem
+ {
+ [JsonPropertyName("id")] public required string Id { get; set; }
+ [JsonPropertyName("name")] public string? Name { get; set; }
+ [JsonPropertyName("headsign")] public string? Headsign { get; set; }
+ [JsonPropertyName("directionId")] public int DirectionId { get; set; }
+ [JsonPropertyName("code")] public string? Code { get; set; }
+ [JsonPropertyName("semanticHash")] public string? SemanticHash { get; set; }
+ [JsonPropertyName("patternGeometry")] public GeometryItem? PatternGeometry { get; set; }
+ [JsonPropertyName("stops")] public List<StopItem> Stops { get; set; } = [];
+ [JsonPropertyName("tripsForDate")] public List<TripItem> TripsForDate { get; set; } = [];
+ }
+
+ public class GeometryItem
+ {
+ [JsonPropertyName("points")] public string? Points { get; set; }
+ }
+
+ public class StopItem
+ {
+ [JsonPropertyName("gtfsId")] public required string GtfsId { get; set; }
+ [JsonPropertyName("code")] public string? Code { get; set; }
+ [JsonPropertyName("name")] public required string Name { get; set; }
+ [JsonPropertyName("lat")] public double Lat { get; set; }
+ [JsonPropertyName("lon")] public double Lon { get; set; }
+ }
+
+ public class TripItem
+ {
+ [JsonPropertyName("stoptimes")] public List<StoptimeItem> Stoptimes { get; set; } = [];
+ }
+
+ public class StoptimeItem
+ {
+ [JsonPropertyName("scheduledDeparture")] public int ScheduledDeparture { get; set; }
+ }
+}
diff --git a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs
new file mode 100644
index 0000000..fc69452
--- /dev/null
+++ b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs
@@ -0,0 +1,66 @@
+using System.Globalization;
+using System.Text.Json.Serialization;
+
+namespace Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries;
+
+public class RoutesListContent : IGraphRequest<RoutesListContent.Args>
+{
+ public record Args(string[] Feeds, string ServiceDate);
+
+ public static string Query(Args args)
+ {
+ var feedsStr = string.Join(", ", args.Feeds.Select(f => $"\"{f}\""));
+ return string.Create(CultureInfo.InvariantCulture, $$"""
+ query Query {
+ routes(feeds: [{{feedsStr}}]) {
+ gtfsId
+ shortName
+ longName
+ color
+ textColor
+ sortOrder
+ agency {
+ name
+ }
+ patterns {
+ tripsForDate(serviceDate: "{{args.ServiceDate}}") {
+ id
+ }
+ }
+ }
+ }
+ """);
+ }
+}
+
+public class RoutesListResponse : AbstractGraphResponse
+{
+ [JsonPropertyName("routes")] public List<RouteItem> Routes { get; set; } = [];
+
+ public class RouteItem
+ {
+ [JsonPropertyName("gtfsId")] public required string GtfsId { get; set; }
+ [JsonPropertyName("shortName")] public string? ShortName { get; set; }
+ [JsonPropertyName("longName")] public string? LongName { get; set; }
+ [JsonPropertyName("color")] public string? Color { get; set; }
+ [JsonPropertyName("textColor")] public string? TextColor { get; set; }
+ [JsonPropertyName("sortOrder")] public int? SortOrder { get; set; }
+ [JsonPropertyName("agency")] public AgencyItem? Agency { get; set; }
+ [JsonPropertyName("patterns")] public List<PatternItem> Patterns { get; set; } = [];
+ }
+
+ public class AgencyItem
+ {
+ [JsonPropertyName("name")] public string? Name { get; set; }
+ }
+
+ public class PatternItem
+ {
+ [JsonPropertyName("tripsForDate")] public List<TripItem> TripsForDate { get; set; } = [];
+ }
+
+ public class TripItem
+ {
+ [JsonPropertyName("id")] public string? Id { get; set; }
+ }
+}
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<typeof PositionSchema>;
export type Arrival = z.infer<typeof ArrivalSchema>;
export type StopArrivalsResponse = z.infer<typeof StopArrivalsResponseSchema>;
+// 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<typeof RouteSchema>;
+export type PatternStop = z.infer<typeof PatternStopSchema>;
+export type Pattern = z.infer<typeof PatternSchema>;
+export type RouteDetails = z.infer<typeof RouteDetailsSchema>;
+
// 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<Route[]> => {
+ 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<RouteDetails> => {
+ 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 (
- <div className="container mx-auto px-4 py-6">
- <p className="mb-6 text-gray-700 dark:text-gray-300">
- {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."
- )}
- </p>
-
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- {VIGO_LINES.map((line) => (
- <a
- key={line.lineNumber}
- href={line.scheduleUrl}
- target="_blank"
- rel="noopener noreferrer"
- className="flex items-center gap-3 p-4 bg-surface rounded-lg shadow hover:shadow-lg transition-shadow border border-border"
- >
- <LineIcon line={line.lineNumber} mode="rounded" />
- <div className="flex-1 min-w-0">
- <p className="text-sm md:text-md font-semibold text-text">
- {line.routeName}
- </p>
- </div>
- </a>
- ))}
- </div>
- </div>
- );
-}
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<string | null>(
+ null
+ );
+ const [selectedStopId, setSelectedStopId] = useState<string | null>(null);
+ const mapRef = useRef<MapRef>(null);
+ const stopRefs = useRef<Record<string, HTMLDivElement | null>>({});
+
+ 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 (
+ <div className="flex justify-center py-12">
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
+ </div>
+ );
+ }
+
+ if (!route) {
+ return (
+ <div className="p-4">{t("routes.not_found", "Línea no encontrada")}</div>
+ );
+ }
+
+ 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<number, typeof route.patterns>
+ );
+
+ 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 (
+ <div className="flex flex-col h-full overflow-hidden">
+ <div className="p-4 bg-surface border-b border-border">
+ <select
+ className="w-full p-2 rounded-lg border border-border bg-background text-text focus:ring-2 focus:ring-primary outline-none"
+ value={selectedPattern?.id}
+ onChange={(e) => {
+ setSelectedPatternId(e.target.value);
+ setSelectedStopId(null);
+ }}
+ >
+ {Object.entries(patternsByDirection).map(([dir, patterns]) => (
+ <optgroup
+ key={dir}
+ label={
+ dir === "0"
+ ? t("routes.direction_outbound", "Ida")
+ : t("routes.direction_inbound", "Vuelta")
+ }
+ >
+ {patterns.map((pattern) => (
+ <option key={pattern.id} value={pattern.id}>
+ {pattern.code ? `${pattern.code.slice(-2)}: ` : ""}
+ {pattern.headsign || pattern.name}{" "}
+ {t("routes.trip_count_short", { count: pattern.tripCount })}
+ </option>
+ ))}
+ </optgroup>
+ ))}
+ </select>
+ </div>
+
+ <div className="flex-1 flex flex-col overflow-hidden">
+ <div className="flex-1 flex flex-col relative overflow-hidden">
+ <div className="h-1/2 relative">
+ <AppMap
+ ref={mapRef}
+ initialViewState={
+ selectedPattern?.stops[0]
+ ? {
+ latitude: selectedPattern.stops[0].lat,
+ longitude: selectedPattern.stops[0].lon,
+ zoom: 13,
+ }
+ : undefined
+ }
+ interactiveLayerIds={["stop-circles"]}
+ onClick={(e) => {
+ 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 && (
+ <Source type="geojson" data={geojson}>
+ <Layer
+ id="route-line"
+ type="line"
+ paint={{
+ "line-color": route.color ? `#${route.color}` : "#3b82f6",
+ "line-width": 4,
+ "line-opacity": 0.8,
+ }}
+ />
+ </Source>
+ )}
+ <Source type="geojson" data={stopsGeojson}>
+ <Layer
+ id="stop-circles"
+ type="circle"
+ paint={{
+ "circle-radius": 6,
+ "circle-color": "#ffffff",
+ "circle-stroke-width": 2,
+ "circle-stroke-color": route.color
+ ? `#${route.color}`
+ : "#3b82f6",
+ }}
+ />
+ </Source>
+ </AppMap>
+ </div>
+
+ <div className="flex-1 overflow-y-auto p-4 bg-background">
+ <h3 className="text-lg font-bold mb-4">
+ {t("routes.stops", "Paradas")}
+ </h3>
+ <div className="space-y-4">
+ {selectedPattern?.stops.map((stop, idx) => (
+ <div
+ key={`${stop.id}-${idx}`}
+ ref={(el) => {
+ 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"
+ }`}
+ >
+ <div className="flex flex-col items-center">
+ <div
+ className={`w-3 h-3 rounded-full mt-1.5 ${selectedStopId === stop.id ? "bg-primary" : "bg-gray-400"}`}
+ ></div>
+ {idx < selectedPattern.stops.length - 1 && (
+ <div className="w-0.5 h-full bg-border -mb-3 mt-1"></div>
+ )}
+ </div>
+ <div className="flex-1">
+ <p className="font-semibold text-text">
+ {stop.name}
+ {stop.code && (
+ <span className="text-xs font-normal text-gray-500 ml-2">
+ {stop.code}
+ </span>
+ )}
+ </p>
+
+ {selectedStopId === stop.id &&
+ stop.scheduledDepartures.length > 0 && (
+ <div className="mt-2 flex flex-wrap gap-1">
+ {stop.scheduledDepartures.map((dep, i) => (
+ <span
+ key={i}
+ className="text-[10px] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded"
+ >
+ {Math.floor(dep / 3600)
+ .toString()
+ .padStart(2, "0")}
+ :
+ {Math.floor((dep % 3600) / 60)
+ .toString()
+ .padStart(2, "0")}
+ </span>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
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<string, typeof routes>
+ );
+
+ return (
+ <div className="container mx-auto px-4 py-6">
+ <p className="mb-6 text-gray-700 dark:text-gray-300">
+ {t(
+ "routes.description",
+ "A continuación se muestra una lista de las rutas de autobús urbano con sus respectivos trayectos."
+ )}
+ </p>
+
+ {isLoading && (
+ <div className="flex justify-center py-12">
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
+ </div>
+ )}
+
+ <div className="space-y-8">
+ {routesByAgency &&
+ Object.entries(routesByAgency).map(([agency, agencyRoutes]) => (
+ <div key={agency}>
+ <h2 className="text-xl font-bold text-text mb-4 border-b border-border pb-2">
+ {agency}
+ </h2>
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {agencyRoutes.map((route) => (
+ <Link
+ key={route.id}
+ to={`/routes/${route.id}`}
+ className="flex items-center gap-3 p-4 bg-surface rounded-lg shadow hover:shadow-lg transition-shadow border border-border"
+ >
+ <LineIcon
+ line={route.shortName ?? "?"}
+ mode="pill"
+ colour={route.color ?? undefined}
+ textColour={route.textColor ?? undefined}
+ />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm md:text-md font-semibold text-text">
+ {route.longName}
+ </p>
+ </div>
+ </Link>
+ ))}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}