From b96273b54a9b47c79e0afe40a918f751e82097ae Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 18:02:36 +0100 Subject: Link previous trip shapes for GPS positioning on circular routes (#111) Co-authored-by: Ariel Costas Guerrero Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> --- .../Controllers/VigoController.cs | 56 +++++++-- .../Types/StopSchedule.cs | 105 ++++++++-------- src/Costasdev.Busurbano.Backend/appsettings.json | 6 + src/common/stop_schedule.proto | 3 + src/frontend/app/components/NavBar.tsx | 89 ------------- src/frontend/app/components/layout/AppShell.css | 10 -- src/frontend/app/components/layout/AppShell.tsx | 4 +- .../app/components/layout/NavBar.module.css | 46 +++++++ src/frontend/app/components/layout/NavBar.tsx | 99 +++++++++++++++ src/frontend/app/root.css | 36 ------ src/frontend/app/routes/map.tsx | 2 +- src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py | 43 +++---- .../src/proto/stop_schedule_pb2.pyi | 65 +++++----- src/gtfs_vigo_stops/src/report_writer.py | 1 + src/gtfs_vigo_stops/src/trips.py | 20 ++- src/gtfs_vigo_stops/stop_report.py | 138 ++++++++++++++++++++- 16 files changed, 459 insertions(+), 264 deletions(-) delete mode 100644 src/frontend/app/components/NavBar.tsx create mode 100644 src/frontend/app/components/layout/NavBar.module.css create mode 100644 src/frontend/app/components/layout/NavBar.tsx (limited to 'src') diff --git a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs index 1f81bf1..91ccdab 100644 --- a/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs +++ b/src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs @@ -254,13 +254,13 @@ public class VigoController : ControllerBase ScheduledArrival? closestCirculation = null; // Matching strategy: - // 1) Filter trips that are not "too early" (TimeDiff <= 3). + // 1) Filter trips that are not "too early" (TimeDiff <= 7). // TimeDiff = Schedule - Realtime. - // If TimeDiff > 3, bus is > 3 mins early. Reject. + // If TimeDiff > 7, bus is > 7 mins early. Reject. // 2) From the valid trips, pick the one with smallest Abs(TimeDiff). // This handles "as late as it gets" (large negative TimeDiff) by preferring smaller delays if available, // but accepting large delays if that's the only option (and better than an invalid early trip). - const int maxEarlyArrivalMinutes = 5; + const int maxEarlyArrivalMinutes = 7; var bestMatch = possibleCirculations .Select(c => new @@ -280,7 +280,7 @@ public class VigoController : ControllerBase if (closestCirculation == null) { // No scheduled match: include realtime-only entry - _logger.LogWarning("No schedule match for realtime line {Line} towards {Route} in {Minutes} minutes", estimate.Line, estimate.Route, estimate.Minutes); + _logger.LogWarning("No schedule match for realtime line {Line} towards {Route} in {Minutes} minutes (tried matching {NormalizedRoute})", estimate.Line, estimate.Route, estimate.Minutes, NormalizeRouteName(estimate.Route)); consolidatedCirculations.Add(new ConsolidatedCirculation { Line = estimate.Line, @@ -307,15 +307,41 @@ public class VigoController : ControllerBase Position? currentPosition = null; int? stopShapeIndex = null; - // Calculate bus position only for realtime trips that have already departed - if (isRunning && !string.IsNullOrEmpty(closestCirculation.ShapeId)) + // Calculate bus position for realtime trips + if (!string.IsNullOrEmpty(closestCirculation.ShapeId)) { - var shape = await _shapeService.LoadShapeAsync(closestCirculation.ShapeId); - if (shape != null && stopLocation != null) + // Check if we are likely on the previous trip + // If the bus is further away than the distance from the start of the trip to the stop, + // it implies the bus is on the previous trip (or earlier). + double distOnPrevTrip = estimate.Meters - closestCirculation.ShapeDistTraveled; + bool usePreviousShape = !isRunning && + !string.IsNullOrEmpty(closestCirculation.PreviousTripShapeId) && + distOnPrevTrip > 0; + + if (usePreviousShape) { - var result = _shapeService.GetBusPosition(shape, stopLocation, estimate.Meters); - currentPosition = result.BusPosition; - stopShapeIndex = result.StopIndex; + var prevShape = await _shapeService.LoadShapeAsync(closestCirculation.PreviousTripShapeId); + if (prevShape != null && prevShape.Points.Count > 0) + { + // The bus is on the previous trip. + // We treat the end of the previous shape as the "stop" for the purpose of calculation. + // The distance to traverse backwards from the end of the previous shape is 'distOnPrevTrip'. + var lastPoint = prevShape.Points[prevShape.Points.Count - 1]; + var result = _shapeService.GetBusPosition(prevShape, lastPoint, (int)distOnPrevTrip); + currentPosition = result.BusPosition; + stopShapeIndex = result.StopIndex; + } + } + else + { + // Normal case: bus is on the current trip shape + var shape = await _shapeService.LoadShapeAsync(closestCirculation.ShapeId); + if (shape != null && stopLocation != null) + { + var result = _shapeService.GetBusPosition(shape, stopLocation, estimate.Meters); + currentPosition = result.BusPosition; + stopShapeIndex = result.StopIndex; + } } } @@ -423,6 +449,7 @@ public class VigoController : ControllerBase var normalized = route.Trim().ToLowerInvariant(); // Remove diacritics/accents first, then filter to alphanumeric normalized = RemoveDiacritics(normalized); + normalized = RenameCustom(normalized); return new string(normalized.Where(char.IsLetterOrDigit).ToArray()); } @@ -442,6 +469,13 @@ public class VigoController : ControllerBase return stringBuilder.ToString().Normalize(NormalizationForm.FormC); } + + private static string RenameCustom(string text) + { + // Custom replacements for known problematic route names + return text + .Replace("praza", "p"); + } } public static class StopScheduleExtensions diff --git a/src/Costasdev.Busurbano.Backend/Types/StopSchedule.cs b/src/Costasdev.Busurbano.Backend/Types/StopSchedule.cs index 25721a2..5c3e607 100644 --- a/src/Costasdev.Busurbano.Backend/Types/StopSchedule.cs +++ b/src/Costasdev.Busurbano.Backend/Types/StopSchedule.cs @@ -25,10 +25,10 @@ namespace Costasdev.Busurbano.Backend.Types { byte[] descriptorData = global::System.Convert.FromBase64String( string.Concat( "ChNzdG9wX3NjaGVkdWxlLnByb3RvEgVwcm90byIhCglFcHNnMjU4MjkSCQoB", - "eBgBIAEoARIJCgF5GAIgASgBIuMDCgxTdG9wQXJyaXZhbHMSDwoHc3RvcF9p", + "eBgBIAEoARIJCgF5GAIgASgBIoMECgxTdG9wQXJyaXZhbHMSDwoHc3RvcF9p", "ZBgBIAEoCRIiCghsb2NhdGlvbhgDIAEoCzIQLnByb3RvLkVwc2cyNTgyORI2", "CghhcnJpdmFscxgFIAMoCzIkLnByb3RvLlN0b3BBcnJpdmFscy5TY2hlZHVs", - "ZWRBcnJpdmFsGuUCChBTY2hlZHVsZWRBcnJpdmFsEhIKCnNlcnZpY2VfaWQY", + "ZWRBcnJpdmFsGoUDChBTY2hlZHVsZWRBcnJpdmFsEhIKCnNlcnZpY2VfaWQY", "ASABKAkSDwoHdHJpcF9pZBgCIAEoCRIMCgRsaW5lGAMgASgJEg0KBXJvdXRl", "GAQgASgJEhAKCHNoYXBlX2lkGAUgASgJEhsKE3NoYXBlX2Rpc3RfdHJhdmVs", "ZWQYBiABKAESFQoNc3RvcF9zZXF1ZW5jZRgLIAEoDRIUCgxuZXh0X3N0cmVl", @@ -36,14 +36,15 @@ namespace Costasdev.Busurbano.Backend.Types { "YW1lGBYgASgJEhUKDXN0YXJ0aW5nX3RpbWUYFyABKAkSFAoMY2FsbGluZ190", "aW1lGCEgASgJEhMKC2NhbGxpbmdfc3NtGCIgASgNEhUKDXRlcm1pbnVzX2Nv", "ZGUYKSABKAkSFQoNdGVybWludXNfbmFtZRgqIAEoCRIVCg10ZXJtaW51c190", - "aW1lGCsgASgJIjsKBVNoYXBlEhAKCHNoYXBlX2lkGAEgASgJEiAKBnBvaW50", - "cxgDIAMoCzIQLnByb3RvLkVwc2cyNTgyOUIkqgIhQ29zdGFzZGV2LkJ1c3Vy", - "YmFuby5CYWNrZW5kLlR5cGVzYgZwcm90bzM=")); + "aW1lGCsgASgJEh4KFnByZXZpb3VzX3RyaXBfc2hhcGVfaWQYMyABKAkiOwoF", + "U2hhcGUSEAoIc2hhcGVfaWQYASABKAkSIAoGcG9pbnRzGAMgAygLMhAucHJv", + "dG8uRXBzZzI1ODI5QiSqAiFDb3N0YXNkZXYuQnVzdXJiYW5vLkJhY2tlbmQu", + "VHlwZXNiBnByb3RvMw==")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { }, new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] { new pbr::GeneratedClrTypeInfo(typeof(global::Costasdev.Busurbano.Backend.Types.Epsg25829), global::Costasdev.Busurbano.Backend.Types.Epsg25829.Parser, new[]{ "X", "Y" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::Costasdev.Busurbano.Backend.Types.StopArrivals), global::Costasdev.Busurbano.Backend.Types.StopArrivals.Parser, new[]{ "StopId", "Location", "Arrivals" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { new pbr::GeneratedClrTypeInfo(typeof(global::Costasdev.Busurbano.Backend.Types.StopArrivals.Types.ScheduledArrival), global::Costasdev.Busurbano.Backend.Types.StopArrivals.Types.ScheduledArrival.Parser, new[]{ "ServiceId", "TripId", "Line", "Route", "ShapeId", "ShapeDistTraveled", "StopSequence", "NextStreets", "StartingCode", "StartingName", "StartingTime", "CallingTime", "CallingSsm", "TerminusCode", "TerminusName", "TerminusTime" }, null, null, null, null)}), + new pbr::GeneratedClrTypeInfo(typeof(global::Costasdev.Busurbano.Backend.Types.StopArrivals), global::Costasdev.Busurbano.Backend.Types.StopArrivals.Parser, new[]{ "StopId", "Location", "Arrivals" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { new pbr::GeneratedClrTypeInfo(typeof(global::Costasdev.Busurbano.Backend.Types.StopArrivals.Types.ScheduledArrival), global::Costasdev.Busurbano.Backend.Types.StopArrivals.Types.ScheduledArrival.Parser, new[]{ "ServiceId", "TripId", "Line", "Route", "ShapeId", "ShapeDistTraveled", "StopSequence", "NextStreets", "StartingCode", "StartingName", "StartingTime", "CallingTime", "CallingSsm", "TerminusCode", "TerminusName", "TerminusTime", "PreviousTripShapeId" }, null, null, null, null)}), new pbr::GeneratedClrTypeInfo(typeof(global::Costasdev.Busurbano.Backend.Types.Shape), global::Costasdev.Busurbano.Backend.Types.Shape.Parser, new[]{ "ShapeId", "Points" }, null, null, null, null) })); } @@ -51,7 +52,6 @@ namespace Costasdev.Busurbano.Backend.Types { } #region Messages - [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class Epsg25829 : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage @@ -236,11 +236,7 @@ namespace Costasdev.Busurbano.Backend.Types { #else uint tag; while ((tag = input.ReadTag()) != 0) { - if ((tag & 7) == 4) { - // Abort on any end group tag. - return; - } - switch(tag) { + switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; @@ -263,11 +259,7 @@ namespace Costasdev.Busurbano.Backend.Types { void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { uint tag; while ((tag = input.ReadTag()) != 0) { - if ((tag & 7) == 4) { - // Abort on any end group tag. - return; - } - switch(tag) { + switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; @@ -286,7 +278,6 @@ namespace Costasdev.Busurbano.Backend.Types { } - [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class StopArrivals : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage @@ -492,11 +483,7 @@ namespace Costasdev.Busurbano.Backend.Types { #else uint tag; while ((tag = input.ReadTag()) != 0) { - if ((tag & 7) == 4) { - // Abort on any end group tag. - return; - } - switch(tag) { + switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; @@ -526,11 +513,7 @@ namespace Costasdev.Busurbano.Backend.Types { void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { uint tag; while ((tag = input.ReadTag()) != 0) { - if ((tag & 7) == 4) { - // Abort on any end group tag. - return; - } - switch(tag) { + switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; @@ -559,7 +542,6 @@ namespace Costasdev.Busurbano.Backend.Types { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static partial class Types { - [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class ScheduledArrival : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage @@ -610,6 +592,7 @@ namespace Costasdev.Busurbano.Backend.Types { terminusCode_ = other.terminusCode_; terminusName_ = other.terminusName_; terminusTime_ = other.terminusTime_; + previousTripShapeId_ = other.previousTripShapeId_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -810,6 +793,21 @@ namespace Costasdev.Busurbano.Backend.Types { } } + /// Field number for the "previous_trip_shape_id" field. + public const int PreviousTripShapeIdFieldNumber = 51; + private string previousTripShapeId_ = ""; + /// + /// Shape ID of the previous trip when the bus comes from another trip that ends at the starting point + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string PreviousTripShapeId { + get { return previousTripShapeId_; } + set { + previousTripShapeId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -841,6 +839,7 @@ namespace Costasdev.Busurbano.Backend.Types { if (TerminusCode != other.TerminusCode) return false; if (TerminusName != other.TerminusName) return false; if (TerminusTime != other.TerminusTime) return false; + if (PreviousTripShapeId != other.PreviousTripShapeId) return false; return Equals(_unknownFields, other._unknownFields); } @@ -864,6 +863,7 @@ namespace Costasdev.Busurbano.Backend.Types { if (TerminusCode.Length != 0) hash ^= TerminusCode.GetHashCode(); if (TerminusName.Length != 0) hash ^= TerminusName.GetHashCode(); if (TerminusTime.Length != 0) hash ^= TerminusTime.GetHashCode(); + if (PreviousTripShapeId.Length != 0) hash ^= PreviousTripShapeId.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -943,6 +943,10 @@ namespace Costasdev.Busurbano.Backend.Types { output.WriteRawTag(218, 2); output.WriteString(TerminusTime); } + if (PreviousTripShapeId.Length != 0) { + output.WriteRawTag(154, 3); + output.WriteString(PreviousTripShapeId); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -1014,6 +1018,10 @@ namespace Costasdev.Busurbano.Backend.Types { output.WriteRawTag(218, 2); output.WriteString(TerminusTime); } + if (PreviousTripShapeId.Length != 0) { + output.WriteRawTag(154, 3); + output.WriteString(PreviousTripShapeId); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -1070,6 +1078,9 @@ namespace Costasdev.Busurbano.Backend.Types { if (TerminusTime.Length != 0) { size += 2 + pb::CodedOutputStream.ComputeStringSize(TerminusTime); } + if (PreviousTripShapeId.Length != 0) { + size += 2 + pb::CodedOutputStream.ComputeStringSize(PreviousTripShapeId); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -1128,6 +1139,9 @@ namespace Costasdev.Busurbano.Backend.Types { if (other.TerminusTime.Length != 0) { TerminusTime = other.TerminusTime; } + if (other.PreviousTripShapeId.Length != 0) { + PreviousTripShapeId = other.PreviousTripShapeId; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -1139,11 +1153,7 @@ namespace Costasdev.Busurbano.Backend.Types { #else uint tag; while ((tag = input.ReadTag()) != 0) { - if ((tag & 7) == 4) { - // Abort on any end group tag. - return; - } - switch(tag) { + switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; @@ -1211,6 +1221,10 @@ namespace Costasdev.Busurbano.Backend.Types { TerminusTime = input.ReadString(); break; } + case 410: { + PreviousTripShapeId = input.ReadString(); + break; + } } } #endif @@ -1222,11 +1236,7 @@ namespace Costasdev.Busurbano.Backend.Types { void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { uint tag; while ((tag = input.ReadTag()) != 0) { - if ((tag & 7) == 4) { - // Abort on any end group tag. - return; - } - switch(tag) { + switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; @@ -1294,6 +1304,10 @@ namespace Costasdev.Busurbano.Backend.Types { TerminusTime = input.ReadString(); break; } + case 410: { + PreviousTripShapeId = input.ReadString(); + break; + } } } } @@ -1306,7 +1320,6 @@ namespace Costasdev.Busurbano.Backend.Types { } - [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class Shape : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage @@ -1480,11 +1493,7 @@ namespace Costasdev.Busurbano.Backend.Types { #else uint tag; while ((tag = input.ReadTag()) != 0) { - if ((tag & 7) == 4) { - // Abort on any end group tag. - return; - } - switch(tag) { + switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); break; @@ -1507,11 +1516,7 @@ namespace Costasdev.Busurbano.Backend.Types { void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { uint tag; while ((tag = input.ReadTag()) != 0) { - if ((tag & 7) == 4) { - // Abort on any end group tag. - return; - } - switch(tag) { + switch(tag) { default: _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); break; diff --git a/src/Costasdev.Busurbano.Backend/appsettings.json b/src/Costasdev.Busurbano.Backend/appsettings.json index 49830b4..d09e564 100644 --- a/src/Costasdev.Busurbano.Backend/appsettings.json +++ b/src/Costasdev.Busurbano.Backend/appsettings.json @@ -4,6 +4,12 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning", "System.Net.Http.HttpClient": "Warning" + }, + "Console": { + "FormatterName": "simple", + "FormatterOptions": { + "SingleLine": true + } } }, "AllowedHosts": "*" diff --git a/src/common/stop_schedule.proto b/src/common/stop_schedule.proto index 40d5d30..74268f8 100644 --- a/src/common/stop_schedule.proto +++ b/src/common/stop_schedule.proto @@ -31,6 +31,9 @@ message StopArrivals { string terminus_code = 41; string terminus_name = 42; string terminus_time = 43; + + // Shape ID of the previous trip when the bus comes from another trip that ends at the starting point + string previous_trip_shape_id = 51; } string stop_id = 1; diff --git a/src/frontend/app/components/NavBar.tsx b/src/frontend/app/components/NavBar.tsx deleted file mode 100644 index b8c6ad6..0000000 --- a/src/frontend/app/components/NavBar.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Link } from "react-router"; -import { Map, MapPin, Settings } from "lucide-react"; -import { useApp } from "../AppContext"; -import type { LngLatLike } from "maplibre-gl"; -import { useTranslation } from "react-i18next"; - -// Helper: check if coordinates are within Vigo bounds -function isWithinVigo(lngLat: LngLatLike): boolean { - let lng: number, lat: number; - if (Array.isArray(lngLat)) { - [lng, lat] = lngLat; - } else if ("lng" in lngLat && "lat" in lngLat) { - lng = lngLat.lng; - lat = lngLat.lat; - } else { - return false; - } - // Rough bounding box for Vigo - return lat >= 42.18 && lat <= 42.3 && lng >= -8.78 && lng <= -8.65; -} - -export default function NavBar() { - const { t } = useTranslation(); - const { mapState, updateMapState, mapPositionMode } = useApp(); - - const navItems = [ - { - name: t("navbar.stops", "Paradas"), - icon: MapPin, - path: "/", - exact: true, - }, - { - name: t("navbar.map", "Mapa"), - icon: Map, - path: "/map", - callback: () => { - if (mapPositionMode !== "gps") { - return; - } - - if (!("geolocation" in navigator)) { - return; - } - - navigator.geolocation.getCurrentPosition( - (position) => { - const { latitude, longitude } = position.coords; - const coords: LngLatLike = [latitude, longitude]; - if (isWithinVigo(coords)) { - updateMapState(coords, 16); - } - }, - () => {} - ); - }, - }, - { - name: t("navbar.settings", "Ajustes"), - icon: Settings, - path: "/settings", - }, - ]; - - return ( - - ); -} diff --git a/src/frontend/app/components/layout/AppShell.css b/src/frontend/app/components/layout/AppShell.css index 89a4d1f..eee678c 100644 --- a/src/frontend/app/components/layout/AppShell.css +++ b/src/frontend/app/components/layout/AppShell.css @@ -50,14 +50,4 @@ .app-shell__bottom-nav { display: none; } - - /* Override NavBar styles for sidebar */ - .app-shell__sidebar .navigation-bar { - flex-direction: column; - height: 100%; - justify-content: flex-start; - padding-top: 1rem; - gap: 1rem; - border-top: none; - } } diff --git a/src/frontend/app/components/layout/AppShell.tsx b/src/frontend/app/components/layout/AppShell.tsx index e0559ac..91f6c0d 100644 --- a/src/frontend/app/components/layout/AppShell.tsx +++ b/src/frontend/app/components/layout/AppShell.tsx @@ -1,11 +1,11 @@ import React, { useState } from "react"; import { Outlet } from "react-router"; import { PageTitleProvider, usePageTitleContext } from "~/contexts/PageTitleContext"; -import NavBar from "../NavBar"; import { ThemeColorManager } from "../ThemeColorManager"; import "./AppShell.css"; import { Drawer } from "./Drawer"; import { Header } from "./Header"; +import NavBar from "./NavBar"; const AppShellContent: React.FC = () => { const { title } = usePageTitleContext(); @@ -22,7 +22,7 @@ const AppShellContent: React.FC = () => { setIsDrawerOpen(false)} />
diff --git a/src/frontend/app/components/layout/NavBar.module.css b/src/frontend/app/components/layout/NavBar.module.css new file mode 100644 index 0000000..504b93b --- /dev/null +++ b/src/frontend/app/components/layout/NavBar.module.css @@ -0,0 +1,46 @@ +.navBar { + display: flex; + justify-content: space-around; + align-items: center; + padding: 0.5rem 0; + + background-color: var(--background-color); + border-top: 1px solid var(--border-color); +} + +.vertical { + flex-direction: column; + height: 100%; + justify-content: flex-start; + padding-top: 1rem; + gap: 1rem; + border-top: none; +} + +.link { + flex: 1 0; + display: flex; + flex-direction: column; + align-items: center; + text-decoration: none; + color: var(--text-color); + padding: 0.25rem 0; + border-radius: 0.5rem; +} + +.link svg { + width: 1.375rem; + height: 1.375rem; + fill: none; + stroke-width: 2; +} + +.link span { + font-size: 13px; + line-height: 1; + font-family: system-ui; +} + +.active { + color: var(--button-background-color); +} diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx new file mode 100644 index 0000000..91c8810 --- /dev/null +++ b/src/frontend/app/components/layout/NavBar.tsx @@ -0,0 +1,99 @@ +import { Map, MapPin, Settings } from "lucide-react"; +import type { LngLatLike } from "maplibre-gl"; +import { useTranslation } from "react-i18next"; +import { Link, useLocation } from "react-router"; +import { useApp } from "../../AppContext"; +import styles from "./NavBar.module.css"; + +// Helper: check if coordinates are within Vigo bounds +function isWithinVigo(lngLat: LngLatLike): boolean { + let lng: number, lat: number; + if (Array.isArray(lngLat)) { + [lng, lat] = lngLat; + } else if ("lng" in lngLat && "lat" in lngLat) { + lng = lngLat.lng; + lat = lngLat.lat; + } else { + return false; + } + // Rough bounding box for Vigo + return lat >= 42.18 && lat <= 42.3 && lng >= -8.78 && lng <= -8.65; +} + +interface NavBarProps { + orientation?: "horizontal" | "vertical"; +} + +export default function NavBar({ orientation = "horizontal" }: NavBarProps) { + const { t } = useTranslation(); + const { mapState, updateMapState, mapPositionMode } = useApp(); + const location = useLocation(); + + const navItems = [ + { + name: t("navbar.stops", "Paradas"), + icon: MapPin, + path: "/", + exact: true, + }, + { + name: t("navbar.map", "Mapa"), + icon: Map, + path: "/map", + callback: () => { + if (mapPositionMode !== "gps") { + return; + } + + if (!("geolocation" in navigator)) { + return; + } + + navigator.geolocation.getCurrentPosition( + (position) => { + const { latitude, longitude } = position.coords; + const coords: LngLatLike = [latitude, longitude]; + if (isWithinVigo(coords)) { + updateMapState(coords, 16); + } + }, + () => {} + ); + }, + }, + { + name: t("navbar.settings", "Ajustes"), + icon: Settings, + path: "/settings", + }, + ]; + + return ( + + ); +} diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css index f87fdc3..d718d92 100644 --- a/src/frontend/app/root.css +++ b/src/frontend/app/root.css @@ -122,43 +122,7 @@ body { overflow-x: hidden; } -.navigation-bar { - display: flex; - justify-content: space-around; - align-items: center; - padding: 0.5rem 0; - - background-color: var(--background-color); - border-top: 1px solid var(--border-color); -} -.navigation-bar__link { - flex: 1 0; - display: flex; - flex-direction: column; - align-items: center; - text-decoration: none; - color: var(--text-color); - padding: 0.25rem 0; - border-radius: 0.5rem; -} - -.navigation-bar__link svg { - width: 1.375rem; - height: 1.375rem; - fill: none; - stroke-width: 2; -} - -.navigation-bar__link span { - font-size: 13px; - line-height: 1; - font-family: system-ui; -} - -.navigation-bar__link.active { - color: var(--button-background-color); -} .theme-toggle { background: none; diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 57fb04e..a711a64 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -170,7 +170,7 @@ export default function StopMap() { } > - + None: ... +class Shape(_message.Message): + __slots__ = ["points", "shape_id"] + POINTS_FIELD_NUMBER: _ClassVar[int] + SHAPE_ID_FIELD_NUMBER: _ClassVar[int] + points: _containers.RepeatedCompositeFieldContainer[Epsg25829] + shape_id: str + def __init__(self, shape_id: _Optional[str] = ..., points: _Optional[_Iterable[_Union[Epsg25829, _Mapping]]] = ...) -> None: ... + class StopArrivals(_message.Message): - __slots__ = () + __slots__ = ["arrivals", "location", "stop_id"] class ScheduledArrival(_message.Message): - __slots__ = () - SERVICE_ID_FIELD_NUMBER: _ClassVar[int] - TRIP_ID_FIELD_NUMBER: _ClassVar[int] + __slots__ = ["calling_ssm", "calling_time", "line", "next_streets", "previous_trip_shape_id", "route", "service_id", "shape_dist_traveled", "shape_id", "starting_code", "starting_name", "starting_time", "stop_sequence", "terminus_code", "terminus_name", "terminus_time", "trip_id"] + CALLING_SSM_FIELD_NUMBER: _ClassVar[int] + CALLING_TIME_FIELD_NUMBER: _ClassVar[int] LINE_FIELD_NUMBER: _ClassVar[int] + NEXT_STREETS_FIELD_NUMBER: _ClassVar[int] + PREVIOUS_TRIP_SHAPE_ID_FIELD_NUMBER: _ClassVar[int] ROUTE_FIELD_NUMBER: _ClassVar[int] - SHAPE_ID_FIELD_NUMBER: _ClassVar[int] + SERVICE_ID_FIELD_NUMBER: _ClassVar[int] SHAPE_DIST_TRAVELED_FIELD_NUMBER: _ClassVar[int] - STOP_SEQUENCE_FIELD_NUMBER: _ClassVar[int] - NEXT_STREETS_FIELD_NUMBER: _ClassVar[int] + SHAPE_ID_FIELD_NUMBER: _ClassVar[int] STARTING_CODE_FIELD_NUMBER: _ClassVar[int] STARTING_NAME_FIELD_NUMBER: _ClassVar[int] STARTING_TIME_FIELD_NUMBER: _ClassVar[int] - CALLING_TIME_FIELD_NUMBER: _ClassVar[int] - CALLING_SSM_FIELD_NUMBER: _ClassVar[int] + STOP_SEQUENCE_FIELD_NUMBER: _ClassVar[int] TERMINUS_CODE_FIELD_NUMBER: _ClassVar[int] TERMINUS_NAME_FIELD_NUMBER: _ClassVar[int] TERMINUS_TIME_FIELD_NUMBER: _ClassVar[int] - service_id: str - trip_id: str + TRIP_ID_FIELD_NUMBER: _ClassVar[int] + calling_ssm: int + calling_time: str line: str + next_streets: _containers.RepeatedScalarFieldContainer[str] + previous_trip_shape_id: str route: str - shape_id: str + service_id: str shape_dist_traveled: float - stop_sequence: int - next_streets: _containers.RepeatedScalarFieldContainer[str] + shape_id: str starting_code: str starting_name: str starting_time: str - calling_time: str - calling_ssm: int + stop_sequence: int terminus_code: str terminus_name: str terminus_time: str - def __init__(self, service_id: _Optional[str] = ..., trip_id: _Optional[str] = ..., line: _Optional[str] = ..., route: _Optional[str] = ..., shape_id: _Optional[str] = ..., shape_dist_traveled: _Optional[float] = ..., stop_sequence: _Optional[int] = ..., next_streets: _Optional[_Iterable[str]] = ..., starting_code: _Optional[str] = ..., starting_name: _Optional[str] = ..., starting_time: _Optional[str] = ..., calling_time: _Optional[str] = ..., calling_ssm: _Optional[int] = ..., terminus_code: _Optional[str] = ..., terminus_name: _Optional[str] = ..., terminus_time: _Optional[str] = ...) -> None: ... - STOP_ID_FIELD_NUMBER: _ClassVar[int] - LOCATION_FIELD_NUMBER: _ClassVar[int] + trip_id: str + def __init__(self, service_id: _Optional[str] = ..., trip_id: _Optional[str] = ..., line: _Optional[str] = ..., route: _Optional[str] = ..., shape_id: _Optional[str] = ..., shape_dist_traveled: _Optional[float] = ..., stop_sequence: _Optional[int] = ..., next_streets: _Optional[_Iterable[str]] = ..., starting_code: _Optional[str] = ..., starting_name: _Optional[str] = ..., starting_time: _Optional[str] = ..., calling_time: _Optional[str] = ..., calling_ssm: _Optional[int] = ..., terminus_code: _Optional[str] = ..., terminus_name: _Optional[str] = ..., terminus_time: _Optional[str] = ..., previous_trip_shape_id: _Optional[str] = ...) -> None: ... ARRIVALS_FIELD_NUMBER: _ClassVar[int] - stop_id: str - location: Epsg25829 + LOCATION_FIELD_NUMBER: _ClassVar[int] + STOP_ID_FIELD_NUMBER: _ClassVar[int] arrivals: _containers.RepeatedCompositeFieldContainer[StopArrivals.ScheduledArrival] + location: Epsg25829 + stop_id: str def __init__(self, stop_id: _Optional[str] = ..., location: _Optional[_Union[Epsg25829, _Mapping]] = ..., arrivals: _Optional[_Iterable[_Union[StopArrivals.ScheduledArrival, _Mapping]]] = ...) -> None: ... - -class Shape(_message.Message): - __slots__ = () - SHAPE_ID_FIELD_NUMBER: _ClassVar[int] - POINTS_FIELD_NUMBER: _ClassVar[int] - shape_id: str - points: _containers.RepeatedCompositeFieldContainer[Epsg25829] - def __init__(self, shape_id: _Optional[str] = ..., points: _Optional[_Iterable[_Union[Epsg25829, _Mapping]]] = ...) -> None: ... diff --git a/src/gtfs_vigo_stops/src/report_writer.py b/src/gtfs_vigo_stops/src/report_writer.py index 695931f..f6d8763 100644 --- a/src/gtfs_vigo_stops/src/report_writer.py +++ b/src/gtfs_vigo_stops/src/report_writer.py @@ -51,6 +51,7 @@ def write_stop_protobuf( terminus_code=arrival["terminus_code"], terminus_name=arrival["terminus_name"], terminus_time=arrival["terminus_time"], + previous_trip_shape_id=arrival.get("previous_trip_shape_id", ""), ) for arrival in arrivals ], diff --git a/src/gtfs_vigo_stops/src/trips.py b/src/gtfs_vigo_stops/src/trips.py index 0c1375c..0cedd26 100644 --- a/src/gtfs_vigo_stops/src/trips.py +++ b/src/gtfs_vigo_stops/src/trips.py @@ -10,18 +10,19 @@ class TripLine: """ Class representing a trip line in the GTFS data. """ - def __init__(self, route_id: str, service_id: str, trip_id: str, headsign: str, direction_id: int, shape_id: str|None = None): + def __init__(self, route_id: str, service_id: str, trip_id: str, headsign: str, direction_id: int, shape_id: str|None = None, block_id: str|None = None): self.route_id = route_id self.service_id = service_id self.trip_id = trip_id self.headsign = headsign self.direction_id = direction_id self.shape_id = shape_id + self.block_id = block_id self.route_short_name = "" self.route_color = "" def __str__(self): - return f"TripLine({self.route_id=}, {self.service_id=}, {self.trip_id=}, {self.headsign=}, {self.direction_id=}, {self.shape_id=})" + return f"TripLine({self.route_id=}, {self.service_id=}, {self.trip_id=}, {self.headsign=}, {self.direction_id=}, {self.shape_id=}, {self.block_id=})" TRIPS_BY_SERVICE_ID: dict[str, dict[str, list[TripLine]]] = {} @@ -74,6 +75,13 @@ def get_trips_for_services(feed_dir: str, service_ids: list[str]) -> dict[str, l else: logger.warning("shape_id column not found in trips.txt") + # Check if block_id column exists + block_id_index = None + if 'block_id' in header: + block_id_index = header.index('block_id') + else: + logger.info("block_id column not found in trips.txt") + # Initialize cache for this feed directory TRIPS_BY_SERVICE_ID[feed_dir] = {} @@ -96,6 +104,11 @@ def get_trips_for_services(feed_dir: str, service_ids: list[str]) -> dict[str, l if shape_id_index is not None and shape_id_index < len(parts): shape_id = parts[shape_id_index] if parts[shape_id_index] else None + # Get block_id if available + block_id = None + if block_id_index is not None and block_id_index < len(parts): + block_id = parts[block_id_index] if parts[block_id_index] else None + trip_line = TripLine( route_id=parts[route_id_index], service_id=service_id, @@ -103,7 +116,8 @@ def get_trips_for_services(feed_dir: str, service_ids: list[str]) -> dict[str, l headsign=parts[headsign_index], direction_id=int( parts[direction_id_index] if parts[direction_id_index] else -1), - shape_id=shape_id + shape_id=shape_id, + block_id=block_id ) TRIPS_BY_SERVICE_ID[feed_dir][service_id].append(trip_line) diff --git a/src/gtfs_vigo_stops/stop_report.py b/src/gtfs_vigo_stops/stop_report.py index a827eaa..cee11ea 100644 --- a/src/gtfs_vigo_stops/stop_report.py +++ b/src/gtfs_vigo_stops/stop_report.py @@ -4,7 +4,7 @@ import shutil import sys import time import traceback -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional, Tuple from src.shapes import process_shapes from src.common import get_all_feed_dates @@ -13,10 +13,10 @@ from src.logger import get_logger from src.report_writer import write_stop_json, write_stop_protobuf from src.routes import load_routes from src.services import get_active_services -from src.stop_times import get_stops_for_trips +from src.stop_times import get_stops_for_trips, StopTime from src.stops import get_all_stops, get_all_stops_by_code, get_numeric_code from src.street_name import get_street_name, normalise_stop_name -from src.trips import get_trips_for_services +from src.trips import get_trips_for_services, TripLine logger = get_logger("stop_report") @@ -143,6 +143,130 @@ def is_next_day_service(time_str: str) -> bool: return False +def parse_trip_id_components(trip_id: str) -> Optional[Tuple[str, str, int]]: + """ + Parse a trip ID in format XXXYYY-Z or XXXYYY_Z where: + - XXX = line number (e.g., 003) + - YYY = shift/internal ID (e.g., 001) + - Z = trip number (e.g., 12) + + Supported formats: + 1. ..._XXXYYY_Z (e.g. "C1 01SNA00_001001_18") + 2. ..._XXXYYY-Z (e.g. "VIGO_20241122_003001-12") + + Returns tuple of (line, shift_id, trip_number) or None if parsing fails. + """ + try: + parts = trip_id.split("_") + if len(parts) < 2: + return None + + # Try format 1: ..._XXXYYY_Z + # Check if second to last part is 6 digits (XXXYYY) and last part is numeric + if len(parts) >= 2: + shift_part = parts[-2] + trip_num_str = parts[-1] + + if len(shift_part) == 6 and shift_part.isdigit() and trip_num_str.isdigit(): + line = shift_part[:3] + shift_id = shift_part[3:6] + trip_number = int(trip_num_str) + return (line, shift_id, trip_number) + + # Try format 2: ..._XXXYYY-Z + # The trip ID is the last part in format XXXYYY-Z + trip_part = parts[-1] + + if "-" in trip_part: + shift_part, trip_num_str = trip_part.split("-", 1) + + # shift_part should be 6 digits: XXXYYY + if len(shift_part) == 6 and shift_part.isdigit(): + line = shift_part[:3] # First 3 digits + shift_id = shift_part[3:6] # Next 3 digits + trip_number = int(trip_num_str) + return (line, shift_id, trip_number) + + return None + except (ValueError, IndexError): + return None + + +def build_trip_previous_shape_map( + trips: Dict[str, List[TripLine]], + stops_for_all_trips: Dict[str, List[StopTime]], +) -> Dict[str, Optional[str]]: + """ + Build a mapping from trip_id to previous_trip_shape_id. + + Links trips based on trip ID structure (XXXYYY-Z) where trips with the same + XXX (line) and YYY (shift) and sequential Z (trip numbers) are connected + if the terminus of trip N matches the start of trip N+1. + + Args: + trips: Dictionary of service_id -> list of trips + stops_for_all_trips: Dictionary of trip_id -> list of stop times + + Returns: + Dictionary mapping trip_id to previous_trip_shape_id (or None) + """ + trip_previous_shape: Dict[str, Optional[str]] = {} + + # Collect all trips across all services + all_trips_list: List[TripLine] = [] + for trip_list in trips.values(): + all_trips_list.extend(trip_list) + + # Group trips by shift ID (line + shift combination) + trips_by_shift: Dict[str, List[Tuple[TripLine, int, str, str]]] = {} + + for trip in all_trips_list: + parsed = parse_trip_id_components(trip.trip_id) + if not parsed: + continue + + line, shift_id, trip_number = parsed + shift_key = f"{line}{shift_id}" + + trip_stops = stops_for_all_trips.get(trip.trip_id) + if not trip_stops or len(trip_stops) < 2: + continue + + first_stop = trip_stops[0] + last_stop = trip_stops[-1] + + if shift_key not in trips_by_shift: + trips_by_shift[shift_key] = [] + + trips_by_shift[shift_key].append(( + trip, + trip_number, + first_stop.stop_id, + last_stop.stop_id + )) + + # For each shift, sort trips by trip number and link consecutive trips + for shift_key, shift_trips in trips_by_shift.items(): + # Sort by trip number + shift_trips.sort(key=lambda x: x[1]) + + # Link consecutive trips if their stops match + for i in range(1, len(shift_trips)): + current_trip, current_num, current_start_stop, _ = shift_trips[i] + prev_trip, prev_num, _, prev_end_stop = shift_trips[i - 1] + + # Check if trips are consecutive (trip numbers differ by 1), + # if previous trip's terminus matches current trip's start, + # and if both trips have valid shape IDs + if (current_num == prev_num + 1 and + prev_end_stop == current_start_stop and + prev_trip.shape_id and + current_trip.shape_id): + trip_previous_shape[current_trip.trip_id] = prev_trip.shape_id + + return trip_previous_shape + + def get_stop_arrivals(feed_dir: str, date: str) -> Dict[str, List[Dict[str, Any]]]: """ Process trips for the given date and organize stop arrivals. @@ -192,6 +316,10 @@ def get_stop_arrivals(feed_dir: str, date: str) -> Dict[str, List[Dict[str, Any] stops_for_all_trips = get_stops_for_trips(feed_dir, all_trip_ids) logger.info(f"Precomputed stops for {len(stops_for_all_trips)} trips.") + # Build mapping from trip_id to previous trip's shape_id + trip_previous_shape_map = build_trip_previous_shape_map(trips, stops_for_all_trips) + logger.info(f"Built previous trip shape mapping for {len(trip_previous_shape_map)} trips.") + # Load routes information routes = load_routes(feed_dir) logger.info(f"Loaded {len(routes)} routes from feed.") @@ -335,6 +463,9 @@ def get_stop_arrivals(feed_dir: str, date: str) -> Dict[str, List[Dict[str, Any] next_streets = [] trip_id_fmt = "_".join(trip_id.split("_")[1:3]) + + # Get previous trip shape_id if available + previous_trip_shape_id = trip_previous_shape_map.get(trip_id, "") stop_arrivals[stop_code].append( { @@ -356,6 +487,7 @@ def get_stop_arrivals(feed_dir: str, date: str) -> Dict[str, List[Dict[str, Any] "terminus_code": terminus_code, "terminus_name": terminus_name, "terminus_time": final_terminus_time, + "previous_trip_shape_id": previous_trip_shape_id, } ) -- cgit v1.3