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.
234 lines
6.3 KiB
Go
234 lines
6.3 KiB
Go
package routes
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// BRouter's round trips come out about this many times longer than the
|
|
// roundTripDistance asked for (it's closer to a radius than a length).
|
|
const brouterStretch = 5.1
|
|
|
|
var brouterProfile = map[Kind]string{Foot: "hiking-mountain", Bike: "trekking"}
|
|
|
|
// Loops asks BRouter for round trips of about targetKm that start and end
|
|
// at p, heading out in three directions. The first heads out into the wind
|
|
// (windFrom, in degrees) so the way home is the easy part. The start point
|
|
// is rounded to ~100 m before it leaves the machine.
|
|
func (c *Client) Loops(ctx context.Context, kind Kind, p Point, targetKm float64, windFrom int) ([]Suggestion, error) {
|
|
if c.BRouterURL == "" || c.BRouterURL == "-" || targetKm <= 0 {
|
|
return nil, nil
|
|
}
|
|
p = Point{math.Round(p.Lat*1000) / 1000, math.Round(p.Lon*1000) / 1000}
|
|
key := fmt.Sprintf("%s_%.3f_%.3f_%.1f_%d", kind, p.Lat, p.Lon, targetKm, windFrom/30)
|
|
c.mu.Lock()
|
|
c.initLocked()
|
|
if hit, ok := c.loops[key]; ok && time.Since(hit.at) < cacheFor {
|
|
c.mu.Unlock()
|
|
return hit.s, nil
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
type result struct {
|
|
r *Route
|
|
err error
|
|
}
|
|
results := make([]result, 3)
|
|
var wg sync.WaitGroup
|
|
for i := range results {
|
|
wg.Go(func() {
|
|
dir := (windFrom + i*120) % 360
|
|
r, err := c.roundTrip(ctx, kind, p, targetKm, dir)
|
|
results[i] = result{r, err}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
var out []Suggestion
|
|
var errs []error
|
|
for _, res := range results {
|
|
if res.err != nil {
|
|
errs = append(errs, res.err)
|
|
continue
|
|
}
|
|
out = append(out, Suggestion{Route: res.r, Plan: "loop", Laps: 1, TotalKm: res.r.LengthKm, JoinAt: p})
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("brouter: %w", errors.Join(errs...))
|
|
}
|
|
// Closest to the target first; into-the-wind wins ties within 10%.
|
|
slices.SortStableFunc(out, func(a, b Suggestion) int {
|
|
da, db := math.Abs(a.TotalKm-targetKm), math.Abs(b.TotalKm-targetKm)
|
|
if math.Abs(da-db) < 0.1*targetKm {
|
|
return 0
|
|
}
|
|
if da < db {
|
|
return -1
|
|
}
|
|
return 1
|
|
})
|
|
|
|
c.mu.Lock()
|
|
c.loops[key] = cachedLoops{out, time.Now()}
|
|
for _, s := range out {
|
|
c.byID[s.Route.ID] = s.Route
|
|
}
|
|
c.mu.Unlock()
|
|
return out, nil
|
|
}
|
|
|
|
type cachedLoops struct {
|
|
s []Suggestion
|
|
at time.Time
|
|
}
|
|
|
|
func (c *Client) roundTrip(ctx context.Context, kind Kind, p Point, targetKm float64, dir int) (*Route, error) {
|
|
q := url.Values{
|
|
"lonlats": {fmt.Sprintf("%.3f,%.3f", p.Lon, p.Lat)},
|
|
"profile": {brouterProfile[kind]},
|
|
"alternativeidx": {"0"},
|
|
"format": {"geojson"},
|
|
"engineMode": {"4"}, // round trip
|
|
"roundTripDistance": {strconv.Itoa(int(targetKm * 1000 / brouterStretch))},
|
|
"direction": {strconv.Itoa(dir)},
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BRouterURL+"?"+q.Encode(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", userAgent)
|
|
hc := c.HTTP
|
|
if hc == nil {
|
|
hc = http.DefaultClient
|
|
}
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
|
return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(msg)))
|
|
}
|
|
r, err := parseBRouter(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.Kind, r.Heading = kind, dir
|
|
r.ID = c.nextLoopID()
|
|
r.Name = fmt.Sprintf("%.1f km loop heading %s", r.LengthKm, compass(dir))
|
|
return r, nil
|
|
}
|
|
|
|
// nextLoopID hands out negative ids so generated loops never collide with
|
|
// OpenStreetMap relation ids.
|
|
func (c *Client) nextLoopID() int64 {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.loopSeq--
|
|
return c.loopSeq
|
|
}
|
|
|
|
func parseBRouter(r io.Reader) (*Route, error) {
|
|
var out struct {
|
|
Features []struct {
|
|
Properties struct {
|
|
Length string `json:"track-length"`
|
|
Ascent string `json:"filtered ascend"`
|
|
Messages [][]string `json:"messages"`
|
|
} `json:"properties"`
|
|
Geometry struct {
|
|
Coordinates [][]float64 `json:"coordinates"` // lon, lat, elevation
|
|
} `json:"geometry"`
|
|
} `json:"features"`
|
|
}
|
|
if err := json.NewDecoder(r).Decode(&out); err != nil {
|
|
return nil, fmt.Errorf("decode brouter: %w", err)
|
|
}
|
|
if len(out.Features) == 0 || len(out.Features[0].Geometry.Coordinates) < 2 {
|
|
return nil, errors.New("brouter: empty route")
|
|
}
|
|
f := out.Features[0]
|
|
m, _ := strconv.Atoi(f.Properties.Length)
|
|
ascent, _ := strconv.Atoi(f.Properties.Ascent)
|
|
pts := make([]Point, len(f.Geometry.Coordinates))
|
|
for i, c := range f.Geometry.Coordinates {
|
|
pts[i] = Point{c[1], c[0]}
|
|
}
|
|
return &Route{
|
|
LengthKm: math.Round(float64(m)/100) / 10,
|
|
Loop: true,
|
|
Generated: true,
|
|
AscentM: ascent,
|
|
PavedPct: pavedPct(f.Properties.Messages),
|
|
Ways: [][]Point{simplify(pts, 0.015)},
|
|
}, nil
|
|
}
|
|
|
|
var (
|
|
pavedSurfaces = []string{"asphalt", "paved", "concrete", "concrete:plates", "concrete:lanes", "paving_stones", "sett", "metal", "wood"}
|
|
unpavedHighways = []string{"track", "path", "bridleway"}
|
|
)
|
|
|
|
// pavedPct estimates how much of the route is paved from BRouter's per-
|
|
// segment way tags: the surface tag when there is one, else the road type.
|
|
func pavedPct(msgs [][]string) int {
|
|
if len(msgs) < 2 {
|
|
return -1
|
|
}
|
|
dist, tags := slices.Index(msgs[0], "Distance"), slices.Index(msgs[0], "WayTags")
|
|
if dist < 0 || tags < 0 {
|
|
return -1
|
|
}
|
|
var paved, total float64
|
|
for _, row := range msgs[1:] {
|
|
if len(row) <= max(dist, tags) {
|
|
continue
|
|
}
|
|
d, _ := strconv.ParseFloat(row[dist], 64)
|
|
kv := map[string]string{}
|
|
for _, f := range strings.Fields(row[tags]) {
|
|
k, v, _ := strings.Cut(f, "=")
|
|
kv[k] = v
|
|
}
|
|
total += d
|
|
if s, ok := kv["surface"]; ok {
|
|
if slices.Contains(pavedSurfaces, s) {
|
|
paved += d
|
|
}
|
|
} else if !slices.Contains(unpavedHighways, kv["highway"]) {
|
|
paved += d
|
|
}
|
|
}
|
|
if total == 0 {
|
|
return -1
|
|
}
|
|
return int(math.Round(100 * paved / total))
|
|
}
|
|
|
|
func compass(deg int) string {
|
|
dirs := []string{"north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west"}
|
|
return dirs[((deg%360+360)%360+22)/45%8]
|
|
}
|
|
|
|
// IntoWind reports whether a loop heads out roughly into the wind, so the
|
|
// way home has a tailwind.
|
|
func (r *Route) IntoWind(windFrom int) bool {
|
|
if !r.Generated {
|
|
return false
|
|
}
|
|
d := ((r.Heading-windFrom)%360 + 360) % 360
|
|
return d <= 45 || d >= 315
|
|
}
|