From c37ca4e8b4f4eb5f27c72b57145f8c37f12a48d1 Mon Sep 17 00:00:00 2001 From: Pedan Date: Fri, 19 Sep 2025 14:45:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui,roundtrip):=20modern=20neutral=20?= =?UTF-8?q?=E2=80=9CApple=20Glass=E2=80=9D=20UI,=20scenic=20round=20trips,?= =?UTF-8?q?=20and=20isochrone=20guidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UI (neutral minimal, glass-like) - Rewrote app shell and control panel in [app/web/static/index.html](cci:7://file:///home/pedan/freemoto/app/web/static/index.html:0:0-0:0) - Modernized responsive styles in [app/web/static/styles.css](cci:7://file:///home/pedan/freemoto/app/web/static/styles.css:0:0-0:0) - Proper internal scrolling for control panel, suggestions, waypoints, and directions sheet - Improved dark mode, spacing, and visual polish - Round Trip generation - Direction preference (N/E/S/W) with biased waypoint bearings ([route.js](cci:7://file:///home/pedan/freemoto/app/web/static/route.js:0:0-0:0)) - Ferry avoidance by default on round trips; added strong ferry cost - Coastal safety: reverse-geocode water check and automatic inland adjustment for generated waypoints - New “Scenic optimizer” option: generate candidate loops, route them, score twistiness, pick the best - Heading-change/turn-density based scoring using decoded polyline - Isochrone-guided loops - Backend: added `/isochrone` proxy in [app/web/main.go](cci:7://file:///home/pedan/freemoto/app/web/main.go:0:0-0:0) (derives Valhalla base from `VALHALLA_URL`) - Frontend: optional “Use isochrone guidance” + time (minutes); sample preferred sector of polygon - Settings persistence and live updates - Direction, scenic, isochrone, and distance persist via localStorage - Changing options regenerates round trips when enabled - Docker/Build - Confirmed multi-arch build (amd64/arm64) and push to git.ztsw.de/pedan/freemoto/freemoto-web:latest Refs: [index.html](cci:7://file:///home/pedan/freemoto/app/web/static/index.html:0:0-0:0), [styles.css](cci:7://file:///home/pedan/freemoto/app/web/static/styles.css:0:0-0:0), [route.js](cci:7://file:///home/pedan/freemoto/app/web/static/route.js:0:0-0:0), [main.js](cci:7://file:///home/pedan/freemoto/app/web/static/main.js:0:0-0:0), [app/web/main.go](cci:7://file:///home/pedan/freemoto/app/web/main.go:0:0-0:0), [Dockerfile](cci:7://file:///home/pedan/freemoto/Dockerfile:0:0-0:0) --- app/web/main.go | 59 +++++++ app/web/static/index.html | 60 +++++-- app/web/static/main.js | 55 +++++++ app/web/static/route.js | 318 ++++++++++++++++++++++++++++++++++++-- app/web/static/styles.css | 40 +++-- 5 files changed, 494 insertions(+), 38 deletions(-) diff --git a/app/web/main.go b/app/web/main.go index 7d9bbee..735d38a 100644 --- a/app/web/main.go +++ b/app/web/main.go @@ -48,6 +48,54 @@ func parseLogLevel(s string) int { } } +// proxyToValhallaPOST forwards a POST with JSON body to a specific Valhalla endpoint (e.g., /isochrone) +func proxyToValhallaPOST(w http.ResponseWriter, r *http.Request, target string) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target, r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + req.Header = r.Header.Clone() + if req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + + client := http.Client{Timeout: 20 * time.Second} + start := time.Now() + resp, err := client.Do(req) + if err != nil { + if rid, ok := r.Context().Value(ctxReqID).(string); ok { + logf(levelError, "rid=%s upstream=valhalla target=%s error=%v", rid, target, err) + } else { + logf(levelError, "upstream=valhalla target=%s error=%v", target, err) + } + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // Forward common headers and status + for key, values := range resp.Header { + if key == "Content-Type" || key == "Content-Encoding" || key == "Cache-Control" || key == "Vary" { + for _, v := range values { + w.Header().Add(key, v) + } + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) + + if rid, ok := r.Context().Value(ctxReqID).(string); ok { + logf(levelDebug, "rid=%s upstream=valhalla target=%s status=%d dur=%s", rid, target, resp.StatusCode, time.Since(start)) + } else { + logf(levelDebug, "upstream=valhalla target=%s status=%d dur=%s", target, resp.StatusCode, time.Since(start)) + } +} + func logf(level int, format string, args ...any) { if level < currentLogLevel { return @@ -74,6 +122,8 @@ func main() { if valhallaURL == "" { valhallaURL = "http://valhalla:8002/route" } + // Derive Valhalla base for other endpoints like /isochrone + valhallaBase := strings.TrimSuffix(valhallaURL, "/route") nominatimBase := os.Getenv("NOMINATIM_URL") if nominatimBase == "" { @@ -100,6 +150,15 @@ func main() { http.Handle("/route", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { proxyToValhalla(w, r, valhallaURL) }))) + http.Handle("/isochrone", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Forward to Valhalla isochrone endpoint (POST JSON) + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + target := valhallaBase + "/isochrone" + proxyToValhallaPOST(w, r, target) + }))) // Nominatim proxy endpoints http.Handle("/geocode", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { proxyToNominatimGET(w, r, nominatimBase+"/search", nominatimUA) diff --git a/app/web/static/index.html b/app/web/static/index.html index fa8c051..9e91986 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -9,7 +9,7 @@ - +