Skip to content

Commit 87fbb8d

Browse files
drewdairees
andauthored
writer for locations.geojson output (#534)
Co-authored-by: Ian Rees <ian@ianrees.net>
1 parent b901112 commit 87fbb8d

File tree

6 files changed

+341
-79
lines changed

6 files changed

+341
-79
lines changed

diff/diff_cmd.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/interline-io/transitland-lib/tlcli"
1818
"github.com/interline-io/transitland-lib/tlcsv"
1919
"github.com/spf13/pflag"
20+
"github.com/twpayne/go-geom/encoding/geojson"
2021
)
2122

2223
type Command struct {
@@ -33,7 +34,9 @@ type Command struct {
3334

3435
func (cmd *Command) HelpDesc() (string, string) {
3536
a := "Calculate difference between two feeds, writing output in a GTFS-like format"
36-
b := "This command is experimental; it may provide incorrect results or crash on large feeds."
37+
b := `This command is experimental; it may provide incorrect results or crash on large feeds.
38+
39+
Note: This command only processes CSV files; GeoJSON files (such as locations.geojson) are not included in the diff comparison.`
3740
return a, b
3841
}
3942

@@ -334,6 +337,11 @@ func (adapter *diffAdapter) WriteRows(efn string, rows [][]string) error {
334337
return nil
335338
}
336339

340+
func (adapter *diffAdapter) WriteFeatures(filename string, features []*geojson.Feature) error {
341+
// diffAdapter only handles CSV files, so GeoJSON is ignored
342+
return nil
343+
}
344+
337345
type diffKey struct {
338346
efn string
339347
eid string

doc/cli/transitland_diff.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Calculate difference between two feeds, writing output in a GTFS-like format
88

99
This command is experimental; it may provide incorrect results or crash on large feeds.
1010

11+
Note: This command only processes CSV files; GeoJSON files (such as locations.geojson) are not included in the diff comparison.
12+
1113
```
1214
transitland diff [flags] <feed1> <feed2> <output>
1315
```

tlcsv/adapter.go

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"crypto/sha1"
77
"encoding/csv"
8+
"encoding/json"
89
"errors"
910
"fmt"
1011
"io"
@@ -17,6 +18,7 @@ import (
1718
"github.com/interline-io/log"
1819
"github.com/interline-io/transitland-lib/causes"
1920
"github.com/interline-io/transitland-lib/request"
21+
"github.com/twpayne/go-geom/encoding/geojson"
2022
)
2123

2224
// Adapter provides an interface for working with various kinds of GTFS sources: zip, directory, url.
@@ -35,6 +37,7 @@ type Adapter interface {
3537
// WriterAdapter provides a writing interface.
3638
type WriterAdapter interface {
3739
WriteRows(string, [][]string) error
40+
WriteFeatures(string, []*geojson.Feature) error
3841
Adapter
3942
}
4043

@@ -381,15 +384,17 @@ func (adapter *ZipAdapter) findInternalPrefix() (string, error) {
381384

382385
// DirAdapter supports plain directories of CSV files.
383386
type DirAdapter struct {
384-
path string
385-
files map[string]*os.File
387+
path string
388+
files map[string]*os.File
389+
geojsonFeatures map[string][]*geojson.Feature
386390
}
387391

388392
// NewDirAdapter returns an initialized DirAdapter.
389393
func NewDirAdapter(path string) *DirAdapter {
390394
return &DirAdapter{
391-
path: strings.TrimPrefix(path, "file://"),
392-
files: map[string]*os.File{},
395+
path: strings.TrimPrefix(path, "file://"),
396+
files: map[string]*os.File{},
397+
geojsonFeatures: map[string][]*geojson.Feature{},
393398
}
394399
}
395400

@@ -457,8 +462,19 @@ func (adapter *DirAdapter) Open() error {
457462
return nil
458463
}
459464

460-
// Close the adapter.
465+
// Close the adapter. Flushes any buffered GeoJSON files before closing.
461466
func (adapter *DirAdapter) Close() error {
467+
// Flush all buffered GeoJSON files
468+
for filename, features := range adapter.geojsonFeatures {
469+
if len(features) > 0 {
470+
if err := adapter.flushGeoJSON(filename, features); err != nil {
471+
return err
472+
}
473+
}
474+
}
475+
adapter.geojsonFeatures = map[string][]*geojson.Feature{}
476+
477+
// Close all file handles
462478
for _, f := range adapter.files {
463479
if err := f.Close(); err != nil {
464480
return err
@@ -542,6 +558,40 @@ func (adapter *DirAdapter) WriteRows(filename string, rows [][]string) error {
542558
return nil
543559
}
544560

561+
// WriteFeatures buffers GeoJSON features to be written when the adapter is closed.
562+
// This mirrors how CSV rows are buffered and written.
563+
func (adapter *DirAdapter) WriteFeatures(filename string, features []*geojson.Feature) error {
564+
if len(features) == 0 {
565+
return nil
566+
}
567+
adapter.geojsonFeatures[filename] = append(adapter.geojsonFeatures[filename], features...)
568+
return nil
569+
}
570+
571+
// flushGeoJSON writes all buffered features for a file as a FeatureCollection.
572+
func (adapter *DirAdapter) flushGeoJSON(filename string, features []*geojson.Feature) error {
573+
// Close existing file if open (we need to overwrite, not append)
574+
if in, ok := adapter.files[filename]; ok {
575+
in.Close()
576+
delete(adapter.files, filename)
577+
}
578+
579+
// Create new file
580+
in, err := os.Create(filepath.Join(adapter.path, filename))
581+
if err != nil {
582+
return err
583+
}
584+
adapter.files[filename] = in
585+
586+
// Write FeatureCollection
587+
fc := geojson.FeatureCollection{
588+
Features: features,
589+
}
590+
encoder := json.NewEncoder(in)
591+
encoder.SetIndent("", " ")
592+
return encoder.Encode(&fc)
593+
}
594+
545595
/////////////////////
546596

547597
// ZipWriterAdapter functions the same as DirAdapter, but writes to a temporary directory, and creates a zip archive when closed.
@@ -564,6 +614,16 @@ func NewZipWriterAdapter(path string) *ZipWriterAdapter {
564614

565615
// Close creates a zip archive of all the written files at the specified destination.
566616
func (adapter *ZipWriterAdapter) Close() error {
617+
// Flush any buffered GeoJSON files first
618+
for filename, features := range adapter.DirAdapter.geojsonFeatures {
619+
if len(features) > 0 {
620+
if err := adapter.DirAdapter.flushGeoJSON(filename, features); err != nil {
621+
return err
622+
}
623+
}
624+
}
625+
adapter.DirAdapter.geojsonFeatures = map[string][]*geojson.Feature{}
626+
567627
out, err := os.Create(adapter.outpath)
568628
if err != nil {
569629
return nil

tlcsv/geojson.go

Lines changed: 56 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ import (
1515
// entity and whether it was successfully parsed.
1616
type GeoJSONFeatureParser[T any] func(*geojson.Feature) (T, bool)
1717

18+
// GeoJSONFeatureWriter is a callback function for converting a GTFS entity
19+
// into a GeoJSON feature. It receives the entity and should return the feature
20+
// and whether it was successfully converted.
21+
type GeoJSONFeatureWriter[T any] func(T) (*geojson.Feature, bool)
22+
1823
// readGeoJSON reads and parses a GeoJSON FeatureCollection file using a
1924
// provided parser function. This provides a generic way to read any GeoJSON
2025
// file format into GTFS entities.
@@ -97,50 +102,54 @@ func (reader *Reader) readLocationsGeoJSON(filename string) ([]gtfs.Location, er
97102
return readGeoJSON(reader, filename, parseLocationFeature)
98103
}
99104

100-
// Example: To add support for level.geojson in the future, create a parser function:
101-
//
102-
// func parseLevelFeature(feature *geojson.Feature) (gtfs.Level, bool) {
103-
// level := gtfs.Level{}
104-
// if feature.ID != "" {
105-
// level.LevelID = tt.NewString(feature.ID)
106-
// }
107-
// if feature.Properties != nil {
108-
// if v, ok := feature.Properties["level_name"].(string); ok {
109-
// level.LevelName = tt.NewString(v)
110-
// }
111-
// if v, ok := feature.Properties["level_index"].(float64); ok {
112-
// level.LevelIndex = tt.NewFloat(v)
113-
// }
114-
// }
115-
// // Parse geometry (Polygon or MultiPolygon)
116-
// if feature.Geometry != nil {
117-
// switch g := feature.Geometry.(type) {
118-
// case *geom.Polygon, *geom.MultiPolygon:
119-
// g.SetSRID(4326)
120-
// level.Geometry = tt.NewGeometry(g)
121-
// default:
122-
// return level, false
123-
// }
124-
// }
125-
// return level, true
126-
// }
127-
//
128-
// Then in reader.go, add:
129-
//
130-
// func (reader *Reader) Levels() chan gtfs.Level {
131-
// out := make(chan gtfs.Level, bufferSize)
132-
// go func() {
133-
// defer close(out)
134-
// // Try GeoJSON first
135-
// levels, err := readGeoJSON(reader, "levels.geojson", parseLevelFeature)
136-
// if err == nil {
137-
// for _, level := range levels {
138-
// out <- level
139-
// }
140-
// return
141-
// }
142-
// // Fall back to CSV
143-
// ReadEntities[gtfs.Level](reader, "levels.txt")
144-
// }()
145-
// return out
146-
// }
105+
// writeLocationFeature converts a gtfs.Location entity to a GeoJSON feature.
106+
// This is used for locations.geojson (GTFS-Flex extension).
107+
func writeLocationFeature(loc *gtfs.Location) (*geojson.Feature, bool) {
108+
if loc == nil {
109+
return nil, false
110+
}
111+
feature := &geojson.Feature{}
112+
113+
// Set feature ID from LocationID
114+
if loc.LocationID.Val != "" {
115+
feature.ID = loc.LocationID.Val
116+
}
117+
118+
// Set properties
119+
properties := make(map[string]any)
120+
if loc.StopName.Val != "" {
121+
properties["stop_name"] = loc.StopName.Val
122+
}
123+
if loc.StopDesc.Val != "" {
124+
properties["stop_desc"] = loc.StopDesc.Val
125+
}
126+
if loc.ZoneID.Val != "" {
127+
properties["zone_id"] = loc.ZoneID.Val
128+
}
129+
if loc.StopURL.Val != "" {
130+
properties["stop_url"] = loc.StopURL.Val
131+
}
132+
if len(properties) > 0 {
133+
feature.Properties = properties
134+
}
135+
136+
// Set geometry - must be Polygon or MultiPolygon for locations
137+
if !loc.Geometry.Valid {
138+
return nil, false
139+
}
140+
141+
// Ensure SRID is set
142+
switch g := loc.Geometry.Val.(type) {
143+
case *geom.Polygon:
144+
g.SetSRID(4326)
145+
feature.Geometry = g
146+
case *geom.MultiPolygon:
147+
g.SetSRID(4326)
148+
feature.Geometry = g
149+
default:
150+
// Invalid geometry type for location - skip
151+
return nil, false
152+
}
153+
154+
return feature, true
155+
}

0 commit comments

Comments
 (0)