diff options
Diffstat (limited to 'src/Costasdev.Busurbano.Backend')
13 files changed, 806 insertions, 74 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs index 7158137..934935e 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs @@ -1,6 +1,7 @@ using System.Net; using Costasdev.Busurbano.Backend.GraphClient; using Costasdev.Busurbano.Backend.GraphClient.App; +using Costasdev.Busurbano.Backend.Services; using Costasdev.Busurbano.Backend.Types.Arrivals; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; @@ -14,16 +15,22 @@ public partial class ArrivalsController : ControllerBase private readonly ILogger<ArrivalsController> _logger; private readonly IMemoryCache _cache; private readonly HttpClient _httpClient; + private readonly ArrivalsPipeline _pipeline; + private readonly FeedService _feedService; public ArrivalsController( ILogger<ArrivalsController> logger, IMemoryCache cache, - HttpClient httpClient + HttpClient httpClient, + ArrivalsPipeline pipeline, + FeedService feedService ) { _logger = logger; _cache = cache; _httpClient = httpClient; + _pipeline = pipeline; + _feedService = feedService; } [HttpGet("arrivals")] @@ -37,11 +44,7 @@ public partial class ArrivalsController : ControllerBase var todayLocal = nowLocal.Date; var requestContent = ArrivalsAtStopContent.Query( - new ArrivalsAtStopContent.Args( - id, - reduced ? 4 : 10, - ShouldFetchPastArrivals(id) - ) + new ArrivalsAtStopContent.Args(id, reduced) ); var request = new HttpRequestMessage(HttpMethod.Post, "http://100.67.54.115:3957/otp/gtfs/v1"); @@ -60,10 +63,26 @@ public partial class ArrivalsController : ControllerBase } var stop = responseBody.Data.Stop; + _logger.LogInformation("Fetched {Count} arrivals for stop {StopName} ({StopId})", stop.Arrivals.Count, stop.Name, id); + List<Arrival> arrivals = []; foreach (var item in stop.Arrivals) { - var departureTime = todayLocal.AddSeconds(item.ScheduledDepartureSeconds); + if (item.PickupTypeParsed.Equals(ArrivalsAtStopResponse.PickupType.None)) + { + continue; + } + + if (item.Trip.Geometry?.Points != null) + { + _logger.LogDebug("Trip {TripId} has geometry", item.Trip.GtfsId); + } + + // Calculate departure time using the service day in the feed's timezone (Europe/Madrid) + // This ensures we treat ScheduledDepartureSeconds as relative to the local midnight of the service day + var serviceDayLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(item.ServiceDay), tz); + var departureTime = serviceDayLocal.Date.AddSeconds(item.ScheduledDepartureSeconds); + var minutesToArrive = (int)(departureTime - nowLocal).TotalMinutes; //var isRunning = departureTime < nowLocal; @@ -89,17 +108,29 @@ public partial class ArrivalsController : ControllerBase Estimate = new ArrivalDetails { Minutes = minutesToArrive, - Precision = departureTime < nowLocal ? ArrivalPrecision.Past : ArrivalPrecision.Scheduled - } + Precision = departureTime < nowLocal.AddMinutes(-1) ? ArrivalPrecision.Past : ArrivalPrecision.Scheduled + }, + RawOtpTrip = item }; arrivals.Add(arrival); } - return Ok(new StopArrivalsResponse + await _pipeline.ExecuteAsync(new ArrivalsContext { + StopId = id, StopCode = stop.Code, - StopName = stop.Name, + IsReduced = reduced, + Arrivals = arrivals, + NowLocal = nowLocal + }); + + var feedId = id.Split(':')[0]; + + return Ok(new StopArrivalsResponse + { + StopCode = _feedService.NormalizeStopCode(feedId, stop.Code), + StopName = _feedService.NormalizeStopName(feedId, stop.Name), Arrivals = arrivals }); } diff --git a/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs b/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs index 6354d67..0e9d21b 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using System.Text.Json; using Costasdev.Busurbano.Backend.Helpers; +using Costasdev.Busurbano.Backend.Services; namespace Costasdev.Busurbano.Backend.Controllers; @@ -20,29 +21,21 @@ public class TileController : ControllerBase private readonly ILogger<TileController> _logger; private readonly IMemoryCache _cache; private readonly HttpClient _httpClient; + private readonly FeedService _feedService; public TileController( ILogger<TileController> logger, IMemoryCache cache, - HttpClient httpClient + HttpClient httpClient, + FeedService feedService ) { _logger = logger; _cache = cache; _httpClient = httpClient; + _feedService = feedService; } - private static readonly string[] HiddenStops = - [ - "vitrasa:20223", // Castrelos (Pavillón - U1) - "vitrasa:20146", // García Barbón, 7 (A, 18A) - "vitrasa:20220", // COIA-SAMIL (15) - "vitrasa:20001", // Samil por Beiramar (15B) - "vitrasa:20002", // Samil por Torrecedeira (15C) - "vitrasa:20144", // Samil por Coia (C3d, C3i) - "vitrasa:20145" // Samil por Bouzs (C3d, C3i) - ]; - [HttpGet("stops/{z:int}/{x:int}/{y:int}")] public async Task<IActionResult> Stops(int z, int x, int y) { @@ -97,24 +90,14 @@ public class TileController : ControllerBase { var idParts = stop.GtfsId.Split(':', 2); string feedId = idParts[0]; - string codeWithinFeed = stop.Code ?? string.Empty; + string codeWithinFeed = _feedService.NormalizeStopCode(feedId, stop.Code ?? string.Empty); - // TODO: Refactor this, maybe do it client-side or smth - if (feedId == "vitrasa") - { - var digits = new string(codeWithinFeed.Where(char.IsDigit).ToArray()); - if (int.TryParse(digits, out int code)) - { - codeWithinFeed = code.ToString(); - } - } - - if (HiddenStops.Contains($"{feedId}:{codeWithinFeed}")) + if (_feedService.IsStopHidden($"{feedId}:{codeWithinFeed}")) { return; } - var (Color, TextColor) = GetFallbackColourForFeed(idParts[0]); + var (Color, TextColor) = _feedService.GetFallbackColourForFeed(idParts[0]); var distinctRoutes = GetDistinctRoutes(feedId, stop.Routes ?? []); Feature feature = new() @@ -129,7 +112,7 @@ public class TileController : ControllerBase // The public identifier, usually feed:code or feed:id, recognisable by users and in other systems { "code", $"{idParts[0]}:{codeWithinFeed}" }, // The name of the stop - { "name", stop.Name }, + { "name", _feedService.NormalizeStopName(feedId, stop.Name) }, // Routes { "routes", JsonSerializer .Serialize( @@ -176,21 +159,15 @@ public class TileController : ControllerBase return File(ms.ToArray(), "application/x-protobuf"); } - private static List<StopTileResponse.Route> GetDistinctRoutes(string feedId, List<StopTileResponse.Route> routes) + private List<StopTileResponse.Route> GetDistinctRoutes(string feedId, List<StopTileResponse.Route> routes) { List<StopTileResponse.Route> distinctRoutes = []; HashSet<string> seen = new(); foreach (var route in routes) { - var seenId = route.ShortName; - if (feedId == "xunta") - { - // For Xunta routes we take only the contract number (XG123, for example) - seenId = seenId.Substring(0, 5); - - route.ShortName = seenId; - } + var seenId = _feedService.NormalizeRouteShortName(feedId, route.ShortName ?? string.Empty); + route.ShortName = seenId; if (seen.Contains(seenId)) { @@ -206,19 +183,4 @@ public class TileController : ControllerBase Comparer<string?>.Create(SortingHelper.SortRouteShortNames) )]; } - - private static (string Color, string TextColor) GetFallbackColourForFeed(string feed) - { - return feed switch - { - "vitrasa" => ("#95D516", "#000000"), - "santiago" => ("#508096", "#FFFFFF"), - "coruna" => ("#E61C29", "#FFFFFF"), - "xunta" => ("#007BC4", "#FFFFFF"), - "renfe" => ("#870164", "#FFFFFF"), - "feve" => ("#EE3D32", "#FFFFFF"), - _ => ("#000000", "#FFFFFF"), - - }; - } } diff --git a/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs index 2c34784..cf2907c 100644 --- a/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs +++ b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs @@ -5,28 +5,25 @@ namespace Costasdev.Busurbano.Backend.GraphClient.App; public class ArrivalsAtStopContent : IGraphRequest<ArrivalsAtStopContent.Args> { - public const int PastArrivalMinutesIncluded = -15; + public const int PastArrivalMinutesIncluded = -75; - public record Args(string Id, int DepartureCount, bool PastArrivals); + public record Args(string Id, bool Reduced); public static string Query(Args args) { - var startTime = DateTimeOffset.Now; - if (args.PastArrivals) - { - startTime = DateTimeOffset.Now.AddMinutes(PastArrivalMinutesIncluded); - } - + var startTime = DateTimeOffset.UtcNow.AddMinutes(PastArrivalMinutesIncluded); var startTimeUnix = startTime.ToUnixTimeSeconds(); + var geometryField = args.Reduced ? "" : @"tripGeometry { points }"; return string.Create(CultureInfo.InvariantCulture, $@" query Query {{ stop(id:""{args.Id}"") {{ code name - arrivals: stoptimesWithoutPatterns(numberOfDepartures:{args.DepartureCount}, startTime: {startTimeUnix}) {{ + arrivals: stoptimesWithoutPatterns(numberOfDepartures: 100, startTime: {startTimeUnix}, timeRange: 14400) {{ headsign scheduledDeparture + serviceDay pickupType trip {{ @@ -36,10 +33,18 @@ public class ArrivalsAtStopContent : IGraphRequest<ArrivalsAtStopContent.Args> route {{ color textColor + longName }} departureStoptime {{ scheduledDeparture }} + {geometryField} + stoptimes {{ + stop {{ + name + }} + scheduledDeparture + }} }} }} }} @@ -68,9 +73,12 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse [JsonPropertyName("scheduledDeparture")] public int ScheduledDepartureSeconds { get; set; } + [JsonPropertyName("serviceDay")] + public long ServiceDay { get; set; } + [JsonPropertyName("pickupType")] public required string PickupTypeOriginal { get; set; } - public PickupType PickupTypeParsed => PickupTypeParsed.Parse(PickupTypeOriginal); + public PickupType PickupTypeParsed => PickupType.Parse(PickupTypeOriginal); [JsonPropertyName("trip")] public required TripDetails Trip { get; set; } } @@ -87,6 +95,26 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse public required DepartureStoptime DepartureStoptime { get; set; } [JsonPropertyName("route")] public required RouteDetails Route { get; set; } + + [JsonPropertyName("tripGeometry")] public GeometryDetails? Geometry { get; set; } + + [JsonPropertyName("stoptimes")] public List<StoptimeDetails> Stoptimes { get; set; } = []; + } + + public class GeometryDetails + { + [JsonPropertyName("points")] public string? Points { get; set; } + } + + public class StoptimeDetails + { + [JsonPropertyName("stop")] public required StopDetails Stop { get; set; } + [JsonPropertyName("scheduledDeparture")] public int ScheduledDeparture { get; set; } + } + + public class StopDetails + { + [JsonPropertyName("name")] public required string Name { get; set; } } public class DepartureStoptime @@ -100,6 +128,8 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse [JsonPropertyName("color")] public string? Color { get; set; } [JsonPropertyName("textColor")] public string? TextColor { get; set; } + + [JsonPropertyName("longName")] public string? LongName { get; set; } } public class PickupType @@ -111,7 +141,7 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse _value = value; } - public PickupType Parse(string value) + public static PickupType Parse(string value) { return value switch { diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs index 70372e8..c34f00e 100644 --- a/src/Costasdev.Busurbano.Backend/Program.cs +++ b/src/Costasdev.Busurbano.Backend/Program.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; using Costasdev.Busurbano.Backend.Configuration; using Costasdev.Busurbano.Backend.Services; +using Costasdev.Busurbano.Backend.Services.Processors; using Costasdev.Busurbano.Backend.Services.Providers; var builder = WebApplication.CreateBuilder(args); @@ -17,6 +18,15 @@ 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, FilterAndSortProcessor>(); +builder.Services.AddScoped<IArrivalsProcessor, NextStopsProcessor>(); +builder.Services.AddScoped<IArrivalsProcessor, MarqueeProcessor>(); +builder.Services.AddScoped<IArrivalsProcessor, ShapeProcessor>(); +builder.Services.AddScoped<IArrivalsProcessor, FeedConfigProcessor>(); +builder.Services.AddScoped<ArrivalsPipeline>(); builder.Services.AddHttpClient<OtpService>(); builder.Services.AddScoped<VitrasaTransitProvider>(); diff --git a/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs b/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs new file mode 100644 index 0000000..8699a1e --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs @@ -0,0 +1,58 @@ +using Costasdev.Busurbano.Backend.Types.Arrivals; + +namespace Costasdev.Busurbano.Backend.Services; + +public class ArrivalsContext +{ + /// <summary> + /// The full GTFS ID of the stop (e.g., "vitrasa:1400") + /// </summary> + public required string StopId { get; set; } + + /// <summary> + /// The public code of the stop (e.g., "1400") + /// </summary> + public required string StopCode { get; set; } + + /// <summary> + /// Whether to return a reduced number of arrivals (e.g., 4 instead of 10) + /// </summary> + public bool IsReduced { get; set; } + + public required List<Arrival> Arrivals { get; set; } + public required DateTime NowLocal { get; set; } +} + +public interface IArrivalsProcessor +{ + /// <summary> + /// Processes the arrivals in the context. Processors are executed in the order they are registered. + /// </summary> + Task ProcessAsync(ArrivalsContext context); +} + +/// <summary> +/// Orchestrates the enrichment of arrival data through a series of processors. +/// This follows a pipeline pattern where each step (processor) adds or modifies data +/// in the shared ArrivalsContext. +/// </summary> +public class ArrivalsPipeline +{ + private readonly IEnumerable<IArrivalsProcessor> _processors; + + public ArrivalsPipeline(IEnumerable<IArrivalsProcessor> processors) + { + _processors = processors; + } + + /// <summary> + /// Executes all registered processors sequentially. + /// </summary> + public async Task ExecuteAsync(ArrivalsContext context) + { + foreach (var processor in _processors) + { + await processor.ProcessAsync(context); + } + } +} diff --git a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs new file mode 100644 index 0000000..48f9338 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs @@ -0,0 +1,189 @@ +using System.Text.RegularExpressions; +using Costasdev.Busurbano.Backend.Types.Arrivals; + +namespace Costasdev.Busurbano.Backend.Services; + +public class FeedService +{ + private static readonly Regex RemoveQuotationMarks = new(@"[""”]", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex StreetNameRegex = new(@"^(.*?)(?:,|\s\s|\s-\s| \d| S\/N|\s\()", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Dictionary<string, string> NameReplacements = new(StringComparer.OrdinalIgnoreCase) + { + { "Rúa da Salguera Entrada", "Rúa da Salgueira" }, + { "Rúa da Salgueira Entrada", "Rúa da Salgueira" }, + { "Estrada de Miraflores", "Estrada Miraflores" }, + { "FORA DE SERVIZO.G.B.", "" }, + { "Praza de Fernando O Católico", "" }, + { "Rúa da Travesía de Vigo", "Travesía de Vigo" }, + { "Rúa de ", " " }, + { "Rúa do ", " " }, + { "Rúa da ", " " }, + { "Rúa das ", " " }, + { "Avda. de ", " " }, + { "Avda. do ", " " }, + { "Avda. da ", " " }, + { "Avda. das ", " " }, + { "Riós", "Ríos" }, + { "Avda. Beiramar Porto Pesqueiro Berbés", "Berbés" }, + { "Conde de Torrecedeira", "Torrecedeira" } + }; + + public (string Color, string TextColor) GetFallbackColourForFeed(string feed) + { + return feed switch + { + "vitrasa" => ("#95D516", "#000000"), + "santiago" => ("#508096", "#FFFFFF"), + "coruna" => ("#E61C29", "#FFFFFF"), + "xunta" => ("#007BC4", "#FFFFFF"), + "renfe" => ("#870164", "#FFFFFF"), + "feve" => ("#EE3D32", "#FFFFFF"), + _ => ("#000000", "#FFFFFF"), + }; + } + + public string NormalizeStopCode(string feedId, string code) + { + if (feedId == "vitrasa") + { + var digits = new string(code.Where(char.IsDigit).ToArray()); + if (int.TryParse(digits, out int numericCode)) + { + return numericCode.ToString(); + } + } + return code; + } + + public string NormalizeRouteShortName(string feedId, string shortName) + { + if (feedId == "xunta" && shortName.StartsWith("XG") && shortName.Length >= 8) + { + // XG817014 -> 817.14 + var contract = shortName.Substring(2, 3); + var lineStr = shortName.Substring(5); + if (int.TryParse(lineStr, out int line)) + { + return $"{contract}.{line}"; + } + } + return shortName; + } + + public string NormalizeStopName(string feedId, string name) + { + if (feedId == "vitrasa") + { + return name + .Replace("\"", "") + .Replace(" ", ", ") + .Trim(); + } + + return name; + } + + public string NormalizeRouteNameForMatching(string name) + { + var normalized = name.Trim().ToLowerInvariant(); + // Remove diacritics/accents + normalized = Regex.Replace(normalized.Normalize(System.Text.NormalizationForm.FormD), @"\p{Mn}", ""); + // Keep only alphanumeric + return Regex.Replace(normalized, @"[^a-z0-9]", ""); + } + + public string GetStreetName(string originalName) + { + var name = RemoveQuotationMarks.Replace(originalName, "").Trim(); + var match = StreetNameRegex.Match(name); + var streetName = match.Success ? match.Groups[1].Value : name; + + foreach (var replacement in NameReplacements) + { + if (streetName.Contains(replacement.Key, StringComparison.OrdinalIgnoreCase)) + { + streetName = streetName.Replace(replacement.Key, replacement.Value, StringComparison.OrdinalIgnoreCase); + return streetName.Trim(); + } + } + + return streetName.Trim(); + } + + public string? GenerateMarquee(string feedId, List<string> nextStops) + { + if (nextStops.Count == 0) return null; + + if (feedId == "vitrasa") + { + var streets = nextStops + .Select(GetStreetName) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .Distinct() + .ToList(); + + return string.Join(" - ", streets); + } + + return feedId switch + { + "xunta" => string.Join(" > ", nextStops), + _ => string.Join(", ", nextStops.Take(4)) + }; + } + + public bool IsStopHidden(string stopId) + { + return HiddenStops.Contains(stopId); + } + + public ShiftBadge? GetShiftBadge(string feedId, string tripId) + { + if (feedId != "vitrasa") return null; + + // Example: C1 04LN 02_001004_4 + var parts = tripId.Split('_'); + if (parts.Length < 2) return null; + + var shiftGroup = parts[parts.Length - 2]; // 001004 + var tripNumber = parts[parts.Length - 1]; // 4 + + if (shiftGroup.Length != 6) return null; + + if (!int.TryParse(shiftGroup.Substring(0, 3), out var routeNum)) return null; + if (!int.TryParse(shiftGroup.Substring(3, 3), out var shiftNum)) return null; + + var routeName = routeNum switch + { + 1 => "C1", + 3 => "C3", + 30 => "N1", + 33 => "N4", + 8 => "A", + 101 => "H", + 201 => "U1", + 202 => "U2", + 150 => "REF", + 500 => "TUR", + _ => $"L{routeNum}" + }; + + return new ShiftBadge + { + ShiftName = $"{routeName}-{shiftNum}", + ShiftTrip = tripNumber + }; + } + + private static readonly string[] HiddenStops = + [ + "vitrasa:20223", // Castrelos (Pavillón - U1) + "vitrasa:20146", // García Barbón, 7 (A, 18A) + "vitrasa:20220", // COIA-SAMIL (15) + "vitrasa:20001", // Samil por Beiramar (15B) + "vitrasa:20002", // Samil por Torrecedeira (15C) + "vitrasa:20144", // Samil por Coia (C3d, C3i) + "vitrasa:20145" // Samil por Bouzs (C3d, C3i) + ]; +} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs new file mode 100644 index 0000000..fde3e0a --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs @@ -0,0 +1,84 @@ +using Costasdev.Busurbano.Backend.Helpers; +using Costasdev.Busurbano.Backend.Types.Arrivals; + +namespace Costasdev.Busurbano.Backend.Services.Processors; + +public class FeedConfigProcessor : IArrivalsProcessor +{ + private readonly FeedService _feedService; + + public FeedConfigProcessor(FeedService feedService) + { + _feedService = feedService; + } + + public Task ProcessAsync(ArrivalsContext context) + { + var feedId = context.StopId.Split(':')[0]; + var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); + + foreach (var arrival in context.Arrivals) + { + arrival.Route.ShortName = _feedService.NormalizeRouteShortName(feedId, arrival.Route.ShortName); + arrival.Headsign.Destination = _feedService.NormalizeStopName(feedId, arrival.Headsign.Destination); + + // Apply Vitrasa-specific line formatting + if (feedId == "vitrasa") + { + FormatVitrasaLine(arrival); + arrival.Shift = _feedService.GetShiftBadge(feedId, arrival.TripId); + } + + if (string.IsNullOrEmpty(arrival.Route.Colour) || arrival.Route.Colour == "FFFFFF") + { + arrival.Route.Colour = fallbackColor; + arrival.Route.TextColour = fallbackTextColor; + } + else if (string.IsNullOrEmpty(arrival.Route.TextColour) || arrival.Route.TextColour == "000000") + { + arrival.Route.TextColour = ContrastHelper.GetBestTextColour(arrival.Route.Colour); + } + } + + return Task.CompletedTask; + } + + private static void FormatVitrasaLine(Arrival arrival) + { + arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("*", ""); + + if (arrival.Headsign.Destination == "FORA DE SERVIZO.G.B.") + { + arrival.Headsign.Destination = "García Barbón, 7 (fora de servizo)"; + return; + } + + switch (arrival.Route.ShortName) + { + case "A" when arrival.Headsign.Destination.StartsWith("\"1\""): + arrival.Route.ShortName = "A1"; + arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"1\"", ""); + break; + case "6": + arrival.Headsign.Destination = arrival.Headsign.Destination.Replace("\"", ""); + break; + case "FUT": + if (arrival.Headsign.Destination == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO") + { + arrival.Route.ShortName = "MAR"; + arrival.Headsign.Destination = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO"; + } + else if (arrival.Headsign.Destination == "P. ESPAÑA-T.VIGO-S.BADÍA") + { + arrival.Route.ShortName = "RIO"; + arrival.Headsign.Destination = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA"; + } + else if (arrival.Headsign.Destination == "NAVIA-BOUZAS-URZAIZ-G. ESPINO") + { + arrival.Route.ShortName = "GOL"; + arrival.Headsign.Destination = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO"; + } + break; + } + } +} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs new file mode 100644 index 0000000..c209db5 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs @@ -0,0 +1,44 @@ +using Costasdev.Busurbano.Backend.Types.Arrivals; + +namespace Costasdev.Busurbano.Backend.Services.Processors; + +/// <summary> +/// Filters and sorts the arrivals based on the feed and the requested limit. +/// This should run after real-time matching but before heavy enrichment (shapes, marquee). +/// </summary> +public class FilterAndSortProcessor : IArrivalsProcessor +{ + public Task ProcessAsync(ArrivalsContext context) + { + var feedId = context.StopId.Split(':')[0]; + + // 1. Sort by minutes + var sorted = context.Arrivals + .OrderBy(a => a.Estimate.Minutes) + .ToList(); + + // 2. Filter based on feed rules + var filtered = sorted.Where(a => + { + if (feedId == "vitrasa") + { + // For Vitrasa, we hide past arrivals because we have real-time + // If a past arrival was matched to a real-time estimate, its Minutes will be >= 0 + return a.Estimate.Minutes >= 0; + } + + // For others, show up to 10 minutes ago + return a.Estimate.Minutes >= -10; + }).ToList(); + + // 3. Limit results + var limit = context.IsReduced ? 4 : 10; + var limited = filtered.Take(limit).ToList(); + + // Update the context list in-place + context.Arrivals.Clear(); + context.Arrivals.AddRange(limited); + + return Task.CompletedTask; + } +} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs new file mode 100644 index 0000000..ec65493 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs @@ -0,0 +1,26 @@ +namespace Costasdev.Busurbano.Backend.Services.Processors; + +public class MarqueeProcessor : IArrivalsProcessor +{ + private readonly FeedService _feedService; + + public MarqueeProcessor(FeedService feedService) + { + _feedService = feedService; + } + + public Task ProcessAsync(ArrivalsContext context) + { + var feedId = context.StopId.Split(':')[0]; + + foreach (var arrival in context.Arrivals) + { + if (string.IsNullOrEmpty(arrival.Headsign.Marquee)) + { + arrival.Headsign.Marquee = _feedService.GenerateMarquee(feedId, arrival.NextStops); + } + } + + return Task.CompletedTask; + } +} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs new file mode 100644 index 0000000..6273e0d --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs @@ -0,0 +1,34 @@ +using Costasdev.Busurbano.Backend.GraphClient.App; + +namespace Costasdev.Busurbano.Backend.Services.Processors; + +public class NextStopsProcessor : IArrivalsProcessor +{ + private readonly FeedService _feedService; + + public NextStopsProcessor(FeedService feedService) + { + _feedService = feedService; + } + + public Task ProcessAsync(ArrivalsContext context) + { + var feedId = context.StopId.Split(':')[0]; + + foreach (var arrival in context.Arrivals) + { + if (arrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival) continue; + + // Filter stoptimes that are after the current stop's departure + var currentStopDeparture = otpArrival.ScheduledDepartureSeconds; + + arrival.NextStops = otpArrival.Trip.Stoptimes + .Where(s => s.ScheduledDeparture > currentStopDeparture) + .OrderBy(s => s.ScheduledDeparture) + .Select(s => _feedService.NormalizeStopName(feedId, s.Stop.Name)) + .ToList(); + } + + return Task.CompletedTask; + } +} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs new file mode 100644 index 0000000..300ce70 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs @@ -0,0 +1,97 @@ +using Costasdev.Busurbano.Backend.GraphClient.App; + +namespace Costasdev.Busurbano.Backend.Services.Processors; + +public class ShapeProcessor : IArrivalsProcessor +{ + private readonly ILogger<ShapeProcessor> _logger; + + public ShapeProcessor(ILogger<ShapeProcessor> logger) + { + _logger = logger; + } + + public Task ProcessAsync(ArrivalsContext context) + { + if (context.IsReduced) + { + return Task.CompletedTask; + } + + foreach (var arrival in context.Arrivals) + { + if (arrival.RawOtpTrip is not ArrivalsAtStopResponse.Arrival otpArrival) continue; + + var encodedPoints = otpArrival.Trip.Geometry?.Points; + if (string.IsNullOrEmpty(encodedPoints)) + { + _logger.LogDebug("No geometry found for trip {TripId}", arrival.TripId); + continue; + } + + try + { + var points = Decode(encodedPoints); + if (points.Count == 0) continue; + + arrival.Shape = new + { + type = "Feature", + geometry = new + { + type = "LineString", + coordinates = points.Select(p => new[] { p.Lon, p.Lat }).ToList() + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error decoding shape for trip {TripId}", arrival.TripId); + } + } + + return Task.CompletedTask; + } + + private static List<(double Lat, double Lon)> Decode(string encodedPoints) + { + 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) + { + sum = 0; + shifter = 0; + do + { + next5bits = (int)polylineChars[index++] - 63; + sum |= (next5bits & 31) << shifter; + shifter += 5; + } while (next5bits >= 32 && index < polylineChars.Length); + + currentLat += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1); + + 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/VitrasaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs new file mode 100644 index 0000000..7c98cfb --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs @@ -0,0 +1,158 @@ +using Costasdev.Busurbano.Backend.GraphClient.App; +using Costasdev.Busurbano.Backend.Types.Arrivals; +using Costasdev.VigoTransitApi; + +namespace Costasdev.Busurbano.Backend.Services.Processors; + +public class VitrasaRealTimeProcessor : IArrivalsProcessor +{ + private readonly VigoTransitApiClient _api; + private readonly FeedService _feedService; + private readonly ILogger<VitrasaRealTimeProcessor> _logger; + + public VitrasaRealTimeProcessor(HttpClient http, FeedService feedService, ILogger<VitrasaRealTimeProcessor> logger) + { + _api = new VigoTransitApiClient(http); + _feedService = feedService; + _logger = logger; + } + + public async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("vitrasa:")) return; + + var normalizedCode = _feedService.NormalizeStopCode("vitrasa", context.StopCode); + if (!int.TryParse(normalizedCode, out var numericStopId)) return; + + try + { + var realtime = await _api.GetStopEstimates(numericStopId); + var estimates = realtime.Estimates + .Where(e => !string.IsNullOrWhiteSpace(e.Route) && !e.Route.Trim().EndsWith('*')) + .ToList(); + + var usedTripIds = new HashSet<string>(); + var newArrivals = new List<Arrival>(); + + foreach (var estimate in estimates) + { + var estimateRouteNormalized = _feedService.NormalizeRouteNameForMatching(estimate.Route); + + var bestMatch = context.Arrivals + .Where(a => !usedTripIds.Contains(a.TripId)) + .Where(a => a.Route.ShortName.Trim() == estimate.Line.Trim()) + .Select(a => + { + var arrivalRouteNormalized = _feedService.NormalizeRouteNameForMatching(a.Headsign.Destination); + string? arrivalLongNameNormalized = null; + string? arrivalLastStopNormalized = null; + + if (a.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival) + { + if (otpArrival.Trip.Route.LongName != null) + { + arrivalLongNameNormalized = _feedService.NormalizeRouteNameForMatching(otpArrival.Trip.Route.LongName); + } + + var lastStop = otpArrival.Trip.Stoptimes.LastOrDefault(); + if (lastStop != null) + { + arrivalLastStopNormalized = _feedService.NormalizeRouteNameForMatching(lastStop.Stop.Name); + } + } + + // Strict route matching logic ported from VitrasaTransitProvider + // Check against Headsign, LongName, and LastStop + var routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalRouteNormalized); + + if (!routeMatch && arrivalLongNameNormalized != null) + { + routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalLongNameNormalized); + } + + if (!routeMatch && arrivalLastStopNormalized != null) + { + routeMatch = IsRouteMatch(estimateRouteNormalized, arrivalLastStopNormalized); + } + + return new + { + Arrival = a, + TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule + RouteMatch = routeMatch + }; + }) + .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) + { + var arrival = bestMatch.Arrival; + _logger.LogInformation("Matched Vitrasa 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 }; + } + + // Prefer real-time headsign if available and different + if (!string.IsNullOrWhiteSpace(estimate.Route)) + { + arrival.Headsign.Destination = estimate.Route; + } + + usedTripIds.Add(arrival.TripId); + } + else + { + _logger.LogInformation("Adding unmatched Vitrasa real-time arrival for line {Line} in {Minutes}m", + estimate.Line, estimate.Minutes); + + // Try to find a "template" arrival with the same line to copy colors from + var template = context.Arrivals + .FirstOrDefault(a => a.Route.ShortName.Trim() == estimate.Line.Trim()); + + newArrivals.Add(new Arrival + { + TripId = $"vitrasa:rt:{estimate.Line}:{estimate.Route}:{estimate.Minutes}", + Route = new RouteInfo + { + ShortName = estimate.Line, + Colour = template?.Route.Colour ?? "FFFFFF", + TextColour = template?.Route.TextColour ?? "000000" + }, + Headsign = new HeadsignInfo + { + Destination = estimate.Route + }, + Estimate = new ArrivalDetails + { + Minutes = estimate.Minutes, + Precision = ArrivalPrecision.Confident + } + }); + } + } + + 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/Types/Arrivals/Arrival.cs b/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs index 516a1c5..65ef606 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Arrivals/Arrival.cs @@ -21,6 +21,15 @@ public class Arrival [JsonPropertyName("shift")] public ShiftBadge? Shift { get; set; } + + [JsonPropertyName("shape")] + public object? Shape { get; set; } + + [JsonIgnore] + public List<string> NextStops { get; set; } = []; + + [JsonIgnore] + public object? RawOtpTrip { get; set; } } public class RouteInfo @@ -78,8 +87,8 @@ public class DelayBadge public class ShiftBadge { [JsonPropertyName("shiftName")] - public string ShiftName { get; set; } + public required string ShiftName { get; set; } [JsonPropertyName("shiftTrip")] - public string ShiftTrip { get; set; } + public required string ShiftTrip { get; set; } } |
