From 91f7d7dd5a4ca8453cfdbc9a3beeb216b6638ef7 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Mon, 22 Dec 2025 14:13:45 +0100 Subject: Implement fetching scheduled arrivals for stop --- .../Controllers/ArrivalsController.cs | 52 ++++++ .../Controllers/StopsTile.cs | 198 --------------------- .../Controllers/TileController.cs | 194 ++++++++++++++++++++ .../Costasdev.Busurbano.Backend.csproj | 2 +- .../GraphClient/App/ArrivalsAtStop.cs | 82 +++++++++ src/frontend/app/routes/map.tsx | 17 +- 6 files changed, 342 insertions(+), 203 deletions(-) create mode 100644 src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Controllers/StopsTile.cs create mode 100644 src/Costasdev.Busurbano.Backend/Controllers/TileController.cs create mode 100644 src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs diff --git a/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs new file mode 100644 index 0000000..eb81784 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Controllers/ArrivalsController.cs @@ -0,0 +1,52 @@ +using Costasdev.Busurbano.Backend.GraphClient; +using Costasdev.Busurbano.Backend.GraphClient.App; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace Costasdev.Busurbano.Backend.Controllers; + +[ApiController] +[Route("api")] +public class ArrivalsController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + + public ArrivalsController( + ILogger logger, + IMemoryCache cache, + HttpClient httpClient + ) + { + _logger = logger; + _cache = cache; + _httpClient = httpClient; + } + + [HttpGet("arrivals")] + public async Task GetArrivals(string id) + { + var requestContent = ArrivalsAtStopContent.Query(id); + var request = new HttpRequestMessage(HttpMethod.Post, "http://100.67.54.115:3957/otp/gtfs/v1"); + request.Content = JsonContent.Create(new GraphClientRequest + { + Query = requestContent + }); + + var response = await _httpClient.SendAsync(request); + var responseBody = await response.Content.ReadFromJsonAsync>(); + + 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"); + } + + return Ok(responseBody.Data?.Stop); + } +} diff --git a/src/Costasdev.Busurbano.Backend/Controllers/StopsTile.cs b/src/Costasdev.Busurbano.Backend/Controllers/StopsTile.cs deleted file mode 100644 index 56f836e..0000000 --- a/src/Costasdev.Busurbano.Backend/Controllers/StopsTile.cs +++ /dev/null @@ -1,198 +0,0 @@ -using Costasdev.Busurbano.Backend.GraphClient; -using Costasdev.Busurbano.Backend.GraphClient.App; -using NetTopologySuite.IO.VectorTiles; -using NetTopologySuite.IO.VectorTiles.Mapbox; - -using Microsoft.AspNetCore.Mvc; - -using Microsoft.Extensions.Caching.Memory; -using NetTopologySuite.Features; -using System.Text.Json; -using Costasdev.Busurbano.Backend.Helpers; - -[ApiController] -[Route("api/tiles")] -public class TileController : ControllerBase -{ - private readonly ILogger _logger; - private readonly IMemoryCache _cache; - private readonly HttpClient _httpClient; - - public TileController( - ILogger logger, - IMemoryCache cache, - HttpClient httpClient - ) - { - _logger = logger; - _cache = cache; - _httpClient = httpClient; - } - - /* - vitrasa:20223: # Castrelos (Pavillón) - Final U1 - hide: true -vitrasa:20146: # García Barbón 7 - final líneas A y 18A - hide: true -vitrasa:20220: # (Samil) COIA-SAMIL - Final L15A - hide: true -vitrasa:20001: # (Samil) Samil por Beiramar - Final L15B - hide: true -vitrasa:20002: # (Samil) Samil por Torrecedeira - Final L15C - hide: true -vitrasa:20144: # (Samil) Samil por Coia - Final C3D+C3i - hide: true -vitrasa:20145: # (Samil) Samil por Bouzas - Final C3D+C3i - hide: true - */ - private static readonly string[] HiddenStops = - [ - "vitrasa:20223", - "vitrasa:20146", - "vitrasa:20220", - "vitrasa:20001", - "vitrasa:20002", - "vitrasa:20144", - "vitrasa:20145" - ]; - - [HttpGet("stops/{z}/{x}/{y}")] - public async Task GetTrafficTile(int z, int x, int y) - { - 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 - double n = Math.Pow(2, z); - double lonMin = x / n * 360.0 - 180.0; - double lonMax = (x + 1) / n * 360.0 - 180.0; - - double latMaxRad = Math.Atan(Math.Sinh(Math.PI * (1 - 2 * y / n))); - double latMax = latMaxRad * 180.0 / Math.PI; - - double latMinRad = Math.Atan(Math.Sinh(Math.PI * (1 - 2 * (y + 1) / n))); - double latMin = latMinRad * 180.0 / Math.PI; - - var requestContent = StopTileRequestContent.Query(new StopTileRequestContent.Bbox(lonMin, latMin, lonMax, latMax)); - var request = new HttpRequestMessage(HttpMethod.Post, "http://100.67.54.115:3957/otp/gtfs/v1"); - request.Content = JsonContent.Create(new GraphClientRequest - { - Query = requestContent - }); - - var response = await _httpClient.SendAsync(request); - var responseBody = await response.Content.ReadFromJsonAsync>(); - - if (responseBody == null || !responseBody.IsSuccess) - { - _logger.LogError("Error fetching stop data: {StatusCode}", response.StatusCode); - _logger.LogError("Sent request: {RequestContent}", await request.Content.ReadAsStringAsync()); - _logger.LogError("Response body: {ResponseBody}", 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 lyr = new Layer { Name = "stops" }; - - responseBody.Data?.StopsByBbox?.ForEach(stop => - { - var idParts = stop.GtfsId.Split(':', 2); - string codeWithinFeed = stop.Code ?? string.Empty; - - // TODO: Refactor this, maybe do it client-side or smth - if (idParts[0] == "vitrasa") - { - var digits = new string(codeWithinFeed.Where(char.IsDigit).ToArray()); - if (int.TryParse(digits, out int code)) - { - codeWithinFeed = code.ToString(); - } - } - - if (HiddenStops.Contains($"{idParts[0]}:{codeWithinFeed}")) - { - return; - } - - var fallbackColours = GetFallbackColourForFeed(idParts[0]); - - 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, corresponding to the feed where the info comes from, used for icons probably - { "feed", idParts[0] }, - // The public identifier, usually feed:code or feed:id, recognisable by users and in other systems - { "code", $"{idParts[0]}:{codeWithinFeed}" }, - // The name of the stop - { "name", stop.Name }, - // Routes - { "routes", JsonSerializer.Serialize(stop.Routes?.OrderBy( - r => r.ShortName, - Comparer.Create(SortingHelper.SortRouteShortNames) - ).Select(r => { - var colour = r.Color ?? fallbackColours.Color; - string textColour; - - if (r.Color is null) // None is present, use fallback - { - textColour = fallbackColours.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 - }; - })) } - } - }; - - lyr.Features.Add(feature); - }); - - vt.Layers.Add(lyr); - - 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 static (string Color, string TextColor) GetFallbackColourForFeed(string feed) - { - return feed switch - { - "vitrasa" => ("#95D516", "#000000"), - "santiago" => ("#508096", "#FFFFFF"), - "coruna" => ("#E61C29", "#FFFFFF"), - "xunta" => ("#007BC4", "#FFFFFF"), - "renfe" => ("#870164", "#FFFFFF"), - "feve" => ("#EE3D32", "#FFFFFF"), - _ => ("#000000", "#FFFFFF"), - - }; - } - -} diff --git a/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs b/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs new file mode 100644 index 0000000..fad18e7 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Controllers/TileController.cs @@ -0,0 +1,194 @@ +using Costasdev.Busurbano.Backend.GraphClient; +using Costasdev.Busurbano.Backend.GraphClient.App; + +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 Costasdev.Busurbano.Backend.Helpers; + +namespace Costasdev.Busurbano.Backend.Controllers; + +[ApiController] +[Route("api/tiles")] +public class TileController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IMemoryCache _cache; + private readonly HttpClient _httpClient; + + public TileController( + ILogger logger, + IMemoryCache cache, + HttpClient httpClient + ) + { + _logger = logger; + _cache = cache; + _httpClient = httpClient; + } + + private static readonly string[] HiddenStops = + [ + "vitrasa:20223", // Castrelos (Pavillón - U1) + "vitrasa:20146", // García Barbón, 7 (A, 18A) + "vitrasa:20220", // COIA-SAMIL (15) + "vitrasa:20001", // Samil por Beiramar (15B) + "vitrasa:20002", // Samil por Torrecedeira (15C) + "vitrasa:20144", // Samil por Coia (C3d, C3i) + "vitrasa:20145" // Samil por Bouzs (C3d, C3i) + ]; + + [HttpGet("stops/{z:int}/{x:int}/{y:int}")] + public async Task Stops(int z, int x, int y) + { + if (z < 9 || z > 16) + { + return BadRequest("Zoom level out of range (9-16)"); + } + + 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, "http://100.67.54.115:3957/otp/gtfs/v1"); + request.Content = JsonContent.Create(new GraphClientRequest + { + Query = requestContent + }); + + var response = await _httpClient.SendAsync(request); + var responseBody = await response.Content.ReadFromJsonAsync>(); + + 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 lyr = new Layer { Name = "stops" }; + + responseBody.Data?.StopsByBbox?.ForEach(stop => + { + var idParts = stop.GtfsId.Split(':', 2); + string codeWithinFeed = stop.Code ?? string.Empty; + + // TODO: Refactor this, maybe do it client-side or smth + if (idParts[0] == "vitrasa") + { + var digits = new string(codeWithinFeed.Where(char.IsDigit).ToArray()); + if (int.TryParse(digits, out int code)) + { + codeWithinFeed = code.ToString(); + } + } + + if (HiddenStops.Contains($"{idParts[0]}:{codeWithinFeed}")) + { + return; + } + + var fallbackColours = GetFallbackColourForFeed(idParts[0]); + + 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, corresponding to the feed where the info comes from, used for icons probably + { "feed", idParts[0] }, + // The public identifier, usually feed:code or feed:id, recognisable by users and in other systems + { "code", $"{idParts[0]}:{codeWithinFeed}" }, + // The name of the stop + { "name", stop.Name }, + // Routes + { "routes", JsonSerializer.Serialize(stop.Routes? + .DistinctBy(r => r.ShortName) + .OrderBy( + r => r.ShortName, + Comparer.Create(SortingHelper.SortRouteShortNames) + ).Select(r => { + var colour = r.Color ?? fallbackColours.Color; + string textColour; + + if (r.Color is null) // None is present, use fallback + { + textColour = fallbackColours.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 + }; + })) } + } + }; + + lyr.Features.Add(feature); + }); + + vt.Layers.Add(lyr); + + 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 static (string Color, string TextColor) GetFallbackColourForFeed(string feed) + { + return feed switch + { + "vitrasa" => ("#95D516", "#000000"), + "santiago" => ("#508096", "#FFFFFF"), + "coruna" => ("#E61C29", "#FFFFFF"), + "xunta" => ("#007BC4", "#FFFFFF"), + "renfe" => ("#870164", "#FFFFFF"), + "feve" => ("#EE3D32", "#FFFFFF"), + _ => ("#000000", "#FFFFFF"), + + }; + } + +} diff --git a/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj b/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj index 4b556da..3bff631 100644 --- a/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj +++ b/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj @@ -1,6 +1,6 @@  - net9.0 + net10.0 enable enable Costasdev.Busurbano.Backend diff --git a/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs new file mode 100644 index 0000000..dfecdd6 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/GraphClient/App/ArrivalsAtStop.cs @@ -0,0 +1,82 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace Costasdev.Busurbano.Backend.GraphClient.App; + +public class ArrivalsAtStopContent : IGraphRequest +{ + public static string Query(string id) + { + return string.Create(CultureInfo.InvariantCulture, $@" + query Query {{ + stop(id:""{id}"") {{ + code + name + arrivals: stoptimesWithoutPatterns(numberOfDepartures:10) {{ + trip {{ + gtfsId + routeShortName + route {{ + color + textColor + }} + }} + headsign + scheduledDeparture + }} + }} + }} + "); + } +} + +public class ArrivalsAtStopResponse : AbstractGraphResponse +{ + [JsonPropertyName("stop")] + public StopItem Stop { get; set; } + + public class StopItem + { + [JsonPropertyName("code")] + public required string Code { get; set; } + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("arrivals")] + public List Arrivals { get; set; } = []; + } + + public class Arrival + { + [JsonPropertyName("headsign")] + public required string Headsign { get; set; } + + [JsonPropertyName("scheduledDeparture")] + public int ScheduledDepartureSeconds { get; set; } + + [JsonPropertyName("trip")] + public required TripDetails Trip { get; set; } + } + + public class TripDetails + { + [JsonPropertyName("gtfsId")] + public required string GtfsId { get; set; } + + [JsonPropertyName("routeShortName")] + public required string RouteShortName { get; set; } + + [JsonPropertyName("route")] + public required RouteDetails Route { get; set; } + } + + public class RouteDetails + { + [JsonPropertyName("color")] + public required string Color { get; set; } + + [JsonPropertyName("textColor")] + public required string TextColor { get; set; } + } +} diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index db9de59..279f096 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -122,7 +122,12 @@ export default function StopMap() { Array.isArray(center) ? center[1] : center.lng; const handlePointClick = (feature: any) => { - const props: any = feature.properties; + const props: { + id: string; + code: string; + name: string; + routes: string; + } = feature.properties; // TODO: Move ID to constant, improve type checking if (!props || feature.layer.id !== "stops") { console.warn("Invalid feature properties:", props); @@ -130,14 +135,18 @@ export default function StopMap() { } const stopId = props.id; - - console.debug("Stop clicked:", stopId, props); + const routes: { + shortName: string; + colour: string; + textColour: string; + }[] = JSON.parse(props.routes || "[]"); setSelectedStop({ stopId: props.id, stopCode: props.code, name: props.name || "Unknown Stop", - lines: JSON.parse(props.routes || "[]").map((route) => { + lines: routes.map((route) => { + console.log(route); return { line: route.shortName, colour: route.colour, -- cgit v1.3