From 4fb2fe683b75464917dec4b1a0aaee63830f3b9a Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sun, 28 Dec 2025 15:59:32 +0100 Subject: feat: Refactor NavBar and Planner components; update geocoding services - Removed unused Navigation2 icon from NavBar. - Updated usePlanner hook to manage route history and improve local storage handling. - Enhanced PlannerApi with new fare properties and improved itinerary handling. - Added recent routes feature in StopList with navigation to planner. - Implemented NominatimGeocodingService for autocomplete and reverse geocoding. - Updated UI components for better user experience and accessibility. - Added translations for recent routes in multiple languages. - Improved CSS styles for map controls and overall layout. --- .../Configuration/AppConfiguration.cs | 1 + .../Controllers/RoutePlannerController.cs | 14 +-- src/Costasdev.Busurbano.Backend/Program.cs | 1 + .../Services/FareService.cs | 34 ++++--- .../Services/IGeocodingService.cs | 9 ++ .../Services/NominatimGeocodingService.cs | 101 +++++++++++++++++++++ .../Services/OtpService.cs | 77 +--------------- .../Types/Nominatim/NominatimModels.cs | 75 +++++++++++++++ .../Types/Otp/OtpModels.cs | 40 -------- .../Types/Planner/PlannerResponse.cs | 6 +- 10 files changed, 225 insertions(+), 133 deletions(-) create mode 100644 src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs create mode 100644 src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs create mode 100644 src/Costasdev.Busurbano.Backend/Types/Nominatim/NominatimModels.cs (limited to 'src/Costasdev.Busurbano.Backend') diff --git a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs index 3204e33..9e4d12f 100644 --- a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs +++ b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs @@ -10,6 +10,7 @@ public class AppConfiguration [Obsolete] public required string OtpPlannerBaseUrl { get; set; } = "https://planificador-rutas.vigo.org/otp/routers/default"; public required string OpenTripPlannerBaseUrl { get; set; } + public string NominatimBaseUrl { get; set; } = "https://nominatim.openstreetmap.org"; // Default Routing Parameters public double WalkSpeed { get; set; } = 1.4; diff --git a/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs index 7d47383..a7faf44 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs @@ -15,18 +15,21 @@ public partial class RoutePlannerController : ControllerBase { private readonly ILogger _logger; private readonly OtpService _otpService; + private readonly IGeocodingService _geocodingService; private readonly AppConfiguration _config; private readonly HttpClient _httpClient; public RoutePlannerController( ILogger logger, OtpService otpService, + IGeocodingService geocodingService, IOptions config, HttpClient httpClient ) { _logger = logger; _otpService = otpService; + _geocodingService = geocodingService; _config = config.Value; _httpClient = httpClient; } @@ -39,14 +42,14 @@ public partial class RoutePlannerController : ControllerBase return BadRequest("Query cannot be empty"); } - var results = await _otpService.GetAutocompleteAsync(query); + var results = await _geocodingService.GetAutocompleteAsync(query); return Ok(results); } [HttpGet("reverse")] public async Task> Reverse([FromQuery] double lat, [FromQuery] double lon) { - var result = await _otpService.GetReverseGeocodeAsync(lat, lon); + var result = await _geocodingService.GetReverseGeocodeAsync(lat, lon); if (result == null) { return NotFound(); @@ -60,13 +63,13 @@ public partial class RoutePlannerController : ControllerBase [FromQuery] double fromLon, [FromQuery] double toLat, [FromQuery] double toLon, - [FromQuery] DateTimeOffset time, + [FromQuery] DateTimeOffset? time, [FromQuery] bool arriveBy = false) { try { var requestContent = PlanConnectionContent.Query( - new PlanConnectionContent.Args(fromLat, fromLon, toLat, toLon, time, arriveBy) + new PlanConnectionContent.Args(fromLat, fromLon, toLat, toLon, time ?? DateTimeOffset.Now, arriveBy) ); var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1"); @@ -78,7 +81,7 @@ public partial class RoutePlannerController : ControllerBase var response = await _httpClient.SendAsync(request); var responseBody = await response.Content.ReadFromJsonAsync>(); - if (responseBody is not { IsSuccess: true } || responseBody.Data?.PlanConnection.Edges.Length == 0) + if (responseBody is not { IsSuccess: true }) { LogErrorFetchingRoutes(response.StatusCode, await response.Content.ReadAsStringAsync()); return StatusCode(500, "An error occurred while planning the route."); @@ -96,5 +99,4 @@ public partial class RoutePlannerController : ControllerBase [LoggerMessage(LogLevel.Error, "Error fetching route planning, received {statusCode} {responseBody}")] partial void LogErrorFetchingRoutes(HttpStatusCode? statusCode, string responseBody); - } diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs index 94ebe62..97e7354 100644 --- a/src/Costasdev.Busurbano.Backend/Program.cs +++ b/src/Costasdev.Busurbano.Backend/Program.cs @@ -36,6 +36,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Costasdev.Busurbano.Backend/Services/FareService.cs b/src/Costasdev.Busurbano.Backend/Services/FareService.cs index d0423e6..c08d1d5 100644 --- a/src/Costasdev.Busurbano.Backend/Services/FareService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/FareService.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Options; namespace Costasdev.Busurbano.Backend.Services; -public record FareResult(decimal CashFareEuro, decimal CardFareEuro); +public record FareResult(decimal CashFareEuro, bool CashFareIsTotal, decimal CardFareEuro, bool CardFareIsTotal); public class FareService { @@ -41,18 +41,23 @@ public class FareService if (!transitLegs.Any()) { - return new FareResult(0, 0); + return new FareResult(0, true, 0, true); } + var cashResult = CalculateCashTotal(transitLegs); + var cardResult = CalculateCardTotal(transitLegs); + return new FareResult( - CalculateCashTotal(transitLegs), - CalculateCardTotal(transitLegs) + cashResult.Item1, cashResult.Item2, + cardResult.Item1, cardResult.Item2 ); } - private decimal CalculateCashTotal(IEnumerable legs) + private (decimal, bool) CalculateCashTotal(IEnumerable legs) { decimal total = 0L; + bool allLegsProcessed = true; + foreach (var leg in legs) { switch (leg.FeedId) @@ -80,21 +85,25 @@ public class FareService total += _xuntaFareProvider.GetPrice(leg.From!.ZoneId!, leg.To!.ZoneId!)!.PriceCash; break; + default: + allLegsProcessed = false; + _logger.LogWarning("Unknown FeedId: {FeedId}", leg.FeedId); + break; } } - return total; + return (total, allLegsProcessed); } - private decimal CalculateCardTotal(IEnumerable legs) + private (decimal, bool) CalculateCardTotal(IEnumerable legs) { List wallet = []; decimal totalCost = 0; + bool allLegsProcessed = true; + foreach (var leg in legs) { - _logger.LogDebug("Processing leg {leg}", leg); - int maxMinutes; int maxUsages; string? metroArea = null; @@ -138,6 +147,7 @@ public class FareService break; default: _logger.LogWarning("Unknown FeedId: {FeedId}", leg.FeedId); + allLegsProcessed = false; continue; } @@ -193,17 +203,17 @@ public class FareService } } - return totalCost; + return (totalCost, allLegsProcessed); } } public class TicketPurchased { - public string FeedId { get; set; } + public required string FeedId { get; set; } public DateTime PurchasedAt { get; set; } public string? MetroArea { get; set; } - public string StartZone { get; set; } + public required string StartZone { get; set; } public int UsedTimes = 1; public decimal TotalPaid { get; set; } diff --git a/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs b/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs new file mode 100644 index 0000000..3ac29d6 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/IGeocodingService.cs @@ -0,0 +1,9 @@ +using Costasdev.Busurbano.Backend.Types.Planner; + +namespace Costasdev.Busurbano.Backend.Services; + +public interface IGeocodingService +{ + Task> GetAutocompleteAsync(string query); + Task GetReverseGeocodeAsync(double lat, double lon); +} diff --git a/src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs b/src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs new file mode 100644 index 0000000..01e57f1 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/NominatimGeocodingService.cs @@ -0,0 +1,101 @@ +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 _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", "Busurbano/1.0 (https://github.com/arielcostas/Busurbano)"); + } + } + + 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/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs index b7e2d3f..704139d 100644 --- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs @@ -30,77 +30,6 @@ public class OtpService _feedService = feedService; } - public async Task> GetAutocompleteAsync(string query) - { - if (string.IsNullOrWhiteSpace(query)) return new List(); - - var cacheKey = $"otp_autocomplete_{query.ToLowerInvariant()}"; - if (_cache.TryGetValue(cacheKey, out List? cachedResults) && cachedResults != null) - { - return cachedResults; - } - - try - { - // https://planificador-rutas-api.vigo.org/v1/autocomplete?text=XXXX&layers=venue,street,address&lang=es - var url = $"{_config.OtpGeocodingBaseUrl}/autocomplete?text={Uri.EscapeDataString(query)}&layers=venue,address&lang=es"; - var response = await _httpClient.GetFromJsonAsync(url); - - var results = response?.Features.Select(f => new PlannerSearchResult - { - Name = f.Properties?.Name, - Label = $"{f.Properties?.PostalCode} {f.Properties?.LocalAdmin}, {f.Properties?.Region}", - Layer = f.Properties?.Layer, - Lat = f.Geometry?.Coordinates.Count > 1 ? f.Geometry.Coordinates[1] : 0, - Lon = f.Geometry?.Coordinates.Count > 0 ? f.Geometry.Coordinates[0] : 0 - }).ToList() ?? new List(); - - _cache.Set(cacheKey, results, TimeSpan.FromMinutes(30)); // Cache for 30 mins - return results; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching autocomplete results"); - return new List(); - } - } - - public async Task GetReverseGeocodeAsync(double lat, double lon) - { - var cacheKey = $"otp_reverse_{lat:F5}_{lon:F5}"; - if (_cache.TryGetValue(cacheKey, out PlannerSearchResult? cachedResult) && cachedResult != null) - { - return cachedResult; - } - - try - { - // https://planificador-rutas-api.vigo.org/v1/reverse?point.lat=LAT&point.lon=LON&lang=es - var url = $"{_config.OtpGeocodingBaseUrl}/reverse?point.lat={lat.ToString(CultureInfo.InvariantCulture)}&point.lon={lon.ToString(CultureInfo.InvariantCulture)}&lang=es"; - var response = await _httpClient.GetFromJsonAsync(url); - - var feature = response?.Features.FirstOrDefault(); - if (feature == null) return null; - - var result = new PlannerSearchResult - { - Name = feature.Properties?.Name, - Label = $"{feature.Properties?.PostalCode} {feature.Properties?.LocalAdmin}, {feature.Properties?.Region}", - Layer = feature.Properties?.Layer, - Lat = feature.Geometry?.Coordinates.Count > 1 ? feature.Geometry.Coordinates[1] : 0, - Lon = feature.Geometry?.Coordinates.Count > 0 ? feature.Geometry.Coordinates[0] : 0 - }; - - _cache.Set(cacheKey, result, TimeSpan.FromMinutes(60)); // Cache for 1 hour - return result; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error fetching reverse geocode results"); - return null; - } - } - private Leg MapLeg(OtpLeg otpLeg) { return new Leg @@ -240,8 +169,10 @@ public class OtpService TransitTimeSeconds = node.DurationSeconds - node.WalkSeconds - node.WaitingSeconds, WaitingTimeSeconds = node.WaitingSeconds, Legs = legs, - CashFareEuro = fares.CashFareEuro, - CardFareEuro = fares.CardFareEuro + CashFare = fares.CashFareEuro, + CashFareIsTotal = fares.CashFareIsTotal, + CardFare = fares.CardFareEuro, + CardFareIsTotal = fares.CardFareIsTotal }; } diff --git a/src/Costasdev.Busurbano.Backend/Types/Nominatim/NominatimModels.cs b/src/Costasdev.Busurbano.Backend/Types/Nominatim/NominatimModels.cs new file mode 100644 index 0000000..a73cdd2 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Types/Nominatim/NominatimModels.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; + +namespace Costasdev.Busurbano.Backend.Types.Nominatim; + +public class NominatimSearchResult +{ + [JsonPropertyName("place_id")] + public long PlaceId { get; set; } + + [JsonPropertyName("licence")] + public string? Licence { get; set; } + + [JsonPropertyName("osm_type")] + public string? OsmType { get; set; } + + [JsonPropertyName("osm_id")] + public long OsmId { get; set; } + + [JsonPropertyName("lat")] + public string? Lat { get; set; } + + [JsonPropertyName("lon")] + public string? Lon { get; set; } + + [JsonPropertyName("display_name")] + public string? DisplayName { get; set; } + + [JsonPropertyName("address")] + public NominatimAddress? Address { get; set; } + + [JsonPropertyName("extratags")] + public Dictionary? ExtraTags { get; set; } + + [JsonPropertyName("category")] + public string? Category { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("importance")] + public double Importance { get; set; } +} + +public class NominatimAddress +{ + [JsonPropertyName("house_number")] + public string? HouseNumber { get; set; } + + [JsonPropertyName("road")] + public string? Road { get; set; } + + [JsonPropertyName("suburb")] + public string? Suburb { get; set; } + + [JsonPropertyName("city")] + public string? City { get; set; } + + [JsonPropertyName("municipality")] + public string? Municipality { get; set; } + + [JsonPropertyName("county")] + public string? County { get; set; } + + [JsonPropertyName("state")] + public string? State { get; set; } + + [JsonPropertyName("postcode")] + public string? Postcode { get; set; } + + [JsonPropertyName("country")] + public string? Country { get; set; } + + [JsonPropertyName("country_code")] + public string? CountryCode { get; set; } +} diff --git a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs index b67663d..2c076a2 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs @@ -163,43 +163,3 @@ public class OtpWalkStep [JsonPropertyName("lon")] public double Lon { get; set; } } - -// Geocoding Models (Pelias-like) -public class OtpGeocodeResponse -{ - [JsonPropertyName("features")] - public List Features { get; set; } = new(); -} - -public class OtpGeocodeFeature -{ - [JsonPropertyName("geometry")] - public OtpGeocodeGeometry? Geometry { get; set; } - - [JsonPropertyName("properties")] - public OtpGeocodeProperties? Properties { get; set; } -} - -public class OtpGeocodeGeometry -{ - [JsonPropertyName("coordinates")] - public List Coordinates { get; set; } = new(); // [lon, lat] -} - -public class OtpGeocodeProperties -{ - [JsonPropertyName("name")] - public string? Name { get; set; } - - [JsonPropertyName("postalcode")] - public string? PostalCode { get; set; } - - [JsonPropertyName("localadmin")] - public string? LocalAdmin { get; set; } - - [JsonPropertyName("region")] - public string? Region { get; set; } - - [JsonPropertyName("layer")] - public string? Layer { get; set; } -} diff --git a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs index f88942f..a0cf754 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs @@ -16,8 +16,10 @@ public class Itinerary public double TransitTimeSeconds { get; set; } public double WaitingTimeSeconds { get; set; } public List Legs { get; set; } = []; - public decimal? CashFareEuro { get; set; } - public decimal? CardFareEuro { get; set; } + public decimal? CashFare { get; set; } + public bool? CashFareIsTotal { get; set; } + public decimal? CardFare { get; set; } + public bool? CardFareIsTotal { get; set; } } public class Leg -- cgit v1.3