From a304c24b32c0327436bbd8c2853e60668e161b42 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 29 Dec 2025 00:41:52 +0100 Subject: Rename a lot of stuff, add Santiago real time --- .../Services/Processors/AbstractProcessor.cs | 56 ----- .../Services/Processors/CorunaRealTimeProcessor.cs | 184 --------------- .../Services/Processors/FeedConfigProcessor.cs | 84 ------- .../Services/Processors/FilterAndSortProcessor.cs | 44 ---- .../Services/Processors/MarqueeProcessor.cs | 26 --- .../Services/Processors/NextStopsProcessor.cs | 34 --- .../Services/Processors/ShapeProcessor.cs | 132 ----------- .../Processors/VitrasaRealTimeProcessor.cs | 254 --------------------- 8 files changed, 814 deletions(-) delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs (limited to 'src/Costasdev.Busurbano.Backend/Services/Processors') diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs deleted file mode 100644 index 343f511..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 587917e..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs +++ /dev/null @@ -1,184 +0,0 @@ -using Costasdev.Busurbano.Backend.Types; -using Costasdev.Busurbano.Backend.Types.Arrivals; -using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; -using Costasdev.Busurbano.Sources.TranviasCoruna; -using Arrival = Costasdev.Busurbano.Backend.Types.Arrivals.Arrival; - -namespace Costasdev.Busurbano.Backend.Services.Processors; - -public class CorunaRealTimeProcessor : AbstractRealTimeProcessor -{ - private readonly CorunaRealtimeEstimatesProvider _realtime; - private readonly FeedService _feedService; - private readonly ILogger _logger; - private readonly ShapeTraversalService _shapeService; - - public CorunaRealTimeProcessor( - HttpClient http, - FeedService feedService, - ILogger logger, - ShapeTraversalService shapeService) - { - _realtime = new CorunaRealtimeEstimatesProvider(http); - _feedService = feedService; - _logger = logger; - _shapeService = shapeService; - } - - public override async Task ProcessAsync(ArrivalsContext context) - { - if (!context.StopId.StartsWith("coruna:")) return; - - var normalizedCode = _feedService.NormalizeStopCode("coruna", context.StopCode); - if (!int.TryParse(normalizedCode, out var numericStopId)) return; - - try - { - // Load schedule - var todayDate = context.NowLocal.Date.ToString("yyyy-MM-dd"); - - Epsg25829? stopLocation = null; - if (context.StopLocation != null) - { - stopLocation = _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude); - } - - var realtime = await _realtime.GetEstimatesForStop(numericStopId); - - var usedTripIds = new HashSet(); - var newArrivals = new List(); - - foreach (var estimate in realtime) - { - var bestMatch = context.Arrivals - .Where(a => !usedTripIds.Contains(a.TripId)) - .Where(a => a.Route.RouteIdInGtfs.Trim() == estimate.RouteId.Trim()) - .Select(a => - { - return new - { - Arrival = a, - TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule - RouteMatch = true - }; - }) - .Where(x => x.RouteMatch) // Strict route matching - .Where(x => x.TimeDiff >= -7 && x.TimeDiff <= 75) // Allow 7m early (RealTime < Schedule) or 75m late (RealTime > Schedule) - .OrderBy(x => Math.Abs(x.TimeDiff)) // Best time fit - .FirstOrDefault(); - - if (bestMatch == null) - { - continue; - } - - var arrival = bestMatch.Arrival; - - var scheduledMinutes = arrival.Estimate.Minutes; - arrival.Estimate.Minutes = estimate.Minutes; - arrival.Estimate.Precision = ArrivalPrecision.Confident; - - // Calculate delay badge - var delayMinutes = estimate.Minutes - scheduledMinutes; - if (delayMinutes != 0) - { - arrival.Delay = new DelayBadge { Minutes = delayMinutes }; - } - - // Calculate position - if (stopLocation != null) - { - Position? currentPosition = null; - int? stopShapeIndex = null; - - if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival && - otpArrival.Trip.Geometry?.Points != null) - { - var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) - .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) - .ToList(); - - var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); - - // Ensure meters is positive - var meters = Math.Max(0, estimate.Metres); - var result = _shapeService.GetBusPosition(shape, stopLocation, meters); - - currentPosition = result.BusPosition; - stopShapeIndex = result.StopIndex; - - if (currentPosition != null) - { - _logger.LogInformation("Calculated position from OTP geometry for trip {TripId}: {Lat}, {Lon}", arrival.TripId, currentPosition.Latitude, currentPosition.Longitude); - } - - // Populate Shape GeoJSON - if (!context.IsReduced && currentPosition != null) - { - var features = new List(); - features.Add(new - { - type = "Feature", - geometry = new - { - type = "LineString", - coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList() - }, - properties = new { type = "route" } - }); - - // Add stops if available - if (otpArrival.Trip.Stoptimes != null) - { - foreach (var stoptime in otpArrival.Trip.Stoptimes) - { - features.Add(new - { - type = "Feature", - geometry = new - { - type = "Point", - coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } - }, - properties = new - { - type = "stop", - name = stoptime.Stop.Name - } - }); - } - } - - arrival.Shape = new - { - type = "FeatureCollection", - features = features - }; - } - } - - if (currentPosition != null) - { - arrival.CurrentPosition = currentPosition; - arrival.StopShapeIndex = stopShapeIndex; - } - } - - usedTripIds.Add(arrival.TripId); - - } - - context.Arrivals.AddRange(newArrivals); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching Vitrasa real-time data for stop {StopId}", context.StopId); - } - } - - private static bool IsRouteMatch(string a, string b) - { - return a == b || a.Contains(b) || b.Contains(a); - } - -} diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs deleted file mode 100644 index fde3e0a..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index c209db5..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Costasdev.Busurbano.Backend.Types.Arrivals; - -namespace Costasdev.Busurbano.Backend.Services.Processors; - -/// -/// 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). -/// -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 deleted file mode 100644 index ec65493..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index a00a68a..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; - -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 deleted file mode 100644 index 40bc508..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; - -namespace Costasdev.Busurbano.Backend.Services.Processors; - -public class ShapeProcessor : IArrivalsProcessor -{ - private readonly ILogger _logger; - - public ShapeProcessor(ILogger logger) - { - _logger = logger; - } - - public Task ProcessAsync(ArrivalsContext context) - { - if (context.IsReduced) - { - return Task.CompletedTask; - } - - 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; - 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; - - var features = new List(); - - // Route LineString - features.Add(new - { - type = "Feature", - geometry = new - { - type = "LineString", - coordinates = points.Select(p => new[] { p.Lon, p.Lat }).ToList() - }, - properties = new { type = "route" } - }); - - // Stops - if (otpArrival.Trip.Stoptimes != null) - { - foreach (var stoptime in otpArrival.Trip.Stoptimes) - { - features.Add(new - { - type = "Feature", - geometry = new - { - type = "Point", - coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } - }, - properties = new - { - type = "stop", - name = stoptime.Stop.Name - } - }); - } - } - - arrival.Shape = new - { - type = "FeatureCollection", - features = features - }; - } - catch (Exception ex) - { - _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 deleted file mode 100644 index f3a8d91..0000000 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs +++ /dev/null @@ -1,254 +0,0 @@ -using Costasdev.Busurbano.Backend.Configuration; -using Costasdev.Busurbano.Backend.Types; -using Costasdev.Busurbano.Backend.Types.Arrivals; -using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; -using Costasdev.VigoTransitApi; -using Microsoft.Extensions.Options; - -namespace Costasdev.Busurbano.Backend.Services.Processors; - -public class VitrasaRealTimeProcessor : AbstractRealTimeProcessor -{ - private readonly VigoTransitApiClient _api; - private readonly FeedService _feedService; - private readonly ILogger _logger; - private readonly ShapeTraversalService _shapeService; - private readonly AppConfiguration _configuration; - - public VitrasaRealTimeProcessor( - HttpClient http, - FeedService feedService, - ILogger logger, - ShapeTraversalService shapeService, - IOptions options) - { - _api = new VigoTransitApiClient(http); - _feedService = feedService; - _logger = logger; - _shapeService = shapeService; - _configuration = options.Value; - } - - public override 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 - { - // 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('*')) - .ToList(); - - var usedTripIds = new HashSet(); - var newArrivals = new List(); - - 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; - - var scheduledMinutes = arrival.Estimate.Minutes; - arrival.Estimate.Minutes = estimate.Minutes; - arrival.Estimate.Precision = ArrivalPrecision.Confident; - - // Calculate delay badge - var delayMinutes = estimate.Minutes - scheduledMinutes; - arrival.Delay = new DelayBadge { Minutes = delayMinutes }; - - // Prefer real-time headsign if available and different - if (!string.IsNullOrWhiteSpace(estimate.Route)) - { - arrival.Headsign.Destination = estimate.Route; - } - - // Calculate position - if (stopLocation != null) - { - Position? currentPosition = null; - int? stopShapeIndex = null; - - if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival && - otpArrival.Trip.Geometry?.Points != null) - { - var decodedPoints = Decode(otpArrival.Trip.Geometry.Points) - .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon }) - .ToList(); - - var shape = _shapeService.CreateShapeFromWgs84(decodedPoints); - - // Ensure meters is positive - var meters = Math.Max(0, estimate.Meters); - var result = _shapeService.GetBusPosition(shape, stopLocation, meters); - - currentPosition = result.BusPosition; - stopShapeIndex = result.StopIndex; - - if (currentPosition != null) - { - _logger.LogInformation("Calculated position from OTP geometry for trip {TripId}: {Lat}, {Lon}", arrival.TripId, currentPosition.Latitude, currentPosition.Longitude); - } - - // Populate Shape GeoJSON - if (!context.IsReduced && currentPosition != null) - { - var features = new List(); - features.Add(new - { - type = "Feature", - geometry = new - { - type = "LineString", - coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList() - }, - properties = new { type = "route" } - }); - - // Add stops if available - if (otpArrival.Trip.Stoptimes != null) - { - foreach (var stoptime in otpArrival.Trip.Stoptimes) - { - features.Add(new - { - type = "Feature", - geometry = new - { - type = "Point", - coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat } - }, - properties = new - { - type = "stop", - name = stoptime.Stop.Name - } - }); - } - } - - arrival.Shape = new - { - type = "FeatureCollection", - features = features - }; - } - } - - if (currentPosition != null) - { - arrival.CurrentPosition = currentPosition; - arrival.StopShapeIndex = stopShapeIndex; - } - } - - usedTripIds.Add(arrival.TripId); - } - else - { - _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 - { - GtfsId = $"vitrasa:{estimate.Line}", - 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); - } -} -- cgit v1.3