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,18 @@
|
|||||||
|
# Where your OpenAI-compatible model server lives (oMLX, llama.cpp, Ollama, LM Studio, ...)
|
||||||
|
LLM_BASE_URL=http://127.0.0.1:8000/v1
|
||||||
|
# Leave empty to use the first model the server lists
|
||||||
|
LLM_MODEL=gemma-4-E2B-it-qat-4bit
|
||||||
|
# Only if your server requires one
|
||||||
|
LLM_API_KEY=
|
||||||
|
|
||||||
|
# Prefill the town field
|
||||||
|
# DEFAULT_PLACE=Gent
|
||||||
|
# ADDR=127.0.0.1:3000
|
||||||
|
# LLM_TIMEOUT=3m
|
||||||
|
# One or more Overpass endpoints, tried in order
|
||||||
|
# OVERPASS_URL=https://overpass-api.de/api/interpreter
|
||||||
|
# BRouter plans loops from your door; self-host it or set "-" to turn loops off
|
||||||
|
# BROUTER_URL=https://brouter.de/brouter
|
||||||
|
# Open-Meteo is open source; point these at your own instance if you like
|
||||||
|
# OPEN_METEO_URL=https://api.open-meteo.com/v1/forecast
|
||||||
|
# OPEN_METEO_GEOCODE_URL=https://geocoding-api.open-meteo.com/v1/search
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
bin/
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
dist/
|
||||||
|
dev/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
|
PLATFORMS := linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64 windows/arm64
|
||||||
|
|
||||||
|
.PHONY: generate build run test dist
|
||||||
|
|
||||||
|
generate: ## regenerate Templ components
|
||||||
|
go tool templ generate
|
||||||
|
|
||||||
|
build: generate ## build ./bin/workout-suggester for this machine
|
||||||
|
go build -trimpath -ldflags "$(LDFLAGS)" -o bin/workout-suggester ./cmd/workout-suggester
|
||||||
|
|
||||||
|
run: build ## run in the foreground (reads ./.env)
|
||||||
|
./bin/workout-suggester --debug
|
||||||
|
|
||||||
|
test: ## unit + handler tests (no model or network needed)
|
||||||
|
go test -race ./...
|
||||||
|
|
||||||
|
dist: generate ## cross-compile for every platform into ./dist
|
||||||
|
@for p in $(PLATFORMS); do \
|
||||||
|
os=$${p%/*}; arch=$${p#*/}; ext=$$( [ $$os = windows ] && echo .exe ); \
|
||||||
|
echo " $$os/$$arch"; \
|
||||||
|
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags "$(LDFLAGS)" \
|
||||||
|
-o dist/workout-suggester-$$os-$$arch$$ext ./cmd/workout-suggester || exit 1; \
|
||||||
|
done
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# workout-suggester
|
||||||
|
|
||||||
|
Tell it where you are, how hard you want to go and for how long. It checks the weather and tells you whether to run, ride or walk, or to stay in and do HIIT when it's grim. It also gives you a signposted OpenStreetMap route that fits the distance, as a GPX file for your watch.
|
||||||
|
|
||||||
|
The advice comes from **Gemma 4 E2B running on your own machine**. It's a **Go + Templ + HTMX** app in one ~9 MB binary.
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<img src="docs/screenshot.png" width="62%" alt="Desktop: weather, activity scores, the coach's plan and a route map">
|
||||||
|
<img src="docs/screenshot-mobile.png" width="30%" alt="The same on a phone">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. **Weather:** [Open-Meteo](https://open-meteo.com/) (open source, no key) gives current conditions and the next 24 hours. If it's overloaded, the app retries once and then falls back to [MET Norway](https://api.met.no/). MET Norway has no gusts or rain chance outside the Nordics, so gusts are estimated (shown as ≈), the rain chance shows as unknown, and sunrise/sunset are computed locally. If both fail, it uses the last forecast it had, up to 3 h old. Place search falls back from Open-Meteo to [Nominatim](https://nominatim.org/).
|
||||||
|
2. **Scoring (plain Go rules, not the model):** each activity starts at 100 and loses points for rain, gusts, cold, heat, ice, fog and darkness. The amount depends on the activity, e.g. 50 km/h gusts cost a ride 40 points and a run only 15. Below 40 everywhere, you're sent inside. The app also looks up to 12 hours ahead for a clearly better daylight window ("better at 17:00").
|
||||||
|
3. **Routes:**
|
||||||
|
- **Loops from your door:** [BRouter](https://github.com/abrensch/brouter), an open-source OpenStreetMap router, generates round trips of your target length in three directions. It uses a foot profile for runs and walks and a bike profile for rides. The first loop heads out into the wind so the way home has a tailwind. Each loop shows its climb and how much of it is paved.
|
||||||
|
- **Signposted routes nearby:** the [Overpass API](https://overpass-api.de/) finds named cycling and walking routes (Belgian *fietsroutes*, *wandelroutes*) and the nearest *knooppunt* (node-network junction). They're matched to your distance as loops, laps or out-and-back. The way to the start counts towards your distance, and long-distance trails like the Streek-GR are left out.
|
||||||
|
4. **Coach:** all of the above goes into the system prompt as a short sheet of facts. Gemma writes the plan: why, when, the session (warm-up, main set, cool-down), route, what to wear and a plan B. You can ask follow-ups ("only 30 min", "make it harder").
|
||||||
|
|
||||||
|
The split is deliberate: small models get numbers wrong, so code decides the facts and the model explains them and builds the workout.
|
||||||
|
|
||||||
|
## Why open
|
||||||
|
|
||||||
|
- **The model runs on your machine.** Your location, your notes ("sore knee") and your questions never reach an AI company. There's no API key and nothing to pay per token.
|
||||||
|
- **Only rough coordinates leave the machine.** Weather lookups use about 1 km precision, signposted-route lookups a ~2 km grid, and loop planning ~100 m. Distances to routes are computed locally with your exact position.
|
||||||
|
- **Every part can be swapped or self-hosted.** Point `LLM_BASE_URL` at oMLX, llama.cpp, Ollama or LM Studio and use any model. Open-Meteo, Overpass and BRouter are open source too, so `OPEN_METEO_URL`, `OVERPASS_URL` and `BROUTER_URL` can point at your own instances.
|
||||||
|
- **Routes keep working without a connection.** Route data is cached on disk for a week. GPX export means your phone can stay in your pocket.
|
||||||
|
- **OpenStreetMap knows the local routes,** down to the knooppunten network and park loops. No closed fitness API gives you that for free.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
You need an OpenAI-compatible model server. On a Mac:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
omlx serve # or: llama-server -hf unsloth/gemma-4-E2B-it-GGUF:Q4_K_M --no-mmproj
|
||||||
|
cp .env.example .env # set LLM_BASE_URL / LLM_MODEL (and LLM_API_KEY if your server wants one)
|
||||||
|
make run # → http://127.0.0.1:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Tap **📍 Here** (the browser asks for your location) or type a town.
|
||||||
|
|
||||||
|
| Setting | Default |
|
||||||
|
|---|---|
|
||||||
|
| `LLM_BASE_URL` | `http://127.0.0.1:8000/v1` (oMLX) |
|
||||||
|
| `LLM_MODEL` | first model the server lists |
|
||||||
|
| `LLM_API_KEY` | none |
|
||||||
|
| `DEFAULT_PLACE` | none (prefills the town field) |
|
||||||
|
| `ADDR` | `127.0.0.1:3000` |
|
||||||
|
| `BROUTER_URL` | `https://brouter.de/brouter` (self-hostable; `-` turns loops off) |
|
||||||
|
| `OVERPASS_URL` | `https://overpass-api.de/api/interpreter` (space-separated list, tried in order) |
|
||||||
|
| `OPEN_METEO_URL`, `OPEN_METEO_GEOCODE_URL` | the public Open-Meteo API |
|
||||||
|
|
||||||
|
`gemma-4-E2B-it-qat-4bit` answers in about 10 s on an M-series Mac. `E4B` follows the format more closely.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make test # unit + handler tests, no network or model needed
|
||||||
|
uv run scripts/e2e.py --url http://127.0.0.1:3000 [--mobile] [--dark] # browser test + screenshots
|
||||||
|
make dist # cross-compile for Linux, macOS, Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/workout-suggester entry point
|
||||||
|
internal/weather Open-Meteo + MET Norway fallback, local sunrise maths (rounded coords, 15 min cache)
|
||||||
|
internal/coach scoring rules, distance targets, the prompt
|
||||||
|
internal/routes BRouter loops, Overpass client (retries, disk cache), route matching, GPX
|
||||||
|
internal/session per-browser plan + chat
|
||||||
|
internal/llm minimal OpenAI-compatible streaming client
|
||||||
|
internal/web handlers, Templ views, HTMX + SSE, Leaflet map
|
||||||
|
```
|
||||||
|
|
||||||
|
Weather data by [Open-Meteo](https://open-meteo.com/) and [MET Norway](https://api.met.no/) (both CC BY 4.0). Route data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors (ODbL). Loops by [BRouter](https://brouter.de/).
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Command workout-suggester checks the weather where you are and has a local,
|
||||||
|
// open-weight model tell you what workout to do outside (or inside, if it's
|
||||||
|
// truly grim), with signposted OpenStreetMap routes to match.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"git.b0b.be/bdeb/workout-suggester/internal/config"
|
||||||
|
"git.b0b.be/bdeb/workout-suggester/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev" // set with -ldflags "-X main.version=..."
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(os.Args[1:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "workout-suggester:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("workout-suggester", flag.ContinueOnError)
|
||||||
|
envFile := fs.String("env-file", "", "load settings from this KEY=VALUE file (default: ./.env if present)")
|
||||||
|
debug := fs.Bool("debug", false, "verbose logging")
|
||||||
|
showVersion := fs.Bool("version", false, "print the version and exit")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Println(version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if *envFile == "" {
|
||||||
|
if _, err := os.Stat(".env"); err == nil {
|
||||||
|
*envFile = ".env"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *envFile != "" {
|
||||||
|
if err := config.LoadEnvFile(*envFile); err != nil {
|
||||||
|
return fmt.Errorf("env file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
level := slog.LevelInfo
|
||||||
|
if *debug {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
}
|
||||||
|
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
|
||||||
|
|
||||||
|
cacheDir := ""
|
||||||
|
if dir, err := os.UserCacheDir(); err == nil {
|
||||||
|
cacheDir = filepath.Join(dir, "workout-suggester", "routes")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
return web.New(cfg, cacheDir, log).Run(ctx)
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 988 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,27 @@
|
|||||||
|
module git.b0b.be/bdeb/workout-suggester
|
||||||
|
|
||||||
|
go 1.27.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/a-h/templ v0.3.1070
|
||||||
|
github.com/yuin/goldmark v1.8.6
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.6 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cli/browser v1.3.0 // indirect
|
||||||
|
github.com/fatih/color v1.19.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/natefinch/atomic v1.0.1 // indirect
|
||||||
|
golang.org/x/mod v0.41.0 // indirect
|
||||||
|
golang.org/x/net v0.59.0 // indirect
|
||||||
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
|
golang.org/x/tools v0.51.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
tool github.com/a-h/templ/cmd/templ
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||||
|
github.com/a-h/templ v0.3.1070 h1:3iCOXHXqFXa99OQzgkUOLJbNzv/QjFGLwKZkBAg+ADA=
|
||||||
|
github.com/a-h/templ v0.3.1070/go.mod h1:iIqB3g2RHBe/WlnPwa+Bvhh1TKXHWLRnBD9NWHOLm78=
|
||||||
|
github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
|
||||||
|
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||||
|
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||||
|
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||||
|
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||||
|
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||||
|
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -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
@@ -0,0 +1,68 @@
|
|||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.11"
|
||||||
|
# dependencies = ["playwright"]
|
||||||
|
# ///
|
||||||
|
"""Browser end-to-end test: taps "📍 Here" (with a faked GPS fix), waits for
|
||||||
|
the coach's streamed advice and the route map, asks a follow-up, and saves
|
||||||
|
screenshots.
|
||||||
|
|
||||||
|
uv run scripts/e2e.py [--url http://127.0.0.1:3000] [--dark] [--mobile]
|
||||||
|
"""
|
||||||
|
import argparse, sys
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--url", default="http://127.0.0.1:3000")
|
||||||
|
p.add_argument("--channel", default="msedge", help="installed browser: chrome, msedge, ...")
|
||||||
|
p.add_argument("--out", default="docs")
|
||||||
|
p.add_argument("--dark", action="store_true")
|
||||||
|
p.add_argument("--mobile", action="store_true")
|
||||||
|
p.add_argument("--lat", type=float, default=51.0543)
|
||||||
|
p.add_argument("--lon", type=float, default=3.7174)
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
name = "screenshot" + ("-mobile" if args.mobile else "") + ("-dark" if args.dark else "")
|
||||||
|
viewport = {"width": 400, "height": 860} if args.mobile else {"width": 1000, "height": 900}
|
||||||
|
|
||||||
|
with sync_playwright() as pw:
|
||||||
|
browser = pw.chromium.launch(channel=args.channel, headless=True)
|
||||||
|
ctx = browser.new_context(
|
||||||
|
viewport=viewport,
|
||||||
|
color_scheme="dark" if args.dark else "light",
|
||||||
|
geolocation={"latitude": args.lat, "longitude": args.lon},
|
||||||
|
permissions=["geolocation"],
|
||||||
|
device_scale_factor=2,
|
||||||
|
)
|
||||||
|
page = ctx.new_page()
|
||||||
|
errors = []
|
||||||
|
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||||
|
page.goto(args.url)
|
||||||
|
page.evaluate("localStorage.clear()")
|
||||||
|
page.reload()
|
||||||
|
|
||||||
|
expect(page.locator("#health .dot.ok")).to_be_visible(timeout=10_000)
|
||||||
|
page.get_by_text("🔥 Hard").click()
|
||||||
|
page.get_by_text("📍 Here").click() # locates and submits
|
||||||
|
|
||||||
|
expect(page.locator(".weather")).to_be_visible(timeout=20_000)
|
||||||
|
expect(page.locator(".score.picked")).to_be_visible()
|
||||||
|
expect(page.locator("#thread .markdown")).to_be_visible(timeout=180_000)
|
||||||
|
if page.locator(".score.indoor.picked").count() == 0:
|
||||||
|
expect(page.locator(".routes .map.leaflet-container")).to_be_visible(timeout=60_000)
|
||||||
|
page.wait_for_timeout(1500) # tiles
|
||||||
|
|
||||||
|
page.screenshot(path=f"{args.out}/{name}.png", full_page=True)
|
||||||
|
|
||||||
|
page.get_by_text("⏱️ Only 30 min").click()
|
||||||
|
expect(page.locator("#thread .markdown")).to_have_count(2, timeout=180_000)
|
||||||
|
replies = page.locator("#thread .markdown").all_inner_texts()
|
||||||
|
print("--- plan ---\n" + replies[0] + "\n--- follow-up ---\n" + replies[1])
|
||||||
|
|
||||||
|
page.reload() # the plan survives a reload
|
||||||
|
expect(page.locator("#thread .markdown")).to_have_count(2)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
if errors:
|
||||||
|
print("page errors:", errors, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"ok: {args.out}/{name}.png")
|
||||||
Reference in new issue
Block a user