aboutsummaryrefslogtreecommitdiff
path: root/src/Costasdev.Busurbano.Backend
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 /src/Costasdev.Busurbano.Backend
parent87417c313b455ba0dee19708528cc8d0b830a276 (diff)
Full real-time page, coruña real time
Diffstat (limited to 'src/Costasdev.Busurbano.Backend')
-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
14 files changed, 494 insertions, 12 deletions
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; } = [];
}