aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
17 files changed, 1248 insertions, 38 deletions
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>
+ );
+}