aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--route-planning.prompt.md25
-rw-r--r--src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs9
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs61
-rw-r--r--src/Costasdev.Busurbano.Backend/Program.cs1
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/OtpService.cs271
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs187
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs75
-rw-r--r--src/frontend/app/components/layout/AppShell.css27
-rw-r--r--src/frontend/app/components/layout/AppShell.tsx3
-rw-r--r--src/frontend/app/components/layout/NavBar.module.css3
-rw-r--r--src/frontend/app/components/layout/NavBar.tsx7
-rw-r--r--src/frontend/app/config/RegionConfig.ts5
-rw-r--r--src/frontend/app/data/PlannerApi.ts96
-rw-r--r--src/frontend/app/hooks/usePlanner.ts101
-rw-r--r--src/frontend/app/maps/styleloader.ts8
-rw-r--r--src/frontend/app/routes.tsx1
-rw-r--r--src/frontend/app/routes/map.tsx16
-rw-r--r--src/frontend/app/routes/planner.tsx415
18 files changed, 1273 insertions, 38 deletions
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<ActionResult<List<PlannerSearchResult>>> 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<ActionResult<PlannerSearchResult>> 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<ActionResult<RoutePlan>> 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<ShapeTraversalService>();
+builder.Services.AddHttpClient<OtpService>();
builder.Services.AddScoped<VitrasaTransitProvider>();
builder.Services.AddScoped<RenfeTransitProvider>();
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<OtpService> _logger;
+
+ public OtpService(HttpClient httpClient, IOptions<AppConfiguration> config, IMemoryCache cache, ILogger<OtpService> logger)
+ {
+ _httpClient = httpClient;
+ _config = config.Value;
+ _cache = cache;
+ _logger = logger;
+ }
+
+ public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query)
+ {
+ if (string.IsNullOrWhiteSpace(query)) return new List<PlannerSearchResult>();
+
+ var cacheKey = $"otp_autocomplete_{query.ToLowerInvariant()}";
+ if (_cache.TryGetValue(cacheKey, out List<PlannerSearchResult>? 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<OtpGeocodeResponse>(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<PlannerSearchResult>();
+
+ _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<PlannerSearchResult>();
+ }
+ }
+
+ public async Task<PlannerSearchResult?> 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<OtpGeocodeResponse>(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<RoutePlan> 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<string, string>
+ {
+ { "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<OtpResponse>(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<double> { c.Lon, c.Lat }).ToList()
+ };
+ }
+
+ // Polyline decoding algorithm
+ private static List<(double Lat, double Lon)> Decode(string encodedPoints)
+ {
+ if (string.IsNullOrEmpty(encodedPoints))
+ return new List<(double, double)>();
+
+ var poly = new List<(double, double)>();
+ char[] polylineChars = encodedPoints.ToCharArray();
+ int index = 0;
+
+ int currentLat = 0;
+ int currentLng = 0;
+ int next5bits;
+ int sum;
+ int shifter;
+
+ while (index < polylineChars.Length)
+ {
+ // calculate next latitude
+ sum = 0;
+ shifter = 0;
+ do
+ {
+ next5bits = (int)polylineChars[index++] - 63;
+ sum |= (next5bits & 31) << shifter;
+ shifter += 5;
+ } while (next5bits >= 32 && index < polylineChars.Length);
+
+ if (index >= polylineChars.Length)
+ break;
+
+ currentLat += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1);
+
+ // calculate next longitude
+ sum = 0;
+ shifter = 0;
+ do
+ {
+ next5bits = (int)polylineChars[index++] - 63;
+ sum |= (next5bits & 31) << shifter;
+ shifter += 5;
+ } while (next5bits >= 32 && index < polylineChars.Length);
+
+ currentLng += (sum & 1) == 1 ? ~(sum >> 1) : (sum >> 1);
+
+ poly.Add((Convert.ToDouble(currentLat) / 100000.0, Convert.ToDouble(currentLng) / 100000.0));
+ }
+
+ return poly;
+ }
+}
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<OtpItinerary> 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<OtpLeg> 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<OtpWalkStep> 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<OtpGeocodeFeature> 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<double> 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<Itinerary> 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<Leg> 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<Step> 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<List<double>> 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 = () => {
/>
<Drawer isOpen={isDrawerOpen} onClose={() => setIsDrawerOpen(false)} />
<div className="app-shell__body">
- <aside className="app-shell__sidebar">
- <NavBar orientation="vertical" />
- </aside>
<main className="app-shell__main">
<Outlet />
</main>
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<PlannerSearchResult[]> {
+ 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<PlannerSearchResult | null> {
+ 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<RoutePlan> {
+ 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<PlannerSearchResult | null>(null);
+ const [destination, setDestination] = useState<PlannerSearchResult | null>(
+ null
+ );
+ const [plan, setPlan] = useState<RoutePlan | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(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<MapRef>(null);
- const [mapStyleKey, setMapStyleKey] = useState<string>("light");
// Style state for Map component
- const [mapStyle, setMapStyle] = useState<StyleSpecification>(defaultStyle);
+ const [mapStyle, setMapStyle] = useState<StyleSpecification>(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<PlannerSearchResult[]>([]);
+ 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 (
+ <div className="mb-4 relative">
+ <label className="block text-sm font-medium text-gray-700 mb-1">
+ {label}
+ </label>
+ <div className="flex gap-2">
+ <input
+ type="text"
+ className="w-full p-2 border rounded shadow-sm"
+ value={query}
+ onChange={(e) => {
+ setQuery(e.target.value);
+ if (!e.target.value) onChange(null);
+ }}
+ placeholder={placeholder}
+ onFocus={() => setShowResults(true)}
+ />
+ {value && (
+ <button
+ onClick={() => {
+ setQuery("");
+ onChange(null);
+ }}
+ className="px-2 text-gray-500"
+ >
+ ✕
+ </button>
+ )}
+ </div>
+ {showResults && results.length > 0 && (
+ <ul className="absolute z-10 w-full bg-white border rounded shadow-lg mt-1 max-h-60 overflow-auto">
+ {results.map((res, idx) => (
+ <li
+ key={idx}
+ className="p-2 hover:bg-gray-100 cursor-pointer border-b last:border-b-0"
+ onClick={() => {
+ onChange(res);
+ setQuery(res.name || "");
+ setShowResults(false);
+ }}
+ >
+ <div className="font-medium">{res.name}</div>
+ <div className="text-xs text-gray-500">{res.label}</div>
+ </li>
+ ))}
+ </ul>
+ )}
+ </div>
+ );
+};
+
+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 (
+ <div
+ className="bg-white p-4 rounded-lg shadow mb-3 cursor-pointer hover:bg-gray-50 border border-gray-200"
+ onClick={onClick}
+ >
+ <div className="flex justify-between items-center mb-2">
+ <div className="font-bold text-lg">
+ {startTime} - {endTime}
+ </div>
+ <div className="text-gray-600">{durationMinutes} min</div>
+ </div>
+ <div className="flex items-center gap-2 overflow-x-auto pb-2">
+ {itinerary.legs.map((leg, idx) => (
+ <React.Fragment key={idx}>
+ {idx > 0 && <span className="text-gray-400">›</span>}
+ <div
+ className={`px-2 py-1 rounded text-sm whitespace-nowrap ${
+ leg.mode === "WALK"
+ ? "bg-gray-200 text-gray-700"
+ : "bg-blue-600 text-white"
+ }`}
+ >
+ {leg.mode === "WALK" ? "Walk" : leg.routeShortName || leg.mode}
+ </div>
+ </React.Fragment>
+ ))}
+ </div>
+ <div className="text-sm text-gray-500 mt-1">
+ Walk: {Math.round(itinerary.walkDistanceMeters)}m
+ </div>
+ </div>
+ );
+};
+
+const ItineraryDetail = ({
+ itinerary,
+ onClose,
+}: {
+ itinerary: Itinerary;
+ onClose: () => void;
+}) => {
+ const mapRef = useRef<MapRef>(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<StyleSpecification>(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 (
+ <div className="fixed inset-0 z-50 bg-white flex flex-col">
+ <div className="relative flex-1">
+ <Map
+ ref={mapRef}
+ initialViewState={{
+ longitude: REGION_DATA.defaultCenter.lng,
+ latitude: REGION_DATA.defaultCenter.lat,
+ zoom: 13,
+ }}
+ mapStyle={mapStyle}
+ attributionControl={false}
+ >
+ <Source id="route" type="geojson" data={routeGeoJson as any}>
+ <Layer
+ id="route-line"
+ type="line"
+ layout={{
+ "line-join": "round",
+ "line-cap": "round",
+ }}
+ paint={{
+ "line-color": ["get", "color"],
+ "line-width": 5,
+ }}
+ />
+ </Source>
+ {/* Markers for start/end/transfers could be added here */}
+ </Map>
+
+ <button
+ onClick={onClose}
+ className="absolute top-4 left-4 bg-white p-2 rounded-full shadow z-10"
+ >
+ ← Back
+ </button>
+ </div>
+
+ <Sheet
+ isOpen={sheetOpen}
+ onClose={() => setSheetOpen(false)}
+ detent="content"
+ initialSnap={0}
+ >
+ <Sheet.Container>
+ <Sheet.Header />
+ <Sheet.Content className="px-4 pb-4 overflow-y-auto">
+ <h2 className="text-xl font-bold mb-4">Itinerary Details</h2>
+ <div className="space-y-4">
+ {itinerary.legs.map((leg, idx) => (
+ <div key={idx} className="flex gap-3">
+ <div className="flex flex-col items-center">
+ <div
+ className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold ${
+ leg.mode === "WALK"
+ ? "bg-gray-200 text-gray-700"
+ : "bg-blue-600 text-white"
+ }`}
+ >
+ {leg.mode === "WALK" ? "🚶" : "🚌"}
+ </div>
+ {idx < itinerary.legs.length - 1 && (
+ <div className="w-0.5 flex-1 bg-gray-300 my-1"></div>
+ )}
+ </div>
+ <div className="flex-1 pb-4">
+ <div className="font-bold">
+ {leg.mode === "WALK"
+ ? "Walk"
+ : `${leg.routeShortName} ${leg.headsign}`}
+ </div>
+ <div className="text-sm text-gray-600">
+ {new Date(leg.startTime).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+ {" - "}
+ {new Date(leg.endTime).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+ </div>
+ <div className="text-sm mt-1">
+ {leg.mode === "WALK" ? (
+ <span>
+ Walk {Math.round(leg.distanceMeters)}m to{" "}
+ {leg.to?.name}
+ </span>
+ ) : (
+ <span>
+ From {leg.from?.name} to {leg.to?.name}
+ </span>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </Sheet.Content>
+ </Sheet.Container>
+ <Sheet.Backdrop onTap={() => setSheetOpen(false)} />
+ </Sheet>
+ </div>
+ );
+};
+
+// --- Main Page ---
+
+export default function PlannerPage() {
+ const {
+ origin,
+ setOrigin,
+ destination,
+ setDestination,
+ plan,
+ loading,
+ error,
+ searchRoute,
+ clearRoute,
+ } = usePlanner();
+
+ const [selectedItinerary, setSelectedItinerary] = useState<Itinerary | null>(
+ null
+ );
+
+ const handleSearch = () => {
+ if (origin && destination) {
+ searchRoute(origin, destination);
+ }
+ };
+
+ if (selectedItinerary) {
+ return (
+ <ItineraryDetail
+ itinerary={selectedItinerary}
+ onClose={() => setSelectedItinerary(null)}
+ />
+ );
+ }
+
+ return (
+ <div className="p-4 max-w-md mx-auto pb-20">
+ <h1 className="text-2xl font-bold mb-4">Route Planner</h1>
+
+ {/* Form */}
+ <div className="bg-white p-4 rounded-lg shadow mb-6">
+ <AutocompleteInput
+ label="From"
+ value={origin}
+ onChange={setOrigin}
+ placeholder="Search origin..."
+ />
+ <AutocompleteInput
+ label="To"
+ value={destination}
+ onChange={setDestination}
+ placeholder="Search destination..."
+ />
+
+ <button
+ onClick={handleSearch}
+ disabled={!origin || !destination || loading}
+ className={`w-full py-3 rounded font-bold text-white ${
+ !origin || !destination || loading
+ ? "bg-gray-400"
+ : "bg-green-600 hover:bg-green-700"
+ }`}
+ >
+ {loading ? "Calculating..." : "Find Route"}
+ </button>
+
+ {error && (
+ <div className="mt-4 p-3 bg-red-100 text-red-700 rounded">
+ {error}
+ </div>
+ )}
+ </div>
+
+ {/* Results */}
+ {plan && (
+ <div>
+ <div className="flex justify-between items-center mb-4">
+ <h2 className="text-xl font-bold">Results</h2>
+ <button onClick={clearRoute} className="text-sm text-red-500">
+ Clear
+ </button>
+ </div>
+
+ {plan.itineraries.length === 0 ? (
+ <div className="p-8 text-center bg-gray-50 rounded-lg border border-dashed border-gray-300">
+ <div className="text-4xl mb-2">😕</div>
+ <h3 className="text-lg font-bold mb-1">No routes found</h3>
+ <p className="text-gray-600">
+ We couldn't find a route for your trip. Try changing the time or
+ locations.
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {plan.itineraries.map((itinerary, idx) => (
+ <ItinerarySummary
+ key={idx}
+ itinerary={itinerary}
+ onClick={() => setSelectedItinerary(itinerary)}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}