From a3eb2d0441ae18f75604a4bee64db18391469837 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 2 Jan 2026 01:08:41 +0100 Subject: feat: Integrate Geoapify geocoding service and update configuration --- .../Services/Geocoding/GeoapifyGeocodingService.cs | 110 ++++++++ .../Services/Geocoding/IGeocodingService.cs | 9 + .../Geocoding/NominatimGeocodingService.cs | 101 ++++++++ src/Enmarcha.Backend/Services/IGeocodingService.cs | 9 - .../Services/NominatimGeocodingService.cs | 101 -------- .../Services/Processors/CorunaRealTimeProcessor.cs | 1 - .../Services/Processors/FilterAndSortProcessor.cs | 2 - .../Processors/SantiagoRealTimeProcessor.cs | 3 - .../Services/Providers/ITransitProvider.cs | 8 - .../Services/Providers/RenfeTransitProvider.cs | 64 ----- .../Services/Providers/VitrasaTransitProvider.cs | 281 --------------------- .../Services/Providers/XuntaFareProvider.cs | 2 +- .../Services/ShapeTraversalService.cs | 1 - 13 files changed, 221 insertions(+), 471 deletions(-) create mode 100644 src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs create mode 100644 src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs create mode 100644 src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs delete mode 100644 src/Enmarcha.Backend/Services/IGeocodingService.cs delete mode 100644 src/Enmarcha.Backend/Services/NominatimGeocodingService.cs delete mode 100644 src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs delete mode 100644 src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs delete mode 100644 src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs (limited to 'src/Enmarcha.Backend/Services') diff --git a/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs new file mode 100644 index 0000000..d6cf5f6 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Geocoding/GeoapifyGeocodingService.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Types.Geoapify; +using Enmarcha.Backend.Types.Planner; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Services.Geocoding; + +public class GeoapifyGeocodingService : IGeocodingService +{ + private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly AppConfiguration _config; + + private static readonly string[] ForbiddenResultTypes = ["city", "state", "county", "postcode"]; + + public GeoapifyGeocodingService(HttpClient httpClient, IMemoryCache cache, ILogger logger, IOptions config) + { + _httpClient = httpClient; + _cache = cache; + _logger = logger; + _config = config.Value; + + // Geoapify requires a User-Agent + if (!_httpClient.DefaultRequestHeaders.Contains("User-Agent")) + { + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)"); + } + } + + public async Task> GetAutocompleteAsync(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return []; + } + + var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}"; + if (_cache.TryGetValue(cacheKey, out List? cachedResults) && cachedResults != null) + { + return cachedResults; + } + + var url = $"https://api.geoapify.com/v1/geocode/autocomplete?text={Uri.EscapeDataString(query)}&lang=gl&limit=5&filter=rect:-9.449497230816405,41.89720361654395,-6.581039728137625,43.92616367306067&format=json"; + + try + { + var response = await _httpClient.GetFromJsonAsync(url + $"&apiKey={_config.GeoapifyApiKey}"); + + + var results = response?.results + .Where(x => !ForbiddenResultTypes.Contains(x.result_type)) + .Select(MapToPlannerSearchResult) + .ToList() ?? []; + + _cache.Set(cacheKey, results, TimeSpan.FromMinutes(60)); + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Geoapify autocomplete results from {Url}", url); + return new List(); + } + } + + public async Task GetReverseGeocodeAsync(double lat, double lon) + { + var cacheKey = $"nominatim_reverse_{lat:F5}_{lon:F5}"; + if (_cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult) && cachedResult != null) + { + return cachedResult; + } + + var url = + $"https://api.geoapify.com/v1/geocode/reverse?lat={lat.ToString(CultureInfo.InvariantCulture)}&lon={lon.ToString(CultureInfo.InvariantCulture)}&lang=gl&format=json"; + try + { + var response = await _httpClient.GetFromJsonAsync(url + $"&apiKey={_config.GeoapifyApiKey}"); + + if (response == null) return null; + + var result = MapToPlannerSearchResult(response.results[0]); + + _cache.Set(cacheKey, result, TimeSpan.FromMinutes(60)); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Geoapify reverse geocode results from {Url}", url); + return null; + } + } + + private PlannerSearchResult MapToPlannerSearchResult(Result result) + { + var name = result.name ?? result.address_line1; + var label = $"{result.street} ({result.postcode} {result.city}, {result.county})"; + + return new PlannerSearchResult + { + Name = name, + Label = label, + Lat = result.lat, + Lon = result.lon, + Layer = result.result_type + }; + } +} diff --git a/src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs new file mode 100644 index 0000000..8619b0a --- /dev/null +++ b/src/Enmarcha.Backend/Services/Geocoding/IGeocodingService.cs @@ -0,0 +1,9 @@ +using Enmarcha.Backend.Types.Planner; + +namespace Enmarcha.Backend.Services.Geocoding; + +public interface IGeocodingService +{ + Task> GetAutocompleteAsync(string query); + Task GetReverseGeocodeAsync(double lat, double lon); +} diff --git a/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs b/src/Enmarcha.Backend/Services/Geocoding/NominatimGeocodingService.cs new file mode 100644 index 0000000..c38b1e6 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Geocoding/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.Geocoding; + +public class NominatimGeocodingService : IGeocodingService +{ + private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly AppConfiguration _config; + + private const string GaliciaBounds = "-9.3,43.8,-6.7,41.7"; + + public NominatimGeocodingService(HttpClient httpClient, IMemoryCache cache, ILogger logger, IOptions 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", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app; ariel@costas.dev)"); + } + } + + public async Task> GetAutocompleteAsync(string query) + { + if (string.IsNullOrWhiteSpace(query)) return new List(); + + var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}"; + if (_cache.TryGetValue(cacheKey, out List? 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>(url); + + var results = response?.Select(MapToPlannerSearchResult).ToList() ?? new List(); + + _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(); + } + } + + public async Task 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(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/IGeocodingService.cs b/src/Enmarcha.Backend/Services/IGeocodingService.cs deleted file mode 100644 index 5c1b19e..0000000 --- a/src/Enmarcha.Backend/Services/IGeocodingService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Enmarcha.Backend.Types.Planner; - -namespace Enmarcha.Backend.Services; - -public interface IGeocodingService -{ - Task> GetAutocompleteAsync(string query); - Task GetReverseGeocodeAsync(double lat, double lon); -} diff --git a/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs b/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs deleted file mode 100644 index e8eccb5..0000000 --- a/src/Enmarcha.Backend/Services/NominatimGeocodingService.cs +++ /dev/null @@ -1,101 +0,0 @@ -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 _logger; - private readonly AppConfiguration _config; - - private const string GaliciaBounds = "-9.3,43.8,-6.7,41.7"; - - public NominatimGeocodingService(HttpClient httpClient, IMemoryCache cache, ILogger logger, IOptions 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", "Mozilla/5.0 (Compatible; Enmarcha/0.1; https://enmarcha.app)"); - } - } - - public async Task> GetAutocompleteAsync(string query) - { - if (string.IsNullOrWhiteSpace(query)) return new List(); - - var cacheKey = $"nominatim_autocomplete_{query.ToLowerInvariant()}"; - if (_cache.TryGetValue(cacheKey, out List? 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>(url); - - var results = response?.Select(MapToPlannerSearchResult).ToList() ?? new List(); - - _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(); - } - } - - public async Task 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(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/Processors/CorunaRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs index ad5465f..6a9d9dc 100644 --- a/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs +++ b/src/Enmarcha.Backend/Services/Processors/CorunaRealTimeProcessor.cs @@ -2,7 +2,6 @@ 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; diff --git a/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs index 7df00fa..18ba2ac 100644 --- a/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs +++ b/src/Enmarcha.Backend/Services/Processors/FilterAndSortProcessor.cs @@ -1,5 +1,3 @@ -using Enmarcha.Backend.Types.Arrivals; - namespace Enmarcha.Backend.Services.Processors; /// diff --git a/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs index d14cfa0..929e439 100644 --- a/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs +++ b/src/Enmarcha.Backend/Services/Processors/SantiagoRealTimeProcessor.cs @@ -1,7 +1,4 @@ using Enmarcha.Backend.Helpers; -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; diff --git a/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs deleted file mode 100644 index 77f6341..0000000 --- a/src/Enmarcha.Backend/Services/Providers/ITransitProvider.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Enmarcha.Backend.Types; - -namespace Enmarcha.Backend.Services.Providers; - -public interface ITransitProvider -{ - Task> GetCirculationsAsync(string stopId, DateTime now); -} diff --git a/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs b/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs deleted file mode 100644 index 036c9b1..0000000 --- a/src/Enmarcha.Backend/Services/Providers/RenfeTransitProvider.cs +++ /dev/null @@ -1,64 +0,0 @@ -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 _logger; - - public RenfeTransitProvider(IOptions options, ILogger logger) - { - _configuration = options.Value; - _logger = logger; - } - - public async Task> 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(); - - 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 deleted file mode 100644 index 8a05fc6..0000000 --- a/src/Enmarcha.Backend/Services/Providers/VitrasaTransitProvider.cs +++ /dev/null @@ -1,281 +0,0 @@ -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 _logger; - - public VitrasaTransitProvider(HttpClient http, IOptions options, ShapeTraversalService shapeService, LineFormatterService lineFormatter, ILogger logger) - { - _api = new VigoTransitApiClient(http); - _configuration = options.Value; - _shapeService = shapeService; - _lineFormatter = lineFormatter; - _logger = logger; - } - - public async Task> 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 consolidatedCirculations = []; - var usedTripIds = new HashSet(); - - 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(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 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 index 4bb60e2..1780c09 100644 --- a/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs +++ b/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs @@ -1,4 +1,4 @@ -using System.Collections.Frozen; +using System.Collections.Frozen; using System.Globalization; using CsvHelper; using CsvHelper.Configuration.Attributes; diff --git a/src/Enmarcha.Backend/Services/ShapeTraversalService.cs b/src/Enmarcha.Backend/Services/ShapeTraversalService.cs index 221a975..1f77929 100644 --- a/src/Enmarcha.Backend/Services/ShapeTraversalService.cs +++ b/src/Enmarcha.Backend/Services/ShapeTraversalService.cs @@ -3,7 +3,6 @@ using Enmarcha.Backend.Types; using Microsoft.Extensions.Options; using ProjNet.CoordinateSystems; using ProjNet.CoordinateSystems.Transformations; -using SysFile = System.IO.File; namespace Enmarcha.Backend.Services; -- cgit v1.3