diff options
| author | Copilot <198982749+Copilot@users.noreply.github.com> | 2025-11-22 18:02:36 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-11-22 18:02:36 +0100 |
| commit | b96273b54a9b47c79e0afe40a918f751e82097ae (patch) | |
| tree | ae2990aac150d880df0307124807560cc4593038 | |
| parent | 738cd874fa68cde13dbe6c3f12738abec8e3a8d2 (diff) | |
Link previous trip shapes for GPS positioning on circular routes (#111)
Co-authored-by: Ariel Costas Guerrero <ariel@costas.dev>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com>
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs | 56 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/Types/StopSchedule.cs | 105 | ||||
| -rw-r--r-- | src/Costasdev.Busurbano.Backend/appsettings.json | 6 | ||||
| -rw-r--r-- | src/common/stop_schedule.proto | 3 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/AppShell.css | 10 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/AppShell.tsx | 4 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/NavBar.module.css | 46 | ||||
| -rw-r--r-- | src/frontend/app/components/layout/NavBar.tsx (renamed from src/frontend/app/components/NavBar.tsx) | 20 | ||||
| -rw-r--r-- | src/frontend/app/root.css | 36 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 2 | ||||
| -rw-r--r-- | src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py | 43 | ||||
| -rw-r--r-- | src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.pyi | 65 | ||||
| -rw-r--r-- | src/gtfs_vigo_stops/src/report_writer.py | 1 | ||||
| -rw-r--r-- | src/gtfs_vigo_stops/src/trips.py | 20 | ||||
| -rw-r--r-- | src/gtfs_vigo_stops/stop_report.py | 138 |
15 files changed, 375 insertions, 180 deletions
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 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 { - var result = _shapeService.GetBusPosition(shape, stopLocation, estimate.Meters); - currentPosition = result.BusPosition; - stopShapeIndex = result.StopIndex; + // 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<Epsg25829> #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<StopArrivals> #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<ScheduledArrival> #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 { } } + /// <summary>Field number for the "previous_trip_shape_id" field.</summary> + public const int PreviousTripShapeIdFieldNumber = 51; + private string previousTripShapeId_ = ""; + /// <summary> + /// Shape ID of the previous trip when the bus comes from another trip that ends at the starting point + /// </summary> + [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<Shape> #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/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 = () => { <Drawer isOpen={isDrawerOpen} onClose={() => setIsDrawerOpen(false)} /> <div className="app-shell__body"> <aside className="app-shell__sidebar"> - <NavBar /> + <NavBar orientation="vertical" /> </aside> <main className="app-shell__main"> <Outlet /> 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/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx index b8c6ad6..91c8810 100644 --- a/src/frontend/app/components/NavBar.tsx +++ b/src/frontend/app/components/layout/NavBar.tsx @@ -1,8 +1,9 @@ -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"; +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 { @@ -19,9 +20,14 @@ function isWithinVigo(lngLat: LngLatLike): boolean { return lat >= 42.18 && lat <= 42.3 && lng >= -8.78 && lng <= -8.65; } -export default function NavBar() { +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 = [ { @@ -63,7 +69,11 @@ export default function NavBar() { ]; return ( - <nav className="navigation-bar"> + <nav + className={`${styles.navBar} ${ + orientation === "vertical" ? styles.vertical : "" + }`} + > {navItems.map((item) => { const Icon = item.icon; const isActive = item.exact @@ -74,7 +84,7 @@ export default function NavBar() { <Link key={item.name} to={item.path} - className={`navigation-bar__link ${isActive ? "active" : ""}`} + className={`${styles.link} ${isActive ? styles.active : ""}`} onClick={item.callback ? item.callback : undefined} title={item.name} aria-label={item.name} 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() { } > <NavigationControl position="top-right" /> - <GeolocateControl position="top-right" trackUserLocation={true} /> + <GeolocateControl position="top-right" trackUserLocation={true} positionOptions={{enableHighAccuracy: false}} /> <Source id="stops-source" diff --git a/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py b/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py index d9f8e52..cb4f336 100644 --- a/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py +++ b/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py @@ -1,22 +1,11 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! -# NO CHECKED-IN PROTOBUF GENCODE # source: stop_schedule.proto -# Protobuf Python Version: 6.33.0 """Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import runtime_version as _runtime_version from google.protobuf import symbol_database as _symbol_database -from google.protobuf.internal import builder as _builder -_runtime_version.ValidateProtobufRuntimeVersion( - _runtime_version.Domain.PUBLIC, - 6, - 33, - 0, - '', - 'stop_schedule.proto' -) # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() @@ -24,20 +13,20 @@ _sym_db = _symbol_database.Default() -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13stop_schedule.proto\x12\x05proto\"!\n\tEpsg25829\x12\t\n\x01x\x18\x01 \x01(\x01\x12\t\n\x01y\x18\x02 \x01(\x01\"\xe3\x03\n\x0cStopArrivals\x12\x0f\n\x07stop_id\x18\x01 \x01(\t\x12\"\n\x08location\x18\x03 \x01(\x0b\x32\x10.proto.Epsg25829\x12\x36\n\x08\x61rrivals\x18\x05 \x03(\x0b\x32$.proto.StopArrivals.ScheduledArrival\x1a\xe5\x02\n\x10ScheduledArrival\x12\x12\n\nservice_id\x18\x01 \x01(\t\x12\x0f\n\x07trip_id\x18\x02 \x01(\t\x12\x0c\n\x04line\x18\x03 \x01(\t\x12\r\n\x05route\x18\x04 \x01(\t\x12\x10\n\x08shape_id\x18\x05 \x01(\t\x12\x1b\n\x13shape_dist_traveled\x18\x06 \x01(\x01\x12\x15\n\rstop_sequence\x18\x0b \x01(\r\x12\x14\n\x0cnext_streets\x18\x0c \x03(\t\x12\x15\n\rstarting_code\x18\x15 \x01(\t\x12\x15\n\rstarting_name\x18\x16 \x01(\t\x12\x15\n\rstarting_time\x18\x17 \x01(\t\x12\x14\n\x0c\x63\x61lling_time\x18! \x01(\t\x12\x13\n\x0b\x63\x61lling_ssm\x18\" \x01(\r\x12\x15\n\rterminus_code\x18) \x01(\t\x12\x15\n\rterminus_name\x18* \x01(\t\x12\x15\n\rterminus_time\x18+ \x01(\t\";\n\x05Shape\x12\x10\n\x08shape_id\x18\x01 \x01(\t\x12 \n\x06points\x18\x03 \x03(\x0b\x32\x10.proto.Epsg25829B$\xaa\x02!Costasdev.Busurbano.Backend.Typesb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13stop_schedule.proto\x12\x05proto\"!\n\tEpsg25829\x12\t\n\x01x\x18\x01 \x01(\x01\x12\t\n\x01y\x18\x02 \x01(\x01\"\x83\x04\n\x0cStopArrivals\x12\x0f\n\x07stop_id\x18\x01 \x01(\t\x12\"\n\x08location\x18\x03 \x01(\x0b\x32\x10.proto.Epsg25829\x12\x36\n\x08\x61rrivals\x18\x05 \x03(\x0b\x32$.proto.StopArrivals.ScheduledArrival\x1a\x85\x03\n\x10ScheduledArrival\x12\x12\n\nservice_id\x18\x01 \x01(\t\x12\x0f\n\x07trip_id\x18\x02 \x01(\t\x12\x0c\n\x04line\x18\x03 \x01(\t\x12\r\n\x05route\x18\x04 \x01(\t\x12\x10\n\x08shape_id\x18\x05 \x01(\t\x12\x1b\n\x13shape_dist_traveled\x18\x06 \x01(\x01\x12\x15\n\rstop_sequence\x18\x0b \x01(\r\x12\x14\n\x0cnext_streets\x18\x0c \x03(\t\x12\x15\n\rstarting_code\x18\x15 \x01(\t\x12\x15\n\rstarting_name\x18\x16 \x01(\t\x12\x15\n\rstarting_time\x18\x17 \x01(\t\x12\x14\n\x0c\x63\x61lling_time\x18! \x01(\t\x12\x13\n\x0b\x63\x61lling_ssm\x18\" \x01(\r\x12\x15\n\rterminus_code\x18) \x01(\t\x12\x15\n\rterminus_name\x18* \x01(\t\x12\x15\n\rterminus_time\x18+ \x01(\t\x12\x1e\n\x16previous_trip_shape_id\x18\x33 \x01(\t\";\n\x05Shape\x12\x10\n\x08shape_id\x18\x01 \x01(\t\x12 \n\x06points\x18\x03 \x03(\x0b\x32\x10.proto.Epsg25829B$\xaa\x02!Costasdev.Busurbano.Backend.Typesb\x06proto3') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'stop_schedule_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: -_globals = globals() -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'stop_schedule_pb2', _globals) -if not _descriptor._USE_C_DESCRIPTORS: - _globals['DESCRIPTOR']._loaded_options = None - _globals['DESCRIPTOR']._serialized_options = b'\252\002!Costasdev.Busurbano.Backend.Types' - _globals['_EPSG25829']._serialized_start=30 - _globals['_EPSG25829']._serialized_end=63 - _globals['_STOPARRIVALS']._serialized_start=66 - _globals['_STOPARRIVALS']._serialized_end=549 - _globals['_STOPARRIVALS_SCHEDULEDARRIVAL']._serialized_start=192 - _globals['_STOPARRIVALS_SCHEDULEDARRIVAL']._serialized_end=549 - _globals['_SHAPE']._serialized_start=551 - _globals['_SHAPE']._serialized_end=610 + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\252\002!Costasdev.Busurbano.Backend.Types' + _EPSG25829._serialized_start=30 + _EPSG25829._serialized_end=63 + _STOPARRIVALS._serialized_start=66 + _STOPARRIVALS._serialized_end=581 + _STOPARRIVALS_SCHEDULEDARRIVAL._serialized_start=192 + _STOPARRIVALS_SCHEDULEDARRIVAL._serialized_end=581 + _SHAPE._serialized_start=583 + _SHAPE._serialized_end=642 # @@protoc_insertion_point(module_scope) diff --git a/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.pyi b/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.pyi index 615999f..355798f 100644 --- a/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.pyi +++ b/src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.pyi @@ -1,68 +1,69 @@ from google.protobuf.internal import containers as _containers from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message -from collections.abc import Iterable as _Iterable, Mapping as _Mapping -from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union +from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor class Epsg25829(_message.Message): - __slots__ = () + __slots__ = ["x", "y"] X_FIELD_NUMBER: _ClassVar[int] Y_FIELD_NUMBER: _ClassVar[int] x: float y: float def __init__(self, x: _Optional[float] = ..., y: _Optional[float] = ...) -> 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, } ) |
