aboutsummaryrefslogtreecommitdiff
path: root/src/Enmarcha.Backend/Services
diff options
context:
space:
mode:
Diffstat (limited to 'src/Enmarcha.Backend/Services')
-rw-r--r--src/Enmarcha.Backend/Services/ArrivalsPipeline.cs61
-rw-r--r--src/Enmarcha.Backend/Services/FareService.cs225
-rw-r--r--src/Enmarcha.Backend/Services/FeedService.cs213
-rw-r--r--src/Enmarcha.Backend/Services/IGeocodingService.cs9
-rw-r--r--src/Enmarcha.Backend/Services/LineFormatterService.cs53
-rw-r--r--src/Enmarcha.Backend/Services/NominatimGeocodingService.cs101
-rw-r--r--src/Enmarcha.Backend/Services/OtpService.cs357
-rw-r--r--src/Enmarcha.Backend/Services/Processors/AbstractProcessor.cs56
-rw-r--r--src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs181
-rw-r--r--src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs84
-rw-r--r--src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs44
-rw-r--r--src/Enmarcha.Backend/Services/Processors/MarqueeProcessor.cs26
-rw-r--r--src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs34
-rw-r--r--src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs88
-rw-r--r--src/Enmarcha.Backend/Services/Processors/ShapeProcessor.cs132
-rw-r--r--src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs254
-rw-r--r--src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs8
-rw-r--r--src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs64
-rw-r--r--src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs281
-rw-r--r--src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs57
-rw-r--r--src/Enmarcha.Backend/Services/ShapeTraversalService.cs224
21 files changed, 2552 insertions, 0 deletions
diff --git a/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs b/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs
new file mode 100644
index 0000000..57a46e1
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs
@@ -0,0 +1,61 @@
+using Enmarcha.Backend.Types;
+using Enmarcha.Backend.Types.Arrivals;
+
+namespace Enmarcha.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 Position? StopLocation { 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/Enmarcha.Backend/Services/FareService.cs b/src/Enmarcha.Backend/Services/FareService.cs
new file mode 100644
index 0000000..bf85f03
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/FareService.cs
@@ -0,0 +1,225 @@
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Services.Providers;
+using Enmarcha.Backend.Types.Planner;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Services;
+
+public record FareResult(decimal CashFareEuro, bool CashFareIsTotal, decimal CardFareEuro, bool CardFareIsTotal);
+
+public class FareService
+{
+ private readonly AppConfiguration _config;
+ private readonly XuntaFareProvider _xuntaFareProvider;
+ private readonly ILogger<FareService> _logger;
+
+ private const decimal VitrasaCashFare = 1.63M;
+ private const decimal VitrasaCardFare = 0.67M;
+
+ private const decimal CorunaCashFare = 1.30M;
+ private const decimal CorunaCardFare = 0.45M;
+
+ private const decimal SantiagoCashFare = 1.00M;
+ private const decimal SantiagoCardFare = 0.36M;
+
+ public FareService(
+ IOptions<AppConfiguration> config,
+ XuntaFareProvider xuntaFareProvider,
+ ILogger<FareService> logger
+ )
+ {
+ _config = config.Value;
+ _xuntaFareProvider = xuntaFareProvider;
+ _logger = logger;
+ }
+
+ public FareResult CalculateFare(IEnumerable<Leg> legs)
+ {
+ var transitLegs = legs
+ .Where(l => l.Mode != null && !l.Mode.Equals("WALK", StringComparison.CurrentCultureIgnoreCase))
+ .ToList();
+
+ if (!transitLegs.Any())
+ {
+ return new FareResult(0, true, 0, true);
+ }
+
+ var cashResult = CalculateCashTotal(transitLegs);
+ var cardResult = CalculateCardTotal(transitLegs);
+
+ return new FareResult(
+ cashResult.Item1, cashResult.Item2,
+ cardResult.Item1, cardResult.Item2
+ );
+ }
+
+ private (decimal, bool) CalculateCashTotal(IEnumerable<Leg> legs)
+ {
+ decimal total = 0L;
+ bool allLegsProcessed = true;
+
+ foreach (var leg in legs)
+ {
+ switch (leg.FeedId)
+ {
+ case "tussa":
+ total += SantiagoCashFare;
+ break;
+ case "tranvias":
+ total += CorunaCashFare;
+ break;
+ case "vitrasa":
+ total += VitrasaCashFare;
+ break;
+ case "xunta":
+ // TODO: Handle potentiall blow-ups
+ if (leg.From is not { ZoneId: not null })
+ {
+ _logger.LogInformation("Ignored fare calculation for leg without From ZoneId. {FromStop}", leg.From?.StopId);
+ }
+
+ if (leg.To is not { ZoneId: not null })
+ {
+ _logger.LogInformation("Ignored fare calculation for leg without To ZoneId. {ToStop}", leg.To?.StopId);
+ }
+
+ total += _xuntaFareProvider.GetPrice(leg.From!.ZoneId!, leg.To!.ZoneId!)!.PriceCash;
+ break;
+ default:
+ allLegsProcessed = false;
+ _logger.LogWarning("Unknown FeedId: {FeedId}", leg.FeedId);
+ break;
+ }
+ }
+
+ return (total, allLegsProcessed);
+ }
+
+ private (decimal, bool) CalculateCardTotal(IEnumerable<Leg> legs)
+ {
+ List<TicketPurchased> wallet = [];
+ decimal totalCost = 0;
+
+ bool allLegsProcessed = true;
+
+ foreach (var leg in legs)
+ {
+ int maxMinutes;
+ int maxUsages;
+ string? metroArea = null;
+ decimal initialFare = 0;
+
+ switch (leg.FeedId)
+ {
+ case "vitrasa":
+ maxMinutes = 45;
+ maxUsages = 3;
+ initialFare = VitrasaCardFare;
+ break;
+ case "tranvias":
+ maxMinutes = 45;
+ maxUsages = 2;
+ initialFare = CorunaCardFare;
+ break;
+ case "tussa":
+ maxMinutes = 60;
+ maxUsages = 2;
+ initialFare = SantiagoCardFare;
+ break;
+ case "xunta":
+ if (leg.From?.ZoneId == null || leg.To?.ZoneId == null)
+ {
+ _logger.LogWarning("Missing ZoneId for Xunta leg. From: {From}, To: {To}", leg.From?.StopId, leg.To?.StopId);
+ continue;
+ }
+
+ var priceRecord = _xuntaFareProvider.GetPrice(leg.From.ZoneId, leg.To.ZoneId);
+ if (priceRecord == null)
+ {
+ _logger.LogWarning("No price record found for Xunta leg from {From} to {To}", leg.From.ZoneId, leg.To.ZoneId);
+ continue;
+ }
+
+ metroArea = priceRecord.MetroArea;
+ initialFare = priceRecord.PriceCard;
+ maxMinutes = 60;
+ maxUsages = (metroArea != null && metroArea.StartsWith("ATM", StringComparison.OrdinalIgnoreCase)) ? 3 : 1;
+ break;
+ default:
+ _logger.LogWarning("Unknown FeedId: {FeedId}", leg.FeedId);
+ allLegsProcessed = false;
+ continue;
+ }
+
+ var validTicket = wallet.FirstOrDefault(t => t.FeedId == leg.FeedId && t.IsValid(leg.StartTime, maxMinutes, maxUsages));
+
+ if (validTicket != null)
+ {
+ if (leg.FeedId == "xunta" && maxUsages > 1) // ATM upgrade logic
+ {
+ var upgradeRecord = _xuntaFareProvider.GetPrice(validTicket.StartZone, leg.To!.ZoneId!);
+ if (upgradeRecord != null)
+ {
+ decimal upgradeCost = Math.Max(0, upgradeRecord.PriceCard - validTicket.TotalPaid);
+ totalCost += upgradeCost;
+ validTicket.TotalPaid += upgradeCost;
+ validTicket.UsedTimes++;
+ _logger.LogDebug("Xunta ATM upgrade: added {Cost}€, total paid for ticket: {TotalPaid}€", upgradeCost, validTicket.TotalPaid);
+ }
+ else
+ {
+ // Fallback: treat as new ticket if upgrade path not found
+ totalCost += initialFare;
+ wallet.Add(new TicketPurchased
+ {
+ FeedId = leg.FeedId,
+ PurchasedAt = leg.StartTime,
+ MetroArea = metroArea,
+ StartZone = leg.From!.ZoneId!,
+ TotalPaid = initialFare
+ });
+ }
+ }
+ else
+ {
+ // Free transfer for city systems or non-ATM Xunta (though non-ATM Xunta has maxUsages=1)
+ validTicket.UsedTimes++;
+ _logger.LogDebug("Free transfer for {FeedId}", leg.FeedId);
+ }
+ }
+ else
+ {
+ // New ticket
+ totalCost += initialFare;
+ wallet.Add(new TicketPurchased
+ {
+ FeedId = leg.FeedId!,
+ PurchasedAt = leg.StartTime,
+ MetroArea = metroArea,
+ StartZone = leg.FeedId == "xunta" ? leg.From!.ZoneId! : string.Empty,
+ TotalPaid = initialFare
+ });
+ _logger.LogDebug("New ticket for {FeedId}: {Cost}€", leg.FeedId, initialFare);
+ }
+ }
+
+ return (totalCost, allLegsProcessed);
+ }
+}
+
+public class TicketPurchased
+{
+ public required string FeedId { get; set; }
+
+ public DateTime PurchasedAt { get; set; }
+ public string? MetroArea { get; set; }
+ public required string StartZone { get; set; }
+
+ public int UsedTimes = 1;
+ public decimal TotalPaid { get; set; }
+
+ public bool IsValid(DateTime startTime, int maxMinutes, int maxUsagesIncluded)
+ {
+ return (startTime - PurchasedAt).TotalMinutes <= maxMinutes && UsedTimes < maxUsagesIncluded;
+ }
+}
diff --git a/src/Enmarcha.Backend/Services/FeedService.cs b/src/Enmarcha.Backend/Services/FeedService.cs
new file mode 100644
index 0000000..8b0d3e7
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/FeedService.cs
@@ -0,0 +1,213 @@
+using System.Text.RegularExpressions;
+using Enmarcha.Backend.Types.Arrivals;
+
+namespace Enmarcha.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" },
+ { "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" },
+ { "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" => ("#81D002", "#000000"),
+ "tussa" => ("#508096", "#FFFFFF"),
+ "tranvias" => ("#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"))
+ {
+ if (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:D2}";
+ }
+ }
+ else if (shortName.Length > 2)
+ {
+ // XG883 -> 883
+ return shortName.Substring(2);
+ }
+ }
+ 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")
+ {
+ return name
+ .Trim()
+ .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 is "vitrasa" or "tranvias" or "tussa")
+ {
+ 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/Enmarcha.Backend/Services/IGeocodingService.cs b/src/Enmarcha.Backend/Services/IGeocodingService.cs
new file mode 100644
index 0000000..5c1b19e
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/IGeocodingService.cs
@@ -0,0 +1,9 @@
+using Enmarcha.Backend.Types.Planner;
+
+namespace Enmarcha.Backend.Services;
+
+public interface IGeocodingService
+{
+ Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query);
+ Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon);
+}
diff --git a/src/Enmarcha.Backend/Services/LineFormatterService.cs b/src/Enmarcha.Backend/Services/LineFormatterService.cs
new file mode 100644
index 0000000..d3b6109
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/LineFormatterService.cs
@@ -0,0 +1,53 @@
+using Enmarcha.Backend.Types;
+
+namespace Enmarcha.Backend.Services;
+
+public class LineFormatterService
+{
+ public ConsolidatedCirculation Format(ConsolidatedCirculation circulation)
+ {
+ circulation.Route = circulation.Route.Replace("*", "");
+
+ if (circulation.Route == "FORA DE SERVIZO.G.B.")
+ {
+ circulation.Route = "García Barbón, 7 (fora de servizo)";
+ return circulation;
+ }
+
+ switch (circulation.Line)
+ {
+ case "A" when circulation.Route.StartsWith("\"1\""):
+ circulation.Line = "A1";
+ circulation.Route = circulation.Route.Replace("\"1\"", "");
+ return circulation;
+ case "6":
+ circulation.Route = circulation.Route
+ .Replace("\"", "");
+ return circulation;
+ case "FUT":
+ {
+ if (circulation.Route == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO")
+ {
+ circulation.Line = "MAR";
+ circulation.Route = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO";
+ }
+
+ if (circulation.Route == "P. ESPAÑA-T.VIGO-S.BADÍA")
+ {
+ circulation.Line = "RIO";
+ circulation.Route = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA";
+ }
+
+ if (circulation.Route == "NAVIA-BOUZAS-URZAIZ-G. ESPINO")
+ {
+ circulation.Line = "GOL";
+ circulation.Route = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO";
+ }
+
+ return circulation;
+ }
+ default:
+ return circulation;
+ }
+ }
+}
diff --git a/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs b/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs
new file mode 100644
index 0000000..8c4b8a5
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs
@@ -0,0 +1,101 @@
+using System.Globalization;
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Types.Nominatim;
+using Enmarcha.Backend.Types.Planner;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Services;
+
+public class NominatimGeocodingService : IGeocodingService
+{
+ private readonly HttpClient _httpClient;
+ private readonly IMemoryCache _cache;
+ private readonly ILogger<NominatimGeocodingService> _logger;
+ private readonly AppConfiguration _config;
+
+ private const string GaliciaBounds = "-9.3,43.8,-6.7,41.7";
+
+ public NominatimGeocodingService(HttpClient httpClient, IMemoryCache cache, ILogger<NominatimGeocodingService> logger, IOptions<AppConfiguration> config)
+ {
+ _httpClient = httpClient;
+ _cache = cache;
+ _logger = logger;
+ _config = config.Value;
+
+ // Nominatim requires a User-Agent
+ if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent"))
+ {
+ _httpClient.DefaultRequestHeaders.Add("User-Agent", "Enmarcha/0.1 testing only, will replace soon. Written 2025-12-28 (https://enmarcha.app)");
+ }
+ }
+
+ public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query)
+ {
+ if (string.IsNullOrWhiteSpace(query)) return new List<PlannerSearchResult>();
+
+ var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}";
+ if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? cachedResults) && cachedResults != null)
+ {
+ return cachedResults;
+ }
+
+ try
+ {
+ var url = $"{_config.NominatimBaseUrl}/search?q={Uri.EscapeDataString(query)}&format=jsonv2&viewbox={GaliciaBounds}&bounded=1&countrycodes=es&addressdetails=1";
+ var response = await _httpClient.GetFromJsonAsync<List<NominatimSearchResult>>(url);
+
+ var results = response?.Select(MapToPlannerSearchResult).ToList() ?? new List<PlannerSearchResult>();
+
+ _cache.Set(cacheKey, results, TimeSpan.FromMinutes(30));
+ return results;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching Nominatim autocomplete results from {Url}", _config.NominatimBaseUrl);
+ return new List<PlannerSearchResult>();
+ }
+ }
+
+ public async Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon)
+ {
+ var cacheKey = $"nominatim_reverse_{lat:F5}_{lon:F5}";
+ if (_cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult) && cachedResult != null)
+ {
+ return cachedResult;
+ }
+
+ try
+ {
+ var url = $"{_config.NominatimBaseUrl}/reverse?lat={lat.ToString(CultureInfo.InvariantCulture)}&lon={lon.ToString(CultureInfo.InvariantCulture)}&format=jsonv2&addressdetails=1";
+ var response = await _httpClient.GetFromJsonAsync<NominatimSearchResult>(url);
+
+ if (response == null) return null;
+
+ var result = MapToPlannerSearchResult(response);
+
+ _cache.Set(cacheKey, result, TimeSpan.FromMinutes(60));
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching Nominatim reverse geocode results from {Url}", _config.NominatimBaseUrl);
+ return null;
+ }
+ }
+
+ private PlannerSearchResult MapToPlannerSearchResult(NominatimSearchResult result)
+ {
+ var name = result.Address?.Road ?? result.DisplayName?.Split(',').FirstOrDefault();
+ var label = result.DisplayName;
+
+ return new PlannerSearchResult
+ {
+ Name = name,
+ Label = label,
+ Lat = double.TryParse(result.Lat, CultureInfo.InvariantCulture, out var lat) ? lat : 0,
+ Lon = double.TryParse(result.Lon, CultureInfo.InvariantCulture, out var lon) ? lon : 0,
+ Layer = result.Type
+ };
+ }
+}
diff --git a/src/Enmarcha.Backend/Services/OtpService.cs b/src/Enmarcha.Backend/Services/OtpService.cs
new file mode 100644
index 0000000..e4b4846
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/OtpService.cs
@@ -0,0 +1,357 @@
+using System.Globalization;
+using Enmarcha.Sources.OpenTripPlannerGql.Queries;
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Helpers;
+using Enmarcha.Backend.Types.Otp;
+using Enmarcha.Backend.Types.Planner;
+using Enmarcha.Backend.Types.Transit;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Services;
+
+public class OtpService
+{
+ private readonly HttpClient _httpClient;
+ private readonly AppConfiguration _config;
+ private readonly IMemoryCache _cache;
+ private readonly ILogger<OtpService> _logger;
+ private readonly FareService _fareService;
+ private readonly LineFormatterService _lineFormatter;
+ private readonly FeedService _feedService;
+
+ public OtpService(HttpClient httpClient, IOptions<AppConfiguration> config, IMemoryCache cache, ILogger<OtpService> logger, FareService fareService, LineFormatterService lineFormatter, FeedService feedService)
+ {
+ _httpClient = httpClient;
+ _config = config.Value;
+ _cache = cache;
+ _logger = logger;
+ _fareService = fareService;
+ _lineFormatter = lineFormatter;
+ _feedService = feedService;
+ }
+
+ public RouteDto MapRoute(RoutesListResponse.RouteItem route)
+ {
+ var feedId = route.GtfsId.Split(':')[0];
+ return new RouteDto
+ {
+ Id = route.GtfsId,
+ ShortName = _feedService.NormalizeRouteShortName(feedId, route.ShortName ?? string.Empty),
+ LongName = route.LongName,
+ Color = route.Color,
+ TextColor = route.TextColor,
+ SortOrder = route.SortOrder,
+ AgencyName = route.Agency?.Name,
+ TripCount = route.Patterns.Sum(p => p.TripsForDate.Count)
+ };
+ }
+
+ public RouteDetailsDto MapRouteDetails(RouteDetailsResponse.RouteItem route)
+ {
+ var feedId = route.GtfsId?.Split(':')[0] ?? "unknown";
+ return new RouteDetailsDto
+ {
+ ShortName = _feedService.NormalizeRouteShortName(feedId, route.ShortName ?? string.Empty),
+ LongName = route.LongName,
+ Color = route.Color,
+ TextColor = route.TextColor,
+ AgencyName = route.Agency?.Name,
+ Patterns = route.Patterns.Select(p => MapPattern(p, feedId)).ToList()
+ };
+ }
+
+ private PatternDto MapPattern(RouteDetailsResponse.PatternItem pattern, string feedId)
+ {
+ return new PatternDto
+ {
+ Id = pattern.Id,
+ Name = pattern.Name,
+ Headsign = pattern.Headsign,
+ DirectionId = pattern.DirectionId,
+ Code = pattern.Code,
+ SemanticHash = pattern.SemanticHash,
+ TripCount = pattern.TripsForDate.Count,
+ Geometry = DecodePolyline(pattern.PatternGeometry?.Points)?.Coordinates,
+ Stops = pattern.Stops.Select((s, i) => new PatternStopDto
+ {
+ Id = s.GtfsId,
+ Code = _feedService.NormalizeStopCode(feedId, s.Code ?? string.Empty),
+ Name = _feedService.NormalizeStopName(feedId, s.Name),
+ Lat = s.Lat,
+ Lon = s.Lon,
+ ScheduledDepartures = pattern.TripsForDate
+ .Select(t => t.Stoptimes.ElementAtOrDefault(i)?.ScheduledDeparture ?? -1)
+ .Where(d => d != -1)
+ .OrderBy(d => d)
+ .ToList()
+ }).ToList()
+ };
+ }
+
+ private Leg MapLeg(OtpLeg otpLeg)
+ {
+ return new Leg
+ {
+ Mode = otpLeg.Mode,
+ RouteName = otpLeg.Route,
+ RouteShortName = otpLeg.RouteShortName,
+ RouteLongName = otpLeg.RouteLongName,
+ Headsign = otpLeg.Headsign,
+ AgencyName = otpLeg.AgencyName,
+ RouteColor = otpLeg.RouteColor,
+ RouteTextColor = otpLeg.RouteTextColor,
+ From = MapPlace(otpLeg.From),
+ To = MapPlace(otpLeg.To),
+ StartTime = DateTimeOffset.FromUnixTimeMilliseconds(otpLeg.StartTime).UtcDateTime,
+ EndTime = DateTimeOffset.FromUnixTimeMilliseconds(otpLeg.EndTime).UtcDateTime,
+ DistanceMeters = otpLeg.Distance,
+ Geometry = DecodePolyline(otpLeg.LegGeometry?.Points),
+ Steps = otpLeg.Steps.Select(MapStep).ToList(),
+ IntermediateStops = otpLeg.IntermediateStops.Select(MapPlace).Where(p => p != null).Cast<PlannerPlace>().ToList()
+ };
+ }
+
+ private PlannerPlace? MapPlace(OtpPlace? otpPlace)
+ {
+ if (otpPlace == null) return null;
+ var feedId = otpPlace.StopId?.Split(':')[0] ?? "unknown";
+ return new PlannerPlace
+ {
+ Name = _feedService.NormalizeStopName(feedId, otpPlace.Name),
+ Lat = otpPlace.Lat,
+ Lon = otpPlace.Lon,
+ StopId = otpPlace.StopId, // Use string directly
+ StopCode = _feedService.NormalizeStopCode(feedId, otpPlace.StopCode ?? string.Empty)
+ };
+ }
+
+ private Step MapStep(OtpWalkStep otpStep)
+ {
+ return new Step
+ {
+ DistanceMeters = otpStep.Distance,
+ RelativeDirection = otpStep.RelativeDirection,
+ AbsoluteDirection = otpStep.AbsoluteDirection,
+ StreetName = otpStep.StreetName,
+ Lat = otpStep.Lat,
+ Lon = otpStep.Lon
+ };
+ }
+
+ private PlannerGeometry? DecodePolyline(string? encodedPoints)
+ {
+ if (string.IsNullOrEmpty(encodedPoints)) return null;
+
+ var coordinates = Decode(encodedPoints);
+ return new PlannerGeometry
+ {
+ Coordinates = coordinates.Select(c => new List<double> { c.Lon, c.Lat }).ToList()
+ };
+ }
+
+ // Polyline decoding algorithm
+ private 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;
+ }
+
+ public RoutePlan MapPlanResponse(PlanConnectionResponse response)
+ {
+ var itineraries = response.PlanConnection.Edges
+ .Select(e => MapItinerary(e.Node))
+ .ToList();
+
+ return new RoutePlan
+ {
+ Itineraries = itineraries
+ };
+ }
+
+ private Itinerary MapItinerary(PlanConnectionResponse.Node node)
+ {
+ var legs = node.Legs.Select(MapLeg).ToList();
+ var fares = _fareService.CalculateFare(legs);
+
+ return new Itinerary
+ {
+ DurationSeconds = node.DurationSeconds,
+ StartTime = DateTime.Parse(node.Start8601, null, DateTimeStyles.RoundtripKind),
+ EndTime = DateTime.Parse(node.End8601, null, DateTimeStyles.RoundtripKind),
+ WalkDistanceMeters = node.WalkDistance,
+ WalkTimeSeconds = node.WalkSeconds,
+ TransitTimeSeconds = node.DurationSeconds - node.WalkSeconds - node.WaitingSeconds,
+ WaitingTimeSeconds = node.WaitingSeconds,
+ Legs = legs,
+ CashFare = fares.CashFareEuro,
+ CashFareIsTotal = fares.CashFareIsTotal,
+ CardFare = fares.CardFareEuro,
+ CardFareIsTotal = fares.CardFareIsTotal
+ };
+ }
+
+ private Leg MapLeg(PlanConnectionResponse.Leg leg)
+ {
+ var feedId = leg.From.Stop?.GtfsId?.Split(':')[0] ?? "unknown";
+ var shortName = _feedService.NormalizeRouteShortName(feedId, leg.Route?.ShortName ?? string.Empty);
+ var headsign = leg.Headsign;
+
+ if (feedId == "vitrasa")
+ {
+ headsign = headsign?.Replace("*", "");
+ if (headsign == "FORA DE SERVIZO.G.B.")
+ {
+ headsign = "García Barbón, 7 (fora de servizo)";
+ }
+
+ switch (shortName)
+ {
+ case "A" when headsign != null && headsign.StartsWith("\"1\""):
+ shortName = "A1";
+ headsign = headsign.Replace("\"1\"", "");
+ break;
+ case "6":
+ headsign = headsign?.Replace("\"", "");
+ break;
+ case "FUT":
+ if (headsign == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO")
+ {
+ shortName = "MAR";
+ headsign = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO";
+ }
+ else if (headsign == "P. ESPAÑA-T.VIGO-S.BADÍA")
+ {
+ shortName = "RIO";
+ headsign = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA";
+ }
+ else if (headsign == "NAVIA-BOUZAS-URZAIZ-G. ESPINO")
+ {
+ shortName = "GOL";
+ headsign = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO";
+ }
+ break;
+ }
+ }
+
+ var color = leg.Route?.Color;
+ var textColor = leg.Route?.TextColor;
+
+ if (string.IsNullOrEmpty(color) || color == "FFFFFF")
+ {
+ var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId);
+ color = fallbackColor.Replace("#", "");
+ textColor = fallbackTextColor.Replace("#", "");
+ }
+ else if (string.IsNullOrEmpty(textColor) || textColor == "000000")
+ {
+ textColor = ContrastHelper.GetBestTextColour(color).Replace("#", "");
+ }
+
+ return new Leg
+ {
+ Mode = leg.Mode,
+ FeedId = feedId,
+ RouteId = leg.Route?.GtfsId,
+ RouteName = leg.Route?.LongName,
+ RouteShortName = shortName,
+ RouteLongName = leg.Route?.LongName,
+ Headsign = headsign,
+ AgencyName = leg.Route?.Agency?.Name,
+ RouteColor = color,
+ RouteTextColor = textColor,
+ From = MapPlace(leg.From),
+ To = MapPlace(leg.To),
+ StartTime = DateTime.Parse(leg.Start.ScheduledTime8601, null, DateTimeStyles.RoundtripKind),
+ EndTime = DateTime.Parse(leg.End.ScheduledTime8601, null, DateTimeStyles.RoundtripKind),
+ DistanceMeters = leg.Distance,
+ Geometry = DecodePolyline(leg.LegGeometry?.Points),
+ Steps = leg.Steps.Select(MapStep).ToList(),
+ IntermediateStops = leg.StopCalls.Select(sc => MapPlace(sc.StopLocation)).ToList()
+ };
+ }
+
+ private PlannerPlace MapPlace(PlanConnectionResponse.LegPosition pos)
+ {
+ var feedId = pos.Stop?.GtfsId?.Split(':')[0] ?? "unknown";
+ return new PlannerPlace
+ {
+ Name = _feedService.NormalizeStopName(feedId, pos.Name),
+ Lat = pos.Latitude,
+ Lon = pos.Longitude,
+ StopId = pos.Stop?.GtfsId,
+ StopCode = _feedService.NormalizeStopCode(feedId, pos.Stop?.Code ?? string.Empty),
+ ZoneId = pos.Stop?.ZoneId
+ };
+ }
+
+ private PlannerPlace MapPlace(PlanConnectionResponse.StopLocation stop)
+ {
+ var feedId = stop.GtfsId?.Split(':')[0] ?? "unknown";
+ return new PlannerPlace
+ {
+ Name = _feedService.NormalizeStopName(feedId, stop.Name),
+ Lat = stop.Latitude,
+ Lon = stop.Longitude,
+ StopId = stop.GtfsId,
+ StopCode = _feedService.NormalizeStopCode(feedId, stop.Code ?? string.Empty)
+ };
+ }
+
+ private Step MapStep(PlanConnectionResponse.Step step)
+ {
+ return new Step
+ {
+ DistanceMeters = step.Distance,
+ RelativeDirection = step.RelativeDirection,
+ AbsoluteDirection = step.AbsoluteDirection,
+ StreetName = step.StreetName,
+ Lat = step.Latitude,
+ Lon = step.Longitude
+ };
+ }
+}
diff --git a/src/Enmarcha.Backend/Services/Processors/AbstractProcessor.cs b/src/Enmarcha.Backend/Services/Processors/AbstractProcessor.cs
new file mode 100644
index 0000000..d6b420f
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Processors/AbstractProcessor.cs
@@ -0,0 +1,56 @@
+using Enmarcha.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/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs
new file mode 100644
index 0000000..ca3f91d
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs
@@ -0,0 +1,181 @@
+using Enmarcha.Sources.OpenTripPlannerGql.Queries;
+using Enmarcha.Sources.TranviasCoruna;
+using Enmarcha.Backend.Types;
+using Enmarcha.Backend.Types.Arrivals;
+using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival;
+
+namespace Enmarcha.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("tranvias:")) return;
+
+ var normalizedCode = _feedService.NormalizeStopCode("tranvias", context.StopCode);
+ if (!int.TryParse(normalizedCode, out var numericStopId)) return;
+
+ try
+ {
+ 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;
+
+ 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/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs b/src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs
new file mode 100644
index 0000000..2d5f5d9
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Processors/FeedConfigProcessor.cs
@@ -0,0 +1,84 @@
+using Enmarcha.Backend.Helpers;
+using Enmarcha.Backend.Types.Arrivals;
+
+namespace Enmarcha.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/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs
new file mode 100644
index 0000000..7df00fa
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs
@@ -0,0 +1,44 @@
+using Enmarcha.Backend.Types.Arrivals;
+
+namespace Enmarcha.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/Enmarcha.Backend/Services/Processors/MarqueeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/MarqueeProcessor.cs
new file mode 100644
index 0000000..9e620c2
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Processors/MarqueeProcessor.cs
@@ -0,0 +1,26 @@
+namespace Enmarcha.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/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs b/src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs
new file mode 100644
index 0000000..5d02066
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Processors/NextStopsProcessor.cs
@@ -0,0 +1,34 @@
+using Enmarcha.Sources.OpenTripPlannerGql.Queries;
+
+namespace Enmarcha.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/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs
new file mode 100644
index 0000000..28b38a9
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs
@@ -0,0 +1,88 @@
+using Enmarcha.Sources.OpenTripPlannerGql.Queries;
+using Enmarcha.Sources.TranviasCoruna;
+using Enmarcha.Backend.Types;
+using Enmarcha.Backend.Types.Arrivals;
+using Enmarcha.Sources.Tussa;
+using Arrival = Enmarcha.Backend.Types.Arrivals.Arrival;
+
+namespace Enmarcha.Backend.Services.Processors;
+
+public class SantiagoRealTimeProcessor : AbstractRealTimeProcessor
+{
+ private readonly SantiagoRealtimeEstimatesProvider _realtime;
+ private readonly FeedService _feedService;
+ private readonly ILogger<SantiagoRealTimeProcessor> _logger;
+
+ public SantiagoRealTimeProcessor(
+ HttpClient http,
+ FeedService feedService,
+ ILogger<SantiagoRealTimeProcessor> logger)
+ {
+ _realtime = new SantiagoRealtimeEstimatesProvider(http);
+ _feedService = feedService;
+ _logger = logger;
+ }
+
+ public override async Task ProcessAsync(ArrivalsContext context)
+ {
+ if (!context.StopId.StartsWith("tussa:")) return;
+
+ var normalizedCode = _feedService.NormalizeStopCode("tussa", context.StopCode);
+ if (!int.TryParse(normalizedCode, out var numericStopId)) return;
+
+ try
+ {
+ 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;
+
+ 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 };
+ }
+
+ 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);
+ }
+ }
+
+}
diff --git a/src/Enmarcha.Backend/Services/Processors/ShapeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/ShapeProcessor.cs
new file mode 100644
index 0000000..f3af3a5
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Processors/ShapeProcessor.cs
@@ -0,0 +1,132 @@
+using Enmarcha.Sources.OpenTripPlannerGql.Queries;
+
+namespace Enmarcha.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 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<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)
+ {
+ _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/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs
new file mode 100644
index 0000000..5d44995
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Processors/VitrasaRealTimeProcessor.cs
@@ -0,0 +1,254 @@
+using Enmarcha.Sources.OpenTripPlannerGql.Queries;
+using Costasdev.VigoTransitApi;
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Types;
+using Enmarcha.Backend.Types.Arrivals;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Services.Processors;
+
+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,
+ ShapeTraversalService shapeService,
+ IOptions<AppConfiguration> 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<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;
+
+ 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<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
+ {
+ _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);
+ }
+}
diff --git a/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs
new file mode 100644
index 0000000..77f6341
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs
@@ -0,0 +1,8 @@
+using Enmarcha.Backend.Types;
+
+namespace Enmarcha.Backend.Services.Providers;
+
+public interface ITransitProvider
+{
+ Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime now);
+}
diff --git a/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs
new file mode 100644
index 0000000..036c9b1
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs
@@ -0,0 +1,64 @@
+using Enmarcha.Backend.Extensions;
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Types;
+using Microsoft.Extensions.Options;
+using SysFile = System.IO.File;
+
+namespace Enmarcha.Backend.Services.Providers;
+
+[Obsolete]
+public class RenfeTransitProvider : ITransitProvider
+{
+ private readonly AppConfiguration _configuration;
+ private readonly ILogger<RenfeTransitProvider> _logger;
+
+ public RenfeTransitProvider(IOptions<AppConfiguration> options, ILogger<RenfeTransitProvider> logger)
+ {
+ _configuration = options.Value;
+ _logger = logger;
+ }
+
+ public async Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime nowLocal)
+ {
+ var todayDate = nowLocal.Date.ToString("yyyy-MM-dd");
+ StopArrivals stopArrivals = null!;
+
+ if (stopArrivals == null)
+ {
+ return [];
+ }
+
+ var now = nowLocal.AddSeconds(60 - nowLocal.Second);
+ var scopeEnd = now.AddMinutes(8 * 60);
+
+ var scheduledWindow = stopArrivals.Arrivals
+ .Where(c => c.CallingDateTime(nowLocal.Date) != null)
+ .Where(c => c.CallingDateTime(nowLocal.Date)!.Value >= now && c.CallingDateTime(nowLocal.Date)!.Value <= scopeEnd)
+ .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value);
+
+ var consolidatedCirculations = new List<ConsolidatedCirculation>();
+
+ foreach (var sched in scheduledWindow)
+ {
+ var minutes = (int)(sched.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes;
+
+ consolidatedCirculations.Add(new ConsolidatedCirculation
+ {
+ Line = sched.Line,
+ Route = sched.Route,
+ Schedule = new ScheduleData
+ {
+ Running = sched.StartingDateTime(nowLocal.Date)!.Value <= now,
+ Minutes = minutes,
+ TripId = sched.ServiceId[(sched.ServiceId.Length - 6)..(sched.ServiceId.Length - 1)],
+ ServiceId = sched.ServiceId[(sched.ServiceId.Length - 6)..(sched.ServiceId.Length - 1)],
+ ShapeId = sched.ShapeId,
+ },
+ RealTime = null,
+ NextStreets = [.. sched.NextStreets]
+ });
+ }
+
+ return consolidatedCirculations;
+ }
+}
diff --git a/src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs
new file mode 100644
index 0000000..8a05fc6
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs
@@ -0,0 +1,281 @@
+using System.Globalization;
+using System.Text;
+using Enmarcha.Backend.Extensions;
+using Costasdev.VigoTransitApi;
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Types;
+using Microsoft.Extensions.Options;
+using static Enmarcha.Backend.Types.StopArrivals.Types;
+using SysFile = System.IO.File;
+
+namespace Enmarcha.Backend.Services.Providers;
+
+[Obsolete]
+public class VitrasaTransitProvider : ITransitProvider
+{
+ private readonly VigoTransitApiClient _api;
+ private readonly AppConfiguration _configuration;
+ private readonly ShapeTraversalService _shapeService;
+ private readonly LineFormatterService _lineFormatter;
+ private readonly ILogger<VitrasaTransitProvider> _logger;
+
+ public VitrasaTransitProvider(HttpClient http, IOptions<AppConfiguration> options, ShapeTraversalService shapeService, LineFormatterService lineFormatter, ILogger<VitrasaTransitProvider> logger)
+ {
+ _api = new VigoTransitApiClient(http);
+ _configuration = options.Value;
+ _shapeService = shapeService;
+ _lineFormatter = lineFormatter;
+ _logger = logger;
+ }
+
+ public async Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime nowLocal)
+ {
+ // Vitrasa stop IDs are integers, but we receive string "vitrasa:1234" or just "1234" if legacy
+ // The caller (Controller) should probably strip the prefix, but let's handle it here just in case or assume it's stripped.
+ // The user said: "Routing the request to one or tthe other will just work with the prefix. For example calling `/api/GetConsolidatedCirculations?stopId=vitrasa:1400` will call the vitrasa driver with stop 1400."
+ // So I should expect the ID part only here? Or the full ID?
+ // Usually providers take the ID they understand. I'll assume the controller strips the prefix.
+
+ if (!int.TryParse(stopId, out var numericStopId))
+ {
+ _logger.LogError("Invalid Vitrasa stop ID: {StopId}", stopId);
+ return [];
+ }
+
+ var realtimeTask = _api.GetStopEstimates(numericStopId);
+ var todayDate = nowLocal.Date.ToString("yyyy-MM-dd");
+
+ // Load both today's and tomorrow's schedules to handle night services
+ var timetableTask = LoadStopArrivalsProto(stopId, todayDate);
+
+ // Wait for real-time data and today's schedule (required)
+ await Task.WhenAll(realtimeTask, timetableTask);
+
+ var realTimeEstimates = realtimeTask.Result.Estimates
+ .Where(e => !string.IsNullOrWhiteSpace(e.Route) && !e.Route.Trim().EndsWith('*'))
+ .ToList();
+
+ // Handle case where schedule file doesn't exist - return realtime-only data
+ if (timetableTask.Result == null)
+ {
+ _logger.LogWarning("No schedule data available for stop {StopId} on {Date}, returning realtime-only data", stopId, todayDate);
+
+ var realtimeOnlyCirculations = realTimeEstimates.Select(estimate => new ConsolidatedCirculation
+ {
+ Line = estimate.Line,
+ Route = estimate.Route,
+ Schedule = null,
+ RealTime = new RealTimeData
+ {
+ Minutes = estimate.Minutes,
+ Distance = estimate.Meters
+ }
+ }).OrderBy(c => c.RealTime!.Minutes).ToList();
+
+ return realtimeOnlyCirculations;
+ }
+
+ var timetable = timetableTask.Result.Arrivals
+ .Where(c => c.StartingDateTime(nowLocal.Date) != null && c.CallingDateTime(nowLocal.Date) != null)
+ .ToList();
+
+ var stopLocation = timetableTask.Result.Location;
+
+ var now = nowLocal.AddSeconds(60 - nowLocal.Second);
+ // Define the scope end as the time of the last realtime arrival (no extra buffer)
+ var scopeEnd = realTimeEstimates.Count > 0
+ ? now.AddMinutes(Math.Min(realTimeEstimates.Max(e => e.Minutes) + 5, 75))
+ : now.AddMinutes(60); // If no estimates, show next hour of scheduled only
+
+ List<ConsolidatedCirculation> consolidatedCirculations = [];
+ var usedTripIds = new HashSet<string>();
+
+ foreach (var estimate in realTimeEstimates)
+ {
+ var estimatedArrivalTime = now.AddMinutes(estimate.Minutes);
+
+ var possibleCirculations = timetable
+ .Where(c =>
+ {
+ // Match by line number
+ if (c.Line.Trim() != estimate.Line.Trim())
+ return false;
+
+ // Match by route (destination) - compare with both Route field and Terminus stop name
+ // Normalize both sides: remove non-ASCII-alnum characters and lowercase
+ var estimateRoute = NormalizeRouteName(estimate.Route);
+ var scheduleRoute = NormalizeRouteName(c.Route);
+ var scheduleTerminus = NormalizeRouteName(c.TerminusName);
+
+ // TODO: Replace ñapa with fuzzy matching or better logic
+ return scheduleRoute == estimateRoute || scheduleTerminus == estimateRoute ||
+ scheduleRoute.Contains(estimateRoute) || estimateRoute.Contains(scheduleRoute);
+ })
+ .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value)
+ .ToArray();
+
+ StopArrivals.Types.ScheduledArrival? closestCirculation = null;
+
+ const int maxEarlyArrivalMinutes = 7;
+
+ var bestMatch = possibleCirculations
+ .Select(c => new
+ {
+ Circulation = c,
+ TimeDiff = (c.CallingDateTime(nowLocal.Date)!.Value - estimatedArrivalTime).TotalMinutes
+ })
+ .Where(x => x.TimeDiff <= maxEarlyArrivalMinutes && x.TimeDiff >= -75)
+ .OrderBy(x => Math.Abs(x.TimeDiff))
+ .FirstOrDefault();
+
+ if (bestMatch != null)
+ {
+ closestCirculation = bestMatch.Circulation;
+ }
+
+ if (closestCirculation == null)
+ {
+ // No scheduled match: include realtime-only entry
+ _logger.LogWarning("No schedule match for realtime line {Line} towards {Route} in {Minutes} minutes (tried matching {NormalizedRoute})", estimate.Line, estimate.Route, estimate.Minutes, NormalizeRouteName(estimate.Route));
+ consolidatedCirculations.Add(new ConsolidatedCirculation
+ {
+ Line = estimate.Line,
+ Route = estimate.Route,
+ Schedule = null,
+ RealTime = new RealTimeData
+ {
+ Minutes = estimate.Minutes,
+ Distance = estimate.Meters
+ }
+ });
+
+ continue;
+ }
+
+ // Ensure each scheduled trip is only matched once to a realtime estimate
+ if (usedTripIds.Contains(closestCirculation.TripId))
+ {
+ _logger.LogInformation("Skipping duplicate realtime match for TripId {TripId}", closestCirculation.TripId);
+ continue;
+ }
+
+ var isRunning = closestCirculation.StartingDateTime(nowLocal.Date)!.Value <= now;
+ Position? currentPosition = null;
+ int? stopShapeIndex = null;
+ bool usePreviousShape = false;
+
+ consolidatedCirculations.Add(new ConsolidatedCirculation
+ {
+ Line = estimate.Line,
+ Route = estimate.Route == closestCirculation.TerminusName ? closestCirculation.Route : estimate.Route,
+ NextStreets = [.. closestCirculation.NextStreets],
+ Schedule = new ScheduleData
+ {
+ Running = isRunning,
+ Minutes = (int)(closestCirculation.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes,
+ TripId = closestCirculation.TripId,
+ ServiceId = closestCirculation.ServiceId,
+ ShapeId = closestCirculation.ShapeId,
+ },
+ RealTime = new RealTimeData
+ {
+ Minutes = estimate.Minutes,
+ Distance = estimate.Meters
+ },
+ CurrentPosition = currentPosition,
+ StopShapeIndex = stopShapeIndex,
+ IsPreviousTrip = usePreviousShape,
+ PreviousTripShapeId = usePreviousShape ? closestCirculation.PreviousTripShapeId : null
+ });
+
+ usedTripIds.Add(closestCirculation.TripId);
+ }
+
+ // Add scheduled-only circulations between now and the last realtime arrival
+ if (scopeEnd > now)
+ {
+ var matchedTripIds = new HashSet<string>(usedTripIds);
+
+ var scheduledWindow = timetable
+ .Where(c => c.CallingDateTime(nowLocal.Date)!.Value >= now && c.CallingDateTime(nowLocal.Date)!.Value <= scopeEnd)
+ .OrderBy(c => c.CallingDateTime(nowLocal.Date)!.Value);
+
+ foreach (var sched in scheduledWindow)
+ {
+ if (matchedTripIds.Contains(sched.TripId))
+ {
+ continue; // already represented via a matched realtime
+ }
+
+ var minutes = (int)(sched.CallingDateTime(nowLocal.Date)!.Value - now).TotalMinutes;
+ if (minutes == 0)
+ {
+ continue;
+ }
+
+ consolidatedCirculations.Add(new ConsolidatedCirculation
+ {
+ Line = sched.Line,
+ Route = sched.Route,
+ Schedule = new ScheduleData
+ {
+ Running = sched.StartingDateTime(nowLocal.Date)!.Value <= now,
+ Minutes = minutes,
+ TripId = sched.TripId,
+ ServiceId = sched.ServiceId,
+ ShapeId = sched.ShapeId,
+ },
+ RealTime = null
+ });
+ }
+ }
+
+ // Sort by ETA (RealTime minutes if present; otherwise Schedule minutes)
+ var sorted = consolidatedCirculations
+ .OrderBy(c => c.RealTime?.Minutes ?? c.Schedule!.Minutes)
+ .Select(_lineFormatter.Format)
+ .ToList();
+
+ return sorted;
+ }
+
+ private async Task<StopArrivals?> LoadStopArrivalsProto(string stopId, string dateString)
+ {
+ return new StopArrivals();
+ // var file = Path.Combine(_configuration.VitrasaScheduleBasePath, dateString, stopId + ".pb");
+ // if (!SysFile.Exists(file))
+ // {
+ // _logger.LogWarning("Stop arrivals proto file not found: {File}", file);
+ // return null;
+ // }
+ //
+ // var contents = await SysFile.ReadAllBytesAsync(file);
+ // var stopArrivals = StopArrivals.Parser.ParseFrom(contents);
+ // return stopArrivals;
+ }
+
+ private static string NormalizeRouteName(string route)
+ {
+ var normalized = route.Trim().ToLowerInvariant();
+ // Remove diacritics/accents first, then filter to alphanumeric
+ normalized = RemoveDiacritics(normalized);
+ return new string(normalized.Where(char.IsLetterOrDigit).ToArray());
+ }
+
+ private static string RemoveDiacritics(string text)
+ {
+ var normalizedString = text.Normalize(NormalizationForm.FormD);
+ var stringBuilder = new StringBuilder();
+
+ foreach (var c in normalizedString)
+ {
+ var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
+ if (unicodeCategory != UnicodeCategory.NonSpacingMark)
+ {
+ stringBuilder.Append(c);
+ }
+ }
+
+ return stringBuilder.ToString().Normalize(NormalizationForm.FormC);
+ }
+}
diff --git a/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs b/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs
new file mode 100644
index 0000000..4bb60e2
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs
@@ -0,0 +1,57 @@
+using System.Collections.Frozen;
+using System.Globalization;
+using CsvHelper;
+using CsvHelper.Configuration.Attributes;
+
+namespace Enmarcha.Backend.Services.Providers;
+
+public class PriceRecord
+{
+ [Name("conc_inicio")] public string Origin { get; set; }
+ [Name("conc_fin")] public string Destination { get; set; }
+ [Name("bonificacion")] public string? MetroArea { get; set; }
+ [Name("efectivo")] public decimal PriceCash { get; set; }
+ [Name("tpg")] public decimal PriceCard { get; set; }
+}
+
+public class XuntaFareProvider
+{
+ private readonly FrozenDictionary<(string, string), PriceRecord> _priceMatrix;
+
+ public XuntaFareProvider(IWebHostEnvironment env)
+ {
+ var filePath = Path.Combine(env.ContentRootPath, "Data", "xunta_fares.csv");
+
+ using var reader = new StreamReader(filePath);
+ using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
+
+ // We do GroupBy first to prevent duplicates from throwing an exception
+ _priceMatrix = csv.GetRecords<PriceRecord>()
+ .GroupBy(record => (record.Origin, record.Destination))
+ .ToFrozenDictionary(
+ group => group.Key,
+ group => group.First()
+ );
+ }
+
+ public PriceRecord? GetPrice(string origin, string destination)
+ {
+ var originMunicipality = origin[..5];
+ var destinationMunicipality = destination[..5];
+
+ var valueOrDefault = _priceMatrix.GetValueOrDefault((originMunicipality, destinationMunicipality));
+
+ /* This happens in cases where traffic is forbidden (like inside municipalities with urban transit */
+ if (valueOrDefault?.PriceCash == 0.0M)
+ {
+ valueOrDefault.PriceCash = 100;
+ }
+
+ if (valueOrDefault?.PriceCard == 0.0M)
+ {
+ valueOrDefault.PriceCard = 100;
+ }
+
+ return valueOrDefault;
+ }
+}
diff --git a/src/Enmarcha.Backend/Services/ShapeTraversalService.cs b/src/Enmarcha.Backend/Services/ShapeTraversalService.cs
new file mode 100644
index 0000000..221a975
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/ShapeTraversalService.cs
@@ -0,0 +1,224 @@
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Types;
+using Microsoft.Extensions.Options;
+using ProjNet.CoordinateSystems;
+using ProjNet.CoordinateSystems.Transformations;
+using SysFile = System.IO.File;
+
+namespace Enmarcha.Backend.Services;
+
+/// <summary>
+/// Service for loading shapes and calculating remaining path from a given stop point
+/// </summary>
+public class ShapeTraversalService
+{
+ private readonly AppConfiguration _configuration;
+ private readonly ILogger<ShapeTraversalService> _logger;
+ private readonly ICoordinateTransformation _transformation;
+
+ public ShapeTraversalService(IOptions<AppConfiguration> options, ILogger<ShapeTraversalService> logger)
+ {
+ _configuration = options.Value;
+ _logger = logger;
+
+ // Set up coordinate transformation from EPSG:25829 (meters) to EPSG:4326 (lat/lng)
+ var ctFactory = new CoordinateTransformationFactory();
+ var csFactory = new CoordinateSystemFactory();
+
+ // EPSG:25829 - ETRS89 / UTM zone 29N
+ var source = csFactory.CreateFromWkt(
+ "PROJCS[\"ETRS89 / UTM zone 29N\",GEOGCS[\"ETRS89\",DATUM[\"European_Terrestrial_Reference_System_1989\",SPHEROID[\"GRS 1980\",6378137,298.257222101,AUTHORITY[\"EPSG\",\"7019\"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY[\"EPSG\",\"6258\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4258\"]],PROJECTION[\"Transverse_Mercator\"],PARAMETER[\"latitude_of_origin\",0],PARAMETER[\"central_meridian\",-9],PARAMETER[\"scale_factor\",0.9996],PARAMETER[\"false_easting\",500000],PARAMETER[\"false_northing\",0],UNIT[\"metre\",1,AUTHORITY[\"EPSG\",\"9001\"]],AXIS[\"Easting\",EAST],AXIS[\"Northing\",NORTH],AUTHORITY[\"EPSG\",\"25829\"]]");
+
+ // EPSG:4326 - WGS84
+ var target = GeographicCoordinateSystem.WGS84;
+
+ _transformation = ctFactory.CreateFromCoordinateSystems(source, target);
+ }
+
+ 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>
+ /// <param name="shape">The shape points (in EPSG:25829 meters)</param>
+ /// <param name="stopLocation">The stop location (in EPSG:25829 meters)</param>
+ /// <param name="distanceMeters">Distance in meters from the stop to traverse backwards</param>
+ /// <returns>The lat/lng position of the bus and the stop index on the shape</returns>
+ public (Position? BusPosition, int StopIndex) GetBusPosition(Shape shape, Epsg25829 stopLocation, int distanceMeters)
+ {
+ if (shape.Points.Count == 0 || distanceMeters <= 0)
+ {
+ return (null, -1);
+ }
+
+ // Find the closest point on the shape to the stop
+ int closestPointIndex = FindClosestPointIndex(shape.Points, stopLocation);
+
+ // Calculate the total distance from the start of the shape to the stop
+ double totalDistanceToStop = CalculateTotalDistance(shape.Points.ToArray(), closestPointIndex);
+
+ // If the reported distance exceeds the total distance to the stop, the bus is likely
+ // on a previous trip whose shape we don't have. Don't provide position information.
+ if (distanceMeters > totalDistanceToStop)
+ {
+ _logger.LogDebug("Distance {Distance}m exceeds total shape distance to stop {Total}m - bus likely on previous trip", distanceMeters, totalDistanceToStop);
+ return (null, closestPointIndex);
+ }
+
+ // Traverse backwards from the closest point to find the position at the given distance
+ var (busPoint, forwardIndex) = TraverseBackwards(shape.Points.ToArray(), closestPointIndex, distanceMeters);
+
+ if (busPoint == null)
+ {
+ return (null, closestPointIndex);
+ }
+
+ var forwardPoint = shape.Points[forwardIndex];
+
+ // Compute orientation in EPSG:25829 (meters): 0°=North, 90°=East (azimuth)
+ var dx = forwardPoint.X - busPoint.X; // Easting difference
+ var dy = forwardPoint.Y - busPoint.Y; // Northing difference
+ var bearing = Math.Atan2(dx, dy) * 180.0 / Math.PI; // swap for 0° north
+ if (bearing < 0) bearing += 360.0;
+
+ // Transform from EPSG:25829 (meters) to EPSG:4326 (lat/lng)
+ var pos = TransformToLatLng(busPoint);
+ pos.OrientationDegrees = (int)Math.Round(bearing);
+ pos.ShapeIndex = forwardIndex;
+ return (pos, closestPointIndex);
+ }
+
+ /// <summary>
+ /// Traverses backwards along the shape from a starting point by the specified distance
+ /// </summary>
+ private (Epsg25829 point, int forwardIndex) TraverseBackwards(Epsg25829[] shapePoints, int startIndex, double distanceMeters)
+ {
+ if (startIndex <= 0)
+ {
+ // Already at the beginning, return the first point
+ var forwardIdx = Math.Min(1, shapePoints.Length - 1);
+ return (shapePoints[0], forwardIdx);
+ }
+
+ double remainingDistance = distanceMeters;
+ int currentIndex = startIndex;
+
+ while (currentIndex > 0 && remainingDistance > 0)
+ {
+ var segmentDistance = CalculateDistance(shapePoints[currentIndex], shapePoints[currentIndex - 1]);
+
+ if (segmentDistance >= remainingDistance)
+ {
+ // The bus position is somewhere along this segment
+ // Interpolate between the two points
+ var ratio = remainingDistance / segmentDistance;
+ var interpolated = InterpolatePoint(shapePoints[currentIndex], shapePoints[currentIndex - 1], ratio);
+ // Forward direction is towards the stop (increasing index direction)
+ return (interpolated, currentIndex);
+ }
+
+ remainingDistance -= segmentDistance;
+ currentIndex--;
+ }
+
+ // We've reached the beginning of the shape
+ var fwd = Math.Min(1, shapePoints.Length - 1);
+ return (shapePoints[0], fwd);
+ }
+
+ /// <summary>
+ /// Interpolates a point between two points at a given ratio
+ /// </summary>
+ private Epsg25829 InterpolatePoint(Epsg25829 from, Epsg25829 to, double ratio)
+ {
+ return new Epsg25829
+ {
+ X = from.X + (to.X - from.X) * ratio,
+ Y = from.Y + (to.Y - from.Y) * ratio
+ };
+ }
+
+ /// <summary>
+ /// Finds the index of the closest point in the shape to the given location
+ /// </summary>
+ private int FindClosestPointIndex(IEnumerable<Epsg25829> shapePoints, Epsg25829 location)
+ {
+ var pointsArray = shapePoints.ToArray();
+ var minDistance = double.MaxValue;
+ var closestIndex = 0;
+
+ for (int i = 0; i < pointsArray.Length; i++)
+ {
+ var distance = CalculateDistance(pointsArray[i], location);
+ if (distance < minDistance)
+ {
+ minDistance = distance;
+ closestIndex = i;
+ }
+ }
+
+ return closestIndex;
+ }
+
+ /// <summary>
+ /// Calculates Euclidean distance between two points in meters
+ /// </summary>
+ private double CalculateDistance(Epsg25829 p1, Epsg25829 p2)
+ {
+ var dx = p1.X - p2.X;
+ var dy = p1.Y - p2.Y;
+ return Math.Sqrt(dx * dx + dy * dy);
+ }
+
+ /// <summary>
+ /// Calculates the total distance along the shape from the start to a given index
+ /// </summary>
+ private double CalculateTotalDistance(Epsg25829[] shapePoints, int endIndex)
+ {
+ if (endIndex <= 0 || shapePoints.Length == 0)
+ {
+ return 0;
+ }
+
+ double totalDistance = 0;
+ for (int i = 1; i <= endIndex && i < shapePoints.Length; i++)
+ {
+ totalDistance += CalculateDistance(shapePoints[i - 1], shapePoints[i]);
+ }
+
+ return totalDistance;
+ }
+
+ /// <summary>
+ /// Transforms a point from EPSG:25829 (meters) to EPSG:4326 (lat/lng)
+ /// </summary>
+ private Position TransformToLatLng(Epsg25829 point)
+ {
+ var transformed = _transformation.MathTransform.Transform(new[] { point.X, point.Y });
+ return new Position
+ {
+ // Round to 6 decimals (~0.1m precision)
+ Longitude = Math.Round(transformed[0], 6),
+ Latitude = Math.Round(transformed[1], 6)
+ };
+ }
+
+}