aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Costasdev.Busurbano.Backend/Controllers/VigoController.cs56
-rw-r--r--src/Costasdev.Busurbano.Backend/Types/StopSchedule.cs105
-rw-r--r--src/Costasdev.Busurbano.Backend/appsettings.json6
-rw-r--r--src/common/stop_schedule.proto3
-rw-r--r--src/frontend/app/components/layout/AppShell.css10
-rw-r--r--src/frontend/app/components/layout/AppShell.tsx4
-rw-r--r--src/frontend/app/components/layout/NavBar.module.css46
-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.css36
-rw-r--r--src/frontend/app/routes/map.tsx2
-rw-r--r--src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.py43
-rw-r--r--src/gtfs_vigo_stops/src/proto/stop_schedule_pb2.pyi65
-rw-r--r--src/gtfs_vigo_stops/src/report_writer.py1
-rw-r--r--src/gtfs_vigo_stops/src/trips.py20
-rw-r--r--src/gtfs_vigo_stops/stop_report.py138
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,
}
)