From 3a1a1e6dc2f6f0abceac5da0cfb530fdb45fc6f5 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 12 Dec 2025 10:24:43 +0100 Subject: Initial ultra-ñapa implementation of OTP integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- route-planning.prompt.md | 25 ++ .../Configuration/AppConfiguration.cs | 9 + .../Controllers/RoutePlannerController.cs | 61 +++ src/Costasdev.Busurbano.Backend/Program.cs | 1 + .../Services/OtpService.cs | 271 ++++++++++++++ .../Types/Otp/OtpModels.cs | 187 ++++++++++ .../Types/Planner/PlannerModels.cs | 75 ++++ src/frontend/app/components/layout/AppShell.css | 27 +- src/frontend/app/components/layout/AppShell.tsx | 3 - .../app/components/layout/NavBar.module.css | 3 + src/frontend/app/components/layout/NavBar.tsx | 7 +- src/frontend/app/config/RegionConfig.ts | 5 +- src/frontend/app/data/PlannerApi.ts | 96 +++++ src/frontend/app/hooks/usePlanner.ts | 101 +++++ src/frontend/app/maps/styleloader.ts | 8 + src/frontend/app/routes.tsx | 1 + src/frontend/app/routes/map.tsx | 16 +- src/frontend/app/routes/planner.tsx | 415 +++++++++++++++++++++ 18 files changed, 1273 insertions(+), 38 deletions(-) create mode 100644 route-planning.prompt.md create mode 100644 src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs create mode 100644 src/Costasdev.Busurbano.Backend/Services/OtpService.cs create mode 100644 src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs create mode 100644 src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs create mode 100644 src/frontend/app/data/PlannerApi.ts create mode 100644 src/frontend/app/hooks/usePlanner.ts create mode 100644 src/frontend/app/routes/planner.tsx diff --git a/route-planning.prompt.md b/route-planning.prompt.md new file mode 100644 index 0000000..13cd1bf --- /dev/null +++ b/route-planning.prompt.md @@ -0,0 +1,25 @@ +## Plan: Implement Route Planning with OTP Integration + +This plan introduces a route planning feature by creating a backend proxy for the Vigo OpenTripPlanner API. The backend will standardize responses and manage routing parameters, while the frontend will provide the search UI, map visualization, and local persistence of results. + +### Steps + +1. **Backend Config**: Update [AppConfiguration.cs](src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs) and `appsettings.json` with OTP URLs and default routing parameters (e.g., `walkSpeed`, `maxWalkDistance`). +2. **Backend Models**: Create `OtpResponse` models for deserialization and "Standardized" DTOs (`RoutePlan`, `Itinerary`, `Leg`) in `Types/` to decouple the API. + - _Note_: The Standardized DTO should convert OTP's "Encoded Polyline" geometry into standard GeoJSON LineStrings for easier consumption by the frontend. +3. **Backend Service**: Implement `OtpService` to fetch data from the 3 OTP endpoints (Autocomplete, Reverse, Plan) and map them to Standardized DTOs. + - _Caching_: Implement `IMemoryCache` for Autocomplete and Reverse Geocoding requests to reduce load on the city API. +4. **Backend Controller**: Create `RoutePlannerController` to expose `/api/planner/*` endpoints, injecting the configured parameters into OTP requests. +5. **Frontend API**: Create `app/data/PlannerApi.ts` to consume the new backend endpoints. +6. **Frontend Logic**: Implement a hook to manage route state and persist results to `localStorage` with an expiry check (1-2 hours). +7. **Frontend UI**: Create `app/routes/planner.tsx` handling three distinct states/views: + - **Planner Form**: Input for Origin, Destination, and Departure/Arrival time. + - **Results List**: A summary list of available itineraries (walking duration, bus lines, total time). + - **Result Detail**: A map view with a non-modal bottom sheet displaying precise step-by-step instructions (walk to X, take line Y, etc.). +8. **Frontend Routing**: Register the new `/planner` route in [app/routes.tsx](src/frontend/app/routes.tsx). + +### Further Considerations + +1. **Caching**: Confirmed. We will use `IMemoryCache` in the backend for geocoding services as a stopgap until a custom solution is built. +2. **Map Geometry**: The backend will handle the decoding of OTP's Encoded Polyline format, serving standard GeoJSON to the frontend. +3. **Error Handling**: In case of errors (no route found, service down), the UI will display a large, friendly explanatory message occupying the main content area. diff --git a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs index 49c001f..a61fdb6 100644 --- a/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs +++ b/src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs @@ -4,4 +4,13 @@ public class AppConfiguration { public required string VitrasaScheduleBasePath { get; set; } public required string RenfeScheduleBasePath { get; set; } + + public string OtpGeocodingBaseUrl { get; set; } = "https://planificador-rutas-api.vigo.org/v1"; + public string OtpPlannerBaseUrl { get; set; } = "https://planificador-rutas.vigo.org/otp/routers/default"; + + // Default Routing Parameters + public double WalkSpeed { get; set; } = 1.4; + public int MaxWalkDistance { get; set; } = 1000; + public int MaxWalkTime { get; set; } = 20; + public int NumItineraries { get; set; } = 4; } diff --git a/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs new file mode 100644 index 0000000..efddf82 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs @@ -0,0 +1,61 @@ +using Costasdev.Busurbano.Backend.Services; +using Costasdev.Busurbano.Backend.Types.Planner; +using Microsoft.AspNetCore.Mvc; + +namespace Costasdev.Busurbano.Backend.Controllers; + +[ApiController] +[Route("api/planner")] +public class RoutePlannerController : ControllerBase +{ + private readonly OtpService _otpService; + + public RoutePlannerController(OtpService otpService) + { + _otpService = otpService; + } + + [HttpGet("autocomplete")] + public async Task>> Autocomplete([FromQuery] string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return BadRequest("Query cannot be empty"); + } + + var results = await _otpService.GetAutocompleteAsync(query); + return Ok(results); + } + + [HttpGet("reverse")] + public async Task> Reverse([FromQuery] double lat, [FromQuery] double lon) + { + var result = await _otpService.GetReverseGeocodeAsync(lat, lon); + if (result == null) + { + return NotFound(); + } + return Ok(result); + } + + [HttpGet("plan")] + public async Task> Plan( + [FromQuery] double fromLat, + [FromQuery] double fromLon, + [FromQuery] double toLat, + [FromQuery] double toLon, + [FromQuery] DateTime? time = null, + [FromQuery] bool arriveBy = false) + { + try + { + var plan = await _otpService.GetRoutePlanAsync(fromLat, fromLon, toLat, toLon, time, arriveBy); + return Ok(plan); + } + catch (Exception) + { + // Log error + return StatusCode(500, "An error occurred while planning the route."); + } + } +} diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs index 959e114..74c6337 100644 --- a/src/Costasdev.Busurbano.Backend/Program.cs +++ b/src/Costasdev.Busurbano.Backend/Program.cs @@ -11,6 +11,7 @@ builder.Services.AddHttpClient(); builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs new file mode 100644 index 0000000..4c22ff5 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs @@ -0,0 +1,271 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Types.Otp; +using Costasdev.Busurbano.Backend.Types.Planner; +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 _logger; + + public OtpService(HttpClient httpClient, IOptions config, IMemoryCache cache, ILogger logger) + { + _httpClient = httpClient; + _config = config.Value; + _cache = cache; + _logger = logger; + } + + 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,street,address&lang=es"; + var response = await _httpClient.GetFromJsonAsync(url); + + var results = response?.Features.Select(f => new PlannerSearchResult + { + Name = f.Properties?.Name, + Label = f.Properties?.Label, + 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?.Label, + 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; + } + } + + public async Task GetRoutePlanAsync(double fromLat, double fromLon, double toLat, double toLon, DateTime? time = null, bool arriveBy = false) + { + try + { + var date = time ?? DateTime.Now; + var dateStr = date.ToString("MM/dd/yyyy", CultureInfo.InvariantCulture); + var timeStr = date.ToString("h:mm tt", CultureInfo.InvariantCulture); + + var queryParams = new Dictionary + { + { "fromPlace", $"{fromLat.ToString(CultureInfo.InvariantCulture)},{fromLon.ToString(CultureInfo.InvariantCulture)}" }, + { "toPlace", $"{toLat.ToString(CultureInfo.InvariantCulture)},{toLon.ToString(CultureInfo.InvariantCulture)}" }, + { "arriveBy", arriveBy.ToString().ToLower() }, + { "date", dateStr }, + { "time", timeStr }, + { "locale", "es" }, + { "showIntermediateStops", "true" }, + { "mode", "TRANSIT,WALK" }, + { "numItineraries", _config.NumItineraries.ToString() }, + { "walkSpeed", _config.WalkSpeed.ToString(CultureInfo.InvariantCulture) }, + { "maxWalkDistance", _config.MaxWalkDistance.ToString() }, // Note: OTP might ignore this if it's too small + { "optimize", "QUICK" }, + { "wheelchair", "false" } + }; + + var queryString = string.Join("&", queryParams.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); + var url = $"{_config.OtpPlannerBaseUrl}/plan?{queryString}"; + + var response = await _httpClient.GetFromJsonAsync(url); + + if (response?.Plan == null) + { + return new RoutePlan(); + } + + return MapToRoutePlan(response.Plan); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching route plan"); + throw; + } + } + + private RoutePlan MapToRoutePlan(OtpPlan otpPlan) + { + return new RoutePlan + { + Itineraries = otpPlan.Itineraries.Select(MapItinerary).ToList() + }; + } + + private Itinerary MapItinerary(OtpItinerary otpItinerary) + { + return new Itinerary + { + DurationSeconds = otpItinerary.Duration, + StartTime = DateTimeOffset.FromUnixTimeMilliseconds(otpItinerary.StartTime).LocalDateTime, // Assuming local time or handling timezone + EndTime = DateTimeOffset.FromUnixTimeMilliseconds(otpItinerary.EndTime).LocalDateTime, + WalkDistanceMeters = otpItinerary.WalkDistance, + WalkTimeSeconds = otpItinerary.WalkTime, + TransitTimeSeconds = otpItinerary.TransitTime, + WaitingTimeSeconds = otpItinerary.WaitingTime, + Legs = otpItinerary.Legs.Select(MapLeg).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, + From = MapPlace(otpLeg.From), + To = MapPlace(otpLeg.To), + StartTime = DateTimeOffset.FromUnixTimeMilliseconds(otpLeg.StartTime).LocalDateTime, + EndTime = DateTimeOffset.FromUnixTimeMilliseconds(otpLeg.EndTime).LocalDateTime, + Geometry = DecodePolyline(otpLeg.LegGeometry?.Points), + Steps = otpLeg.Steps.Select(MapStep).ToList() + }; + } + + private PlannerPlace? MapPlace(OtpPlace? otpPlace) + { + if (otpPlace == null) return null; + return new PlannerPlace + { + Name = otpPlace.Name, + Lat = otpPlace.Lat, + Lon = otpPlace.Lon, + StopId = otpPlace.StopId, // Use string directly + StopCode = otpPlace.StopCode + }; + } + + 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 { 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; + } +} diff --git a/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs new file mode 100644 index 0000000..3d3de17 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs @@ -0,0 +1,187 @@ +using System.Text.Json.Serialization; + +namespace Costasdev.Busurbano.Backend.Types.Otp; + +public class OtpResponse +{ + [JsonPropertyName("plan")] + public OtpPlan? Plan { get; set; } + + [JsonPropertyName("error")] + public OtpError? Error { get; set; } +} + +public class OtpError +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("msg")] + public string? Msg { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } +} + +public class OtpPlan +{ + [JsonPropertyName("date")] + public long Date { get; set; } + + [JsonPropertyName("from")] + public OtpPlace? From { get; set; } + + [JsonPropertyName("to")] + public OtpPlace? To { get; set; } + + [JsonPropertyName("itineraries")] + public List Itineraries { get; set; } = new(); +} + +public class OtpItinerary +{ + [JsonPropertyName("duration")] + public long Duration { get; set; } + + [JsonPropertyName("startTime")] + public long StartTime { get; set; } + + [JsonPropertyName("endTime")] + public long EndTime { get; set; } + + [JsonPropertyName("walkTime")] + public long WalkTime { get; set; } + + [JsonPropertyName("transitTime")] + public long TransitTime { get; set; } + + [JsonPropertyName("waitingTime")] + public long WaitingTime { get; set; } + + [JsonPropertyName("walkDistance")] + public double WalkDistance { get; set; } + + [JsonPropertyName("legs")] + public List Legs { get; set; } = new(); +} + +public class OtpLeg +{ + [JsonPropertyName("startTime")] + public long StartTime { get; set; } + + [JsonPropertyName("endTime")] + public long EndTime { get; set; } + + [JsonPropertyName("mode")] + public string? Mode { get; set; } + + [JsonPropertyName("route")] + public string? Route { get; set; } + + [JsonPropertyName("routeShortName")] + public string? RouteShortName { get; set; } + + [JsonPropertyName("routeLongName")] + public string? RouteLongName { get; set; } + + [JsonPropertyName("agencyName")] + public string? AgencyName { get; set; } + + [JsonPropertyName("from")] + public OtpPlace? From { get; set; } + + [JsonPropertyName("to")] + public OtpPlace? To { get; set; } + + [JsonPropertyName("legGeometry")] + public OtpGeometry? LegGeometry { get; set; } + + [JsonPropertyName("steps")] + public List Steps { get; set; } = new(); + + [JsonPropertyName("headsign")] + public string? Headsign { get; set; } +} + +public class OtpPlace +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("lat")] + public double Lat { get; set; } + + [JsonPropertyName("lon")] + public double Lon { get; set; } + + [JsonPropertyName("stopId")] + public string? StopId { get; set; } + + [JsonPropertyName("stopCode")] + public string? StopCode { get; set; } +} + +public class OtpGeometry +{ + [JsonPropertyName("points")] + public string? Points { get; set; } + + [JsonPropertyName("length")] + public int Length { get; set; } +} + +public class OtpWalkStep +{ + [JsonPropertyName("distance")] + public double Distance { get; set; } + + [JsonPropertyName("relativeDirection")] + public string? RelativeDirection { get; set; } + + [JsonPropertyName("streetName")] + public string? StreetName { get; set; } + + [JsonPropertyName("absoluteDirection")] + public string? AbsoluteDirection { get; set; } + + [JsonPropertyName("lat")] + public double Lat { get; set; } + + [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("label")] + public string? Label { get; set; } + + [JsonPropertyName("layer")] + public string? Layer { get; set; } +} diff --git a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs new file mode 100644 index 0000000..30e5e2d --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; + +namespace Costasdev.Busurbano.Backend.Types.Planner; + +public class RoutePlan +{ + public List Itineraries { get; set; } = new(); +} + +public class Itinerary +{ + public double DurationSeconds { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public double WalkDistanceMeters { get; set; } + public double WalkTimeSeconds { get; set; } + public double TransitTimeSeconds { get; set; } + public double WaitingTimeSeconds { get; set; } + public List Legs { get; set; } = new(); +} + +public class Leg +{ + public string? Mode { get; set; } // WALK, BUS, etc. + public string? RouteName { get; set; } + public string? RouteShortName { get; set; } + public string? RouteLongName { get; set; } + public string? Headsign { get; set; } + public string? AgencyName { get; set; } + public PlannerPlace? From { get; set; } + public PlannerPlace? To { get; set; } + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public double DistanceMeters { get; set; } + + // GeoJSON LineString geometry + public PlannerGeometry? Geometry { get; set; } + + public List Steps { get; set; } = new(); +} + +public class PlannerPlace +{ + public string? Name { get; set; } + public double Lat { get; set; } + public double Lon { get; set; } + public string? StopId { get; set; } + public string? StopCode { get; set; } +} + +public class PlannerGeometry +{ + public string Type { get; set; } = "LineString"; + public List> Coordinates { get; set; } = new(); // [[lon, lat], ...] +} + +public class Step +{ + public double DistanceMeters { get; set; } + public string? RelativeDirection { get; set; } + public string? AbsoluteDirection { get; set; } + public string? StreetName { get; set; } + public double Lat { get; set; } + public double Lon { get; set; } +} + +// For Autocomplete/Reverse +public class PlannerSearchResult +{ + public string? Name { get; set; } + public string? Label { get; set; } + public double Lat { get; set; } + public double Lon { get; set; } + public string? Layer { get; set; } +} diff --git a/src/frontend/app/components/layout/AppShell.css b/src/frontend/app/components/layout/AppShell.css index eee678c..17aae8c 100644 --- a/src/frontend/app/components/layout/AppShell.css +++ b/src/frontend/app/components/layout/AppShell.css @@ -14,20 +14,12 @@ .app-shell__body { display: flex; + flex-direction: column; flex: 1; overflow: hidden; position: relative; } -.app-shell__sidebar { - display: none; /* Hidden on mobile */ - width: 80px; - border-right: 1px solid var(--border-color); - background: var(--background-color); - flex-shrink: 0; - z-index: 5; -} - .app-shell__main { flex: 1; overflow: auto; @@ -37,17 +29,12 @@ .app-shell__bottom-nav { flex-shrink: 0; - display: block; /* Visible on mobile */ + display: block; z-index: 10; -} - -/* Desktop styles */ -@media (min-width: 768px) { - .app-shell__sidebar { - display: block; - } - .app-shell__bottom-nav { - display: none; - } + position: sticky; + bottom: 0; + width: 100%; + background: var(--background-color); + border-top: 1px solid var(--border-color); } diff --git a/src/frontend/app/components/layout/AppShell.tsx b/src/frontend/app/components/layout/AppShell.tsx index 08aee59..afc19f3 100644 --- a/src/frontend/app/components/layout/AppShell.tsx +++ b/src/frontend/app/components/layout/AppShell.tsx @@ -24,9 +24,6 @@ const AppShellContent: React.FC = () => { /> setIsDrawerOpen(false)} />
-
diff --git a/src/frontend/app/components/layout/NavBar.module.css b/src/frontend/app/components/layout/NavBar.module.css index 504b93b..6b46459 100644 --- a/src/frontend/app/components/layout/NavBar.module.css +++ b/src/frontend/app/components/layout/NavBar.module.css @@ -6,6 +6,9 @@ background-color: var(--background-color); border-top: 1px solid var(--border-color); + + max-width: 500px; + margin-inline: auto; } .vertical { diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx index 40591c4..150755f 100644 --- a/src/frontend/app/components/layout/NavBar.tsx +++ b/src/frontend/app/components/layout/NavBar.tsx @@ -1,4 +1,4 @@ -import { Home, Map, Route } from "lucide-react"; +import { Home, Map, Navigation2, Route } from "lucide-react"; import type { LngLatLike } from "maplibre-gl"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router"; @@ -71,6 +71,11 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) { icon: Route, path: "/lines", }, + { + name: t("navbar.planner", "Planificador"), + icon: Navigation2, + path: "/planner", + }, ]; return ( diff --git a/src/frontend/app/config/RegionConfig.ts b/src/frontend/app/config/RegionConfig.ts index 75da06d..d595b3f 100644 --- a/src/frontend/app/config/RegionConfig.ts +++ b/src/frontend/app/config/RegionConfig.ts @@ -28,7 +28,10 @@ export const REGION_DATA: RegionData = { consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations", timetableEndpoint: "/api/vigo/GetStopTimetable", shapeEndpoint: "/api/vigo/GetShape", - defaultCenter: [42.229188855975046, -8.72246955783102] as LngLatLike, + defaultCenter: { + lat: 42.229188855975046, + lng: -8.72246955783102, + } as LngLatLike, bounds: { sw: [-8.951059, 42.098923] as LngLatLike, ne: [-8.447748, 42.3496] as LngLatLike, diff --git a/src/frontend/app/data/PlannerApi.ts b/src/frontend/app/data/PlannerApi.ts new file mode 100644 index 0000000..db47dcc --- /dev/null +++ b/src/frontend/app/data/PlannerApi.ts @@ -0,0 +1,96 @@ +export interface PlannerSearchResult { + name?: string; + label?: string; + lat: number; + lon: number; + layer?: string; +} + +export interface RoutePlan { + itineraries: Itinerary[]; +} + +export interface Itinerary { + durationSeconds: number; + startTime: string; + endTime: string; + walkDistanceMeters: number; + walkTimeSeconds: number; + transitTimeSeconds: number; + waitingTimeSeconds: number; + legs: Leg[]; +} + +export interface Leg { + mode?: string; + routeName?: string; + routeShortName?: string; + routeLongName?: string; + headsign?: string; + agencyName?: string; + from?: PlannerPlace; + to?: PlannerPlace; + startTime: string; + endTime: string; + distanceMeters: number; + geometry?: PlannerGeometry; + steps: Step[]; +} + +export interface PlannerPlace { + name?: string; + lat: number; + lon: number; + stopId?: string; + stopCode?: string; +} + +export interface PlannerGeometry { + type: string; + coordinates: number[][]; +} + +export interface Step { + distanceMeters: number; + relativeDirection?: string; + absoluteDirection?: string; + streetName?: string; + lat: number; + lon: number; +} + +export async function searchPlaces( + query: string +): Promise { + const response = await fetch( + `/api/planner/autocomplete?query=${encodeURIComponent(query)}` + ); + if (!response.ok) return []; + return response.json(); +} + +export async function reverseGeocode( + lat: number, + lon: number +): Promise { + const response = await fetch(`/api/planner/reverse?lat=${lat}&lon=${lon}`); + if (!response.ok) return null; + return response.json(); +} + +export async function planRoute( + fromLat: number, + fromLon: number, + toLat: number, + toLon: number, + time?: Date, + arriveBy: boolean = false +): Promise { + let url = `/api/planner/plan?fromLat=${fromLat}&fromLon=${fromLon}&toLat=${toLat}&toLon=${toLon}&arriveBy=${arriveBy}`; + if (time) { + url += `&time=${time.toISOString()}`; + } + const response = await fetch(url); + if (!response.ok) throw new Error("Failed to plan route"); + return response.json(); +} diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts new file mode 100644 index 0000000..1572896 --- /dev/null +++ b/src/frontend/app/hooks/usePlanner.ts @@ -0,0 +1,101 @@ +import { useEffect, useState } from "react"; +import { + type PlannerSearchResult, + type RoutePlan, + planRoute, +} from "../data/PlannerApi"; + +const STORAGE_KEY = "planner_last_route"; +const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours + +interface StoredRoute { + timestamp: number; + origin: PlannerSearchResult; + destination: PlannerSearchResult; + plan: RoutePlan; +} + +export function usePlanner() { + const [origin, setOrigin] = useState(null); + const [destination, setDestination] = useState( + null + ); + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Load from storage on mount + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const data: StoredRoute = JSON.parse(stored); + if (Date.now() - data.timestamp < EXPIRY_MS) { + setOrigin(data.origin); + setDestination(data.destination); + setPlan(data.plan); + } else { + localStorage.removeItem(STORAGE_KEY); + } + } catch (e) { + localStorage.removeItem(STORAGE_KEY); + } + } + }, []); + + const searchRoute = async ( + from: PlannerSearchResult, + to: PlannerSearchResult, + time?: Date, + arriveBy: boolean = false + ) => { + setLoading(true); + setError(null); + try { + const result = await planRoute( + from.lat, + from.lon, + to.lat, + to.lon, + time, + arriveBy + ); + setPlan(result); + setOrigin(from); + setDestination(to); + + // Save to storage + const toStore: StoredRoute = { + timestamp: Date.now(), + origin: from, + destination: to, + plan: result, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); + } catch (err) { + setError("Failed to calculate route. Please try again."); + setPlan(null); + } finally { + setLoading(false); + } + }; + + const clearRoute = () => { + setPlan(null); + setOrigin(null); + setDestination(null); + localStorage.removeItem(STORAGE_KEY); + }; + + return { + origin, + setOrigin, + destination, + setDestination, + plan, + loading, + error, + searchRoute, + clearRoute, + }; +} diff --git a/src/frontend/app/maps/styleloader.ts b/src/frontend/app/maps/styleloader.ts index 8109e0b..7d90116 100644 --- a/src/frontend/app/maps/styleloader.ts +++ b/src/frontend/app/maps/styleloader.ts @@ -5,6 +5,14 @@ export interface StyleLoaderOptions { includeTraffic?: boolean; } +export const DEFAULT_STYLE: StyleSpecification = { + version: 8, + glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`, + sprite: `${window.location.origin}/maps/spritesheet/sprite`, + sources: {}, + layers: [], +}; + export async function loadStyle( styleName: string, colorScheme: Theme, diff --git a/src/frontend/app/routes.tsx b/src/frontend/app/routes.tsx index 16d0da7..052eb83 100644 --- a/src/frontend/app/routes.tsx +++ b/src/frontend/app/routes.tsx @@ -9,4 +9,5 @@ export default [ route("/settings", "routes/settings.tsx"), route("/about", "routes/about.tsx"), route("/favourites", "routes/favourites.tsx"), + route("/planner", "routes/planner.tsx"), ] satisfies RouteConfig; diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 187e9f2..182f4ce 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -1,7 +1,7 @@ import StopDataProvider, { type Stop } from "../data/StopDataProvider"; import "./map.css"; -import { loadStyle } from "app/maps/styleloader"; +import { DEFAULT_STYLE, loadStyle } from "app/maps/styleloader"; import type { Feature as GeoJsonFeature, Point } from "geojson"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -19,15 +19,6 @@ import { REGION_DATA } from "~/config/RegionConfig"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { useApp } from "../AppContext"; -// Default minimal fallback style before dynamic loading -const defaultStyle: StyleSpecification = { - version: 8, - glyphs: `${window.location.origin}/maps/fonts/{fontstack}/{range}.pbf`, - sprite: `${window.location.origin}/maps/spritesheet/sprite`, - sources: {}, - layers: [], -}; - // Componente principal del mapa export default function StopMap() { const { t } = useTranslation(); @@ -48,10 +39,9 @@ export default function StopMap() { const [isSheetOpen, setIsSheetOpen] = useState(false); const { mapState, updateMapState, theme } = useApp(); const mapRef = useRef(null); - const [mapStyleKey, setMapStyleKey] = useState("light"); // Style state for Map component - const [mapStyle, setMapStyle] = useState(defaultStyle); + const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); // Handle click events on clusters and individual stops const onMapClick = (e: MapLayerMouseEvent) => { @@ -111,7 +101,7 @@ export default function StopMap() { loadStyle(styleName, theme) .then((style) => setMapStyle(style)) .catch((error) => console.error("Failed to load map style:", error)); - }, [mapStyleKey, theme]); + }, [theme]); useEffect(() => { const handleMapChange = () => { diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx new file mode 100644 index 0000000..094ff8e --- /dev/null +++ b/src/frontend/app/routes/planner.tsx @@ -0,0 +1,415 @@ +import maplibregl, { type StyleSpecification } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import React, { useEffect, useRef, useState } from "react"; +import Map, { Layer, Source, type MapRef } from "react-map-gl/maplibre"; +import { Sheet } from "react-modal-sheet"; +import { useApp } from "~/AppContext"; +import { REGION_DATA } from "~/config/RegionConfig"; +import { + searchPlaces, + type Itinerary, + type PlannerSearchResult, +} from "~/data/PlannerApi"; +import { usePlanner } from "~/hooks/usePlanner"; +import { DEFAULT_STYLE, loadStyle } from "~/maps/styleloader"; +import "../tailwind-full.css"; + +// --- Components --- + +const AutocompleteInput = ({ + label, + value, + onChange, + placeholder, +}: { + label: string; + value: PlannerSearchResult | null; + onChange: (val: PlannerSearchResult | null) => void; + placeholder: string; +}) => { + const [query, setQuery] = useState(value?.name || ""); + const [results, setResults] = useState([]); + const [showResults, setShowResults] = useState(false); + + useEffect(() => { + if (value) setQuery(value.name || ""); + }, [value]); + + useEffect(() => { + const timer = setTimeout(async () => { + if (query.length > 2 && query !== value?.name) { + const res = await searchPlaces(query); + setResults(res); + setShowResults(true); + } else { + setResults([]); + } + }, 500); + return () => clearTimeout(timer); + }, [query, value]); + + return ( +
+ +
+ { + setQuery(e.target.value); + if (!e.target.value) onChange(null); + }} + placeholder={placeholder} + onFocus={() => setShowResults(true)} + /> + {value && ( + + )} +
+ {showResults && results.length > 0 && ( +
    + {results.map((res, idx) => ( +
  • { + onChange(res); + setQuery(res.name || ""); + setShowResults(false); + }} + > +
    {res.name}
    +
    {res.label}
    +
  • + ))} +
+ )} +
+ ); +}; + +const ItinerarySummary = ({ + itinerary, + onClick, +}: { + itinerary: Itinerary; + onClick: () => void; +}) => { + const durationMinutes = Math.round(itinerary.durationSeconds / 60); + const startTime = new Date(itinerary.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + const endTime = new Date(itinerary.endTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + + return ( +
+
+
+ {startTime} - {endTime} +
+
{durationMinutes} min
+
+
+ {itinerary.legs.map((leg, idx) => ( + + {idx > 0 && } +
+ {leg.mode === "WALK" ? "Walk" : leg.routeShortName || leg.mode} +
+
+ ))} +
+
+ Walk: {Math.round(itinerary.walkDistanceMeters)}m +
+
+ ); +}; + +const ItineraryDetail = ({ + itinerary, + onClose, +}: { + itinerary: Itinerary; + onClose: () => void; +}) => { + const mapRef = useRef(null); + const [sheetOpen, setSheetOpen] = useState(true); + + // Prepare GeoJSON for the route + const routeGeoJson = { + type: "FeatureCollection", + features: itinerary.legs.map((leg) => ({ + type: "Feature", + geometry: { + type: "LineString", + coordinates: leg.geometry?.coordinates || [], + }, + properties: { + mode: leg.mode, + color: leg.mode === "WALK" ? "#9ca3af" : "#2563eb", // Gray for walk, Blue for transit + }, + })), + }; + + // Fit bounds on mount + useEffect(() => { + if (mapRef.current && itinerary.legs.length > 0) { + const bounds = new maplibregl.LngLatBounds(); + itinerary.legs.forEach((leg) => { + leg.geometry?.coordinates.forEach((coord) => { + bounds.extend([coord[0], coord[1]]); + }); + }); + mapRef.current.fitBounds(bounds, { padding: 50 }); + } + }, [itinerary]); + + const { theme } = useApp(); + + const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); + useEffect(() => { + //const styleName = "carto"; + const styleName = "openfreemap"; + loadStyle(styleName, theme) + .then((style) => setMapStyle(style)) + .catch((error) => console.error("Failed to load map style:", error)); + }, [theme]); + + return ( +
+
+ + + + + {/* Markers for start/end/transfers could be added here */} + + + +
+ + setSheetOpen(false)} + detent="content" + initialSnap={0} + > + + + +

Itinerary Details

+
+ {itinerary.legs.map((leg, idx) => ( +
+
+
+ {leg.mode === "WALK" ? "🚶" : "🚌"} +
+ {idx < itinerary.legs.length - 1 && ( +
+ )} +
+
+
+ {leg.mode === "WALK" + ? "Walk" + : `${leg.routeShortName} ${leg.headsign}`} +
+
+ {new Date(leg.startTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} + {" - "} + {new Date(leg.endTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+
+ {leg.mode === "WALK" ? ( + + Walk {Math.round(leg.distanceMeters)}m to{" "} + {leg.to?.name} + + ) : ( + + From {leg.from?.name} to {leg.to?.name} + + )} +
+
+
+ ))} +
+
+
+ setSheetOpen(false)} /> +
+
+ ); +}; + +// --- Main Page --- + +export default function PlannerPage() { + const { + origin, + setOrigin, + destination, + setDestination, + plan, + loading, + error, + searchRoute, + clearRoute, + } = usePlanner(); + + const [selectedItinerary, setSelectedItinerary] = useState( + null + ); + + const handleSearch = () => { + if (origin && destination) { + searchRoute(origin, destination); + } + }; + + if (selectedItinerary) { + return ( + setSelectedItinerary(null)} + /> + ); + } + + return ( +
+

Route Planner

+ + {/* Form */} +
+ + + + + + {error && ( +
+ {error} +
+ )} +
+ + {/* Results */} + {plan && ( +
+
+

Results

+ +
+ + {plan.itineraries.length === 0 ? ( +
+
😕
+

No routes found

+

+ We couldn't find a route for your trip. Try changing the time or + locations. +

+
+ ) : ( +
+ {plan.itineraries.map((itinerary, idx) => ( + setSelectedItinerary(itinerary)} + /> + ))} +
+ )} +
+ )} +
+ ); +} -- cgit v1.3