From ac366d04cd54869c9a2b090aae24a276c32a85a6 Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Sat, 14 Feb 2026 01:35:54 +0100 Subject: feat: Implemen experimental bus stop usage display --- .../Controllers/ArrivalsController.cs | 9 +- src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv | 60 ++++++++ src/Enmarcha.Backend/Enmarcha.Backend.csproj | 2 +- src/Enmarcha.Backend/Program.cs | 1 + src/Enmarcha.Backend/Services/ArrivalsPipeline.cs | 1 + .../Services/Processors/VigoUsageProcessor.cs | 121 +++++++++++++++++ .../Types/Arrivals/BusStopUsagePoint.cs | 15 ++ .../Types/Arrivals/StopArrivalsResponse.cs | 3 + src/frontend/app/api/schema.ts | 12 +- .../app/components/stop/StopUsageModal.tsx | 151 +++++++++++++++++++++ src/frontend/app/i18n/locales/en-GB.json | 15 ++ src/frontend/app/i18n/locales/es-ES.json | 15 ++ src/frontend/app/i18n/locales/gl-ES.json | 15 ++ src/frontend/app/routes/map.tsx | 12 ++ src/frontend/app/routes/stops-$id.tsx | 43 +++++- src/frontend/public/pwa-worker.js | 11 +- 16 files changed, 470 insertions(+), 16 deletions(-) create mode 100644 src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv create mode 100644 src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs create mode 100644 src/Enmarcha.Backend/Types/Arrivals/BusStopUsagePoint.cs create mode 100644 src/frontend/app/components/stop/StopUsageModal.tsx diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs index 13fb430..a887c89 100644 --- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs +++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs @@ -130,7 +130,7 @@ public partial class ArrivalsController : ControllerBase arrivals.Add(arrival); } - await _pipeline.ExecuteAsync(new ArrivalsContext + var context = new ArrivalsContext { StopId = id, StopCode = stop.Code, @@ -138,7 +138,9 @@ public partial class ArrivalsController : ControllerBase Arrivals = arrivals, NowLocal = nowLocal, StopLocation = new Position { Latitude = stop.Lat, Longitude = stop.Lon } - }); + }; + + await _pipeline.ExecuteAsync(context); var feedId = id.Split(':')[0]; @@ -167,7 +169,8 @@ public partial class ArrivalsController : ControllerBase ContrastHelper.GetBestTextColour(r.Color ?? fallbackColor) : r.TextColor })], - Arrivals = [.. arrivals.Where(a => a.Estimate.Minutes >= timeThreshold)] + Arrivals = [.. arrivals.Where(a => a.Estimate.Minutes >= timeThreshold)], + Usage = context.Usage }); } diff --git a/src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv b/src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv new file mode 100644 index 0000000..8091fd9 --- /dev/null +++ b/src/Enmarcha.Backend/Data/vitrasa_stops_p95.csv @@ -0,0 +1,60 @@ +codigo +14264 +8630 +6940 +14206 +14333 +8750 +5610 +6930 +8610 +8840 +5800 +1310 +5820 +5630 +6300 +2780 +8820 +8460 +8520 +8540 +8470 +14892 +8040 +8480 +6570 +8450 +6960 +5300 +6860 +5790 +5540 +20102 +6620 +14337 +5650 +1360 +20193 +8500 +1920 +20111 +20075 +5560 +5570 +7000 +5530 +2820 +5620 +5680 +5520 +6550 +1260 +1280 +8770 +2830 +14123 +14163 +14168 +2020 +8060 diff --git a/src/Enmarcha.Backend/Enmarcha.Backend.csproj b/src/Enmarcha.Backend/Enmarcha.Backend.csproj index 1591e7c..de6489e 100644 --- a/src/Enmarcha.Backend/Enmarcha.Backend.csproj +++ b/src/Enmarcha.Backend/Enmarcha.Backend.csproj @@ -35,7 +35,7 @@ - + PreserveNewest diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs index 8599a5c..f4b39ff 100644 --- a/src/Enmarcha.Backend/Program.cs +++ b/src/Enmarcha.Backend/Program.cs @@ -128,6 +128,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs b/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs index 4f49afe..6d8c2c0 100644 --- a/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs +++ b/src/Enmarcha.Backend/Services/ArrivalsPipeline.cs @@ -23,6 +23,7 @@ public class ArrivalsContext public Position? StopLocation { get; set; } public required List Arrivals { get; set; } + public List? Usage { get; set; } public required DateTime NowLocal { get; set; } } diff --git a/src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs b/src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs new file mode 100644 index 0000000..f5c7664 --- /dev/null +++ b/src/Enmarcha.Backend/Services/Processors/VigoUsageProcessor.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using CsvHelper; +using CsvHelper.Configuration; +using Enmarcha.Backend.Types.Arrivals; +using Microsoft.Extensions.Caching.Memory; +using System.Globalization; + +namespace Enmarcha.Backend.Services.Processors; + +public class VigoUsageProcessor : IArrivalsProcessor +{ + private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly IHostEnvironment _environment; + private readonly FeedService _feedService; + private static readonly HashSet _vigoStopsWhitelist = []; + private static bool _whitelistLoaded = false; + private static readonly object _lock = new(); + + public VigoUsageProcessor( + HttpClient httpClient, + IMemoryCache cache, + ILogger logger, + IHostEnvironment environment, + FeedService feedService) + { + _httpClient = httpClient; + _cache = cache; + _logger = logger; + _environment = environment; + _feedService = feedService; + + LoadWhitelist(); + } + + private void LoadWhitelist() + { + if (_whitelistLoaded) return; + + lock (_lock) + { + if (_whitelistLoaded) return; + + try + { + var path = Path.Combine(_environment.ContentRootPath, "Data", "vitrasa_stops_p95.csv"); + if (File.Exists(path)) + { + using var reader = new StreamReader(path); + using var csv = new CsvReader(reader, CultureInfo.InvariantCulture); + csv.Read(); + csv.ReadHeader(); + while (csv.Read()) + { + var code = csv.GetField("codigo"); + if (!string.IsNullOrWhiteSpace(code)) + { + _vigoStopsWhitelist.Add(code.Trim()); + } + } + _logger.LogInformation("Loaded {Count} Vigo stops for usage data whitelist", _vigoStopsWhitelist.Count); + } + else + { + _logger.LogWarning("Vigo stops whitelist CSV not found at {Path}", path); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading Vigo stops whitelist"); + } + finally + { + _whitelistLoaded = true; + } + } + } + + public async Task ProcessAsync(ArrivalsContext context) + { + if (!context.StopId.StartsWith("vitrasa:")) return; + + var normalizedCode = _feedService.NormalizeStopCode("vitrasa", context.StopCode); + if (!_vigoStopsWhitelist.Contains(normalizedCode)) return; + + var cacheKey = $"vigo_usage_{normalizedCode}"; + if (_cache.TryGetValue(cacheKey, out List? cachedUsage)) + { + context.Usage = cachedUsage; + return; + } + + try + { + using var activity = Telemetry.Source.StartActivity("FetchVigoUsage"); + var url = $"https://datos.vigo.org/vci_api_app/api2.jsp?tipo=TRANSPORTE_PARADA_HORAS_USO¶da={normalizedCode}"; + var response = await _httpClient.GetAsync(url); + + if (response.IsSuccessStatusCode) + { + var json = await response.Content.ReadAsStringAsync(); + var usage = JsonSerializer.Deserialize>(json); + + if (usage != null) + { + _cache.Set(cacheKey, usage, TimeSpan.FromDays(7)); + context.Usage = usage; + } + } + else + { + _logger.LogWarning("Failed to fetch usage data for stop {StopCode}, status: {Status}", normalizedCode, response.StatusCode); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching usage data for Vigo stop {StopCode}", normalizedCode); + } + } +} diff --git a/src/Enmarcha.Backend/Types/Arrivals/BusStopUsagePoint.cs b/src/Enmarcha.Backend/Types/Arrivals/BusStopUsagePoint.cs new file mode 100644 index 0000000..edb08f4 --- /dev/null +++ b/src/Enmarcha.Backend/Types/Arrivals/BusStopUsagePoint.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Enmarcha.Backend.Types.Arrivals; + +public class BusStopUsagePoint +{ + [JsonPropertyName("h")] + public required int Hour { get; set; } + + [JsonPropertyName("t")] + public required int Total { get; set; } + + [JsonPropertyName("d")] + public required int DayOfWeek { get; set; } +} diff --git a/src/Enmarcha.Backend/Types/Arrivals/StopArrivalsResponse.cs b/src/Enmarcha.Backend/Types/Arrivals/StopArrivalsResponse.cs index 4d2f481..ddc4535 100644 --- a/src/Enmarcha.Backend/Types/Arrivals/StopArrivalsResponse.cs +++ b/src/Enmarcha.Backend/Types/Arrivals/StopArrivalsResponse.cs @@ -18,4 +18,7 @@ public class StopArrivalsResponse [JsonPropertyName("arrivals")] public List Arrivals { get; set; } = []; + + [JsonPropertyName("usage")] + public List? Usage { get; set; } } diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts index d9aec89..c0c97a4 100644 --- a/src/frontend/app/api/schema.ts +++ b/src/frontend/app/api/schema.ts @@ -36,8 +36,14 @@ export const ShiftBadgeSchema = z.object({ export const PositionSchema = z.object({ latitude: z.number(), longitude: z.number(), - orientationDegrees: z.number(), - shapeIndex: z.number(), + orientationDegrees: z.number().optional().nullable(), + shapeIndex: z.number().optional().nullable(), +}); + +export const BusStopUsagePointSchema = z.object({ + h: z.number().int(), + t: z.number().int(), + d: z.number().int(), }); export const VehicleInformationSchema = z.object({ @@ -67,6 +73,7 @@ export const StopArrivalsResponseSchema = z.object({ stopLocation: PositionSchema.optional().nullable(), routes: z.array(RouteInfoSchema), arrivals: z.array(ArrivalSchema), + usage: z.array(BusStopUsagePointSchema).optional().nullable(), }); export type RouteInfo = z.infer; @@ -77,6 +84,7 @@ export type DelayBadge = z.infer; export type ShiftBadge = z.infer; export type Position = z.infer; export type Arrival = z.infer; +export type BusStopUsagePoint = z.infer; export type StopArrivalsResponse = z.infer; // Transit Routes diff --git a/src/frontend/app/components/stop/StopUsageModal.tsx b/src/frontend/app/components/stop/StopUsageModal.tsx new file mode 100644 index 0000000..41db64a --- /dev/null +++ b/src/frontend/app/components/stop/StopUsageModal.tsx @@ -0,0 +1,151 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Sheet } from "react-modal-sheet"; +import type { BusStopUsagePoint } from "~/api/schema"; + +interface StopUsageModalProps { + isOpen: boolean; + onClose: () => void; + usage: BusStopUsagePoint[]; +} + +export const StopUsageModal = ({ + isOpen, + onClose, + usage, +}: StopUsageModalProps) => { + const { t } = useTranslation(); + + // Get current day of week (1=Monday, 7=Sunday) + // JS getDay(): 0=Sunday, 1=Monday ... 6=Saturday + const currentJsDay = new Date().getDay(); + const initialDay = currentJsDay === 0 ? 7 : currentJsDay; + + const [selectedDay, setSelectedDay] = useState(initialDay); + + const days = [ + { id: 1, label: t("days.monday") }, + { id: 2, label: t("days.tuesday") }, + { id: 3, label: t("days.wednesday") }, + { id: 4, label: t("days.thursday") }, + { id: 5, label: t("days.friday") }, + { id: 6, label: t("days.saturday") }, + { id: 7, label: t("days.sunday") }, + ]; + + const filteredData = useMemo(() => { + const data = usage.filter((u) => u.d === selectedDay); + // Ensure all 24 hours are represented + const fullDay = Array.from({ length: 24 }, (_, h) => { + const match = data.find((u) => u.h === h); + return { h, t: match?.t ?? 0 }; + }); + return fullDay; + }, [usage, selectedDay]); + + const maxUsage = useMemo(() => { + const max = Math.max(...filteredData.map((d) => d.t)); + return max === 0 ? 1 : max; + }, [filteredData]); + + // Use a square root scale to make smaller values more visible + const getScaledHeight = (value: number) => { + if (value <= 0) return 0; + const scaledValue = Math.sqrt(value); + const scaledMax = Math.sqrt(maxUsage); + return (scaledValue / scaledMax) * 100; + }; + + return ( + + + + +
+
+

+ {t("stop.usage_title", "Ocupación por horas")} +

+
+ {days.map((day) => ( + + ))} +
+
+ +
+
+ {/* Horizontal grid lines */} +
+
+
+
+
+ + {filteredData.map((data) => { + const height = getScaledHeight(data.t); + const isServiceHour = data.h >= 7 && data.h < 23; + + return ( +
+
+ {data.t > 0 && ( +
+ {data.t} {t("stop.usage_passengers", "pas.")} +
+ )} +
+ {data.h % 4 === 0 && ( + + {data.h}h + + )} +
+ ); + })} +
+
{/* Spacer for hour labels */} +
+ +
+

+ {t( + "stop.usage_scale_info", + "Escala no lineal para resaltar valores bajos" + )} +

+

+ {t( + "stop.usage_disclaimer", + "Datos históricos aproximados de ocupación." + )} +

+
+
+
+
+ +
+ ); +}; diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index f3d15b3..6cb939d 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -222,5 +222,20 @@ "gps_quality": "GPS Quality", "gps_reliable": "GPS data is from the current trip, and the position is a reliable estimate.", "gps_imprecise": "GPS data seems to indicate the bus is on the previous trip (possibly from another line). The position might not be reliable." + }, + "days": { + "monday": "Mo", + "tuesday": "Tu", + "wednesday": "We", + "thursday": "Th", + "friday": "Fr", + "saturday": "Sa", + "sunday": "Su" + }, + "stop": { + "usage_title": "Hourly occupancy", + "usage_passengers": "pax", + "usage_disclaimer": "Based on average historical occupancy from recent months available at datos.vigo.org. Does not reflect real-time occupancy.", + "usage_scale_info": "Graph uses a non-linear scale to better highlight lower occupancy values." } } diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index ea74031..5334fe1 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -222,5 +222,20 @@ "gps_quality": "Calidad GPS", "gps_reliable": "Los datos del GPS son del viaje actual, y la posición es una estimación fiable.", "gps_imprecise": "Los datos del GPS parecen indicar que el autobús está realizando el viaje anterior (posiblemente de otra línea). La posición puede no ser fiable." + }, + "days": { + "monday": "L", + "tuesday": "M", + "wednesday": "X", + "thursday": "J", + "friday": "V", + "saturday": "S", + "sunday": "D" + }, + "stop": { + "usage_title": "Ocupación por horas", + "usage_passengers": "pas.", + "usage_disclaimer": "Basado en la ocupación histórica promedio de los últimos meses disponible en datos.vigo.org. No refleja la ocupación en tiempo real.", + "usage_scale_info": "La escala del gráfico no es lineal para resaltar mejor los valores bajos." } } diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 7072f55..132ab0b 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -215,5 +215,20 @@ "gps_quality": "Calidade GPS", "gps_reliable": "Os datos do GPS son da viaxe actual, e a posición é unha estimación fiable.", "gps_imprecise": "Os datos do GPS parecen indicar que o autobús está a realizar a viaxe anterior (posiblemente doutra liña). A posición pode non ser fiable." + }, + "days": { + "monday": "L", + "tuesday": "M", + "wednesday": "Mc", + "thursday": "X", + "friday": "V", + "saturday": "S", + "sunday": "D" + }, + "stop": { + "usage_title": "Ocupación por horas", + "usage_passengers": "pas.", + "usage_disclaimer": "Baseado na ocupación histórica media dos últimos meses dispoñible en datos.vigo.org. Non reflicte a ocupación en tempo real.", + "usage_scale_info": "A escala do gráfico non é lineal para resaltar mellor os valores baixos." } } diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index c9c4850..2686222 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -307,6 +307,18 @@ export default function StopMap() { "text-anchor": "center", "text-justify": "center", "text-size": ["interpolate", ["linear"], ["zoom"], 11, 8, 22, 16], + "symbol-sort-key": [ + "match", + ["get", "transitKind"], + "coach", + 3, + "train", + 2, + "bus", + 1, + 0, + ], + "text-allow-overlap": false, }} paint={{ "text-color": [ diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx index bff8c7f..8f84764 100644 --- a/src/frontend/app/routes/stops-$id.tsx +++ b/src/frontend/app/routes/stops-$id.tsx @@ -1,18 +1,31 @@ -import { CircleHelp, Eye, EyeClosed, Star } from "lucide-react"; +import { + ChartNoAxesColumn, + CircleHelp, + Eye, + EyeClosed, + Star, +} from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useParams } from "react-router"; import { fetchArrivals } from "~/api/arrivals"; -import { type Arrival, type Position, type RouteInfo } from "~/api/schema"; +import { + type Arrival, + type Position, + type RouteInfo, + type StopArrivalsResponse, +} from "~/api/schema"; import { ArrivalList } from "~/components/arrivals/ArrivalList"; import { ErrorDisplay } from "~/components/ErrorDisplay"; import LineIcon from "~/components/LineIcon"; import { PullToRefresh } from "~/components/PullToRefresh"; import { StopHelpModal } from "~/components/stop/StopHelpModal"; import { StopMapModal } from "~/components/stop/StopMapModal"; +import { StopUsageModal } from "~/components/stop/StopUsageModal"; import { usePageTitle } from "~/contexts/PageTitleContext"; import { formatHex } from "~/utils/colours"; import StopDataProvider from "../data/StopDataProvider"; +import "../tailwind-full.css"; import "./stops-$id.css"; export const getArrivalId = (a: Arrival): string => { @@ -36,7 +49,7 @@ export default function Estimates() { ); // Data state - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [dataDate, setDataDate] = useState(null); const [dataLoading, setDataLoading] = useState(true); const [dataError, setDataError] = useState(null); @@ -45,6 +58,7 @@ export default function Estimates() { const [isManualRefreshing, setIsManualRefreshing] = useState(false); const [isMapModalOpen, setIsMapModalOpen] = useState(false); const [isHelpModalOpen, setIsHelpModalOpen] = useState(false); + const [isUsageVisible, setIsUsageVisible] = useState(false); const [isReducedView, setIsReducedView] = useState(false); const [selectedArrivalId, setSelectedArrivalId] = useState< string | undefined @@ -84,7 +98,7 @@ export default function Estimates() { setDataError(null); const response = await fetchArrivals(stopId, false); - setData(response.arrivals); + setData(response); setStopName(response.stopName); setApiRoutes(response.routes); if (response.stopLocation) { @@ -179,6 +193,15 @@ export default function Estimates() { onClick={toggleFavourite} /> + {data.usage && data.usage.length > 0 && ( + setIsUsageVisible(!isUsageVisible)} + /> + )} + setIsHelpModalOpen(true)} @@ -210,7 +233,7 @@ export default function Estimates() { { setSelectedArrivalId(getArrivalId(arrival)); @@ -230,7 +253,7 @@ export default function Estimates() { longitude: apiLocation?.longitude, lines: [], }} - circulations={(data ?? []).map((a) => ({ + circulations={(data?.arrivals ?? []).map((a) => ({ id: getArrivalId(a), currentPosition: a.currentPosition ?? undefined, stopShapeIndex: a.stopShapeIndex ?? undefined, @@ -248,6 +271,14 @@ export default function Estimates() { isOpen={isHelpModalOpen} onClose={() => setIsHelpModalOpen(false)} /> + + {data?.usage && ( + setIsUsageVisible(false)} + usage={data.usage} + /> + )} ); diff --git a/src/frontend/public/pwa-worker.js b/src/frontend/public/pwa-worker.js index 0a9be9c..9427bd3 100644 --- a/src/frontend/public/pwa-worker.js +++ b/src/frontend/public/pwa-worker.js @@ -1,10 +1,13 @@ -const CACHE_VERSION = "20260101a"; +const CACHE_VERSION = "20260211a"; const STATIC_CACHE_NAME = `static-cache-${CACHE_VERSION}`; const STATIC_CACHE_ASSETS = [ "/favicon.ico", - "/icon-square-256.png", - "/icon-round-256.png", - "/icon-inverse.png", + "/icon-192.png", + "/icon-512.png", + "/icon-maskable-192.png", + "/icon-maskable-512.png", + "/icon-monochrome-256.png", + "/icon.svg", ]; const EXPR_CACHE_AFTER_FIRST_VIEW = /(\/assets\/.*)/; -- cgit v1.3