aboutsummaryrefslogtreecommitdiff
path: root/src/Costasdev.Busurbano.Backend/Services
diff options
context:
space:
mode:
Diffstat (limited to 'src/Costasdev.Busurbano.Backend/Services')
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs61
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/FareService.cs225
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/FeedService.cs213
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs9
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs53
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs101
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/OtpService.cs357
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/AbstractProcessor.cs56
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs184
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/FeedConfigProcessor.cs84
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/FilterAndSortProcessor.cs44
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs26
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/NextStopsProcessor.cs34
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/ShapeProcessor.cs132
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs254
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs8
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs78
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs311
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Providers/XuntaFareProvider.cs57
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs284
20 files changed, 0 insertions, 2571 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs b/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs
deleted file mode 100644
index 3c9368c..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/ArrivalsPipeline.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using Costasdev.Busurbano.Backend.Types;
-using Costasdev.Busurbano.Backend.Types.Arrivals;
-
-namespace Costasdev.Busurbano.Backend.Services;
-
-public class ArrivalsContext
-{
- /// <summary>
- /// The full GTFS ID of the stop (e.g., "vitrasa:1400")
- /// </summary>
- public required string StopId { get; set; }
-
- /// <summary>
- /// The public code of the stop (e.g., "1400")
- /// </summary>
- public required string StopCode { get; set; }
-
- /// <summary>
- /// Whether to return a reduced number of arrivals (e.g., 4 instead of 10)
- /// </summary>
- public bool IsReduced { get; set; }
-
- public 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/Costasdev.Busurbano.Backend/Services/FareService.cs b/src/Costasdev.Busurbano.Backend/Services/FareService.cs
deleted file mode 100644
index c08d1d5..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/FareService.cs
+++ /dev/null
@@ -1,225 +0,0 @@
-using Costasdev.Busurbano.Backend.Configuration;
-using Costasdev.Busurbano.Backend.Services.Providers;
-using Costasdev.Busurbano.Backend.Types.Planner;
-using Microsoft.Extensions.Options;
-
-namespace Costasdev.Busurbano.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 "santiago":
- total += SantiagoCashFare;
- break;
- case "coruna":
- 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 "coruna":
- maxMinutes = 45;
- maxUsages = 2;
- initialFare = CorunaCardFare;
- break;
- case "santiago":
- 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/Costasdev.Busurbano.Backend/Services/FeedService.cs b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs
deleted file mode 100644
index 3ef079c..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs
+++ /dev/null
@@ -1,213 +0,0 @@
-using System.Text.RegularExpressions;
-using Costasdev.Busurbano.Backend.Types.Arrivals;
-
-namespace Costasdev.Busurbano.Backend.Services;
-
-public class FeedService
-{
- private static readonly Regex RemoveQuotationMarks = new(@"[""”]", RegexOptions.IgnoreCase | RegexOptions.Compiled);
- private static readonly Regex StreetNameRegex = new(@"^(.*?)(?:,|\s\s|\s-\s| \d| S\/N|\s\()", RegexOptions.IgnoreCase | RegexOptions.Compiled);
-
- private static readonly Dictionary<string, string> NameReplacements = new(StringComparer.OrdinalIgnoreCase)
- {
- { "Rúa da Salguera Entrada", "Rúa da Salgueira" },
- { "Rúa da Salgueira Entrada", "Rúa da Salgueira" },
- { "Estrada de Miraflores", "Estrada Miraflores" },
- { "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"),
- "santiago" => ("#508096", "#FFFFFF"),
- "coruna" => ("#E61C29", "#FFFFFF"),
- "xunta" => ("#007BC4", "#FFFFFF"),
- "renfe" => ("#870164", "#FFFFFF"),
- "feve" => ("#EE3D32", "#FFFFFF"),
- _ => ("#000000", "#FFFFFF"),
- };
- }
-
- public string NormalizeStopCode(string feedId, string code)
- {
- if (feedId == "vitrasa")
- {
- var digits = new string(code.Where(char.IsDigit).ToArray());
- if (int.TryParse(digits, out int numericCode))
- {
- return numericCode.ToString();
- }
- }
- return code;
- }
-
- public string NormalizeRouteShortName(string feedId, string shortName)
- {
- if (feedId == "xunta" && shortName.StartsWith("XG"))
- {
- 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 == "vitrasa" || feedId == "coruna")
- {
- var streets = nextStops
- .Select(GetStreetName)
- .Where(s => !string.IsNullOrWhiteSpace(s))
- .Distinct()
- .ToList();
-
- return string.Join(" - ", streets);
- }
-
- return feedId switch
- {
- "xunta" => string.Join(" > ", nextStops),
- _ => string.Join(", ", nextStops.Take(4))
- };
- }
-
- public bool IsStopHidden(string stopId)
- {
- return HiddenStops.Contains(stopId);
- }
-
- public ShiftBadge? GetShiftBadge(string feedId, string tripId)
- {
- if (feedId != "vitrasa") return null;
-
- // Example: C1 04LN 02_001004_4
- var parts = tripId.Split('_');
- if (parts.Length < 2) return null;
-
- var shiftGroup = parts[parts.Length - 2]; // 001004
- var tripNumber = parts[parts.Length - 1]; // 4
-
- if (shiftGroup.Length != 6) return null;
-
- if (!int.TryParse(shiftGroup.Substring(0, 3), out var routeNum)) return null;
- if (!int.TryParse(shiftGroup.Substring(3, 3), out var shiftNum)) return null;
-
- var routeName = routeNum switch
- {
- 1 => "C1",
- 3 => "C3",
- 30 => "N1",
- 33 => "N4",
- 8 => "A",
- 101 => "H",
- 201 => "U1",
- 202 => "U2",
- 150 => "REF",
- 500 => "TUR",
- _ => $"L{routeNum}"
- };
-
- return new ShiftBadge
- {
- ShiftName = $"{routeName}-{shiftNum}",
- ShiftTrip = tripNumber
- };
- }
-
- private static readonly string[] HiddenStops =
- [
- "vitrasa:20223", // Castrelos (Pavillón - U1)
- "vitrasa:20146", // García Barbón, 7 (A, 18A)
- "vitrasa:20220", // COIA-SAMIL (15)
- "vitrasa:20001", // Samil por Beiramar (15B)
- "vitrasa:20002", // Samil por Torrecedeira (15C)
- "vitrasa:20144", // Samil por Coia (C3d, C3i)
- "vitrasa:20145" // Samil por Bouzs (C3d, C3i)
- ];
-}
diff --git a/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs b/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs
deleted file mode 100644
index 3ac29d6..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using Costasdev.Busurbano.Backend.Types.Planner;
-
-namespace Costasdev.Busurbano.Backend.Services;
-
-public interface IGeocodingService
-{
- Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query);
- Task<PlannerSearchResult?> GetReverseGeocodeAsync(double lat, double lon);
-}
diff --git a/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs b/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs
deleted file mode 100644
index db9f2a5..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using Costasdev.Busurbano.Backend.Types;
-
-namespace Costasdev.Busurbano.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/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs b/src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs
deleted file mode 100644
index 01e57f1..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs
+++ /dev/null
@@ -1,101 +0,0 @@
-using System.Globalization;
-using Costasdev.Busurbano.Backend.Configuration;
-using Costasdev.Busurbano.Backend.Types.Nominatim;
-using Costasdev.Busurbano.Backend.Types.Planner;
-using Microsoft.Extensions.Caching.Memory;
-using Microsoft.Extensions.Options;
-
-namespace Costasdev.Busurbano.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", "Busurbano/1.0 (https://github.com/arielcostas/Busurbano)");
- }
- }
-
- 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/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs
deleted file mode 100644
index fb7413c..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs
+++ /dev/null
@@ -1,357 +0,0 @@
-using System.Globalization;
-using Costasdev.Busurbano.Backend.Configuration;
-using Costasdev.Busurbano.Backend.Helpers;
-using Costasdev.Busurbano.Backend.Types.Otp;
-using Costasdev.Busurbano.Backend.Types.Planner;
-using Costasdev.Busurbano.Backend.Types.Transit;
-using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries;
-using Microsoft.Extensions.Caching.Memory;
-using Microsoft.Extensions.Options;
-
-namespace Costasdev.Busurbano.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/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<CorunaRealTimeProcessor> _logger;
- private readonly ShapeTraversalService _shapeService;
-
- public CorunaRealTimeProcessor(
- HttpClient http,
- FeedService feedService,
- ILogger<CorunaRealTimeProcessor> logger,
- ShapeTraversalService shapeService)
- {
- _realtime = new CorunaRealtimeEstimatesProvider(http);
- _feedService = feedService;
- _logger = logger;
- _shapeService = shapeService;
- }
-
- public override async Task ProcessAsync(ArrivalsContext context)
- {
- if (!context.StopId.StartsWith("coruna:")) return;
-
- var normalizedCode = _feedService.NormalizeStopCode("coruna", context.StopCode);
- if (!int.TryParse(normalizedCode, out var numericStopId)) return;
-
- try
- {
- // Load schedule
- var todayDate = context.NowLocal.Date.ToString("yyyy-MM-dd");
-
- Epsg25829? stopLocation = null;
- if (context.StopLocation != null)
- {
- stopLocation = _shapeService.TransformToEpsg25829(context.StopLocation.Latitude, context.StopLocation.Longitude);
- }
-
- var realtime = await _realtime.GetEstimatesForStop(numericStopId);
-
- var usedTripIds = new HashSet<string>();
- var newArrivals = new List<Arrival>();
-
- foreach (var estimate in realtime)
- {
- var bestMatch = context.Arrivals
- .Where(a => !usedTripIds.Contains(a.TripId))
- .Where(a => a.Route.RouteIdInGtfs.Trim() == estimate.RouteId.Trim())
- .Select(a =>
- {
- return new
- {
- Arrival = a,
- TimeDiff = estimate.Minutes - a.Estimate.Minutes, // RealTime - Schedule
- RouteMatch = true
- };
- })
- .Where(x => x.RouteMatch) // Strict route matching
- .Where(x => x.TimeDiff >= -7 && x.TimeDiff <= 75) // Allow 7m early (RealTime < Schedule) or 75m late (RealTime > Schedule)
- .OrderBy(x => Math.Abs(x.TimeDiff)) // Best time fit
- .FirstOrDefault();
-
- if (bestMatch == null)
- {
- continue;
- }
-
- var arrival = bestMatch.Arrival;
-
- var scheduledMinutes = arrival.Estimate.Minutes;
- arrival.Estimate.Minutes = estimate.Minutes;
- arrival.Estimate.Precision = ArrivalPrecision.Confident;
-
- // Calculate delay badge
- var delayMinutes = estimate.Minutes - scheduledMinutes;
- if (delayMinutes != 0)
- {
- arrival.Delay = new DelayBadge { Minutes = delayMinutes };
- }
-
- // Calculate position
- if (stopLocation != null)
- {
- Position? currentPosition = null;
- int? stopShapeIndex = null;
-
- if (arrival.RawOtpTrip is ArrivalsAtStopResponse.Arrival otpArrival &&
- otpArrival.Trip.Geometry?.Points != null)
- {
- var decodedPoints = Decode(otpArrival.Trip.Geometry.Points)
- .Select(p => new Position { Latitude = p.Lat, Longitude = p.Lon })
- .ToList();
-
- var shape = _shapeService.CreateShapeFromWgs84(decodedPoints);
-
- // Ensure meters is positive
- var meters = Math.Max(0, estimate.Metres);
- var result = _shapeService.GetBusPosition(shape, stopLocation, meters);
-
- currentPosition = result.BusPosition;
- stopShapeIndex = result.StopIndex;
-
- if (currentPosition != null)
- {
- _logger.LogInformation("Calculated position from OTP geometry for trip {TripId}: {Lat}, {Lon}", arrival.TripId, currentPosition.Latitude, currentPosition.Longitude);
- }
-
- // Populate Shape GeoJSON
- if (!context.IsReduced && currentPosition != null)
- {
- var features = new List<object>();
- features.Add(new
- {
- type = "Feature",
- geometry = new
- {
- type = "LineString",
- coordinates = decodedPoints.Select(p => new[] { p.Longitude, p.Latitude }).ToList()
- },
- properties = new { type = "route" }
- });
-
- // Add stops if available
- if (otpArrival.Trip.Stoptimes != null)
- {
- foreach (var stoptime in otpArrival.Trip.Stoptimes)
- {
- features.Add(new
- {
- type = "Feature",
- geometry = new
- {
- type = "Point",
- coordinates = new[] { stoptime.Stop.Lon, stoptime.Stop.Lat }
- },
- properties = new
- {
- type = "stop",
- name = stoptime.Stop.Name
- }
- });
- }
- }
-
- arrival.Shape = new
- {
- type = "FeatureCollection",
- features = features
- };
- }
- }
-
- if (currentPosition != null)
- {
- arrival.CurrentPosition = currentPosition;
- arrival.StopShapeIndex = stopShapeIndex;
- }
- }
-
- usedTripIds.Add(arrival.TripId);
-
- }
-
- context.Arrivals.AddRange(newArrivals);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error fetching Vitrasa real-time data for stop {StopId}", context.StopId);
- }
- }
-
- private static bool IsRouteMatch(string a, string b)
- {
- return a == b || a.Contains(b) || b.Contains(a);
- }
-
-}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/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;
-
-/// <summary>
-/// Filters and sorts the arrivals based on the feed and the requested limit.
-/// This should run after real-time matching but before heavy enrichment (shapes, marquee).
-/// </summary>
-public class FilterAndSortProcessor : IArrivalsProcessor
-{
- public Task ProcessAsync(ArrivalsContext context)
- {
- var feedId = context.StopId.Split(':')[0];
-
- // 1. Sort by minutes
- var sorted = context.Arrivals
- .OrderBy(a => a.Estimate.Minutes)
- .ToList();
-
- // 2. Filter based on feed rules
- var filtered = sorted.Where(a =>
- {
- if (feedId == "vitrasa")
- {
- // For Vitrasa, we hide past arrivals because we have real-time
- // If a past arrival was matched to a real-time estimate, its Minutes will be >= 0
- return a.Estimate.Minutes >= 0;
- }
-
- // For others, show up to 10 minutes ago
- return a.Estimate.Minutes >= -10;
- }).ToList();
-
- // 3. Limit results
- var limit = context.IsReduced ? 4 : 10;
- var limited = filtered.Take(limit).ToList();
-
- // Update the context list in-place
- context.Arrivals.Clear();
- context.Arrivals.AddRange(limited);
-
- return Task.CompletedTask;
- }
-}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/MarqueeProcessor.cs
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<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/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<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/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs
deleted file mode 100644
index f0440e4..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Providers/ITransitProvider.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using Costasdev.Busurbano.Backend.Types;
-
-namespace Costasdev.Busurbano.Backend.Services.Providers;
-
-public interface ITransitProvider
-{
- Task<List<ConsolidatedCirculation>> GetCirculationsAsync(string stopId, DateTime now);
-}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs
deleted file mode 100644
index 1793ada..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Providers/RenfeTransitProvider.cs
+++ /dev/null
@@ -1,78 +0,0 @@
-using Costasdev.Busurbano.Backend.Configuration;
-using Costasdev.Busurbano.Backend.Extensions;
-using Costasdev.Busurbano.Backend.Types;
-using Microsoft.Extensions.Options;
-using SysFile = System.IO.File;
-
-namespace Costasdev.Busurbano.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");
- var stopArrivals = await LoadStopArrivalsProto(stopId, todayDate);
-
- 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;
- }
-
- private async Task<StopArrivals?> LoadStopArrivalsProto(string stopId, string dateString)
- {
- var file = Path.Combine(_configuration.RenfeScheduleBasePath, 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;
- }
-}
diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs
deleted file mode 100644
index a736652..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs
+++ /dev/null
@@ -1,311 +0,0 @@
-using System.Globalization;
-using System.Text;
-using Costasdev.Busurbano.Backend.Configuration;
-using Costasdev.Busurbano.Backend.Extensions;
-using Costasdev.Busurbano.Backend.Types;
-using Costasdev.VigoTransitApi;
-using Microsoft.Extensions.Options;
-using static Costasdev.Busurbano.Backend.Types.StopArrivals.Types;
-using SysFile = System.IO.File;
-
-namespace Costasdev.Busurbano.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();
-
- 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;
-
- // Calculate bus position for realtime trips
- if (!string.IsNullOrEmpty(closestCirculation.ShapeId))
- {
- double distOnPrevTrip = estimate.Meters - closestCirculation.ShapeDistTraveled;
- usePreviousShape = !isRunning &&
- !string.IsNullOrEmpty(closestCirculation.PreviousTripShapeId) &&
- distOnPrevTrip > 0;
-
- if (usePreviousShape)
- {
- var prevShape = await _shapeService.LoadShapeAsync(closestCirculation.PreviousTripShapeId);
- if (prevShape != null && prevShape.Points.Count > 0)
- {
- var lastPoint = prevShape.Points[prevShape.Points.Count - 1];
- var result = _shapeService.GetBusPosition(prevShape, lastPoint, (int)distOnPrevTrip);
- currentPosition = result.BusPosition;
- stopShapeIndex = result.StopIndex;
- }
- }
- else
- {
- var shape = await _shapeService.LoadShapeAsync(closestCirculation.ShapeId);
- if (shape != null && stopLocation != null)
- {
- var result = _shapeService.GetBusPosition(shape, stopLocation, estimate.Meters);
- currentPosition = result.BusPosition;
- stopShapeIndex = result.StopIndex;
- }
- }
- }
-
- 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)
- {
- 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/Costasdev.Busurbano.Backend/Services/Providers/XuntaFareProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/XuntaFareProvider.cs
deleted file mode 100644
index 7536c69..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/Providers/XuntaFareProvider.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-using System.Collections.Frozen;
-using System.Globalization;
-using CsvHelper;
-using CsvHelper.Configuration.Attributes;
-
-namespace Costasdev.Busurbano.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/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs b/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs
deleted file mode 100644
index c3c66f4..0000000
--- a/src/Costasdev.Busurbano.Backend/Services/ShapeTraversalService.cs
+++ /dev/null
@@ -1,284 +0,0 @@
-using Costasdev.Busurbano.Backend.Configuration;
-using Costasdev.Busurbano.Backend.Types;
-using Microsoft.Extensions.Options;
-using ProjNet.CoordinateSystems;
-using ProjNet.CoordinateSystems.Transformations;
-using SysFile = System.IO.File;
-
-namespace Costasdev.Busurbano.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);
- }
-
- /// <summary>
- /// Loads a shape from disk
- /// </summary>
- public async Task<Shape?> LoadShapeAsync(string shapeId)
- {
- var file = Path.Combine(_configuration.VitrasaScheduleBasePath, "shapes", shapeId + ".pb");
- if (!SysFile.Exists(file))
- {
- _logger.LogWarning("Shape file not found: {ShapeId}", shapeId);
- return null;
- }
-
- try
- {
- var contents = await SysFile.ReadAllBytesAsync(file);
- var shape = Shape.Parser.ParseFrom(contents);
- return shape;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error loading shape {ShapeId}", shapeId);
- return null;
- }
- }
-
- public async Task<List<Position>?> GetShapePathAsync(string shapeId, int startIndex)
- {
- var shape = await LoadShapeAsync(shapeId);
- if (shape == null) return null;
-
- var result = new List<Position>();
- // Ensure startIndex is within bounds
- if (startIndex < 0) startIndex = 0;
- // If startIndex is beyond the end, return empty list
- if (startIndex >= shape.Points.Count) return result;
-
- for (int i = startIndex; i < shape.Points.Count; i++)
- {
- var pos = TransformToLatLng(shape.Points[i]);
- pos.ShapeIndex = i;
- result.Add(pos);
- }
- return result;
- }
-
- public async Task<int?> FindClosestPointIndexAsync(string shapeId, double lat, double lon)
- {
- var shape = await LoadShapeAsync(shapeId);
- if (shape == null) return null;
-
- // Transform input WGS84 to EPSG:25829
- // Input is [Longitude, Latitude]
- var inverseTransform = _transformation.MathTransform.Inverse();
- var transformed = inverseTransform.Transform(new[] { lon, lat });
-
- var location = new Epsg25829 { X = transformed[0], Y = transformed[1] };
-
- return FindClosestPointIndex(shape.Points, location);
- }
-
- public Shape CreateShapeFromWgs84(List<Position> points)
- {
- var shape = new Shape();
- var inverseTransform = _transformation.MathTransform.Inverse();
-
- foreach (var point in points)
- {
- var transformed = inverseTransform.Transform(new[] { point.Longitude, point.Latitude });
- shape.Points.Add(new Epsg25829 { X = transformed[0], Y = transformed[1] });
- }
- return shape;
- }
-
- public Epsg25829 TransformToEpsg25829(double lat, double lon)
- {
- var inverseTransform = _transformation.MathTransform.Inverse();
- var transformed = inverseTransform.Transform(new[] { lon, lat });
- return new Epsg25829 { X = transformed[0], Y = transformed[1] };
- }
-
- /// <summary>
- /// Calculates the bus position by reverse-traversing the shape from the stop location
- /// </summary>
- /// <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)
- };
- }
-
-}