aboutsummaryrefslogtreecommitdiff
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
parent37cdb0c418a7f2b47e40ae9db7ad86e1fddc86fe (diff)
Implement loading stops as tiles from OTP
-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
-rw-r--r--src/frontend/app/AppContext.tsx2
-rw-r--r--src/frontend/app/components/LineIcon.tsx27
-rw-r--r--src/frontend/app/components/PlannerOverlay.tsx3
-rw-r--r--src/frontend/app/components/RegionSelector.tsx33
-rw-r--r--src/frontend/app/components/StopMapModal.tsx6
-rw-r--r--src/frontend/app/components/map/StopSummarySheet.css (renamed from src/frontend/app/components/StopSummarySheet.css)0
-rw-r--r--src/frontend/app/components/map/StopSummarySheet.tsx (renamed from src/frontend/app/components/StopSummarySheet.tsx)51
-rw-r--r--src/frontend/app/components/map/StopSummarySheetSkeleton.tsx (renamed from src/frontend/app/components/StopSummarySheetSkeleton.tsx)0
-rw-r--r--src/frontend/app/config/RegionConfig.ts44
-rw-r--r--src/frontend/app/config/constants.ts22
-rw-r--r--src/frontend/app/contexts/MapContext.tsx10
-rw-r--r--src/frontend/app/contexts/SettingsContext.tsx2
-rw-r--r--src/frontend/app/data/SpecialPlacesProvider.ts6
-rw-r--r--src/frontend/app/data/StopDataProvider.ts64
-rw-r--r--src/frontend/app/routes/home.tsx2
-rw-r--r--src/frontend/app/routes/map.tsx142
-rw-r--r--src/frontend/app/routes/planner.tsx10
-rw-r--r--src/frontend/app/routes/stops-$id.tsx7
-rw-r--r--src/frontend/public/maps/spritesheet/sprite.json30
-rw-r--r--src/frontend/public/maps/spritesheet/sprite.pngbin3426 -> 5926 bytes
-rw-r--r--src/frontend/public/maps/spritesheet/sprite@2x.json30
-rw-r--r--src/frontend/public/maps/spritesheet/sprite@2x.pngbin7465 -> 13514 bytes
29 files changed, 646 insertions, 321 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);
+ }
+
+}
diff --git a/src/frontend/app/AppContext.tsx b/src/frontend/app/AppContext.tsx
index 2102ad7..8ff7de2 100644
--- a/src/frontend/app/AppContext.tsx
+++ b/src/frontend/app/AppContext.tsx
@@ -1,6 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import { type ReactNode } from "react";
-import { type RegionId } from "./config/RegionConfig";
+import { type RegionId } from "./config/constants";
import { MapProvider, useMap } from "./contexts/MapContext";
import {
SettingsProvider,
diff --git a/src/frontend/app/components/LineIcon.tsx b/src/frontend/app/components/LineIcon.tsx
index 8bbeb20..5d85c60 100644
--- a/src/frontend/app/components/LineIcon.tsx
+++ b/src/frontend/app/components/LineIcon.tsx
@@ -4,9 +4,16 @@ import "./LineIcon.css";
interface LineIconProps {
line: string;
mode?: "rounded" | "pill" | "default";
+ colour?: string;
+ textColour?: string;
}
-const LineIcon: React.FC<LineIconProps> = ({ line, mode = "default" }) => {
+const LineIcon: React.FC<LineIconProps> = ({
+ line,
+ mode = "default",
+ colour,
+ textColour,
+}) => {
const actualLine = useMemo(() => {
return line.trim().replace("510", "NAD");
}, [line]);
@@ -15,16 +22,26 @@ const LineIcon: React.FC<LineIconProps> = ({ line, mode = "default" }) => {
return /^[a-zA-Z]/.test(actualLine) ? actualLine : `L${actualLine}`;
}, [actualLine]);
- const cssVarName = `--line-${formattedLine.toLowerCase()}`;
- const cssTextVarName = `--line-${formattedLine.toLowerCase()}-text`;
+ const actualLineColour = useMemo(() => {
+ const actualColour = colour?.startsWith("#") ? colour : `#${colour}`;
+ return colour ? actualColour : `var(--line-${formattedLine.toLowerCase()})`;
+ }, [formattedLine]);
+ const actualTextColour = useMemo(() => {
+ const actualTextColour = textColour?.startsWith("#")
+ ? textColour
+ : `#${textColour}`;
+ return textColour
+ ? actualTextColour
+ : `var(--line-${formattedLine.toLowerCase()}-text, #000000)`;
+ }, [formattedLine]);
return (
<span
className={`line-icon-${mode}`}
style={
{
- "--line-colour": `var(${cssVarName})`,
- "--line-text-colour": `var(${cssTextVarName}, #000000)`,
+ "--line-colour": actualLineColour,
+ "--line-text-colour": actualTextColour,
} as React.CSSProperties
}
>
diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx
index 12cfb0f..af71e48 100644
--- a/src/frontend/app/components/PlannerOverlay.tsx
+++ b/src/frontend/app/components/PlannerOverlay.tsx
@@ -8,7 +8,6 @@ import React, {
} from "react";
import { useTranslation } from "react-i18next";
import PlaceListItem from "~/components/PlaceListItem";
-import { REGION_DATA } from "~/config/RegionConfig";
import {
reverseGeocode,
searchPlaces,
@@ -59,7 +58,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
[]
);
const [recentPlaces, setRecentPlaces] = useState<PlannerSearchResult[]>([]);
- const RECENT_KEY = `recentPlaces_${REGION_DATA.id}`;
+ const RECENT_KEY = `recentPlaces`;
const clearRecentPlaces = useCallback(() => {
setRecentPlaces([]);
try {
diff --git a/src/frontend/app/components/RegionSelector.tsx b/src/frontend/app/components/RegionSelector.tsx
deleted file mode 100644
index 124b574..0000000
--- a/src/frontend/app/components/RegionSelector.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useApp } from "../AppContext";
-import { getAvailableRegions } from "../config/RegionConfig";
-import "./RegionSelector.css";
-
-export function RegionSelector() {
- const { region, setRegion } = useApp();
- const regions = getAvailableRegions();
-
- const handleRegionChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
- const newRegion = e.target.value as any;
- setRegion(newRegion);
- };
-
- return (
- <div className="region-selector">
- <label htmlFor="region-select" className="region-label">
- Región:
- </label>
- <select
- id="region-select"
- className="region-select"
- value={region}
- onChange={handleRegionChange}
- >
- {regions.map((r) => (
- <option key={r.id} value={r.id}>
- {r.name}
- </option>
- ))}
- </select>
- </div>
- );
-}
diff --git a/src/frontend/app/components/StopMapModal.tsx b/src/frontend/app/components/StopMapModal.tsx
index 1cb6d88..bb6a3fa 100644
--- a/src/frontend/app/components/StopMapModal.tsx
+++ b/src/frontend/app/components/StopMapModal.tsx
@@ -9,7 +9,7 @@ import React, {
import Map, { Layer, Marker, Source, type MapRef } from "react-map-gl/maplibre";
import { Sheet } from "react-modal-sheet";
import { useApp } from "~/AppContext";
-import { REGION_DATA } from "~/config/RegionConfig";
+import { APP_CONSTANTS } from "~/config/constants";
import { getLineColour } from "~/data/LineColors";
import type { Stop } from "~/data/StopDataProvider";
import { loadStyle } from "~/maps/styleloader";
@@ -243,7 +243,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
!selectedBus ||
!selectedBus.schedule?.shapeId ||
selectedBus.currentPosition?.shapeIndex === undefined ||
- !REGION_DATA.shapeEndpoint
+ !APP_CONSTANTS.shapeEndpoint
) {
setShapeData(null);
setPreviousShapeData(null);
@@ -263,7 +263,7 @@ export const StopMapModal: React.FC<StopMapModalProps> = ({
sLat?: number,
sLon?: number
) => {
- let url = `${REGION_DATA.shapeEndpoint}?shapeId=${sId}`;
+ let url = `${APP_CONSTANTS.shapeEndpoint}?shapeId=${sId}`;
if (bIndex !== undefined) url += `&busShapeIndex=${bIndex}`;
if (sIndex !== undefined) url += `&stopShapeIndex=${sIndex}`;
else if (sLat && sLon) url += `&stopLat=${sLat}&stopLon=${sLon}`;
diff --git a/src/frontend/app/components/StopSummarySheet.css b/src/frontend/app/components/map/StopSummarySheet.css
index 5869d41..5869d41 100644
--- a/src/frontend/app/components/StopSummarySheet.css
+++ b/src/frontend/app/components/map/StopSummarySheet.css
diff --git a/src/frontend/app/components/StopSummarySheet.tsx b/src/frontend/app/components/map/StopSummarySheet.tsx
index c2d6ffe..b24e71c 100644
--- a/src/frontend/app/components/StopSummarySheet.tsx
+++ b/src/frontend/app/components/map/StopSummarySheet.tsx
@@ -4,19 +4,27 @@ import { useTranslation } from "react-i18next";
import { Sheet } from "react-modal-sheet";
import { Link } from "react-router";
import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList";
-import { REGION_DATA } from "~/config/RegionConfig";
-import type { Stop } from "~/data/StopDataProvider";
-import { type ConsolidatedCirculation } from "../routes/stops-$id";
-import { ErrorDisplay } from "./ErrorDisplay";
-import LineIcon from "./LineIcon";
-import { StopAlert } from "./StopAlert";
+import { APP_CONSTANTS } from "~/config/constants";
+import { type ConsolidatedCirculation } from "../../routes/stops-$id";
+import { ErrorDisplay } from "../ErrorDisplay";
+import LineIcon from "../LineIcon";
import "./StopSummarySheet.css";
import { StopSummarySheetSkeleton } from "./StopSummarySheetSkeleton";
-interface StopSheetProps {
+export interface StopSheetProps {
isOpen: boolean;
onClose: () => void;
- stop: Stop;
+ stop: {
+ stopId: string;
+ stopCode?: string;
+ stopFeed?: string;
+ name: string;
+ lines: {
+ line: string;
+ colour?: string;
+ textColour?: string;
+ }[];
+ };
}
interface ErrorInfo {
@@ -29,7 +37,7 @@ const loadConsolidatedData = async (
stopId: string
): Promise<ConsolidatedCirculation[]> => {
const resp = await fetch(
- `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
+ `${APP_CONSTANTS.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
{
headers: {
Accept: "application/json",
@@ -116,21 +124,24 @@ export const StopSheet: React.FC<StopSheetProps> = ({
<Sheet.Content drag="y">
<div className="stop-sheet-content">
<div className="stop-sheet-header">
- <h2 className="stop-sheet-title">{stop.name.original}</h2>
- <span className="stop-sheet-id">({stop.stopId})</span>
+ <h2 className="stop-sheet-title">{stop.name}</h2>
+ <span className="stop-sheet-id">({stop.stopCode})</span>
</div>
- <div
- className={`stop-sheet-lines-container ${stop.lines.length >= 10 ? "scrollable" : ""}`}
- >
- {stop.lines.map((line) => (
- <div key={line} className="stop-sheet-line-icon">
- <LineIcon line={line} mode="rounded" />
- </div>
+ <div className={`d-flex flex-wrap flex-row gap-2`}>
+ {stop.lines.map((lineObj) => (
+ <LineIcon
+ key={lineObj.line}
+ line={lineObj.line}
+ mode="pill"
+ colour={lineObj.colour}
+ textColour={lineObj.textColour}
+ />
))}
</div>
- <StopAlert stop={stop} compact />
+ {/* TODO: Enable stop alerts when available */}
+ {/*<StopAlert stop={stop} compact />*/}
{loading ? (
<StopSummarySheetSkeleton />
@@ -158,7 +169,7 @@ export const StopSheet: React.FC<StopSheetProps> = ({
) : (
<ConsolidatedCirculationList
data={data.slice(0, 4)}
- driver={stop.stopId.split(":")[0]}
+ driver={stop.stopFeed}
reduced
/>
)}
diff --git a/src/frontend/app/components/StopSummarySheetSkeleton.tsx b/src/frontend/app/components/map/StopSummarySheetSkeleton.tsx
index 7697efc..7697efc 100644
--- a/src/frontend/app/components/StopSummarySheetSkeleton.tsx
+++ b/src/frontend/app/components/map/StopSummarySheetSkeleton.tsx
diff --git a/src/frontend/app/config/RegionConfig.ts b/src/frontend/app/config/RegionConfig.ts
deleted file mode 100644
index d595b3f..0000000
--- a/src/frontend/app/config/RegionConfig.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { LngLatLike } from "maplibre-gl";
-
-export type RegionId = "vigo";
-
-export interface RegionData {
- id: RegionId;
- name: string;
- stopsEndpoint: string;
- estimatesEndpoint: string;
- consolidatedCirculationsEndpoint: string;
- timetableEndpoint: string;
- shapeEndpoint: string;
- defaultCenter: LngLatLike;
- bounds: {
- sw: LngLatLike;
- ne: LngLatLike;
- };
- textColour: string;
- defaultZoom: number;
- showMeters: boolean;
-}
-
-export const REGION_DATA: RegionData = {
- id: "vigo",
- name: "Vigo",
- stopsEndpoint: "/stops/vigo.json",
- estimatesEndpoint: "/api/vigo/GetStopEstimates",
- consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations",
- timetableEndpoint: "/api/vigo/GetStopTimetable",
- shapeEndpoint: "/api/vigo/GetShape",
- defaultCenter: {
- lat: 42.229188855975046,
- lng: -8.72246955783102,
- } as LngLatLike,
- bounds: {
- sw: [-8.951059, 42.098923] as LngLatLike,
- ne: [-8.447748, 42.3496] as LngLatLike,
- },
- textColour: "#e72b37",
- defaultZoom: 14,
- showMeters: true,
-};
-
-export const getAvailableRegions = (): RegionData[] => [REGION_DATA];
diff --git a/src/frontend/app/config/constants.ts b/src/frontend/app/config/constants.ts
new file mode 100644
index 0000000..9a0fdd1
--- /dev/null
+++ b/src/frontend/app/config/constants.ts
@@ -0,0 +1,22 @@
+import type { LngLatLike } from "maplibre-gl";
+
+export type RegionId = "vigo";
+
+export const APP_CONSTANTS = {
+ id: "vigo",
+
+ stopsEndpoint: "/stops/vigo.json",
+ consolidatedCirculationsEndpoint: "/api/vigo/GetConsolidatedCirculations",
+ shapeEndpoint: "/api/vigo/GetShape",
+ defaultCenter: {
+ lat: 42.229188855975046,
+ lng: -8.72246955783102,
+ } as LngLatLike,
+ bounds: {
+ sw: [-9.629517, 41.463312] as LngLatLike,
+ ne: [-6.289673, 43.711564] as LngLatLike,
+ },
+ textColour: "#e72b37",
+ defaultZoom: 14,
+ showMeters: true,
+};
diff --git a/src/frontend/app/contexts/MapContext.tsx b/src/frontend/app/contexts/MapContext.tsx
index af13bb7..db1392c 100644
--- a/src/frontend/app/contexts/MapContext.tsx
+++ b/src/frontend/app/contexts/MapContext.tsx
@@ -6,7 +6,7 @@ import {
useState,
type ReactNode,
} from "react";
-import { REGION_DATA } from "~/config/RegionConfig";
+import { APP_CONSTANTS } from "~/config/constants";
interface MapState {
center: LngLatLike;
@@ -36,8 +36,8 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
// We might want to ensure we have a fallback if the region changed while the app was closed?
// But for now, let's stick to the existing logic.
return {
- center: parsed.center || REGION_DATA.defaultCenter,
- zoom: parsed.zoom || REGION_DATA.defaultZoom,
+ center: parsed.center || APP_CONSTANTS.defaultCenter,
+ zoom: parsed.zoom || APP_CONSTANTS.defaultZoom,
userLocation: parsed.userLocation || null,
hasLocationPermission: parsed.hasLocationPermission || false,
};
@@ -46,8 +46,8 @@ export const MapProvider = ({ children }: { children: ReactNode }) => {
}
}
return {
- center: REGION_DATA.defaultCenter,
- zoom: REGION_DATA.defaultZoom,
+ center: APP_CONSTANTS.defaultCenter,
+ zoom: APP_CONSTANTS.defaultZoom,
userLocation: null,
hasLocationPermission: false,
};
diff --git a/src/frontend/app/contexts/SettingsContext.tsx b/src/frontend/app/contexts/SettingsContext.tsx
index 5f6ff46..d66ee52 100644
--- a/src/frontend/app/contexts/SettingsContext.tsx
+++ b/src/frontend/app/contexts/SettingsContext.tsx
@@ -5,7 +5,7 @@ import {
useState,
type ReactNode,
} from "react";
-import { APP_CONFIG } from "../config/AppConfig";
+import { APP_CONFIG } from "~/config/AppConfig";
export type Theme = "light" | "dark" | "system";
export type TableStyle = "regular" | "grouped" | "experimental_consolidated";
diff --git a/src/frontend/app/data/SpecialPlacesProvider.ts b/src/frontend/app/data/SpecialPlacesProvider.ts
index 2e3be68..d11b119 100644
--- a/src/frontend/app/data/SpecialPlacesProvider.ts
+++ b/src/frontend/app/data/SpecialPlacesProvider.ts
@@ -1,5 +1,3 @@
-import { REGION_DATA } from "~/config/RegionConfig";
-
export interface SpecialPlace {
name: string;
type: "stop" | "address";
@@ -9,8 +7,8 @@ export interface SpecialPlace {
longitude?: number;
}
-const STORAGE_KEY_HOME = `specialPlace_home_${REGION_DATA.id}`;
-const STORAGE_KEY_WORK = `specialPlace_work_${REGION_DATA.id}`;
+const STORAGE_KEY_HOME = `specialPlace_home`;
+const STORAGE_KEY_WORK = `specialPlace_work`;
function getHome(): SpecialPlace | null {
try {
diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts
index e523bd1..7bab10c 100644
--- a/src/frontend/app/data/StopDataProvider.ts
+++ b/src/frontend/app/data/StopDataProvider.ts
@@ -1,19 +1,13 @@
-import { REGION_DATA } from "~/config/RegionConfig";
+import { APP_CONSTANTS } from "~/config/constants";
export interface CachedStopList {
timestamp: number;
data: Stop[];
}
-export type StopName = {
- original: string;
- intersect?: string;
-};
-
export interface Stop {
stopId: string;
- type?: "bus" | "train";
- name: StopName;
+ name: string;
latitude?: number;
longitude?: number;
lines: string[];
@@ -41,13 +35,13 @@ function normalizeId(id: number | string): string {
// Initialize cachedStops and customNames once per region
async function initStops() {
- if (!cachedStopsByRegion[REGION_DATA.id]) {
- const response = await fetch(REGION_DATA.stopsEndpoint);
+ if (!cachedStopsByRegion[APP_CONSTANTS.id]) {
+ const response = await fetch(APP_CONSTANTS.stopsEndpoint);
const rawStops = (await response.json()) as any[];
// build array and map
- stopsMapByRegion[REGION_DATA.id] = {};
- cachedStopsByRegion[REGION_DATA.id] = rawStops.map((raw) => {
+ stopsMapByRegion[APP_CONSTANTS.id] = {};
+ cachedStopsByRegion[APP_CONSTANTS.id] = rawStops.map((raw) => {
const id = normalizeId(raw.stopId);
const entry = {
...raw,
@@ -55,21 +49,23 @@ async function initStops() {
type: raw.type || (id.startsWith("renfe:") ? "train" : "bus"),
favourite: false,
} as Stop;
- stopsMapByRegion[REGION_DATA.id][id] = entry;
+ stopsMapByRegion[APP_CONSTANTS.id][id] = entry;
return entry;
});
// load custom names
- const rawCustom = localStorage.getItem(`customStopNames_${REGION_DATA.id}`);
+ const rawCustom = localStorage.getItem(
+ `customStopNames_${APP_CONSTANTS.id}`
+ );
if (rawCustom) {
const parsed = JSON.parse(rawCustom);
const normalized: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
normalized[normalizeId(key)] = value as string;
}
- customNamesByRegion[REGION_DATA.id] = normalized;
+ customNamesByRegion[APP_CONSTANTS.id] = normalized;
} else {
- customNamesByRegion[REGION_DATA.id] = {};
+ customNamesByRegion[APP_CONSTANTS.id] = {};
}
}
}
@@ -92,9 +88,9 @@ async function getStops(): Promise<Stop[]> {
async function getStopById(stopId: string | number): Promise<Stop | undefined> {
await initStops();
const id = normalizeId(stopId);
- const stop = stopsMapByRegion[REGION_DATA.id]?.[id];
+ const stop = stopsMapByRegion[APP_CONSTANTS.id]?.[id];
if (stop) {
- const rawFav = localStorage.getItem(`favouriteStops_${REGION_DATA.id}`);
+ const rawFav = localStorage.getItem(`favouriteStops_${APP_CONSTANTS.id}`);
const favouriteStops = rawFav
? (JSON.parse(rawFav) as (number | string)[]).map(normalizeId)
: [];
@@ -105,32 +101,29 @@ async function getStopById(stopId: string | number): Promise<Stop | undefined> {
// Updated display name to include custom names
function getDisplayName(stop: Stop): string {
- const customNames = customNamesByRegion[REGION_DATA.id] || {};
- if (customNames[stop.stopId]) return customNames[stop.stopId];
- const nameObj = stop.name;
- return nameObj.intersect || nameObj.original;
+ return stop.name;
}
// New: set or remove custom names
function setCustomName(stopId: string | number, label: string) {
const id = normalizeId(stopId);
- if (!customNamesByRegion[REGION_DATA.id]) {
- customNamesByRegion[REGION_DATA.id] = {};
+ if (!customNamesByRegion[APP_CONSTANTS.id]) {
+ customNamesByRegion[APP_CONSTANTS.id] = {};
}
- customNamesByRegion[REGION_DATA.id][id] = label;
+ customNamesByRegion[APP_CONSTANTS.id][id] = label;
localStorage.setItem(
- `customStopNames_${REGION_DATA.id}`,
- JSON.stringify(customNamesByRegion[REGION_DATA.id])
+ `customStopNames_${APP_CONSTANTS.id}`,
+ JSON.stringify(customNamesByRegion[APP_CONSTANTS.id])
);
}
function removeCustomName(stopId: string | number) {
const id = normalizeId(stopId);
- if (customNamesByRegion[REGION_DATA.id]?.[id]) {
- delete customNamesByRegion[REGION_DATA.id][id];
+ if (customNamesByRegion[APP_CONSTANTS.id]?.[id]) {
+ delete customNamesByRegion[APP_CONSTANTS.id][id];
localStorage.setItem(
- `customStopNames_${REGION_DATA.id}`,
- JSON.stringify(customNamesByRegion[REGION_DATA.id])
+ `customStopNames_${APP_CONSTANTS.id}`,
+ JSON.stringify(customNamesByRegion[APP_CONSTANTS.id])
);
}
}
@@ -138,7 +131,7 @@ function removeCustomName(stopId: string | number) {
// New: get custom label for a stop
function getCustomName(stopId: string | number): string | undefined {
const id = normalizeId(stopId);
- return customNamesByRegion[REGION_DATA.id]?.[id];
+ return customNamesByRegion[APP_CONSTANTS.id]?.[id];
}
function addFavourite(stopId: string | number) {
@@ -231,7 +224,7 @@ function getFavouriteIds(): string[] {
// New function to load stops from network
async function loadStopsFromNetwork(): Promise<Stop[]> {
- const response = await fetch(REGION_DATA.stopsEndpoint);
+ const response = await fetch(APP_CONSTANTS.stopsEndpoint);
const rawStops = (await response.json()) as any[];
return rawStops.map((raw) => {
const id = normalizeId(raw.stopId);
@@ -244,6 +237,10 @@ async function loadStopsFromNetwork(): Promise<Stop[]> {
});
}
+function getTileUrlTemplate(): string {
+ return window.location.origin + "/api/tiles/stops/{z}/{x}/{y}";
+}
+
export default {
getStops,
getStopById,
@@ -258,4 +255,5 @@ export default {
getRecent,
getFavouriteIds,
loadStopsFromNetwork,
+ getTileUrlTemplate,
};
diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx
index e97659a..36565bd 100644
--- a/src/frontend/app/routes/home.tsx
+++ b/src/frontend/app/routes/home.tsx
@@ -31,7 +31,7 @@ export default function StopList() {
() =>
new Fuse(data || [], {
threshold: 0.3,
- keys: ["name.original", "name.intersect", "stopId"],
+ keys: ["name", "stopId"],
}),
[data]
);
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index 39fc062..db9de59 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -1,8 +1,7 @@
-import StopDataProvider, { type Stop } from "../data/StopDataProvider";
+import StopDataProvider from "../data/StopDataProvider";
import "./map.css";
import { DEFAULT_STYLE, loadStyle } from "app/maps/styleloader";
-import type { Feature as GeoJsonFeature, Point } from "geojson";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Map, {
@@ -16,8 +15,11 @@ import Map, {
} from "react-map-gl/maplibre";
import { useNavigate } from "react-router";
import { PlannerOverlay } from "~/components/PlannerOverlay";
-import { StopSheet } from "~/components/StopSummarySheet";
-import { REGION_DATA } from "~/config/RegionConfig";
+import {
+ StopSheet,
+ type StopSheetProps,
+} from "~/components/map/StopSummarySheet";
+import { APP_CONSTANTS } from "~/config/constants";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { usePlanner } from "~/hooks/usePlanner";
import { useApp } from "../AppContext";
@@ -28,19 +30,9 @@ export default function StopMap() {
const { t } = useTranslation();
const navigate = useNavigate();
usePageTitle(t("navbar.map", "Mapa"));
- const [stops, setStops] = useState<
- GeoJsonFeature<
- Point,
- {
- stopId: string;
- name: string;
- lines: string[];
- cancelled?: boolean;
- prefix: string;
- }
- >[]
- >([]);
- const [selectedStop, setSelectedStop] = useState<Stop | null>(null);
+ const [selectedStop, setSelectedStop] = useState<
+ StopSheetProps["stop"] | null
+ >(null);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const { mapState, updateMapState, theme } = useApp();
const mapRef = useRef<MapRef>(null);
@@ -63,46 +55,11 @@ export default function StopMap() {
return;
}
const feature = features[0];
- console.debug("Map click feature:", feature);
- const props: any = feature.properties;
handlePointClick(feature);
};
useEffect(() => {
- StopDataProvider.getStops().then((data) => {
- const features: GeoJsonFeature<
- Point,
- {
- stopId: string;
- name: string;
- lines: string[];
- cancelled?: boolean;
- prefix: string;
- }
- >[] = data.map((s) => ({
- type: "Feature",
- geometry: {
- type: "Point",
- coordinates: [s.longitude as number, s.latitude as number],
- },
- properties: {
- stopId: s.stopId,
- name: s.name.original,
- lines: s.lines,
- cancelled: s.cancelled ?? false,
- prefix: s.stopId.startsWith("renfe:")
- ? "stop-renfe"
- : s.cancelled
- ? "stop-vitrasa-cancelled"
- : "stop-vitrasa",
- },
- }));
- setStops(features);
- });
- }, []);
-
- useEffect(() => {
//const styleName = "carto";
const styleName = "openfreemap";
loadStyle(styleName, theme)
@@ -166,26 +123,29 @@ export default function StopMap() {
const handlePointClick = (feature: any) => {
const props: any = feature.properties;
- if (!props || !props.stopId) {
+ // TODO: Move ID to constant, improve type checking
+ if (!props || feature.layer.id !== "stops") {
console.warn("Invalid feature properties:", props);
return;
}
- const stopId = props.stopId;
+ const stopId = props.id;
- // fetch full stop to get lines array
- StopDataProvider.getStopById(stopId)
- .then((stop) => {
- if (!stop) {
- console.warn("Stop not found:", stopId);
- return;
- }
- setSelectedStop(stop);
- setIsSheetOpen(true);
- })
- .catch((err) => {
- console.error("Error fetching stop details:", err);
- });
+ console.debug("Stop clicked:", stopId, props);
+
+ setSelectedStop({
+ stopId: props.id,
+ stopCode: props.code,
+ name: props.name || "Unknown Stop",
+ lines: JSON.parse(props.routes || "[]").map((route) => {
+ return {
+ line: route.shortName,
+ colour: route.colour,
+ textColour: route.textColour,
+ };
+ }),
+ });
+ setIsSheetOpen(true);
};
return (
@@ -203,7 +163,7 @@ export default function StopMap() {
style={{ width: "100%", height: "100%" }}
interactiveLayerIds={["stops", "stops-label"]}
onClick={onMapClick}
- minZoom={11}
+ minZoom={5}
scrollZoom
pitch={0}
roll={0}
@@ -214,7 +174,7 @@ export default function StopMap() {
zoom: mapState.zoom,
}}
attributionControl={{ compact: false }}
- maxBounds={[REGION_DATA.bounds.sw, REGION_DATA.bounds.ne]}
+ maxBounds={[APP_CONSTANTS.bounds.sw, APP_CONSTANTS.bounds.ne]}
>
<NavigationControl position="bottom-right" />
<GeolocateControl
@@ -225,8 +185,10 @@ export default function StopMap() {
<Source
id="stops-source"
- type="geojson"
- data={{ type: "FeatureCollection", features: stops }}
+ type="vector"
+ tiles={[StopDataProvider.getTileUrlTemplate()]}
+ minzoom={11}
+ maxzoom={20}
/>
<Layer
@@ -234,8 +196,26 @@ export default function StopMap() {
type="symbol"
minzoom={11}
source="stops-source"
+ source-layer="stops"
layout={{
- "icon-image": ["get", "prefix"],
+ // TODO: Fix ñapa by maybe including this from the server side?
+ "icon-image": [
+ "match",
+ ["get", "feed"],
+ "vitrasa",
+ "stop-vitrasa",
+ "santiago",
+ "stop-santiago",
+ "coruna",
+ "stop-coruna",
+ "xunta",
+ "stop-xunta",
+ "renfe",
+ "stop-renfe",
+ "feve",
+ "stop-feve",
+ "#stop-generic",
+ ],
"icon-size": [
"interpolate",
["linear"],
@@ -256,6 +236,7 @@ export default function StopMap() {
id="stops-label"
type="symbol"
source="stops-source"
+ source-layer="stops"
minzoom={16}
layout={{
"text-field": ["get", "name"],
@@ -267,10 +248,21 @@ export default function StopMap() {
}}
paint={{
"text-color": [
- "case",
- ["==", ["get", "prefix"], "stop-renfe"],
+ "match",
+ ["get", "feed"],
+ "vitrasa",
+ "#95D516",
+ "santiago",
+ "#508096",
+ "coruna",
+ "#E61C29",
+ "xunta",
+ "#007BC4",
+ "renfe",
"#870164",
- "#e72b37",
+ "feve",
+ "#EE3D32",
+ "#333333",
],
"text-halo-color": "#FFF",
"text-halo-width": 1,
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx
index b1a9813..9e44425 100644
--- a/src/frontend/app/routes/planner.tsx
+++ b/src/frontend/app/routes/planner.tsx
@@ -9,7 +9,7 @@ import { useLocation } from "react-router";
import { useApp } from "~/AppContext";
import LineIcon from "~/components/LineIcon";
import { PlannerOverlay } from "~/components/PlannerOverlay";
-import { REGION_DATA } from "~/config/RegionConfig";
+import { APP_CONSTANTS } from "~/config/constants";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { type Itinerary } from "~/data/PlannerApi";
import { usePlanner } from "~/hooks/usePlanner";
@@ -392,7 +392,7 @@ const ItineraryDetail = ({
if (!arrivalsByStop[stopKey]) {
try {
const resp = await fetch(
- `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${encodeURIComponent(leg.from.stopCode || leg.from.stopId)}`,
+ `${APP_CONSTANTS.consolidatedCirculationsEndpoint}?stopId=${encodeURIComponent(leg.from.stopCode || leg.from.stopId)}`,
{ headers: { Accept: "application/json" } }
);
@@ -424,9 +424,11 @@ const ItineraryDetail = ({
ref={mapRef}
initialViewState={{
longitude:
- origin?.lon || (REGION_DATA.defaultCenter as [number, number])[0],
+ origin?.lon ||
+ (APP_CONSTANTS.defaultCenter as [number, number])[0],
latitude:
- origin?.lat || (REGION_DATA.defaultCenter as [number, number])[1],
+ origin?.lat ||
+ (APP_CONSTANTS.defaultCenter as [number, number])[1],
zoom: 13,
}}
mapStyle={mapStyle}
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index 6d06215..46358dc 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -10,7 +10,7 @@ import { StopHelpModal } from "~/components/StopHelpModal";
import { StopMapModal } from "~/components/StopMapModal";
import { ConsolidatedCirculationList } from "~/components/Stops/ConsolidatedCirculationList";
import { ConsolidatedCirculationListSkeleton } from "~/components/Stops/ConsolidatedCirculationListSkeleton";
-import { REGION_DATA } from "~/config/RegionConfig";
+import { APP_CONSTANTS } from "~/config/constants";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import StopDataProvider, { type Stop } from "../data/StopDataProvider";
@@ -58,7 +58,7 @@ const loadConsolidatedData = async (
stopId: string
): Promise<ConsolidatedCirculation[]> => {
const resp = await fetch(
- `${REGION_DATA.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
+ `${APP_CONSTANTS.consolidatedCirculationsEndpoint}?stopId=${stopId}`,
{
headers: {
Accept: "application/json",
@@ -123,8 +123,7 @@ export default function Estimates() {
// Helper function to get the display name for the stop
const getStopDisplayName = useCallback(() => {
if (customName) return customName;
- if (stopData?.name.intersect) return stopData.name.intersect;
- if (stopData?.name.original) return stopData.name.original;
+ if (stopData?.name) return stopData.name;
return `Parada ${stopId}`;
}, [customName, stopData, stopId]);
diff --git a/src/frontend/public/maps/spritesheet/sprite.json b/src/frontend/public/maps/spritesheet/sprite.json
index 46a525b..1b3a78b 100644
--- a/src/frontend/public/maps/spritesheet/sprite.json
+++ b/src/frontend/public/maps/spritesheet/sprite.json
@@ -7,17 +7,41 @@
"height": 32,
"pixelRatio": 1
},
- "stop-vitrasa-cancelled": {
- "id": "stop-vitrasa-cancelled",
+ "stop-santiago": {
+ "id": "stop-santiago",
"x": 32,
"y": 0,
"width": 32,
"height": 32,
"pixelRatio": 1
},
+ "stop-coruna": {
+ "id": "stop-coruna",
+ "x": 64,
+ "y": 0,
+ "width": 32,
+ "height": 32,
+ "pixelRatio": 1
+ },
+ "stop-xunta": {
+ "id": "stop-xunta",
+ "x": 96,
+ "y": 0,
+ "width": 32,
+ "height": 32,
+ "pixelRatio": 1
+ },
"stop-renfe": {
"id": "stop-renfe",
- "x": 64,
+ "x": 128,
+ "y": 0,
+ "width": 32,
+ "height": 32,
+ "pixelRatio": 1
+ },
+ "stop-feve": {
+ "id": "stop-feve",
+ "x": 160,
"y": 0,
"width": 32,
"height": 32,
diff --git a/src/frontend/public/maps/spritesheet/sprite.png b/src/frontend/public/maps/spritesheet/sprite.png
index a63888a..d8a32ab 100644
--- a/src/frontend/public/maps/spritesheet/sprite.png
+++ b/src/frontend/public/maps/spritesheet/sprite.png
Binary files differ
diff --git a/src/frontend/public/maps/spritesheet/sprite@2x.json b/src/frontend/public/maps/spritesheet/sprite@2x.json
index 02e05d5..d2e89dd 100644
--- a/src/frontend/public/maps/spritesheet/sprite@2x.json
+++ b/src/frontend/public/maps/spritesheet/sprite@2x.json
@@ -7,17 +7,41 @@
"height": 64,
"pixelRatio": 2
},
- "stop-vitrasa-cancelled": {
- "id": "stop-vitrasa-cancelled",
+ "stop-santiago": {
+ "id": "stop-santiago",
"x": 64,
"y": 0,
"width": 64,
"height": 64,
"pixelRatio": 2
},
+ "stop-coruna": {
+ "id": "stop-coruna",
+ "x": 128,
+ "y": 0,
+ "width": 64,
+ "height": 64,
+ "pixelRatio": 2
+ },
+ "stop-xunta": {
+ "id": "stop-xunta",
+ "x": 192,
+ "y": 0,
+ "width": 64,
+ "height": 64,
+ "pixelRatio": 2
+ },
"stop-renfe": {
"id": "stop-renfe",
- "x": 128,
+ "x": 256,
+ "y": 0,
+ "width": 64,
+ "height": 64,
+ "pixelRatio": 2
+ },
+ "stop-feve": {
+ "id": "stop-feve",
+ "x": 320,
"y": 0,
"width": 64,
"height": 64,
diff --git a/src/frontend/public/maps/spritesheet/sprite@2x.png b/src/frontend/public/maps/spritesheet/sprite@2x.png
index 5f4b575..ba873a4 100644
--- a/src/frontend/public/maps/spritesheet/sprite@2x.png
+++ b/src/frontend/public/maps/spritesheet/sprite@2x.png
Binary files differ