aboutsummaryrefslogtreecommitdiff
path: root/src/Costasdev.Busurbano.Backend
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-19 13:06:27 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-19 13:06:27 +0100
commit2a9aca302485bc08f5b2dd2a54987de6f80fc338 (patch)
tree38171abad21b2952eca6ff9e8534545b4c28ed12 /src/Costasdev.Busurbano.Backend
parent37cdb0c418a7f2b47e40ae9db7ad86e1fddc86fe (diff)
Implement loading stops as tiles from OTP
Diffstat (limited to 'src/Costasdev.Busurbano.Backend')
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/StopsTile.cs198
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/VigoController.Legacy.cs78
-rw-r--r--src/Costasdev.Busurbano.Backend/Costasdev.Busurbano.Backend.csproj5
-rw-r--r--src/Costasdev.Busurbano.Backend/GraphClient/App/StopTile.cs76
-rw-r--r--src/Costasdev.Busurbano.Backend/GraphClient/ResponseTypes.cs36
-rw-r--r--src/Costasdev.Busurbano.Backend/Helpers/ContrastHelper.cs48
-rw-r--r--src/Costasdev.Busurbano.Backend/Helpers/SortingHelper.cs35
7 files changed, 396 insertions, 80 deletions
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<TileController> _logger;
+ private readonly IMemoryCache _cache;
+ private readonly HttpClient _httpClient;
+
+ public TileController(
+ ILogger<TileController> 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<IActionResult> 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<GraphClientResponse<StopTileResponse>>();
+
+ 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<string?>.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<IActionResult> 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<IActionResult> 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<List<ScheduledStop>>(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 @@
<PackageReference Include="Google.Protobuf" Version="3.33.1" />
<PackageReference Include="ProjNet" Version="2.1.0" />
- <PackageReference Include="NetTopologySuite" Version="2.6.0" />
- <PackageReference Include="NetTopologySuite.IO.GeoJSON" Version="4.0.0" />
+ <PackageReference Include="NetTopologySuite" Version="2.6.0" />
+ <PackageReference Include="NetTopologySuite.IO.GeoJSON" Version="4.0.0" />
+ <PackageReference Include="NetTopologySuite.IO.VectorTiles.Mapbox" Version="1.1.0" />
</ItemGroup>
<ItemGroup>
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<StopTileRequestContent.Bbox>
+{
+ 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<Stop>? 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<Route>? 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<T> where T : AbstractGraphResponse
+{
+ [JsonPropertyName("data")]
+ public T? Data { get; set; }
+
+ [JsonPropertyName("errors")]
+ public List<GraphClientError>? Errors { get; set; }
+
+ public bool IsSuccess => Errors == null || Errors.Count == 0;
+}
+
+public interface IGraphRequest<T>
+{
+ 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);
+ }
+
+}