Skip to content

Commit 3b9edc8

Browse files
authored
Convert GTFS Realtime Vehicle Positions to GeoJSON or GeoJSONL (#467)
* Convert GTFS Realtime Vehicle Positions to GeoJSON or GeoJSONL * doc update * when no VPs, just emit empty geojson/geojsonl * regenerate docs * one more doc page updated
1 parent 8697c1c commit 3b9edc8

File tree

5 files changed

+442
-17
lines changed

5 files changed

+442
-17
lines changed

cmds/rt_convert_cmd.go

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"os"
7+
"strings"
78

89
"github.com/interline-io/transitland-lib/request"
910
"github.com/interline-io/transitland-lib/rt"
@@ -16,22 +17,30 @@ import (
1617
type RTConvertCommand struct {
1718
InputFile string
1819
OutputFile string
20+
Format string
1921
}
2022

2123
func (cmd *RTConvertCommand) HelpDesc() (string, string) {
22-
return "Convert GTFS-RealTime to JSON", ""
24+
return "Convert GTFS Realtime to JSON.", "Convert GTFS Realtime protocol buffer files to JSON format. Eases inspecting live feeds. Enables processing with JSON-based tools like jq. For vehicle position feeds, you can also convert to GeoJSON (FeatureCollection) or GeoJSONL (one feature per line) formats for visualization or geographic analysis. See https://www.interline.io/blog/geojsonl-extracts/ for more information about GeoJSONL. Note: GeoJSON formats only include vehicle position; trip updates and service alerts can be converted to JSON but not GeoJSON/GeoJSONL."
2325
}
2426

2527
func (cmd *RTConvertCommand) HelpExample() string {
26-
return `% {{.ParentCommand}} {{.Command}} "trips.pb"`
28+
return `% {{.ParentCommand}} {{.Command}} "trips.pb"
29+
% {{.ParentCommand}} {{.Command}} --format geojson "vehicle_positions.pb"
30+
% {{.ParentCommand}} {{.Command}} --format geojsonl "vehicle_positions.pb"
31+
% {{.ParentCommand}} {{.Command}} --format json --out output.json "alerts.pb"
32+
% {{.ParentCommand}} {{.Command}} --format geojson --out mbta_vehicles.geojson "https://cdn.mbta.com/realtime/VehiclePositions.pb"
33+
% {{.ParentCommand}} {{.Command}} --format geojson "https://developer.trimet.org/ws/gtfs/VehiclePositions"
34+
% {{.ParentCommand}} {{.Command}} --format json "https://developer.trimet.org/ws/V1/TripUpdate"`
2735
}
2836

2937
func (cmd *RTConvertCommand) HelpArgs() string {
3038
return "[flags] <input pb>"
3139
}
3240

3341
func (cmd *RTConvertCommand) AddFlags(fl *pflag.FlagSet) {
34-
fl.StringVarP(&cmd.OutputFile, "out", "o", "", "Write JSON to file; defaults to stdout")
42+
fl.StringVarP(&cmd.OutputFile, "out", "o", "", "Write output to file; defaults to stdout")
43+
fl.StringVarP(&cmd.Format, "format", "f", "json", "Output format: json, geojson, geojsonl (geojson formats only convert vehicle position entities)")
3544
}
3645

3746
func (cmd *RTConvertCommand) Parse(args []string) error {
@@ -49,12 +58,28 @@ func (cmd *RTConvertCommand) Run(ctx context.Context) error {
4958
if err != nil {
5059
return err
5160
}
52-
// Create json
53-
mOpts := protojson.MarshalOptions{UseProtoNames: true, Indent: " "}
54-
rtJson, err := mOpts.Marshal(msg)
55-
if err != nil {
56-
return err
61+
62+
var outputData []byte
63+
64+
// Handle different output formats
65+
switch strings.ToLower(cmd.Format) {
66+
case "json":
67+
// Create json
68+
mOpts := protojson.MarshalOptions{UseProtoNames: true, Indent: " "}
69+
outputData, err = mOpts.Marshal(msg)
70+
if err != nil {
71+
return err
72+
}
73+
case "geojson", "geojsonl":
74+
// Convert to GeoJSON - will handle empty vehicle positions gracefully
75+
outputData, err = rt.VehiclePositionsToGeoJSON(msg, cmd.Format == "geojsonl")
76+
if err != nil {
77+
return err
78+
}
79+
default:
80+
return errors.New("unsupported format: " + cmd.Format)
5781
}
82+
5883
// Write
5984
outf := os.Stdout
6085
if cmd.OutputFile != "" {
@@ -65,7 +90,7 @@ func (cmd *RTConvertCommand) Run(ctx context.Context) error {
6590
}
6691
defer outf.Close()
6792
}
68-
if _, err := outf.Write(rtJson); err != nil {
93+
if _, err := outf.Write(outputData); err != nil {
6994
return err
7095
}
7196
return nil

doc/cli/transitland.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ transitland-lib utilities
2424
* [transitland merge](transitland_merge.md) - Merge multiple GTFS feeds
2525
* [transitland polylines-create](transitland_polylines-create.md) - Converts input geometry file to polylines
2626
* [transitland rebuild-stats](transitland_rebuild-stats.md) - Rebuild statistics for feeds or specific feed versions
27-
* [transitland rt-convert](transitland_rt-convert.md) - Convert GTFS-RealTime to JSON
27+
* [transitland rt-convert](transitland_rt-convert.md) - Convert GTFS Realtime to JSON.
2828
* [transitland sync](transitland_sync.md) - Sync DMFR files to database
2929
* [transitland unimport](transitland_unimport.md) - Unimport feed versions
3030
* [transitland validate](transitland_validate.md) - Validate a GTFS feed
3131
* [transitland version](transitland_version.md) - Program version and supported GTFS and GTFS-RT versions
3232

33-
###### Auto generated by spf13/cobra on 24-Jun-2025
33+
###### Auto generated by spf13/cobra on 5-Aug-2025

doc/cli/transitland_rt-convert.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
## transitland rt-convert
22

3-
Convert GTFS-RealTime to JSON
3+
Convert GTFS Realtime to JSON.
44

55
### Synopsis
66

7-
Convert GTFS-RealTime to JSON
8-
7+
Convert GTFS Realtime to JSON.
98

9+
Convert GTFS Realtime protocol buffer files to JSON format. Eases inspecting live feeds. Enables processing with JSON-based tools like jq. For vehicle position feeds, you can also convert to GeoJSON (FeatureCollection) or GeoJSONL (one feature per line) formats for visualization or geographic analysis. See https://www.interline.io/blog/geojsonl-extracts/ for more information about GeoJSONL. Note: GeoJSON formats only include vehicle position; trip updates and service alerts can be converted to JSON but not GeoJSON/GeoJSONL.
1010

1111
```
1212
transitland rt-convert [flags] <input pb>
@@ -16,17 +16,24 @@ transitland rt-convert [flags] <input pb>
1616

1717
```
1818
% transitland rt-convert "trips.pb"
19+
% transitland rt-convert --format geojson "vehicle_positions.pb"
20+
% transitland rt-convert --format geojsonl "vehicle_positions.pb"
21+
% transitland rt-convert --format json --out output.json "alerts.pb"
22+
% transitland rt-convert --format geojson --out mbta_vehicles.geojson "https://cdn.mbta.com/realtime/VehiclePositions.pb"
23+
% transitland rt-convert --format geojson "https://developer.trimet.org/ws/gtfs/VehiclePositions"
24+
% transitland rt-convert --format json "https://developer.trimet.org/ws/V1/TripUpdate"
1925
```
2026

2127
### Options
2228

2329
```
24-
-h, --help help for rt-convert
25-
-o, --out string Write JSON to file; defaults to stdout
30+
-f, --format string Output format: json, geojson, geojsonl (geojson formats only convert vehicle position entities) (default "json")
31+
-h, --help help for rt-convert
32+
-o, --out string Write output to file; defaults to stdout
2633
```
2734

2835
### SEE ALSO
2936

3037
* [transitland](transitland.md) - transitland-lib utilities
3138

32-
###### Auto generated by spf13/cobra on 22-May-2025
39+
###### Auto generated by spf13/cobra on 5-Aug-2025

rt/geojson.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package rt
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
7+
"github.com/interline-io/transitland-lib/rt/pb"
8+
)
9+
10+
// createVehicleFeature creates a GeoJSON feature from a vehicle entity
11+
func createVehicleFeature(entity *pb.FeedEntity) map[string]any {
12+
vehicle := entity.Vehicle
13+
properties := map[string]any{
14+
"id": entity.Id,
15+
}
16+
17+
// Add vehicle descriptor properties
18+
if vehicle.Vehicle != nil {
19+
if vehicle.Vehicle.Id != nil {
20+
properties["vehicle_id"] = *vehicle.Vehicle.Id
21+
}
22+
if vehicle.Vehicle.Label != nil {
23+
properties["vehicle_label"] = *vehicle.Vehicle.Label
24+
}
25+
if vehicle.Vehicle.LicensePlate != nil {
26+
properties["vehicle_license_plate"] = *vehicle.Vehicle.LicensePlate
27+
}
28+
}
29+
30+
// Add trip information
31+
if vehicle.Trip != nil {
32+
if vehicle.Trip.TripId != nil {
33+
properties["trip_id"] = *vehicle.Trip.TripId
34+
}
35+
if vehicle.Trip.RouteId != nil {
36+
properties["route_id"] = *vehicle.Trip.RouteId
37+
}
38+
if vehicle.Trip.DirectionId != nil {
39+
properties["direction_id"] = *vehicle.Trip.DirectionId
40+
}
41+
}
42+
43+
// Add position and timestamp
44+
if vehicle.Position != nil {
45+
if vehicle.Position.Latitude != nil {
46+
properties["latitude"] = *vehicle.Position.Latitude
47+
}
48+
if vehicle.Position.Longitude != nil {
49+
properties["longitude"] = *vehicle.Position.Longitude
50+
}
51+
}
52+
53+
if vehicle.Timestamp != nil {
54+
properties["timestamp"] = *vehicle.Timestamp
55+
}
56+
57+
if vehicle.CurrentStopSequence != nil {
58+
properties["current_stop_sequence"] = *vehicle.CurrentStopSequence
59+
}
60+
61+
if vehicle.StopId != nil {
62+
properties["stop_id"] = *vehicle.StopId
63+
}
64+
65+
if vehicle.CurrentStatus != nil {
66+
properties["current_status"] = *vehicle.CurrentStatus
67+
}
68+
69+
if vehicle.CongestionLevel != nil {
70+
properties["congestion_level"] = *vehicle.CongestionLevel
71+
}
72+
73+
return map[string]any{
74+
"type": "Feature",
75+
"properties": properties,
76+
"geometry": map[string]any{
77+
"type": "Point",
78+
"coordinates": []float64{
79+
float64(*vehicle.Position.Longitude),
80+
float64(*vehicle.Position.Latitude),
81+
},
82+
},
83+
}
84+
}
85+
86+
// VehiclePositionsToGeoJSON converts vehicle position protobuf data to GeoJSON format
87+
func VehiclePositionsToGeoJSON(rtMsg *pb.FeedMessage, isGeoJSONL bool) ([]byte, error) {
88+
features := []map[string]any{}
89+
90+
for _, entity := range rtMsg.Entity {
91+
if entity.Vehicle == nil || entity.Vehicle.Position == nil {
92+
continue
93+
}
94+
95+
feature := createVehicleFeature(entity)
96+
features = append(features, feature)
97+
}
98+
99+
if isGeoJSONL {
100+
// Return GeoJSONL format (one feature per line)
101+
var result []byte
102+
for i, feature := range features {
103+
featureBytes, err := json.Marshal(feature)
104+
if err != nil {
105+
return nil, err
106+
}
107+
result = append(result, featureBytes...)
108+
if i < len(features)-1 {
109+
result = append(result, '\n')
110+
}
111+
}
112+
return result, nil
113+
} else {
114+
// Return standard GeoJSON format
115+
featureCollection := map[string]any{
116+
"type": "FeatureCollection",
117+
"features": features,
118+
}
119+
return json.Marshal(featureCollection)
120+
}
121+
}
122+
123+
// VehiclePositionsToGeoJSONLStream streams vehicle position protobuf data to GeoJSONL format
124+
// This function writes features directly to the provided writer as they are processed,
125+
// reducing memory usage for large datasets.
126+
func VehiclePositionsToGeoJSONLStream(rtMsg *pb.FeedMessage, w io.Writer) error {
127+
encoder := json.NewEncoder(w)
128+
129+
for _, entity := range rtMsg.Entity {
130+
if entity.Vehicle == nil || entity.Vehicle.Position == nil {
131+
continue
132+
}
133+
134+
feature := createVehicleFeature(entity)
135+
136+
// Encode and write the feature directly to the writer
137+
if err := encoder.Encode(feature); err != nil {
138+
return err
139+
}
140+
}
141+
142+
return nil
143+
}

0 commit comments

Comments
 (0)