diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-28 22:24:26 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-28 22:25:01 +0100 |
| commit | 48ec0aae80a200d7eb50639ff4c4ca8ae564f29b (patch) | |
| tree | 8cf2a2a02a49d8295985d90679c33c5bc8375818 | |
| parent | b2ddc0ef449ccbe7f0d33e539ccdfc1baef04e2c (diff) | |
Implement displaying routes with dynamic data from OTP
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Controllers/TransitController.cs | 127 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Services/OtpService.cs | 59 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Types/Transit/RouteDtos.cs | 45 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/RouteDetailsContent.cs | 103 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs | 66 | ||||
| -rw-r--r-- | src/frontend/app/api/schema.ts | 46 | ||||
| -rw-r--r-- | src/frontend/app/api/transit.ts | 39 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/Header.css | 1 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/NavBar.tsx | 4 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 15 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 15 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 15 | ||||
| -rw-r--r-- | src/frontend/app/routes.tsx | 3 | ||||
| -rw-r--r-- | src/frontend/app/routes/lines.tsx | 40 | ||||
| -rw-r--r-- | src/frontend/app/routes/routes-$id.tsx | 269 | ||||
| -rw-r--r-- | src/frontend/app/routes/routes.tsx | 76 |
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> + ); +} |
