Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 48 additions & 6 deletions internal/app/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,13 @@ type Model struct {
feed gtfs.Feed
feedIndexes gtfs.Indexes

focus endpointFocus
stationPos int
fromStation string
toStation string
route gtfs.RouteResult
focus endpointFocus
stationPos int
fromStation string
toStation string
route gtfs.RouteResult
clock func() time.Time
showScheduleDetail bool
}

type endpointFocus uint8
Expand Down Expand Up @@ -160,6 +162,7 @@ func NewWithConfig(cache *render.TileCache, lat, lon float64, config Config) Mod
trainSeed: 41,
trainFleet: 24,
focused: true,
clock: time.Now,
}
}

Expand Down Expand Up @@ -233,6 +236,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.showHelp = !m.showHelp
m.invalidate()
return m, tea.Batch(m.renderCmd(), m.syncSimulation())
case "e":
m.showScheduleDetail = !m.showScheduleDetail
m.invalidate()
return m, tea.Batch(m.renderCmd(), m.syncSimulation())
case "tab":
m.focus = (m.focus + 1) % 3
m.setStatus()
Expand Down Expand Up @@ -820,7 +827,9 @@ func (m Model) helpContent() string {
"",
accent.Render(" Other"),
" " + key.Render("?") + dim.Render(" toggle help ") + key.Render("q") + dim.Render(" quit"),
" " + key.Render("e") + dim.Render(" expand scheduled stop/transfer detail"),
" " + dim.Render("Trains pause when unfocused, overlaid, or below 20×8; compact terminals reduce motion."),
" " + dim.Render("Schedules are static GTFS; expired weekly calendars may be carried forward for demo use."),
"",
dim.Render(" Tip: set terminal background to #000000 for AMOLED look"),
}
Expand Down Expand Up @@ -1261,6 +1270,7 @@ func (m Model) sidebarLines(height, width int) []string {
switch m.route.Status {
case gtfs.RouteReady:
lines = append(lines, dim.Render(" Route ready · highlighted on map"))
lines = append(lines, dim.Render(" "+m.scheduleSummary()))
for i, leg := range m.route.Legs {
name := leg.FamilyName
if name == "" {
Expand All @@ -1273,6 +1283,12 @@ func (m Model) sidebarLines(height, width int) []string {
lines = append(lines, dim.Render(" TRANSFER at "+m.endpointName(leg.To)))
}
}
if m.showScheduleDetail && m.route.Schedule.Available() {
lines = append(lines, accent.Render(" SCHEDULED STOP DETAIL"))
for _, stop := range m.route.Schedule.Stops {
lines = append(lines, dim.Render(fmt.Sprintf(" %s arr %s · dep %s", m.endpointName(stop.StationID), stop.Arrival.Format("15:04:05"), stop.Departure.Format("15:04:05"))))
}
}
case gtfs.RouteLoading:
lines = append(lines, dim.Render(" Planning route…"))
case gtfs.RouteUnreachable:
Expand Down Expand Up @@ -1358,6 +1374,21 @@ func (m Model) routeSummary() string {
return fmt.Sprintf("%d stops · %d transfers · %s", m.route.Stops, m.route.Transfers, strings.Join(sequence, " → "))
}

func (m Model) scheduleSummary() string {
schedule := m.route.Schedule
if !schedule.Available() {
return "SCHEDULED · timing unavailable"
}
return fmt.Sprintf("SCHEDULED · NEXT SERVICE %s→%s · Duration %s · press e for stops", schedule.NextDeparture.Format("15:04"), schedule.NextArrival.Format("15:04"), formatDuration(schedule.Duration))
}

func formatDuration(value time.Duration) string {
if value < time.Minute {
return value.Round(time.Second).String()
}
return fmt.Sprintf("%dh %02dm", int(value/time.Hour), int(value/time.Minute)%60)
}

// clearRouteForPendingSelection prevents a completed route from remaining
// visually active while endpoint changes are being planned. It also makes
// malformed endpoint IDs fail closed instead of drawing stale geometry.
Expand Down Expand Up @@ -1548,8 +1579,19 @@ func (m Model) routeCmd() tea.Cmd {
if !ready {
return routeReadyMsg{seq: seq, feedSeq: feedSeq, result: gtfs.RouteResult{Status: gtfs.RouteUnavailable, Message: "Route unavailable until GTFS is ready"}}
}
return routeReadyMsg{seq: seq, feedSeq: feedSeq, result: gtfs.PlanRoute(graph, from, to)}
result := gtfs.PlanRoute(graph, from, to)
if result.Status == gtfs.RouteReady {
result.Schedule = gtfs.PlanScheduledJourney(m.feedIndexes, result, m.now(), gtfs.DefaultSchedulePolicy)
}
return routeReadyMsg{seq: seq, feedSeq: feedSeq, result: result}
}
}

func (m Model) now() time.Time {
if m.clock != nil {
return m.clock()
}
return time.Now()
}

func (m Model) mapWidth() int {
Expand Down
13 changes: 13 additions & 0 deletions internal/gtfs/fixture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,19 @@ func TestLoaderBoundaryUsesFilesystemSource(t *testing.T) {
var _ Loader = fixtureLoader{}
}

func TestDelhiMiniFixtureScheduleFieldsAndExplicitSyntheticPolicy(t *testing.T) {
feed, err := Load(context.Background(), os.DirFS("testdata/delhi-mini"))
if err != nil {
t.Fatal(err)
}
if len(feed.Calendar) != 0 || len(feed.CalendarDates) != 0 {
t.Fatal("synthetic fixture unexpectedly gained calendar rules")
}
if feed.Trips[0].ServiceID != "weekday" || feed.StopTimes[0].ArrivalTime == "" {
t.Fatal("fixture did not retain schedule fields")
}
}

type fixtureLoader struct{}

func (fixtureLoader) Load(_ context.Context, _ fs.FS) (Feed, error) {
Expand Down
25 changes: 18 additions & 7 deletions internal/gtfs/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,15 @@ type ShapeIndex map[string]Shape
// Indexes contains the derived data-preparation indexes. Maps are keyed by
// source IDs; the ordered slices are the deterministic iteration form.
type Indexes struct {
Stations StationIndex
Lines LineIndex
Families LineFamilyIndex
Shapes ShapeIndex
Trips map[string]TripView
Graph RouteGraph
Stations StationIndex
Lines LineIndex
Families LineFamilyIndex
Shapes ShapeIndex
Trips map[string]TripView
Graph RouteGraph
Schedules map[string]TripSchedule
Calendar []Calendar
CalendarDates []CalendarDate

StationIDs []string
LineIDs []string
Expand Down Expand Up @@ -166,6 +169,10 @@ func BuildIndexes(feed Feed) (Indexes, error) {
return Indexes{}, err
}
tripViews, tripIDsOrdered := buildTripViews(trips, lines, feed.StopTimes, stopToStation)
schedules, err := buildSchedules(trips, lines, feed.StopTimes, stopToStation, feed.Calendar, feed.CalendarDates)
if err != nil {
return Indexes{}, err
}
attachTripShapes(lines, trips)
attachStationLines(stations, stopToStation, feed.StopTimes, trips)
attachStationFamilies(stations, lines)
Expand All @@ -187,6 +194,9 @@ func BuildIndexes(feed Feed) (Indexes, error) {
Shapes: shapes,
Trips: tripViews,
Graph: RouteGraph{},
Schedules: schedules,
Calendar: append([]Calendar(nil), feed.Calendar...),
CalendarDates: append([]CalendarDate(nil), feed.CalendarDates...),
StationIDs: stationIDs,
LineIDs: lineIDs,
ShapeIDs: shapeIDs,
Expand Down Expand Up @@ -499,14 +509,15 @@ func buildTripViews(trips []Trip, lines LineIndex, stopTimes []StopTime, stopToS
views := make(map[string]TripView, len(trips))
ids := make([]string, 0, len(trips))
for _, trip := range trips {
views[trip.ID] = TripView{ID: trip.ID, LineID: trip.RouteID, FamilyID: lines[trip.RouteID].FamilyID, ShapeID: trip.ShapeID, DirectionID: trip.DirectionID, StopIDs: []string{}, StationIDs: []string{}}
views[trip.ID] = TripView{ID: trip.ID, LineID: trip.RouteID, FamilyID: lines[trip.RouteID].FamilyID, ShapeID: trip.ShapeID, DirectionID: trip.DirectionID, ServiceID: trip.ServiceID, StopIDs: []string{}, StationIDs: []string{}, Stops: []ScheduledStop{}}
ids = append(ids, trip.ID)
}
sort.Strings(ids)
for _, stopTime := range ordered {
view := views[stopTime.TripID]
view.StopIDs = append(view.StopIDs, stopTime.StopID)
view.StationIDs = append(view.StationIDs, stopToStation[stopTime.StopID])
view.Stops = append(view.Stops, ScheduledStop{StopID: stopTime.StopID, StationID: stopToStation[stopTime.StopID], Sequence: stopTime.Sequence, ArrivalSeconds: stopTime.ArrivalSeconds, DepartureSeconds: stopTime.DepartureSeconds})
views[stopTime.TripID] = view
}
return views, ids
Expand Down
55 changes: 47 additions & 8 deletions internal/gtfs/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gtfs
import (
"context"
"io/fs"
"time"

"github.com/paulmach/orb"
)
Expand All @@ -11,11 +12,13 @@ import (
// IDs retain their source values so downstream rendering and routing can make
// stable references without depending on source-file layout.
type Feed struct {
Stops []Stop
Routes []Route
Trips []Trip
StopTimes []StopTime
Shapes []ShapePoint
Stops []Stop
Routes []Route
Trips []Trip
StopTimes []StopTime
Shapes []ShapePoint
Calendar []Calendar
CalendarDates []CalendarDate
}

// Stop is a station or platform represented by a stable ID and coordinates.
Expand Down Expand Up @@ -43,6 +46,7 @@ type Route struct {
type Trip struct {
ID string
RouteID string
ServiceID string
ShapeID string
DirectionID *int
}
Expand All @@ -57,8 +61,10 @@ type TripView struct {
FamilyID string
ShapeID string
DirectionID *int
ServiceID string
StopIDs []string
StationIDs []string
Stops []ScheduledStop
}

// StationPlacement is one passenger-facing station's placement on one line
Expand Down Expand Up @@ -91,9 +97,42 @@ type LineShape struct {

// StopTime places a stop in a trip's ordered sequence.
type StopTime struct {
TripID string
StopID string
Sequence int
TripID string
StopID string
Sequence int
ArrivalTime string
DepartureTime string
ArrivalSeconds int
DepartureSeconds int
}

// Calendar is one GTFS weekly service rule. Dates are date-only values stored
// at UTC midnight; schedule calculations always interpret them in Delhi time.
type Calendar struct {
ServiceID string
StartDate time.Time
EndDate time.Time
Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday bool
}

// CalendarDate is a GTFS exception: 1 adds service and 2 removes it.
type CalendarDate struct {
ServiceID string
Date time.Time
ExceptionType int
}

// ScheduledStop retains the timing association for one passenger-facing stop.
type ScheduledStop struct {
StopID, StationID string
Sequence int
ArrivalSeconds, DepartureSeconds int
}

// TripSchedule is the validated, indexed timing projection of one trip.
type TripSchedule struct {
TripID, ServiceID, RouteID, FamilyID, ShapeID string
Stops []ScheduledStop
}

// ShapePoint is one ordered geographic point on a trip shape.
Expand Down
Loading
Loading