aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs17
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/RoutePlannerController.cs53
-rw-r--r--src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs2
-rw-r--r--src/Costasdev.Busurbano.Backend/Program.cs2
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/FareService.cs49
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/FeedService.cs21
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/LineFormatterService.cs2
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/OtpService.cs187
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/CorunaRealTimeProcessor.cs2
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Processors/VitrasaRealTimeProcessor.cs2
-rw-r--r--src/Costasdev.Busurbano.Backend/Services/Providers/VitrasaTransitProvider.cs6
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/Planner/PlannerResponse.cs2
-rw-r--r--src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs18
-rw-r--r--src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs238
-rw-r--r--src/frontend/app/api/planner.ts34
-rw-r--r--src/frontend/app/api/schema.ts69
-rw-r--r--src/frontend/app/components/LineIcon.css12
-rw-r--r--src/frontend/app/hooks/usePlanQuery.ts29
-rw-r--r--src/frontend/app/hooks/usePlanner.ts93
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json6
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json2
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json6
-rw-r--r--src/frontend/app/routes/planner.tsx106
23 files changed, 794 insertions, 164 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; }
}
diff --git a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs
index bbf2c08..bce35a2 100644
--- a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs
+++ b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/ArrivalsAtStop.cs
@@ -46,6 +46,11 @@ public class ArrivalsAtStopContent : IGraphRequest<ArrivalsAtStopContent.Args>
departureStoptime {{
scheduledDeparture
}}
+ arrivalStoptime {{
+ stop {{
+ gtfsId
+ }}
+ }}
{geometryField}
stoptimes {{
stop {{
@@ -110,6 +115,9 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse
[JsonPropertyName("departureStoptime")]
public required DepartureStoptime DepartureStoptime { get; set; }
+ [JsonPropertyName("arrivalStoptime")]
+ public required ArrivalStoptime ArrivalStoptime { get; set; }
+
[JsonPropertyName("route")] public required RouteDetails Route { get; set; }
[JsonPropertyName("tripGeometry")] public GeometryDetails? Geometry { get; set; }
@@ -141,6 +149,16 @@ public class ArrivalsAtStopResponse : AbstractGraphResponse
public int ScheduledDeparture { get; set; }
}
+ public class ArrivalStoptime
+ {
+ [JsonPropertyName("stop")] public ArrivalStoptimeStop Stop { get; set; }
+ }
+
+ public class ArrivalStoptimeStop
+ {
+ [JsonPropertyName("gtfsId")] public required string GtfsId { get; set; }
+ }
+
public class RouteDetails
{
[JsonPropertyName("gtfsId")] public required string GtfsId { get; set; }
diff --git a/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs
new file mode 100644
index 0000000..a4bf8d1
--- /dev/null
+++ b/src/Costasdev.Busurbano.Sources.OpenTripPlannerGql/Queries/PlanConnectionContent.cs
@@ -0,0 +1,238 @@
+using System.Globalization;
+using System.Text.Json.Serialization;
+
+namespace Costasdev.Busurbano.Sources.OpenTripPlannerGql.Queries;
+
+#pragma warning disable CS8618
+
+public class PlanConnectionContent : IGraphRequest<PlanConnectionContent.Args>
+{
+ public record Args(
+ double StartLatitude,
+ double StartLongitude,
+ double EndLatitude,
+ double EndLongitude,
+ DateTimeOffset ReferenceTime,
+ bool ReferenceIsArrival = false
+ );
+
+ public static string Query(Args args)
+ {
+ var madridTz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid");
+
+ // Treat incoming DateTime as Madrid local wall-clock time
+ var localMadridTime =
+ DateTime.SpecifyKind(args.ReferenceTime.UtcDateTime, DateTimeKind.Unspecified);
+
+ var offset = madridTz.GetUtcOffset(localMadridTime);
+ var actualTimeOfQuery = new DateTimeOffset(localMadridTime, offset);
+
+ var dateTimeParameter = args.ReferenceIsArrival ? $"latestArrival:\"{actualTimeOfQuery:O}\"" : $"earliestDeparture:\"{actualTimeOfQuery:O}\"";
+
+ return string.Create(CultureInfo.InvariantCulture,
+ $$"""
+ query Query {
+ planConnection(
+ first: 4
+ origin: {
+ location:{
+ coordinate:{
+ latitude:{{args.StartLatitude}}
+ longitude:{{args.StartLongitude}}
+ }
+ }
+ }
+ destination:{
+ location:{
+ coordinate:{
+ latitude:{{args.EndLatitude}}
+ longitude:{{args.EndLongitude}}
+ }
+ }
+ }
+ dateTime:{
+ {{dateTimeParameter}}
+ }
+ ) {
+ edges {
+ node {
+ duration
+ start
+ end
+ walkTime
+ walkDistance
+ waitingTime
+ legs {
+ start {
+ scheduledTime
+ }
+ end {
+ scheduledTime
+ }
+ mode
+ route {
+ shortName
+ longName
+ agency {
+ name
+ }
+ color
+ textColor
+ }
+ from {
+ name
+ lat
+ lon
+ stop {
+ gtfsId
+ code
+ name
+ lat
+ lon
+ }
+ }
+ to {
+ name
+ lat
+ lon
+ stop {
+ gtfsId
+ code
+ name
+ lat
+ lon
+ }
+ }
+ stopCalls {
+ stopLocation {
+ ... on Stop {
+ gtfsId
+ code
+ name
+ lat
+ lon
+ }
+ }
+ }
+ legGeometry {
+ points
+ }
+ steps {
+ distance
+ relativeDirection
+ streetName
+ absoluteDirection
+ lat
+ lon
+ }
+ headsign
+ distance
+ }
+ }
+ }
+ }
+ }
+ """);
+ }
+}
+
+public class PlanConnectionResponse : AbstractGraphResponse
+{
+ public PlanConnectionItem PlanConnection { get; set; }
+
+ public class PlanConnectionItem
+ {
+ [JsonPropertyName("edges")]
+ public Edge[] Edges { get; set; }
+ }
+
+ public class Edge
+ {
+ [JsonPropertyName("node")]
+ public Node Node { get; set; }
+ }
+
+ public class Node
+ {
+ [JsonPropertyName("duration")] public int DurationSeconds { get; set; }
+ [JsonPropertyName("start")] public string Start8601 { get; set; }
+ [JsonPropertyName("end")] public string End8601 { get; set; }
+ [JsonPropertyName("walkTime")] public int WalkSeconds { get; set; }
+ [JsonPropertyName("walkDistance")] public double WalkDistance { get; set; }
+ [JsonPropertyName("waitingTime")] public int WaitingSeconds { get; set; }
+ [JsonPropertyName("legs")] public Leg[] Legs { get; set; }
+ }
+
+ public class Leg
+ {
+ [JsonPropertyName("start")] public ScheduledTimeContainer Start { get; set; }
+ [JsonPropertyName("end")] public ScheduledTimeContainer End { get; set; }
+ [JsonPropertyName("mode")] public string Mode { get; set; } // TODO: Make enum, maybe
+ [JsonPropertyName("route")] public TransitRoute? Route { get; set; }
+ [JsonPropertyName("from")] public LegPosition From { get; set; }
+ [JsonPropertyName("to")] public LegPosition To { get; set; }
+ [JsonPropertyName("stopCalls")] public StopCall[] StopCalls { get; set; }
+ [JsonPropertyName("legGeometry")] public LegGeometry LegGeometry { get; set; }
+ [JsonPropertyName("steps")] public Step[] Steps { get; set; }
+ [JsonPropertyName("headsign")] public string? Headsign { get; set; }
+ [JsonPropertyName("distance")] public double Distance { get; set; }
+ }
+
+ public class TransitRoute
+ {
+ [JsonPropertyName("shortName")] public string ShortName { get; set; }
+ [JsonPropertyName("longName")] public string LongName { get; set; }
+ [JsonPropertyName("agency")] public AgencyNameContainer Agency { get; set; }
+ [JsonPropertyName("color")] public string Color { get; set; }
+ [JsonPropertyName("textColor")] public string TextColor { get; set; }
+ }
+
+ public class LegPosition
+ {
+ [JsonPropertyName("name")] public string Name { get; set; }
+ [JsonPropertyName("lat")] public double Latitude { get; set; }
+ [JsonPropertyName("lon")] public double Longitude { get; set; }
+ [JsonPropertyName("stop")] public StopLocation Stop { get; set; }
+ }
+
+ public class ScheduledTimeContainer
+ {
+ [JsonPropertyName("scheduledTime")]
+ public string ScheduledTime8601 { get; set; }
+ }
+
+ public class AgencyNameContainer
+ {
+ [JsonPropertyName("name")] public string Name { get; set; }
+ }
+
+ public class StopCall
+ {
+ [JsonPropertyName("stopLocation")]
+ public StopLocation StopLocation { get; set; }
+ }
+
+ public class StopLocation
+ {
+ [JsonPropertyName("gtfsId")] public string GtfsId { get; set; }
+ [JsonPropertyName("code")] public string Code { get; set; }
+ [JsonPropertyName("name")] public string Name { get; set; }
+ [JsonPropertyName("lat")] public double Latitude { get; set; }
+ [JsonPropertyName("lon")] public double Longitude { get; set; }
+ }
+
+ public class Step
+ {
+ [JsonPropertyName("distance")] public double Distance { get; set; }
+ [JsonPropertyName("relativeDirection")] public string RelativeDirection { get; set; }
+ [JsonPropertyName("streetName")] public string StreetName { get; set; } // TODO: "sidewalk", "path" or actual street name
+ [JsonPropertyName("absoluteDirection")] public string AbsoluteDirection { get; set; }
+ [JsonPropertyName("lat")] public double Latitude { get; set; }
+ [JsonPropertyName("lon")] public double Longitude { get; set; }
+ }
+
+ public class LegGeometry
+ {
+ [JsonPropertyName("points")] public string? Points { get; set; }
+ }
+}
diff --git a/src/frontend/app/api/planner.ts b/src/frontend/app/api/planner.ts
new file mode 100644
index 0000000..86f44f0
--- /dev/null
+++ b/src/frontend/app/api/planner.ts
@@ -0,0 +1,34 @@
+import { RoutePlanSchema, type RoutePlan } from "./schema";
+
+export const fetchPlan = async (
+ 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 resp = await fetch(url, {
+ headers: {
+ Accept: "application/json",
+ },
+ });
+
+ if (!resp.ok) {
+ throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
+ }
+
+ const data = await resp.json();
+ try {
+ return RoutePlanSchema.parse(data);
+ } catch (e) {
+ console.error("Zod parsing failed for route plan:", e);
+ console.log("Received data:", data);
+ throw e;
+ }
+};
diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts
index 9cc5bd4..05f3a87 100644
--- a/src/frontend/app/api/schema.ts
+++ b/src/frontend/app/api/schema.ts
@@ -8,7 +8,7 @@ export const RouteInfoSchema = z.object({
export const HeadsignInfoSchema = z.object({
badge: z.string().optional().nullable(),
- destination: z.string(),
+ destination: z.string().nullable(),
marquee: z.string().optional().nullable(),
});
@@ -108,3 +108,70 @@ export const ConsolidatedCirculationSchema = z.object({
export type ConsolidatedCirculation = z.infer<
typeof ConsolidatedCirculationSchema
>;
+
+// Route Planner
+export const PlannerPlaceSchema = z.object({
+ name: z.string().optional().nullable(),
+ lat: z.number(),
+ lon: z.number(),
+ stopId: z.string().optional().nullable(),
+ stopCode: z.string().optional().nullable(),
+});
+
+export const PlannerGeometrySchema = z.object({
+ type: z.string(),
+ coordinates: z.array(z.array(z.number())),
+});
+
+export const PlannerStepSchema = z.object({
+ distanceMeters: z.number(),
+ relativeDirection: z.string().optional().nullable(),
+ absoluteDirection: z.string().optional().nullable(),
+ streetName: z.string().optional().nullable(),
+ lat: z.number(),
+ lon: z.number(),
+});
+
+export const PlannerLegSchema = z.object({
+ mode: z.string().optional().nullable(),
+ routeName: z.string().optional().nullable(),
+ routeShortName: z.string().optional().nullable(),
+ routeLongName: z.string().optional().nullable(),
+ routeColor: z.string().optional().nullable(),
+ routeTextColor: z.string().optional().nullable(),
+ headsign: z.string().optional().nullable(),
+ agencyName: z.string().optional().nullable(),
+ from: PlannerPlaceSchema.optional().nullable(),
+ to: PlannerPlaceSchema.optional().nullable(),
+ startTime: z.string(),
+ endTime: z.string(),
+ distanceMeters: z.number(),
+ geometry: PlannerGeometrySchema.optional().nullable(),
+ steps: z.array(PlannerStepSchema),
+ intermediateStops: z.array(PlannerPlaceSchema),
+});
+
+export const ItinerarySchema = z.object({
+ durationSeconds: z.number(),
+ startTime: z.string(),
+ endTime: z.string(),
+ walkDistanceMeters: z.number(),
+ walkTimeSeconds: z.number(),
+ transitTimeSeconds: z.number(),
+ waitingTimeSeconds: z.number(),
+ legs: z.array(PlannerLegSchema),
+ cashFareEuro: z.number().optional().nullable(),
+ cardFareEuro: z.number().optional().nullable(),
+});
+
+export const RoutePlanSchema = z.object({
+ itineraries: z.array(ItinerarySchema),
+ timeOffsetSeconds: z.number().optional().nullable(),
+});
+
+export type PlannerPlace = z.infer<typeof PlannerPlaceSchema>;
+export type PlannerGeometry = z.infer<typeof PlannerGeometrySchema>;
+export type PlannerStep = z.infer<typeof PlannerStepSchema>;
+export type PlannerLeg = z.infer<typeof PlannerLegSchema>;
+export type Itinerary = z.infer<typeof ItinerarySchema>;
+export type RoutePlan = z.infer<typeof RoutePlanSchema>;
diff --git a/src/frontend/app/components/LineIcon.css b/src/frontend/app/components/LineIcon.css
index 448b5fd..a8a413c 100644
--- a/src/frontend/app/components/LineIcon.css
+++ b/src/frontend/app/components/LineIcon.css
@@ -127,18 +127,20 @@
}
.line-icon-rounded {
- display: block;
- width: 4.25ch;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 4.25ch;
height: 4.25ch;
box-sizing: border-box;
background-color: var(--line-colour);
color: var(--line-text-colour);
- padding: 1.4ch 0.8ch;
+ padding: 0 0.8ch;
text-align: center;
- border-radius: 50%;
+ border-radius: 2.125ch;
font: 600 13px / 1 monospace;
letter-spacing: 0.05em;
- text-wrap: nowrap;
+ white-space: nowrap;
}
diff --git a/src/frontend/app/hooks/usePlanQuery.ts b/src/frontend/app/hooks/usePlanQuery.ts
new file mode 100644
index 0000000..103f5f4
--- /dev/null
+++ b/src/frontend/app/hooks/usePlanQuery.ts
@@ -0,0 +1,29 @@
+import { useQuery } from "@tanstack/react-query";
+import { fetchPlan } from "../api/planner";
+
+export const usePlanQuery = (
+ fromLat: number | undefined,
+ fromLon: number | undefined,
+ toLat: number | undefined,
+ toLon: number | undefined,
+ time?: Date,
+ arriveBy: boolean = false,
+ enabled: boolean = true
+) => {
+ return useQuery({
+ queryKey: [
+ "plan",
+ fromLat,
+ fromLon,
+ toLat,
+ toLon,
+ time?.toISOString(),
+ arriveBy,
+ ],
+ queryFn: () =>
+ fetchPlan(fromLat!, fromLon!, toLat!, toLon!, time, arriveBy),
+ enabled: !!(fromLat && fromLon && toLat && toLon) && enabled,
+ staleTime: 60000, // 1 minute
+ retry: false,
+ });
+};
diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts
index 6123f8a..a28167a 100644
--- a/src/frontend/app/hooks/usePlanner.ts
+++ b/src/frontend/app/hooks/usePlanner.ts
@@ -1,9 +1,6 @@
import { useCallback, useEffect, useState } from "react";
-import {
- type PlannerSearchResult,
- type RoutePlan,
- planRoute,
-} from "../data/PlannerApi";
+import { type PlannerSearchResult, type RoutePlan } from "../data/PlannerApi";
+import { usePlanQuery } from "./usePlanQuery";
const STORAGE_KEY = "planner_last_route";
const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
@@ -32,6 +29,48 @@ export function usePlanner() {
number | null
>(null);
+ const {
+ data: queryPlan,
+ isLoading: queryLoading,
+ error: queryError,
+ isFetching,
+ } = usePlanQuery(
+ origin?.lat,
+ origin?.lon,
+ destination?.lat,
+ destination?.lon,
+ searchTime ?? undefined,
+ arriveBy,
+ !!(origin && destination)
+ );
+
+ // Sync query result to local state and storage
+ useEffect(() => {
+ if (queryPlan) {
+ setPlan(queryPlan as any); // Cast because of slight type differences if any, but they should match now
+
+ if (origin && destination) {
+ const toStore: StoredRoute = {
+ timestamp: Date.now(),
+ origin,
+ destination,
+ plan: queryPlan as any,
+ searchTime: searchTime ?? new Date(),
+ arriveBy,
+ selectedItineraryIndex: selectedItineraryIndex ?? undefined,
+ };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
+ }
+ }
+ }, [
+ queryPlan,
+ origin,
+ destination,
+ searchTime,
+ arriveBy,
+ selectedItineraryIndex,
+ ]);
+
// Load from storage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
@@ -60,41 +99,11 @@ export function usePlanner() {
time?: Date,
arriveByParam: boolean = false
) => {
- setLoading(true);
- setError(null);
- try {
- const result = await planRoute(
- from.lat,
- from.lon,
- to.lat,
- to.lon,
- time,
- arriveByParam
- );
- setPlan(result);
- setOrigin(from);
- setDestination(to);
- setSearchTime(time ?? new Date());
- setArriveBy(arriveByParam);
- setSelectedItineraryIndex(null); // Reset when doing new search
-
- // Save to storage
- const toStore: StoredRoute = {
- timestamp: Date.now(),
- origin: from,
- destination: to,
- plan: result,
- searchTime: time ?? new Date(),
- arriveBy: arriveByParam,
- selectedItineraryIndex: undefined,
- };
- localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
- } catch (err) {
- setError("Failed to calculate route. Please try again.");
- setPlan(null);
- } finally {
- setLoading(false);
- }
+ setOrigin(from);
+ setDestination(to);
+ setSearchTime(time ?? new Date());
+ setArriveBy(arriveByParam);
+ setSelectedItineraryIndex(null);
};
const clearRoute = () => {
@@ -145,8 +154,8 @@ export function usePlanner() {
destination,
setDestination,
plan,
- loading,
- error,
+ loading: queryLoading || (isFetching && !plan),
+ error: queryError ? "Failed to calculate route. Please try again." : null,
searchTime,
arriveBy,
selectedItineraryIndex,
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index a8f3f52..2c58ebe 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -134,9 +134,11 @@
"walk_to": "Walk {{distance}} to {{destination}}",
"from_to": "From {{from}} to {{to}}",
"itinerary_details": "Itinerary Details",
+ "direction": "Direction",
+ "operator": "Operator",
"back": "← Back",
- "cash_fare": "€{{amount}}",
- "card_fare": "€{{amount}}"
+ "fare": "€{{amount}}",
+ "free": "Free"
},
"common": {
"loading": "Loading...",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index 2bffac9..298733e 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -134,6 +134,8 @@
"walk_to": "Caminar {{distance}} hasta {{destination}}",
"from_to": "De {{from}} a {{to}}",
"itinerary_details": "Detalles del itinerario",
+ "direction": "Dirección",
+ "operator": "Operador",
"back": "← Atrás",
"fare": "{{amount}} €",
"free": "Gratuito"
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index 5086feb..833279f 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -134,9 +134,11 @@
"walk_to": "Camiñar {{distance}} ata {{destination}}",
"from_to": "De {{from}} a {{to}}",
"itinerary_details": "Detalles do itinerario",
+ "direction": "Dirección",
+ "operator": "Operador",
"back": "← Atrás",
- "cash_fare": "{{amount}} €",
- "card_fare": "{{amount}} €"
+ "fare": "{{amount}} €",
+ "free": "Gratuíto"
},
"common": {
"loading": "Cargando...",
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx
index e99cb03..44488c8 100644
--- a/src/frontend/app/routes/planner.tsx
+++ b/src/frontend/app/routes/planner.tsx
@@ -1,53 +1,24 @@
import { Coins, CreditCard, Footprints } from "lucide-react";
-import maplibregl, { type StyleSpecification } from "maplibre-gl";
+import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Layer, Source, type MapRef } from "react-map-gl/maplibre";
import { useLocation } from "react-router";
-import { useApp } from "~/AppContext";
+import { type ConsolidatedCirculation, type Itinerary } from "~/api/schema";
import LineIcon from "~/components/LineIcon";
import { PlannerOverlay } from "~/components/PlannerOverlay";
import { AppMap } from "~/components/shared/AppMap";
import { APP_CONSTANTS } from "~/config/constants";
import { usePageTitle } from "~/contexts/PageTitleContext";
-import { type Itinerary } from "~/data/PlannerApi";
import { usePlanner } from "~/hooks/usePlanner";
import "../tailwind-full.css";
-export interface ConsolidatedCirculation {
- line: string;
- route: string;
- schedule?: {
- running: boolean;
- minutes: number;
- serviceId: string;
- tripId: string;
- shapeId?: string;
- };
- realTime?: {
- minutes: number;
- distance: number;
- };
- currentPosition?: {
- latitude: number;
- longitude: number;
- orientationDegrees: number;
- shapeIndex?: number;
- };
- isPreviousTrip?: boolean;
- previousTripShapeId?: string;
- nextStreets?: string[];
-}
-
-const FARE_CASH_PER_BUS = 1.63;
-const FARE_CARD_PER_BUS = 0.67;
-
const formatDistance = (meters: number) => {
- const intMeters = Math.round(meters);
- if (intMeters >= 1000) return `${(intMeters / 1000).toFixed(1)} km`;
- return `${intMeters} m`;
+ if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`;
+ const rounded = Math.round(meters / 100) * 100;
+ return `${rounded} m`;
};
const haversineMeters = (a: [number, number], b: [number, number]) => {
@@ -116,12 +87,8 @@ const ItinerarySummary = ({
const busLegsCount = itinerary.legs.filter(
(leg) => leg.mode !== "WALK"
).length;
- const cashFare = (
- itinerary.cashFareEuro ?? busLegsCount * FARE_CASH_PER_BUS
- ).toFixed(2);
- const cardFare = (
- itinerary.cardFareEuro ?? busLegsCount * FARE_CARD_PER_BUS
- ).toFixed(2);
+ const cashFare = (itinerary.cashFareEuro ?? 0).toFixed(2);
+ const cardFare = (itinerary.cardFareEuro ?? 0).toFixed(2);
return (
<div
@@ -132,9 +99,7 @@ const ItinerarySummary = ({
<div className="font-bold text-lg text-text">
{startTime} - {endTime}
</div>
- <div className="text-muted">
- {durationMinutes} min
- </div>
+ <div className="text-muted">{durationMinutes} min</div>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-2">
@@ -168,6 +133,8 @@ const ItinerarySummary = ({
<LineIcon
line={leg.routeShortName || leg.routeName || leg.mode || ""}
mode="pill"
+ colour={leg.routeColor || undefined}
+ textColour={leg.routeTextColor || undefined}
/>
</div>
)}
@@ -180,7 +147,7 @@ const ItinerarySummary = ({
<span>
{t("planner.walk")}: {formatDistance(walkTotals.meters)}
{walkTotals.minutes
- ? ` • ${walkTotals.minutes} {t("estimates.minutes")}`
+ ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}`
: ""}
</span>
<span className="flex items-center gap-3">
@@ -566,7 +533,7 @@ const ItineraryDetail = ({
</div>
{/* Details Panel */}
- <div className="h-1/3 md:h-full md:w-96 lg:w-md overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700">
+ <div className="h-1/3 md:h-full md:w-96 lg:w-lg overflow-y-auto bg-white dark:bg-slate-900 border-t md:border-t-0 md:border-l border-slate-200 dark:border-slate-700">
<div className="px-4 py-4">
<h2 className="text-xl font-bold mb-4 text-slate-900 dark:text-slate-100">
{t("planner.itinerary_details")}
@@ -575,7 +542,7 @@ const ItineraryDetail = ({
<div>
{itinerary.legs.map((leg, idx) => (
<div key={idx} className="flex gap-3">
- <div className="flex flex-col items-center">
+ <div className="flex flex-col items-center w-20 shrink-0">
{leg.mode === "WALK" ? (
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shadow-sm"
@@ -587,6 +554,8 @@ const ItineraryDetail = ({
<LineIcon
line={leg.routeShortName || leg.routeName || ""}
mode="rounded"
+ colour={leg.routeColor || undefined}
+ textColour={leg.routeTextColor || undefined}
/>
)}
{idx < itinerary.legs.length - 1 && (
@@ -598,29 +567,42 @@ const ItineraryDetail = ({
{leg.mode === "WALK" ? (
t("planner.walk")
) : (
- <>
- <span>
+ <div className="flex flex-col">
+ <span className="text-[10px] uppercase text-muted font-bold leading-none mb-1">
+ {t("planner.direction")}
+ </span>
+ <span className="leading-tight">
{leg.headsign ||
leg.routeLongName ||
leg.routeName ||
""}
</span>
- </>
+ </div>
)}
</div>
- <div className="text-sm text-gray-600 dark:text-gray-400">
- {new Date(leg.startTime).toLocaleTimeString([], {
- hour: "2-digit",
- minute: "2-digit",
- timeZone: "Europe/Madrid",
- })}{" "}
- -{" "}
- {(
- (new Date(leg.endTime).getTime() -
- new Date(leg.startTime).getTime()) /
- 60000
- ).toFixed(0)}{" "}
- {t("estimates.minutes")}
+ <div className="text-sm text-gray-600 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-1 mt-1">
+ <span>
+ {new Date(leg.startTime).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZone: "Europe/Madrid",
+ })}{" "}
+ -{" "}
+ {(
+ (new Date(leg.endTime).getTime() -
+ new Date(leg.startTime).getTime()) /
+ 60000
+ ).toFixed(0)}{" "}
+ {t("estimates.minutes")}
+ </span>
+ <span>•</span>
+ <span>{formatDistance(leg.distanceMeters)}</span>
+ {leg.agencyName && (
+ <>
+ <span>•</span>
+ <span className="italic">{leg.agencyName}</span>
+ </>
+ )}
</div>
{leg.mode !== "WALK" &&
leg.from?.stopId &&