aboutsummaryrefslogtreecommitdiff
path: root/src/Enmarcha.Backend/Controllers
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-29 00:41:52 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-29 00:41:52 +0100
commita304c24b32c0327436bbd8c2853e60668e161b42 (patch)
tree08f65c05daca134cf4d2e4f779bd15d98fd66370 /src/Enmarcha.Backend/Controllers
parent120a3c6bddd0fb8d9fa05df4763596956554c025 (diff)
Rename a lot of stuff, add Santiago real time
Diffstat (limited to 'src/Enmarcha.Backend/Controllers')
-rw-r--r--src/Enmarcha.Backend/Controllers/ArrivalsController.cs249
-rw-r--r--src/Enmarcha.Backend/Controllers/RoutePlannerController.cs102
-rw-r--r--src/Enmarcha.Backend/Controllers/TileController.cs217
-rw-r--r--src/Enmarcha.Backend/Controllers/TrafficDataController.cs110
-rw-r--r--src/Enmarcha.Backend/Controllers/TransitController.cs127
-rw-r--r--src/Enmarcha.Backend/Controllers/VigoController.cs69
6 files changed, 874 insertions, 0 deletions
diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
new file mode 100644
index 0000000..7260fb4
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
@@ -0,0 +1,249 @@
+using System.Net;
+using Enmarcha.Sources.OpenTripPlannerGql;
+using Enmarcha.Sources.OpenTripPlannerGql.Queries;
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Helpers;
+using Enmarcha.Backend.Services;
+using Enmarcha.Backend.Types;
+using Enmarcha.Backend.Types.Arrivals;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Controllers;
+
+[ApiController]
+[Route("api/stops")]
+public partial class ArrivalsController : ControllerBase
+{
+ private readonly ILogger<ArrivalsController> _logger;
+ private readonly IMemoryCache _cache;
+ private readonly HttpClient _httpClient;
+ private readonly ArrivalsPipeline _pipeline;
+ private readonly FeedService _feedService;
+ private readonly AppConfiguration _config;
+
+ public ArrivalsController(
+ ILogger<ArrivalsController> logger,
+ IMemoryCache cache,
+ HttpClient httpClient,
+ ArrivalsPipeline pipeline,
+ FeedService feedService,
+ IOptions<AppConfiguration> configOptions
+ )
+ {
+ _logger = logger;
+ _cache = cache;
+ _httpClient = httpClient;
+ _pipeline = pipeline;
+ _feedService = feedService;
+ _config = configOptions.Value;
+ }
+
+ [HttpGet("arrivals")]
+ public async Task<IActionResult> GetArrivals(
+ [FromQuery] string id,
+ [FromQuery] bool reduced
+ )
+ {
+ var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid");
+ var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz);
+ var todayLocal = nowLocal.Date;
+
+ var requestContent = ArrivalsAtStopContent.Query(
+ new ArrivalsAtStopContent.Args(id, reduced)
+ );
+
+ 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<ArrivalsAtStopResponse>>();
+
+ if (responseBody is not { IsSuccess: true } || responseBody.Data?.Stop == null)
+ {
+ LogErrorFetchingStopData(response.StatusCode, await response.Content.ReadAsStringAsync());
+ return StatusCode(500, "Error fetching stop data");
+ }
+
+ var stop = responseBody.Data.Stop;
+ _logger.LogInformation("Fetched {Count} arrivals for stop {StopName} ({StopId})", stop.Arrivals.Count, stop.Name, id);
+
+ 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);
+ }
+
+ // Calculate departure time using the service day in the feed's timezone (Europe/Madrid)
+ // This ensures we treat ScheduledDepartureSeconds as relative to the local midnight of the service day
+ var serviceDayLocal = TimeZoneInfo.ConvertTime(DateTimeOffset.FromUnixTimeSeconds(item.ServiceDay), tz);
+ var departureTime = serviceDayLocal.Date.AddSeconds(item.ScheduledDepartureSeconds);
+
+ var minutesToArrive = (int)(departureTime - nowLocal).TotalMinutes;
+ //var isRunning = departureTime < nowLocal;
+
+ Arrival arrival = new()
+ {
+ TripId = item.Trip.GtfsId,
+ Route = new RouteInfo
+ {
+ GtfsId = item.Trip.Route.GtfsId,
+ ShortName = item.Trip.RouteShortName,
+ Colour = item.Trip.Route.Color ?? "FFFFFF",
+ TextColour = item.Trip.Route.TextColor ?? "000000"
+ },
+ Headsign = new HeadsignInfo
+ {
+ Destination = item.Headsign
+ },
+ Estimate = new ArrivalDetails
+ {
+ Minutes = minutesToArrive,
+ Precision = departureTime < nowLocal.AddMinutes(-1) ? ArrivalPrecision.Past : ArrivalPrecision.Scheduled
+ },
+ RawOtpTrip = item
+ };
+
+ arrivals.Add(arrival);
+ }
+
+ await _pipeline.ExecuteAsync(new ArrivalsContext
+ {
+ StopId = id,
+ StopCode = stop.Code,
+ IsReduced = reduced,
+ Arrivals = arrivals,
+ NowLocal = nowLocal,
+ StopLocation = new Position { Latitude = stop.Lat, Longitude = stop.Lon }
+ });
+
+ var feedId = id.Split(':')[0];
+
+ // 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),
+ StopName = _feedService.NormalizeStopName(feedId, stop.Name),
+ StopLocation = new Position
+ {
+ Latitude = stop.Lat,
+ Longitude = stop.Lon
+ },
+ Routes = [.. stop.Routes
+ .OrderBy(
+ r => r.ShortName,
+ Comparer<string?>.Create(SortingHelper.SortRouteShortNames)
+ )
+ .Select(r => new RouteInfo
+ {
+ GtfsId = r.GtfsId,
+ ShortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""),
+ 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)]
+ });
+
+ }
+
+ private static int GetThresholdForFeed(string id)
+ {
+ string feedId = id.Split(':', 2)[0];
+
+ if (feedId is "vitrasa" or "tranvias" or "tussa")
+ {
+ return 0;
+ }
+
+ return -30;
+ }
+
+ [HttpGet]
+ public async Task<IActionResult> GetStops([FromQuery] string ids)
+ {
+ if (string.IsNullOrWhiteSpace(ids))
+ {
+ return BadRequest("Ids parameter is required");
+ }
+
+ var stopIds = ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var requestContent = StopsInfoContent.Query(new StopsInfoContent.Args(stopIds));
+
+ 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<StopsInfoResponse>>();
+
+ if (responseBody is not { IsSuccess: true } || responseBody.Data?.Stops == null)
+ {
+ return StatusCode(500, "Error fetching stops data");
+ }
+
+ var result = responseBody.Data.Stops.ToDictionary(
+ s => s.GtfsId,
+ s =>
+ {
+ var feedId = s.GtfsId.Split(':', 2)[0];
+ var (fallbackColor, _) = _feedService.GetFallbackColourForFeed(feedId);
+
+ return new
+ {
+ id = s.GtfsId,
+ code = _feedService.NormalizeStopCode(feedId, s.Code ?? ""),
+ name = s.Name,
+ routes = s.Routes
+ .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames))
+ .Select(r => new
+ {
+ shortName = _feedService.NormalizeRouteShortName(feedId, r.ShortName ?? ""),
+ colour = r.Color ?? fallbackColor,
+ textColour = r.TextColor is null or "000000" ?
+ ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) :
+ r.TextColor
+ })
+ .ToList()
+ };
+ }
+ );
+
+ return Ok(result);
+ }
+
+ [HttpGet("search")]
+ public IActionResult SearchStops([FromQuery] string q)
+ {
+ // Placeholder for future implementation with Postgres and fuzzy searching
+ return Ok(new List<object>());
+ }
+
+ [LoggerMessage(LogLevel.Error, "Error fetching stop data, received {statusCode} {responseBody}")]
+ partial void LogErrorFetchingStopData(HttpStatusCode statusCode, string responseBody);
+}
diff --git a/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs
new file mode 100644
index 0000000..c7201b0
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/RoutePlannerController.cs
@@ -0,0 +1,102 @@
+using System.Net;
+using Enmarcha.Sources.OpenTripPlannerGql;
+using Enmarcha.Sources.OpenTripPlannerGql.Queries;
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Services;
+using Enmarcha.Backend.Types.Planner;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Controllers;
+
+[ApiController]
+[Route("api/planner")]
+public partial class RoutePlannerController : ControllerBase
+{
+ private readonly ILogger<RoutePlannerController> _logger;
+ private readonly OtpService _otpService;
+ private readonly IGeocodingService _geocodingService;
+ private readonly AppConfiguration _config;
+ private readonly HttpClient _httpClient;
+
+ public RoutePlannerController(
+ ILogger<RoutePlannerController> logger,
+ OtpService otpService,
+ IGeocodingService geocodingService,
+ IOptions<AppConfiguration> config,
+ HttpClient httpClient
+ )
+ {
+ _logger = logger;
+ _otpService = otpService;
+ _geocodingService = geocodingService;
+ _config = config.Value;
+ _httpClient = httpClient;
+ }
+
+ [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 _geocodingService.GetAutocompleteAsync(query);
+ return Ok(results);
+ }
+
+ [HttpGet("reverse")]
+ public async Task<ActionResult<PlannerSearchResult>> Reverse([FromQuery] double lat, [FromQuery] double lon)
+ {
+ var result = await _geocodingService.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] DateTimeOffset? time,
+ [FromQuery] bool arriveBy = false)
+ {
+ try
+ {
+ var requestContent = PlanConnectionContent.Query(
+ new PlanConnectionContent.Args(fromLat, fromLon, toLat, toLon, time ?? DateTimeOffset.Now, 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>>();
+
+ if (responseBody is not { IsSuccess: true })
+ {
+ 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 e)
+ {
+ _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/Enmarcha.Backend/Controllers/TileController.cs b/src/Enmarcha.Backend/Controllers/TileController.cs
new file mode 100644
index 0000000..3459997
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/TileController.cs
@@ -0,0 +1,217 @@
+using NetTopologySuite.Features;
+using NetTopologySuite.IO.VectorTiles;
+using NetTopologySuite.IO.VectorTiles.Mapbox;
+
+using Microsoft.AspNetCore.Mvc;
+
+using Microsoft.Extensions.Caching.Memory;
+using System.Text.Json;
+using Enmarcha.Sources.OpenTripPlannerGql;
+using Enmarcha.Sources.OpenTripPlannerGql.Queries;
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Helpers;
+using Enmarcha.Backend.Services;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Controllers;
+
+[ApiController]
+[Route("api/tiles")]
+public class TileController : ControllerBase
+{
+ private readonly ILogger<TileController> _logger;
+ private readonly IMemoryCache _cache;
+ private readonly HttpClient _httpClient;
+ private readonly FeedService _feedService;
+ private readonly AppConfiguration _config;
+
+ public TileController(
+ ILogger<TileController> logger,
+ IMemoryCache cache,
+ HttpClient httpClient,
+ FeedService feedService,
+ IOptions<AppConfiguration> configOptions
+ )
+ {
+ _logger = logger;
+ _cache = cache;
+ _httpClient = httpClient;
+ _feedService = feedService;
+ _config = configOptions.Value;
+ }
+
+ [HttpGet("stops/{z:int}/{x:int}/{y:int}")]
+ public async Task<IActionResult> Stops(int z, int x, int y)
+ {
+ if (z is < 9 or > 20)
+ {
+ return BadRequest("Zoom level out of range (9-20)");
+ }
+
+ var cacheHit = _cache.TryGetValue($"stops-tile-{z}-{x}-{y}", out byte[]? cachedTile);
+ if (cacheHit && cachedTile != null)
+ {
+ Response.Headers.Append("X-Cache-Hit", "true");
+ return File(cachedTile, "application/x-protobuf");
+ }
+
+ // Calculate bounding box in EPSG:4326
+ var n = Math.Pow(2, z);
+ var lonMin = x / n * 360.0 - 180.0;
+ var lonMax = (x + 1) / n * 360.0 - 180.0;
+
+ var latMaxRad = Math.Atan(Math.Sinh(Math.PI * (1 - 2 * y / n)));
+ var latMax = latMaxRad * 180.0 / Math.PI;
+
+ var latMinRad = Math.Atan(Math.Sinh(Math.PI * (1 - 2 * (y + 1) / n)));
+ var latMin = latMinRad * 180.0 / Math.PI;
+
+ var requestContent = StopTileRequestContent.Query(new StopTileRequestContent.Bbox(lonMin, latMin, lonMax, latMax));
+ 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<StopTileResponse>>();
+
+ if (responseBody is not { IsSuccess: true })
+ {
+ _logger.LogError(
+ "Error fetching stop data, received {StatusCode} {ResponseBody}",
+ response.StatusCode,
+ await response.Content.ReadAsStringAsync()
+ );
+ return StatusCode(500, "Error fetching stop data");
+ }
+
+ var tileDef = new NetTopologySuite.IO.VectorTiles.Tiles.Tile(x, y, z);
+ VectorTile vt = new() { TileId = tileDef.Id };
+ var stopsLayer = new Layer { Name = "stops" };
+
+ responseBody.Data?.StopsByBbox?.ForEach(stop =>
+ {
+ var idParts = stop.GtfsId.Split(':', 2);
+ string feedId = idParts[0];
+ string codeWithinFeed = _feedService.NormalizeStopCode(feedId, stop.Code ?? string.Empty);
+
+ if (_feedService.IsStopHidden($"{feedId}:{codeWithinFeed}"))
+ {
+ return;
+ }
+
+ // TODO: Duplicate from ArrivalsController
+ var (Color, TextColor) = _feedService.GetFallbackColourForFeed(idParts[0]);
+ var distinctRoutes = GetDistinctRoutes(feedId, stop.Routes ?? []);
+
+ Feature feature = new()
+ {
+ Geometry = new NetTopologySuite.Geometries.Point(stop.Lon, stop.Lat),
+ Attributes = new AttributesTable
+ {
+ // The ID will be used to request the arrivals
+ { "id", stop.GtfsId },
+ // The feed is the first part of the GTFS ID
+ { "feed", idParts[0] },
+ // The public identifier, usually feed:code or feed:id, recognisable by users and in other systems
+ { "code", $"{idParts[0]}:{codeWithinFeed}" },
+ { "name", _feedService.NormalizeStopName(feedId, stop.Name) },
+ { "icon", GetIconNameForFeed(feedId) },
+ { "transitKind", GetTransitKind(feedId) },
+ // Routes
+ { "routes", JsonSerializer
+ .Serialize(
+ distinctRoutes.Select(r => {
+ var colour = r.Color ?? Color;
+ string textColour;
+
+ if (r.Color is null) // None is present, use fallback
+ {
+ textColour = TextColor;
+ }
+ else if (r.TextColor is null || r.TextColor.EndsWith("000000"))
+ {
+ // Text colour not provided, or default-black; check the better contrasting
+ textColour = ContrastHelper.GetBestTextColour(colour);
+ }
+ else
+ {
+ // Use provided text colour
+ textColour = r.TextColor;
+ }
+
+ return new {
+ shortName = r.ShortName,
+ colour,
+ textColour
+ };
+ }))
+ }
+ }
+ };
+
+ stopsLayer.Features.Add(feature);
+ });
+
+ vt.Layers.Add(stopsLayer);
+
+ using var ms = new MemoryStream();
+ vt.Write(ms, minLinealExtent: 1, minPolygonalExtent: 2);
+
+ _cache.Set($"stops-tile-{z}-{x}-{y}", ms.ToArray(), TimeSpan.FromMinutes(15));
+ Response.Headers.Append("X-Cache-Hit", "false");
+
+ return File(ms.ToArray(), "application/x-protobuf");
+ }
+
+ private string GetIconNameForFeed(string feedId)
+ {
+ return feedId switch
+ {
+ "vitrasa" => "stop-vitrasa",
+ "tussa" => "stop-tussa",
+ "tranvias" => "stop-tranvias",
+ "xunta" => "stop-xunta",
+ "renfe" => "stop-renfe",
+ "feve" => "stop-feve",
+ _ => "stop-generic",
+ };
+ }
+
+ private string GetTransitKind(string feedId)
+ {
+ return feedId switch
+ {
+ "vitrasa" or "tussa" or "tranvias" => "bus",
+ "xunta" => "coach",
+ "renfe" or "feve" => "train",
+ _ => "unknown",
+ };
+ }
+
+ private List<StopTileResponse.Route> GetDistinctRoutes(string feedId, List<StopTileResponse.Route> routes)
+ {
+ List<StopTileResponse.Route> distinctRoutes = [];
+ HashSet<string> seen = new();
+
+ foreach (var route in routes)
+ {
+ var seenId = _feedService.GetUniqueRouteShortName(feedId, route.ShortName ?? string.Empty);
+ route.ShortName = seenId;
+
+ if (seen.Contains(seenId))
+ {
+ continue;
+ }
+
+ seen.Add(seenId);
+ distinctRoutes.Add(route);
+ }
+
+ return [.. distinctRoutes.OrderBy(
+ r => r.ShortName,
+ Comparer<string?>.Create(SortingHelper.SortRouteShortNames)
+ )];
+ }
+}
diff --git a/src/Enmarcha.Backend/Controllers/TrafficDataController.cs b/src/Enmarcha.Backend/Controllers/TrafficDataController.cs
new file mode 100644
index 0000000..6a95d49
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/TrafficDataController.cs
@@ -0,0 +1,110 @@
+using System.Globalization;
+using System.Text;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Caching.Memory;
+using NetTopologySuite.Features;
+using NetTopologySuite.IO;
+
+namespace Enmarcha.Backend.Controllers;
+
+[ApiController]
+[Route("api")]
+public class TrafficDataController : ControllerBase
+{
+ private readonly ILogger<TrafficDataController> _logger;
+ private readonly IMemoryCache _cache;
+ private readonly HttpClient _httpClient;
+
+ public TrafficDataController(
+ ILogger<TrafficDataController> logger,
+ IMemoryCache cache,
+ HttpClient httpClient
+ )
+ {
+ _logger = logger;
+ _cache = cache;
+ _httpClient = httpClient;
+ }
+
+ [HttpGet("traffic")]
+ public async Task<IActionResult> Get()
+ {
+ var trafficData = _cache.GetOrCreate("vigo-traffic-geojson", entry =>
+ {
+ var data = GetTrafficData();
+
+ entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
+
+ return data.Result;
+ });
+
+ if (string.IsNullOrEmpty(trafficData))
+ {
+ return StatusCode(404);
+ }
+
+ return Content(trafficData, "application/json", Encoding.UTF8);
+ }
+
+ private async Task<string> GetTrafficData()
+ {
+ var resp = await _httpClient.GetAsync("https://datos.vigo.org/data/trafico/treal.geojson");
+ var body = resp.Content.ReadAsStringAsync().Result;
+
+ var reader = new GeoJsonReader();
+ var featureCollection = reader.Read<FeatureCollection>(body);
+
+ var filteredFeatures = new FeatureCollection();
+ foreach (var kvp in featureCollection)
+ {
+ var newAttributes = new AttributesTable();
+
+ if (
+ !kvp.Attributes.Exists("actualizacion") ||
+ !kvp.Attributes.Exists("style")
+ )
+ {
+ continue;
+ }
+
+ var updateParsed = DateTime.TryParseExact(
+ kvp.Attributes["actualizacion"].ToString(),
+ "dd/MM/yyyy H:mm:ss",
+ null,
+ DateTimeStyles.None,
+ out var updatedAt
+ );
+
+ if (!updateParsed || updatedAt < DateTime.Today)
+ {
+ continue;
+ }
+
+ var style = kvp.Attributes["style"].ToString();
+
+ if (style == "#SINDATOS")
+ {
+ continue;
+ }
+
+ var vehiculosAttribute = (kvp.Attributes["vehiculos"] ?? "0").ToString();
+
+ var vehiclesParsed = int.TryParse(vehiculosAttribute, out var vehicles);
+ if (!vehiclesParsed || vehicles <= 0)
+ {
+ continue;
+ }
+
+ newAttributes.Add("updatedAt", updatedAt.ToString("O"));
+ newAttributes.Add("style", style);
+ newAttributes.Add("vehicles", vehicles);
+
+ kvp.Attributes = newAttributes;
+ filteredFeatures.Add(kvp);
+ }
+
+ var writer = new GeoJsonWriter();
+
+ return writer.Write(filteredFeatures);
+ }
+}
diff --git a/src/Enmarcha.Backend/Controllers/TransitController.cs b/src/Enmarcha.Backend/Controllers/TransitController.cs
new file mode 100644
index 0000000..62b3725
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/TransitController.cs
@@ -0,0 +1,127 @@
+using Enmarcha.Sources.OpenTripPlannerGql;
+using Enmarcha.Sources.OpenTripPlannerGql.Queries;
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Helpers;
+using Enmarcha.Backend.Services;
+using Enmarcha.Backend.Types.Transit;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Controllers;
+
+[ApiController]
+[Route("api/transit")]
+public class TransitController : ControllerBase
+{
+ private readonly ILogger<TransitController> _logger;
+ private readonly OtpService _otpService;
+ private readonly AppConfiguration _config;
+ private readonly HttpClient _httpClient;
+ private readonly IMemoryCache _cache;
+
+ public TransitController(
+ ILogger<TransitController> logger,
+ OtpService otpService,
+ IOptions<AppConfiguration> config,
+ HttpClient httpClient,
+ IMemoryCache cache
+ )
+ {
+ _logger = logger;
+ _otpService = otpService;
+ _config = config.Value;
+ _httpClient = httpClient;
+ _cache = cache;
+ }
+
+ [HttpGet("routes")]
+ public async Task<ActionResult<List<RouteDto>>> GetRoutes([FromQuery] string[] feeds)
+ {
+ if (feeds.Length == 0)
+ {
+ feeds = ["tussa", "vitrasa", "tranvias", "feve"];
+ }
+
+ var serviceDate = DateTime.Now.ToString("yyyy-MM-dd");
+ var cacheKey = $"routes_{string.Join("_", feeds)}_{serviceDate}";
+ if (_cache.TryGetValue(cacheKey, out List<RouteDto>? cachedRoutes))
+ {
+ return Ok(cachedRoutes);
+ }
+
+ try
+ {
+ var query = RoutesListContent.Query(new RoutesListContent.Args(feeds, serviceDate));
+ var response = await SendOtpQueryAsync<RoutesListResponse>(query);
+
+ if (response?.Data == null)
+ {
+ return StatusCode(500, "Failed to fetch routes from OTP.");
+ }
+
+ var routes = response.Data.Routes
+ .Select(_otpService.MapRoute)
+ .Where(r => r.TripCount > 0)
+ .OrderBy(r => r.ShortName, Comparer<string?>.Create(SortingHelper.SortRouteShortNames))
+ .ToList();
+
+ _cache.Set(cacheKey, routes, TimeSpan.FromHours(1));
+
+ return Ok(routes);
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Error fetching routes");
+ return StatusCode(500, "An error occurred while fetching routes.");
+ }
+ }
+
+ [HttpGet("routes/{id}")]
+ public async Task<ActionResult<RouteDetailsDto>> GetRouteDetails(string id)
+ {
+ var serviceDate = DateTime.Now.ToString("yyyy-MM-dd");
+ var cacheKey = $"route_details_{id}_{serviceDate}";
+
+ if (_cache.TryGetValue(cacheKey, out RouteDetailsDto? cachedDetails))
+ {
+ return Ok(cachedDetails);
+ }
+
+ try
+ {
+ var query = RouteDetailsContent.Query(new RouteDetailsContent.Args(id, serviceDate));
+ var response = await SendOtpQueryAsync<RouteDetailsResponse>(query);
+
+ if (response?.Data?.Route == null)
+ {
+ return NotFound();
+ }
+
+ var details = _otpService.MapRouteDetails(response.Data.Route);
+ _cache.Set(cacheKey, details, TimeSpan.FromHours(1));
+
+ return Ok(details);
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "Error fetching route details for {Id}", id);
+ return StatusCode(500, "An error occurred while fetching route details.");
+ }
+ }
+
+ private async Task<GraphClientResponse<T>?> SendOtpQueryAsync<T>(string query) where T : AbstractGraphResponse
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1");
+ request.Content = JsonContent.Create(new GraphClientRequest { Query = query });
+
+ var response = await _httpClient.SendAsync(request);
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogError("OTP query failed with status {StatusCode}", response.StatusCode);
+ return null;
+ }
+
+ return await response.Content.ReadFromJsonAsync<GraphClientResponse<T>>();
+ }
+}
diff --git a/src/Enmarcha.Backend/Controllers/VigoController.cs b/src/Enmarcha.Backend/Controllers/VigoController.cs
new file mode 100644
index 0000000..4199251
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/VigoController.cs
@@ -0,0 +1,69 @@
+using Costasdev.VigoTransitApi;
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Services;
+using Enmarcha.Backend.Services.Providers;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Controllers;
+
+[ApiController]
+[Route("api/vigo")]
+public partial class VigoController : ControllerBase
+{
+ private readonly ILogger<VigoController> _logger;
+ private readonly VigoTransitApiClient _api;
+ private readonly AppConfiguration _configuration;
+ private readonly ShapeTraversalService _shapeService;
+ private readonly VitrasaTransitProvider _vitrasaProvider;
+ private readonly RenfeTransitProvider _renfeProvider;
+
+ public VigoController(
+ HttpClient http,
+ IOptions<AppConfiguration> options,
+ ILogger<VigoController> logger,
+ ShapeTraversalService shapeService,
+ VitrasaTransitProvider vitrasaProvider,
+ RenfeTransitProvider renfeProvider)
+ {
+ _logger = logger;
+ _api = new VigoTransitApiClient(http);
+ _configuration = options.Value;
+ _shapeService = shapeService;
+ _vitrasaProvider = vitrasaProvider;
+ _renfeProvider = renfeProvider;
+ }
+
+ [HttpGet("GetConsolidatedCirculations")]
+ public async Task<IActionResult> GetConsolidatedCirculations(
+ [FromQuery] string stopId
+ )
+ {
+ // Use Europe/Madrid timezone consistently to avoid UTC/local skew
+ var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid");
+ var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz);
+
+ ITransitProvider provider;
+ string effectiveStopId;
+
+ if (stopId.StartsWith("renfe:"))
+ {
+ provider = _renfeProvider;
+ effectiveStopId = stopId.Substring("renfe:".Length);
+ }
+ else if (stopId.StartsWith("vitrasa:"))
+ {
+ provider = _vitrasaProvider;
+ effectiveStopId = stopId.Substring("vitrasa:".Length);
+ }
+ else
+ {
+ // Legacy/Default
+ provider = _vitrasaProvider;
+ effectiveStopId = stopId;
+ }
+
+ var result = await provider.GetCirculationsAsync(effectiveStopId, nowLocal);
+ return Ok(result);
+ }
+}