aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-23 21:33:17 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-23 21:33:17 +0100
commit4a866f5352a51916ddb9849b2d68213856196c9c (patch)
tree3ba01ba01d5f6931adaf708b76ffccdd798fc78b
parent87417c313b455ba0dee19708528cc8d0b830a276 (diff)
Full real-time page, coruña real time
-rw-r--r--Costasdev.Busurbano.slnx1
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs20
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/TileController.cs3
-rw-r--r--src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj2
-rw-r--r--src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs24
-rw-r--r--src/Costasdev.Busurbano.Backend/Program.cs4
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs3
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/FeedService.cs21
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs56
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs189
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs37
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs109
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs20
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs12
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Arrivals/StopArrivalsResponse.cs6
-rw-r--r--src/Costasdev.Busurbano.Sources.TranviasCoruna/CorunaRealtimeEstimatesProvider.cs50
-rw-r--r--src/Costasdev.Busurbano.Sources.TranviasCoruna/Costasdev.Busurbano.Sources.TranviasCoruna.csproj9
-rw-r--r--src/Costasdev.Busurbano.Sources.TranviasCoruna/Response.cs34
-rw-r--r--src/frontend/app/api/schema.ts14
-rw-r--r--src/frontend/app/components/StopMapModal.tsx286
-rw-r--r--src/frontend/app/components/arrivals/ArrivalCard.tsx231
-rw-r--r--src/frontend/app/components/arrivals/ArrivalList.tsx29
-rw-r--r--src/frontend/app/components/arrivals/ReducedArrivalCard.tsx198
-rw-r--r--src/frontend/app/routes/stops-$id.tsx174
24 files changed, 1212 insertions, 320 deletions
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 @@
<Solution>
+ <Project Path="src/Costasdev.Busurbano.Sources.TranviasCoruna/Costasdev.Busurbano.Sources.TranviasCoruna.csproj" />
<Project Path="src\Costasdev.Busurbano.Backend\Costasdev.Busurbano.Backend.csproj" />
<Project Path="src\Costasdev.Busurbano.ServiceViewer\Costasdev.Busurbano.ServiceViewer.csproj" />
<Project Path="src\frontend\frontend.esproj" />
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 @@
</ItemGroup>
<ItemGroup>
- <Folder Include="wwwroot\" />
+ <ProjectReference Include="..\Costasdev.Busurbano.Sources.TranviasCoruna\Costasdev.Busurbano.Sources.TranviasCoruna.csproj" />
</ItemGroup>
</Project>
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<ArrivalsAtStopContent.Args>
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<ArrivalsAtStopContent.Args>
serviceId
routeShortName
route {{
+ gtfsId
color
textColor
longName
@@ -42,6 +51,8 @@ public class ArrivalsAtStopContent : IGraphRequest<ArrivalsAtStopContent.Args>
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<RouteDetails> Routes { get; set; } = [];
+
[JsonPropertyName("arrivals")] public List<Arrival> 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<ShapeTraversalService>();
builder.Services.AddSingleton<FeedService>();
builder.Services.AddScoped<IArrivalsProcessor, VitrasaRealTimeProcessor>();
+builder.Services.AddScoped<IArrivalsProcessor, CorunaRealTimeProcessor>();
+
builder.Services.AddScoped<IArrivalsProcessor, FilterAndSortProcessor>();
builder.Services.AddScoped<IArrivalsProcessor, NextStopsProcessor>();
builder.Services.AddScoped<IArrivalsProcessor, MarqueeProcessor>();
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
/// </summary>
public bool IsReduced { get; set; }
+ public Position? StopLocation { get; set; }
+
public required List<Arrival> 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<CorunaRealTimeProcessor> _logger;
+ private readonly ShapeTraversalService _shapeService;
+
+ public CorunaRealTimeProcessor(
+ HttpClient http,
+ FeedService feedService,
+ ILogger<CorunaRealTimeProcessor> 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<string>();
+ var newArrivals = new List<Arrival>();
+
+ 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<object>();
+ 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<object>();
+
+ // 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<VitrasaRealTimeProcessor> _logger;
+ private readonly ShapeTraversalService _shapeService;
+ private readonly AppConfiguration _configuration;
- public VitrasaRealTimeProcessor(HttpClient http, FeedService feedService, ILogger<VitrasaRealTimeProcessor> logger)
+ public VitrasaRealTimeProcessor(
+ HttpClient http,
+ FeedService feedService,
+ ILogger<VitrasaRealTimeProcessor> logger,
+ ShapeTraversalService shapeService,
+ IOptions<AppConfiguration> 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<object>();
+ 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<Position> 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] };
+ }
+
/// <summary>
/// Calculates the bus position by reverse-traversing the shape from the stop location
/// </summary>
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<string> 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<RouteInfo> Routes { get; set; } = [];
+
[JsonPropertyName("arrivals")]
public List<Arrival> 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<List<CorunaEstimate>> GetEstimatesForStop(int stopId)
+ {
+ var url = GetRequestUrl(stopId.ToString());
+
+ var response = await _http.GetAsync(url);
+ var queryitrResponse = await response.Content.ReadFromJsonAsync<QueryitrResponse>();
+
+ 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 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+</Project>
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<typeof ArrivalPrecisionSchema>;
export type ArrivalDetails = z.infer<typeof ArrivalDetailsSchema>;
export type DelayBadge = z.infer<typeof DelayBadgeSchema>;
export type ShiftBadge = z.infer<typeof ShiftBadgeSchema>;
+export type Position = z.infer<typeof PositionSchema>;
export type Arrival = z.infer<typeof ArrivalSchema>;
export type StopArrivalsResponse = z.infer<typeof StopArrivalsResponseSchema>;
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<StopMapModalProps> = ({
const circulation = circulations.find(
(c) => c.id === selectedCirculationId
);
- if (circulation?.currentPosition) {
+ if (circulation) {
return circulation;
}
}
@@ -97,27 +98,146 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
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 getStopsFromFeatureCollection = (data: any) => {
+ if (!data || data.type !== "FeatureCollection" || !data.features)
+ return [];
+ return data.features.filter((f: any) => f.properties?.type === "stop");
+ };
- const start = Math.min(busIdx, stopIdx);
- const end = Math.max(busIdx, stopIdx);
+ 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;
+ };
- for (let i = start; i <= end; i++) {
- points.push({ lat: coords[i][1], lon: coords[i][0] });
+ 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<StopMapModalProps> = ({
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<StopMapModalProps> = ({
.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<StopMapModalProps> = ({
// 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<StopMapModalProps> = ({
// 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<StopMapModalProps> = ({
onPitchStart={() => {
userInteracted.current = true;
}}
+ onLoad={() => {
+ handleCenter();
+ }}
>
{/* Previous Shape Layer */}
{previousShapeData && selectedBus && (
@@ -462,6 +518,20 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
"line-join": "round",
}}
/>
+
+ {/* Stops Layer */}
+ <Layer
+ id="route-stops"
+ type="circle"
+ filter={["==", "type", "stop"]}
+ paint={{
+ "circle-color": "#FFFFFF",
+ "circle-radius": 4,
+ "circle-stroke-width": 2,
+ "circle-stroke-color": getLineColour(selectedBus.line)
+ .background,
+ }}
+ />
</Source>
)}
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<ArrivalCardProps> = ({ arrival }) => {
+const AutoMarquee = ({ text }: { text: string }) => {
+ const containerRef = useRef<HTMLDivElement>(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 (
+ <div ref={containerRef} className="w-full overflow-hidden">
+ <Marquee speed={40} gradient={false}>
+ <div className="mr-32 text-xs font-mono text-slate-500 dark:text-slate-400">
+ {text}
+ </div>
+ </Marquee>
+ </div>
+ );
+ }
+
+ return (
+ <div
+ ref={containerRef}
+ className="w-full overflow-hidden text-xs font-mono text-slate-500 dark:text-slate-400 truncate"
+ >
+ {text}
+ </div>
+ );
+};
+
+export const ArrivalCard: React.FC<ArrivalCardProps> = ({
+ arrival,
+ onClick,
+}) => {
const { t } = useTranslation();
const { route, headsign, estimate, delay, shift } = arrival;
@@ -36,6 +84,14 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ 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<ArrivalCardProps> = ({ 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<ArrivalCardProps> = ({ 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<ArrivalCardProps> = ({ 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 (
- <div className="flex-none flex items-center gap-2.5 min-h-12 rounded px-3 py-2.5 transition-all bg-slate-50 dark:bg-slate-800 shadow-sm">
- <div className="shrink-0 min-w-[7ch]">
+ <Tag
+ type={isClickable ? "button" : undefined}
+ onClick={isClickable ? onClick : undefined}
+ className={`w-full text-left flex items-start gap-3 rounded-xl px-3 py-3 transition-all bg-slate-50 dark:bg-slate-800 border border-gray-200 dark:border-gray-700 shadow-sm ${
+ isClickable
+ ? "hover:border-blue-400 dark:hover:border-blue-500 active:scale-[0.98] cursor-pointer"
+ : ""
+ }`}
+ >
+ <div className="shrink-0 min-w-[7ch] mt-0.5">
<LineIcon
line={route.shortName}
colour={route.colour}
@@ -105,72 +180,80 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => {
/>
</div>
<div className="flex-1 min-w-0 flex flex-col gap-1">
- <span
- className={`text-base font-medium overflow-hidden text-ellipsis line-clamp-2 leading-tight ${estimate.precision == "past" ? "line-through" : ""}`}
- >
- {headsign.destination}
- </span>
- {metaChips.length > 0 && (
- <div className="flex items-center gap-1 flex-wrap">
- {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 (
- <span
- key={`${chip.label}-${idx}`}
- className={`text-xs px-2.5 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 font-medium tracking-wide ${chipColourClasses}`}
- >
- {chip.kind === "gps" && (
- <LocateIcon className="w-3 h-3 my-0.5 inline-block" />
- )}
- {chip.kind === "warning" && (
- <AlertTriangle className="w-3 h-3 my-0.5 inline-block" />
- )}
- {chip.label}
- </span>
- );
- })}
+ <div className="flex justify-between items-start gap-2">
+ <div className="flex-1 min-w-0">
+ <span
+ className={`text-base font-bold overflow-hidden text-ellipsis line-clamp-2 leading-tight text-slate-900 dark:text-slate-100 ${estimate.precision == "past" ? "line-through opacity-60" : ""}`}
+ >
+ {headsign.destination}
+ </span>
+ {headsign.marquee && (
+ <div className="mt-0.5">
+ <AutoMarquee text={headsign.marquee} />
+ </div>
+ )}
+ </div>
+ <div
+ className={`
+ inline-flex items-center justify-center px-2 py-1 rounded-lg shrink-0 min-w-12
+ ${timeClass}
+ `.trim()}
+ >
+ <div className="flex flex-col items-center leading-none">
+ <span className="text-lg font-bold">{etaValue}</span>
+ <span className="text-[0.55rem] font-bold uppercase tracking-tighter opacity-80">
+ {etaUnit}
+ </span>
+ </div>
</div>
- )}
- </div>
- <div
- className={`
- inline-flex items-center justify-center px-2 py-1.5 rounded-xl shrink-0
- ${timeClass}
- `.trim()}
- >
- <div className="flex flex-col items-center leading-none">
- <span className="text-lg font-bold leading-none">{etaValue}</span>
- <span className="text-[0.65rem] uppercase tracking-wider mt-0.5 opacity-90">
- {etaUnit}
- </span>
+ </div>
+
+ <div className="flex items-center gap-0.5 flex-wrap">
+ {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 (
+ <span
+ key={`${chip.label}-${idx}`}
+ className={`text-xs px-2.5 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 font-medium tracking-wide ${chipColourClasses}`}
+ >
+ {chip.kind === "gps" && (
+ <LocateIcon className="w-2.5 h-2.5 inline-block" />
+ )}
+ {chip.kind === "warning" && (
+ <AlertTriangle className="w-2.5 h-2.5 inline-block" />
+ )}
+ {chip.label}
+ </span>
+ );
+ })}
</div>
</div>
- </div>
+ </Tag>
);
};
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<ArrivalListProps> = ({
arrivals,
reduced,
+ onArrivalClick,
}) => {
+ const clickable = Boolean(onArrivalClick);
+
return (
<div className="flex flex-col gap-3">
- {arrivals.map((arrival, index) => (
- <ReducedArrivalCard
- key={`${arrival.route.shortName}-${index}`}
- arrival={arrival}
- reduced={reduced}
- />
- ))}
+ {arrivals.map((arrival, index) =>
+ reduced ? (
+ <ReducedArrivalCard
+ key={`${arrival.tripId}-${index}`}
+ arrival={arrival}
+ onClick={clickable ? () => onArrivalClick?.(arrival) : undefined}
+ />
+ ) : (
+ <ArrivalCard
+ key={`${arrival.tripId}-${index}`}
+ arrival={arrival}
+ onClick={clickable ? () => onArrivalClick?.(arrival) : undefined}
+ />
+ )
+ )}
</div>
);
};
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<ArrivalCardProps> = ({
+ 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 (
+ <Tag
+ onClick={isClickable ? onClick : undefined}
+ className={`w-full text-left flex-none flex items-center gap-3 min-h-12 rounded px-3 py-2.5 transition-all bg-slate-50 dark:bg-slate-800 border border-gray-200 dark:border-gray-700 ${
+ isClickable
+ ? "hover:border-blue-400 dark:hover:border-blue-500 active:scale-[0.99] cursor-pointer"
+ : ""
+ }`}
+ >
+ <div className="shrink-0 min-w-[7ch] mt-0.5">
+ <LineIcon
+ line={route.shortName}
+ colour={route.colour}
+ textColour={route.textColour}
+ mode="pill"
+ />
+ </div>
+ <div className="flex-1 min-w-0 flex flex-col gap-0.5">
+ <span
+ className={`text-base font-medium overflow-hidden text-ellipsis line-clamp-2 leading-tight ${estimate.precision == "past" ? "line-through opacity-60" : ""}`}
+ >
+ {headsign.destination}
+ </span>
+ {metaChips.length > 0 && (
+ <div className="flex items-center gap-0.5 flex-wrap">
+ {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 (
+ <span
+ key={`${chip.label}-${idx}`}
+ className={`text-xs px-2.5 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 font-medium tracking-wide ${chipColourClasses}`}
+ >
+ {chip.kind === "gps" && (
+ <LocateIcon className="w-3 h-3 my-0.5 inline-block" />
+ )}
+ {chip.kind === "warning" && (
+ <AlertTriangle className="w-3 h-3 my-0.5 inline-block" />
+ )}
+ {chip.label}
+ </span>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ <div
+ className={`
+ inline-flex items-center justify-center px-2 py-1.5 rounded-xl shrink-0
+ ${timeClass}
+ `.trim()}
+ >
+ <div className="flex flex-col items-center leading-none">
+ <span className="text-lg font-bold leading-none">{etaValue}</span>
+ <span className="text-[0.55rem] uppercase tracking-wider mt-0.5 opacity-90">
+ {etaUnit}
+ </span>
+ </div>
+ </div>
+ </Tag>
+ );
+};
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<ConsolidatedCirculation[]> => {
- 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<string | undefined>(undefined);
- const [stopData, setStopData] = useState<Stop | undefined>(undefined);
+ const [stopName, setStopName] = useState<string | undefined>(undefined);
+ const [apiRoutes, setApiRoutes] = useState<RouteInfo[]>([]);
+ const [apiLocation, setApiLocation] = useState<Position | undefined>(
+ undefined
+ );
// Data state
- const [data, setData] = useState<ConsolidatedCirculation[] | null>(null);
+ const [data, setData] = useState<Arrival[] | null>(null);
const [dataDate, setDataDate] = useState<Date | null>(null);
const [dataLoading, setDataLoading] = useState(true);
const [dataError, setDataError] = useState<ErrorInfo | null>(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 (
<PullToRefresh onRefresh={handleManualRefresh}>
<div className="page-container stops-page">
- {stopData && stopData.lines && stopData.lines.length > 0 && (
+ {apiRoutes.length > 0 && (
<div className={`estimates-lines-container scrollable`}>
- {stopData.lines.map((line) => (
- <div key={line} className="estimates-line-icon">
- <LineIcon line={line} mode="rounded" />
+ {apiRoutes.map((line) => (
+ <div key={line.shortName} className="estimates-line-icon">
+ <LineIcon
+ line={line.shortName}
+ colour={line.colour}
+ textColour={line.textColour}
+ mode="pill"
+ />
</div>
))}
</div>
)}
- {stopData && <StopAlert stop={stopData} />}
+ {/*{stopData && <StopAlert stop={stopData} />}*/}
<div className="estimates-list-container">
{dataLoading ? (
@@ -281,12 +216,11 @@ export default function Estimates() {
)}
</div>
</div>
- <ConsolidatedCirculationList
- data={data}
+ <ArrivalList
+ arrivals={data}
reduced={isReducedView}
- driver={stopData?.stopId.split(":")[0]}
- onCirculationClick={(estimate, idx) => {
- setSelectedCirculationId(getCirculationId(estimate));
+ onArrivalClick={(arrival) => {
+ setSelectedArrivalId(getArrivalId(arrival));
setIsMapModalOpen(true);
}}
/>
@@ -294,25 +228,29 @@ export default function Estimates() {
) : null}
</div>
- {stopData && (
+ {apiLocation && (
<StopMapModal
- stop={stopData}
- circulations={(data ?? []).map((c) => ({
- 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}
/>
)}