Go + Templ + HTMX app that checks the weather (Open-Meteo, MET Norway fallback), scores run/ride/walk with plain rules, and has a local open-weight model (Gemma 4 E2B via any OpenAI-compatible server) write the plan. Routes are loops from your door (BRouter) plus signposted OpenStreetMap routes (Overpass), with GPX export.
136 lines
3.7 KiB
Go
136 lines
3.7 KiB
Go
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)
|
|
}
|