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 +- .../Queries/PlanConnectionContent.cs | 1 + src/frontend/app/api/schema.ts | 6 +- src/frontend/app/components/PlannerOverlay.tsx | 63 ++--- src/frontend/app/components/layout/NavBar.tsx | 9 +- src/frontend/app/data/PlannerApi.ts | 7 +- src/frontend/app/data/StopDataProvider.ts | 17 +- src/frontend/app/hooks/usePlanQuery.ts | 4 +- src/frontend/app/hooks/usePlanner.ts | 178 +++++++++++--- src/frontend/app/i18n/locales/en-GB.json | 1 + src/frontend/app/i18n/locales/es-ES.json | 1 + src/frontend/app/i18n/locales/gl-ES.json | 1 + src/frontend/app/root.css | 7 +- src/frontend/app/routes/home.tsx | 74 +++++- src/frontend/app/routes/map.tsx | 7 +- src/frontend/app/routes/planner.tsx | 256 +++++++++------------ src/frontend/app/tailwind-full.css | 28 +++ 26 files changed, 640 insertions(+), 378 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 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 diff --git a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs index b65af50..db3de49 100644 --- a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs +++ b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs @@ -54,6 +54,7 @@ public class PlanConnectionContent : IGraphRequest dateTime:{ {{dateTimeParameter}} } + searchWindow:"PT5H" ) { edges { node { diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index bb2fbcc..63f4368 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -163,8 +163,10 @@ export const ItinerarySchema = z.object({ transitTimeSeconds: z.number(), waitingTimeSeconds: z.number(), legs: z.array(PlannerLegSchema), - cashFareEuro: z.number().optional().nullable(), - cardFareEuro: z.number().optional().nullable(), + cashFare: z.number().optional().nullable(), + cashFareIsTotal: z.boolean().optional().nullable(), + cardFare: z.number().optional().nullable(), + cardFareIsTotal: z.boolean().optional().nullable(), }); export const RoutePlanSchema = z.object({ diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx index af71e48..55e52d7 100644 --- a/src/frontend/app/components/PlannerOverlay.tsx +++ b/src/frontend/app/components/PlannerOverlay.tsx @@ -29,6 +29,8 @@ interface PlannerOverlayProps { clearPickerOnOpen?: boolean; showLastDestinationWhenCollapsed?: boolean; cardBackground?: string; + userLocation?: { latitude: number; longitude: number } | null; + autoLoad?: boolean; } export const PlannerOverlay: React.FC = ({ @@ -39,10 +41,12 @@ export const PlannerOverlay: React.FC = ({ clearPickerOnOpen = false, showLastDestinationWhenCollapsed = true, cardBackground, + userLocation, + autoLoad = true, }) => { const { t } = useTranslation(); const { origin, setOrigin, destination, setDestination, loading, error } = - usePlanner(); + usePlanner({ autoLoad }); const [isExpanded, setIsExpanded] = useState(false); const [originQuery, setOriginQuery] = useState(origin?.name || ""); const [destQuery, setDestQuery] = useState(""); @@ -85,6 +89,21 @@ export const PlannerOverlay: React.FC = ({ : origin?.name || "" ); }, [origin, t]); + + useEffect(() => { + if (userLocation && !origin) { + const initial: PlannerSearchResult = { + name: t("planner.current_location"), + label: "GPS", + lat: userLocation.latitude, + lon: userLocation.longitude, + layer: "current-location", + }; + setOrigin(initial); + setOriginQuery(initial.name || ""); + } + }, [userLocation, origin, t, setOrigin]); + useEffect(() => { setDestQuery(destination?.name || ""); }, [destination]); @@ -185,14 +204,6 @@ export const PlannerOverlay: React.FC = ({ clearPickerOnOpen ? "" : field === "origin" ? originQuery : destQuery ); setPickerOpen(true); - - // When opening destination picker, auto-fill origin from current location if not set - if (field === "destination" && !origin) { - console.log( - "[PlannerOverlay] Destination picker opened with no origin, requesting geolocation" - ); - setOriginFromCurrentLocation(false); - } }; const applyPickedResult = (result: PlannerSearchResult) => { @@ -323,11 +334,11 @@ export const PlannerOverlay: React.FC = ({ const wrapperClass = inline ? "w-full" - : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center"; + : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center mb-3"; const cardClass = inline - ? `pointer-events-auto w-full overflow-hidden rounded-xl px-2 flex flex-col gap-3 ${cardBackground || "bg-white dark:bg-slate-900"}` - : `pointer-events-auto w-[min(640px,calc(100%-16px))] px-2 py-1 flex flex-col gap-3 m-4 overflow-hidden rounded-xl border border-slate-200/80 dark:border-slate-700/70 shadow-2xl backdrop-blur ${cardBackground || "bg-white/95 dark:bg-slate-900/90"}`; + ? `pointer-events-auto w-full overflow-hidden rounded-xl px-2 flex flex-col gap-4 ${cardBackground || "bg-white dark:bg-slate-900"} mb-3` + : `pointer-events-auto w-[min(640px,calc(100%-16px))] px-2 py-1 flex flex-col gap-4 m-4 overflow-hidden rounded-xl border border-slate-200/80 dark:border-slate-700/70 shadow-2xl backdrop-blur ${cardBackground || "bg-white/95 dark:bg-slate-900/90"} mb-3`; return (
@@ -349,10 +360,10 @@ export const PlannerOverlay: React.FC = ({ ) : ( <> -
+
+ ))} +
+
+ )} +
+ {/* Search Section */}
-

+

{t("stoplist.search_label", "Buscar paradas")}

@@ -119,6 +119,7 @@ export default function StopMap() { clearPickerOnOpen={true} showLastDestinationWhenCollapsed={false} cardBackground="bg-white/95 dark:bg-slate-900/90" + autoLoad={false} /> { return `${rounded} m`; }; +const formatDuration = (minutes: number, t: any) => { + if (minutes < 60) return `${minutes} ${t("estimates.minutes")}`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (m === 0) return `${h}h`; + return `${h}h ${m}min`; +}; + const haversineMeters = (a: [number, number], b: [number, number]) => { const toRad = (v: number) => (v * Math.PI) / 180; const R = 6371000; @@ -84,11 +93,8 @@ const ItinerarySummary = ({ }); const walkTotals = sumWalkMetrics(itinerary.legs); - const busLegsCount = itinerary.legs.filter( - (leg) => leg.mode !== "WALK" - ).length; - const cashFare = (itinerary.cashFareEuro ?? 0).toFixed(2); - const cardFare = (itinerary.cardFareEuro ?? 0).toFixed(2); + const cashFare = (itinerary.cashFare ?? 0).toFixed(2); + const cardFare = (itinerary.cardFare ?? 0).toFixed(2); return (
{startTime} - {endTime}
-
{durationMinutes} min
+
{formatDuration(durationMinutes, t)}
@@ -125,7 +131,7 @@ const ItinerarySummary = ({
- {legDurationMinutes} {t("estimates.minutes")} + {formatDuration(legDurationMinutes, t)}
) : ( @@ -147,7 +153,7 @@ const ItinerarySummary = ({ {t("planner.walk")}: {formatDistance(walkTotals.meters)} {walkTotals.minutes - ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}` + ? ` • ${formatDuration(walkTotals.minutes, t)}` : ""} @@ -156,12 +162,14 @@ const ItinerarySummary = ({ {cashFare === "0.00" ? t("planner.free") : t("planner.fare", { amount: cashFare })} + {itinerary.cashFareIsTotal ? "" : "++"} {cardFare === "0.00" ? t("planner.free") : t("planner.fare", { amount: cardFare })} + {itinerary.cashFareIsTotal ? "" : "++"}
@@ -206,83 +214,39 @@ const ItineraryDetail = ({ // Create GeoJSON for all markers const markersGeoJson = useMemo(() => { const features: any[] = []; - const origin = itinerary.legs[0]?.from; - const destination = itinerary.legs[itinerary.legs.length - 1]?.to; - - // Origin marker (red) - if (origin?.lat && origin?.lon) { - features.push({ - type: "Feature", - geometry: { type: "Point", coordinates: [origin.lon, origin.lat] }, - properties: { type: "origin", name: origin.name || "Origin" }, - }); - } - // Destination marker (green) - if (destination?.lat && destination?.lon) { - features.push({ - type: "Feature", - geometry: { - type: "Point", - coordinates: [destination.lon, destination.lat], - }, - properties: { - type: "destination", - name: destination.name || "Destination", - }, - }); - } - - // Collect unique stops with their roles (board, alight, transfer) - const stopsMap: Record< - string, - { lat: number; lon: number; name: string; type: string } - > = {}; + // Add points for each leg transition itinerary.legs.forEach((leg, idx) => { - if (leg.mode !== "WALK") { - // Boarding stop - if (leg.from?.lat && leg.from?.lon) { - const key = `${leg.from.lat},${leg.from.lon}`; - if (!stopsMap[key]) { - const isTransfer = - idx > 0 && itinerary.legs[idx - 1].mode !== "WALK"; - stopsMap[key] = { - lat: leg.from.lat, - lon: leg.from.lon, - name: leg.from.name || "", - type: isTransfer ? "transfer" : "board", - }; - } - } - // Alighting stop - if (leg.to?.lat && leg.to?.lon) { - const key = `${leg.to.lat},${leg.to.lon}`; - if (!stopsMap[key]) { - const isTransfer = - idx < itinerary.legs.length - 1 && - itinerary.legs[idx + 1].mode !== "WALK"; - stopsMap[key] = { - lat: leg.to.lat, - lon: leg.to.lon, - name: leg.to.name || "", - type: isTransfer ? "transfer" : "alight", - }; - } - } + // Add "from" point of the leg + if (leg.from?.lat && leg.from?.lon) { + features.push({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [leg.from.lon, leg.from.lat], + }, + properties: { + type: idx === 0 ? "origin" : "transfer", + name: leg.from.name || "", + index: idx.toString(), + }, + }); } - }); - // Add stop markers - Object.values(stopsMap).forEach((stop) => { - features.push({ - type: "Feature", - geometry: { type: "Point", coordinates: [stop.lon, stop.lat] }, - properties: { type: stop.type, name: stop.name }, - }); - }); + // If it's the last leg, also add the "to" point + if (idx === itinerary.legs.length - 1 && leg.to?.lat && leg.to?.lon) { + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: [leg.to.lon, leg.to.lat] }, + properties: { + type: "destination", + name: leg.to.name || "", + index: (idx + 1).toString(), + }, + }); + } - // Add intermediate stops - itinerary.legs.forEach((leg) => { + // Add intermediate stops leg.intermediateStops?.forEach((stop) => { features.push({ type: "Feature", @@ -389,7 +353,9 @@ const ItineraryDetail = ({ zoom: 13, }} showTraffic={false} - attributionControl={false} + showGeolocate={true} + showNavigation={true} + attributionControl={true} > - {/* Outer circle for origin/destination markers */} - - {/* Inner circle for origin/destination markers */} + {/* Intermediate stops (smaller white dots) - rendered first to be at the bottom */} - {/* Stop markers (board, alight, transfer) */} + {/* Outer circle for all numbered markers */} - {/* Intermediate stops (smaller white dots) */} + {/* Numbers for markers */} @@ -590,12 +530,14 @@ const ItineraryDetail = ({ - {( - (new Date(leg.endTime).getTime() - - new Date(leg.startTime).getTime()) / - 60000 - ).toFixed(0)}{" "} - {t("estimates.minutes")} + {formatDuration( + Math.round( + (new Date(leg.endTime).getTime() - + new Date(leg.startTime).getTime()) / + 60000 + ), + t + )} {formatDistance(leg.distanceMeters)} @@ -654,8 +596,8 @@ const ItineraryDetail = ({ {circ.route} - - {minutes} {t("estimates.minutes")} + + {formatDuration(minutes, t)} {circ.realTime && " 🟢"} @@ -735,6 +677,7 @@ export default function PlannerPage() { const location = useLocation(); const { plan, + loading, searchRoute, clearRoute, searchTime, @@ -815,6 +758,13 @@ export default function PlannerPage() { cardBackground="bg-transparent" /> + {loading && !plan && ( +
+
+

{t("planner.searching")}

+
+ )} + {plan && (
diff --git a/src/frontend/app/tailwind-full.css b/src/frontend/app/tailwind-full.css index 1767d61..e7c4dd3 100644 --- a/src/frontend/app/tailwind-full.css +++ b/src/frontend/app/tailwind-full.css @@ -1,3 +1,31 @@ @import "tailwindcss"; +@theme { + --color-primary: var(--button-background-color); + --color-background: var(--background-color); + --color-text: var(--text-color); + --color-subtitle: var(--subtitle-color); + --color-border: var(--border-color); + --color-surface: var(--message-background-color); + + --font-display: var(--font-display); + --font-sans: var(--font-ui); + + /* Semantic colors for easier migration from slate/gray */ + --color-muted: var(--subtitle-color); + --color-accent: var(--button-background-color); + + /* Generated-like palette using color-mix for flexibility */ + --color-primary-50: color-mix(in oklab, var(--button-background-color) 5%, white); + --color-primary-100: color-mix(in oklab, var(--button-background-color) 10%, white); + --color-primary-200: color-mix(in oklab, var(--button-background-color) 20%, white); + --color-primary-300: color-mix(in oklab, var(--button-background-color) 40%, white); + --color-primary-400: color-mix(in oklab, var(--button-background-color) 60%, white); + --color-primary-500: var(--button-background-color); + --color-primary-600: color-mix(in oklab, var(--button-background-color) 80%, black); + --color-primary-700: color-mix(in oklab, var(--button-background-color) 60%, black); + --color-primary-800: color-mix(in oklab, var(--button-background-color) 40%, black); + --color-primary-900: color-mix(in oklab, var(--button-background-color) 20%, black); +} + @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); -- cgit v1.3