diff --git a/cmd/analyze-gpx/main.go b/cmd/analyze-gpx/main.go index 3a63cfd..6603874 100644 --- a/cmd/analyze-gpx/main.go +++ b/cmd/analyze-gpx/main.go @@ -9,6 +9,9 @@ import ( "os" "path" + "github.com/dhconnelly/rtreego" + + "github.com/ray1729/gpx-utils/pkg/cafes" "github.com/ray1729/gpx-utils/pkg/placenames" ) @@ -22,21 +25,26 @@ func main() { if err != nil { log.Fatal(err) } + // TODO add --stops flag to select stops database + stopsIndex, err := cafes.FetchCtcCamIndex() + if err != nil { + log.Fatal(err) + } gs, err := placenames.NewGPXSummarizer() if err != nil { log.Fatal(err) } if info.IsDir() { - err = summarizeDirectory(gs, inFile) + err = summarizeDirectory(gs, stopsIndex, inFile) } else { - err = summarizeSingleFile(gs, inFile) + err = summarizeSingleFile(gs, stopsIndex, inFile) } if err != nil { log.Fatal(err) } } -func summarizeDirectory(gs *placenames.GPXSummarizer, dirName string) error { +func summarizeDirectory(gs *placenames.GPXSummarizer, stops *rtreego.Rtree, dirName string) error { files, err := ioutil.ReadDir(dirName) if err != nil { return err @@ -51,7 +59,7 @@ func summarizeDirectory(gs *placenames.GPXSummarizer, dirName string) error { return fmt.Errorf("error opening %s for reading: %v", filename, err) } log.Printf("Analyzing %s", filename) - summary, err := gs.SummarizeTrack(r) + summary, err := gs.SummarizeTrack(r, stops) if err != nil { return fmt.Errorf("error creating summary of GPX track %s: %v", filename, err) } @@ -72,12 +80,12 @@ func summarizeDirectory(gs *placenames.GPXSummarizer, dirName string) error { return nil } -func summarizeSingleFile(gs *placenames.GPXSummarizer, filename string) error { +func summarizeSingleFile(gs *placenames.GPXSummarizer, stops *rtreego.Rtree, filename string) error { r, err := os.Open(filename) if err != nil { return fmt.Errorf("error opening %s for reading: %v", filename, err) } - summary, err := gs.SummarizeTrack(r) + summary, err := gs.SummarizeTrack(r, stops) if err != nil { return fmt.Errorf("error creating summary of GPX track %s: %v", filename, err) } diff --git a/cmd/serve-rwgps/main.go b/cmd/serve-rwgps/main.go index 6dbd7d7..2c3e7ee 100644 --- a/cmd/serve-rwgps/main.go +++ b/cmd/serve-rwgps/main.go @@ -3,11 +3,15 @@ package main import ( "bytes" "encoding/json" + "fmt" "log" "net/http" "os" "strconv" + "github.com/dhconnelly/rtreego" + + "github.com/ray1729/gpx-utils/pkg/cafes" "github.com/ray1729/gpx-utils/pkg/placenames" "github.com/ray1729/gpx-utils/pkg/rwgps" ) @@ -22,38 +26,73 @@ func main() { log.Fatal(err) } gpxSummarizer = gs + if err = loadStops(); err != nil { + log.Fatal(err) + } http.HandleFunc("/rwgps", rwgpsHandler) + log.Printf("Listening for requests on %s", listenAddr) log.Fatal(http.ListenAndServe(listenAddr, nil)) } +func loadStops() error { + var err error + log.Println("Fetching CTC Cambridge cafe stops") + stops["ctccam"], err = cafes.FetchCtcCamIndex() + if err != nil { + return err + } + log.Printf("Loaded %d ctccam stops", stops["ctccam"].Size()) + log.Println("Fetching cyclingmaps.net cafe stops") + stops["cyclingmapsnet"], err = cafes.FetchCyclingMapsIndex() + if err != nil { + return err + } + log.Printf("Loaded %d cyclingmapsnet stops", stops["cyclingmapsnet"].Size()) + return nil +} + var gpxSummarizer *placenames.GPXSummarizer +var stops = make(map[string]*rtreego.Rtree) func rwgpsHandler(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - x := q.Get("routeId") - log.Printf("Handilng request for routeId=%s", x) - if x == "" { + rawRouteId := q.Get("routeId") + stopsName := q.Get("stops") + log.Printf("Handling request for routeId=%s stops=%s", rawRouteId, stopsName) + if rawRouteId == "" { + log.Printf("Missing routeId") http.Error(w, "routeId is required", http.StatusBadRequest) return } - routeId, err := strconv.Atoi(x) + routeId, err := strconv.Atoi(rawRouteId) if err != nil { - log.Printf("Invalid route id: %s", x) - http.Error(w, "Invalid route id", http.StatusBadRequest) + log.Println("Error parsing route id '%s': %v", rawRouteId, err) + http.Error(w, fmt.Sprintf("Invalid routeId: %s", rawRouteId), http.StatusBadRequest) return } + var stopsIndex *rtreego.Rtree + if stopsName != "" { + stopsIndex = stops[stopsName] + if stopsIndex == nil { + log.Printf("Invalid stops: %s", stopsName) + http.Error(w, fmt.Sprintf("Invalid stops: %s", stopsName), http.StatusBadRequest) + return + } + } track, err := rwgps.FetchTrack(routeId) if err != nil { log.Println(err.Error()) switch err.(type) { case *rwgps.ErrNotFound: http.Error(w, err.Error(), http.StatusNotFound) + case *rwgps.ErrNotPublic: + http.Error(w, err.Error(), http.StatusForbidden) default: http.Error(w, err.Error(), http.StatusInternalServerError) } return } - summary, err := gpxSummarizer.SummarizeTrack(bytes.NewReader(track)) + summary, err := gpxSummarizer.SummarizeTrack(bytes.NewReader(track), stopsIndex) if err != nil { log.Printf("Error analyzing route %d: %v", routeId, err) http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/cafes/ctccam.go b/pkg/cafes/ctccam.go new file mode 100644 index 0000000..10de25c --- /dev/null +++ b/pkg/cafes/ctccam.go @@ -0,0 +1,93 @@ +package cafes + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + + "github.com/dhconnelly/rtreego" + "github.com/fofanov/go-osgb" +) + +const ctcCamWaypointsUrl = "https://ctccambridge.org.uk/ctccambridge-waypoints.gpx" + +type Waypoint struct { + Lat float64 `xml:"lat,attr"` + Lon float64 `xml:"lon,attr"` + Name string `xml:"name"` + Url string `xml:"url"` +} + +type Waypoints struct { + Waypoints []Waypoint `xml:"wpt"` +} + +type RefreshmentStop struct { + Name string + Url string + Easting float64 + Northing float64 +} + +func (s *RefreshmentStop) Bounds() *rtreego.Rect { + p := rtreego.Point{s.Easting, s.Northing} + return p.ToRect(100) +} + +func (s *RefreshmentStop) Contains(p rtreego.Point) bool { + if len(p) != 2 { + panic("Expected a 2-dimensional point") + } + bounds := s.Bounds() + for i := 0; i < 2; i++ { + if p[i] < bounds.PointCoord(i) || p[i] > bounds.PointCoord(i)+bounds.LengthsCoord(i) { + return false + } + } + return true +} + +func BuildCtcCamIndex(r io.Reader) (*rtreego.Rtree, error) { + dec := xml.NewDecoder(r) + var wpt Waypoints + err := dec.Decode(&wpt) + if err != nil { + return nil, err + } + trans, err := osgb.NewOSTN15Transformer() + if err != nil { + return nil, err + } + stops := make([]rtreego.Spatial, len(wpt.Waypoints)) + for i, w := range wpt.Waypoints { + gpsCoord := osgb.NewETRS89Coord(w.Lon, w.Lat, 0) + ngCoord, err := trans.ToNationalGrid(gpsCoord) + if err != nil { + return nil, fmt.Errorf("Error translating coordinates %v: %v", gpsCoord, err) + } + stops[i] = &RefreshmentStop{ + Name: w.Name, + Url: w.Url, + Easting: ngCoord.Easting, + Northing: ngCoord.Northing, + } + } + return rtreego.NewTree(2, 25, 50, stops...), nil +} + +func FetchCtcCamIndex() (*rtreego.Rtree, error) { + res, err := http.Get(ctcCamWaypointsUrl) + if err != nil { + return nil, fmt.Errorf("error getting %s: %v", ctcCamWaypointsUrl, err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status fetching %s: %s", ctcCamWaypointsUrl, res.Status) + } + index, err := BuildCtcCamIndex(res.Body) + if err != nil { + return nil, fmt.Errorf("error building CTC Cambridge stops index: %v", err) + } + return index, nil +} diff --git a/pkg/placenames/summarize.go b/pkg/placenames/summarize.go index af457ab..077b5d6 100644 --- a/pkg/placenames/summarize.go +++ b/pkg/placenames/summarize.go @@ -9,10 +9,12 @@ import ( "github.com/dhconnelly/rtreego" "github.com/fofanov/go-osgb" "github.com/twpayne/go-gpx" + + "github.com/ray1729/gpx-utils/pkg/cafes" ) type GPXSummarizer struct { - rt *rtreego.Rtree + poi *rtreego.Rtree trans osgb.CoordinateTransformer } @@ -25,7 +27,7 @@ func NewGPXSummarizer() (*GPXSummarizer, error) { if err != nil { return nil, err } - return &GPXSummarizer{rt, trans}, nil + return &GPXSummarizer{poi: rt, trans: trans}, nil } func distance(p1, p2 rtreego.Point) float64 { @@ -46,6 +48,12 @@ type POI struct { Distance float64 } +type RefreshmentStop struct { + Name string + Url string + Distance float64 +} + type TrackSummary struct { Name string Time time.Time @@ -55,9 +63,10 @@ type TrackSummary struct { Distance float64 Ascent float64 PointsOfInterest []POI + RefreshmentStops []RefreshmentStop `json:",omitempty"` } -func (gs *GPXSummarizer) SummarizeTrack(r io.Reader) (*TrackSummary, error) { +func (gs *GPXSummarizer) SummarizeTrack(r io.Reader, stops *rtreego.Rtree) (*TrackSummary, error) { g, err := gpx.Read(r) if err != nil { return nil, err @@ -76,6 +85,7 @@ func (gs *GPXSummarizer) SummarizeTrack(r io.Reader) (*TrackSummary, error) { var prevPlacePoint rtreego.Point var prevPoint rtreego.Point var prevHeight float64 + var prevStop *cafes.RefreshmentStop init := true for _, trk := range g.Trk { @@ -88,7 +98,7 @@ func (gs *GPXSummarizer) SummarizeTrack(r io.Reader) (*TrackSummary, error) { } thisPoint := rtreego.Point{ngCoord.Easting, ngCoord.Northing} thisHeight := ngCoord.Height - nn, _ := gs.rt.NearestNeighbor(thisPoint).(*NamedBoundary) + nn, _ := gs.poi.NearestNeighbor(thisPoint).(*NamedBoundary) if init { s.Start = nn.Name prevPlace = nn.Name @@ -108,6 +118,17 @@ func (gs *GPXSummarizer) SummarizeTrack(r io.Reader) (*TrackSummary, error) { prevPlace = nn.Name prevPlacePoint = thisPoint } + if stops != nil { + stop, ok := stops.NearestNeighbor(thisPoint).(*cafes.RefreshmentStop) + if ok && stop.Contains(thisPoint) && (prevStop == nil || stop.Name != prevStop.Name) { + s.RefreshmentStops = append(s.RefreshmentStops, RefreshmentStop{ + Name: stop.Name, + Url: stop.Url, + Distance: s.Distance, + }) + prevStop = stop + } + } prevPoint = thisPoint prevHeight = thisHeight } diff --git a/pkg/rwgps/rwgps.go b/pkg/rwgps/rwgps.go index ba58374..2f0579f 100644 --- a/pkg/rwgps/rwgps.go +++ b/pkg/rwgps/rwgps.go @@ -1,7 +1,6 @@ package rwgps import ( - "bytes" "fmt" "io/ioutil" "net/http" @@ -15,6 +14,14 @@ func (e *ErrNotFound) Error() string { return fmt.Sprintf("RideWithGPS track %d not found", e.RouteId) } +type ErrNotPublic struct { + RouteId int +} + +func (e *ErrNotPublic) Error() string { + return fmt.Sprintf("RideWithGPS track %d is not public", e.RouteId) +} + func FetchTrack(routeId int) ([]byte, error) { url := fmt.Sprintf("https://ridewithgps.com/routes/%d.gpx?sub_format=track", routeId) resp, err := http.Get(url) @@ -22,16 +29,18 @@ func FetchTrack(routeId int) ([]byte, error) { return nil, fmt.Errorf("error getting %s: %v", url, err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusNotFound { + return nil, &ErrNotFound{routeId} + } + if resp.StatusCode == http.StatusForbidden { + return nil, &ErrNotPublic{routeId} + } + return nil, fmt.Errorf("error retrieving route %d: %s", routeId, resp.Status) + } data, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("error reading response from %s: %v", url, err) } - if IsNotFound(data) { - return nil, &ErrNotFound{routeId} - } return data, nil } - -func IsNotFound(data []byte) bool { - return bytes.HasPrefix(data, []byte("")) && bytes.Contains(data, []byte("Error (404 not found)")) -}