diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Configuration/AppConfiguration.cs | 9 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs | 61 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Program.cs | 1 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Services/OtpService.cs | 271 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Types/Otp/OtpModels.cs | 187 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Types/Planner/PlannerModels.cs | 75 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/AppShell.css | 27 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/AppShell.tsx | 3 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/NavBar.module.css | 3 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/NavBar.tsx | 7 | ||||
| -rw-r--r-- | src/frontend/app/config/RegionConfig.ts | 5 | ||||
| -rw-r--r-- | src/frontend/app/data/PlannerApi.ts | 96 | ||||
| -rw-r--r-- | src/frontend/app/hooks/usePlanner.ts | 101 | ||||
| -rw-r--r-- | src/frontend/app/maps/styleloader.ts | 8 | ||||
| -rw-r--r-- | src/frontend/app/routes.tsx | 1 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 16 | ||||
| -rw-r--r-- | src/frontend/app/routes/planner.tsx | 415 |
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> + ); +} |
