diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-12 10:24:43 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2025-12-12 10:24:54 +0100 |
| commit | 3a1a1e6dc2f6f0abceac5da0cfb530fdb45fc6f5 (patch) | |
| tree | 0b887eece835ff12ebd2eea831483407223e1a22 /src/Costasdev.Busurbano.Backend | |
| parent | d65ce8288bbda3cb6e0b37613c29d7bf52703ba7 (diff) | |
Initial ultra-ñapa implementation of OTP integration
Diffstat (limited to 'src/Costasdev.Busurbano.Backend')
6 files changed, 604 insertions, 0 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; } +} |
