feat: workout-suggester, weather-aware workouts from a local Gemma
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.
This commit is contained in:
42 files changed
+7219
No files matched your search
@@ -0,0 +1,324 @@
|
||||
// Package coach decides which workouts the weather allows. The scoring is
|
||||
// plain rules so the numbers are always right; the language model only
|
||||
// explains the result and fills in the workout itself.
|
||||
package coach
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/routes"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// Activity is something to do.
|
||||
type Activity string
|
||||
|
||||
const (
|
||||
Any Activity = ""
|
||||
Run Activity = "run"
|
||||
Ride Activity = "ride"
|
||||
Walk Activity = "walk"
|
||||
Indoor Activity = "indoor"
|
||||
)
|
||||
|
||||
// Outdoor lists the activities we score against the weather.
|
||||
var Outdoor = []Activity{Run, Ride, Walk}
|
||||
|
||||
// Label is a human name with an emoji.
|
||||
func (a Activity) Label() string {
|
||||
switch a {
|
||||
case Run:
|
||||
return "🏃 Run"
|
||||
case Ride:
|
||||
return "🚴 Ride"
|
||||
case Walk:
|
||||
return "🥾 Walk / hike"
|
||||
case Indoor:
|
||||
return "🏠 Indoor workout"
|
||||
}
|
||||
return "✨ Surprise me"
|
||||
}
|
||||
|
||||
// Noun is the activity as a word for prompts.
|
||||
func (a Activity) Noun() string {
|
||||
switch a {
|
||||
case Run:
|
||||
return "running"
|
||||
case Ride:
|
||||
return "cycling"
|
||||
case Walk:
|
||||
return "walking/hiking"
|
||||
case Indoor:
|
||||
return "an indoor workout (HIIT, bodyweight or gym)"
|
||||
}
|
||||
return "anything"
|
||||
}
|
||||
|
||||
// RouteKind is the kind of signposted route that suits the activity.
|
||||
func (a Activity) RouteKind() routes.Kind {
|
||||
if a == Ride {
|
||||
return routes.Bike
|
||||
}
|
||||
return routes.Foot
|
||||
}
|
||||
|
||||
// Intensity is how hard the user wants to go.
|
||||
type Intensity string
|
||||
|
||||
const (
|
||||
Easy Intensity = "easy"
|
||||
Moderate Intensity = "moderate"
|
||||
Hard Intensity = "hard"
|
||||
)
|
||||
|
||||
// Request is what the user asked for.
|
||||
type Request struct {
|
||||
Activity Activity
|
||||
Intensity Intensity
|
||||
Minutes int
|
||||
Note string
|
||||
}
|
||||
|
||||
// Score is how good the weather is for an activity, 0–100, with the reasons
|
||||
// points were taken off.
|
||||
type Score struct {
|
||||
Activity Activity
|
||||
Value int
|
||||
Reasons []string
|
||||
}
|
||||
|
||||
// Verdict buckets a score.
|
||||
func (s Score) Verdict() string {
|
||||
switch {
|
||||
case s.Value >= 75:
|
||||
return "great"
|
||||
case s.Value >= 50:
|
||||
return "fine"
|
||||
case s.Value >= 30:
|
||||
return "meh"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
// Later is a noticeably better window later today.
|
||||
type Later struct {
|
||||
Start time.Time
|
||||
Activity Activity
|
||||
Score int
|
||||
}
|
||||
|
||||
// Assessment is the full verdict for a request.
|
||||
type Assessment struct {
|
||||
Scores []Score // now, one per outdoor activity
|
||||
Pick Activity
|
||||
Later *Later
|
||||
TargetKm float64 // 0 for indoor
|
||||
Pace string
|
||||
}
|
||||
|
||||
// IndoorBelow is the best outdoor score under which we send people inside.
|
||||
const IndoorBelow = 40
|
||||
|
||||
// Assess scores every outdoor activity for the user's time window and picks one.
|
||||
func Assess(f weather.Forecast, req Request) Assessment {
|
||||
n := windowHours(f, req.Minutes)
|
||||
now := f.Hours[:min(n, len(f.Hours))]
|
||||
var a Assessment
|
||||
for _, act := range Outdoor {
|
||||
a.Scores = append(a.Scores, score(act, now))
|
||||
}
|
||||
a.Pick = pick(a.Scores, req)
|
||||
a.Later = later(f, req, n, a.Scores)
|
||||
a.TargetKm, a.Pace = target(a.Pick, req.Intensity, req.Minutes)
|
||||
return a
|
||||
}
|
||||
|
||||
// ScoreOf returns the score for one activity.
|
||||
func (a Assessment) ScoreOf(act Activity) Score {
|
||||
for _, s := range a.Scores {
|
||||
if s.Activity == act {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return Score{Activity: act}
|
||||
}
|
||||
|
||||
func windowHours(f weather.Forecast, minutes int) int {
|
||||
return max(1, int(math.Ceil(float64(f.Now.Time.Minute()+minutes)/60)))
|
||||
}
|
||||
|
||||
func pick(scores []Score, req Request) Activity {
|
||||
if req.Activity == Indoor {
|
||||
return Indoor
|
||||
}
|
||||
byAct := map[Activity]int{}
|
||||
for _, s := range scores {
|
||||
byAct[s.Activity] = s.Value
|
||||
}
|
||||
if req.Activity != Any && byAct[req.Activity] >= 50 {
|
||||
return req.Activity
|
||||
}
|
||||
best, bestV := Indoor, -1
|
||||
for _, act := range Outdoor {
|
||||
v := byAct[act]
|
||||
if act == Walk { // a stroll is an easy workout
|
||||
v += map[Intensity]int{Easy: 5, Moderate: -10, Hard: -20}[req.Intensity]
|
||||
}
|
||||
if v > bestV {
|
||||
best, bestV = act, v
|
||||
}
|
||||
}
|
||||
if byAct[best] < IndoorBelow {
|
||||
return Indoor
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// later looks up to 12 hours ahead for a daylight window that is clearly
|
||||
// better than now for the picked (or wanted) activity.
|
||||
func later(f weather.Forecast, req Request, n int, nowScores []Score) *Later {
|
||||
acts := Outdoor
|
||||
if req.Activity != Any && req.Activity != Indoor {
|
||||
acts = []Activity{req.Activity}
|
||||
}
|
||||
bestNow := 0
|
||||
for _, s := range nowScores {
|
||||
if slices.Contains(acts, s.Activity) {
|
||||
bestNow = max(bestNow, s.Value)
|
||||
}
|
||||
}
|
||||
if bestNow >= 75 {
|
||||
return nil
|
||||
}
|
||||
for start := 1; start <= 12 && start+n <= len(f.Hours); start++ {
|
||||
win := f.Hours[start : start+n]
|
||||
if !win[0].IsDay {
|
||||
continue
|
||||
}
|
||||
for _, act := range acts {
|
||||
s := score(act, win)
|
||||
if s.Value >= 60 && s.Value >= bestNow+20 {
|
||||
return &Later{Start: win[0].Time, Activity: act, Score: s.Value}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// score applies simple, explainable rules to the worst hour in the window.
|
||||
func score(act Activity, hours []weather.Hour) Score {
|
||||
var (
|
||||
maxPrecip, maxGust, maxWind float64
|
||||
minFeels, maxFeels = math.Inf(1), math.Inf(-1)
|
||||
maxProb int
|
||||
thunder, ice, snow, fog bool
|
||||
dark bool
|
||||
)
|
||||
for _, h := range hours {
|
||||
maxPrecip = max(maxPrecip, h.Precip)
|
||||
maxGust = max(maxGust, h.Gusts)
|
||||
maxWind = max(maxWind, h.Wind)
|
||||
minFeels = min(minFeels, h.Feels)
|
||||
maxFeels = max(maxFeels, h.Feels)
|
||||
maxProb = max(maxProb, h.PrecipProb)
|
||||
thunder = thunder || h.Code >= 95
|
||||
ice = ice || h.Code == 56 || h.Code == 57 || h.Code == 66 || h.Code == 67
|
||||
snow = snow || (h.Code >= 71 && h.Code <= 77) || h.Code == 85 || h.Code == 86
|
||||
fog = fog || h.Code == 45 || h.Code == 48
|
||||
dark = dark || !h.IsDay
|
||||
}
|
||||
|
||||
s := Score{Activity: act, Value: 100}
|
||||
take := func(points int, why string) {
|
||||
if points > 0 {
|
||||
s.Value -= points
|
||||
s.Reasons = append(s.Reasons, why)
|
||||
}
|
||||
}
|
||||
// per activity: ride, run, walk
|
||||
by := func(ride, run, walk int) int {
|
||||
switch act {
|
||||
case Ride:
|
||||
return ride
|
||||
case Run:
|
||||
return run
|
||||
}
|
||||
return walk
|
||||
}
|
||||
|
||||
if thunder {
|
||||
take(100, "thunderstorms: stay off open ground")
|
||||
}
|
||||
switch {
|
||||
case maxPrecip >= 2:
|
||||
take(by(70, 65, 65), fmt.Sprintf("heavy rain (up to %.1f mm/h)", maxPrecip))
|
||||
case maxPrecip >= 0.5:
|
||||
take(by(40, 30, 30), fmt.Sprintf("rain (up to %.1f mm/h)", maxPrecip))
|
||||
case maxPrecip >= 0.1:
|
||||
take(by(15, 5, 10), "light drizzle")
|
||||
case maxProb >= 60:
|
||||
take(10, fmt.Sprintf("%d%% chance of rain", maxProb))
|
||||
}
|
||||
switch {
|
||||
case maxGust >= 60:
|
||||
take(by(70, 40, 35), fmt.Sprintf("storm gusts up to %.0f km/h", maxGust))
|
||||
case maxGust >= 45:
|
||||
take(by(40, 15, 10), fmt.Sprintf("strong gusts up to %.0f km/h", maxGust))
|
||||
case maxGust >= 35:
|
||||
take(by(20, 5, 0), fmt.Sprintf("gusty, up to %.0f km/h", maxGust))
|
||||
}
|
||||
if maxWind >= 25 {
|
||||
take(by(15, 5, 0), fmt.Sprintf("steady %.0f km/h wind", maxWind))
|
||||
}
|
||||
switch {
|
||||
case minFeels < -5:
|
||||
take(by(50, 30, 35), fmt.Sprintf("bitter cold (feels like %.0f°C)", minFeels))
|
||||
case minFeels < 2:
|
||||
take(by(20, 5, 10), fmt.Sprintf("cold (feels like %.0f°C)", minFeels))
|
||||
}
|
||||
switch {
|
||||
case maxFeels > 30:
|
||||
take(by(15, 40, 20), fmt.Sprintf("hot (feels like %.0f°C)", maxFeels))
|
||||
case maxFeels > 26:
|
||||
take(by(5, 15, 5), fmt.Sprintf("warm (feels like %.0f°C)", maxFeels))
|
||||
}
|
||||
if ice {
|
||||
take(by(60, 40, 30), "freezing rain: slippery")
|
||||
} else if snow {
|
||||
take(by(50, 20, 10), "snow")
|
||||
}
|
||||
if fog {
|
||||
take(by(15, 5, 5), "fog: low visibility")
|
||||
}
|
||||
if dark {
|
||||
take(by(10, 5, 10), "dark: bring lights and something reflective")
|
||||
}
|
||||
s.Value = max(0, s.Value)
|
||||
return s
|
||||
}
|
||||
|
||||
// target turns time and intensity into a distance at a sensible pace.
|
||||
func target(act Activity, in Intensity, minutes int) (km float64, pace string) {
|
||||
i := map[Intensity]int{Easy: 0, Moderate: 1, Hard: 2}[in]
|
||||
h := float64(minutes) / 60
|
||||
switch act {
|
||||
case Run:
|
||||
minPerKm := []float64{6.5, 5.75, 5.0}[i]
|
||||
km = float64(minutes) / minPerKm
|
||||
pace = fmt.Sprintf("about %d:%02d min/km", int(minPerKm), int(math.Round((minPerKm-math.Floor(minPerKm))*60)))
|
||||
case Ride:
|
||||
kmh := []float64{18, 24, 28}[i]
|
||||
km = h * kmh
|
||||
pace = fmt.Sprintf("about %.0f km/h", kmh)
|
||||
case Walk:
|
||||
kmh := []float64{4.5, 5, 5.5}[i]
|
||||
km = h * kmh
|
||||
pace = fmt.Sprintf("about %.1f km/h", kmh)
|
||||
default:
|
||||
return 0, ""
|
||||
}
|
||||
return math.Round(km*2) / 2, pace
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package coach
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// forecast builds a 24-hour forecast where every hour looks like h, then
|
||||
// lets the test tweak individual hours.
|
||||
func forecast(h weather.Hour, tweak func(i int, h *weather.Hour)) weather.Forecast {
|
||||
start := time.Date(2026, 10, 11, 14, 0, 0, 0, time.UTC)
|
||||
f := weather.Forecast{Sunset: start.Add(5 * time.Hour)}
|
||||
for i := range 24 {
|
||||
x := h
|
||||
x.Time = start.Add(time.Duration(i) * time.Hour)
|
||||
x.IsDay = i < 5
|
||||
if tweak != nil {
|
||||
tweak(i, &x)
|
||||
}
|
||||
f.Hours = append(f.Hours, x)
|
||||
}
|
||||
f.Now = f.Hours[0]
|
||||
return f
|
||||
}
|
||||
|
||||
var nice = weather.Hour{Temp: 15, Feels: 14, Wind: 10, Gusts: 20, Code: 1}
|
||||
|
||||
func TestAssess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
f weather.Forecast
|
||||
req Request
|
||||
pick Activity
|
||||
check func(t *testing.T, a Assessment)
|
||||
}{
|
||||
{
|
||||
name: "nice weather, anything goes",
|
||||
f: forecast(nice, nil),
|
||||
req: Request{Intensity: Moderate, Minutes: 60},
|
||||
pick: Run,
|
||||
},
|
||||
{
|
||||
name: "wanted activity is honoured when fine",
|
||||
f: forecast(nice, nil),
|
||||
req: Request{Activity: Ride, Intensity: Hard, Minutes: 90},
|
||||
pick: Ride,
|
||||
check: func(t *testing.T, a Assessment) {
|
||||
if a.TargetKm != 42 {
|
||||
t.Errorf("90 min hard ride target = %v km, want 42", a.TargetKm)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "windy: running beats cycling",
|
||||
f: forecast(weather.Hour{Temp: 12, Feels: 9, Wind: 30, Gusts: 50, Code: 3}, nil),
|
||||
req: Request{Intensity: Moderate, Minutes: 45},
|
||||
pick: Run,
|
||||
check: func(t *testing.T, a Assessment) {
|
||||
if ride, run := a.ScoreOf(Ride).Value, a.ScoreOf(Run).Value; ride >= run {
|
||||
t.Errorf("ride %d should score below run %d in strong gusts", ride, run)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "storm sends you inside",
|
||||
f: forecast(weather.Hour{Temp: 8, Feels: 3, Wind: 40, Gusts: 70, Precip: 3, PrecipProb: 95, Code: 63}, nil),
|
||||
req: Request{Activity: Run, Intensity: Moderate, Minutes: 60},
|
||||
pick: Indoor,
|
||||
},
|
||||
{
|
||||
name: "thunder is a hard no",
|
||||
f: forecast(weather.Hour{Temp: 22, Feels: 22, Code: 95}, nil),
|
||||
req: Request{Intensity: Easy, Minutes: 30},
|
||||
pick: Indoor,
|
||||
},
|
||||
{
|
||||
name: "rain now, dry later: suggests the later window",
|
||||
f: forecast(nice, func(i int, h *weather.Hour) {
|
||||
if i < 2 {
|
||||
h.Precip, h.PrecipProb, h.Code = 3, 90, 63
|
||||
}
|
||||
}),
|
||||
req: Request{Activity: Ride, Intensity: Moderate, Minutes: 60},
|
||||
pick: Indoor,
|
||||
check: func(t *testing.T, a Assessment) {
|
||||
if a.Later == nil || a.Later.Start.Hour() != 16 || a.Later.Activity != Ride {
|
||||
t.Errorf("later = %+v, want a ride at 16:00", a.Later)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hard intensity doesn't pick a stroll",
|
||||
f: forecast(weather.Hour{Temp: 15, Feels: 14, Wind: 10, Gusts: 20, Precip: 0.6, Code: 61}, nil),
|
||||
req: Request{Intensity: Hard, Minutes: 60},
|
||||
pick: Run,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := Assess(tt.f, tt.req)
|
||||
if a.Pick != tt.pick {
|
||||
t.Errorf("pick = %s, want %s (scores %+v)", a.Pick, tt.pick, a.Scores)
|
||||
}
|
||||
if tt.check != nil {
|
||||
tt.check(t, a)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarkIsMentioned(t *testing.T) {
|
||||
f := forecast(nice, nil)
|
||||
f.Hours = f.Hours[4:] // 18:00, the last daylight hour, then night
|
||||
f.Now = f.Hours[0]
|
||||
s := Assess(f, Request{Intensity: Easy, Minutes: 90}).ScoreOf(Run)
|
||||
if !strings.Contains(strings.Join(s.Reasons, ","), "dark") {
|
||||
t.Errorf("reasons %v should mention the dark", s.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetPace(t *testing.T) {
|
||||
km, pace := target(Run, Moderate, 60)
|
||||
if km != 10.5 || pace != "about 5:45 min/km" {
|
||||
t.Errorf("got %v %q", km, pace)
|
||||
}
|
||||
if km, _ := target(Indoor, Hard, 60); km != 0 {
|
||||
t.Errorf("indoor target = %v, want 0", km)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactsCarryTheNumbers(t *testing.T) {
|
||||
f := forecast(nice, nil)
|
||||
p := Plan{Place: "Gent", Forecast: f, Request: Request{Intensity: Moderate, Minutes: 60, Note: "sore knee"},
|
||||
Assessment: Assess(f, Request{Intensity: Moderate, Minutes: 60}), RoutesLoaded: true}
|
||||
got := p.SystemPrompt()
|
||||
for _, want := range []string{"Gent", "15°C", "sore knee", "The app's pick: running", "about 10.5 km", "Routes: none found nearby"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("system prompt lacks %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package coach
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/routes"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// Plan is everything the model needs to know, gathered before it speaks.
|
||||
type Plan struct {
|
||||
Place string
|
||||
At routes.Point // exact; only used on this machine
|
||||
Forecast weather.Forecast
|
||||
Request Request
|
||||
Assessment Assessment
|
||||
RoutesLoaded bool
|
||||
Routes []routes.Suggestion
|
||||
Junction *JunctionInfo
|
||||
RoutesErr string
|
||||
}
|
||||
|
||||
// JunctionInfo is the nearest node-network junction to start from.
|
||||
type JunctionInfo struct {
|
||||
Ref string
|
||||
Kind routes.Kind
|
||||
Km float64
|
||||
}
|
||||
|
||||
// FirstQuestion is the hidden user turn that asks for the plan.
|
||||
const FirstQuestion = "What should I do right now?"
|
||||
|
||||
// FollowUpHint is added to follow-up questions sent to the model.
|
||||
const FollowUpHint = "Answer in a few short lines, not the full plan format. Only change what I asked."
|
||||
|
||||
const persona = `You are the coach inside "workout-suggester", an app that gets people off their screen and outside.
|
||||
The app has already checked the weather and scored each activity with fixed rules. Trust the FACTS below and never invent other numbers, temperatures, times or route names.
|
||||
Be warm, direct and brief: under 180 words, Markdown, no tables, no preamble.`
|
||||
|
||||
const firstReplyFormat = `Answer the first question in exactly this shape:
|
||||
**<emoji> <the pick, e.g. "Go for a run">**: one sentence on why, citing the weather.
|
||||
**When:** now, or the better window from the FACTS if there is one.
|
||||
**The session:** 3–5 bullets: warm-up, a main set that matches the intensity and the minutes, cool-down. Hard = intervals or tempo; easy = conversational pace.
|
||||
**Route:** recommend the first listed route by its name and say how to use it (from the door, laps, out and back); mention its climb if it has one and fits the user's note. If no routes are listed, suggest an easy out-and-back from the door.
|
||||
**Wear:** one line for this weather.
|
||||
**Plan B:** one line.
|
||||
|
||||
If the pick is an indoor workout, give a concrete HIIT, bodyweight or gym session for the minutes and intensity under **The session**, skip **Route** and **Wear**, and say under **When** whether it gets nicer outside later.`
|
||||
|
||||
// SystemPrompt is the persona plus the facts and the answer format.
|
||||
func (p Plan) SystemPrompt() string {
|
||||
return persona + "\n\n" + p.Facts() + "\n\n" + firstReplyFormat
|
||||
}
|
||||
|
||||
// Facts is a compact, model-friendly summary of the plan.
|
||||
func (p Plan) Facts() string {
|
||||
var b strings.Builder
|
||||
f, a, r := p.Forecast, p.Assessment, p.Request
|
||||
w := func(format string, args ...any) { fmt.Fprintf(&b, format+"\n", args...) }
|
||||
desc, _ := weather.Describe(f.Now.Code)
|
||||
|
||||
w("FACTS")
|
||||
where := p.Place
|
||||
if where == "" {
|
||||
where = "the user's location"
|
||||
}
|
||||
w("- Place: %s. Local time: %s. Sunset: %s.", where, f.Now.Time.Format("Mon 15:04"), f.Sunset.Format("15:04"))
|
||||
gusts := fmt.Sprintf("gusts %.0f km/h", f.Now.Gusts)
|
||||
if f.GustsEstimated {
|
||||
gusts = fmt.Sprintf("gusts around %.0f km/h (estimated)", f.Now.Gusts)
|
||||
}
|
||||
chance := ""
|
||||
if f.Now.PrecipProb >= 0 {
|
||||
chance = fmt.Sprintf(", rain chance %d%%", f.Now.PrecipProb)
|
||||
}
|
||||
w("- Weather now: %.0f°C (feels like %.0f°C), %s, wind %.0f km/h from the %s, %s, rain %.1f mm%s.",
|
||||
f.Now.Temp, f.Now.Feels, desc, f.Now.Wind, weather.Compass(f.Now.WindDirection), gusts, f.Now.Precip, chance)
|
||||
w("- User wants: %s, %s intensity, %d minutes.", r.Activity.Noun(), r.Intensity, r.Minutes)
|
||||
if r.Note != "" {
|
||||
w("- User note: %q", r.Note)
|
||||
}
|
||||
w("- Scores for the next %d minutes (0-100, rules-based):", r.Minutes)
|
||||
for _, s := range a.Scores {
|
||||
why := "good conditions"
|
||||
if len(s.Reasons) > 0 {
|
||||
why = strings.Join(s.Reasons, "; ")
|
||||
}
|
||||
w(" - %s: %d (%s): %s", s.Activity.Noun(), s.Value, s.Verdict(), why)
|
||||
}
|
||||
w("- The app's pick: %s.", a.Pick.Noun())
|
||||
if r.Activity != Any && r.Activity != a.Pick {
|
||||
w("- The user asked for %s but the weather makes it a poor choice right now; explain kindly.", r.Activity.Noun())
|
||||
}
|
||||
if a.Later != nil {
|
||||
w("- Better window: %s scores %d from %s.", a.Later.Activity.Noun(), a.Later.Score, a.Later.Start.Format("15:04"))
|
||||
} else {
|
||||
w("- Better window later today: none, now is as good as it gets.")
|
||||
}
|
||||
if a.TargetKm > 0 {
|
||||
w("- Target: about %.1f km at %s.", a.TargetKm, a.Pace)
|
||||
}
|
||||
if a.Pick != Indoor {
|
||||
switch {
|
||||
case len(p.Routes) > 0:
|
||||
w("- Routes, best fit first:")
|
||||
for i, s := range p.Routes {
|
||||
r := s.Route
|
||||
if r.Generated {
|
||||
extra := fmt.Sprintf(", %d m of climbing", r.AscentM)
|
||||
if r.PavedPct >= 0 {
|
||||
extra += fmt.Sprintf(", %d%% paved", r.PavedPct)
|
||||
}
|
||||
if r.IntoWind(f.Now.WindDirection) {
|
||||
extra += ", heads out into the wind so the way home has a tailwind"
|
||||
}
|
||||
w(" %d. %q: a loop from the user's door, %.1f km%s.", i+1, r.Name, r.LengthKm, extra)
|
||||
continue
|
||||
}
|
||||
loop := ""
|
||||
if r.Loop {
|
||||
loop = ", loop"
|
||||
}
|
||||
w(" %d. %q: signposted route, %.1f km%s, %.1f km from the user; suggested: %s (≈%.1f km).",
|
||||
i+1, r.Name, r.LengthKm, loop, s.FromYouKm, s.Describe(), s.TotalKm)
|
||||
}
|
||||
case p.RoutesErr != "":
|
||||
w("- Routes: the map service couldn't be reached, so no routes are listed.")
|
||||
default:
|
||||
w("- Routes: none found nearby.")
|
||||
}
|
||||
if j := p.Junction; j != nil {
|
||||
kind := "walking"
|
||||
if j.Kind == routes.Bike {
|
||||
kind = "cycling"
|
||||
}
|
||||
w("- Nearest %s junction (knooppunt): number %s, %.1f km away.", kind, j.Ref, j.Km)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Package config loads workout-suggester settings from the environment and
|
||||
// an optional .env file.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all runtime settings.
|
||||
type Config struct {
|
||||
Addr string // ADDR: where the web UI listens
|
||||
BaseURL string // LLM_BASE_URL: OpenAI-compatible API root, e.g. http://127.0.0.1:8000/v1
|
||||
APIKey string // LLM_API_KEY: optional bearer token for the model server
|
||||
Model string // LLM_MODEL: model id; empty means "first model the server lists"
|
||||
Timeout time.Duration // LLM_TIMEOUT: upper bound for a single reply
|
||||
OverpassURL string // OVERPASS_URL: OpenStreetMap Overpass API endpoint
|
||||
BRouterURL string // BROUTER_URL: BRouter endpoint for loops from your door; "-" disables
|
||||
DefaultPlace string // DEFAULT_PLACE: prefilled town in the form
|
||||
ForecastURL string // OPEN_METEO_URL: forecast endpoint (Open-Meteo is self-hostable)
|
||||
GeocodeURL string // OPEN_METEO_GEOCODE_URL: place search endpoint
|
||||
}
|
||||
|
||||
// Load reads the configuration from environment variables, applying defaults.
|
||||
func Load() (Config, error) {
|
||||
c := Config{
|
||||
Addr: env("ADDR", "127.0.0.1:3000"),
|
||||
BaseURL: strings.TrimRight(env("LLM_BASE_URL", "http://127.0.0.1:8000/v1"), "/"),
|
||||
APIKey: os.Getenv("LLM_API_KEY"),
|
||||
Model: os.Getenv("LLM_MODEL"),
|
||||
Timeout: 3 * time.Minute,
|
||||
OverpassURL: env("OVERPASS_URL", "https://overpass-api.de/api/interpreter"),
|
||||
BRouterURL: env("BROUTER_URL", "https://brouter.de/brouter"),
|
||||
DefaultPlace: os.Getenv("DEFAULT_PLACE"),
|
||||
ForecastURL: env("OPEN_METEO_URL", "https://api.open-meteo.com/v1/forecast"),
|
||||
GeocodeURL: env("OPEN_METEO_GEOCODE_URL", "https://geocoding-api.open-meteo.com/v1/search"),
|
||||
}
|
||||
if v := os.Getenv("LLM_TIMEOUT"); v != "" {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("LLM_TIMEOUT: %w", err)
|
||||
}
|
||||
c.Timeout = d
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// LoadEnvFile sets variables from a KEY=VALUE file. Variables that are
|
||||
// already set in the environment win, so the real environment can override
|
||||
// the file. Blank lines and lines starting with # are ignored; values may be
|
||||
// wrapped in single or double quotes.
|
||||
func LoadEnvFile(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for n := 1; sc.Scan(); n++ {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(strings.TrimPrefix(line, "export "), "=")
|
||||
if !ok {
|
||||
return fmt.Errorf("%s:%d: expected KEY=VALUE", path, n)
|
||||
}
|
||||
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
|
||||
if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] {
|
||||
val = val[1 : len(val)-1]
|
||||
}
|
||||
if _, set := os.LookupEnv(key); !set {
|
||||
os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Package llm is a minimal client for OpenAI-compatible chat APIs, as served
|
||||
// by llama.cpp, oMLX, Ollama, LM Studio, Lemonade, vLLM and friends.
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Message is one chat turn.
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "system", "user" or "assistant"
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// Client talks to a single OpenAI-compatible endpoint.
|
||||
type Client struct {
|
||||
BaseURL string // e.g. http://127.0.0.1:8000/v1
|
||||
APIKey string
|
||||
Model string // empty: use the first model the server lists
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Models lists the model ids the server offers. It doubles as a health check.
|
||||
func (c *Client) Models(ctx context.Context) ([]string, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodGet, "/models", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := checkStatus(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode models: %w", err)
|
||||
}
|
||||
ids := make([]string, len(out.Data))
|
||||
for i, m := range out.Data {
|
||||
ids[i] = m.ID
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ResolveModel returns the configured model, or the first one the server
|
||||
// lists when none is configured.
|
||||
func (c *Client) ResolveModel(ctx context.Context) (string, error) {
|
||||
if c.Model != "" {
|
||||
return c.Model, nil
|
||||
}
|
||||
ids, err := c.Models(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return "", errors.New("model server lists no models; set LLM_MODEL")
|
||||
}
|
||||
return ids[0], nil
|
||||
}
|
||||
|
||||
// Stream sends the conversation and calls onDelta for every chunk of the
|
||||
// reply as it arrives. It returns when the reply is complete, the context is
|
||||
// cancelled, or onDelta returns an error.
|
||||
func (c *Client) Stream(ctx context.Context, msgs []Message, onDelta func(string) error) error {
|
||||
model, err := c.ResolveModel(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": model,
|
||||
"messages": msgs,
|
||||
"stream": true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := checkStatus(resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
data, ok := strings.CutPrefix(sc.Text(), "data:")
|
||||
if !ok {
|
||||
continue // blank separators, comments, "event:" lines
|
||||
}
|
||||
data = strings.TrimSpace(data)
|
||||
if data == "[DONE]" {
|
||||
return nil
|
||||
}
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
return fmt.Errorf("decode stream chunk: %w", err)
|
||||
}
|
||||
if chunk.Error != nil {
|
||||
return fmt.Errorf("model server: %s", chunk.Error.Message)
|
||||
}
|
||||
for _, ch := range chunk.Choices {
|
||||
if ch.Delta.Content == "" {
|
||||
continue
|
||||
}
|
||||
if err := onDelta(ch.Delta.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil // stream ended without [DONE]; treat what we got as the reply
|
||||
}
|
||||
|
||||
func checkStatus(resp *http.Response) error {
|
||||
if resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("model server returned %s: %s", resp.Status, bytes.TrimSpace(msg))
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeServer mimics an OpenAI-compatible server that streams the given chunks.
|
||||
func fakeServer(t *testing.T, chunks []string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/models":
|
||||
fmt.Fprint(w, `{"data":[{"id":"tiny-model"},{"id":"other"}]}`)
|
||||
case "/v1/chat/completions":
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
|
||||
http.Error(w, `{"error":"bad key"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
Msgs []Message `json:"messages"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
if req.Model != "tiny-model" || !req.Stream || len(req.Msgs) != 1 {
|
||||
t.Errorf("unexpected request: %+v", req)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprint(w, ": keep-alive comment\n\n")
|
||||
fmt.Fprint(w, `data: {"choices":[{"delta":{"role":"assistant"}}]}`+"\n\n")
|
||||
for _, c := range chunks {
|
||||
b, _ := json.Marshal(c)
|
||||
fmt.Fprintf(w, `data: {"choices":[{"delta":{"content":%s}}]}`+"\n\n", b)
|
||||
}
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestStream(t *testing.T) {
|
||||
srv := fakeServer(t, []string{"Hel", "lo", "\n", "world"})
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{BaseURL: srv.URL + "/v1", APIKey: "secret"} // no model: auto-pick first
|
||||
var got strings.Builder
|
||||
err := c.Stream(context.Background(), []Message{{Role: "user", Content: "hi"}}, func(d string) error {
|
||||
got.WriteString(d)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.String() != "Hello\nworld" {
|
||||
t.Fatalf("got %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamHTTPError(t *testing.T) {
|
||||
srv := fakeServer(t, nil)
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{BaseURL: srv.URL + "/v1", APIKey: "wrong", Model: "tiny-model"}
|
||||
err := c.Stream(context.Background(), []Message{{Role: "user", Content: "hi"}}, func(string) error { return nil })
|
||||
if err == nil || !strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("want 401 error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamCallbackErrorStops(t *testing.T) {
|
||||
srv := fakeServer(t, []string{"a", "b", "c"})
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{BaseURL: srv.URL + "/v1", APIKey: "secret"}
|
||||
stop := fmt.Errorf("stop")
|
||||
n := 0
|
||||
err := c.Stream(context.Background(), []Message{{Role: "user", Content: "hi"}}, func(string) error {
|
||||
n++
|
||||
return stop
|
||||
})
|
||||
if err != stop || n != 1 {
|
||||
t.Fatalf("err=%v n=%d", err, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveModel(t *testing.T) {
|
||||
srv := fakeServer(t, nil)
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{BaseURL: srv.URL + "/v1"}
|
||||
if m, err := c.ResolveModel(context.Background()); err != nil || m != "tiny-model" {
|
||||
t.Fatalf("auto: %q %v", m, err)
|
||||
}
|
||||
c.Model = "pinned"
|
||||
if m, _ := c.ResolveModel(context.Background()); m != "pinned" {
|
||||
t.Fatalf("pinned: %q", m)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Package routes finds signposted cycling, walking and running routes near a
|
||||
// point using OpenStreetMap data from the Overpass API, and picks the ones
|
||||
// that fit a target distance.
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kind is what a route is signposted for.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
Bike Kind = "bike"
|
||||
Foot Kind = "foot"
|
||||
)
|
||||
|
||||
// Point is a WGS84 coordinate.
|
||||
type Point struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
|
||||
// Route is a named OSM route relation with its geometry.
|
||||
type Route struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind Kind `json:"kind"`
|
||||
LengthKm float64 `json:"length_km"`
|
||||
Loop bool `json:"loop"`
|
||||
Ways [][]Point `json:"ways"` // unordered pieces, as OSM stores them
|
||||
|
||||
// Set for loops generated by BRouter rather than signposted in OSM.
|
||||
Generated bool `json:"generated,omitempty"`
|
||||
Heading int `json:"heading,omitempty"` // direction it sets off in, degrees
|
||||
AscentM int `json:"ascent_m,omitempty"` // total climb
|
||||
PavedPct int `json:"paved_pct,omitempty"` // -1 when unknown
|
||||
}
|
||||
|
||||
// Junction is a numbered node of the Belgian/Dutch node networks
|
||||
// (fietsknooppunt / wandelknooppunt).
|
||||
type Junction struct {
|
||||
Ref string `json:"ref"`
|
||||
Kind Kind `json:"kind"`
|
||||
Point Point `json:"point"`
|
||||
}
|
||||
|
||||
// Area holds everything found around one spot.
|
||||
type Area struct {
|
||||
Routes []Route `json:"routes"`
|
||||
Junctions []Junction `json:"junctions"`
|
||||
Fetched time.Time `json:"fetched"`
|
||||
}
|
||||
|
||||
// Client queries Overpass for signposted routes, keeping results in memory
|
||||
// and on disk so an area you've loaded once keeps working without a
|
||||
// connection, and BRouter for loops generated from your door.
|
||||
type Client struct {
|
||||
URLs []string // Overpass endpoints, tried in order
|
||||
BRouterURL string // e.g. https://brouter.de/brouter; empty or "-" disables loops
|
||||
HTTP *http.Client
|
||||
CacheDir string // empty: no disk cache
|
||||
|
||||
mu sync.Mutex
|
||||
areas map[string]*Area
|
||||
byID map[int64]*Route
|
||||
locks map[string]*sync.Mutex
|
||||
loops map[string]cachedLoops
|
||||
loopSeq int64
|
||||
}
|
||||
|
||||
func (c *Client) initLocked() {
|
||||
if c.byID == nil {
|
||||
c.areas, c.byID, c.locks, c.loops = map[string]*Area{}, map[int64]*Route{}, map[string]*sync.Mutex{}, map[string]cachedLoops{}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
bikeRadius = 15000 // m
|
||||
footRadius = 8000 // m
|
||||
junctionRadius = 3000 // m
|
||||
cacheFor = 7 * 24 * time.Hour
|
||||
userAgent = "workout-suggester/0.1 (+https://git.b0b.be/bdeb/workout-suggester)"
|
||||
)
|
||||
|
||||
// cell snaps a coordinate to a ~2 km grid so nearby requests share a cache
|
||||
// entry and the exact location never leaves the machine.
|
||||
func cell(lat, lon float64) (float64, float64, string) {
|
||||
lat, lon = math.Round(lat*50)/50, math.Round(lon*50)/50
|
||||
return lat, lon, fmt.Sprintf("%.2f_%.2f", lat, lon)
|
||||
}
|
||||
|
||||
// Near returns the routes and junctions around a point.
|
||||
func (c *Client) Near(ctx context.Context, lat, lon float64) (*Area, error) {
|
||||
clat, clon, key := cell(lat, lon)
|
||||
|
||||
// One fetch per cell at a time; concurrent callers wait for it.
|
||||
c.mu.Lock()
|
||||
c.initLocked()
|
||||
l, ok := c.locks[key]
|
||||
if !ok {
|
||||
l = &sync.Mutex{}
|
||||
c.locks[key] = l
|
||||
}
|
||||
c.mu.Unlock()
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
|
||||
c.mu.Lock()
|
||||
a := c.areas[key]
|
||||
c.mu.Unlock()
|
||||
if a == nil {
|
||||
a = c.readDisk(key)
|
||||
}
|
||||
if a != nil && time.Since(a.Fetched) < cacheFor {
|
||||
c.remember(key, a)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
fresh, err := c.fetch(ctx, clat, clon)
|
||||
if err != nil {
|
||||
if a != nil { // stale beats nothing
|
||||
c.remember(key, a)
|
||||
return a, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
c.remember(key, fresh)
|
||||
c.writeDisk(key, fresh)
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
// Route looks up a route seen in an earlier Near call.
|
||||
func (c *Client) Route(id int64) (*Route, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.initLocked()
|
||||
r, ok := c.byID[id]
|
||||
return r, ok
|
||||
}
|
||||
|
||||
func (c *Client) remember(key string, a *Area) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.areas[key] = a
|
||||
for i := range a.Routes {
|
||||
c.byID[a.Routes[i].ID] = &a.Routes[i]
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readDisk(key string) *Area {
|
||||
if c.CacheDir == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(c.CacheDir, key+".json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var a Area
|
||||
if json.Unmarshal(b, &a) != nil {
|
||||
return nil
|
||||
}
|
||||
return &a
|
||||
}
|
||||
|
||||
func (c *Client) writeDisk(key string, a *Area) {
|
||||
if c.CacheDir == "" {
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(a)
|
||||
if err != nil || os.MkdirAll(c.CacheDir, 0o755) != nil {
|
||||
return
|
||||
}
|
||||
tmp := filepath.Join(c.CacheDir, key+".json.tmp")
|
||||
if os.WriteFile(tmp, b, 0o644) == nil {
|
||||
os.Rename(tmp, filepath.Join(c.CacheDir, key+".json"))
|
||||
}
|
||||
}
|
||||
|
||||
func query(lat, lon float64) string {
|
||||
at := func(r int) string { return fmt.Sprintf("(around:%d,%.4f,%.4f)", r, lat, lon) }
|
||||
return `[out:json][timeout:25];(` +
|
||||
`relation["type"="route"]["route"="bicycle"]["network"~"^(lcn|rcn)$"]["network:type"!="node_network"]` + at(bikeRadius) + `;` +
|
||||
`relation["type"="route"]["route"~"^(hiking|foot|walking|running)$"]["network"~"^(lwn|rwn)$"]["network:type"!="node_network"]` + at(footRadius) + `;` +
|
||||
`);out geom;(` +
|
||||
`node["rcn_ref"]` + at(junctionRadius) + `;` +
|
||||
`node["rwn_ref"]` + at(junctionRadius) + `;` +
|
||||
`);out;`
|
||||
}
|
||||
|
||||
// fetch tries each Overpass endpoint, retrying busy servers a couple of times.
|
||||
func (c *Client) fetch(ctx context.Context, lat, lon float64) (*Area, error) {
|
||||
hc := c.HTTP
|
||||
if hc == nil {
|
||||
hc = http.DefaultClient
|
||||
}
|
||||
body := url.Values{"data": {query(lat, lon)}}.Encode()
|
||||
var errs []error
|
||||
for attempt := range 3 {
|
||||
for _, u := range c.URLs {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json") // overpass-api.de answers 406 without it
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
errs = append(errs, fmt.Errorf("%s: %s", u, resp.Status))
|
||||
continue
|
||||
}
|
||||
a, err := parse(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
select { // busy servers (429/504) usually recover within seconds
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt+1) * 2 * time.Second):
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("overpass: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
func parse(r io.Reader) (*Area, error) {
|
||||
var out struct {
|
||||
Elements []struct {
|
||||
Type string `json:"type"`
|
||||
ID int64 `json:"id"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
Members []struct {
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Geometry []Point `json:"geometry"`
|
||||
} `json:"members"`
|
||||
} `json:"elements"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if err := json.NewDecoder(r).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode overpass: %w", err)
|
||||
}
|
||||
if strings.Contains(out.Remark, "runtime error") {
|
||||
return nil, fmt.Errorf("overpass: %s", out.Remark)
|
||||
}
|
||||
a := &Area{Fetched: time.Now()}
|
||||
for _, e := range out.Elements {
|
||||
t := e.Tags
|
||||
switch e.Type {
|
||||
case "node":
|
||||
if ref := t["rcn_ref"]; ref != "" {
|
||||
a.Junctions = append(a.Junctions, Junction{Ref: ref, Kind: Bike, Point: Point{e.Lat, e.Lon}})
|
||||
}
|
||||
if ref := t["rwn_ref"]; ref != "" {
|
||||
a.Junctions = append(a.Junctions, Junction{Ref: ref, Kind: Foot, Point: Point{e.Lat, e.Lon}})
|
||||
}
|
||||
case "relation":
|
||||
name := t["name"]
|
||||
if name == "" {
|
||||
name = t["ref"]
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
r := Route{ID: e.ID, Name: name, Kind: Foot, Loop: t["roundtrip"] == "yes"}
|
||||
if t["route"] == "bicycle" {
|
||||
r.Kind = Bike
|
||||
}
|
||||
for _, m := range e.Members {
|
||||
if m.Type != "way" || len(m.Geometry) < 2 {
|
||||
continue
|
||||
}
|
||||
r.LengthKm += pathKm(m.Geometry)
|
||||
r.Ways = append(r.Ways, simplify(m.Geometry, 0.015))
|
||||
}
|
||||
if r.LengthKm < 0.5 {
|
||||
continue
|
||||
}
|
||||
r.LengthKm = math.Round(r.LengthKm*10) / 10
|
||||
a.Routes = append(a.Routes, r)
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Km is the great-circle distance between two points.
|
||||
func Km(a, b Point) float64 {
|
||||
const R = 6371.0
|
||||
la1, la2 := a.Lat*math.Pi/180, b.Lat*math.Pi/180
|
||||
dla, dlo := la2-la1, (b.Lon-a.Lon)*math.Pi/180
|
||||
h := math.Sin(dla/2)*math.Sin(dla/2) + math.Cos(la1)*math.Cos(la2)*math.Sin(dlo/2)*math.Sin(dlo/2)
|
||||
return 2 * R * math.Asin(math.Sqrt(h))
|
||||
}
|
||||
|
||||
func pathKm(pts []Point) float64 {
|
||||
var d float64
|
||||
for i := 1; i < len(pts); i++ {
|
||||
d += Km(pts[i-1], pts[i])
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// simplify drops points closer than minKm to the last kept one, which is
|
||||
// plenty for a map or a GPX track and keeps the page small.
|
||||
func simplify(pts []Point, minKm float64) []Point {
|
||||
out := []Point{pts[0]}
|
||||
for i := 1; i < len(pts)-1; i++ {
|
||||
if Km(out[len(out)-1], pts[i]) >= minKm {
|
||||
out = append(out, pts[i])
|
||||
}
|
||||
}
|
||||
return append(out, pts[len(pts)-1])
|
||||
}
|
||||
|
||||
// Closest returns the point of the route nearest to p and its distance.
|
||||
func (r *Route) Closest(p Point) (Point, float64) {
|
||||
best, bestKm := Point{}, math.Inf(1)
|
||||
for _, w := range r.Ways {
|
||||
for _, q := range w {
|
||||
if d := Km(p, q); d < bestKm {
|
||||
best, bestKm = q, d
|
||||
}
|
||||
}
|
||||
}
|
||||
return best, bestKm
|
||||
}
|
||||
|
||||
// NearestJunction finds the closest node-network junction of a kind.
|
||||
func (a *Area) NearestJunction(kind Kind, p Point) (Junction, float64, bool) {
|
||||
var best Junction
|
||||
bestKm := math.Inf(1)
|
||||
for _, j := range a.Junctions {
|
||||
if j.Kind != kind {
|
||||
continue
|
||||
}
|
||||
if d := Km(p, j.Point); d < bestKm {
|
||||
best, bestKm = j, d
|
||||
}
|
||||
}
|
||||
return best, bestKm, !math.IsInf(bestKm, 1)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// line returns a straight path of about km kilometres going east from p.
|
||||
func line(p Point, km float64) []Point {
|
||||
const kmPerDegLon = 70.0 // at ~51°N
|
||||
return []Point{p, {p.Lat, p.Lon + km/kmPerDegLon/2}, {p.Lat, p.Lon + km/kmPerDegLon}}
|
||||
}
|
||||
|
||||
const overpassJSON = `{"elements":[
|
||||
{"type":"relation","id":1,"tags":{"route":"foot","name":"Park loop","roundtrip":"yes"},
|
||||
"members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.7357}]}]},
|
||||
{"type":"relation","id":2,"tags":{"route":"bicycle","name":"River ride"},
|
||||
"members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.9857}]},{"type":"node"}]},
|
||||
{"type":"relation","id":3,"tags":{"route":"foot"},"members":[]},
|
||||
{"type":"node","id":9,"lat":51.051,"lon":3.701,"tags":{"rcn_ref":"56"}}
|
||||
]}`
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
a, err := parse(strings.NewReader(overpassJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Routes) != 2 {
|
||||
t.Fatalf("got %d routes, want 2 (unnamed one skipped): %+v", len(a.Routes), a.Routes)
|
||||
}
|
||||
park, ride := a.Routes[0], a.Routes[1]
|
||||
if park.Kind != Foot || !park.Loop || park.LengthKm < 2.4 || park.LengthKm > 2.6 {
|
||||
t.Errorf("park = %+v", park)
|
||||
}
|
||||
if ride.Kind != Bike || ride.Loop || ride.LengthKm < 19.5 || ride.LengthKm > 20.5 {
|
||||
t.Errorf("ride = %+v", ride)
|
||||
}
|
||||
j, km, ok := a.NearestJunction(Bike, Point{51.05, 3.70})
|
||||
if !ok || j.Ref != "56" || km > 0.2 {
|
||||
t.Errorf("junction = %+v %.2f %v", j, km, ok)
|
||||
}
|
||||
if _, _, ok := a.NearestJunction(Foot, Point{51.05, 3.70}); ok {
|
||||
t.Error("found a walking junction that isn't there")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggest(t *testing.T) {
|
||||
home := Point{51.05, 3.70}
|
||||
a := &Area{Routes: []Route{
|
||||
{ID: 1, Name: "short loop", Kind: Foot, Loop: true, LengthKm: 5, Ways: [][]Point{line(home, 5)}},
|
||||
{ID: 2, Name: "perfect loop far away", Kind: Foot, Loop: true, LengthKm: 10, Ways: [][]Point{line(Point{51.2, 3.7}, 10)}},
|
||||
{ID: 3, Name: "long path", Kind: Foot, LengthKm: 30, Ways: [][]Point{line(home, 30)}},
|
||||
{ID: 4, Name: "bike loop", Kind: Bike, Loop: true, LengthKm: 10, Ways: [][]Point{line(home, 10)}},
|
||||
}}
|
||||
got := Suggest(a, Foot, 10, home, 3)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d suggestions, want 2 (one too far, one for bikes): %+v", len(got), got)
|
||||
}
|
||||
if got[0].Route.ID != 1 || got[0].Plan != "laps" || got[0].Laps != 2 || got[0].TotalKm != 10 {
|
||||
t.Errorf("first = %+v, want 2 laps of the short loop", got[0])
|
||||
}
|
||||
if got[1].Plan != "section" || got[1].TotalKm != 10 {
|
||||
t.Errorf("second = %+v, want a 10 km section", got[1])
|
||||
}
|
||||
if s := got[0].Describe(); s != "2 laps of the 5.0 km loop" {
|
||||
t.Errorf("describe = %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearRetriesAndCaches(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Accept") != "application/json" {
|
||||
http.Error(w, "406", http.StatusNotAcceptable)
|
||||
return
|
||||
}
|
||||
if calls.Add(1) == 1 {
|
||||
http.Error(w, "busy", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, overpassJSON)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
c := &Client{URLs: []string{srv.URL}, CacheDir: dir}
|
||||
a, err := c.Near(context.Background(), 51.05, 3.70)
|
||||
if err != nil || len(a.Routes) != 2 {
|
||||
t.Fatalf("Near = %v, %v", a, err)
|
||||
}
|
||||
if _, ok := c.Route(2); !ok {
|
||||
t.Error("route 2 not remembered for GPX")
|
||||
}
|
||||
// Same ~2 km cell: no new request. A fresh client reads the disk cache.
|
||||
c.Near(context.Background(), 51.051, 3.701)
|
||||
(&Client{URLs: []string{srv.URL}, CacheDir: dir}).Near(context.Background(), 51.05, 3.70)
|
||||
if n := calls.Load(); n != 2 {
|
||||
t.Errorf("overpass called %d times, want 2 (one busy, one ok)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteGPX(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
r := &Route{Name: "Park & loop", Ways: [][]Point{{{51, 3}, {51.1, 3.1}}, {{51.2, 3.2}, {51.3, 3.3}}}}
|
||||
if err := WriteGPX(&buf, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var g struct {
|
||||
Name string `xml:"trk>name"`
|
||||
Segs []struct {
|
||||
Pts []struct{} `xml:"trkpt"`
|
||||
} `xml:"trk>trkseg"`
|
||||
}
|
||||
if err := xml.Unmarshal(buf.Bytes(), &g); err != nil {
|
||||
t.Fatal(err, buf.String())
|
||||
}
|
||||
if g.Name != "Park & loop" || len(g.Segs) != 2 || len(g.Segs[1].Pts) != 2 {
|
||||
t.Errorf("gpx = %+v\n%s", g, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoops(t *testing.T) {
|
||||
var dirs []string
|
||||
var mu sync.Mutex
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
mu.Lock()
|
||||
dirs = append(dirs, q.Get("direction"))
|
||||
mu.Unlock()
|
||||
if q.Get("engineMode") != "4" || q.Get("profile") != "hiking-mountain" || q.Get("lonlats") != "4.700,50.880" {
|
||||
http.Error(w, "bad query "+r.URL.RawQuery, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if q.Get("roundTripDistance") != "1960" { // 10 km / 5.1
|
||||
http.Error(w, "bad distance "+q.Get("roundTripDistance"), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if q.Get("direction") == "330" {
|
||||
http.Error(w, "no route", http.StatusInternalServerError) // one direction failing is fine
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "testdata/brouter.json")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{BRouterURL: srv.URL}
|
||||
// The exact spot is rounded to ~100 m before it's sent.
|
||||
got, err := c.Loops(context.Background(), Foot, Point{50.88012, 4.70044}, 10, 90)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slices.Sort(dirs)
|
||||
if !slices.Equal(dirs, []string{"210", "330", "90"}) {
|
||||
t.Errorf("directions asked = %v, want into the wind (90) and ±120°", dirs)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d loops, want 2", len(got))
|
||||
}
|
||||
r := got[0].Route
|
||||
if !r.Generated || !r.Loop || r.LengthKm != 5.9 || r.AscentM != 17 || r.PavedPct < 0 || r.PavedPct > 100 || r.ID >= 0 {
|
||||
t.Errorf("loop = %+v", r)
|
||||
}
|
||||
if !strings.HasPrefix(r.Name, "5.9 km loop heading ") {
|
||||
t.Errorf("name = %q", r.Name)
|
||||
}
|
||||
if _, ok := c.Route(r.ID); !ok {
|
||||
t.Error("loop not remembered for GPX")
|
||||
}
|
||||
n := len(dirs)
|
||||
c.Loops(context.Background(), Foot, Point{50.88012, 4.70044}, 10, 90)
|
||||
if len(dirs) != n {
|
||||
t.Error("second identical request should hit the cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPavedPct(t *testing.T) {
|
||||
msgs := [][]string{
|
||||
{"Longitude", "Latitude", "Elevation", "Distance", "WayTags"},
|
||||
{"0", "0", "0", "300", "highway=residential"},
|
||||
{"0", "0", "0", "100", "highway=track surface=asphalt"},
|
||||
{"0", "0", "0", "400", "highway=path"},
|
||||
{"0", "0", "0", "200", "highway=footway surface=gravel"},
|
||||
}
|
||||
if got := pavedPct(msgs); got != 40 {
|
||||
t.Errorf("paved = %d%%, want 40%%", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntoWind(t *testing.T) {
|
||||
r := &Route{Generated: true, Heading: 270}
|
||||
if !r.IntoWind(250) || r.IntoWind(90) || (&Route{Heading: 270}).IntoWind(270) {
|
||||
t.Error("IntoWind wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestSkipsLongTrails(t *testing.T) {
|
||||
home := Point{51.05, 3.70}
|
||||
a := &Area{Routes: []Route{
|
||||
{ID: 1, Name: "Streek-GR", Kind: Foot, LengthKm: 130, Ways: [][]Point{line(home, 130)}},
|
||||
{ID: 3, Name: "Streek-GR loop", Kind: Foot, Loop: true, LengthKm: 130, Ways: [][]Point{line(home, 130)}},
|
||||
{ID: 2, Name: "Park loop", Kind: Foot, Loop: true, LengthKm: 8, Ways: [][]Point{line(Point{51.06, 3.70}, 8)}},
|
||||
}}
|
||||
got := Suggest(a, Foot, 10, home, 3)
|
||||
if len(got) != 1 || got[0].Route.ID != 2 { // loop or not, 130 km is not a 10 km workout
|
||||
t.Errorf("got %+v, want only the park loop", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
}
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"creator": "BRouter-1.7.10",
|
||||
"name": "brouter_hiking-mountain_0",
|
||||
"track-length": "5872",
|
||||
"filtered ascend": "17",
|
||||
"plain-ascend": "0",
|
||||
"total-time": "4214",
|
||||
"total-energy": "64086",
|
||||
"cost": "10944",
|
||||
"messages": [
|
||||
["Longitude", "Latitude", "Elevation", "Distance", "CostPerKm", "ElevCost", "TurnCost", "NodeCost", "InitialCost", "WayTags", "NodeTags", "Time", "Energy"],
|
||||
["4700029", "50879984", "23", "3", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "2", "34"],
|
||||
["4700045", "50880001", "22", "2", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones bicycle=yes", "", "3", "52"],
|
||||
["4700077", "50880030", "22", "4", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt bicycle=yes", "highway=crossing crossing=uncontrolled bicycle=yes", "6", "89"],
|
||||
["4700117", "50880063", "22", "5", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt bicycle=yes", "", "10", "135"],
|
||||
["4700293", "50880210", "22", "20", "1200", "0", "0", "0", "0", "highway=pedestrian surface=sett bicycle=yes", "", "23", "203"],
|
||||
["4700423", "50880308", "21", "14", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones bicycle=yes", "", "33", "233"],
|
||||
["4700964", "50880166", "22", "48", "1000", "0", "0", "0", "0", "highway=pedestrian surface=sett bicycle=yes route_hiking_nwn=yes route_hiking_rwn=yes", "", "67", "617"],
|
||||
["4700995", "50880188", "22", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=sett", "", "70", "646"],
|
||||
["4701350", "50880466", "22", "40", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "98", "890"],
|
||||
["4701426", "50880513", "22", "7", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=sett", "highway=crossing crossing=unmarked", "103", "937"],
|
||||
["4701462", "50880534", "22", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=sett", "", "105", "958"],
|
||||
["4701820", "50880705", "21", "31", "1260", "0", "0", "0", "0", "highway=footway surface=compacted access=no foot=yes", "", "126", "1050"],
|
||||
["4701864", "50880726", "21", "4", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=sett", "highway=crossing crossing=unmarked", "129", "1060"],
|
||||
["4701911", "50880749", "21", "4", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=sett", "", "132", "1070"],
|
||||
["4702081", "50880815", "21", "14", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "142", "1087"],
|
||||
["4702141", "50880842", "21", "5", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "crossing=unknown", "145", "1096"],
|
||||
["4702201", "50880864", "21", "5", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "", "149", "1108"],
|
||||
["4703896", "50881681", "20", "159", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "260", "1725"],
|
||||
["4703929", "50881704", "20", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "highway=crossing", "262", "1732"],
|
||||
["4703985", "50881728", "20", "5", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "", "265", "1745"],
|
||||
["4704773", "50882093", "20", "70", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "315", "2066"],
|
||||
["4704838", "50882125", "20", "6", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "crossing=unknown", "319", "2102"],
|
||||
["4704885", "50882149", "20", "4", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "", "322", "2128"],
|
||||
["4704219", "50882691", "20", "76", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "375", "2676"],
|
||||
["4704763", "50883960", "19", "147", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=concrete", "", "480", "3854"],
|
||||
["4705107", "50884096", "19", "29", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "500", "3913"],
|
||||
["4705080", "50884119", "19", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "highway=crossing crossing=unmarked", "502", "3923"],
|
||||
["4705033", "50884156", "19", "5", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "", "506", "3940"],
|
||||
["4705152", "50884205", "18", "10", "1260", "0", "0", "0", "0", "highway=footway", "", "513", "3957"],
|
||||
["4705390", "50884781", "18", "91", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway", "", "576", "4195"],
|
||||
["4705499", "50884835", "18", "9", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "", "582", "4231"],
|
||||
["4705441", "50884869", "18", "6", "1100", "0", "0", "0", "0", "highway=cycleway surface=paving_stones foot=designated bicycle=designated route_hiking_nwn=yes route_hiking_rwn=yes", "", "587", "4257"],
|
||||
["4705465", "50884885", "18", "2", "1100", "0", "0", "0", "0", "highway=cycleway surface=concrete foot=yes route_hiking_nwn=yes route_hiking_rwn=yes", "", "588", "4266"],
|
||||
["4705560", "50884949", "18", "10", "1100", "0", "0", "0", "0", "reversedirection=yes highway=cycleway surface=asphalt foot=designated bicycle=designated route_hiking_nwn=yes route_hiking_rwn=yes", "", "595", "4293"],
|
||||
["4705824", "50886096", "19", "129", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt foot=yes", "crossing=unknown", "688", "5661"],
|
||||
["4705976", "50886872", "18", "87", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt foot=yes", "", "749", "6310"],
|
||||
["4706015", "50886870", "18", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "highway=crossing", "751", "6326"],
|
||||
["4706055", "50886868", "18", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "barrier=kerb kerb=flush", "754", "6342"],
|
||||
["4706588", "50887183", "18", "54", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "792", "6729"],
|
||||
["4706710", "50887317", "19", "17", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway", "highway=crossing estimated_crossing_class=0 crossing=traffic_signals", "804", "6940"],
|
||||
["4707067", "50887727", "20", "52", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway", "", "843", "7844"],
|
||||
["4707080", "50887764", "20", "4", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "highway=crossing estimated_crossing_class=0 crossing=traffic_signals bicycle=yes", "845", "7912"],
|
||||
["4707124", "50887747", "20", "4", "16800", "0", "0", "0", "0", "reversedirection=yes highway=primary_link surface=asphalt foot=use_sidepath bicycle=use_sidepath", "highway=traffic_signals traffic_signals:direction=forward", "848", "7978"],
|
||||
["4708324", "50887251", "21", "101", "16800", "0", "0", "0", "0", "reversedirection=yes highway=primary_link surface=asphalt foot=use_sidepath bicycle=use_sidepath", "", "923", "9656"],
|
||||
["4707124", "50887747", "20", "101", "16800", "0", "0", "0", "0", "highway=primary_link surface=asphalt foot=use_sidepath bicycle=use_sidepath", "highway=traffic_signals traffic_signals:direction=forward", "994", "10312"],
|
||||
["4707080", "50887764", "20", "4", "16800", "0", "0", "0", "0", "highway=primary_link surface=asphalt foot=use_sidepath bicycle=use_sidepath", "highway=crossing estimated_crossing_class=0 crossing=traffic_signals bicycle=yes", "997", "10343"],
|
||||
["4707067", "50887727", "20", "4", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "", "1000", "10374"],
|
||||
["4706710", "50887317", "19", "52", "1260", "0", "0", "0", "0", "highway=footway", "highway=crossing estimated_crossing_class=0 crossing=traffic_signals", "1036", "10496"],
|
||||
["4706588", "50887183", "18", "17", "1260", "0", "0", "0", "0", "highway=footway", "", "1048", "10515"],
|
||||
["4711122", "50884603", "21", "441", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "1366", "15770"],
|
||||
["4711170", "50884572", "21", "5", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "highway=crossing crossing=uncontrolled", "1370", "15819"],
|
||||
["4711194", "50884557", "21", "2", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "barrier=kerb kerb=flush", "1371", "15839"],
|
||||
["4713706", "50882320", "24", "306", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "1591", "19160"],
|
||||
["4713716", "50882311", "24", "1", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "1592", "19170"],
|
||||
["4714413", "50881554", "28", "98", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "", "1668", "22011"],
|
||||
["4714572", "50881225", "29", "39", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones", "", "1698", "23027"],
|
||||
["4714622", "50881234", "29", "4", "1260", "0", "0", "0", "0", "highway=footway surface=asphalt", "crossing=unknown", "1701", "23119"],
|
||||
["4714653", "50881159", "29", "9", "1320", "0", "0", "0", "0", "highway=residential surface=concrete bicycle=yes sidewalk=both route_bicycle_lcn=yes", "", "1707", "23317"],
|
||||
["4714681", "50881090", "29", "8", "1320", "0", "0", "0", "0", "highway=residential surface=concrete bicycle=yes route_bicycle_ncn=yes route_bicycle_rcn=yes route_bicycle_lcn=yes", "crossing=unknown", "1713", "23484"],
|
||||
["4714797", "50880817", "29", "31", "1320", "0", "0", "0", "0", "highway=residential surface=concrete bicycle=yes route_bicycle_ncn=yes route_bicycle_rcn=yes route_bicycle_lcn=yes", "", "1736", "24071"],
|
||||
["4714876", "50880590", "29", "26", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=concrete bicycle=yes route_bicycle_lcn=yes", "", "1756", "24593"],
|
||||
["4715160", "50879831", "30", "87", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt bicycle=use_sidepath", "", "1820", "26149"],
|
||||
["4715101", "50879883", "30", "7", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service", "highway=give_way direction=forward", "1825", "26274"],
|
||||
["4714381", "50881499", "26", "187", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service", "", "1965", "30005"],
|
||||
["4714614", "50881537", "26", "17", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service surface=concrete", "", "1977", "30222"],
|
||||
["4713938", "50882450", "24", "114", "1440", "0", "0", "0", "0", "highway=service", "", "2057", "30932"],
|
||||
["4713540", "50882794", "24", "47", "1440", "0", "0", "0", "0", "highway=service surface=asphalt", "", "2090", "31017"],
|
||||
["4713938", "50882450", "24", "47", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service surface=asphalt", "", "2124", "31453"],
|
||||
["4714614", "50881537", "27", "114", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service", "", "2205", "32510"],
|
||||
["4714381", "50881499", "27", "17", "1440", "0", "0", "0", "0", "highway=service surface=concrete", "", "2217", "32667"],
|
||||
["4715101", "50879883", "30", "187", "1440", "0", "0", "0", "0", "highway=service", "highway=give_way direction=forward", "2357", "36467"],
|
||||
["4715160", "50879831", "30", "7", "1440", "0", "0", "0", "0", "highway=service", "", "2362", "36570"],
|
||||
["4714907", "50880454", "29", "72", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt bicycle=use_sidepath", "", "2417", "38328"],
|
||||
["4714636", "50880436", "30", "19", "1320", "0", "0", "0", "0", "reversedirection=yes highway=cycleway surface=paving_stones route_bicycle_lcn=yes", "", "2431", "38835"],
|
||||
["4712836", "50879070", "34", "198", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt foot=yes sidewalk=both", "", "2578", "42607"],
|
||||
["4712905", "50879038", "34", "6", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "highway=give_way direction=backward", "2583", "42716"],
|
||||
["4714481", "50878358", "34", "134", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "highway=crossing crossing=uncontrolled traffic_calming=table", "2680", "44525"],
|
||||
["4714517", "50878296", "35", "10", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "highway=crossing crossing=unmarked kerb=flush", "2688", "44730"],
|
||||
["4713125", "50877237", "36", "153", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "", "2802", "47900"],
|
||||
["4713457", "50877115", "36", "29", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service access=permissive", "", "2823", "48359"],
|
||||
["4713125", "50877237", "36", "29", "1440", "0", "0", "0", "0", "highway=service access=permissive", "", "2845", "48838"],
|
||||
["4712507", "50876767", "36", "68", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "highway=give_way direction=forward", "2895", "49693"],
|
||||
["4712479", "50876746", "37", "3", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "", "2897", "49741"],
|
||||
["4712501", "50876618", "37", "14", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=paving_stones junction=roundabout", "", "2907", "49956"],
|
||||
["4712445", "50876614", "37", "4", "1260", "0", "0", "0", "0", "highway=footway surface=paving_stones", "barrier=bollard bicycle=yes foot=yes motor_vehicle=no", "2910", "50016"],
|
||||
["4712448", "50876594", "37", "2", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones bicycle=yes", "barrier=bollard bicycle=yes foot=yes motor_vehicle=no", "2911", "50045"],
|
||||
["4712345", "50876516", "37", "16", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones bicycle=yes", "barrier=bollard bicycle=yes foot=yes motor_vehicle=no", "2923", "50289"],
|
||||
["4712316", "50876514", "37", "2", "1200", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones bicycle=yes", "barrier=bollard bicycle=yes foot=yes motor_vehicle=no", "2925", "50316"],
|
||||
["4712323", "50876470", "37", "5", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "2928", "50394"],
|
||||
["4712121", "50876456", "37", "14", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=paving_stones junction=roundabout", "", "2938", "50576"],
|
||||
["4712096", "50876437", "37", "3", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "highway=give_way direction=backward", "2941", "50614"],
|
||||
["4711050", "50875639", "37", "116", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3024", "51921"],
|
||||
["4711033", "50875627", "37", "2", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "highway=give_way direction=forward", "3026", "51941"],
|
||||
["4710922", "50875545", "37", "12", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3034", "52064"],
|
||||
["4710881", "50875514", "37", "4", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "highway=give_way direction=backward", "3037", "52105"],
|
||||
["4710354", "50875078", "37", "61", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "", "3081", "52710"],
|
||||
["4710315", "50875054", "37", "4", "1320", "0", "0", "0", "0", "highway=residential surface=paving_stones sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3084", "52749"],
|
||||
["4710276", "50875029", "37", "4", "1320", "0", "0", "0", "0", "highway=residential surface=paving_stones sidewalk=both", "highway=stop direction=forward", "3087", "52788"],
|
||||
["4710191", "50874972", "37", "8", "1320", "0", "0", "0", "0", "highway=residential surface=paving_stones sidewalk=both", "highway=stop direction=backward", "3092", "52866"],
|
||||
["4710148", "50874938", "37", "5", "1320", "0", "0", "0", "0", "highway=residential surface=paving_stones sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3096", "52914"],
|
||||
["4710115", "50874913", "37", "4", "1320", "0", "0", "0", "0", "highway=residential surface=paving_stones sidewalk=both", "", "3099", "52953"],
|
||||
["4709794", "50874663", "38", "36", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt", "", "3125", "53524"],
|
||||
["4709764", "50874640", "38", "3", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "barrier=bollard bicycle=yes foot=yes motor_vehicle=no", "3127", "53572"],
|
||||
["4709735", "50874620", "38", "3", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "", "3130", "53619"],
|
||||
["4708064", "50872679", "38", "253", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "", "3313", "56842"],
|
||||
["4708332", "50872658", "37", "19", "1440", "0", "0", "0", "0", "reversedirection=yes highway=service", "", "3326", "56977"],
|
||||
["4708064", "50872679", "38", "19", "1440", "0", "0", "0", "0", "highway=service", "", "3340", "57267"],
|
||||
["4708625", "50874109", "38", "184", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "traffic_calming=choker", "3471", "58852"],
|
||||
["4708058", "50874404", "37", "51", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "traffic_calming=choker", "3507", "59141"],
|
||||
["4707935", "50874469", "38", "11", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "highway=crossing", "3515", "59262"],
|
||||
["4707611", "50874644", "38", "30", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "traffic_calming=choker", "3537", "59585"],
|
||||
["4707340", "50874790", "38", "25", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "highway=give_way direction=backward", "3555", "59845"],
|
||||
["4707325", "50874798", "38", "1", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "highway=crossing crossing=unmarked", "3556", "59855"],
|
||||
["4707260", "50874830", "38", "6", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "", "3560", "59916"],
|
||||
["4707818", "50875257", "38", "62", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt sidewalk=both", "", "3604", "60533"],
|
||||
["4708336", "50875628", "38", "55", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes sidewalk=both", "highway=stop direction=forward", "3644", "61052"],
|
||||
["4708350", "50875638", "38", "1", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3644", "61062"],
|
||||
["4708412", "50875685", "38", "7", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes sidewalk=both", "", "3649", "61129"],
|
||||
["4708322", "50875717", "37", "7", "1440", "0", "0", "0", "0", "reversedirection=yes highway=tertiary surface=asphalt sidewalk=both", "highway=crossing crossing=uncontrolled kerb=lowered", "3654", "61182"],
|
||||
["4706094", "50876714", "38", "193", "1440", "0", "0", "0", "0", "reversedirection=yes highway=tertiary surface=asphalt sidewalk=both", "highway=crossing crossing=unmarked kerb=lowered", "3793", "63402"],
|
||||
["4706058", "50876727", "38", "3", "1440", "0", "0", "0", "0", "reversedirection=yes highway=tertiary surface=asphalt sidewalk=both", "", "3796", "63438"],
|
||||
["4705972", "50876755", "38", "7", "1320", "0", "0", "0", "0", "reversedirection=yes highway=living_street surface=asphalt bicycle=yes sidewalk=both", "", "3801", "63506"],
|
||||
["4705423", "50876932", "38", "43", "1320", "0", "0", "0", "0", "reversedirection=yes highway=living_street surface=paving_stones bicycle=yes sidewalk=both", "", "3831", "63776"],
|
||||
["4705400", "50876939", "38", "2", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt", "highway=crossing crossing=unmarked kerb=flush", "3832", "63789"],
|
||||
["4704857", "50877116", "36", "43", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt", "", "3862", "63789"],
|
||||
["4704683", "50877183", "36", "14", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "highway=crossing crossing=unmarked", "3871", "63789"],
|
||||
["4704644", "50877203", "36", "3", "1320", "0", "0", "0", "0", "reversedirection=yes highway=residential surface=asphalt sidewalk=both", "", "3873", "63789"],
|
||||
["4703626", "50877883", "32", "105", "1200", "0", "0", "0", "0", "reversedirection=yes highway=pedestrian surface=sett bicycle=yes", "", "3946", "64086"],
|
||||
["4702846", "50878849", "29", "126", "1200", "0", "0", "0", "0", "reversedirection=yes highway=pedestrian surface=sett access=delivery foot=yes bicycle=yes", "barrier=bollard", "4030", "64086"],
|
||||
["4702447", "50879023", "27", "34", "1400", "0", "0", "0", "0", "reversedirection=yes highway=pedestrian surface=paving_stones access=delivery foot=yes bicycle=yes", "", "4052", "64086"],
|
||||
["4702453", "50879159", "26", "15", "1200", "0", "0", "0", "0", "reversedirection=yes highway=pedestrian access=delivery foot=yes bicycle=yes", "", "4062", "64086"],
|
||||
["4702176", "50879240", "28", "22", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes route_bicycle_ncn=yes route_bicycle_rcn=yes", "highway=crossing", "4076", "64086"],
|
||||
["4702137", "50879274", "28", "5", "1320", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes sidewalk=both route_bicycle_ncn=yes route_bicycle_rcn=yes", "", "4080", "64086"],
|
||||
["4701863", "50879520", "27", "33", "1100", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes sidewalk=both route_bicycle_lcn=yes route_hiking_nwn=yes route_hiking_rwn=yes", "nodeaccessgranted=yes highway=crossing", "4102", "64086"],
|
||||
["4701074", "50879938", "24", "73", "1300", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes sidewalk=both route_bicycle_lcn=yes route_hiking_nwn=yes route_hiking_rwn=yes", "nodeaccessgranted=yes highway=crossing crossing=uncontrolled", "4150", "64086"],
|
||||
["4700933", "50880001", "23", "12", "1300", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes sidewalk=both route_bicycle_lcn=yes route_hiking_nwn=yes route_hiking_rwn=yes", "", "4157", "64086"],
|
||||
["4700731", "50879935", "23", "16", "1520", "0", "0", "0", "0", "highway=residential surface=asphalt foot=yes bicycle=yes sidewalk=both route_bicycle_lcn=yes", "", "4168", "64086"],
|
||||
["4700609", "50879880", "23", "11", "1520", "0", "0", "0", "0", "highway=residential surface=asphalt bicycle=yes route_bicycle_lcn=yes", "crossing=unknown", "4175", "64086"],
|
||||
["4700567", "50879908", "23", "4", "1460", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "", "4178", "64086"],
|
||||
["4700436", "50879922", "23", "12", "1400", "0", "0", "0", "0", "highway=pedestrian surface=paving_stones", "", "4185", "64086"],
|
||||
["4700399", "50879888", "23", "5", "1460", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt", "highway=crossing crossing=unmarked", "4189", "64086"],
|
||||
["4700077", "50880030", "22", "28", "1431", "0", "0", "0", "0", "highway=residential surface=asphalt bicycle=yes route_bicycle_ncn=yes route_bicycle_rcn=yes route_bicycle_lcn=yes", "highway=crossing crossing=uncontrolled bicycle=yes", "4207", "64086"],
|
||||
["4700045", "50880001", "22", "4", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=asphalt bicycle=yes", "", "4210", "64086"],
|
||||
["4700029", "50879984", "23", "2", "1200", "0", "0", "0", "0", "reversedirection=yes highway=pedestrian surface=paving_stones bicycle=yes", "", "4212", "64086"],
|
||||
["4699997", "50879997", "22", "3", "1260", "0", "0", "0", "0", "reversedirection=yes highway=footway surface=paving_stones", "", "4214", "64086"]
|
||||
],
|
||||
"times": [0,2.163,3.592,6.45,10.023,10.731,23.989,33.714,36.5,39.264,42.056,67.852,70.001,70.711,74.936,98.233,103.186,105.31,122.058,126.223,126.918,129.7,132.485,142.174,145.645,149.121,153.297,155.388,216.549,217.964,230.002,256.888,260.361,262.446,265.926,268.017,315.035,319.27,322.095,341.906,375.975,422.96,434.367,456.478,466.468,480.519,500.66,502.752,506.243,509.709,513.179,520.135,540.402,541.103,576.523,577.222,578.621,582.822,587.027,588.43,595.394,599.581,657.224,665.125,677.329,688.09,735.792,749.828,751.937,754.048,757.568,761.759,768.057,789.447,792.325,794.483,804.63,816.298,843.016,845.964,848.908,923.235,994.634,997.475,1000.317,1025.363,1036.474,1046.156,1048.236,1054.488,1057.969,1188.031,1216.246,1217.69,1219.854,1224.861,1270.5,1294.282,1366.788,1370.37,1371.803,1373.236,1373.959,1394.884,1442.953,1591.905,1592.62,1595.505,1647.111,1651.672,1668.393,1669.148,1698.194,1701.212,1707.979,1713.969,1736.995,1740.685,1747.368,1756.393,1767.421,1771.745,1820.746,1825.924,1829.647,1871.099,1965.407,1977.726,2021.74,2057.81,2090.421,2124.015,2161.896,2205.496,2217.646,2312.819,2353.622,2357.313,2362.428,2412.539,2417.085,2423.174,2431.638,2509.766,2578.798,2583.241,2669.58,2680.645,2685.133,2688.118,2691.843,2775.31,2782.71,2802.574,2806.969,2817.116,2820.704,2823.859,2827.488,2831.152,2841.471,2845.88,2863.9,2884.197,2895.129,2897.33,2907.583,2910.506,2911.966,2913.439,2917.814,2922.222,2923.677,2925.131,2928.797,2938.952,2941.126,2959.208,2964.983,2990.204,3021.826,3024.697,3026.132,3029.003,3034.743,3036.895,3037.612,3050.519,3055.536,3081.326,3084.191,3087.055,3090.635,3092.782,3096.362,3099.225,3116.077,3121.231,3125.641,3127.843,3130.043,3197.625,3232.684,3242.78,3252.782,3262.867,3313.393,3326.857,3340.766,3389.922,3399.826,3409.824,3419.743,3454.78,3465.504,3471.938,3484.501,3502.874,3507.871,3515.79,3537.358,3555.307,3556.024,3560.329,3574.671,3604.764,3611.155,3644.101,3644.817,3649.828,3654.796,3670.425,3726.304,3745.056,3748.627,3780.958,3789.644,3793.981,3796.149,3801.162,3803.31,3820.933,3831.533,3832.948,3862.153,3868.914,3871.629,3872.309,3873.669,3887.58,3914.583,3946.206,3950.333,3988.384,4008.297,4016.443,4030.473,4035.778,4052.43,4057.557,4062.002,4067.258,4074.098,4076.843,4080.28,4102.608,4113.217,4121.783,4125.044,4150.016,4157.703,4168.1,4175.395,4178.04,4183.308,4185.978,4189.325,4207.966,4210.658,4212.018,4214.043]
|
||||
},
|
||||
"geometry": {
|
||||
"type": "LineString",
|
||||
"coordinates": [
|
||||
[4.699997, 50.879997, 22.75],
|
||||
[4.700029, 50.879984, 23.0],
|
||||
[4.700045, 50.880001, 22.75],
|
||||
[4.700077, 50.880030, 22.75],
|
||||
[4.700117, 50.880063, 22.75],
|
||||
[4.700126, 50.880071, 22.5],
|
||||
[4.700293, 50.880210, 22.0],
|
||||
[4.700423, 50.880308, 21.75],
|
||||
[4.700458, 50.880337, 21.75],
|
||||
[4.700497, 50.880366, 21.5],
|
||||
[4.700547, 50.880349, 21.75],
|
||||
[4.700964, 50.880166, 22.5],
|
||||
[4.700995, 50.880188, 22.5],
|
||||
[4.701006, 50.880197, 22.25],
|
||||
[4.701045, 50.880243, 22.0],
|
||||
[4.701350, 50.880466, 22.0],
|
||||
[4.701426, 50.880513, 22.0],
|
||||
[4.701462, 50.880534, 22.0],
|
||||
[4.701734, 50.880664, 21.5],
|
||||
[4.701806, 50.880698, 21.25],
|
||||
[4.701820, 50.880705, 21.25],
|
||||
[4.701864, 50.880726, 21.25],
|
||||
[4.701911, 50.880749, 21.25],
|
||||
[4.702081, 50.880815, 21.0],
|
||||
[4.702141, 50.880842, 21.0],
|
||||
[4.702201, 50.880864, 21.0],
|
||||
[4.702262, 50.880829, 21.0],
|
||||
[4.702299, 50.880840, 21.0],
|
||||
[4.703275, 50.881325, 21.0],
|
||||
[4.703293, 50.881333, 21.0],
|
||||
[4.703486, 50.881429, 21.0],
|
||||
[4.703923, 50.881642, 20.0],
|
||||
[4.703896, 50.881681, 20.0],
|
||||
[4.703929, 50.881704, 20.0],
|
||||
[4.703985, 50.881728, 20.0],
|
||||
[4.704023, 50.881729, 20.0],
|
||||
[4.704773, 50.882093, 20.0],
|
||||
[4.704838, 50.882125, 20.0],
|
||||
[4.704885, 50.882149, 20.0],
|
||||
[4.704640, 50.882349, 20.0],
|
||||
[4.704219, 50.882691, 20.0],
|
||||
[4.704474, 50.883260, 20.0],
|
||||
[4.704538, 50.883397, 20.0],
|
||||
[4.704650, 50.883664, 20.0],
|
||||
[4.704707, 50.883786, 20.0],
|
||||
[4.704763, 50.883960, 19.5],
|
||||
[4.705107, 50.884096, 19.0],
|
||||
[4.705080, 50.884119, 19.0],
|
||||
[4.705033, 50.884156, 19.0],
|
||||
[4.705090, 50.884178, 18.75],
|
||||
[4.705152, 50.884205, 18.75],
|
||||
[4.705069, 50.884275, 18.75],
|
||||
[4.704819, 50.884487, 18.75],
|
||||
[4.704823, 50.884496, 18.75],
|
||||
[4.705390, 50.884781, 18.25],
|
||||
[4.705406, 50.884788, 18.25],
|
||||
[4.705432, 50.884800, 18.25],
|
||||
[4.705499, 50.884835, 18.25],
|
||||
[4.705441, 50.884869, 18.25],
|
||||
[4.705465, 50.884885, 18.25],
|
||||
[4.705560, 50.884949, 18.0],
|
||||
[4.705589, 50.884999, 18.0],
|
||||
[4.705744, 50.885709, 19.0],
|
||||
[4.705767, 50.885811, 19.0],
|
||||
[4.705800, 50.885964, 19.0],
|
||||
[4.705824, 50.886096, 19.0],
|
||||
[4.705941, 50.886695, 18.75],
|
||||
[4.705976, 50.886872, 18.25],
|
||||
[4.706015, 50.886870, 18.25],
|
||||
[4.706055, 50.886868, 18.25],
|
||||
[4.706128, 50.886863, 18.25],
|
||||
[4.706211, 50.886883, 18.0],
|
||||
[4.706328, 50.886925, 18.0],
|
||||
[4.706555, 50.887151, 18.5],
|
||||
[4.706588, 50.887183, 18.75],
|
||||
[4.706610, 50.887205, 18.75],
|
||||
[4.706710, 50.887317, 19.0],
|
||||
[4.706819, 50.887439, 19.25],
|
||||
[4.707067, 50.887727, 20.0],
|
||||
[4.707080, 50.887764, 20.0],
|
||||
[4.707124, 50.887747, 20.0],
|
||||
[4.708324, 50.887251, 21.0],
|
||||
[4.707124, 50.887747, 20.0],
|
||||
[4.707080, 50.887764, 20.0],
|
||||
[4.707067, 50.887727, 20.0],
|
||||
[4.706819, 50.887439, 19.25],
|
||||
[4.706710, 50.887317, 19.0],
|
||||
[4.706610, 50.887205, 18.75],
|
||||
[4.706588, 50.887183, 18.75],
|
||||
[4.706719, 50.887194, 18.75],
|
||||
[4.706785, 50.887176, 18.75],
|
||||
[4.708918, 50.886307, 20.5],
|
||||
[4.709376, 50.886112, 20.75],
|
||||
[4.709399, 50.886102, 20.75],
|
||||
[4.709430, 50.886089, 20.75],
|
||||
[4.709508, 50.886055, 20.5],
|
||||
[4.710023, 50.885591, 21.0],
|
||||
[4.710294, 50.885347, 21.0],
|
||||
[4.711122, 50.884603, 21.0],
|
||||
[4.711170, 50.884572, 21.0],
|
||||
[4.711194, 50.884557, 21.0],
|
||||
[4.711216, 50.884540, 21.0],
|
||||
[4.711223, 50.884534, 21.25],
|
||||
[4.711459, 50.884324, 21.25],
|
||||
[4.711997, 50.883841, 21.75],
|
||||
[4.713706, 50.882320, 24.5],
|
||||
[4.713716, 50.882311, 24.5],
|
||||
[4.713749, 50.882281, 24.75],
|
||||
[4.714302, 50.881798, 28.0],
|
||||
[4.714327, 50.881744, 28.0],
|
||||
[4.714413, 50.881554, 28.25],
|
||||
[4.714431, 50.881557, 28.25],
|
||||
[4.714572, 50.881225, 29.0],
|
||||
[4.714622, 50.881234, 29.0],
|
||||
[4.714653, 50.881159, 29.0],
|
||||
[4.714681, 50.881090, 29.0],
|
||||
[4.714797, 50.880817, 29.0],
|
||||
[4.714814, 50.880777, 29.0],
|
||||
[4.714846, 50.880695, 29.25],
|
||||
[4.714876, 50.880590, 29.75],
|
||||
[4.714907, 50.880454, 29.25],
|
||||
[4.714921, 50.880405, 28.75],
|
||||
[4.715160, 50.879831, 30.0],
|
||||
[4.715101, 50.879883, 30.25],
|
||||
[4.715059, 50.879920, 30.5],
|
||||
[4.714850, 50.880390, 29.25],
|
||||
[4.714381, 50.881499, 26.5],
|
||||
[4.714614, 50.881537, 26.25],
|
||||
[4.714384, 50.882068, 25.0],
|
||||
[4.713938, 50.882450, 24.0],
|
||||
[4.713540, 50.882794, 24.0],
|
||||
[4.713938, 50.882450, 24.75],
|
||||
[4.714384, 50.882068, 26.0],
|
||||
[4.714614, 50.881537, 27.25],
|
||||
[4.714381, 50.881499, 27.75],
|
||||
[4.714850, 50.880390, 30.5],
|
||||
[4.715059, 50.879920, 30.5],
|
||||
[4.715101, 50.879883, 30.25],
|
||||
[4.715160, 50.879831, 30.0],
|
||||
[4.714921, 50.880405, 28.75],
|
||||
[4.714907, 50.880454, 29.25],
|
||||
[4.714793, 50.880442, 29.5],
|
||||
[4.714636, 50.880436, 30.0],
|
||||
[4.713651, 50.879700, 32.0],
|
||||
[4.712836, 50.879070, 34.25],
|
||||
[4.712905, 50.879038, 34.25],
|
||||
[4.714304, 50.878435, 34.0],
|
||||
[4.714481, 50.878358, 34.75],
|
||||
[4.714557, 50.878326, 35.25],
|
||||
[4.714517, 50.878296, 35.25],
|
||||
[4.714471, 50.878260, 35.25],
|
||||
[4.713463, 50.877494, 36.5],
|
||||
[4.713369, 50.877427, 36.75],
|
||||
[4.713125, 50.877237, 36.75],
|
||||
[4.713185, 50.877205, 36.75],
|
||||
[4.713340, 50.877128, 36.5],
|
||||
[4.713403, 50.877117, 36.25],
|
||||
[4.713457, 50.877115, 36.0],
|
||||
[4.713403, 50.877117, 36.25],
|
||||
[4.713340, 50.877128, 36.5],
|
||||
[4.713185, 50.877205, 36.75],
|
||||
[4.713125, 50.877237, 36.75],
|
||||
[4.712896, 50.877063, 36.25],
|
||||
[4.712645, 50.876872, 36.5],
|
||||
[4.712507, 50.876767, 36.75],
|
||||
[4.712479, 50.876746, 37.0],
|
||||
[4.712501, 50.876618, 37.0],
|
||||
[4.712445, 50.876614, 37.0],
|
||||
[4.712448, 50.876594, 37.0],
|
||||
[4.712451, 50.876576, 37.25],
|
||||
[4.712371, 50.876570, 37.0],
|
||||
[4.712379, 50.876518, 37.25],
|
||||
[4.712345, 50.876516, 37.0],
|
||||
[4.712316, 50.876514, 37.0],
|
||||
[4.712323, 50.876470, 37.25],
|
||||
[4.712121, 50.876456, 37.0],
|
||||
[4.712096, 50.876437, 37.0],
|
||||
[4.711873, 50.876267, 37.0],
|
||||
[4.711800, 50.876211, 37.0],
|
||||
[4.711483, 50.875969, 37.0],
|
||||
[4.711086, 50.875667, 37.0],
|
||||
[4.711050, 50.875639, 37.0],
|
||||
[4.711033, 50.875627, 37.0],
|
||||
[4.711000, 50.875602, 37.0],
|
||||
[4.710922, 50.875545, 37.0],
|
||||
[4.710893, 50.875523, 37.0],
|
||||
[4.710881, 50.875514, 37.0],
|
||||
[4.710719, 50.875385, 37.0],
|
||||
[4.710670, 50.875332, 37.0],
|
||||
[4.710354, 50.875078, 37.0],
|
||||
[4.710315, 50.875054, 37.0],
|
||||
[4.710276, 50.875029, 37.0],
|
||||
[4.710221, 50.874995, 37.0],
|
||||
[4.710191, 50.874972, 37.0],
|
||||
[4.710148, 50.874938, 37.0],
|
||||
[4.710115, 50.874913, 37.0],
|
||||
[4.709913, 50.874756, 37.75],
|
||||
[4.709846, 50.874704, 38.0],
|
||||
[4.709794, 50.874663, 38.0],
|
||||
[4.709764, 50.874640, 38.0],
|
||||
[4.709735, 50.874620, 38.0],
|
||||
[4.708889, 50.873974, 38.0],
|
||||
[4.708457, 50.873625, 37.75],
|
||||
[4.708350, 50.873523, 38.0],
|
||||
[4.708263, 50.873409, 37.75],
|
||||
[4.708197, 50.873294, 38.0],
|
||||
[4.708064, 50.872679, 38.75],
|
||||
[4.708332, 50.872658, 37.5],
|
||||
[4.708064, 50.872679, 38.75],
|
||||
[4.708197, 50.873294, 38.0],
|
||||
[4.708263, 50.873409, 37.75],
|
||||
[4.708350, 50.873523, 38.0],
|
||||
[4.708457, 50.873625, 37.75],
|
||||
[4.708889, 50.873974, 38.0],
|
||||
[4.708719, 50.874060, 38.0],
|
||||
[4.708625, 50.874109, 38.0],
|
||||
[4.708423, 50.874214, 37.25],
|
||||
[4.708132, 50.874365, 37.5],
|
||||
[4.708058, 50.874404, 37.75],
|
||||
[4.707935, 50.874469, 38.0],
|
||||
[4.707611, 50.874644, 38.0],
|
||||
[4.707340, 50.874790, 38.0],
|
||||
[4.707325, 50.874798, 38.0],
|
||||
[4.707260, 50.874830, 38.0],
|
||||
[4.707439, 50.874966, 38.0],
|
||||
[4.707818, 50.875257, 38.0],
|
||||
[4.707898, 50.875319, 37.75],
|
||||
[4.708336, 50.875628, 38.0],
|
||||
[4.708350, 50.875638, 38.0],
|
||||
[4.708412, 50.875685, 38.0],
|
||||
[4.708322, 50.875717, 37.75],
|
||||
[4.708046, 50.875813, 37.75],
|
||||
[4.707083, 50.876156, 38.0],
|
||||
[4.706788, 50.876294, 38.25],
|
||||
[4.706735, 50.876321, 38.0],
|
||||
[4.706291, 50.876602, 39.0],
|
||||
[4.706168, 50.876681, 38.75],
|
||||
[4.706094, 50.876714, 38.75],
|
||||
[4.706058, 50.876727, 38.75],
|
||||
[4.705972, 50.876755, 38.5],
|
||||
[4.705931, 50.876768, 38.5],
|
||||
[4.705614, 50.876870, 38.0],
|
||||
[4.705423, 50.876932, 38.0],
|
||||
[4.705400, 50.876939, 38.0],
|
||||
[4.704857, 50.877116, 36.5],
|
||||
[4.704725, 50.877159, 36.0],
|
||||
[4.704683, 50.877183, 36.0],
|
||||
[4.704666, 50.877191, 36.0],
|
||||
[4.704644, 50.877203, 36.0],
|
||||
[4.704412, 50.877323, 35.0],
|
||||
[4.704041, 50.877569, 33.75],
|
||||
[4.703626, 50.877883, 32.5],
|
||||
[4.703643, 50.877935, 32.25],
|
||||
[4.703401, 50.878420, 30.75],
|
||||
[4.703191, 50.878656, 29.75],
|
||||
[4.703075, 50.878735, 30.0],
|
||||
[4.702846, 50.878849, 29.25],
|
||||
[4.702758, 50.878898, 28.75],
|
||||
[4.702447, 50.879023, 27.25],
|
||||
[4.702440, 50.879099, 26.75],
|
||||
[4.702453, 50.879159, 26.25],
|
||||
[4.702343, 50.879173, 27.0],
|
||||
[4.702217, 50.879215, 28.0],
|
||||
[4.702176, 50.879240, 28.0],
|
||||
[4.702137, 50.879274, 28.0],
|
||||
[4.701863, 50.879520, 27.25],
|
||||
[4.701712, 50.879630, 26.25],
|
||||
[4.701582, 50.879708, 25.75],
|
||||
[4.701521, 50.879736, 25.25],
|
||||
[4.701074, 50.879938, 24.0],
|
||||
[4.700933, 50.880001, 23.25],
|
||||
[4.700731, 50.879935, 23.25],
|
||||
[4.700609, 50.879880, 23.5],
|
||||
[4.700567, 50.879908, 23.25],
|
||||
[4.700470, 50.879953, 23.0],
|
||||
[4.700436, 50.879922, 23.25],
|
||||
[4.700399, 50.879888, 23.25],
|
||||
[4.700077, 50.880030, 22.75],
|
||||
[4.700045, 50.880001, 22.75],
|
||||
[4.700029, 50.879984, 23.0],
|
||||
[4.699997, 50.879997, 22.75]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Package session keeps one plan and its follow-up conversation per browser,
|
||||
// in memory.
|
||||
package session
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/coach"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/llm"
|
||||
)
|
||||
|
||||
// State tracks an assistant reply through its lifecycle.
|
||||
type State int
|
||||
|
||||
const (
|
||||
Pending State = iota // created, nobody is generating it yet
|
||||
Streaming // a stream handler owns it
|
||||
Done // finished (successfully or with Err set)
|
||||
)
|
||||
|
||||
// Message is a chat turn.
|
||||
type Message struct {
|
||||
ID string
|
||||
Role string // "user" or "assistant"
|
||||
Content string
|
||||
State State
|
||||
Err string
|
||||
Hidden bool // the app's own question, not shown in the UI
|
||||
}
|
||||
|
||||
// Conversation is one plan plus the chat about it.
|
||||
type Conversation struct {
|
||||
mu sync.Mutex
|
||||
plan coach.Plan
|
||||
messages []*Message
|
||||
lastUsed time.Time
|
||||
}
|
||||
|
||||
// Store maps session ids to conversations.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
convs map[string]*Conversation
|
||||
}
|
||||
|
||||
// NewStore returns an empty store.
|
||||
func NewStore() *Store {
|
||||
return &Store{convs: make(map[string]*Conversation)}
|
||||
}
|
||||
|
||||
// NewID returns a random hex id, suitable for sessions and messages.
|
||||
func NewID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Get returns the conversation for a session, or nil if there is none.
|
||||
func (s *Store) Get(session string) *Conversation {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
c := s.convs[session]
|
||||
if c != nil {
|
||||
c.mu.Lock()
|
||||
c.lastUsed = time.Now()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Start replaces the session's conversation with a fresh plan and returns
|
||||
// the pending first reply.
|
||||
func (s *Store) Start(session string, p coach.Plan) (*Conversation, Message) {
|
||||
c := &Conversation{plan: p, lastUsed: time.Now()}
|
||||
q := &Message{ID: NewID(), Role: "user", Content: coach.FirstQuestion, State: Done, Hidden: true}
|
||||
r := &Message{ID: NewID(), Role: "assistant", State: Pending}
|
||||
c.messages = []*Message{q, r}
|
||||
s.mu.Lock()
|
||||
s.convs[session] = c
|
||||
s.mu.Unlock()
|
||||
return c, *r
|
||||
}
|
||||
|
||||
// Prune drops conversations idle for longer than maxIdle.
|
||||
func (s *Store) Prune(maxIdle time.Duration) {
|
||||
cutoff := time.Now().Add(-maxIdle)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for id, c := range s.convs {
|
||||
c.mu.Lock()
|
||||
idle := c.lastUsed.Before(cutoff)
|
||||
c.mu.Unlock()
|
||||
if idle {
|
||||
delete(s.convs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plan returns the conversation's plan.
|
||||
func (c *Conversation) Plan() coach.Plan {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.plan
|
||||
}
|
||||
|
||||
// UpdatePlan changes the plan, e.g. once routes have been looked up.
|
||||
func (c *Conversation) UpdatePlan(f func(*coach.Plan)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
f(&c.plan)
|
||||
}
|
||||
|
||||
// Messages returns a snapshot of the visible conversation.
|
||||
func (c *Conversation) Messages() []Message {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var out []Message
|
||||
for _, m := range c.messages {
|
||||
if !m.Hidden {
|
||||
out = append(out, *m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Ask appends a user message plus a pending assistant reply and returns both.
|
||||
func (c *Conversation) Ask(text string) (user, reply Message) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
u := &Message{ID: NewID(), Role: "user", Content: text, State: Done}
|
||||
r := &Message{ID: NewID(), Role: "assistant", State: Pending}
|
||||
c.messages = append(c.messages, u, r)
|
||||
return *u, *r
|
||||
}
|
||||
|
||||
// maxHistory bounds how many earlier turns go back to the model; small
|
||||
// models lose the thread (and speed) with long contexts.
|
||||
const maxHistory = 12
|
||||
|
||||
// Claim marks a pending reply as streaming and returns the prompt to send:
|
||||
// the plan's system prompt plus the finished messages before the reply. ok
|
||||
// is false if the reply does not exist or someone else already claimed it;
|
||||
// msg then holds its current state (if it exists).
|
||||
func (c *Conversation) Claim(id string) (prompt []llm.Message, msg Message, ok bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
idx := c.index(id)
|
||||
if idx < 0 {
|
||||
return nil, Message{}, false
|
||||
}
|
||||
m := c.messages[idx]
|
||||
if m.State != Pending {
|
||||
return nil, *m, false
|
||||
}
|
||||
m.State = Streaming
|
||||
|
||||
var history []llm.Message
|
||||
for _, h := range c.messages[:idx] {
|
||||
if h.State == Done && h.Err == "" && h.Content != "" {
|
||||
history = append(history, llm.Message{Role: h.Role, Content: h.Content})
|
||||
}
|
||||
}
|
||||
if len(history) > maxHistory { // keep the opening question and plan
|
||||
history = append(history[:2:2], history[len(history)-maxHistory+2:]...)
|
||||
}
|
||||
// Small models tend to repeat the whole plan template; nudge follow-ups.
|
||||
if n := len(history); n > 2 && history[n-1].Role == "user" {
|
||||
history[n-1].Content += "\n\n(" + coach.FollowUpHint + ")"
|
||||
}
|
||||
prompt = []llm.Message{{Role: "system", Content: c.plan.SystemPrompt()}}
|
||||
return append(prompt, history...), *m, true
|
||||
}
|
||||
|
||||
// IsFirstReply reports whether id is the plan's opening reply.
|
||||
func (c *Conversation) IsFirstReply(id string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.messages) > 1 && c.messages[1].ID == id
|
||||
}
|
||||
|
||||
// Append adds streamed text to a reply.
|
||||
func (c *Conversation) Append(id, delta string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if i := c.index(id); i >= 0 {
|
||||
c.messages[i].Content += delta
|
||||
}
|
||||
}
|
||||
|
||||
// Finish marks a reply as done, recording err if generation failed, and
|
||||
// returns its final state.
|
||||
func (c *Conversation) Finish(id string, err error) Message {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
i := c.index(id)
|
||||
if i < 0 {
|
||||
return Message{ID: id, Role: "assistant", State: Done}
|
||||
}
|
||||
m := c.messages[i]
|
||||
m.State = Done
|
||||
if err != nil {
|
||||
m.Err = err.Error()
|
||||
}
|
||||
return *m
|
||||
}
|
||||
|
||||
func (c *Conversation) index(id string) int {
|
||||
for i, m := range c.messages {
|
||||
if m.ID == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// metNo fetches the MET Norway (yr.no) forecast, a free fallback with
|
||||
// global coverage. Outside the Nordics it has no gusts or rain chance, so
|
||||
// gusts are estimated from wind speed and the rain chance is left unknown.
|
||||
func (c *Client) metNo(ctx context.Context, lat, lon float64) (Forecast, error) {
|
||||
q := url.Values{"lat": {fmt.Sprintf("%.2f", lat)}, "lon": {fmt.Sprintf("%.2f", lon)}}
|
||||
var raw struct {
|
||||
Properties struct {
|
||||
Timeseries []struct {
|
||||
Time time.Time `json:"time"`
|
||||
Data struct {
|
||||
Instant struct {
|
||||
Details map[string]float64 `json:"details"`
|
||||
} `json:"instant"`
|
||||
Next1 *struct {
|
||||
Summary struct {
|
||||
Symbol string `json:"symbol_code"`
|
||||
} `json:"summary"`
|
||||
Details map[string]float64 `json:"details"`
|
||||
} `json:"next_1_hours"`
|
||||
} `json:"data"`
|
||||
} `json:"timeseries"`
|
||||
} `json:"properties"`
|
||||
}
|
||||
u := or(c.MetNoURL, "https://api.met.no/weatherapi/locationforecast/2.0/complete") + "?" + q.Encode()
|
||||
if err := c.getJSON(ctx, u, &raw); err != nil {
|
||||
return Forecast{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if c.now != nil {
|
||||
now = c.now()
|
||||
}
|
||||
f := Forecast{Source: "MET Norway"}
|
||||
f.Sunrise, f.Sunset = sunTimes(now, lat, lon)
|
||||
if f.Sunset.Before(now) {
|
||||
f.Sunrise, f.Sunset = sunTimes(now.Add(24*time.Hour), lat, lon)
|
||||
}
|
||||
for _, t := range raw.Properties.Timeseries {
|
||||
if t.Time.Before(now.Truncate(time.Hour)) || t.Data.Next1 == nil {
|
||||
continue
|
||||
}
|
||||
if len(f.Hours) == 24 {
|
||||
break
|
||||
}
|
||||
d, n := t.Data.Instant.Details, t.Data.Next1.Details
|
||||
h := Hour{
|
||||
Time: t.Time.Local(),
|
||||
Temp: d["air_temperature"],
|
||||
Feels: d["air_temperature"],
|
||||
Precip: n["precipitation_amount"],
|
||||
PrecipProb: -1,
|
||||
Code: symbolCode(t.Data.Next1.Summary.Symbol),
|
||||
Wind: d["wind_speed"] * 3.6,
|
||||
WindDirection: int(math.Round(d["wind_from_direction"])),
|
||||
IsDay: isDay(t.Time, lat, lon),
|
||||
}
|
||||
if v, ok := d["apparent_air_temperature"]; ok {
|
||||
h.Feels = v
|
||||
}
|
||||
if v, ok := n["probability_of_precipitation"]; ok {
|
||||
h.PrecipProb = int(math.Round(v))
|
||||
}
|
||||
if v, ok := d["wind_speed_of_gust"]; ok {
|
||||
h.Gusts = v * 3.6
|
||||
} else {
|
||||
h.Gusts = h.Wind * 1.5 // typical gust factor over open land
|
||||
f.GustsEstimated = true
|
||||
}
|
||||
f.Hours = append(f.Hours, h)
|
||||
}
|
||||
if len(f.Hours) == 0 {
|
||||
return Forecast{}, errors.New("met norway: no hourly forecast")
|
||||
}
|
||||
f.Now = f.Hours[0]
|
||||
f.Now.Time = now.Truncate(time.Minute)
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// symbolCode maps a MET Norway symbol ("lightrainshowers_day") to the
|
||||
// closest WMO weather code.
|
||||
func symbolCode(sym string) int {
|
||||
base, _, _ := strings.Cut(sym, "_")
|
||||
switch {
|
||||
case strings.Contains(base, "thunder"):
|
||||
return 95
|
||||
case strings.Contains(base, "sleet"):
|
||||
if strings.HasPrefix(base, "light") {
|
||||
return 66
|
||||
}
|
||||
return 67
|
||||
case strings.HasSuffix(base, "snowshowers"):
|
||||
if strings.HasPrefix(base, "heavy") {
|
||||
return 86
|
||||
}
|
||||
return 85
|
||||
case strings.HasSuffix(base, "rainshowers"):
|
||||
switch {
|
||||
case strings.HasPrefix(base, "light"):
|
||||
return 80
|
||||
case strings.HasPrefix(base, "heavy"):
|
||||
return 82
|
||||
}
|
||||
return 81
|
||||
}
|
||||
if code, ok := map[string]int{
|
||||
"clearsky": 0, "fair": 1, "partlycloudy": 2, "cloudy": 3, "fog": 45,
|
||||
"lightrain": 61, "rain": 63, "heavyrain": 65,
|
||||
"lightsnow": 71, "snow": 73, "heavysnow": 75,
|
||||
}[base]; ok {
|
||||
return code
|
||||
}
|
||||
return 3 // unknown: say overcast rather than promise sunshine
|
||||
}
|
||||
|
||||
func isDay(t time.Time, lat, lon float64) bool {
|
||||
rise, set := sunTimes(t, lat, lon)
|
||||
return !t.Before(rise) && t.Before(set)
|
||||
}
|
||||
|
||||
// sunTimes computes sunrise and sunset for t's UTC date with the standard
|
||||
// sunrise equation (good to a minute or two), in local time.
|
||||
func sunTimes(t time.Time, lat, lon float64) (rise, set time.Time) {
|
||||
const rad = math.Pi / 180
|
||||
y, m, d := t.UTC().Date()
|
||||
noon := float64(time.Date(y, m, d, 12, 0, 0, 0, time.UTC).Unix())/86400 + 2440587.5 // Julian date
|
||||
n := math.Round(noon - 2451545.0) // days since J2000
|
||||
jStar := n - lon/360
|
||||
M := math.Mod(357.5291+0.98560028*jStar, 360)
|
||||
C := 1.9148*math.Sin(M*rad) + 0.02*math.Sin(2*M*rad) + 0.0003*math.Sin(3*M*rad)
|
||||
lambda := math.Mod(M+C+180+102.9372, 360)
|
||||
transit := 2451545.0 + jStar + 0.0053*math.Sin(M*rad) - 0.0069*math.Sin(2*lambda*rad)
|
||||
sinDec := math.Sin(lambda*rad) * math.Sin(23.4397*rad)
|
||||
cosDec := math.Cos(math.Asin(sinDec))
|
||||
cosW := (math.Sin(-0.833*rad) - math.Sin(lat*rad)*sinDec) / (math.Cos(lat*rad) * cosDec)
|
||||
cosW = math.Max(-1, math.Min(1, cosW)) // polar day/night: clamp
|
||||
w := math.Acos(cosW) / rad
|
||||
toTime := func(j float64) time.Time {
|
||||
return time.Unix(int64(math.Round((j-2440587.5)*86400)), 0).Local()
|
||||
}
|
||||
return toTime(transit - w/360), toTime(transit + w/360)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"latitude":51.052,"longitude":3.712,"generationtime_ms":0.3993511199951172,"utc_offset_seconds":7200,"timezone":"Europe/Brussels","timezone_abbreviation":"GMT+2","elevation":9.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C","apparent_temperature":"°C","precipitation":"mm","weather_code":"wmo code","wind_speed_10m":"km/h","wind_gusts_10m":"km/h","wind_direction_10m":"°","is_day":""},"current":{"time":"2026-10-11T16:30","interval":900,"temperature_2m":14.7,"apparent_temperature":11.7,"precipitation":0.00,"weather_code":0,"wind_speed_10m":12.2,"wind_gusts_10m":27.7,"wind_direction_10m":276,"is_day":1},"hourly_units":{"time":"iso8601","temperature_2m":"°C","apparent_temperature":"°C","precipitation":"mm","weather_code":"wmo code","wind_speed_10m":"km/h","wind_gusts_10m":"km/h","wind_direction_10m":"°","is_day":"","precipitation_probability":"%"},"hourly":{"time":["2026-10-11T16:00","2026-10-11T17:00","2026-10-11T18:00","2026-10-11T19:00","2026-10-11T20:00","2026-10-11T21:00","2026-10-11T22:00","2026-10-11T23:00","2026-10-12T00:00","2026-10-12T01:00","2026-10-12T02:00","2026-10-12T03:00","2026-10-12T04:00","2026-10-12T05:00","2026-10-12T06:00","2026-10-12T07:00","2026-10-12T08:00","2026-10-12T09:00","2026-10-12T10:00","2026-10-12T11:00","2026-10-12T12:00","2026-10-12T13:00","2026-10-12T14:00","2026-10-12T15:00"],"temperature_2m":[14.7,14.6,14.1,13.0,11.4,10.6,10.7,10.7,10.2,10.2,9.4,8.8,9.2,10.0,10.2,10.2,10.6,11.0,12.0,13.1,15.0,16.5,17.3,17.9],"apparent_temperature":[11.7,11.6,11.4,11.0,9.4,8.5,8.9,8.2,7.5,7.7,7.6,7.2,7.7,8.8,9.0,8.7,9.2,9.6,10.7,11.6,13.0,14.0,14.7,15.2],"precipitation":[0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.00,0.10,0.10,0.00,0.00,0.00,0.00,0.00,0.00],"weather_code":[0,0,0,0,0,0,3,0,0,0,0,0,3,3,3,3,51,51,2,3,0,2,2,1],"wind_speed_10m":[12.6,11.9,10.4,6.8,6.5,6.5,4.7,8.3,9.0,9.7,8.6,7.2,7.6,6.8,6.1,8.3,8.6,8.6,8.6,9.4,12.2,14.0,14.0,14.4],"wind_gusts_10m":[28.8,26.3,24.1,19.8,9.4,7.6,7.6,11.5,11.9,16.6,14.8,12.6,12.6,13.0,11.5,14.4,15.8,15.8,16.2,18.7,25.2,28.8,29.5,30.6],"wind_direction_10m":[274,277,276,249,206,177,166,179,186,182,189,169,184,175,139,142,141,141,152,171,184,184,190,187],"is_day":[1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1],"precipitation_probability":[12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"daily_units":{"time":"iso8601","sunrise":"iso8601","sunset":"iso8601"},"daily":{"time":["2026-10-11","2026-10-12"],"sunrise":["2026-10-11T08:01","2026-10-12T08:03"],"sunset":["2026-10-11T19:01","2026-10-12T18:59"]}}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"results":[{"id":2797656,"name":"Ghent","latitude":51.05,"longitude":3.71667,"elevation":10.0,"feature_code":"PPL","country_code":"BE","admin1_id":3337388,"admin2_id":2789733,"admin3_id":2797655,"admin4_id":2797657,"timezone":"Europe/Brussels","population":265086,"postcodes":["9000"],"country_id":2802361,"country":"Belgium","admin1":"Flanders","admin2":"East Flanders","admin3":"Arrondissement of Ghent","admin4":"Gent"}],"generationtime_ms":0.7789135}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"type":"Feature","geometry":{"type":"Point","coordinates":[3.72,51.05,13]},"properties":{"meta":{"updated_at":"2026-10-11T13:19:22Z","units":{"air_pressure_at_sea_level":"hPa","air_temperature":"celsius","air_temperature_max":"celsius","air_temperature_min":"celsius","apparent_air_temperature":"celsius","cloud_area_fraction":"%","cloud_area_fraction_high":"%","cloud_area_fraction_low":"%","cloud_area_fraction_medium":"%","dew_point_temperature":"celsius","fog_area_fraction":"%","precipitation_amount":"mm","relative_humidity":"%","ultraviolet_index_clear_sky":"1","wind_from_direction":"degrees","wind_speed":"m/s"}},"timeseries":[{"time":"2026-10-11T16:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1014.1,"air_temperature":13.1,"apparent_air_temperature":12.5,"cloud_area_fraction":19.5,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":19.5,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.2,"fog_area_fraction":0.0,"relative_humidity":62.8,"ultraviolet_index_clear_sky":0.1,"wind_from_direction":283.8,"wind_speed":2.3}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"fair_day"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":11.1,"air_temperature_min":8.0,"precipitation_amount":0.0}}}},{"time":"2026-10-11T17:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1014.6,"air_temperature":11.1,"apparent_air_temperature":10.8,"cloud_area_fraction":6.2,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":6.2,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.6,"fog_area_fraction":0.0,"relative_humidity":74.1,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":263.2,"wind_speed":1.6}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":10.3,"air_temperature_min":7.8,"precipitation_amount":0.0}}}},{"time":"2026-10-11T18:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1015.2,"air_temperature":10.3,"apparent_air_temperature":9.7,"cloud_area_fraction":2.3,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":2.3,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.5,"fog_area_fraction":0.0,"relative_humidity":78.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":247.4,"wind_speed":1.7}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":9.5,"air_temperature_min":7.6,"precipitation_amount":0.0}}}},{"time":"2026-10-11T19:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1015.6,"air_temperature":9.5,"apparent_air_temperature":8.8,"cloud_area_fraction":0.0,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":0.0,"cloud_area_fraction_medium":0.0,"dew_point_temperature":6.2,"fog_area_fraction":0.0,"relative_humidity":80.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":231.5,"wind_speed":1.8}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":9.0,"air_temperature_min":7.4,"precipitation_amount":0.0}}}},{"time":"2026-10-11T20:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1015.9,"air_temperature":9.0,"apparent_air_temperature":8.1,"cloud_area_fraction":4.7,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":4.7,"cloud_area_fraction_medium":0.0,"dew_point_temperature":5.9,"fog_area_fraction":0.0,"relative_humidity":81.3,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":218.5,"wind_speed":1.9}},"next_12_hours":{"summary":{"symbol_code":"fair_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"air_temperature_max":8.3,"air_temperature_min":7.1,"precipitation_amount":0.0}}}},{"time":"2026-10-11T21:00:00Z","data":{"instant":{"details":{"air_pressure_at_sea_level":1016.4,"air_temperature":8.3,"apparent_air_temperature":7.4,"cloud_area_fraction":0.8,"cloud_area_fraction_high":0.0,"cloud_area_fraction_low":0.8,"cloud_area_fraction_medium":0.0,"dew_point_temperature":5.6,"fog_area_fraction":0.0,"relative_humidity":83.0,"ultraviolet_index_clear_sky":0.0,"wind_from_direction":204.9,"wind_speed":1.8}},"next_12_hours":{"summary":{"symbol_code":"partlycloudy_night"},"details":{}},"next_1_hours":{"summary":{"symbol_code":"clearsky_night"},"details":{"precipitation_amount":0.0}},"next_6_hoLine truncated
|
||||
+1
@@ -0,0 +1 @@
|
||||
[{"place_id":101749274,"licence":"Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright","osm_type":"relation","osm_id":897671,"lat":"51.0538286","lon":"3.7250121","category":"boundary","type":"administrative","place_rank":14,"importance":0.6930824855023677,"addresstype":"city","name":"Ghent","display_name":"Ghent, Gent, East Flanders, Flanders, Belgium","boundingbox":["50.9795422","51.1888910","3.5797616","3.8493413"]}]
|
||||
@@ -0,0 +1,370 @@
|
||||
// Package weather fetches forecasts and place names from Open-Meteo
|
||||
// (open source, no API key), with MET Norway and Nominatim as fallbacks.
|
||||
// Coordinates are rounded to ~1 km before they leave the machine.
|
||||
package weather
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Hour is the weather for one hour of the forecast.
|
||||
type Hour struct {
|
||||
Time time.Time
|
||||
Temp float64 // °C
|
||||
Feels float64 // apparent temperature, °C
|
||||
PrecipProb int // %, or -1 when the source doesn't say
|
||||
Precip float64 // mm
|
||||
Code int // WMO weather code
|
||||
Wind float64 // km/h
|
||||
Gusts float64 // km/h
|
||||
WindDirection int // degrees, where the wind comes from
|
||||
IsDay bool
|
||||
}
|
||||
|
||||
// Forecast is the current weather plus the next 24 hours.
|
||||
type Forecast struct {
|
||||
Now Hour
|
||||
Hours []Hour // starting at the current hour
|
||||
Sunrise time.Time
|
||||
Sunset time.Time
|
||||
Source string // who made the forecast, shown in the UI
|
||||
GustsEstimated bool // the source had no gusts; they're derived from wind speed
|
||||
}
|
||||
|
||||
// Place is a geocoded location.
|
||||
type Place struct {
|
||||
Name string
|
||||
Lat float64
|
||||
Lon float64
|
||||
}
|
||||
|
||||
// Client fetches forecasts from Open-Meteo, falling back to MET Norway when
|
||||
// Open-Meteo is down, and caches them for 15 minutes.
|
||||
type Client struct {
|
||||
ForecastURL string // default https://api.open-meteo.com/v1/forecast
|
||||
GeocodeURL string // default https://geocoding-api.open-meteo.com/v1/search
|
||||
MetNoURL string // default https://api.met.no/weatherapi/locationforecast/2.0/complete; "-" disables
|
||||
NominatimURL string // default https://nominatim.openstreetmap.org/search; "-" disables
|
||||
HTTP *http.Client
|
||||
|
||||
now func() time.Time // for tests; nil means time.Now
|
||||
mu sync.Mutex
|
||||
cache map[string]cached
|
||||
}
|
||||
|
||||
type cached struct {
|
||||
f Forecast
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
cacheFor = 15 * time.Minute
|
||||
staleFor = 3 * time.Hour // when every source is down, an older forecast beats none
|
||||
userAgent = "workout-suggester/0.1 (+https://git.b0b.be/bdeb/workout-suggester)"
|
||||
)
|
||||
|
||||
func (c *Client) http() *http.Client {
|
||||
if c.HTTP != nil {
|
||||
return c.HTTP
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func or(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Round keeps two decimals (~1 km), which is plenty for weather.
|
||||
func Round(v float64) float64 { return math.Round(v*100) / 100 }
|
||||
|
||||
// getJSON fetches u, retrying once when the server is busy (429/5xx).
|
||||
func (c *Client) getJSON(ctx context.Context, u string, out any) error {
|
||||
var err error
|
||||
for attempt := range 2 {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
var retry bool
|
||||
if retry, err = c.getOnce(ctx, u, out); !retry {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) getOnce(ctx context.Context, u string, out any) (retry bool, err error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent) // MET Norway and Nominatim require one
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.http().Do(req)
|
||||
if err != nil {
|
||||
return ctx.Err() == nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
busy := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
|
||||
return busy, fmt.Errorf("%s returned %s: %s", req.URL.Host, resp.Status, bytes.TrimSpace(msg))
|
||||
}
|
||||
return false, json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// Geocode finds a town or city by name, via Open-Meteo or else Nominatim.
|
||||
func (c *Client) Geocode(ctx context.Context, name string) (Place, error) {
|
||||
q := url.Values{"name": {name}, "count": {"1"}, "language": {"en"}, "format": {"json"}}
|
||||
var out struct {
|
||||
Results []struct {
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
Lat float64 `json:"latitude"`
|
||||
Lon float64 `json:"longitude"`
|
||||
} `json:"results"`
|
||||
}
|
||||
err := c.getJSON(ctx, or(c.GeocodeURL, "https://geocoding-api.open-meteo.com/v1/search")+"?"+q.Encode(), &out)
|
||||
if err == nil {
|
||||
if len(out.Results) == 0 {
|
||||
return Place{}, fmt.Errorf("couldn't find a place called %q", name)
|
||||
}
|
||||
r := out.Results[0]
|
||||
n := r.Name
|
||||
if r.Country != "" {
|
||||
n += ", " + r.Country
|
||||
}
|
||||
return Place{Name: n, Lat: r.Lat, Lon: r.Lon}, nil
|
||||
}
|
||||
if c.NominatimURL == "-" {
|
||||
return Place{}, err
|
||||
}
|
||||
p, err2 := c.nominatim(ctx, name)
|
||||
if err2 != nil {
|
||||
return Place{}, errors.Join(err, err2)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (c *Client) nominatim(ctx context.Context, name string) (Place, error) {
|
||||
q := url.Values{"q": {name}, "format": {"jsonv2"}, "limit": {"1"}, "accept-language": {"en"}}
|
||||
var out []struct {
|
||||
Name string `json:"name"`
|
||||
Display string `json:"display_name"`
|
||||
Lat string `json:"lat"`
|
||||
Lon string `json:"lon"`
|
||||
}
|
||||
if err := c.getJSON(ctx, or(c.NominatimURL, "https://nominatim.openstreetmap.org/search")+"?"+q.Encode(), &out); err != nil {
|
||||
return Place{}, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return Place{}, fmt.Errorf("couldn't find a place called %q", name)
|
||||
}
|
||||
lat, err1 := strconv.ParseFloat(out[0].Lat, 64)
|
||||
lon, err2 := strconv.ParseFloat(out[0].Lon, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return Place{}, errors.New("nominatim: bad coordinates")
|
||||
}
|
||||
n := out[0].Name
|
||||
if parts := strings.Split(out[0].Display, ", "); len(parts) > 1 {
|
||||
n += ", " + parts[len(parts)-1] // country
|
||||
}
|
||||
return Place{Name: n, Lat: lat, Lon: lon}, nil
|
||||
}
|
||||
|
||||
// Forecast returns current weather and the next 24 hours.
|
||||
func (c *Client) Forecast(ctx context.Context, lat, lon float64) (Forecast, error) {
|
||||
lat, lon = Round(lat), Round(lon)
|
||||
key := fmt.Sprintf("%.2f,%.2f", lat, lon)
|
||||
c.mu.Lock()
|
||||
hit, ok := c.cache[key]
|
||||
c.mu.Unlock()
|
||||
if ok && time.Since(hit.at) < cacheFor {
|
||||
return hit.f, nil
|
||||
}
|
||||
|
||||
f, err := c.openMeteo(ctx, lat, lon)
|
||||
if err != nil && c.MetNoURL != "-" {
|
||||
var err2 error
|
||||
if f, err2 = c.metNo(ctx, lat, lon); err2 != nil {
|
||||
err = errors.Join(err, err2)
|
||||
} else {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if ok && time.Since(hit.at) < staleFor {
|
||||
hit.f.Source += fmt.Sprintf(", from %s", hit.at.Format("15:04"))
|
||||
return hit.f, nil
|
||||
}
|
||||
return Forecast{}, err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.cache == nil {
|
||||
c.cache = make(map[string]cached)
|
||||
}
|
||||
c.cache[key] = cached{f, time.Now()}
|
||||
c.mu.Unlock()
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (c *Client) openMeteo(ctx context.Context, lat, lon float64) (Forecast, error) {
|
||||
vars := "temperature_2m,apparent_temperature,precipitation,weather_code,wind_speed_10m,wind_gusts_10m,wind_direction_10m,is_day"
|
||||
q := url.Values{
|
||||
"latitude": {strconv.FormatFloat(lat, 'f', 2, 64)},
|
||||
"longitude": {strconv.FormatFloat(lon, 'f', 2, 64)},
|
||||
"current": {vars},
|
||||
"hourly": {vars + ",precipitation_probability"},
|
||||
"daily": {"sunrise,sunset"},
|
||||
"forecast_hours": {"24"},
|
||||
"forecast_days": {"2"},
|
||||
"timezone": {"auto"},
|
||||
}
|
||||
var raw apiResponse
|
||||
if err := c.getJSON(ctx, or(c.ForecastURL, "https://api.open-meteo.com/v1/forecast")+"?"+q.Encode(), &raw); err != nil {
|
||||
return Forecast{}, err
|
||||
}
|
||||
return raw.forecast()
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
UTCOffset int `json:"utc_offset_seconds"`
|
||||
Current struct {
|
||||
Time string `json:"time"`
|
||||
Temp float64 `json:"temperature_2m"`
|
||||
Feels float64 `json:"apparent_temperature"`
|
||||
Precip float64 `json:"precipitation"`
|
||||
Code int `json:"weather_code"`
|
||||
Wind float64 `json:"wind_speed_10m"`
|
||||
Gusts float64 `json:"wind_gusts_10m"`
|
||||
Dir int `json:"wind_direction_10m"`
|
||||
IsDay int `json:"is_day"`
|
||||
} `json:"current"`
|
||||
Hourly struct {
|
||||
Time []string `json:"time"`
|
||||
Temp []float64 `json:"temperature_2m"`
|
||||
Feels []float64 `json:"apparent_temperature"`
|
||||
Precip []float64 `json:"precipitation"`
|
||||
PrecipProb []int `json:"precipitation_probability"`
|
||||
Code []int `json:"weather_code"`
|
||||
Wind []float64 `json:"wind_speed_10m"`
|
||||
Gusts []float64 `json:"wind_gusts_10m"`
|
||||
Dir []int `json:"wind_direction_10m"`
|
||||
IsDay []int `json:"is_day"`
|
||||
} `json:"hourly"`
|
||||
Daily struct {
|
||||
Sunrise []string `json:"sunrise"`
|
||||
Sunset []string `json:"sunset"`
|
||||
} `json:"daily"`
|
||||
}
|
||||
|
||||
func (r apiResponse) forecast() (Forecast, error) {
|
||||
loc := time.FixedZone("local", r.UTCOffset)
|
||||
parse := func(s string) time.Time {
|
||||
t, _ := time.ParseInLocation("2006-01-02T15:04", s, loc)
|
||||
return t
|
||||
}
|
||||
h := r.Hourly
|
||||
n := len(h.Time)
|
||||
if n == 0 || len(h.Temp) != n || len(h.Feels) != n || len(h.Precip) != n || len(h.PrecipProb) != n ||
|
||||
len(h.Code) != n || len(h.Wind) != n || len(h.Gusts) != n || len(h.Dir) != n || len(h.IsDay) != n {
|
||||
return Forecast{}, errors.New("open-meteo: incomplete hourly forecast")
|
||||
}
|
||||
f := Forecast{Source: "Open-Meteo", Now: Hour{
|
||||
Time: parse(r.Current.Time), Temp: r.Current.Temp, Feels: r.Current.Feels,
|
||||
Precip: r.Current.Precip, Code: r.Current.Code, Wind: r.Current.Wind,
|
||||
Gusts: r.Current.Gusts, WindDirection: r.Current.Dir, IsDay: r.Current.IsDay == 1,
|
||||
}}
|
||||
for i := range n {
|
||||
f.Hours = append(f.Hours, Hour{
|
||||
Time: parse(h.Time[i]), Temp: h.Temp[i], Feels: h.Feels[i], PrecipProb: h.PrecipProb[i],
|
||||
Precip: h.Precip[i], Code: h.Code[i], Wind: h.Wind[i], Gusts: h.Gusts[i],
|
||||
WindDirection: h.Dir[i], IsDay: h.IsDay[i] == 1,
|
||||
})
|
||||
}
|
||||
// The current hour's rain chance is the best guess for "now".
|
||||
f.Now.PrecipProb = f.Hours[0].PrecipProb
|
||||
// Today's sunrise/sunset, or tomorrow's once today's sun has set.
|
||||
for i := range r.Daily.Sunset {
|
||||
f.Sunrise, f.Sunset = parse(r.Daily.Sunrise[i]), parse(r.Daily.Sunset[i])
|
||||
if f.Sunset.After(f.Now.Time) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Describe turns a WMO weather code into words and an emoji.
|
||||
func Describe(code int) (string, string) {
|
||||
switch {
|
||||
case code == 0:
|
||||
return "clear sky", "☀️"
|
||||
case code == 1:
|
||||
return "mostly clear", "🌤️"
|
||||
case code == 2:
|
||||
return "partly cloudy", "⛅"
|
||||
case code == 3:
|
||||
return "overcast", "☁️"
|
||||
case code == 45 || code == 48:
|
||||
return "fog", "🌫️"
|
||||
case code >= 51 && code <= 55:
|
||||
return "drizzle", "🌦️"
|
||||
case code == 56 || code == 57:
|
||||
return "freezing drizzle", "🧊"
|
||||
case code >= 61 && code <= 65:
|
||||
return "rain", "🌧️"
|
||||
case code == 66 || code == 67:
|
||||
return "freezing rain", "🧊"
|
||||
case code >= 71 && code <= 77:
|
||||
return "snow", "🌨️"
|
||||
case code >= 80 && code <= 82:
|
||||
return "rain showers", "🌦️"
|
||||
case code == 85 || code == 86:
|
||||
return "snow showers", "🌨️"
|
||||
case code >= 95:
|
||||
return "thunderstorm", "⛈️"
|
||||
}
|
||||
return "unknown", "🌡️"
|
||||
}
|
||||
|
||||
// Icon is Describe's emoji, with a moon for clear nights.
|
||||
func (h Hour) Icon() string {
|
||||
_, icon := Describe(h.Code)
|
||||
if !h.IsDay && h.Code <= 1 {
|
||||
return "🌙"
|
||||
}
|
||||
return icon
|
||||
}
|
||||
|
||||
// Chance is the rain chance as text, "–" when unknown.
|
||||
func (h Hour) Chance() string {
|
||||
if h.PrecipProb < 0 {
|
||||
return "–"
|
||||
}
|
||||
return fmt.Sprintf("%d%%", h.PrecipProb)
|
||||
}
|
||||
|
||||
// Compass turns a wind direction in degrees into N, NE, E, ...
|
||||
func Compass(deg int) string {
|
||||
dirs := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
|
||||
return dirs[((deg%360+360)%360+22)/45%8]
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package weather
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testdata holds real Open-Meteo responses for Ghent.
|
||||
func fakeOpenMeteo(t *testing.T, calls *atomic.Int32) *httptest.Server {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
switch r.URL.Path {
|
||||
case "/v1/forecast":
|
||||
if lat := r.URL.Query().Get("latitude"); lat != "51.05" {
|
||||
t.Errorf("latitude sent = %q, want it rounded to 51.05", lat)
|
||||
}
|
||||
http.ServeFile(w, r, "testdata/forecast.json")
|
||||
case "/v1/search":
|
||||
http.ServeFile(w, r, "testdata/geocode.json")
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestForecast(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := fakeOpenMeteo(t, &calls)
|
||||
c := &Client{ForecastURL: srv.URL + "/v1/forecast"}
|
||||
f, err := c.Forecast(context.Background(), 51.0512345, 3.7212345)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.Hours) != 24 || f.Now.Time.IsZero() || f.Sunset.Before(f.Now.Time) {
|
||||
t.Errorf("forecast = now %v, %d hours, sunset %v", f.Now.Time, len(f.Hours), f.Sunset)
|
||||
}
|
||||
if _, off := f.Now.Time.Zone(); off != 2*3600 {
|
||||
t.Errorf("zone offset = %d, want Brussels summer time", off)
|
||||
}
|
||||
c.Forecast(context.Background(), 51.05, 3.72)
|
||||
if calls.Load() != 1 {
|
||||
t.Errorf("second call within 15 min should hit the cache, made %d requests", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeocode(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := fakeOpenMeteo(t, &calls)
|
||||
p, err := (&Client{GeocodeURL: srv.URL + "/v1/search"}).Geocode(context.Background(), "Gent")
|
||||
if err != nil || p.Name != "Ghent, Belgium" || p.Lat < 51 || p.Lat > 51.1 {
|
||||
t.Errorf("geocode = %+v, %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompassAndIcons(t *testing.T) {
|
||||
for deg, want := range map[int]string{0: "N", 44: "NE", 276: "W", 350: "N", -90: "W"} {
|
||||
if got := Compass(deg); got != want {
|
||||
t.Errorf("Compass(%d) = %s, want %s", deg, got, want)
|
||||
}
|
||||
}
|
||||
if (Hour{Code: 0, IsDay: false}).Icon() != "🌙" || (Hour{Code: 63, IsDay: false}).Icon() != "🌧️" {
|
||||
t.Error("night icons wrong")
|
||||
}
|
||||
}
|
||||
|
||||
// busy answers 503 like an overloaded Open-Meteo, and serves the fallbacks.
|
||||
func busy(t *testing.T, openMeteoCalls *atomic.Int32) *httptest.Server {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/forecast", "/v1/search":
|
||||
openMeteoCalls.Add(1)
|
||||
http.Error(w, `{"reason":"The service is overloaded","error":true}`, http.StatusServiceUnavailable)
|
||||
case "/metno":
|
||||
if r.Header.Get("User-Agent") == "" {
|
||||
http.Error(w, "403", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "testdata/metno.json")
|
||||
case "/nominatim":
|
||||
http.ServeFile(w, r, "testdata/nominatim.json")
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestForecastFallsBackToMetNo(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := busy(t, &calls)
|
||||
c := &Client{ForecastURL: srv.URL + "/v1/forecast", MetNoURL: srv.URL + "/metno",
|
||||
now: func() time.Time { return time.Date(2026, 10, 11, 17, 10, 0, 0, time.UTC) }}
|
||||
f, err := c.Forecast(context.Background(), 51.05, 3.72)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Errorf("open-meteo tried %d times, want 2 (one retry)", calls.Load())
|
||||
}
|
||||
if f.Source != "MET Norway" || !f.GustsEstimated || len(f.Hours) != 24 {
|
||||
t.Errorf("source %q, estimated gusts %v, %d hours", f.Source, f.GustsEstimated, len(f.Hours))
|
||||
}
|
||||
h := f.Hours[0]
|
||||
if h.PrecipProb != -1 || h.Chance() != "–" || h.Gusts <= h.Wind || h.Temp == 0 {
|
||||
t.Errorf("first hour = %+v", h)
|
||||
}
|
||||
if !f.Hours[0].Time.Equal(time.Date(2026, 10, 11, 17, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("first hour at %v, want 17:00 UTC", f.Hours[0].Time)
|
||||
}
|
||||
if !h.IsDay || f.Hours[1].IsDay { // sunset in Ghent is 19:01 CEST
|
||||
t.Errorf("19:00 CEST should be day, 20:00 night: %v %v", h.IsDay, f.Hours[1].IsDay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleForecastBeatsNothing(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := busy(t, &calls)
|
||||
c := &Client{ForecastURL: srv.URL + "/v1/forecast", MetNoURL: "-"}
|
||||
if _, err := c.Forecast(context.Background(), 51.05, 3.72); err == nil {
|
||||
t.Fatal("expected an error with every source down and no cache")
|
||||
}
|
||||
c.cache = map[string]cached{"51.05,3.72": {Forecast{Source: "Open-Meteo"}, time.Now().Add(-time.Hour)}}
|
||||
f, err := c.Forecast(context.Background(), 51.05, 3.72)
|
||||
if err != nil || !strings.HasPrefix(f.Source, "Open-Meteo, from ") {
|
||||
t.Errorf("stale forecast = %q, %v", f.Source, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeocodeFallsBackToNominatim(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
srv := busy(t, &calls)
|
||||
p, err := (&Client{GeocodeURL: srv.URL + "/v1/search", NominatimURL: srv.URL + "/nominatim"}).Geocode(context.Background(), "Gent")
|
||||
if err != nil || !strings.HasSuffix(p.Name, ", Belgium") || p.Lat < 51 || p.Lat > 51.1 {
|
||||
t.Errorf("geocode = %+v, %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSunTimesMatchOpenMeteo(t *testing.T) {
|
||||
// Open-Meteo says 08:01 / 19:01 CEST for Ghent on 11 Oct 2026.
|
||||
cest := time.FixedZone("CEST", 2*3600)
|
||||
rise, set := sunTimes(time.Date(2026, 10, 11, 12, 0, 0, 0, time.UTC), 51.05, 3.72)
|
||||
for _, c := range []struct {
|
||||
got, want time.Time
|
||||
}{{rise, time.Date(2026, 10, 11, 8, 1, 0, 0, cest)}, {set, time.Date(2026, 10, 11, 19, 1, 0, 0, cest)}} {
|
||||
if d := c.got.Sub(c.want); d < -3*time.Minute || d > 3*time.Minute {
|
||||
t.Errorf("got %v, want %v", c.got.In(cest), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymbolCode(t *testing.T) {
|
||||
for sym, want := range map[string]int{
|
||||
"clearsky_night": 0, "partlycloudy_day": 2, "heavyrainshowers_day": 82, "lightrainshowers_night": 80,
|
||||
"rain": 63, "lightsleet": 66, "heavyrainandthunder": 95, "snowshowers_day": 85, "somethingnew": 3,
|
||||
} {
|
||||
if got := symbolCode(sym); got != want {
|
||||
t.Errorf("symbolCode(%q) = %d, want %d", sym, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// Package web serves the app: Templ pages, HTMX fragments and an SSE
|
||||
// endpoint that streams the coach's replies token by token.
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/coach"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/config"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/llm"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/routes"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/session"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/web/views"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
const (
|
||||
sessionCookie = "ws_sid"
|
||||
maxFormLen = 8 << 10
|
||||
routesTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// Server is the web app.
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
llm *llm.Client
|
||||
weather *weather.Client
|
||||
routes *routes.Client
|
||||
store *session.Store
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New wires a server from config. cacheDir keeps OpenStreetMap lookups
|
||||
// between runs; empty disables that.
|
||||
func New(cfg config.Config, cacheDir string, log *slog.Logger) *Server {
|
||||
hc := &http.Client{Timeout: 45 * time.Second}
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
llm: &llm.Client{
|
||||
BaseURL: cfg.BaseURL,
|
||||
APIKey: cfg.APIKey,
|
||||
Model: cfg.Model,
|
||||
HTTP: &http.Client{}, // per-request deadlines come from contexts
|
||||
},
|
||||
weather: &weather.Client{ForecastURL: cfg.ForecastURL, GeocodeURL: cfg.GeocodeURL, HTTP: hc},
|
||||
routes: &routes.Client{URLs: strings.Fields(cfg.OverpassURL), BRouterURL: cfg.BRouterURL, HTTP: hc, CacheDir: cacheDir},
|
||||
store: session.NewStore(),
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the app's routes.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||
mux.HandleFunc("GET /{$}", s.index)
|
||||
mux.HandleFunc("GET /health", s.health)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) })
|
||||
mux.HandleFunc("POST /plan", s.plan)
|
||||
mux.HandleFunc("GET /routes", s.routesCard)
|
||||
mux.HandleFunc("GET /gpx/{id}", s.gpx)
|
||||
mux.HandleFunc("POST /ask", s.ask)
|
||||
mux.HandleFunc("GET /stream/{id}", s.stream)
|
||||
return s.withSession(mux)
|
||||
}
|
||||
|
||||
// Run serves until ctx is cancelled, then shuts down gracefully.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
srv := &http.Server{
|
||||
Addr: s.cfg.Addr,
|
||||
Handler: s.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
// No WriteTimeout: replies stream for as long as the model talks.
|
||||
// LLM_TIMEOUT bounds each reply instead.
|
||||
}
|
||||
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.store.Prune(12 * time.Hour)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- srv.ListenAndServe() }()
|
||||
s.log.Info("workout-suggester listening", "url", "http://"+s.cfg.Addr, "llm", s.cfg.BaseURL, "model", s.cfg.Model)
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
}
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// withSession gives every browser a random session cookie.
|
||||
func (s *Server) withSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sid := ""
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && len(c.Value) == 32 {
|
||||
sid = c.Value
|
||||
} else {
|
||||
sid = session.NewID()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: sid,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, sid)))
|
||||
})
|
||||
}
|
||||
|
||||
func sessionID(r *http.Request) string {
|
||||
sid, _ := r.Context().Value(ctxKey{}).(string)
|
||||
return sid
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := c.Render(r.Context(), w); err != nil {
|
||||
s.log.Error("render", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
||||
d := views.PageData{DefaultPlace: s.cfg.DefaultPlace}
|
||||
if conv := s.store.Get(sessionID(r)); conv != nil {
|
||||
p := conv.Plan()
|
||||
d.Plan, d.Messages = &p, conv.Messages()
|
||||
}
|
||||
s.render(w, r, views.Page(d))
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
model, err := s.llm.ResolveModel(ctx)
|
||||
if err == nil && s.cfg.Model != "" {
|
||||
_, err = s.llm.Models(ctx) // configured model: still check the server is up
|
||||
}
|
||||
h := views.HealthInfo{OK: err == nil, Model: model}
|
||||
if err != nil {
|
||||
h.Err = err.Error()
|
||||
}
|
||||
s.render(w, r, views.Health(h))
|
||||
}
|
||||
|
||||
// parseRequest reads the form into a coach request, with safe defaults.
|
||||
func parseRequest(r *http.Request) coach.Request {
|
||||
req := coach.Request{
|
||||
Activity: coach.Activity(r.FormValue("activity")),
|
||||
Intensity: coach.Intensity(r.FormValue("intensity")),
|
||||
Note: strings.TrimSpace(r.FormValue("note")),
|
||||
}
|
||||
switch req.Activity {
|
||||
case coach.Run, coach.Ride, coach.Walk, coach.Indoor:
|
||||
default:
|
||||
req.Activity = coach.Any
|
||||
}
|
||||
switch req.Intensity {
|
||||
case coach.Easy, coach.Moderate, coach.Hard:
|
||||
default:
|
||||
req.Intensity = coach.Moderate
|
||||
}
|
||||
req.Minutes, _ = strconv.Atoi(r.FormValue("minutes"))
|
||||
req.Minutes = min(240, max(15, req.Minutes))
|
||||
if len(req.Note) > 200 {
|
||||
req.Note = req.Note[:200]
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// plan checks the weather, scores the options and starts the coach's reply.
|
||||
func (s *Server) plan(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormLen)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.render(w, r, views.Error("That form was too big."))
|
||||
return
|
||||
}
|
||||
req := parseRequest(r)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var p coach.Plan
|
||||
lat, errLat := strconv.ParseFloat(r.FormValue("lat"), 64)
|
||||
lon, errLon := strconv.ParseFloat(r.FormValue("lon"), 64)
|
||||
switch place := strings.TrimSpace(r.FormValue("place")); {
|
||||
case errLat == nil && errLon == nil && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180:
|
||||
p.At = routes.Point{Lat: lat, Lon: lon}
|
||||
case place != "":
|
||||
pl, err := s.weather.Geocode(ctx, place)
|
||||
if err != nil {
|
||||
s.log.Warn("geocode", "err", err)
|
||||
s.render(w, r, views.Error("Couldn't find that place. Check the spelling, or tap 📍 Here."))
|
||||
return
|
||||
}
|
||||
p.Place, p.At = pl.Name, routes.Point{Lat: pl.Lat, Lon: pl.Lon}
|
||||
default:
|
||||
s.render(w, r, views.Error("Where are you? Type a town or tap 📍 Here."))
|
||||
return
|
||||
}
|
||||
|
||||
f, err := s.weather.Forecast(ctx, p.At.Lat, p.At.Lon)
|
||||
if err != nil {
|
||||
s.log.Warn("forecast", "err", err)
|
||||
s.render(w, r, views.Error("Couldn't get the weather right now. Are you online?"))
|
||||
return
|
||||
}
|
||||
p.Forecast, p.Request, p.Assessment = f, req, coach.Assess(f, req)
|
||||
|
||||
conv, _ := s.store.Start(sessionID(r), p)
|
||||
s.render(w, r, views.PlanView(conv.Plan(), conv.Messages()))
|
||||
}
|
||||
|
||||
// loadRoutes looks up routes for the plan's pick once per plan. Concurrent
|
||||
// callers (the routes card and the first reply) share one Overpass request.
|
||||
func (s *Server) loadRoutes(ctx context.Context, conv *session.Conversation) coach.Plan {
|
||||
p := conv.Plan()
|
||||
if p.RoutesLoaded || p.Assessment.Pick == coach.Indoor {
|
||||
return p
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, routesTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Loops from the door (BRouter) and signposted routes (Overpass) at once.
|
||||
kind := p.Assessment.Pick.RouteKind()
|
||||
var (
|
||||
loops []routes.Suggestion
|
||||
loopsErr error
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
wg.Go(func() {
|
||||
loops, loopsErr = s.routes.Loops(ctx, kind, p.At, p.Assessment.TargetKm, p.Forecast.Now.WindDirection)
|
||||
})
|
||||
area, err := s.routes.Near(ctx, p.At.Lat, p.At.Lon)
|
||||
wg.Wait()
|
||||
if loopsErr != nil {
|
||||
s.log.Warn("loops", "err", loopsErr)
|
||||
}
|
||||
if err != nil {
|
||||
s.log.Warn("routes", "err", err)
|
||||
}
|
||||
|
||||
conv.UpdatePlan(func(p *coach.Plan) {
|
||||
if p.RoutesLoaded {
|
||||
return
|
||||
}
|
||||
p.RoutesLoaded = true
|
||||
p.Routes = loops[:min(2, len(loops))]
|
||||
if err == nil {
|
||||
p.Routes = append(p.Routes, routes.Suggest(area, kind, p.Assessment.TargetKm, p.At, 4-len(p.Routes))...)
|
||||
if j, km, ok := area.NearestJunction(kind, p.At); ok && km < 2 {
|
||||
p.Junction = &coach.JunctionInfo{Ref: j.Ref, Kind: kind, Km: km}
|
||||
}
|
||||
}
|
||||
if len(p.Routes) == 0 && err != nil {
|
||||
p.RoutesErr = err.Error()
|
||||
}
|
||||
})
|
||||
return conv.Plan()
|
||||
}
|
||||
|
||||
func (s *Server) routesCard(w http.ResponseWriter, r *http.Request) {
|
||||
conv := s.store.Get(sessionID(r))
|
||||
if conv == nil {
|
||||
s.render(w, r, views.Error("This plan expired. Ask again."))
|
||||
return
|
||||
}
|
||||
s.render(w, r, views.RoutesCard(s.loadRoutes(r.Context(), conv)))
|
||||
}
|
||||
|
||||
func (s *Server) gpx(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
route, ok := s.routes.Route(id)
|
||||
if err != nil || !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/gpx+xml")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="osm-%d.gpx"`, id))
|
||||
routes.WriteGPX(w, route)
|
||||
}
|
||||
|
||||
func (s *Server) ask(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormLen)
|
||||
text := strings.TrimSpace(r.FormValue("message"))
|
||||
conv := s.store.Get(sessionID(r))
|
||||
switch {
|
||||
case conv == nil:
|
||||
s.render(w, r, views.Error("This plan expired. Ask again from the top."))
|
||||
return
|
||||
case text == "" || len(text) > 1000:
|
||||
http.Error(w, "message is empty or too long", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, reply := conv.Ask(text)
|
||||
s.render(w, r, views.Exchange(user, reply))
|
||||
}
|
||||
|
||||
// stream generates a reply and pushes it to the browser as server-sent
|
||||
// events. EventSource reconnects automatically, so a reply that is already
|
||||
// claimed or finished is answered with its current state instead of being
|
||||
// generated twice.
|
||||
func (s *Server) stream(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
conv := s.store.Get(sessionID(r))
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // don't let reverse proxies buffer
|
||||
sse := &sseWriter{w: w, rc: http.NewResponseController(w)}
|
||||
|
||||
lost := session.Message{ID: id, Role: "assistant", State: session.Done,
|
||||
Err: "This reply was lost, probably because the server restarted. Please ask again."}
|
||||
if conv == nil {
|
||||
sse.component(r.Context(), "done", views.MessageView(lost))
|
||||
return
|
||||
}
|
||||
// The first reply should know the routes, so wait for them (cached
|
||||
// after the first lookup, and shared with the routes card).
|
||||
if conv.IsFirstReply(id) {
|
||||
s.loadRoutes(r.Context(), conv)
|
||||
}
|
||||
prompt, msg, ok := conv.Claim(id)
|
||||
if !ok {
|
||||
if msg.ID == "" {
|
||||
msg = lost
|
||||
}
|
||||
sse.component(r.Context(), "done", views.MessageView(msg))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.Timeout)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
err := s.llm.Stream(ctx, prompt, func(delta string) error {
|
||||
conv.Append(id, delta)
|
||||
return sse.component(ctx, "token", views.Token(delta))
|
||||
})
|
||||
|
||||
if r.Context().Err() != nil { // browser went away; keep what we have
|
||||
conv.Finish(id, errors.New("interrupted"))
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
err = fmt.Errorf("reply took longer than %s and was cut off", s.cfg.Timeout)
|
||||
case err != nil:
|
||||
s.log.Warn("generation failed", "err", err)
|
||||
err = errors.New("the local model didn't answer. Is your model server running?")
|
||||
default:
|
||||
s.log.Debug("reply done", "id", id, "took", time.Since(start))
|
||||
}
|
||||
sse.component(r.Context(), "done", views.MessageView(conv.Finish(id, err)))
|
||||
}
|
||||
|
||||
type sseWriter struct {
|
||||
w http.ResponseWriter
|
||||
rc *http.ResponseController
|
||||
}
|
||||
|
||||
// component sends one rendered Templ component as a named SSE event.
|
||||
func (s *sseWriter) component(ctx context.Context, event string, c templ.Component) error {
|
||||
var buf bytes.Buffer
|
||||
if err := c.Render(ctx, &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("event: " + event + "\n")
|
||||
for _, line := range strings.Split(buf.String(), "\n") {
|
||||
b.WriteString("data: " + line + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
if _, err := s.w.Write([]byte(b.String())); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.rc.Flush()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/config"
|
||||
)
|
||||
|
||||
// fakeUpstreams serves Open-Meteo (real Ghent fixtures), Overpass and an LLM
|
||||
// that echoes the start of its system prompt, recording what it was sent.
|
||||
func fakeUpstreams(t *testing.T, prompts *[]string) *httptest.Server {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/forecast":
|
||||
http.ServeFile(w, r, "../weather/testdata/forecast.json")
|
||||
case "/v1/search":
|
||||
http.ServeFile(w, r, "../weather/testdata/geocode.json")
|
||||
case "/overpass":
|
||||
fmt.Fprint(w, `{"elements":[{"type":"relation","id":7,"tags":{"route":"foot","name":"Bourgoyenpad","roundtrip":"yes"},
|
||||
"members":[{"type":"way","geometry":[{"lat":51.05,"lon":3.70},{"lat":51.05,"lon":3.775}]}]}]}`)
|
||||
case "/brouter":
|
||||
http.ServeFile(w, r, "../routes/testdata/brouter.json")
|
||||
case "/llm/models":
|
||||
fmt.Fprint(w, `{"data":[{"id":"fake-gemma"}]}`)
|
||||
case "/llm/chat/completions":
|
||||
var req struct {
|
||||
Messages []struct{ Role, Content string }
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
*prompts = append(*prompts, req.Messages[0].Content+"\n---\n"+req.Messages[len(req.Messages)-1].Content)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
for _, part := range []string{"**Go for a run** ", "<script>x</script>"} {
|
||||
b, _ := json.Marshal(part)
|
||||
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s}}]}\n\n", b)
|
||||
}
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newTestApp(t *testing.T, prompts *[]string) (*httptest.Server, *http.Client) {
|
||||
t.Helper()
|
||||
up := fakeUpstreams(t, prompts)
|
||||
cfg := config.Config{
|
||||
BaseURL: up.URL + "/llm",
|
||||
Timeout: 10 * time.Second,
|
||||
OverpassURL: up.URL + "/overpass",
|
||||
BRouterURL: up.URL + "/brouter",
|
||||
ForecastURL: up.URL + "/v1/forecast",
|
||||
GeocodeURL: up.URL + "/v1/search",
|
||||
}
|
||||
app := httptest.NewServer(New(cfg, "", slog.New(slog.NewTextHandler(io.Discard, nil))).Handler())
|
||||
t.Cleanup(app.Close)
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return app, &http.Client{Jar: jar}
|
||||
}
|
||||
|
||||
var streamURL = regexp.MustCompile(`sse-connect="(/stream/[0-9a-f]+)"`)
|
||||
|
||||
func do(t *testing.T, c *http.Client, method, u string, form url.Values) string {
|
||||
t.Helper()
|
||||
var resp *http.Response
|
||||
var err error
|
||||
if method == http.MethodPost {
|
||||
resp, err = c.PostForm(u, form)
|
||||
} else {
|
||||
resp, err = c.Get(u)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestPlanFlow(t *testing.T) {
|
||||
var prompts []string
|
||||
app, c := newTestApp(t, &prompts)
|
||||
|
||||
page := do(t, c, "GET", app.URL+"/", nil)
|
||||
if !strings.Contains(page, "Get me outside") {
|
||||
t.Fatal("index lacks the form")
|
||||
}
|
||||
|
||||
plan := do(t, c, "POST", app.URL+"/plan", url.Values{
|
||||
"place": {"Gent"}, "activity": {"run"}, "intensity": {"easy"}, "minutes": {"30"}, "note": {"bad knee"},
|
||||
})
|
||||
for _, want := range []string{"Ghent, Belgium", "15°", "great", `hx-get="/routes"`} {
|
||||
if !strings.Contains(plan, want) {
|
||||
t.Errorf("plan lacks %q", want)
|
||||
}
|
||||
}
|
||||
m := streamURL.FindStringSubmatch(plan)
|
||||
if m == nil {
|
||||
t.Fatal("plan has no streaming reply")
|
||||
}
|
||||
|
||||
// The first reply waits for routes, so the model sees them.
|
||||
stream := do(t, c, "GET", app.URL+m[1], nil)
|
||||
if !strings.Contains(stream, "event: done") || !strings.Contains(stream, "<strong>Go for a run</strong>") {
|
||||
t.Errorf("stream = %s", stream)
|
||||
}
|
||||
if strings.Contains(stream, "<script>") {
|
||||
t.Error("model output was not escaped")
|
||||
}
|
||||
if len(prompts) != 1 {
|
||||
t.Fatalf("llm called %d times", len(prompts))
|
||||
}
|
||||
for _, want := range []string{"Ghent, Belgium", `"bad knee"`, `"Bourgoyenpad": signposted`, "a loop from the user's door", "m of climbing", "The app's pick: running", "What should I do right now?"} {
|
||||
if !strings.Contains(prompts[0], want) {
|
||||
t.Errorf("prompt lacks %q:\n%s", want, prompts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnecting to a finished reply doesn't generate it again.
|
||||
do(t, c, "GET", app.URL+m[1], nil)
|
||||
if len(prompts) != 1 {
|
||||
t.Error("reply was generated twice")
|
||||
}
|
||||
|
||||
routes := do(t, c, "GET", app.URL+"/routes", nil)
|
||||
if !strings.Contains(routes, "Bourgoyenpad") || !strings.Contains(routes, "/gpx/7") || !strings.Contains(routes, "from your door") || !strings.Contains(routes, "/gpx/-1") {
|
||||
t.Errorf("routes card = %s", routes)
|
||||
}
|
||||
if gpx := do(t, c, "GET", app.URL+"/gpx/7", nil); !strings.Contains(gpx, "<trkpt") {
|
||||
t.Errorf("gpx = %s", gpx)
|
||||
}
|
||||
|
||||
// Follow-ups keep the plan and get the brevity hint.
|
||||
ask := do(t, c, "POST", app.URL+"/ask", url.Values{"message": {"make it harder"}})
|
||||
m = streamURL.FindStringSubmatch(ask)
|
||||
if m == nil || !strings.Contains(ask, "make it harder") {
|
||||
t.Fatalf("ask = %s", ask)
|
||||
}
|
||||
do(t, c, "GET", app.URL+m[1], nil)
|
||||
if len(prompts) != 2 || !strings.Contains(prompts[1], "make it harder\n\n(Answer in a few short lines") {
|
||||
t.Errorf("follow-up prompt = %q", prompts[len(prompts)-1])
|
||||
}
|
||||
|
||||
// Reloading the page shows the plan and the chat again.
|
||||
page = do(t, c, "GET", app.URL+"/", nil)
|
||||
if !strings.Contains(page, "make it harder") || !strings.Contains(page, "Ghent, Belgium") {
|
||||
t.Error("reload lost the plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanNeedsAPlace(t *testing.T) {
|
||||
var prompts []string
|
||||
app, c := newTestApp(t, &prompts)
|
||||
if got := do(t, c, "POST", app.URL+"/plan", url.Values{"minutes": {"60"}}); !strings.Contains(got, "Where are you?") {
|
||||
t.Errorf("got %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
:root {
|
||||
--bg: #f4f6f1;
|
||||
--surface: #ffffff;
|
||||
--text: #1c211c;
|
||||
--muted: #667066;
|
||||
--border: #dfe5db;
|
||||
--accent: #2f8f4e;
|
||||
--accent-soft: #e3f2e7;
|
||||
--accent-text: #ffffff;
|
||||
--great: #2f8f4e;
|
||||
--fine: #7a9a2e;
|
||||
--meh: #c98a12;
|
||||
--no: #c2412d;
|
||||
--bad: #c2412d;
|
||||
--radius: 14px;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111511;
|
||||
--surface: #1a1f1a;
|
||||
--text: #e8ece6;
|
||||
--muted: #93a093;
|
||||
--border: #2b332b;
|
||||
--accent: #4cb46d;
|
||||
--accent-soft: #1f3324;
|
||||
--accent-text: #0c120d;
|
||||
--great: #4cb46d;
|
||||
--fine: #a3c14a;
|
||||
--meh: #e0a93a;
|
||||
--no: #e06a55;
|
||||
--bad: #e06a55;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px 32px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
a { color: var(--accent); }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
header { display: flex; align-items: center; gap: 12px; padding: 16px 0 12px; }
|
||||
header h1 { font-size: 1.15rem; margin: 0; flex: 1; white-space: nowrap; }
|
||||
|
||||
.health { display: flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--muted); }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||
.dot.ok { background: var(--great); }
|
||||
.dot.bad { background: var(--bad); }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card h2 { font-size: 1rem; margin: 0 0 10px; }
|
||||
.card.error { border-color: var(--bad); color: var(--bad); }
|
||||
|
||||
/* the form */
|
||||
#ask { display: grid; gap: 12px; }
|
||||
.where { display: flex; gap: 8px; }
|
||||
input[type=text], textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
input[type=text]:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
fieldset { border: 0; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
legend { font-size: .8rem; color: var(--muted); padding: 0 0 4px; width: 100%; }
|
||||
.pill input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.pill span {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: .92rem;
|
||||
user-select: none;
|
||||
}
|
||||
.pill input:checked + span { background: var(--accent-soft); border-color: var(--accent); font-weight: 600; }
|
||||
.pill input:focus-visible + span { outline: 2px solid var(--accent); }
|
||||
|
||||
button {
|
||||
padding: 10px 18px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:disabled { opacity: .55; cursor: progress; }
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
button.go { font-size: 1.05rem; padding: 12px; }
|
||||
.htmx-request button.go::after { content: " …"; }
|
||||
|
||||
/* weather */
|
||||
.weather .now { display: flex; align-items: center; gap: 12px; }
|
||||
.weather .icon { font-size: 2.4rem; line-height: 1; }
|
||||
.weather .temp { font-size: 2.4rem; font-weight: 700; letter-spacing: -1px; }
|
||||
.facts { display: flex; flex-wrap: wrap; gap: 4px 16px; list-style: none; padding: 0; margin: 10px 0; font-size: .9rem; }
|
||||
.hours {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(52px, 1fr);
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
font-size: .82rem;
|
||||
text-align: center;
|
||||
}
|
||||
.hours li { display: grid; gap: 1px; padding: 6px 2px; border-radius: 8px; background: var(--bg); }
|
||||
.hours li.night { opacity: .7; }
|
||||
.hours .rain { color: #3b6cf6; }
|
||||
|
||||
/* scores */
|
||||
.scores { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 8px; }
|
||||
.score {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 4px solid var(--muted);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
cursor: help;
|
||||
}
|
||||
.score .label { font-size: .78rem; color: var(--muted); }
|
||||
.score .value { font-size: 1.5rem; font-weight: 700; }
|
||||
.score .verdict { font-size: .78rem; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.score.great { border-top-color: var(--great); } .score.great .verdict { color: var(--great); }
|
||||
.score.fine { border-top-color: var(--fine); } .score.fine .verdict { color: var(--fine); }
|
||||
.score.meh { border-top-color: var(--meh); } .score.meh .verdict { color: var(--meh); }
|
||||
.score.no { border-top-color: var(--no); } .score.no .verdict { color: var(--no); }
|
||||
.score.indoor { cursor: default; }
|
||||
.score.picked { outline: 2px solid var(--accent); background: var(--accent-soft); }
|
||||
.later { margin: 0 0 12px; font-size: .92rem; }
|
||||
|
||||
/* coach */
|
||||
#thread { display: grid; gap: 12px; }
|
||||
.msg.user { justify-self: end; background: var(--accent-soft); padding: 6px 12px; border-radius: 12px 12px 4px 12px; max-width: 85%; }
|
||||
.plain { white-space: pre-wrap; }
|
||||
.markdown > :first-child { margin-top: 0; }
|
||||
.markdown > :last-child { margin-bottom: 0; }
|
||||
.markdown p, .markdown ul { margin: 0 0 8px; }
|
||||
.markdown ul { padding-left: 20px; }
|
||||
.error { color: var(--bad); font-size: .9rem; margin: 6px 0 0; }
|
||||
.streaming:empty::after {
|
||||
content: "● ● ●";
|
||||
letter-spacing: 2px;
|
||||
color: var(--muted);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 50% { opacity: .3; } }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0 8px; }
|
||||
.chips button { padding: 5px 10px; font-size: .85rem; }
|
||||
#composer { display: flex; gap: 8px; }
|
||||
#composer textarea { resize: none; max-height: 160px; }
|
||||
|
||||
/* routes */
|
||||
.map { height: 300px; border-radius: 10px; margin-bottom: 10px; z-index: 0; }
|
||||
.route-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; }
|
||||
.route-list li { padding: 8px 10px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; font-size: .9rem; }
|
||||
.route-list li.active { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.route-list .name { font-weight: 600; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-size: .72rem;
|
||||
font-weight: 600;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.badge.signposted { background: #1f6f3a; color: #fff; }
|
||||
.route-list .links { display: flex; gap: 14px; margin-top: 4px; }
|
||||
.junction { margin: 10px 0 0; font-size: .9rem; }
|
||||
.node {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 4px;
|
||||
border-radius: 50%;
|
||||
background: #1f6f3a;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
footer { margin-top: 24px; font-size: .78rem; color: var(--muted); text-align: center; }
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.scores { grid-template-columns: repeat(2, 1fr); }
|
||||
.weather .temp, .weather .icon { font-size: 2rem; }
|
||||
header h1 { font-size: 1rem; }
|
||||
.health { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Small touches on top of HTMX: "📍 Here", remembering the form, the route
|
||||
// map, quick follow-up chips and Enter-to-send.
|
||||
(() => {
|
||||
const ask = document.getElementById("ask");
|
||||
const place = ask.elements.place;
|
||||
const lat = ask.elements.lat;
|
||||
const lon = ask.elements.lon;
|
||||
const locate = document.getElementById("locate");
|
||||
|
||||
// Remember the last choices in this browser only.
|
||||
const KEY = "workout-suggester:form";
|
||||
const save = () => {
|
||||
const d = Object.fromEntries(new FormData(ask));
|
||||
try { localStorage.setItem(KEY, JSON.stringify(d)); } catch {}
|
||||
};
|
||||
try {
|
||||
const d = JSON.parse(localStorage.getItem(KEY) || "{}");
|
||||
for (const [k, v] of Object.entries(d)) {
|
||||
const el = ask.elements[k];
|
||||
if (!el) continue;
|
||||
if (el instanceof RadioNodeList) {
|
||||
for (const r of el) r.checked = r.value === v;
|
||||
} else if (v) {
|
||||
el.value = v;
|
||||
}
|
||||
}
|
||||
if (lat.value) place.placeholder = "📍 your location";
|
||||
} catch {}
|
||||
ask.addEventListener("change", save);
|
||||
ask.addEventListener("htmx:beforeRequest", save);
|
||||
|
||||
// Typing a place overrides the GPS fix.
|
||||
place.addEventListener("input", () => {
|
||||
lat.value = lon.value = "";
|
||||
place.placeholder = "Town or city";
|
||||
});
|
||||
|
||||
locate.addEventListener("click", () => {
|
||||
if (!navigator.geolocation) return alert("This browser can't share its location. Type a town instead.");
|
||||
locate.disabled = true;
|
||||
locate.textContent = "📍 …";
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
lat.value = pos.coords.latitude.toFixed(5);
|
||||
lon.value = pos.coords.longitude.toFixed(5);
|
||||
place.value = "";
|
||||
place.placeholder = "📍 your location";
|
||||
locate.disabled = false;
|
||||
locate.textContent = "📍 Here";
|
||||
save();
|
||||
ask.requestSubmit();
|
||||
},
|
||||
(err) => {
|
||||
locate.disabled = false;
|
||||
locate.textContent = "📍 Here";
|
||||
alert("Couldn't get your location (" + err.message + "). Type a town instead.");
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }
|
||||
);
|
||||
});
|
||||
|
||||
// Draw route maps that arrive in HTMX swaps.
|
||||
const COLORS = ["#2f8f4e", "#3b6cf6", "#c2410c"];
|
||||
const drawMaps = (root) => {
|
||||
if (!window.L) return;
|
||||
for (const el of root.querySelectorAll(".map[data-map]:not(.leaflet-container)")) {
|
||||
const d = JSON.parse(el.dataset.map);
|
||||
// The view must exist before vector layers are added, or Leaflet throws.
|
||||
const map = L.map(el, { scrollWheelZoom: false }).setView(d.you, 13);
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 18,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
}).addTo(map);
|
||||
const layers = d.routes.map((r, i) =>
|
||||
L.featureGroup([
|
||||
L.polyline(r.ways, { color: COLORS[i % COLORS.length], weight: i === 0 ? 5 : 3, opacity: i === 0 ? 0.9 : 0.5 }),
|
||||
L.circleMarker(r.join, { radius: 5, color: COLORS[i % COLORS.length], fillOpacity: 1 }).bindTooltip("Start here"),
|
||||
]).bindTooltip(r.name, { sticky: true })
|
||||
);
|
||||
layers.forEach((l) => l.addTo(map));
|
||||
L.circleMarker(d.you, { radius: 7, color: "#fff", weight: 2, fillColor: "#111", fillOpacity: 1 })
|
||||
.bindTooltip("You").addTo(map);
|
||||
const focus = (i) => {
|
||||
layers.forEach((l, j) => l.eachLayer((x) => x.setStyle && x.setStyle({ weight: j === i ? 5 : 3, opacity: j === i ? 0.9 : 0.4 })));
|
||||
layers[i].bringToFront();
|
||||
map.fitBounds(layers[i].getBounds().extend(d.you), { padding: [20, 20] });
|
||||
};
|
||||
focus(0);
|
||||
const list = el.parentElement.querySelectorAll("[data-route]");
|
||||
list.forEach((li) =>
|
||||
li.addEventListener("click", (e) => {
|
||||
if (e.target.closest("a")) return;
|
||||
list.forEach((x) => x.classList.toggle("active", x === li));
|
||||
focus(+li.dataset.route);
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Follow-up chat: Enter to send, quick chips, auto-grow.
|
||||
document.body.addEventListener("keydown", (e) => {
|
||||
const t = e.target;
|
||||
if (t.matches("#composer textarea") && e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
if (t.value.trim()) t.form.requestSubmit();
|
||||
}
|
||||
});
|
||||
document.body.addEventListener("input", (e) => {
|
||||
if (e.target.matches("#composer textarea")) {
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = e.target.scrollHeight + "px";
|
||||
}
|
||||
});
|
||||
document.body.addEventListener("click", (e) => {
|
||||
const chip = e.target.closest("[data-ask]");
|
||||
if (!chip) return;
|
||||
const form = document.getElementById("composer");
|
||||
form.elements.message.value = chip.dataset.ask;
|
||||
form.requestSubmit();
|
||||
});
|
||||
document.body.addEventListener("htmx:afterRequest", (e) => {
|
||||
if (e.target.id === "composer" && e.detail.successful) {
|
||||
e.target.reset();
|
||||
e.target.elements.message.style.height = "auto";
|
||||
}
|
||||
});
|
||||
|
||||
// outerHTML swaps replace the event target, so just look everywhere.
|
||||
document.body.addEventListener("htmx:afterSettle", (e) => {
|
||||
drawMaps(document);
|
||||
if (e.target.id === "plan") e.target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
addEventListener("load", () => drawMaps(document));
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#2f8f4e"/><path d="M7 25c2-6 1-10 0-14 3 3 4 7 4 14M14 25c0-7 1-12 3-17 1 6 1 11 0 17M20 25c1-5 3-8 6-10-1 4-2 7-3 10" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 295 B |
Vendored
+1
@@ -0,0 +1 @@
|
||||
var htmx=function(){"use strict";const Y={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=pn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.11"};Y.onLoad=V;Y.process=Bt;Y.on=ye;Y.off=xe;Y.trigger=ae;Y.ajax=In;Y.find=a;Y.findAll=x;Y.closest=g;Y.remove=z;Y.addClass=w;Y.removeClass=S;Y.toggleClass=G;Y.takeClass=W;Y.swap=C;Y.defineExtension=zn;Y.removeExtension=Jn;Y.logAll=$;Y.logNone=_;Y.parseInterval=d;Y._=e;const n={addTriggerHandler:Et,bodyContains:ie,canAccessLocalStorage:U,findThisElement:we,filterValues:xn,swap:C,hasAttribute:s,getAttributeValue:f,getClosestAttributeValue:te,getClosestMatch:A,getExpressionVars:qn,getHeaders:yn,getInputValues:pn,getInternalData:re,getSwapSpecification:vn,getTriggerSpecs:lt,getTarget:Se,makeFragment:D,mergeObjects:se,makeSettleInfo:En,oobSwap:Te,querySelectorExt:ce,settleImmediately:Qt,shouldCancel:dt,triggerEvent:ae,triggerErrorEvent:ue,withExtensions:Vt};const he=["get","post","put","delete","patch"];const R=he.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function Q(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function f(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ee(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):ee()}function A(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=f(t,n);const o=f(t,"hx-disinherit");var i=f(t,"hx-inherit");if(e!==t){if(Y.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function te(t,n){let r=null;A(t,function(e){return!!(r=o(t,le(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=ee().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Y.config.inlineScriptNonce){t.nonce=Y.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){if(!Y.config.allowScriptTags){e.querySelectorAll("script").forEach(e=>e.remove());return}Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){T(e)}finally{e.remove()}}})}function D(e){e=e.replace(/<hx-([a-z]+)((?:\s[^>]*)?)>/gi,'<template hx type="$1"$2>').replace(/<\/hx-[a-z]+>/gi,"</template>");const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){oLine truncated
|
||||
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* @preserve
|
||||
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
|
||||
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
|
||||
//# sourceMappingURL=leaflet.js.map
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i<r.length;i++){const u=r[i].trim();const c=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(u,c);return}if(!g.triggerEvent(n,"htmx:sseBeforeMessage",e)){return}f(n,e.data);g.triggerEvent(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=c;a.addEventListener(u,c)}}if(g.getAttributeValue(n,"hx-trigger")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var o=g.getTriggerSpecs(n);o.forEach(function(t){if(t.trigger.slice(0,4)!=="sse:"){return}var r=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(t.trigger.slice(4),r)}htmx.trigger(n,t.trigger,e);htmx.trigger(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=r;a.addEventListener(t.trigger.slice(4),r)})}}function i(e,t){if(e==null){return null}if(g.getAttributeValue(e,"sse-connect")){var r=g.getAttributeValue(e,"sse-connect");if(r==null){return}n(e,r,t)}a(e)}function n(r,e,n){var s=htmx.createEventSource(e);s.onerror=function(e){g.triggerErrorEvent(r,"htmx:sseError",{error:e,source:s});if(l(r)){return}if(s.readyState===EventSource.CLOSED){n=n||0;n=Math.max(Math.min(n*2,128),1);var t=n*500;window.setTimeout(function(){i(r,n)},t)}};s.onopen=function(e){g.triggerEvent(r,"htmx:sseOpen",{source:s});if(n&&n>0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e<t.length;e++){a(t[e])}n=0}};g.getInternalData(r).sseEventSource=s;var t=g.getAttributeValue(r,"sse-close");if(t){s.addEventListener(t,function(){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"message"});s.close()})}}function l(e){if(!g.bodyContains(e)){var t=g.getInternalData(e).sseEventSource;if(t!=undefined){g.triggerEvent(e,"htmx:sseClose",{source:t,type:"nodeMissing"});t.close();return true}}return false}function f(t,r){g.withExtensions(t,function(e){r=e.transformResponse(r,null,t)});var e=g.getSwapSpecification(t);var n=g.getTarget(t);g.swap(n,r,e,{contextElement:t})}function v(e){return g.getInternalData(e).sseEventSource!=null}})();
|
||||
@@ -0,0 +1,160 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/coach"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/routes"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// md renders CommonMark + GFM. Raw HTML in model output is escaped
|
||||
// (goldmark's default), so a reply can't inject markup into the page.
|
||||
// Hard wraps: small models separate sections with single newlines.
|
||||
var md = goldmark.New(goldmark.WithExtensions(extension.GFM), goldmark.WithRendererOptions(html.WithHardWraps()))
|
||||
|
||||
// sectionStart finds "**Label:**" lines. Models often start them right
|
||||
// after a list, which Markdown would fold into the last bullet.
|
||||
var sectionStart = regexp.MustCompile(`\n(\*\*[^*\n]+:?\*\*)`)
|
||||
|
||||
// Markdown renders model output as HTML, falling back to plain text.
|
||||
func Markdown(src string) templ.Component {
|
||||
var buf bytes.Buffer
|
||||
src = sectionStart.ReplaceAllString(src, "\n\n$1")
|
||||
if err := md.Convert([]byte(src), &buf); err != nil {
|
||||
return Token(src)
|
||||
}
|
||||
return templ.Raw(buf.String())
|
||||
}
|
||||
|
||||
func healthTrigger(h HealthInfo) string {
|
||||
if h.Checking {
|
||||
return "load"
|
||||
}
|
||||
return "every 30s"
|
||||
}
|
||||
|
||||
func minutesLabel(m int) string {
|
||||
if m >= 60 && m%60 == 0 {
|
||||
return fmt.Sprintf("%dh", m/60)
|
||||
}
|
||||
if m > 60 {
|
||||
return fmt.Sprintf("%dh%02d", m/60, m%60)
|
||||
}
|
||||
return fmt.Sprintf("%d min", m)
|
||||
}
|
||||
|
||||
func placeName(p string) string {
|
||||
if p == "" {
|
||||
return "📍 your location"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// gustsLabel marks gusts that were estimated from wind speed.
|
||||
func gustsLabel(f weather.Forecast) string {
|
||||
if f.GustsEstimated {
|
||||
return "≈"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nextHours(f weather.Forecast, n int) []weather.Hour {
|
||||
return f.Hours[:min(n, len(f.Hours))]
|
||||
}
|
||||
|
||||
func reasons(s coach.Score) string {
|
||||
if len(s.Reasons) == 0 {
|
||||
return "good conditions"
|
||||
}
|
||||
return strings.Join(s.Reasons, "; ")
|
||||
}
|
||||
|
||||
// pickedReasons explains the pick's score, or why everything outside lost.
|
||||
func pickedReasons(p coach.Plan) string {
|
||||
a := p.Assessment
|
||||
if a.Pick == coach.Indoor {
|
||||
var all []string
|
||||
for _, s := range a.Scores {
|
||||
for _, r := range s.Reasons {
|
||||
if !slices.Contains(all, r) {
|
||||
all = append(all, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(all, "; ")
|
||||
}
|
||||
if s := a.ScoreOf(a.Pick); len(s.Reasons) > 0 {
|
||||
return strings.Join(s.Reasons, "; ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// loopDetails is the one-line summary of a generated loop.
|
||||
func loopDetails(p coach.Plan, r *routes.Route) string {
|
||||
parts := []string{fmt.Sprintf("%d m climb", r.AscentM)}
|
||||
if r.PavedPct >= 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d%% paved", r.PavedPct))
|
||||
}
|
||||
if r.IntoWind(p.Forecast.Now.WindDirection) {
|
||||
parts = append(parts, "into the wind first, tailwind home")
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
func junctionKind(j *coach.JunctionInfo) string {
|
||||
if j.Kind == routes.Bike {
|
||||
return "cycling"
|
||||
}
|
||||
return "walking"
|
||||
}
|
||||
|
||||
// mapData is the JSON the map script draws: you, the routes, the junction.
|
||||
func mapData(p coach.Plan) string {
|
||||
type route struct {
|
||||
Name string `json:"name"`
|
||||
Ways [][][2]float64 `json:"ways"`
|
||||
Join [2]float64 `json:"join"`
|
||||
}
|
||||
out := struct {
|
||||
You [2]float64 `json:"you"`
|
||||
Routes []route `json:"routes"`
|
||||
}{You: [2]float64{p.At.Lat, p.At.Lon}}
|
||||
for _, s := range p.Routes {
|
||||
r := route{Name: s.Route.Name, Join: [2]float64{s.JoinAt.Lat, s.JoinAt.Lon}}
|
||||
for _, w := range s.Route.Ways {
|
||||
pts := make([][2]float64, len(w))
|
||||
for i, q := range w {
|
||||
pts[i] = [2]float64{round5(q.Lat), round5(q.Lon)}
|
||||
}
|
||||
r.Ways = append(r.Ways, pts)
|
||||
}
|
||||
out.Routes = append(out.Routes, r)
|
||||
}
|
||||
b, _ := json.Marshal(out)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func round5(v float64) float64 { return math.Round(v*1e5) / 1e5 }
|
||||
|
||||
// directionsURL opens OpenStreetMap's own (OSRM) directions from the user to
|
||||
// where the route is closest.
|
||||
func directionsURL(p coach.Plan, s routes.Suggestion) string {
|
||||
engine := "fossgis_osrm_foot"
|
||||
if s.Route.Kind == routes.Bike {
|
||||
engine = "fossgis_osrm_bike"
|
||||
}
|
||||
return fmt.Sprintf("https://www.openstreetmap.org/directions?engine=%s&route=%.5f%%2C%.5f%%3B%.5f%%2C%.5f",
|
||||
engine, p.At.Lat, p.At.Lon, s.JoinAt.Lat, s.JoinAt.Lon)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarkdownSections(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
src := "**Go for a run**: nice.\n**When:** now\n**The session:**\n- warm up\n- run\n**Route:** the loop"
|
||||
Markdown(src).Render(context.Background(), &buf)
|
||||
got := buf.String()
|
||||
if strings.Contains(got, "run\n<strong>Route:") || !strings.Contains(got, "</ul>\n<p><strong>Route:</strong>") {
|
||||
t.Errorf("Route folded into the list:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "<p><strong>Go for a run</strong>: nice.</p>") {
|
||||
t.Errorf("first line mangled:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package views
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/coach"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/session"
|
||||
"git.b0b.be/bdeb/workout-suggester/internal/weather"
|
||||
)
|
||||
|
||||
// PageData is everything the full page needs.
|
||||
type PageData struct {
|
||||
DefaultPlace string
|
||||
Plan *coach.Plan // nil before the first plan
|
||||
Messages []session.Message
|
||||
}
|
||||
|
||||
templ Page(d PageData) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>workout-suggester</title>
|
||||
<meta name="description" content="Checks the weather and tells you what workout to do outside, with a local AI model."/>
|
||||
<link rel="stylesheet" href="/static/leaflet.css"/>
|
||||
<link rel="stylesheet" href="/static/app.css"/>
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/sse.min.js" defer></script>
|
||||
<script src="/static/leaflet.js" defer></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🌿 workout-suggester</h1>
|
||||
@Health(HealthInfo{Checking: true})
|
||||
</header>
|
||||
@Form(d.DefaultPlace)
|
||||
<main id="plan" aria-live="polite">
|
||||
if d.Plan != nil {
|
||||
@PlanView(*d.Plan, d.Messages)
|
||||
}
|
||||
</main>
|
||||
<footer>
|
||||
Advice by a local open-weight model · weather <a href="https://open-meteo.com/">Open-Meteo</a> / <a href="https://api.met.no/">MET Norway</a> · routes © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, loops by <a href="https://brouter.de/">BRouter</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ Form(defaultPlace string) {
|
||||
<form id="ask" class="card" hx-post="/plan" hx-target="#plan" hx-swap="innerHTML" hx-disabled-elt="find button[type=submit]">
|
||||
<div class="where">
|
||||
<input type="text" name="place" placeholder="Town or city" value={ defaultPlace } aria-label="Town or city" autocomplete="address-level2"/>
|
||||
<button type="button" id="locate" class="ghost" title="Use my location">📍 Here</button>
|
||||
<input type="hidden" name="lat"/>
|
||||
<input type="hidden" name="lon"/>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend>Up for</legend>
|
||||
@radio("activity", "", coach.Any.Label(), true)
|
||||
@radio("activity", "run", coach.Run.Label(), false)
|
||||
@radio("activity", "ride", coach.Ride.Label(), false)
|
||||
@radio("activity", "walk", coach.Walk.Label(), false)
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>How hard</legend>
|
||||
@radio("intensity", "easy", "😌 Easy", false)
|
||||
@radio("intensity", "moderate", "🙂 Moderate", true)
|
||||
@radio("intensity", "hard", "🔥 Hard", false)
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>How long</legend>
|
||||
for _, m := range []int{30, 45, 60, 90, 120} {
|
||||
@radio("minutes", fmt.Sprint(m), minutesLabel(m), m == 60)
|
||||
}
|
||||
</fieldset>
|
||||
<input type="text" name="note" maxlength="200" placeholder="Anything else? e.g. sore knee, want hills, have a dog" aria-label="Anything else"/>
|
||||
<button type="submit" class="go">Get me outside</button>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ radio(name, value, label string, checked bool) {
|
||||
<label class="pill">
|
||||
<input type="radio" name={ name } value={ value } checked?={ checked }/>
|
||||
<span>{ label }</span>
|
||||
</label>
|
||||
}
|
||||
|
||||
templ Error(msg string) {
|
||||
<div class="card error">⚠️ { msg }</div>
|
||||
}
|
||||
|
||||
// PlanView is the weather, the scores, the coach's advice, routes and the
|
||||
// follow-up chat.
|
||||
templ PlanView(p coach.Plan, msgs []session.Message) {
|
||||
@WeatherCard(p)
|
||||
@Scores(p)
|
||||
<section class="card coach">
|
||||
<div id="thread">
|
||||
for _, m := range msgs {
|
||||
if m.Role == "assistant" && m.State != session.Done {
|
||||
@StreamingReply(m)
|
||||
} else {
|
||||
@MessageView(m)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="chips">
|
||||
<button type="button" class="ghost" data-ask="Make it harder">💪 Harder</button>
|
||||
<button type="button" class="ghost" data-ask="Make it easier">😮💨 Easier</button>
|
||||
<button type="button" class="ghost" data-ask="I only have 30 minutes">⏱️ Only 30 min</button>
|
||||
<button type="button" class="ghost" data-ask="Suggest something different">🔀 Something else</button>
|
||||
</div>
|
||||
<form id="composer" hx-post="/ask" hx-target="#thread" hx-swap="beforeend" hx-disabled-elt="find button">
|
||||
<textarea name="message" rows="1" maxlength="1000" placeholder="Ask the coach…" required></textarea>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</section>
|
||||
if p.Assessment.Pick != coach.Indoor {
|
||||
if p.RoutesLoaded {
|
||||
@RoutesCard(p)
|
||||
} else {
|
||||
<section class="card routes" hx-get="/routes" hx-trigger="load" hx-swap="outerHTML">
|
||||
<h2>🗺️ Routes nearby</h2>
|
||||
<p class="muted">Planning loops from your door and looking up signposted routes…</p>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ WeatherCard(p coach.Plan) {
|
||||
{{ f := p.Forecast }}
|
||||
{{ desc, _ := weather.Describe(f.Now.Code) }}
|
||||
<section class="card weather">
|
||||
<div class="now">
|
||||
<span class="icon">{ f.Now.Icon() }</span>
|
||||
<span class="temp">{ fmt.Sprintf("%.0f°", f.Now.Temp) }</span>
|
||||
<div>
|
||||
<div><strong>{ desc }</strong>, feels like { fmt.Sprintf("%.0f°", f.Now.Feels) }</div>
|
||||
<div class="muted">{ placeName(p.Place) } · { f.Now.Time.Format("Mon 15:04") } · via { f.Source }</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="facts">
|
||||
<li>💨 { fmt.Sprintf("%.0f km/h %s", f.Now.Wind, weather.Compass(f.Now.WindDirection)) }, gusts { gustsLabel(f) }{ fmt.Sprintf("%.0f", f.Now.Gusts) }</li>
|
||||
<li>🌧️ { fmt.Sprintf("%.1f mm", f.Now.Precip) } · { f.Now.Chance() } chance</li>
|
||||
<li>🌇 sunset { f.Sunset.Format("15:04") }</li>
|
||||
</ul>
|
||||
<ol class="hours">
|
||||
for _, h := range nextHours(f, 8) {
|
||||
<li class={ templ.KV("night", !h.IsDay) }>
|
||||
<span class="muted">{ h.Time.Format("15h") }</span>
|
||||
<span>{ h.Icon() }</span>
|
||||
<span>{ fmt.Sprintf("%.0f°", h.Temp) }</span>
|
||||
<span class="rain">{ h.Chance() }</span>
|
||||
<span class="muted">{ fmt.Sprintf("%.0f", h.Gusts) }</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
}
|
||||
|
||||
templ Scores(p coach.Plan) {
|
||||
{{ a := p.Assessment }}
|
||||
<section class="scores">
|
||||
for _, s := range a.Scores {
|
||||
<div class={ "score", s.Verdict(), templ.KV("picked", s.Activity == a.Pick) } title={ reasons(s) }>
|
||||
<div class="label">{ s.Activity.Label() }</div>
|
||||
<div class="value">{ fmt.Sprint(s.Value) }</div>
|
||||
<div class="verdict">{ s.Verdict() }</div>
|
||||
</div>
|
||||
}
|
||||
<div class={ "score", "indoor", templ.KV("picked", a.Pick == coach.Indoor) }>
|
||||
<div class="label">{ coach.Indoor.Label() }</div>
|
||||
<div class="value">🏋️</div>
|
||||
<div class="verdict">
|
||||
if a.Pick == coach.Indoor {
|
||||
today
|
||||
} else {
|
||||
plan B
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
if a.Later != nil {
|
||||
<p class="later">⏰ Better at <strong>{ a.Later.Start.Format("15:04") }</strong>: { a.Later.Activity.Noun() } scores { fmt.Sprint(a.Later.Score) }.</p>
|
||||
}
|
||||
if why := pickedReasons(p); why != "" {
|
||||
<p class="later muted">Why: { why }</p>
|
||||
}
|
||||
}
|
||||
|
||||
templ RoutesCard(p coach.Plan) {
|
||||
<section class="card routes">
|
||||
<h2>🗺️ Routes</h2>
|
||||
if len(p.Routes) == 0 {
|
||||
<p class="muted">
|
||||
if p.RoutesErr != "" {
|
||||
The route services are busy, so no routes this time. Try again in a minute.
|
||||
} else {
|
||||
No { p.Assessment.Pick.Noun() } routes found close by. An out-and-back from your door works too.
|
||||
}
|
||||
</p>
|
||||
} else {
|
||||
<div class="map" data-map={ mapData(p) }></div>
|
||||
<ol class="route-list">
|
||||
for i, s := range p.Routes {
|
||||
<li data-route={ fmt.Sprint(i) } class={ templ.KV("active", i == 0) }>
|
||||
<div class="name">
|
||||
if s.Route.Generated {
|
||||
<span class="badge">from your door</span>
|
||||
} else {
|
||||
<span class="badge signposted">signposted</span>
|
||||
}
|
||||
{ s.Route.Name }
|
||||
</div>
|
||||
<div class="muted">
|
||||
if s.Route.Generated {
|
||||
{ loopDetails(p, s.Route) }
|
||||
} else {
|
||||
{ s.Describe() } ≈ { fmt.Sprintf("%.1f km", s.TotalKm) } · { fmt.Sprintf("%.1f km", s.FromYouKm) } from you
|
||||
}
|
||||
</div>
|
||||
<div class="links">
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("/gpx/%d", s.Route.ID)) } download>⬇ GPX</a>
|
||||
if !s.Route.Generated {
|
||||
<a href={ templ.SafeURL(directionsURL(p, s)) } target="_blank" rel="noopener">🧭 To the start</a>
|
||||
<a href={ templ.SafeURL(fmt.Sprintf("https://www.openstreetmap.org/relation/%d", s.Route.ID)) } target="_blank" rel="noopener">OSM</a>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
if j := p.Junction; j != nil {
|
||||
<p class="junction">📍 Nearest { junctionKind(j) } junction: <span class="node">{ j.Ref }</span> { fmt.Sprintf("%.1f km", j.Km) } away</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
// MessageView renders a finished chat message.
|
||||
templ MessageView(m session.Message) {
|
||||
<div class={ "msg", m.Role } id={ "msg-" + m.ID }>
|
||||
if m.Role == "assistant" {
|
||||
<div class="markdown">
|
||||
@Markdown(m.Content)
|
||||
</div>
|
||||
if m.Err != "" {
|
||||
<p class="error">⚠️ { m.Err }</p>
|
||||
}
|
||||
} else {
|
||||
<div class="plain">{ m.Content }</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Exchange is a follow-up question plus its streaming reply.
|
||||
templ Exchange(user, reply session.Message) {
|
||||
@MessageView(user)
|
||||
@StreamingReply(reply)
|
||||
}
|
||||
|
||||
// StreamingReply opens an SSE connection. "token" events append text as it
|
||||
// arrives; the final "done" event swaps in the rendered Markdown.
|
||||
templ StreamingReply(m session.Message) {
|
||||
<div
|
||||
class="msg assistant"
|
||||
id={ "msg-" + m.ID }
|
||||
hx-ext="sse"
|
||||
sse-connect={ "/stream/" + m.ID }
|
||||
sse-swap="done"
|
||||
hx-swap="outerHTML"
|
||||
sse-close="done"
|
||||
>
|
||||
<div class="plain streaming" sse-swap="token" hx-swap="beforeend"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Token(text string) {
|
||||
<span>{ text }</span>
|
||||
}
|
||||
|
||||
// HealthInfo describes the model server status shown in the header.
|
||||
type HealthInfo struct {
|
||||
Checking bool
|
||||
OK bool
|
||||
Model string
|
||||
Err string
|
||||
}
|
||||
|
||||
templ Health(h HealthInfo) {
|
||||
<div id="health" class="health" hx-get="/health" hx-trigger={ healthTrigger(h) } hx-swap="outerHTML">
|
||||
if h.Checking {
|
||||
<span class="dot"></span> connecting…
|
||||
} else if h.OK {
|
||||
<span class="dot ok"></span> { h.Model }
|
||||
} else {
|
||||
<span class="dot bad"></span> <span title={ h.Err }>model offline</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user