From 4a866f5352a51916ddb9849b2d68213856196c9c Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Tue, 23 Dec 2025 21:33:17 +0100 Subject: Full real-time page, coruña real time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Costasdev.Busurbano.slnx | 1 + .../Controllers/ArrivalsController.cs | 20 +- .../Controllers/TileController.cs | 3 +- .../Costasdev.Busurbano.Backend.csproj | 2 +- .../GraphClient/App/ArrivalsAtStop.cs | 24 ++ src/Costasdev.Busurbano.Backend/Program.cs | 4 + .../Services/ArrivalsPipeline.cs | 3 + .../Services/FeedService.cs | 21 +- .../Services/Processors/AbstractProcessor.cs | 56 ++++ .../Services/Processors/CorunaRealTimeProcessor.cs | 189 ++++++++++++++ .../Services/Processors/ShapeProcessor.cs | 37 ++- .../Processors/VitrasaRealTimeProcessor.cs | 109 +++++++- .../Services/ShapeTraversalService.cs | 20 ++ .../Types/Arrivals/Arrival.cs | 12 + .../Types/Arrivals/StopArrivalsResponse.cs | 6 + .../CorunaRealtimeEstimatesProvider.cs | 50 ++++ ...stasdev.Busurbano.Sources.TranviasCoruna.csproj | 9 + .../Response.cs | 34 +++ src/frontend/app/api/schema.ts | 14 + src/frontend/app/components/StopMapModal.tsx | 290 +++++++++++++-------- .../app/components/arrivals/ArrivalCard.tsx | 231 ++++++++++------ .../app/components/arrivals/ArrivalList.tsx | 29 ++- .../app/components/arrivals/ReducedArrivalCard.tsx | 198 ++++++++++++++ src/frontend/app/routes/stops-$id.tsx | 174 ++++--------- 24 files changed, 1214 insertions(+), 322 deletions(-) create mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs create mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs create mode 100644 src/Costasdev.Busurbano.Sources.TranviasCoruna/CorunaRealtimeEstimatesProvider.cs create mode 100644 src/Costasdev.Busurbano.Sources.TranviasCoruna/Costasdev.Busurbano.Sources.TranviasCoruna.csproj create mode 100644 src/Costasdev.Busurbano.Sources.TranviasCoruna/Response.cs create mode 100644 src/frontend/app/components/arrivals/ReducedArrivalCard.tsx diff --git a/Costasdev.Busurbano.slnx b/Costasdev.Busurbano.slnx index 3d11d95..43ba4b3 100644 --- a/Costasdev.Busurbano.slnx +++ b/Costasdev.Busurbano.slnx @@ -1,4 +1,5 @@ + diff --git a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs index 934935e..61a003e 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs @@ -2,6 +2,7 @@ using Costasdev.Busurbano.Backend.GraphClient; using Costasdev.Busurbano.Backend.GraphClient.App; using Costasdev.Busurbano.Backend.Services; +using Costasdev.Busurbano.Backend.Types; using Costasdev.Busurbano.Backend.Types.Arrivals; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; @@ -87,7 +88,8 @@ public partial class ArrivalsController : ControllerBase //var isRunning = departureTime < nowLocal; // TODO: Handle this properly, since many times it's "tomorrow" but not handled properly - if (minutesToArrive < ArrivalsAtStopContent.PastArrivalMinutesIncluded) + var threshold = ShouldFetchPastArrivals(id) ? ArrivalsAtStopContent.PastArrivalMinutesIncluded : 0; + if (minutesToArrive < threshold) { continue; } @@ -97,6 +99,7 @@ public partial class ArrivalsController : ControllerBase TripId = item.Trip.GtfsId, Route = new RouteInfo { + GtfsId = item.Trip.Route.GtfsId, ShortName = item.Trip.RouteShortName, Colour = item.Trip.Route.Color ?? "FFFFFF", TextColour = item.Trip.Route.TextColor ?? "000000" @@ -122,7 +125,8 @@ public partial class ArrivalsController : ControllerBase StopCode = stop.Code, IsReduced = reduced, Arrivals = arrivals, - NowLocal = nowLocal + NowLocal = nowLocal, + StopLocation = new Position { Latitude = stop.Lat, Longitude = stop.Lon } }); var feedId = id.Split(':')[0]; @@ -131,6 +135,18 @@ public partial class ArrivalsController : ControllerBase { StopCode = _feedService.NormalizeStopCode(feedId, stop.Code), StopName = _feedService.NormalizeStopName(feedId, stop.Name), + StopLocation = new Position + { + Latitude = stop.Lat, + Longitude = stop.Lon + }, + Routes = stop.Routes.Select(r => new RouteInfo + { + GtfsId = r.GtfsId, + ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""), + Colour = r.Color ?? "FFFFFF", + TextColour = r.TextColor ?? "000000" + }).ToList(), Arrivals = arrivals }); } diff --git a/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs b/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs index 0e9d21b..f3fe51c 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs @@ -97,6 +97,7 @@ public class TileController : ControllerBase return; } + // TODO: Duplicate from ArrivalsController var (Color, TextColor) = _feedService.GetFallbackColourForFeed(idParts[0]); var distinctRoutes = GetDistinctRoutes(feedId, stop.Routes ?? []); @@ -166,7 +167,7 @@ public class TileController : ControllerBase foreach (var route in routes) { - var seenId = _feedService.NormalizeRouteShortName(feedId, route.ShortName ?? string.Empty); + var seenId = _feedService.GetUniqueRouteShortName(feedId, route.ShortName ?? string.Empty); route.ShortName = seenId; if (seen.Contains(seenId)) diff --git a/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj b/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj index 3bff631..5e2283c 100644 --- a/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj +++ b/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj @@ -22,6 +22,6 @@ - + diff --git a/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs index cf2907c..a349f9a 100644 --- a/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs +++ b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs @@ -20,6 +20,14 @@ public class ArrivalsAtStopContent : IGraphRequest stop(id:""{args.Id}"") {{ code name + lat + lon + routes {{ + gtfsId + shortName + color + textColor + }} arrivals: stoptimesWithoutPatterns(numberOfDepartures: 100, startTime: {startTimeUnix}, timeRange: 14400) {{ headsign scheduledDeparture @@ -31,6 +39,7 @@ public class ArrivalsAtStopContent : IGraphRequest serviceId routeShortName route {{ + gtfsId color textColor longName @@ -42,6 +51,8 @@ public class ArrivalsAtStopContent : IGraphRequest stoptimes {{ stop {{ name + lat + lon }} scheduledDeparture }} @@ -63,6 +74,12 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse [JsonPropertyName("name")] public required string Name { get; set; } + [JsonPropertyName("lat")] public double Lat { get; set; } + + [JsonPropertyName("lon")] public double Lon { get; set; } + + [JsonPropertyName("routes")] public List Routes { get; set; } = []; + [JsonPropertyName("arrivals")] public List Arrivals { get; set; } = []; } @@ -115,6 +132,8 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse public class StopDetails { [JsonPropertyName("name")] public required string Name { get; set; } + [JsonPropertyName("lat")] public double Lat { get; set; } + [JsonPropertyName("lon")] public double Lon { get; set; } } public class DepartureStoptime @@ -125,6 +144,11 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse public class RouteDetails { + [JsonPropertyName("gtfsId")] public required string GtfsId { get; set; } + public string GtfsIdValue => GtfsId.Split(':', 2)[1]; + + [JsonPropertyName("shortName")] public string? ShortName { get; set; } + [JsonPropertyName("color")] public string? Color { get; set; } [JsonPropertyName("textColor")] public string? TextColor { get; set; } diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs index c34f00e..7d52c29 100644 --- a/src/Costasdev.Busurbano.Backend/Program.cs +++ b/src/Costasdev.Busurbano.Backend/Program.cs @@ -3,6 +3,7 @@ using Costasdev.Busurbano.Backend.Configuration; using Costasdev.Busurbano.Backend.Services; using Costasdev.Busurbano.Backend.Services.Processors; using Costasdev.Busurbano.Backend.Services.Providers; +using Costasdev.Busurbano.Sources.TranviasCoruna; var builder = WebApplication.CreateBuilder(args); @@ -17,10 +18,13 @@ builder.Services builder.Services.AddHttpClient(); builder.Services.AddMemoryCache(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs b/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs index 8699a1e..3c9368c 100644 --- a/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs +++ b/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs @@ -1,3 +1,4 @@ +using Costasdev.Busurbano.Backend.Types; using Costasdev.Busurbano.Backend.Types.Arrivals; namespace Costasdev.Busurbano.Backend.Services; @@ -19,6 +20,8 @@ public class ArrivalsContext /// public bool IsReduced { get; set; } + public Position? StopLocation { get; set; } + public required List Arrivals { get; set; } public required DateTime NowLocal { get; set; } } diff --git a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs index 48f9338..6cebcf2 100644 --- a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs @@ -13,6 +13,9 @@ public class FeedService { "Rúa da Salguera Entrada", "Rúa da Salgueira" }, { "Rúa da Salgueira Entrada", "Rúa da Salgueira" }, { "Estrada de Miraflores", "Estrada Miraflores" }, + { "Avda. de Europa", "Avda. Europa" }, + { "Avda. de Galicia", "Avda. Galicia" }, + { "Avda. de Vigo", "Avda. Vigo" }, { "FORA DE SERVIZO.G.B.", "" }, { "Praza de Fernando O Católico", "" }, { "Rúa da Travesía de Vigo", "Travesía de Vigo" }, @@ -26,7 +29,8 @@ public class FeedService { "Avda. das ", " " }, { "Riós", "Ríos" }, { "Avda. Beiramar Porto Pesqueiro Berbés", "Berbés" }, - { "Conde de Torrecedeira", "Torrecedeira" } + { "Conde de Torrecedeira", "Torrecedeira" }, + }; public (string Color, string TextColor) GetFallbackColourForFeed(string feed) @@ -65,12 +69,23 @@ public class FeedService var lineStr = shortName.Substring(5); if (int.TryParse(lineStr, out int line)) { - return $"{contract}.{line}"; + return $"{contract}.{line:D2}"; } } return shortName; } + public string GetUniqueRouteShortName(string feedId, string shortName) + { + if (feedId == "xunta" && shortName.StartsWith("XG") && shortName.Length >= 8) + { + var contract = shortName.Substring(2, 3); + return $"XG{contract}"; + } + + return NormalizeRouteShortName(feedId, shortName); + } + public string NormalizeStopName(string feedId, string name) { if (feedId == "vitrasa") @@ -115,7 +130,7 @@ public class FeedService { if (nextStops.Count == 0) return null; - if (feedId == "vitrasa") + if (feedId == "vitrasa" || feedId == "coruna") { var streets = nextStops .Select(GetStreetName) diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs new file mode 100644 index 0000000..343f511 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs @@ -0,0 +1,56 @@ +using Costasdev.Busurbano.Backend.Services; + +public abstract class AbstractRealTimeProcessor : IArrivalsProcessor +{ + public abstract Task ProcessAsync(ArrivalsContext context); + + protected static List<(double Lat, double Lon)> Decode(string encodedPoints) + { + if (string.IsNullOrEmpty(encodedPoints)) + return new List<(double, double)>(); + + var poly = new List<(double, double)>(); + char[] polylineChars = encodedPoints.ToCharArray(); + int index = 0; + + int currentLat = 0; + int currentLng = 0; + int next5bits; + int sum; + int shifter; + + while (index < polylineChars.Length) + { + // calculate next latitude + sum = 0; + shifter = 0; + do + { + next5bits = (int)polylineChars[index++] - 63; + sum |= (next5bits & 31) << shifter; + shifter += 5; + } while (next5bits >= 32 && index < polylineChars.Length); + + if (index >= polylineChars.Length) + break; + + currentLat += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1); + + // calculate next longitude + sum = 0; + shifter = 0; + do + { + next5bits = (int)polylineChars[index++] - 63; + sum |= (next5bits & 31) << shifter; + shifter += 5; + } while (next5bits >= 32 && index < polylineChars.Length); + + currentLng += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1); + + poly.Add((Convert.ToDouble(currentLat) / 100000.0, Convert.ToDouble(currentLng) / 100000.0)); + } + + return poly; + } +} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs new file mode 100644 index 0000000..2ac1554 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs @@ -0,0 +1,189 @@ +using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.GraphClient.App; +using Costasdev.Busurbano.Backend.Types; +using Costasdev.Busurbano.Backend.Types.Arrivals; +using Costasdev.VigoTransitApi; +using Costasdev.Busurbano.Sources.TranviasCoruna; +using Microsoft.Extensions.Options; +using Arrival = Costasdev.Busurbano.Backend.Types.Arrivals.Arrival; + +namespace Costasdev.Busurbano.Backend.Services.Processors; + +public class CorunaRealTimeProcessor : AbstractRealTimeProcessor +{ + private readonly CorunaRealtimeEstimatesProvider _realtime; + private readonly FeedService _feedService; + private readonly ILogger _logger; + private readonly ShapeTraversalService _shapeService; + + public CorunaRealTimeProcessor( + HttpClient http, + FeedService feedService, + ILogger logger, + ShapeTraversalService shapeService) + { + _realtime = new CorunaRealtimeEstimatesProvider(http); + _feedService = feedService; + _logger = logger; + _shapeService = shapeService; + } + + public override async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("coruna:")) return; + + var normalizedCode = _feedService.NormalizeStopCode("coruna", context.StopCode); + if (!int.TryParse(normalizedCode, out var numericStopId)) return; + + try + { + // Load schedule + var todayDate = context.NowLocal.Date.ToString("yyyy-MM-dd"); + + Epsg25829? stopLocation = null; + if (context.StopLocation != null) + { + stopLocation = _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); + } + + var realtime = await _realtime.GetEstimatesForStop(numericStopId); + + var usedTripIds = new HashSet(); + var newArrivals = new List(); + + foreach (var estimate in realtime) + { + var bestMatch = context.Arrivals + .Where(a => !usedTripIds.Contains(a.TripId)) + .Where(a => a.Route.RouteIdInGtfs.Trim() == estimate.RouteId.Trim()) + .Select(a => + { + return new + { + Arrival = a, + TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule + RouteMatch = true + }; + }) + .Where(x => x.RouteMatch) // Strict route matching + .Where(x => x.TimeDiff >= -7 && x.TimeDiff <= 75) // Allow 7m early (RealTime < Schedule) or 75m late (RealTime > Schedule) + .OrderBy(x => Math.Abs(x.TimeDiff)) // Best time fit + .FirstOrDefault(); + + if (bestMatch == null) + { + continue; + } + + var arrival = bestMatch.Arrival; + _logger.LogInformation("Matched Coruña real-time for line {Line}: {Scheduled}m -> {RealTime}m (diff: {Diff}m)", + arrival.Route.ShortName, arrival.Estimate.Minutes, estimate.Minutes, bestMatch.TimeDiff); + + var scheduledMinutes = arrival.Estimate.Minutes; + arrival.Estimate.Minutes = estimate.Minutes; + arrival.Estimate.Precision = ArrivalPrecision.Confident; + + // Calculate delay badge + var delayMinutes = estimate.Minutes - scheduledMinutes; + if (delayMinutes != 0) + { + arrival.Delay = new DelayBadge { Minutes = delayMinutes }; + } + + // Calculate position + if (stopLocation != null) + { + Position? currentPosition = null; + int? stopShapeIndex = null; + + if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival && + otpArrival.Trip.Geometry?.Points != null) + { + var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) + .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) + .ToList(); + + var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); + + // Ensure meters is positive + var meters = Math.Max(0, estimate.Metres); + var result = _shapeService.GetBusPosition(shape, stopLocation, meters); + + currentPosition = result.BusPosition; + stopShapeIndex = result.StopIndex; + + if (currentPosition != null) + { + _logger.LogInformation("Calculated position from OTP geometry for trip {TripId}: {Lat}, {Lon}", arrival.TripId, currentPosition.Latitude, currentPosition.Longitude); + } + + // Populate Shape GeoJSON + if (!context.IsReduced && currentPosition != null) + { + var features = new List(); + features.Add(new + { + type = "Feature", + geometry = new + { + type = "LineString", + coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList() + }, + properties = new { type = "route" } + }); + + // Add stops if available + if (otpArrival.Trip.Stoptimes != null) + { + foreach (var stoptime in otpArrival.Trip.Stoptimes) + { + features.Add(new + { + type = "Feature", + geometry = new + { + type = "Point", + coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } + }, + properties = new + { + type = "stop", + name = stoptime.Stop.Name + } + }); + } + } + + arrival.Shape = new + { + type = "FeatureCollection", + features = features + }; + } + } + + if (currentPosition != null) + { + arrival.CurrentPosition = currentPosition; + arrival.StopShapeIndex = stopShapeIndex; + } + } + + usedTripIds.Add(arrival.TripId); + + } + + context.Arrivals.AddRange(newArrivals); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Vitrasa real-time data for stop {StopId}", context.StopId); + } + } + + private static bool IsRouteMatch(string a, string b) + { + return a == b || a.Contains(b) || b.Contains(a); + } + +} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs index 300ce70..93e4a4f 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs @@ -20,6 +20,9 @@ public class ShapeProcessor : IArrivalsProcessor foreach (var arrival in context.Arrivals) { + // If shape is already populated (e.g. by VitrasaRealTimeProcessor), skip + if (arrival.Shape != null) continue; + if (arrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival) continue; var encodedPoints = otpArrival.Trip.Geometry?.Points; @@ -34,14 +37,46 @@ public class ShapeProcessor : IArrivalsProcessor var points = Decode(encodedPoints); if (points.Count == 0) continue; - arrival.Shape = new + var features = new List(); + + // Route LineString + features.Add(new { type = "Feature", geometry = new { type = "LineString", coordinates = points.Select(p => new[] { p.Lon, p.Lat }).ToList() + }, + properties = new { type = "route" } + }); + + // Stops + if (otpArrival.Trip.Stoptimes != null) + { + foreach (var stoptime in otpArrival.Trip.Stoptimes) + { + features.Add(new + { + type = "Feature", + geometry = new + { + type = "Point", + coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } + }, + properties = new + { + type = "stop", + name = stoptime.Stop.Name + } + }); } + } + + arrival.Shape = new + { + type = "FeatureCollection", + features = features }; } catch (Exception ex) diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs index 7c98cfb..a16425f 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs @@ -1,23 +1,35 @@ +using Costasdev.Busurbano.Backend.Configuration; using Costasdev.Busurbano.Backend.GraphClient.App; +using Costasdev.Busurbano.Backend.Types; using Costasdev.Busurbano.Backend.Types.Arrivals; using Costasdev.VigoTransitApi; +using Microsoft.Extensions.Options; namespace Costasdev.Busurbano.Backend.Services.Processors; -public class VitrasaRealTimeProcessor : IArrivalsProcessor +public class VitrasaRealTimeProcessor : AbstractRealTimeProcessor { private readonly VigoTransitApiClient _api; private readonly FeedService _feedService; private readonly ILogger _logger; + private readonly ShapeTraversalService _shapeService; + private readonly AppConfiguration _configuration; - public VitrasaRealTimeProcessor(HttpClient http, FeedService feedService, ILogger logger) + public VitrasaRealTimeProcessor( + HttpClient http, + FeedService feedService, + ILogger logger, + ShapeTraversalService shapeService, + IOptions options) { _api = new VigoTransitApiClient(http); _feedService = feedService; _logger = logger; + _shapeService = shapeService; + _configuration = options.Value; } - public async Task ProcessAsync(ArrivalsContext context) + public override async Task ProcessAsync(ArrivalsContext context) { if (!context.StopId.StartsWith("vitrasa:")) return; @@ -26,6 +38,15 @@ public class VitrasaRealTimeProcessor : IArrivalsProcessor try { + // Load schedule + var todayDate = context.NowLocal.Date.ToString("yyyy-MM-dd"); + + Epsg25829? stopLocation = null; + if (context.StopLocation != null) + { + stopLocation = _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); + } + var realtime = await _api.GetStopEstimates(numericStopId); var estimates = realtime.Estimates .Where(e => !string.IsNullOrWhiteSpace(e.Route) && !e.Route.Trim().EndsWith('*')) @@ -110,6 +131,85 @@ public class VitrasaRealTimeProcessor : IArrivalsProcessor arrival.Headsign.Destination = estimate.Route; } + // Calculate position + if (stopLocation != null) + { + Position? currentPosition = null; + int? stopShapeIndex = null; + + if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival && + otpArrival.Trip.Geometry?.Points != null) + { + var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) + .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) + .ToList(); + + var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); + + // Ensure meters is positive + var meters = Math.Max(0, estimate.Meters); + var result = _shapeService.GetBusPosition(shape, stopLocation, meters); + + currentPosition = result.BusPosition; + stopShapeIndex = result.StopIndex; + + if (currentPosition != null) + { + _logger.LogInformation("Calculated position from OTP geometry for trip {TripId}: {Lat}, {Lon}", arrival.TripId, currentPosition.Latitude, currentPosition.Longitude); + } + + // Populate Shape GeoJSON + if (!context.IsReduced && currentPosition != null) + { + var features = new List(); + features.Add(new + { + type = "Feature", + geometry = new + { + type = "LineString", + coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList() + }, + properties = new { type = "route" } + }); + + // Add stops if available + if (otpArrival.Trip.Stoptimes != null) + { + foreach (var stoptime in otpArrival.Trip.Stoptimes) + { + features.Add(new + { + type = "Feature", + geometry = new + { + type = "Point", + coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } + }, + properties = new + { + type = "stop", + name = stoptime.Stop.Name + } + }); + } + } + + arrival.Shape = new + { + type = "FeatureCollection", + features = features + }; + } + } + + if (currentPosition != null) + { + arrival.CurrentPosition = currentPosition; + arrival.StopShapeIndex = stopShapeIndex; + } + } + usedTripIds.Add(arrival.TripId); } else @@ -126,9 +226,10 @@ public class VitrasaRealTimeProcessor : IArrivalsProcessor TripId = $"vitrasa:rt:{estimate.Line}:{estimate.Route}:{estimate.Minutes}", Route = new RouteInfo { + GtfsId = $"vitrasa:{estimate.Line}", ShortName = estimate.Line, Colour = template?.Route.Colour ?? "FFFFFF", - TextColour = template?.Route.TextColour ?? "000000" + TextColour = template?.Route.TextColour ?? "000000", }, Headsign = new HeadsignInfo { diff --git a/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs b/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs index 63f4a2e..c3c66f4 100644 --- a/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs @@ -95,6 +95,26 @@ public class ShapeTraversalService return FindClosestPointIndex(shape.Points, location); } + public Shape CreateShapeFromWgs84(List points) + { + var shape = new Shape(); + var inverseTransform = _transformation.MathTransform.Inverse(); + + foreach (var point in points) + { + var transformed = inverseTransform.Transform(new[] { point.Longitude, point.Latitude }); + shape.Points.Add(new Epsg25829 { X = transformed[0], Y = transformed[1] }); + } + return shape; + } + + public Epsg25829 TransformToEpsg25829(double lat, double lon) + { + var inverseTransform = _transformation.MathTransform.Inverse(); + var transformed = inverseTransform.Transform(new[] { lon, lat }); + return new Epsg25829 { X = transformed[0], Y = transformed[1] }; + } + /// /// Calculates the bus position by reverse-traversing the shape from the stop location /// diff --git a/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs b/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs index 65ef606..f13babf 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Costasdev.Busurbano.Backend.Types; namespace Costasdev.Busurbano.Backend.Types.Arrivals; @@ -25,6 +26,12 @@ public class Arrival [JsonPropertyName("shape")] public object? Shape { get; set; } + [JsonPropertyName("currentPosition")] + public Position? CurrentPosition { get; set; } + + [JsonPropertyName("stopShapeIndex")] + public int? StopShapeIndex { get; set; } + [JsonIgnore] public List NextStops { get; set; } = []; @@ -34,6 +41,11 @@ public class Arrival public class RouteInfo { + [JsonPropertyName("gtfsId")] + public required string GtfsId { get; set; } + + public string RouteIdInGtfs => GtfsId.Split(':', 2)[1]; + [JsonPropertyName("shortName")] public required string ShortName { get; set; } diff --git a/src/Costasdev.Busurbano.Backend/Types/Arrivals/StopArrivalsResponse.cs b/src/Costasdev.Busurbano.Backend/Types/Arrivals/StopArrivalsResponse.cs index 8c5438c..9a2cec7 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Arrivals/StopArrivalsResponse.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Arrivals/StopArrivalsResponse.cs @@ -10,6 +10,12 @@ public class StopArrivalsResponse [JsonPropertyName("stopName")] public required string StopName { get; set; } + [JsonPropertyName("stopLocation")] + public Position? StopLocation { get; set; } + + [JsonPropertyName("routes")] + public List Routes { get; set; } = []; + [JsonPropertyName("arrivals")] public List Arrivals { get; set; } = []; } diff --git a/src/Costasdev.Busurbano.Sources.TranviasCoruna/CorunaRealtimeEstimatesProvider.cs b/src/Costasdev.Busurbano.Sources.TranviasCoruna/CorunaRealtimeEstimatesProvider.cs new file mode 100644 index 0000000..4bc7ef1 --- /dev/null +++ b/src/Costasdev.Busurbano.Sources.TranviasCoruna/CorunaRealtimeEstimatesProvider.cs @@ -0,0 +1,50 @@ +using System.Net.Http.Json; + +namespace Costasdev.Busurbano.Sources.TranviasCoruna; + +public class CorunaRealtimeEstimatesProvider +{ + private HttpClient _http; + + public CorunaRealtimeEstimatesProvider(HttpClient http) + { + _http = http; + } + + public async Task> GetEstimatesForStop(int stopId) + { + var url = GetRequestUrl(stopId.ToString()); + + var response = await _http.GetAsync(url); + var queryitrResponse = await response.Content.ReadFromJsonAsync(); + + if (queryitrResponse is null) + { + var responseString = await response.Content.ReadAsStringAsync(); + throw new Exception("Error parsing queryitr_v3 response: " + responseString); + } + + return queryitrResponse.ArrivalInfo.Routes.SelectMany(r => + { + return r.Arrivals.Select(arrival => + { + var minutes = arrival.Minutes == "<1" ? 0 : int.Parse(arrival.Minutes); + + return new CorunaEstimate + ( + r.RouteId.ToString(), + minutes, + int.Parse(arrival.Metres), + arrival.VehicleNumber.ToString() + ); + }).ToList(); + }).OrderBy(a => a.Minutes).ToList(); + } + + private string GetRequestUrl(string stopId) + { + return $"https://itranvias.com/queryitr_v3.php?&func=0&dato={stopId}"; + } +} + +public record CorunaEstimate(string RouteId, int Minutes, int Metres, string VehicleNumber); diff --git a/src/Costasdev.Busurbano.Sources.TranviasCoruna/Costasdev.Busurbano.Sources.TranviasCoruna.csproj b/src/Costasdev.Busurbano.Sources.TranviasCoruna/Costasdev.Busurbano.Sources.TranviasCoruna.csproj new file mode 100644 index 0000000..237d661 --- /dev/null +++ b/src/Costasdev.Busurbano.Sources.TranviasCoruna/Costasdev.Busurbano.Sources.TranviasCoruna.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Costasdev.Busurbano.Sources.TranviasCoruna/Response.cs b/src/Costasdev.Busurbano.Sources.TranviasCoruna/Response.cs new file mode 100644 index 0000000..fe2a6cf --- /dev/null +++ b/src/Costasdev.Busurbano.Sources.TranviasCoruna/Response.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Costasdev.Busurbano.Sources.TranviasCoruna; + +public class QueryitrResponse +{ + [JsonPropertyName("buses")] public ArrivalInfo ArrivalInfo { get; set; } +} + +public class ArrivalInfo +{ + [JsonPropertyName("parada")] + public int StopId { get; set; } + [JsonPropertyName("lineas")] + public Route[] Routes { get; set; } +} + +public class Route +{ + [JsonPropertyName("linea")] + public int RouteId { get; set; } + [JsonPropertyName("buses")] + public Arrival[] Arrivals { get; set; } +} + +public class Arrival +{ + [JsonPropertyName("bus")] + public int VehicleNumber { get; set; } + [JsonPropertyName("tiempo")] + public string Minutes { get; set; } + [JsonPropertyName("distancia")] + public string Metres { get; set; } +} diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index bb1e96c..9cc5bd4 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -33,17 +33,30 @@ export const ShiftBadgeSchema = z.object({ shiftTrip: z.string(), }); +export const PositionSchema = z.object({ + latitude: z.number(), + longitude: z.number(), + orientationDegrees: z.number(), + shapeIndex: z.number(), +}); + export const ArrivalSchema = z.object({ + tripId: z.string(), route: RouteInfoSchema, headsign: HeadsignInfoSchema, estimate: ArrivalDetailsSchema, delay: DelayBadgeSchema.optional().nullable(), shift: ShiftBadgeSchema.optional().nullable(), + shape: z.any().optional().nullable(), + currentPosition: PositionSchema.optional().nullable(), + stopShapeIndex: z.number().optional().nullable(), }); export const StopArrivalsResponseSchema = z.object({ stopCode: z.string(), stopName: z.string(), + stopLocation: PositionSchema.optional().nullable(), + routes: z.array(RouteInfoSchema), arrivals: z.array(ArrivalSchema), }); @@ -53,6 +66,7 @@ export type ArrivalPrecision = z.infer; export type ArrivalDetails = z.infer; export type DelayBadge = z.infer; export type ShiftBadge = z.infer; +export type Position = z.infer; export type Arrival = z.infer; export type StopArrivalsResponse = z.infer; diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx index bb6a3fa..d218af4 100644 --- a/src/frontend/app/components/StopMapModal.tsx +++ b/src/frontend/app/components/StopMapModal.tsx @@ -29,10 +29,11 @@ export interface ConsolidatedCirculationForMap { currentPosition?: Position; stopShapeIndex?: number; isPreviousTrip?: boolean; - previousTripShapeId?: string; + previousTripShapeId?: string | null; schedule?: { - shapeId?: string; + shapeId?: string | null; }; + shape?: any; } interface StopMapModalProps { @@ -70,7 +71,7 @@ export const StopMapModal: React.FC = ({ const circulation = circulations.find( (c) => c.id === selectedCirculationId ); - if (circulation?.currentPosition) { + if (circulation) { return circulation; } } @@ -97,27 +98,146 @@ export const StopMapModal: React.FC = ({ const points: { lat: number; lon: number }[] = []; - const addShapePoints = (data: any) => { - if ( - data?.properties?.busPoint && - data?.properties?.stopPoint && - data?.geometry?.coordinates - ) { - const busIdx = data.properties.busPoint.index; - const stopIdx = data.properties.stopPoint.index; - const coords = data.geometry.coordinates; - - const start = Math.min(busIdx, stopIdx); - const end = Math.max(busIdx, stopIdx); - - for (let i = start; i <= end; i++) { - points.push({ lat: coords[i][1], lon: coords[i][0] }); + const getStopsFromFeatureCollection = (data: any) => { + if (!data || data.type !== "FeatureCollection" || !data.features) + return []; + return data.features.filter((f: any) => f.properties?.type === "stop"); + }; + + const findClosestStopIndex = ( + stops: any[], + pos: { lat: number; lon: number } + ) => { + let minDst = Infinity; + let index = -1; + stops.forEach((s: any, idx: number) => { + const [lon, lat] = s.geometry.coordinates; + const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2); + if (dst < minDst) { + minDst = dst; + index = idx; + } + }); + return index; + }; + + const findClosestPointIndex = ( + coords: number[][], + pos: { lat: number; lon: number } + ) => { + let minDst = Infinity; + let index = -1; + coords.forEach((c, idx) => { + const [lon, lat] = c; + const dst = Math.pow(lat - pos.lat, 2) + Math.pow(lon - pos.lon, 2); + if (dst < minDst) { + minDst = dst; + index = idx; + } + }); + return index; + }; + + const addShapePoints = (data: any, isPrevious: boolean) => { + if (!data) return; + + if (data.type === "FeatureCollection") { + const stops = getStopsFromFeatureCollection(data); + if (stops.length === 0) return; + + let startIdx = 0; + let endIdx = stops.length - 1; + + const currentPos = selectedBus?.currentPosition; + const userStopPos = + stop.latitude && stop.longitude + ? { lat: stop.latitude, lon: stop.longitude } + : null; + + if (isPrevious) { + // Previous trip: Start from Bus, End at last stop + if (currentPos) { + const busIdx = findClosestStopIndex(stops, { + lat: currentPos.latitude, + lon: currentPos.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + } else { + // Current trip: Start from Bus (if not previous), End at User Stop + if (!previousShapeData && currentPos) { + const busIdx = findClosestStopIndex(stops, { + lat: currentPos.latitude, + lon: currentPos.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + + if (userStopPos) { + let userIdx = -1; + // Try name match + if (stop.name) { + userIdx = stops.findIndex( + (s: any) => s.properties?.name === stop.name + ); + } + // Fallback to coords + if (userIdx === -1) { + userIdx = findClosestStopIndex(stops, userStopPos); + } + if (userIdx !== -1) endIdx = userIdx; + } + } + + // Add stops in range + if (startIdx <= endIdx) { + for (let i = startIdx; i <= endIdx; i++) { + const [lon, lat] = stops[i].geometry.coordinates; + points.push({ lat, lon }); + } } + return; + } + + const coords = data?.geometry?.coordinates; + if (!coords) return; + + let startIdx = 0; + let endIdx = coords.length - 1; + let foundIndices = false; + + if (data.properties?.busPoint && data.properties?.stopPoint) { + startIdx = data.properties.busPoint.index; + endIdx = data.properties.stopPoint.index; + foundIndices = true; + } else { + // Fallback: find closest points on the line + if (selectedBus?.currentPosition) { + const busIdx = findClosestPointIndex(coords, { + lat: selectedBus.currentPosition.latitude, + lon: selectedBus.currentPosition.longitude, + }); + if (busIdx !== -1) startIdx = busIdx; + } + if (stop.latitude && stop.longitude) { + const stopIdx = findClosestPointIndex(coords, { + lat: stop.latitude, + lon: stop.longitude, + }); + if (stopIdx !== -1) endIdx = stopIdx; + } + } + + const start = Math.min(startIdx, endIdx); + const end = Math.max(startIdx, endIdx); + + for (let i = start; i <= end; i++) { + points.push({ lat: coords[i][1], lon: coords[i][0] }); } }; - addShapePoints(shapeData); - addShapePoints(previousShapeData); + addShapePoints(previousShapeData, true); + addShapePoints(shapeData, false); if (points.length === 0) { if (stop.latitude && stop.longitude) { @@ -130,6 +250,17 @@ export const StopMapModal: React.FC = ({ lon: selectedBus.currentPosition.longitude, }); } + } else { + // Ensure bus and stop are always included if available, to prevent cutting them off + if (selectedBus?.currentPosition) { + points.push({ + lat: selectedBus.currentPosition.latitude, + lon: selectedBus.currentPosition.longitude, + }); + } + if (stop.latitude && stop.longitude) { + points.push({ lat: stop.latitude, lon: stop.longitude }); + } } if (points.length === 0) return; @@ -156,7 +287,7 @@ export const StopMapModal: React.FC = ({ .getMap() .easeTo({ center: [only.lon, only.lat], zoom: 16, duration: 450 }); } else { - mapRef.current.fitBounds(bounds, { + mapRef.current.getMap().fitBounds(bounds, { padding: 80, duration: 500, maxZoom: 17, @@ -196,7 +327,7 @@ export const StopMapModal: React.FC = ({ // Fit bounds on initial load useEffect(() => { - if (!styleSpec || !mapRef.current || hasFitBounds.current || !isOpen) + if (!styleSpec || !mapRef.current || !isOpen) return; const map = mapRef.current.getMap(); @@ -238,103 +369,25 @@ export const StopMapModal: React.FC = ({ // Fetch shape for selected bus useEffect(() => { - if ( - !isOpen || - !selectedBus || - !selectedBus.schedule?.shapeId || - selectedBus.currentPosition?.shapeIndex === undefined || - !APP_CONSTANTS.shapeEndpoint - ) { + if (!isOpen || !selectedBus) { setShapeData(null); setPreviousShapeData(null); return; } - const shapeId = selectedBus.schedule.shapeId; - const shapeIndex = selectedBus.currentPosition.shapeIndex; - const stopShapeIndex = selectedBus.stopShapeIndex; - const stopLat = stop.latitude; - const stopLon = stop.longitude; - - const fetchShape = async ( - sId: string, - bIndex?: number, - sIndex?: number, - sLat?: number, - sLon?: number - ) => { - let url = `${APP_CONSTANTS.shapeEndpoint}?shapeId=${sId}`; - if (bIndex !== undefined) url += `&busShapeIndex=${bIndex}`; - if (sIndex !== undefined) url += `&stopShapeIndex=${sIndex}`; - else if (sLat && sLon) url += `&stopLat=${sLat}&stopLon=${sLon}`; - - const res = await fetch(url); - if (res.ok) return res.json(); - return null; - }; - - const loadShapes = async () => { - if (selectedBus.isPreviousTrip && selectedBus.previousTripShapeId) { - // Bus is on previous trip - // 1. Load previous shape (where bus is) - const prevData = await fetchShape( - selectedBus.previousTripShapeId, - shapeIndex, - stopShapeIndex - ); - - // 2. Load current scheduled shape (where bus is going) - // Bus is not on this shape yet, so no bus index - const currData = await fetchShape( - shapeId, - undefined, - undefined, - stopLat, - stopLon - ); - - if ( - prevData && - prevData.geometry && - prevData.geometry.coordinates && - prevData.properties?.busPoint?.index !== undefined - ) { - const busIdx = prevData.properties.busPoint.index; - const coords = prevData.geometry.coordinates; - // Slice from busIdx - 5 (clamped to 0) to end - const startIdx = Math.max(0, busIdx - 5); - const slicedCoords = coords.slice(startIdx); - - // Join with the first point of the next shape to close the gap - if (currData?.geometry?.coordinates?.length > 0) { - slicedCoords.push(currData.geometry.coordinates[0]); - } - - prevData.geometry.coordinates = slicedCoords; - } - - setPreviousShapeData(prevData); - setShapeData(currData); - } else { - // Normal case - const data = await fetchShape( - shapeId, - shapeIndex, - stopShapeIndex, - stopLat, - stopLon - ); - setShapeData(data); - setPreviousShapeData(null); - } + if (selectedBus.shape) { + setShapeData(selectedBus.shape); + setPreviousShapeData(null); handleCenter(); - }; + return; + } - loadShapes().catch((err) => console.error("Failed to load shape", err)); + setShapeData(null); + setPreviousShapeData(null); }, [isOpen, selectedBus]); - if (busesWithPosition.length === 0) { - return null; // Don't render if no buses with GPS coordinates + if (!selectedBus && busesWithPosition.length === 0) { + return null; // Don't render if no buses with GPS coordinates and no selected bus } return ( @@ -379,6 +432,9 @@ export const StopMapModal: React.FC = ({ onPitchStart={() => { userInteracted.current = true; }} + onLoad={() => { + handleCenter(); + }} > {/* Previous Shape Layer */} {previousShapeData && selectedBus && ( @@ -462,6 +518,20 @@ export const StopMapModal: React.FC = ({ "line-join": "round", }} /> + + {/* Stops Layer */} + )} diff --git a/src/frontend/app/components/arrivals/ArrivalCard.tsx b/src/frontend/app/components/arrivals/ArrivalCard.tsx index 5cfbaa3..6952f8f 100644 --- a/src/frontend/app/components/arrivals/ArrivalCard.tsx +++ b/src/frontend/app/components/arrivals/ArrivalCard.tsx @@ -1,5 +1,6 @@ import { AlertTriangle, LocateIcon } from "lucide-react"; -import React, { useMemo } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import Marquee from "react-fast-marquee"; import { useTranslation } from "react-i18next"; import LineIcon from "~/components/LineIcon"; import { type Arrival } from "../../api/schema"; @@ -7,9 +8,56 @@ import "./ArrivalCard.css"; interface ArrivalCardProps { arrival: Arrival; + onClick?: () => void; } -export const ReducedArrivalCard: React.FC = ({ arrival }) => { +const AutoMarquee = ({ text }: { text: string }) => { + const containerRef = useRef(null); + const [shouldScroll, setShouldScroll] = useState(false); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const checkScroll = () => { + const charWidth = 8; + const availableWidth = el.offsetWidth; + const textWidth = text.length * charWidth; + setShouldScroll(textWidth > availableWidth); + }; + + checkScroll(); + const observer = new ResizeObserver(checkScroll); + observer.observe(el); + return () => observer.disconnect(); + }, [text]); + + if (shouldScroll) { + return ( +
+ +
+ {text} +
+
+
+ ); + } + + return ( +
+ {text} +
+ ); +}; + +export const ArrivalCard: React.FC = ({ + arrival, + onClick, +}) => { const { t } = useTranslation(); const { route, headsign, estimate, delay, shift } = arrival; @@ -36,6 +84,14 @@ export const ReducedArrivalCard: React.FC = ({ arrival }) => { kind?: "regular" | "gps" | "delay" | "warning"; }> = []; + // Badge/Shift info as a chip + if (headsign.badge) { + chips.push({ + label: headsign.badge, + kind: "regular", + }); + } + // Delay chip if (delay) { const delta = Math.round(delay.minutes); @@ -43,7 +99,7 @@ export const ReducedArrivalCard: React.FC = ({ arrival }) => { if (delta === 0) { chips.push({ - label: "OK", + label: t("estimates.delay_on_time"), tone: "delay-ok", kind: "delay", }); @@ -55,14 +111,14 @@ export const ReducedArrivalCard: React.FC = ({ arrival }) => { ? "delay-warn" : "delay-critical"; chips.push({ - label: `R${delta}`, + label: t("estimates.delay_positive", { minutes: delta }), tone, kind: "delay", }); } else { const tone = absDelta <= 2 ? "delay-ok" : "delay-early"; chips.push({ - label: `A${absDelta}`, + label: t("estimates.delay_negative", { minutes: absDelta }), tone, kind: "delay", }); @@ -80,23 +136,42 @@ export const ReducedArrivalCard: React.FC = ({ arrival }) => { // Precision chips if (estimate.precision === "unsure") { chips.push({ - label: "!", + label: t("estimates.low_accuracy"), tone: "warning", kind: "warning", }); } else if (estimate.precision === "confident") { chips.push({ - label: "", // Just the icon for reduced + label: t("estimates.bus_gps_position"), kind: "gps", }); } + if (estimate.precision === "scheduled") { + chips.push({ + label: t("estimates.no_realtime"), + tone: "warning", + kind: "warning", + }); + } + return chips; - }, [delay, shift, estimate.precision]); + }, [delay, shift, estimate.precision, t, headsign.badge]); + + const isClickable = !!onClick && estimate.precision !== "past"; + const Tag = isClickable ? "button" : "div"; return ( -
-
+ +
= ({ arrival }) => { />
- - {headsign.destination} - - {metaChips.length > 0 && ( -
- {metaChips.map((chip, idx) => { - let chipColourClasses = ""; - switch (chip.tone) { - case "delay-ok": - chipColourClasses = - "bg-green-600/20 dark:bg-green-600/30 text-green-700 dark:text-green-300"; - break; - case "delay-warn": - chipColourClasses = - "bg-amber-600/20 dark:bg-yellow-600/30 text-amber-700 dark:text-yellow-300"; - break; - case "delay-critical": - chipColourClasses = - "bg-red-400/20 dark:bg-red-600/30 text-red-600 dark:text-red-300"; - break; - case "delay-early": - chipColourClasses = - "bg-blue-400/20 dark:bg-blue-600/30 text-blue-700 dark:text-blue-300"; - break; - case "warning": - chipColourClasses = - "bg-orange-400/20 dark:bg-orange-600/30 text-orange-700 dark:text-orange-300"; - break; - default: - chipColourClasses = - "bg-black/[0.06] dark:bg-white/[0.12] text-slate-600 dark:text-slate-400"; - } - - return ( - - {chip.kind === "gps" && ( - - )} - {chip.kind === "warning" && ( - - )} - {chip.label} - - ); - })} +
+
+ + {headsign.destination} + + {headsign.marquee && ( +
+ +
+ )} +
+
+
+ {etaValue} + + {etaUnit} + +
- )} -
-
-
- {etaValue} - - {etaUnit} - +
+ +
+ {metaChips.map((chip, idx) => { + let chipColourClasses = ""; + switch (chip.tone) { + case "delay-ok": + chipColourClasses = + "bg-green-600/10 dark:bg-green-600/20 text-green-700 dark:text-green-300"; + break; + case "delay-warn": + chipColourClasses = + "bg-amber-600/10 dark:bg-yellow-600/20 text-amber-700 dark:text-yellow-300"; + break; + case "delay-critical": + chipColourClasses = + "bg-red-400/10 dark:bg-red-600/20 text-red-600 dark:text-red-300"; + break; + case "delay-early": + chipColourClasses = + "bg-blue-400/10 dark:bg-blue-600/20 text-blue-700 dark:text-blue-300"; + break; + case "warning": + chipColourClasses = + "bg-orange-400/10 dark:bg-orange-600/20 text-orange-700 dark:text-orange-300"; + break; + default: + chipColourClasses = + "bg-black/[0.04] dark:bg-white/[0.08] text-slate-500 dark:text-slate-400"; + } + + return ( + + {chip.kind === "gps" && ( + + )} + {chip.kind === "warning" && ( + + )} + {chip.label} + + ); + })}
-
+ ); }; diff --git a/src/frontend/app/components/arrivals/ArrivalList.tsx b/src/frontend/app/components/arrivals/ArrivalList.tsx index b2394fb..0186682 100644 --- a/src/frontend/app/components/arrivals/ArrivalList.tsx +++ b/src/frontend/app/components/arrivals/ArrivalList.tsx @@ -1,25 +1,38 @@ import React from "react"; import { type Arrival } from "../../api/schema"; -import { ReducedArrivalCard } from "./ArrivalCard"; +import { ArrivalCard } from "./ArrivalCard"; +import { ReducedArrivalCard } from "./ReducedArrivalCard"; interface ArrivalListProps { arrivals: Arrival[]; reduced?: boolean; + onArrivalClick?: (arrival: Arrival) => void; } export const ArrivalList: React.FC = ({ arrivals, reduced, + onArrivalClick, }) => { + const clickable = Boolean(onArrivalClick); + return (
- {arrivals.map((arrival, index) => ( - - ))} + {arrivals.map((arrival, index) => + reduced ? ( + onArrivalClick?.(arrival) : undefined} + /> + ) : ( + onArrivalClick?.(arrival) : undefined} + /> + ) + )}
); }; diff --git a/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx b/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx new file mode 100644 index 0000000..2c1ea20 --- /dev/null +++ b/src/frontend/app/components/arrivals/ReducedArrivalCard.tsx @@ -0,0 +1,198 @@ +import { AlertTriangle, LocateIcon } from "lucide-react"; +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import LineIcon from "~/components/LineIcon"; +import { type Arrival } from "../../api/schema"; +import "./ArrivalCard.css"; + +interface ArrivalCardProps { + arrival: Arrival; + onClick?: () => void; +} + +export const ReducedArrivalCard: React.FC = ({ + arrival, + onClick, +}) => { + const { t } = useTranslation(); + const { route, headsign, estimate, delay, shift } = arrival; + + const etaValue = estimate.minutes.toString(); + const etaUnit = t("estimates.minutes", "min"); + + const timeClass = useMemo(() => { + switch (estimate.precision) { + case "confident": + return "time-running"; + case "unsure": + return "time-delayed"; + case "past": + return "time-past"; + default: + return "time-scheduled"; + } + }, [estimate.precision]); + + const metaChips = useMemo(() => { + const chips: Array<{ + label: string; + tone?: string; + kind?: "regular" | "gps" | "delay" | "warning"; + }> = []; + + // Badge/Shift info as a chip + if (headsign.badge) { + chips.push({ + label: headsign.badge, + kind: "regular", + }); + } + + // Delay chip + if (delay) { + const delta = Math.round(delay.minutes); + const absDelta = Math.abs(delta); + + if (delta === 0) { + chips.push({ + label: "OK", + tone: "delay-ok", + kind: "delay", + }); + } else if (delta > 0) { + const tone = + delta <= 2 + ? "delay-ok" + : delta <= 10 + ? "delay-warn" + : "delay-critical"; + chips.push({ + label: `R${delta}`, + tone, + kind: "delay", + }); + } else { + const tone = absDelta <= 2 ? "delay-ok" : "delay-early"; + chips.push({ + label: `A${absDelta}`, + tone, + kind: "delay", + }); + } + } + + // Shift chip + if (shift) { + chips.push({ + label: `${shift.shiftName} · ${shift.shiftTrip}`, + kind: "regular", + }); + } + + // Precision chips + if (estimate.precision === "unsure") { + chips.push({ + label: "!", + tone: "warning", + kind: "warning", + }); + } else if (estimate.precision === "confident") { + chips.push({ + label: "", // Just the icon for reduced + kind: "gps", + }); + } + + return chips; + }, [delay, shift, estimate.precision, headsign.badge]); + + const isClickable = !!onClick && estimate.precision !== "past"; + const Tag = isClickable ? "button" : "div"; + + return ( + +
+ +
+
+ + {headsign.destination} + + {metaChips.length > 0 && ( +
+ {metaChips.map((chip, idx) => { + let chipColourClasses = ""; + switch (chip.tone) { + case "delay-ok": + chipColourClasses = + "bg-green-600/10 dark:bg-green-600/20 text-green-700 dark:text-green-300"; + break; + case "delay-warn": + chipColourClasses = + "bg-amber-600/10 dark:bg-yellow-600/20 text-amber-700 dark:text-yellow-300"; + break; + case "delay-critical": + chipColourClasses = + "bg-red-400/10 dark:bg-red-600/20 text-red-600 dark:text-red-300"; + break; + case "delay-early": + chipColourClasses = + "bg-blue-400/10 dark:bg-blue-600/20 text-blue-700 dark:text-blue-300"; + break; + case "warning": + chipColourClasses = + "bg-orange-400/10 dark:bg-orange-600/20 text-orange-700 dark:text-orange-300"; + break; + default: + chipColourClasses = + "bg-black/[0.04] dark:bg-white/[0.08] text-slate-500 dark:text-slate-400"; + } + + return ( + + {chip.kind === "gps" && ( + + )} + {chip.kind === "warning" && ( + + )} + {chip.label} + + ); + })} +
+ )} +
+
+
+ {etaValue} + + {etaUnit} + +
+
+
+ ); +}; diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index 46358dc..7adcef2 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -2,50 +2,22 @@ import { CircleHelp, Eye, EyeClosed, Star } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router"; +import { fetchArrivals } from "~/api/arrivals"; +import { type Arrival, type Position, type RouteInfo } from "~/api/schema"; +import { ArrivalList } from "~/components/arrivals/ArrivalList"; import { ErrorDisplay } from "~/components/ErrorDisplay"; import LineIcon from "~/components/LineIcon"; import { PullToRefresh } from "~/components/PullToRefresh"; -import { StopAlert } from "~/components/StopAlert"; import { StopHelpModal } from "~/components/StopHelpModal"; import { StopMapModal } from "~/components/StopMapModal"; -import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList"; import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton"; -import { APP_CONSTANTS } from "~/config/constants"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useAutoRefresh } from "~/hooks/useAutoRefresh"; -import StopDataProvider, { type Stop } from "../data/StopDataProvider"; +import StopDataProvider from "../data/StopDataProvider"; import "./stops-$id.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[]; -} - -export const getCirculationId = (c: ConsolidatedCirculation): string => { - if (c.schedule?.tripId) { - return `trip:${c.schedule.tripId}`; - } - return `rt:${c.line}:${c.route}:${c.realTime?.minutes ?? "?"}`; +export const getArrivalId = (a: Arrival): string => { + return a.tripId; }; interface ErrorInfo { @@ -54,59 +26,18 @@ interface ErrorInfo { message?: string; } -const loadConsolidatedData = async ( - stopId: string -): Promise => { - const resp = await fetch( - `${APP_CONSTANTS.consolidatedCirculationsEndpoint}?stopId=${stopId}`, - { - headers: { - Accept: "application/json", - }, - } - ); - - if (!resp.ok) { - throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); - } - - return await resp.json(); -}; - -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[]; -} - export default function Estimates() { const { t } = useTranslation(); const params = useParams(); const stopId = params.id ?? ""; - const [customName, setCustomName] = useState(undefined); - const [stopData, setStopData] = useState(undefined); + const [stopName, setStopName] = useState(undefined); + const [apiRoutes, setApiRoutes] = useState([]); + const [apiLocation, setApiLocation] = useState( + undefined + ); // Data state - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [dataDate, setDataDate] = useState(null); const [dataLoading, setDataLoading] = useState(true); const [dataError, setDataError] = useState(null); @@ -116,16 +47,15 @@ export default function Estimates() { const [isMapModalOpen, setIsMapModalOpen] = useState(false); const [isHelpModalOpen, setIsHelpModalOpen] = useState(false); const [isReducedView, setIsReducedView] = useState(false); - const [selectedCirculationId, setSelectedCirculationId] = useState< + const [selectedArrivalId, setSelectedArrivalId] = useState< string | undefined >(undefined); // Helper function to get the display name for the stop const getStopDisplayName = useCallback(() => { - if (customName) return customName; - if (stopData?.name) return stopData.name; + if (stopName) return stopName; return `Parada ${stopId}`; - }, [customName, stopData, stopId]); + }, [stopId, stopName]); usePageTitle(getStopDisplayName()); @@ -154,16 +84,16 @@ export default function Estimates() { try { setDataError(null); - const body = await loadConsolidatedData(stopId); - setData(body); + const response = await fetchArrivals(stopId, false); + setData(response.arrivals); + setStopName(response.stopName); + setApiRoutes(response.routes); + if (response.stopLocation) { + setApiLocation(response.stopLocation); + } setDataDate(new Date()); - - // Load stop data from StopDataProvider - const stop = await StopDataProvider.getStopById(stopId); - setStopData(stop); - setCustomName(StopDataProvider.getCustomName(stopId)); } catch (error) { - console.error("Error loading consolidated data:", error); + console.error("Error loading arrivals data:", error); setDataError(parseError(error)); setData(null); setDataDate(null); @@ -214,17 +144,22 @@ export default function Estimates() { return (
- {stopData && stopData.lines && stopData.lines.length > 0 && ( + {apiRoutes.length > 0 && (
- {stopData.lines.map((line) => ( -
- + {apiRoutes.map((line) => ( +
+
))}
)} - {stopData && } + {/*{stopData && }*/}
{dataLoading ? ( @@ -281,12 +216,11 @@ export default function Estimates() { )}
- { - setSelectedCirculationId(getCirculationId(estimate)); + onArrivalClick={(arrival) => { + setSelectedArrivalId(getArrivalId(arrival)); setIsMapModalOpen(true); }} /> @@ -294,25 +228,29 @@ export default function Estimates() { ) : null}
- {stopData && ( + {apiLocation && ( ({ - id: getCirculationId(c), - line: c.line, - route: c.route, - currentPosition: c.currentPosition, - isPreviousTrip: c.isPreviousTrip, - previousTripShapeId: c.previousTripShapeId, - schedule: c.schedule - ? { - shapeId: c.schedule.shapeId, - } - : undefined, + stop={{ + stopId: stopId, + name: stopName ?? "", + latitude: apiLocation?.latitude, + longitude: apiLocation?.longitude, + lines: [], + }} + circulations={(data ?? []).map((a) => ({ + id: getArrivalId(a), + line: a.route.shortName, + route: a.headsign.destination, + currentPosition: a.currentPosition ?? undefined, + stopShapeIndex: a.stopShapeIndex ?? undefined, + schedule: { + shapeId: undefined, + }, + shape: a.shape, }))} isOpen={isMapModalOpen} onClose={() => setIsMapModalOpen(false)} - selectedCirculationId={selectedCirculationId} + selectedCirculationId={selectedArrivalId} /> )} -- cgit v1.3