package routes import ( "encoding/xml" "fmt" "io" "math" "slices" ) // Suggestion is a way to use a route for a workout of a given distance. type Suggestion struct { Route *Route Plan string // "loop", "laps", "out-and-back" or "section" Laps int // for "laps" TotalKm float64 // what the user will actually cover on the route FromYouKm float64 // to the nearest point of the route JoinAt Point } // Describe says in a few words how to use the route. func (s Suggestion) Describe() string { switch s.Plan { case "laps": return fmt.Sprintf("%d laps of the %.1f km loop", s.Laps, s.Route.LengthKm) case "out-and-back": return fmt.Sprintf("out and back, %.1f km each way", s.Route.LengthKm) case "section": return fmt.Sprintf("a %.0f km stretch (out and back) of this %.0f km route", s.TotalKm, s.Route.LengthKm) } if s.Route.Generated { return "from your door and back" } if s.Route.Loop { return fmt.Sprintf("the full %.1f km loop", s.Route.LengthKm) } return fmt.Sprintf("the full %.1f km route", s.Route.LengthKm) } // maxApproachKm is how far we'd send someone to reach a route's start. var maxApproachKm = map[Kind]float64{Bike: 6, Foot: 3} // Suggest picks up to n routes of a kind that fit targetKm, starting from p. func Suggest(a *Area, kind Kind, targetKm float64, p Point, n int) []Suggestion { type scored struct { Suggestion cost float64 } var all []scored for i := range a.Routes { r := &a.Routes[i] if r.Kind != kind { continue } if r.LengthKm > 3*targetKm { continue // long-distance trails (Streek-GR & co) aren't a workout route } join, from := r.Closest(p) if from > maxApproachKm[kind] { continue } s := Suggestion{Route: r, Plan: "loop", Laps: 1, TotalKm: r.LengthKm, FromYouKm: from, JoinAt: join} switch { case r.Loop && r.LengthKm < 0.75*targetKm: s.Laps = min(4, max(1, int(math.Round(targetKm/r.LengthKm)))) if s.Laps > 1 { s.Plan = "laps" } s.TotalKm = r.LengthKm * float64(s.Laps) case !r.Loop && 2*r.LengthKm <= 1.3*targetKm: s.Plan, s.TotalKm = "out-and-back", 2*r.LengthKm case r.LengthKm > 1.3*targetKm: s.Plan, s.TotalKm = "section", max(targetKm/2, targetKm-2*from) } // Getting to the start and back is part of the workout. cost := math.Abs(s.TotalKm+2*from-targetKm)/targetKm + 0.03*from if !r.Loop { cost += 0.3 // loops bring you home } if s.Plan == "section" { cost += 0.15 } if s.Laps > 2 { cost += 0.1 // laps get dull } all = append(all, scored{s, cost}) } slices.SortFunc(all, func(x, y scored) int { switch { case x.cost < y.cost: return -1 case x.cost > y.cost: return 1 } return 0 }) var out []Suggestion for _, s := range all[:min(n, len(all))] { out = append(out, s.Suggestion) } return out } // WriteGPX writes a route as a GPX track, one segment per OSM way, ready for // a watch, bike computer or OsmAnd. func WriteGPX(w io.Writer, r *Route) error { type trkpt struct { Lat float64 `xml:"lat,attr"` Lon float64 `xml:"lon,attr"` } type trkseg struct { Points []trkpt `xml:"trkpt"` } type gpx struct { XMLName xml.Name `xml:"gpx"` Version string `xml:"version,attr"` Creator string `xml:"creator,attr"` Xmlns string `xml:"xmlns,attr"` Name string `xml:"trk>name"` Segs []trkseg `xml:"trk>trkseg"` } g := gpx{Version: "1.1", Creator: "workout-suggester (data © OpenStreetMap contributors, ODbL)", Xmlns: "http://www.topografix.com/GPX/1/1", Name: r.Name} for _, way := range r.Ways { var seg trkseg for _, p := range way { seg.Points = append(seg.Points, trkpt{p.Lat, p.Lon}) } g.Segs = append(g.Segs, seg) } io.WriteString(w, xml.Header) enc := xml.NewEncoder(w) enc.Indent("", " ") return enc.Encode(g) }