diff options
Diffstat (limited to 'src/Costasdev.Busurbano.Backend')
12 files changed, 295 insertions, 50 deletions
diff --git a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs index 957668a..6096b53 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs @@ -75,11 +75,18 @@ public partial class ArrivalsController : ControllerBase List<Arrival> arrivals = []; foreach (var item in stop.Arrivals) { + // Discard trip without pickup at stop if (item.PickupTypeParsed.Equals(ArrivalsAtStopResponse.PickupType.None)) { continue; } + // Discard on last stop + if (item.Trip.ArrivalStoptime.Stop.GtfsId == id) + { + continue; + } + if (item.Trip.Geometry?.Points != null) { _logger.LogDebug("Trip {TripId} has geometry", item.Trip.GtfsId); @@ -133,6 +140,8 @@ public partial class ArrivalsController : ControllerBase // Time after an arrival's time to still include it in the response. This is useful without real-time data, for delayed buses. var timeThreshold = GetThresholdForFeed(id); + var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); + return Ok(new StopArrivalsResponse { StopCode = _feedService.NormalizeStopCode(feedId, stop.Code), @@ -151,8 +160,10 @@ public partial class ArrivalsController : ControllerBase { GtfsId = r.GtfsId, ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""), - Colour = r.Color ?? "FFFFFF", - TextColour = r.TextColor ?? "000000" + Colour = r.Color ?? fallbackColor, + TextColour = r.TextColor is null or "000000" ? + ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) : + r.TextColor })], Arrivals = [.. arrivals.Where(a => a.Estimate.Minutes >= timeThreshold)] }); @@ -163,7 +174,7 @@ public partial class ArrivalsController : ControllerBase { string feedId = id.Split(':', 2)[0]; - if (feedId == "vitrasa" || feedId == "coruna") + if (feedId is "vitrasa" or "coruna") { return 0; } diff --git a/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs index efddf82..823cfa5 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs @@ -1,18 +1,34 @@ +using System.Net; +using Costasdev.Busurbano.Backend.Configuration; using Costasdev.Busurbano.Backend.Services; using Costasdev.Busurbano.Backend.Types.Planner; +using Costasdev.Busurbano.Sources.OpenTripPlannerGql; +using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; namespace Costasdev.Busurbano.Backend.Controllers; [ApiController] [Route("api/planner")] -public class RoutePlannerController : ControllerBase +public partial class RoutePlannerController : ControllerBase { + private readonly ILogger<RoutePlannerController> _logger; private readonly OtpService _otpService; + private readonly AppConfiguration _config; + private readonly HttpClient _httpClient; - public RoutePlannerController(OtpService otpService) + public RoutePlannerController( + ILogger<RoutePlannerController> logger, + OtpService otpService, + IOptions<AppConfiguration> config, + HttpClient httpClient + ) { + _logger = logger; _otpService = otpService; + _config = config.Value; + _httpClient = httpClient; } [HttpGet("autocomplete")] @@ -44,18 +60,43 @@ public class RoutePlannerController : ControllerBase [FromQuery] double fromLon, [FromQuery] double toLat, [FromQuery] double toLon, - [FromQuery] DateTime? time = null, + [FromQuery] DateTimeOffset time, [FromQuery] bool arriveBy = false) { try { - var plan = await _otpService.GetRoutePlanAsync(fromLat, fromLon, toLat, toLon, time, arriveBy); + var requestContent = PlanConnectionContent.Query( + new PlanConnectionContent.Args(fromLat, fromLon, toLat, toLon, time, arriveBy) + ); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1"); + request.Content = JsonContent.Create(new GraphClientRequest + { + Query = requestContent + }); + + var response = await _httpClient.SendAsync(request); + var responseBody = await response.Content.ReadFromJsonAsync<GraphClientResponse<PlanConnectionResponse>>(); + + Console.WriteLine(responseBody); + + if (responseBody is not { IsSuccess: true } || responseBody.Data?.PlanConnection.Edges.Length == 0) + { + LogErrorFetchingRoutes(response.StatusCode, await response.Content.ReadAsStringAsync()); + return StatusCode(500, "An error occurred while planning the route."); + } + + var plan = _otpService.MapPlanResponse(responseBody.Data!); return Ok(plan); } - catch (Exception) + catch (Exception e) { - // Log error + _logger.LogError("Exception planning route: {e}", e); return StatusCode(500, "An error occurred while planning the route."); } } + + [LoggerMessage(LogLevel.Error, "Error fetching route planning, received {statusCode} {responseBody}")] + partial void LogErrorFetchingRoutes(HttpStatusCode? statusCode, string responseBody); + } diff --git a/src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs b/src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs index e48660b..f9fd5f2 100644 --- a/src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs +++ b/src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs @@ -25,7 +25,7 @@ public static class ContrastHelper double contrastWithWhite = (1.0 + 0.05) / (luminance + 0.05); double contrastWithBlack = (luminance + 0.05) / 0.05; - if (contrastWithWhite > 3) + if (contrastWithWhite >= 2.5) { return "#FFFFFF"; } diff --git a/src/Costasdev.Busurbano.Backend/Program.cs b/src/Costasdev.Busurbano.Backend/Program.cs index 7d52c29..7651cbc 100644 --- a/src/Costasdev.Busurbano.Backend/Program.cs +++ b/src/Costasdev.Busurbano.Backend/Program.cs @@ -21,6 +21,8 @@ builder.Services.AddMemoryCache(); builder.Services.AddSingleton<ShapeTraversalService>(); builder.Services.AddSingleton<FeedService>(); +builder.Services.AddSingleton<FareService>(); +builder.Services.AddSingleton<LineFormatterService>(); builder.Services.AddScoped<IArrivalsProcessor, VitrasaRealTimeProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, CorunaRealTimeProcessor>(); diff --git a/src/Costasdev.Busurbano.Backend/Services/FareService.cs b/src/Costasdev.Busurbano.Backend/Services/FareService.cs new file mode 100644 index 0000000..0e4fefc --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Services/FareService.cs @@ -0,0 +1,49 @@ +using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Types.Planner; +using Microsoft.Extensions.Options; + +namespace Costasdev.Busurbano.Backend.Services; + +public record FareResult(double CashFareEuro, double CardFareEuro); + +public class FareService +{ + private readonly AppConfiguration _config; + + public FareService(IOptions<AppConfiguration> config) + { + _config = config.Value; + } + + public FareResult CalculateFare(IEnumerable<Leg> legs) + { + var busLegs = legs.Where(l => l.Mode != null && l.Mode.ToUpper() != "WALK").ToList(); + + // Cash fare logic + // TODO: In the future, this should depend on the operator/feed + var cashFare = busLegs.Count * 1.63; // Defaulting to Vitrasa for now + + // Card fare logic (45-min transfer window) + int cardTicketsRequired = 0; + DateTime? lastTicketPurchased = null; + int tripsPaidWithTicket = 0; + + foreach (var leg in busLegs) + { + if (lastTicketPurchased == null || + (leg.StartTime - lastTicketPurchased.Value).TotalMinutes > 45 || + tripsPaidWithTicket >= 3) + { + cardTicketsRequired++; + lastTicketPurchased = leg.StartTime; + tripsPaidWithTicket = 1; + } + else + { + tripsPaidWithTicket++; + } + } + + return new FareResult(cashFare, cardTicketsRequired * 0.67); + } +} diff --git a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs index a8710b5..3ef079c 100644 --- a/src/Costasdev.Busurbano.Backend/Services/FeedService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/FeedService.cs @@ -62,14 +62,22 @@ public class FeedService public string NormalizeRouteShortName(string feedId, string shortName) { - if (feedId == "xunta" && shortName.StartsWith("XG") && shortName.Length >= 8) + if (feedId == "xunta" && shortName.StartsWith("XG")) { - // XG817014 -> 817.14 - var contract = shortName.Substring(2, 3); - var lineStr = shortName.Substring(5); - if (int.TryParse(lineStr, out int line)) + if (shortName.Length >= 8) + { + // XG817014 -> 817.14 + var contract = shortName.Substring(2, 3); + var lineStr = shortName.Substring(5); + if (int.TryParse(lineStr, out int line)) + { + return $"{contract}.{line:D2}"; + } + } + else if (shortName.Length > 2) { - return $"{contract}.{line:D2}"; + // XG883 -> 883 + return shortName.Substring(2); } } return shortName; @@ -91,6 +99,7 @@ public class FeedService if (feedId == "vitrasa") { return name + .Trim() .Replace("\"", "") .Replace(" ", ", ") .Trim(); diff --git a/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs b/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs index 6ec40b1..db9f2a5 100644 --- a/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs @@ -4,7 +4,7 @@ namespace Costasdev.Busurbano.Backend.Services; public class LineFormatterService { - public static ConsolidatedCirculation Format(ConsolidatedCirculation circulation) + public ConsolidatedCirculation Format(ConsolidatedCirculation circulation) { circulation.Route = circulation.Route.Replace("*", ""); diff --git a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs index eca8f50..7eba590 100644 --- a/src/Costasdev.Busurbano.Backend/Services/OtpService.cs +++ b/src/Costasdev.Busurbano.Backend/Services/OtpService.cs @@ -1,8 +1,10 @@ using System.Globalization; using System.Text; using Costasdev.Busurbano.Backend.Configuration; +using Costasdev.Busurbano.Backend.Helpers; using Costasdev.Busurbano.Backend.Types.Otp; using Costasdev.Busurbano.Backend.Types.Planner; +using Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; @@ -14,13 +16,19 @@ public class OtpService private readonly AppConfiguration _config; private readonly IMemoryCache _cache; private readonly ILogger<OtpService> _logger; + private readonly FareService _fareService; + private readonly LineFormatterService _lineFormatter; + private readonly FeedService _feedService; - public OtpService(HttpClient httpClient, IOptions<AppConfiguration> config, IMemoryCache cache, ILogger<OtpService> logger) + public OtpService(HttpClient httpClient, IOptions<AppConfiguration> config, IMemoryCache cache, ILogger<OtpService> logger, FareService fareService, LineFormatterService lineFormatter, FeedService feedService) { _httpClient = httpClient; _config = config.Value; _cache = cache; _logger = logger; + _fareService = fareService; + _lineFormatter = lineFormatter; + _feedService = feedService; } public async Task<List<PlannerSearchResult>> GetAutocompleteAsync(string query) @@ -244,39 +252,17 @@ public class OtpService private PlannerPlace? MapPlace(OtpPlace? otpPlace) { if (otpPlace == null) return null; + var feedId = otpPlace.StopId?.Split(':')[0] ?? "unknown"; return new PlannerPlace { - Name = CorrectStopName(otpPlace.Name), + Name = _feedService.NormalizeStopName(feedId, otpPlace.Name), Lat = otpPlace.Lat, Lon = otpPlace.Lon, StopId = otpPlace.StopId, // Use string directly - StopCode = CorrectStopCode(otpPlace.StopCode) + StopCode = _feedService.NormalizeStopCode(feedId, otpPlace.StopCode ?? string.Empty) }; } - private string CorrectStopCode(string? stopId) - { - if (string.IsNullOrEmpty(stopId)) return stopId ?? string.Empty; - - var sb = new StringBuilder(); - foreach (var c in stopId) - { - if (char.IsNumber(c)) - { - sb.Append(c); - } - } - - return int.Parse(sb.ToString()).ToString(); - } - - private string CorrectStopName(string? stopName) - { - if (string.IsNullOrEmpty(stopName)) return stopName ?? string.Empty; - - return stopName!.Replace(" ", ", ").Replace("\"", ""); - } - private Step MapStep(OtpWalkStep otpStep) { return new Step @@ -351,4 +337,153 @@ public class OtpService return poly; } + + public RoutePlan MapPlanResponse(PlanConnectionResponse response) + { + var itineraries = response.PlanConnection.Edges + .Select(e => MapItinerary(e.Node)) + .ToList(); + + return new RoutePlan + { + Itineraries = itineraries + }; + } + + private Itinerary MapItinerary(PlanConnectionResponse.Node node) + { + var legs = node.Legs.Select(MapLeg).ToList(); + var fares = _fareService.CalculateFare(legs); + + return new Itinerary + { + DurationSeconds = node.DurationSeconds, + StartTime = DateTime.Parse(node.Start8601, null, DateTimeStyles.RoundtripKind), + EndTime = DateTime.Parse(node.End8601, null, DateTimeStyles.RoundtripKind), + WalkDistanceMeters = node.WalkDistance, + WalkTimeSeconds = node.WalkSeconds, + TransitTimeSeconds = node.DurationSeconds - node.WalkSeconds - node.WaitingSeconds, + WaitingTimeSeconds = node.WaitingSeconds, + Legs = legs, + CashFareEuro = fares.CashFareEuro, + CardFareEuro = fares.CardFareEuro + }; + } + + private Leg MapLeg(PlanConnectionResponse.Leg leg) + { + var feedId = leg.From.Stop?.GtfsId?.Split(':')[0] ?? "unknown"; + var shortName = _feedService.NormalizeRouteShortName(feedId, leg.Route?.ShortName ?? string.Empty); + var headsign = leg.Headsign; + + if (feedId == "vitrasa") + { + headsign = headsign?.Replace("*", ""); + if (headsign == "FORA DE SERVIZO.G.B.") + { + headsign = "García Barbón, 7 (fora de servizo)"; + } + + switch (shortName) + { + case "A" when headsign != null && headsign.StartsWith("\"1\""): + shortName = "A1"; + headsign = headsign.Replace("\"1\"", ""); + break; + case "6": + headsign = headsign?.Replace("\"", ""); + break; + case "FUT": + if (headsign == "CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO") + { + shortName = "MAR"; + headsign = "MARCADOR ⚽: CASTELAO-CAMELIAS-G.BARBÓN.M.GARRIDO"; + } + else if (headsign == "P. ESPAÑA-T.VIGO-S.BADÍA") + { + shortName = "RIO"; + headsign = "RÍO ⚽: P. ESPAÑA-T.VIGO-S.BADÍA"; + } + else if (headsign == "NAVIA-BOUZAS-URZAIZ-G. ESPINO") + { + shortName = "GOL"; + headsign = "GOL ⚽: NAVIA-BOUZAS-URZAIZ-G. ESPINO"; + } + break; + } + } + + var color = leg.Route?.Color; + var textColor = leg.Route?.TextColor; + + if (string.IsNullOrEmpty(color) || color == "FFFFFF") + { + var (fallbackColor, fallbackTextColor) = _feedService.GetFallbackColourForFeed(feedId); + color = fallbackColor.Replace("#", ""); + textColor = fallbackTextColor.Replace("#", ""); + } + else if (string.IsNullOrEmpty(textColor) || textColor == "000000") + { + textColor = ContrastHelper.GetBestTextColour(color).Replace("#", ""); + } + + return new Leg + { + Mode = leg.Mode, + RouteName = leg.Route?.LongName, + RouteShortName = shortName, + RouteLongName = leg.Route?.LongName, + Headsign = headsign, + AgencyName = leg.Route?.Agency?.Name, + RouteColor = color, + RouteTextColor = textColor, + From = MapPlace(leg.From), + To = MapPlace(leg.To), + StartTime = DateTime.Parse(leg.Start.ScheduledTime8601, null, DateTimeStyles.RoundtripKind), + EndTime = DateTime.Parse(leg.End.ScheduledTime8601, null, DateTimeStyles.RoundtripKind), + DistanceMeters = leg.Distance, + Geometry = DecodePolyline(leg.LegGeometry?.Points), + Steps = leg.Steps.Select(MapStep).ToList(), + IntermediateStops = leg.StopCalls.Select(sc => MapPlace(sc.StopLocation)).ToList() + }; + } + + private PlannerPlace MapPlace(PlanConnectionResponse.LegPosition pos) + { + var feedId = pos.Stop?.GtfsId?.Split(':')[0] ?? "unknown"; + return new PlannerPlace + { + Name = _feedService.NormalizeStopName(feedId, pos.Name), + Lat = pos.Latitude, + Lon = pos.Longitude, + StopId = pos.Stop?.GtfsId, + StopCode = _feedService.NormalizeStopCode(feedId, pos.Stop?.Code ?? string.Empty) + }; + } + + private PlannerPlace MapPlace(PlanConnectionResponse.StopLocation stop) + { + var feedId = stop.GtfsId?.Split(':')[0] ?? "unknown"; + return new PlannerPlace + { + Name = _feedService.NormalizeStopName(feedId, stop.Name), + Lat = stop.Latitude, + Lon = stop.Longitude, + StopId = stop.GtfsId, + StopCode = _feedService.NormalizeStopCode(feedId, stop.Code ?? string.Empty) + }; + } + + private Step MapStep(PlanConnectionResponse.Step step) + { + return new Step + { + DistanceMeters = step.Distance, + RelativeDirection = step.RelativeDirection, + AbsoluteDirection = step.AbsoluteDirection, + StreetName = step.StreetName, + Lat = step.Latitude, + Lon = step.Longitude + }; + } } diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs index ee98a1f..587917e 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs @@ -73,8 +73,6 @@ public class CorunaRealTimeProcessor : AbstractRealTimeProcessor } var arrival = bestMatch.Arrival; - _logger.LogInformation("Matched Coruña real-time for line {Line}: {Scheduled}m -> {RealTime}m (diff: {Diff}m)", - arrival.Route.ShortName, arrival.Estimate.Minutes, estimate.Minutes, bestMatch.TimeDiff); var scheduledMinutes = arrival.Estimate.Minutes; arrival.Estimate.Minutes = estimate.Minutes; diff --git a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs index 5e0783d..f3a8d91 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs @@ -111,8 +111,6 @@ public class VitrasaRealTimeProcessor : AbstractRealTimeProcessor if (bestMatch != null) { var arrival = bestMatch.Arrival; - _logger.LogInformation("Matched Vitrasa real-time for line {Line}: {Scheduled}m -> {RealTime}m (diff: {Diff}m)", - arrival.Route.ShortName, arrival.Estimate.Minutes, estimate.Minutes, bestMatch.TimeDiff); var scheduledMinutes = arrival.Estimate.Minutes; arrival.Estimate.Minutes = estimate.Minutes; diff --git a/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs b/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs index d245cd8..e54b66e 100644 --- a/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs +++ b/src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs @@ -15,13 +15,15 @@ public class VitrasaTransitProvider : ITransitProvider private readonly VigoTransitApiClient _api; private readonly AppConfiguration _configuration; private readonly ShapeTraversalService _shapeService; + private readonly LineFormatterService _lineFormatter; private readonly ILogger<VitrasaTransitProvider> _logger; - public VitrasaTransitProvider(HttpClient http, IOptions<AppConfiguration> options, ShapeTraversalService shapeService, ILogger<VitrasaTransitProvider> logger) + public VitrasaTransitProvider(HttpClient http, IOptions<AppConfiguration> options, ShapeTraversalService shapeService, LineFormatterService lineFormatter, ILogger<VitrasaTransitProvider> logger) { _api = new VigoTransitApiClient(http); _configuration = options.Value; _shapeService = shapeService; + _lineFormatter = lineFormatter; _logger = logger; } @@ -261,7 +263,7 @@ public class VitrasaTransitProvider : ITransitProvider // Sort by ETA (RealTime minutes if present; otherwise Schedule minutes) var sorted = consolidatedCirculations .OrderBy(c => c.RealTime?.Minutes ?? c.Schedule!.Minutes) - .Select(LineFormatterService.Format) + .Select(_lineFormatter.Format) .ToList(); return sorted; diff --git a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs index c31d12a..b2f4d6a 100644 --- a/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs +++ b/src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs @@ -15,7 +15,7 @@ public class Itinerary public double WalkTimeSeconds { get; set; } public double TransitTimeSeconds { get; set; } public double WaitingTimeSeconds { get; set; } - public List<Leg> Legs { get; set; } = new(); + public List<Leg> Legs { get; set; } = []; public double? CashFareEuro { get; set; } public double? CardFareEuro { get; set; } } |
