From 2a9aca302485bc08f5b2dd2a54987de6f80fc338 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Fri, 19 Dec 2025 13:06:27 +0100 Subject: Implement loading stops as tiles from OTP --- .../Controllers/StopsTile.cs | 198 +++++++++++++++++++++ .../Controllers/VigoController.Legacy.cs | 78 -------- .../Costasdev.Busurbano.Backend.csproj | 5 +- .../GraphClient/App/StopTile.cs | 76 ++++++++ .../GraphClient/ResponseTypes.cs | 36 ++++ .../Helpers/ContrastHelper.cs | 48 +++++ .../Helpers/SortingHelper.cs | 35 ++++ 7 files changed, 396 insertions(+), 80 deletions(-) create mode 100644 src/Costasdev.Busurbano.Backend/Controllers/StopsTile.cs delete mode 100644 src/Costasdev.Busurbano.Backend/Controllers/VigoController.Legacy.cs create mode 100644 src/Costasdev.Busurbano.Backend/GraphClient/App/StopTile.cs create mode 100644 src/Costasdev.Busurbano.Backend/GraphClient/ResponseTypes.cs create mode 100644 src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs create mode 100644 src/Costasdev.Busurbano.Backend/Helpers/SortingHelper.cs (limited to 'src/Costasdev.Busurbano.Backend') diff --git a/src/Costasdev.Busurbano.Backend/Controllers/StopsTile.cs b/src/Costasdev.Busurbano.Backend/Controllers/StopsTile.cs new file mode 100644 index 0000000..56f836e --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Controllers/StopsTile.cs @@ -0,0 +1,198 @@ +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/VigoController.Legacy.cs b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.Legacy.cs deleted file mode 100644 index d006e38..0000000 --- a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.Legacy.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Globalization; -using System.Text.Json; -using Costasdev.Busurbano.Backend.Types; -using Microsoft.AspNetCore.Mvc; -using SysFile = System.IO.File; - -namespace Costasdev.Busurbano.Backend.Controllers; - -public partial class VigoController : ControllerBase -{ - [HttpGet("GetStopEstimates")] - public async Task Run( - [FromQuery] int id - ) - { - try - { - var response = await _api.GetStopEstimates(id); - // Return only the estimates array, not the stop metadata - return new OkObjectResult(response.Estimates); - } - catch (InvalidOperationException) - { - return BadRequest("Stop not found"); - } - } - - [HttpGet("GetStopTimetable")] - public async Task GetStopTimetable( - [FromQuery] int stopId, - [FromQuery] string? date = null -) - { - // Use Europe/Madrid timezone to determine the correct date - var tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid"); - var nowLocal = TimeZoneInfo.ConvertTime(DateTime.UtcNow, tz); - - // If no date provided or date is "today", use Madrid timezone's current date - string effectiveDate; - if (string.IsNullOrEmpty(date) || date == "today") - { - effectiveDate = nowLocal.Date.ToString("yyyy-MM-dd"); - } - else - { - // Validate provided date format - if (!DateTime.TryParseExact(date, "yyyy-MM-dd", null, DateTimeStyles.None, out _)) - { - return BadRequest("Invalid date format. Please use yyyy-MM-dd format."); - } - effectiveDate = date; - } - - try - { - var file = Path.Combine(_configuration.VitrasaScheduleBasePath, effectiveDate, stopId + ".json"); - if (!SysFile.Exists(file)) - { - throw new FileNotFoundException(); - } - - var contents = await SysFile.ReadAllTextAsync(file); - - return new OkObjectResult(JsonSerializer.Deserialize>(contents)!); - } - catch (FileNotFoundException ex) - { - _logger.LogError(ex, "Stop data not found for stop {StopId} on date {Date}", stopId, effectiveDate); - return StatusCode(404, $"Stop data not found for stop {stopId} on date {effectiveDate}"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error loading stop data"); - return StatusCode(500, "Error loading timetable"); - } - } - -} diff --git a/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj b/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj index abacd68..4b556da 100644 --- a/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj +++ b/src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj @@ -16,8 +16,9 @@ - - + + + diff --git a/src/Costasdev.Busurbano.Backend/GraphClient/App/StopTile.cs b/src/Costasdev.Busurbano.Backend/GraphClient/App/StopTile.cs new file mode 100644 index 0000000..8a271f2 --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/GraphClient/App/StopTile.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace Costasdev.Busurbano.Backend.GraphClient.App; + +public class StopTileRequestContent : IGraphRequest +{ + public record Bbox(double MinLon, double MinLat, double MaxLon, double MaxLat); + + public static string Query(Bbox bbox) + { + return string.Create(CultureInfo.InvariantCulture, $@" + query Query {{ + stopsByBbox( + minLat: {bbox.MinLat:F6} + minLon: {bbox.MinLon:F6} + maxLon: {bbox.MaxLon:F6} + maxLat: {bbox.MaxLat:F6} + ) {{ + gtfsId + code + name + lat + lon + routes {{ + gtfsId + shortName + color + textColor + }} + }} + }} + "); + } +} + +public class StopTileResponse : AbstractGraphResponse +{ + [JsonPropertyName("stopsByBbox")] + public List? StopsByBbox { get; set; } + + public record Stop + { + [JsonPropertyName("gtfsId")] + public required string GtfsId { get; init; } + + [JsonPropertyName("code")] + public string? Code { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("lat")] + public required double Lat { get; init; } + + [JsonPropertyName("lon")] + public required double Lon { get; init; } + + [JsonPropertyName("routes")] + public List? Routes { get; init; } + } + + public record Route + { + [JsonPropertyName("gtfsId")] + public required string GtfsId { get; init; } + [JsonPropertyName("shortName")] + public required string ShortName { get; init; } + + [JsonPropertyName("color")] + public string? Color { get; init; } + + [JsonPropertyName("textColor")] + public string? TextColor { get; init; } + } +} diff --git a/src/Costasdev.Busurbano.Backend/GraphClient/ResponseTypes.cs b/src/Costasdev.Busurbano.Backend/GraphClient/ResponseTypes.cs new file mode 100644 index 0000000..2d4d5df --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/GraphClient/ResponseTypes.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace Costasdev.Busurbano.Backend.GraphClient; + +public class GraphClientRequest +{ + public string OperationName { get; set; } = "Query"; + public required string Query { get; set; } +} + +public class GraphClientResponse where T : AbstractGraphResponse +{ + [JsonPropertyName("data")] + public T? Data { get; set; } + + [JsonPropertyName("errors")] + public List? Errors { get; set; } + + public bool IsSuccess => Errors == null || Errors.Count == 0; +} + +public interface IGraphRequest +{ + static abstract string Query(T parameters); +} + +public class AbstractGraphResponse +{ +} + +public class GraphClientError +{ + [JsonPropertyName("message")] + public required string Message { get; set; } +} + diff --git a/src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs b/src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs new file mode 100644 index 0000000..e48660b --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs @@ -0,0 +1,48 @@ +namespace Costasdev.Busurbano.Backend.Helpers; + +using System; +using System.Globalization; + +public static class ContrastHelper +{ + public static string GetBestTextColour(string backgroundHex) + { + // Strip # + backgroundHex = backgroundHex.TrimStart('#'); + + if (backgroundHex.Length != 6) + throw new ArgumentException("Hex colour must be 6 characters (RRGGBB)"); + + // Parse RGB + int r = int.Parse(backgroundHex.Substring(0, 2), NumberStyles.HexNumber); + int g = int.Parse(backgroundHex.Substring(2, 2), NumberStyles.HexNumber); + int b = int.Parse(backgroundHex.Substring(4, 2), NumberStyles.HexNumber); + + // Convert to relative luminance + double luminance = GetRelativeLuminance(r, g, b); + + // Contrast ratios + double contrastWithWhite = (1.0 + 0.05) / (luminance + 0.05); + double contrastWithBlack = (luminance + 0.05) / 0.05; + + if (contrastWithWhite > 3) + { + return "#FFFFFF"; + } + + return "#000000"; + } + + private static double GetRelativeLuminance(int r, int g, int b) + { + double rs = r / 255.0; + double gs = g / 255.0; + double bs = b / 255.0; + + rs = rs <= 0.03928 ? rs / 12.92 : Math.Pow((rs + 0.055) / 1.055, 2.4); + gs = gs <= 0.03928 ? gs / 12.92 : Math.Pow((gs + 0.055) / 1.055, 2.4); + bs = bs <= 0.03928 ? bs / 12.92 : Math.Pow((bs + 0.055) / 1.055, 2.4); + + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; + } +} diff --git a/src/Costasdev.Busurbano.Backend/Helpers/SortingHelper.cs b/src/Costasdev.Busurbano.Backend/Helpers/SortingHelper.cs new file mode 100644 index 0000000..472a56f --- /dev/null +++ b/src/Costasdev.Busurbano.Backend/Helpers/SortingHelper.cs @@ -0,0 +1,35 @@ +namespace Costasdev.Busurbano.Backend.Helpers; + +public class SortingHelper +{ + public static int SortRouteShortNames(string? a, string? b) + { + if (a == null && b == null) return 0; + if (a == null) return 1; + if (b == null) return -1; + + var aDigits = new string(a.Where(char.IsDigit).ToArray()); + var bDigits = new string(b.Where(char.IsDigit).ToArray()); + + bool aHasDigits = int.TryParse(aDigits, out int aNumber); + bool bHasDigits = int.TryParse(bDigits, out int bNumber); + + if (aHasDigits != bHasDigits) + { + // Non-numeric routes (like "A" or "-") go to the beginning + return aHasDigits ? 1 : -1; + } + + if (aHasDigits && bHasDigits) + { + if (aNumber != bNumber) + { + return aNumber.CompareTo(bNumber); + } + } + + // If both are non-numeric, or numeric parts are equal, use alphabetical + return string.Compare(a, b, StringComparison.OrdinalIgnoreCase); + } + +} -- cgit v1.3