Compare commits

...

14 Commits

Author SHA1 Message Date
c37ca4e8b4 feat(ui,roundtrip): modern neutral “Apple Glass” UI, scenic round trips, and isochrone guidance
Some checks failed
build-and-push / docker (push) Failing after 10m2s
- 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)
2025-09-19 14:45:21 +02:00
c8aa3fb3b2 Push newest version
Some checks failed
build-and-push / docker (push) Failing after 10m4s
2025-09-19 09:34:01 +02:00
a2743dd7fb complete overhaul
Some checks failed
build-and-push / docker (push) Failing after 10m36s
2025-09-18 00:23:21 +02:00
6afcfaa5b3 Update .gitea/workflows/docker.yml 2025-09-17 14:58:32 +02:00
ca55fb7e9c Update .gitea/workflows/docker.yml 2025-09-17 14:58:01 +02:00
4f3d350260 Update .gitea/workflows/docker.yml 2025-09-17 14:48:32 +02:00
0a21d5534d Update .gitea/workflows/docker.yml 2025-09-17 14:44:24 +02:00
8ae9f1afdf Updated backend/frontend 2025-09-17 10:39:49 +00:00
58f701c1eb Update .gitea/workflows/docker.yml 2025-08-07 08:42:32 +02:00
d666d2203f Update .gitea/workflows/docker.yml 2025-08-07 08:42:02 +02:00
b35d22dd29 Update .gitea/workflows/docker.yml 2025-08-07 08:40:28 +02:00
Pedan
b01743d9c4 fixed ios export
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 29s
2025-07-29 17:20:36 +02:00
Pedan
8b4a8e9539 Merge branch 'main' of https://git.ztsw.de/pedan/freemoto
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 29s
2025-07-29 17:17:05 +02:00
Pedan
24e735e113 Rewrote UI, added option to export as gpx 2025-07-29 17:16:14 +02:00
21 changed files with 2466 additions and 321 deletions

View File

@@ -0,0 +1,4 @@
{
"name": "go-devcontainer",
"image": "mcr.microsoft.com/devcontainers/go:1-1.24-bookworm"
}

37
.dockerignore Normal file
View File

@@ -0,0 +1,37 @@
# Ignore VCS and IDE files
.git/
.gitea/
.devcontainer/
.vscode/
.idea/
# OS/editor junk
**/.DS_Store
**/*.swp
**/*.swo
**/*.tmp
# Environment and secrets
.env
*.env
# Logs and coverage
*.log
logs/
coverage/
# Build outputs
build/
dist/
out/
bin/
# Node (if any subprojects)
node_modules/
# Python caches (if any scripts)
**/__pycache__/
**/*.pyc
# Go vendor (if used)
vendor/

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Web app configuration
PORT=8080
# Routing backend (Valhalla)
# When using docker-compose, the service is named `valhalla` and exposes 8002
VALHALLA_URL=http://valhalla:8002/route
# Geocoding (Nominatim)
# Defaults to the public Nominatim instance if not set
# NOMINATIM_URL=https://nominatim.openstreetmap.org
# Per Nominatim policy, set a descriptive User-Agent including contact URL or email
NOMINATIM_USER_AGENT=FreeMoto/1.0 (+https://fm.ztsw.de/)
# Logging verbosity: debug, info, warn, error
LOG_LEVEL=info

View File

@@ -0,0 +1,61 @@
name: build-and-push
on:
push:
branches: [ main, master ]
tags: [ 'v*.*.*', 'v*', '*.*.*' ]
#on: [workflow_dispatch]
# Registry/image can be customized here
env:
REGISTRY: git.ztsw.de
IMAGE: git.ztsw.de/pedan/freemoto/freemoto-web
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU (multi-arch)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.CR_USERNAME }}
password: ${{ secrets.CR_PASSWORD }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' }}
- name: Build and push (multi-arch)
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,31 +0,0 @@
name: Build and Publish Docker Image
on:
push:
branches:
- main
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.ztsw.de
username: ${{ secrets.CR_USERNAME }}
password: ${{ secrets.CR_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: git.ztsw.de/pedan/freemoto/freemoto-web:latest

94
.gitignore vendored
View File

@@ -1,46 +1,48 @@
# Go build artifacts
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Go modules cache
vendor/
# Dependency directories
# /go/
# IDE/editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
Thumbs.db
# Environment files
.env
# Docker
*.tar
# Node/npm
node_modules/
npm-debug.log
yarn-error.log
# Custom tiles or generated map data
backend/custom_tiles
# Gitea
#.gitea/workflows/*.log
# Static build output
dist/
build/
# Go build artifacts
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Go modules cache
vendor/
# Dependency directories
# /go/
# IDE/editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
Thumbs.db
# Environment files
.env
# Docker
*.tar
# Node/npm
node_modules/
npm-debug.log
yarn-error.log
# Custom tiles or generated map data
backend/custom_tiles
# Gitea
#.gitea/workflows/*.log
# Static build output
dist/
build/
custom_tiles/
custom_files/

View File

@@ -1,11 +1,47 @@
FROM golang:1.24-alpine
## Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /src
# Install build dependencies (none needed for static build) and enable static build
# Make the build architecture-aware for buildx multi-arch builds
ARG TARGETOS
ARG TARGETARCH
ENV CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH}
# Cache go modules
COPY app/web/go.mod ./app/web/
RUN cd app/web && go mod download
# Copy source
COPY app/web/ ./app/web/
# Build
RUN cd app/web && go build -o /out/freemoto-web ./
## Runtime stage
FROM alpine:3.20
WORKDIR /app/web
COPY app/web/ .
# Add CA certificates for any outbound HTTPS (future-proofing)
RUN apk add --no-cache ca-certificates tzdata wget
RUN go mod tidy
# Copy binary and static files
COPY --from=builder /out/freemoto-web /app/web/freemoto-web
COPY app/web/static/ /app/web/static/
# Use non-root user (create group explicitly for Alpine)
RUN addgroup -S appuser \
&& adduser -S -D -H -h /nonexistent -G appuser appuser \
&& chown -R appuser:appuser /app
USER appuser
ENV PORT=8080
EXPOSE 8080
CMD ["go", "run", "main.go"]
# Simple healthcheck against /healthz
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:${PORT}/healthz || exit 1
ENTRYPOINT ["/app/web/freemoto-web"]

36
LICENSE
View File

@@ -1,18 +1,18 @@
MIT License
Copyright (c) 2025 pedan
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
MIT License
Copyright (c) 2025 pedan
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

133
README.md
View File

@@ -1,12 +1,30 @@
# FreeMoto Web
A web-based motorcycle route planner built with Go, Leaflet, Bootstrap, and Valhalla routing, ready for containerized deployment.
A modern, mobilefirst motorcycle route planner. Backend in Go (proxy + static), frontend with Bootstrap + Leaflet, and Valhalla as the routing engine. Containerready with dockercompose and CI for multiarch images.
## Features
- Interactive map with OpenStreetMap tiles
- Route planning with options (avoid highways, tolls, ferries, unpaved, shortest route)
- Docker-ready and configurable via `.env`
- Modern, mobilefriendly UI (single tap on mobile, doubleclick on desktop)
- Multiwaypoint routing with draggable markers and reorder/remove list
- Round Trip planner
- Distance target (km) and option to avoid repeated segments
- Respects all routing options
- Rider controls
- Twistiness slider (prefer curvy roads)
- Highways preference slider (prefer faster highways)
- Avoid: freeways, tolls, ferries, unpaved; Shortest route toggle
- Directions
- Directions bottom sheet (collapsible, drag-to-resize)
- Nextmaneuver banner; optional voice prompt
- Export GPX
- Track (`<trk>/<trkseg>/<trkpt>`) and/or Route (`<rte>/<rtept>`)
- OSMAndfriendly
- Theming and UX
- Draculastyle dark mode toggle with persistence
- Summary pill (distance/time), floating actions, clean layout
- Performance
- Gzip compression and strong caching for static assets
- Small Alpine runtime image
## Getting Started
@@ -24,6 +42,13 @@ Create a `.env` file in the project root:
```
VALHALLA_URL=http://valhalla:8002/route
PORT=8080
# Optional: Nominatim base URL (defaults to https://nominatim.openstreetmap.org)
NOMINATIM_URL=https://nominatim.openstreetmap.org
# Recommended: configure a descriptive User-Agent per Nominatim usage policy
# Example format: "AppName/Version (+contact-url-or-email)"
NOMINATIM_USER_AGENT=FreeMoto/1.0 (+https://fm.ztsw.de/)
# Optional: log level (debug, info, warn, error). Default: info
LOG_LEVEL=info
```
### Local Development
@@ -35,6 +60,32 @@ go run main.go
Visit [http://localhost:8080](http://localhost:8080) in your browser.
### Quick Start (docker-compose)
Build and run the web app alongside a Valhalla routing container with a single command. A `docker-compose.yml` is provided at the repository root.
1. Copy the example environment file and adjust values if needed:
```bash
cp .env.example .env
```
2. Start the stack:
```bash
docker compose up -d --build
```
3. Open the app:
```
http://localhost:8080
```
Notes:
- The web app will proxy `/route` to the `valhalla` service at `http://valhalla:8002/route`.
- You may mount `custom_files/` into the Valhalla container (see `docker-compose.yml`) for tiles and custom configurations.
### Docker
Build and run the container:
@@ -59,6 +110,9 @@ services:
#environment:
# - VALHALLA_URL=http://10.200.0.15:8002/route
# - PORT=8080
# - NOMINATIM_URL=https://nominatim.openstreetmap.org
# - NOMINATIM_USER_AGENT=FreeMoto/1.0 (+https://fm.ztsw.de/)
# - LOG_LEVEL=debug
valhalla-scripted:
image: ghcr.io/valhalla/valhalla-scripted:latest
ports:
@@ -69,12 +123,73 @@ services:
- tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf
```
## Customization
Alternatively, use the provided root-level `docker-compose.yml` for a local build of the web app and a Valhalla companion service, then run `docker compose up -d --build`.
- **Map UI:** Edit `static/index.html` and `static/main.js`
- **Routing logic:** Edit `static/route.js`
- **Geolocation/autocomplete:** Edit `static/geolocate.js`
- **Backend proxy:** Edit `main.go`
### Notes on Nominatim
- Please follow the official usage policy for Nominatim. Provide a meaningful `NOMINATIM_USER_AGENT` that includes a contact URL or email. The default is `FreeMoto/1.0 (+https://fm.ztsw.de/)`.
- You can point `NOMINATIM_URL` to your own Nominatim instance or keep the default public endpoint.
### Logging
- Configure verbosity with `LOG_LEVEL`.
- Supported values: `debug`, `info` (default), `warn`, `error`.
- Incoming requests are logged at `info` level. Upstream success traces (Valhalla/Nominatim) are at `debug`. Errors are at `error`.
## UI Overview
- Start/End fields with autocomplete and reverse geocoding
- Tap/doubleclick to add waypoints; drag markers to adjust
- Waypoint list for reorder/remove
- Route Options include twistiness, highways, and avoid toggles
- Directions sheet (collapsible, draggable) + Nextmaneuver banner and optional voice
- Export GPX: Track/Route/Both
## Performance
- Static assets are served with gzip (when supported) and strong caching
- `Cache-Control: public, max-age=31536000, immutable` for `/static`
- Short caching for `index.html`
## Multiarch build and Registry
Build and push multiarch (amd64 + arm64) with Buildx:
```bash
# Login first (example)
docker login git.ztsw.de -u <user>
export IMAGE=git.ztsw.de/pedan/freemoto/freemoto-web
export TAG=latest
export TAG2=$(date +%Y%m%d-%H%M)
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t $IMAGE:$TAG \
-t $IMAGE:$TAG2 \
--push \
.
```
## CI: Gitea Actions
A workflow at `.gitea/workflows/build-and-push.yml` builds and pushes multiarch images on pushes and tags.
Configure repository secrets:
- `CR_USERNAME` registry username
- `CR_PASSWORD` registry token/password
Adjust `env.IMAGE`/`env.REGISTRY` in the workflow if you move the image.
## Customization & Development
- Frontend
- `app/web/static/index.html` layout and controls
- `app/web/static/main.js` map init, theme, global controls
- `app/web/static/route.js` routing, waypoints, round trips, GPX
- Backend
- `app/web/main.go` static serving (gzip + caching), proxies `/route`, `/geocode`, `/reverse`
## License

View File

@@ -1,2 +1,2 @@
VALHALLA_URL=http://10.200.0.15:8002/route
VALHALLA_URL=http://10.200.0.15:8002/route
PORT=8080

View File

@@ -1,5 +1,5 @@
module pedan/freemoto
go 1.24.5
require github.com/joho/godotenv v1.5.1
module pedan/freemoto
go 1.24.5
require github.com/joho/godotenv v1.5.1

View File

@@ -1,2 +1,2 @@
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=

View File

@@ -1,53 +1,410 @@
package main
import (
"io"
"log"
"net/http"
"os"
"compress/gzip"
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/joho/godotenv"
"github.com/joho/godotenv"
)
type ctxKey string
const ctxReqID ctxKey = "reqID"
// atomic counter for simple request IDs
var reqIDCounter uint64
// log levels
const (
levelDebug = iota
levelInfo
levelWarn
levelError
)
var currentLogLevel = levelInfo
func parseLogLevel(s string) int {
switch strings.ToLower(s) {
case "debug":
return levelDebug
case "info", "":
return levelInfo
case "warn", "warning":
return levelWarn
case "error", "err":
return levelError
default:
return levelInfo
}
}
// 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
}
prefix := "INFO"
switch level {
case levelDebug:
prefix = "DEBUG"
case levelInfo:
prefix = "INFO"
case levelWarn:
prefix = "WARN"
case levelError:
prefix = "ERROR"
}
// Prepend the prefix as the first argument
log.Printf("[%s] "+format, append([]any{prefix}, args...)...)
}
func main() {
_ = godotenv.Load(".env") // Load .env file
_ = godotenv.Load(".env") // Load .env file
valhallaURL := os.Getenv("VALHALLA_URL")
if valhallaURL == "" {
valhallaURL = "http://10.200.0.15:8002/route"
}
valhallaURL := os.Getenv("VALHALLA_URL")
if valhallaURL == "" {
valhallaURL = "http://valhalla:8002/route"
}
// Derive Valhalla base for other endpoints like /isochrone
valhallaBase := strings.TrimSuffix(valhallaURL, "/route")
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
nominatimBase := os.Getenv("NOMINATIM_URL")
if nominatimBase == "" {
nominatimBase = "https://nominatim.openstreetmap.org"
}
nominatimUA := os.Getenv("NOMINATIM_USER_AGENT")
if nominatimUA == "" {
nominatimUA = "FreeMoto/1.0 (+https://fm.ztsw.de/)"
}
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
http.HandleFunc("/route", func(w http.ResponseWriter, r *http.Request) {
proxyToValhalla(w, r, valhallaURL)
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.ServeFile(w, r, "./static/index.html")
return
}
http.ServeFile(w, r, "./static/"+r.URL.Path[1:])
})
log.Printf("Listening on :%s", port)
http.ListenAndServe(":"+port, nil)
currentLogLevel = parseLogLevel(os.Getenv("LOG_LEVEL"))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// Static files under /static with long-lived caching and gzip for text assets
http.Handle("/static/", withLogging(withCacheGzip(http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))), true)))
http.Handle("/healthz", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})))
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)
})))
http.Handle("/reverse", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyToNominatimGET(w, r, nominatimBase+"/reverse", nominatimUA)
})))
http.Handle("/", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Serve index
if r.URL.Path == "/" {
// Short cache for HTML entrypoint
setShortCache(w)
maybeGzipAndServeFile(w, r, "./static/index.html")
return
}
// Serve other assets from ./static with long cache for versioned files
// Decide cache and compression based on extension
ext := strings.ToLower(filepath.Ext(r.URL.Path))
if isTextLikeExt(ext) {
setLongCache(w)
w.Header().Add("Vary", "Accept-Encoding")
// Use gzip when supported
if acceptsGzip(r) {
gz := gzip.NewWriter(w)
defer gz.Close()
w.Header().Set("Content-Encoding", "gzip")
// Avoid Content-Length when gzipping
w.Header().Del("Content-Length")
gzw := &gzipResponseWriter{ResponseWriter: w, gw: gz}
http.ServeFile(gzw, r, "./static/"+r.URL.Path[1:])
return
}
http.ServeFile(w, r, "./static/"+r.URL.Path[1:])
return
}
// Non-text assets: just set long cache
setLongCache(w)
http.ServeFile(w, r, "./static/"+r.URL.Path[1:])
})))
logf(levelInfo, "Listening on :%s", port)
http.ListenAndServe(":"+port, nil)
}
func proxyToValhalla(w http.ResponseWriter, r *http.Request, valhallaURL string) {
req, _ := http.NewRequest("POST", valhallaURL, r.Body)
req.Header = r.Header
// Create outbound request with caller context and preserve headers
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, valhallaURL, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Header = r.Header.Clone()
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(w, resp.Body)
client := http.Client{Timeout: 15 * 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 error=%v", rid, err)
} else {
logf(levelError, "upstream=valhalla error=%v", err)
}
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Propagate relevant headers and status code
for key, values := range resp.Header {
// Optionally filter headers; here we forward common ones
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 status=%d dur=%s", rid, resp.StatusCode, time.Since(start))
} else {
logf(levelDebug, "upstream=valhalla status=%d dur=%s", resp.StatusCode, time.Since(start))
}
}
// proxyToNominatimGET proxies GET requests to Nominatim endpoints (search/reverse),
// sets a descriptive User-Agent, and enforces a timeout.
func proxyToNominatimGET(w http.ResponseWriter, r *http.Request, target string, userAgent string) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Preserve original query parameters
url := target
if rq := r.URL.RawQuery; rq != "" {
url = url + "?" + rq
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Set a proper User-Agent per Nominatim usage policy
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json")
client := http.Client{Timeout: 10 * 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=nominatim url=%s error=%v", rid, url, err)
} else {
logf(levelError, "upstream=nominatim url=%s error=%v", url, err)
}
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Forward selected headers and status code
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
logf(levelDebug, "rid=%s upstream=nominatim status=%d dur=%s", rid, resp.StatusCode, time.Since(start))
} else {
logf(levelDebug, "upstream=nominatim status=%d dur=%s", resp.StatusCode, time.Since(start))
}
}
// withLogging wraps handlers to add request ID, status, duration logging
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := fmt.Sprintf("%x", atomic.AddUint64(&reqIDCounter, 1))
ctx := context.WithValue(r.Context(), ctxReqID, rid)
r = r.WithContext(ctx)
lrw := &loggingResponseWriter{ResponseWriter: w, status: 200}
start := time.Now()
next.ServeHTTP(lrw, r)
dur := time.Since(start)
logf(levelInfo, "rid=%s method=%s path=%s status=%d bytes=%d dur=%s ua=\"%s\" ip=%s",
rid, r.Method, r.URL.Path, lrw.status, lrw.bytes, dur, r.UserAgent(), clientIP(r))
})
}
type loggingResponseWriter struct {
http.ResponseWriter
status int
bytes int
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.status = code
lrw.ResponseWriter.WriteHeader(code)
}
func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
n, err := lrw.ResponseWriter.Write(b)
lrw.bytes += n
return n, err
}
func clientIP(r *http.Request) string {
// honor X-Forwarded-For if present (first IP)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return xff
}
return r.RemoteAddr
}
// ---- Performance helpers: caching and gzip compression ----
func setShortCache(w http.ResponseWriter) {
// Short cache e.g., for HTML entrypoint
w.Header().Set("Cache-Control", "public, max-age=300")
}
func setLongCache(w http.ResponseWriter) {
// Long-lived cache for versioned static assets
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
func isTextLikeExt(ext string) bool {
switch ext {
case ".html", ".css", ".js", ".mjs", ".json", ".svg", ".xml", ".txt", ".map", ".webmanifest":
return true
default:
return false
}
}
func acceptsGzip(r *http.Request) bool {
ae := r.Header.Get("Accept-Encoding")
return strings.Contains(ae, "gzip")
}
type gzipResponseWriter struct {
http.ResponseWriter
gw *gzip.Writer
}
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
return g.gw.Write(b)
}
// withCacheGzip wraps a handler to set cache headers and gzip text-like responses
func withCacheGzip(next http.Handler, longCache bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if longCache {
setLongCache(w)
} else {
setShortCache(w)
}
ext := strings.ToLower(filepath.Ext(r.URL.Path))
if isTextLikeExt(ext) && acceptsGzip(r) {
w.Header().Add("Vary", "Accept-Encoding")
w.Header().Set("Content-Encoding", "gzip")
w.Header().Del("Content-Length")
gz := gzip.NewWriter(w)
defer gz.Close()
gzw := &gzipResponseWriter{ResponseWriter: w, gw: gz}
next.ServeHTTP(gzw, r)
return
}
next.ServeHTTP(w, r)
})
}
func maybeGzipAndServeFile(w http.ResponseWriter, r *http.Request, path string) {
// For text-like files, gzip when supported
ext := strings.ToLower(filepath.Ext(path))
if isTextLikeExt(ext) && acceptsGzip(r) {
w.Header().Add("Vary", "Accept-Encoding")
w.Header().Set("Content-Encoding", "gzip")
w.Header().Del("Content-Length")
gz := gzip.NewWriter(w)
defer gz.Close()
gzw := &gzipResponseWriter{ResponseWriter: w, gw: gz}
http.ServeFile(gzw, r, path)
return
}
http.ServeFile(w, r, path)
}

View File

@@ -1,6 +1,6 @@
document.addEventListener('DOMContentLoaded', function() {
function geocode(query, callback) {
fetch('https://nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(query))
fetch('/geocode?format=json&q=' + encodeURIComponent(query))
.then(response => response.json())
.then(data => {
if (data && data.length > 0) {
@@ -16,7 +16,7 @@ document.addEventListener('DOMContentLoaded', function() {
navigator.geolocation.getCurrentPosition(function(position) {
var lat = position.coords.latitude;
var lon = position.coords.longitude;
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}`)
fetch(`/reverse?format=json&lat=${lat}&lon=${lon}`)
.then(response => response.json())
.then(data => {
document.getElementById(inputId).value = data.display_name || `${lat},${lon}`;
@@ -27,6 +27,26 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
function formatAddress(item) {
if (!item.address) return item.display_name;
const addr = item.address;
let street = addr.road || addr.pedestrian || addr.cycleway || addr.footway || addr.street || '';
let number = addr.house_number || '';
let postcode = addr.postcode || '';
let city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || addr.county || '';
// Build formatted string: Street Number, Postal code City
let address = '';
if (street) address += street;
if (number) address += (address ? ' ' : '') + number;
if (postcode || city) {
address += (address ? ', ' : '');
address += postcode;
if (postcode && city) address += ' ';
address += city;
}
return address;
}
function showSuggestions(inputId, suggestionsId, value) {
var suggestionsBox = document.getElementById(suggestionsId);
if (!value) {
@@ -34,7 +54,7 @@ document.addEventListener('DOMContentLoaded', function() {
suggestionsBox.style.display = 'none';
return;
}
fetch('https://nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(value))
fetch('/geocode?format=json&addressdetails=1&q=' + encodeURIComponent(value))
.then(response => response.json())
.then(data => {
suggestionsBox.innerHTML = '';
@@ -43,10 +63,11 @@ document.addEventListener('DOMContentLoaded', function() {
var option = document.createElement('button');
option.type = 'button';
option.className = 'list-group-item list-group-item-action';
option.textContent = item.display_name;
option.textContent = formatAddress(item);
option.title = item.display_name; // Show full address on hover
option.onclick = function() {
var input = document.getElementById(inputId);
input.value = item.display_name;
input.value = formatAddress(item);
input.dataset.lat = item.lat;
input.dataset.lon = item.lon;
suggestionsBox.innerHTML = '';

View File

@@ -1,126 +1,245 @@
<!DOCTYPE html>
<html>
<head>
<title>FreeMoto Web</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<style>
html, body, #map {
height: 100%;
margin: 0;
padding: 0;
}
#map {
min-height: 100vh;
min-width: 100vw;
z-index: 1;
}
.controls-container {
position: absolute;
top: 24px;
left: 24px;
z-index: 1001;
background: rgba(255,255,255,0.98);
border-radius: 18px;
box-shadow: 0 2px 16px rgba(0,0,0,0.18);
padding: 28px 32px 22px 32px;
min-width: 300px;
max-width: 370px;
}
.leaflet-control-attribution {
z-index: 1002;
}
.input-group-text {
background: #f8f9fa;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.5rem;
margin-top: 1rem;
color: #495057;
}
hr {
margin: 1.2rem 0;
}
#sourceSuggestions,
#destSuggestions {
position: absolute;
top: 100%;
left: 0;
z-index: 2000;
width: 100%;
max-height: 220px;
overflow-y: auto;
border-radius: 0 0 0.5rem 0.5rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
</style>
</head>
<body>
<div id="map"></div>
<div class="controls-container shadow-lg">
<h4 class="mb-3 text-primary">FreeMoto Route Planner</h4>
<div class="mb-3">
<div class="section-title">Route</div>
<div class="input-group mb-2 position-relative">
<span class="input-group-text" title="Start"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#0d6efd" class="bi bi-geo-alt" viewBox="0 0 16 16"><path d="M8 16s6-5.686 6-10A6 6 0 1 0 2 6c0 4.314 6 10 6 10zm0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></svg></span>
<input type="text" class="form-control" id="sourceInput" placeholder="Enter start address" autocomplete="off">
<button class="btn btn-outline-secondary" type="button" id="useCurrentSource" title="Use current location">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#198754" class="bi bi-crosshair" viewBox="0 0 16 16"><path d="M8 15V1a7 7 0 1 1 0 14zm0-1a6 6 0 1 0 0-12 6 6 0 0 0 0 12z"/><path d="M8 8.5a.5.5 0 0 1-.5-.5V2.707l-2.146 2.147a.5.5 0 1 1-.708-.708l3-3a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V8a.5.5 0 0 1-.5.5z"/></svg>
</button>
<div id="sourceSuggestions" class="list-group position-absolute w-100" style="z-index: 2000;"></div>
</div>
<div class="input-group mb-2 position-relative">
<span class="input-group-text" title="Destination"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#dc3545" class="bi bi-flag" viewBox="0 0 16 16"><path d="M14.778 2.222a.5.5 0 0 1 0 .707l-2.5 2.5a.5.5 0 0 1-.707 0l-2.5-2.5a.5.5 0 0 1 .707-.707L12 3.793l2.071-2.071a.5.5 0 0 1 .707 0z"/><path d="M2.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5z"/></svg></span>
<input type="text" class="form-control" id="destInput" placeholder="Enter destination address" autocomplete="off">
<button class="btn btn-outline-secondary" type="button" id="useCurrentDest" title="Use current location">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#198754" class="bi bi-crosshair" viewBox="0 0 16 16"><path d="M8 15V1a7 7 0 1 1 0 14zm0-1a6 6 0 1 0 0-12 6 6 0 0 0 0 12z"/><path d="M8 8.5a.5.5 0 0 1-.5-.5V2.707l-2.146 2.147a.5.5 0 1 1-.708-.708l3-3a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V8a.5.5 0 0 1-.5.5z"/></svg>
</button>
<div id="destSuggestions" class="list-group position-absolute w-100" style="z-index: 2000;"></div>
</div>
<div class="d-grid gap-2 mb-3">
<button type="button" id="plotRouteBtn" class="btn btn-success btn-sm">Plot Route</button>
</div>
</div>
<hr>
<div class="section-title">Route Options</div>
<form>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="avoidHighways">
<label class="form-check-label" for="avoidHighways">Avoid freeways</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="useShortest">
<label class="form-check-label" for="useShortest">Use shortest route</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="avoidTollRoads">
<label class="form-check-label" for="avoidTollRoads">Avoid toll roads</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="avoidFerries">
<label class="form-check-label" for="avoidFerries">Avoid ferries</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="avoidUnpaved">
<label class="form-check-label" for="avoidUnpaved">Avoid unpaved roads</label>
</div>
</form>
<div class="d-grid gap-2 mb-3">
<button onclick="resetMarkers()" class="btn btn-primary btn-sm">Reset Points</button>
</div>
<div class="btn-group mb-2 w-100" role="group" aria-label="Zoom controls">
<button type="button" class="btn btn-outline-secondary btn-sm" id="zoomInBtn" title="Zoom in">+</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="zoomOutBtn" title="Zoom out"></button>
</div>
</div>
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script src="/main.js"></script>
<script src="/route.js"></script>
<script src="/geolocate.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FreeMoto · Route Planner</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" rel="stylesheet" />
<link href="/styles.css?v=20250918" rel="stylesheet" />
</head>
<body>
<!-- App Bar (Neutral minimal, glassy) -->
<header class="appbar navbar px-3">
<div class="container-fluid d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<span class="navbar-brand mb-0 h1 d-flex align-items-center gap-2">
<span aria-hidden="true">🏍️</span>
<span>FreeMoto</span>
</span>
<span id="summaryPill" class="badge rounded-pill text-bg-light d-none"></span>
</div>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom controls">
<button class="btn btn-outline-secondary" id="zoomOutBtn" title="Zoom out"></button>
<button class="btn btn-outline-secondary" id="zoomInBtn" title="Zoom in">+</button>
</div>
<button class="btn btn-outline-secondary btn-sm" id="clearRouteBtn" title="Clear route">Clear</button>
<button class="btn btn-outline-secondary btn-sm" id="themeToggle" title="Toggle dark mode">🌙</button>
</div>
</div>
</header>
<!-- Map canvas -->
<div id="map" aria-label="Map"></div>
<!-- Control Panel (neutral, glassy, scrollable) -->
<aside class="nav-panel shadow">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<span class="fw-semibold">Planner</span>
</div>
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline" onclick="resetMarkers()">Reset</button>
</div>
<div id="routeInfoCard" class="alert alert-info d-none" role="alert"></div>
<div id="nextManeuverBanner" class="alert alert-secondary py-2 px-3 d-none" role="status"></div>
<!-- Route inputs (compact) -->
<div class="section-title mb-1">Route</div>
<div class="row g-2 align-items-stretch">
<div class="col-12">
<div class="input-group position-relative">
<span class="input-group-text" title="Start" aria-label="Start">A</span>
<input type="text" class="form-control" id="sourceInput" placeholder="Start address" autocomplete="off" />
<button class="btn btn-outline-secondary" type="button" id="useCurrentSource" title="Use current location">📍</button>
<div id="sourceSuggestions" class="list-group position-absolute w-100"></div>
</div>
</div>
<div class="col-12 d-flex justify-content-center">
<button id="swapBtn" class="btn btn-light border rounded-circle shadow-sm" type="button" title="Swap start/end"></button>
</div>
<div class="col-12">
<div class="input-group position-relative">
<span class="input-group-text" title="Destination" aria-label="Destination">B</span>
<input type="text" class="form-control" id="destInput" placeholder="Destination address" autocomplete="off" />
<button class="btn btn-outline-secondary" type="button" id="useCurrentDest" title="Use current location">📍</button>
<div id="destSuggestions" class="list-group position-absolute w-100"></div>
</div>
</div>
</div>
<!-- Quick sliders -->
<div class="row g-3 mt-1">
<div class="col-12">
<label for="twistiness" class="form-label mb-1 text-muted">Twistiness</label>
<div class="d-flex align-items-center gap-2">
<input type="range" class="form-range" id="twistiness" min="0" max="100" value="50" />
<span id="twistinessValue" class="badge rounded-pill text-bg-light">50</span>
</div>
</div>
<div class="col-12">
<label for="highwayPref" class="form-label mb-1 text-muted">Highways</label>
<div class="d-flex align-items-center gap-2">
<input type="range" class="form-range" id="highwayPref" min="0" max="100" value="50" />
<span id="highwayPrefValue" class="badge rounded-pill text-bg-light">50</span>
</div>
</div>
</div>
<div class="d-grid gap-2 my-2 d-none d-md-grid">
<button type="button" id="plotRouteBtn" class="btn btn-success">Plot Route</button>
</div>
<!-- Options (collapsible minimal) -->
<div class="accordion mb-2" id="optionsAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#optionsCollapse" aria-expanded="false" aria-controls="optionsCollapse">
Options
</button>
</h2>
<div id="optionsCollapse" class="accordion-collapse collapse" data-bs-parent="#optionsAccordion">
<div class="accordion-body">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="useShortest">
<label class="form-check-label" for="useShortest">Shortest route</label>
</div>
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="avoidHighways">
<label class="form-check-label" for="avoidHighways">Avoid freeways</label>
</div>
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="avoidTollRoads">
<label class="form-check-label" for="avoidTollRoads">Avoid tolls</label>
</div>
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="avoidFerries">
<label class="form-check-label" for="avoidFerries">Avoid ferries</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="avoidUnpaved">
<label class="form-check-label" for="avoidUnpaved">Avoid unpaved</label>
</div>
<hr>
<div class="row g-2 align-items-center mb-2">
<div class="col-12 col-md-6 d-flex align-items-center gap-2">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="roundTripToggle">
<label class="form-check-label" for="roundTripToggle">Round Trip</label>
</div>
</div>
<div class="col-8 col-md-4">
<div class="input-group input-group-sm">
<input type="number" min="5" max="500" step="5" id="roundTripKm" class="form-control" placeholder="Distance (km)" value="100">
<span class="input-group-text">km</span>
</div>
</div>
<div class="col-4 col-md-2 d-grid">
<button type="button" id="roundTripBtn" class="btn btn-sm btn-outline-primary">Create</button>
</div>
</div>
<div class="row g-2 align-items-center mb-2">
<div class="col-12 col-md-6">
<label for="roundTripDir" class="form-label mb-0 text-muted">Direction preference</label>
</div>
<div class="col-12 col-md-6">
<select id="roundTripDir" class="form-select form-select-sm">
<option value="any" selected>Any</option>
<option value="N">North</option>
<option value="E">East</option>
<option value="S">South</option>
<option value="W">West</option>
</select>
</div>
</div>
<div class="row g-2 align-items-center mb-2">
<div class="col-12 col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="roundTripScenic">
<label class="form-check-label" for="roundTripScenic">Scenic optimizer (twistier)</label>
</div>
</div>
</div>
<div class="row g-2 align-items-center mb-2">
<div class="col-12 col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="roundTripIsochrone">
<label class="form-check-label" for="roundTripIsochrone">Use isochrone guidance</label>
</div>
</div>
<div class="col-12 col-md-6">
<div class="input-group input-group-sm">
<span class="input-group-text">Time</span>
<input type="number" min="10" max="240" step="5" id="isochroneMinutes" class="form-control" placeholder="Minutes" value="60">
<span class="input-group-text">min</span>
</div>
</div>
</div>
<div class="row g-2 align-items-center mb-2">
<div class="col-12 col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="roundTripNoRepeat">
<label class="form-check-label" for="roundTripNoRepeat">Avoid repeated segments</label>
</div>
</div>
</div>
<hr>
<div class="d-flex align-items-center gap-2 flex-wrap">
<label class="form-label mb-0">Export</label>
<select id="exportMode" class="form-select form-select-sm" style="max-width: 180px;">
<option value="both" selected>Track + Route</option>
<option value="track">Track only</option>
<option value="route">Route only</option>
</select>
<div class="form-check form-switch ms-2">
<input class="form-check-input" type="checkbox" id="voiceToggle">
<label class="form-check-label" for="voiceToggle">Voice</label>
</div>
<button type="button" class="btn btn-warning btn-sm export-gpx">Export GPX</button>
</div>
</div>
</div>
</div>
</div>
<div class="mb-2">
<div class="section-title">Waypoints</div>
<ul id="waypointList" class="list-group small"></ul>
</div>
</aside>
<!-- Panel toggle button (mobile portrait) -->
<button id="panelToggle" class="btn btn-outline-secondary btn-sm panel-toggle d-md-none" type="button" aria-expanded="true" title="Toggle panel"></button>
<!-- Floating actions (mobile) -->
<div class="floating-group d-md-none">
<button class="btn btn-primary fab" id="recenterBtn" title="Recenter"></button>
<button class="btn btn-primary" id="plotRouteBtn" title="Plot Route">Route</button>
<button class="btn btn-dark export-gpx" title="Export GPX">GPX</button>
</div>
<!-- Directions Bottom Sheet -->
<div id="directionsSheet" class="sheet" aria-live="polite">
<div class="card shadow-lg sheet-card">
<div class="card-body p-0">
<div class="handle" role="separator" aria-label="Resize directions"></div>
<div class="sheet-header d-flex align-items-center justify-content-between px-3 py-2">
<div class="fw-semibold">Directions</div>
<button class="btn btn-sm btn-outline-secondary" id="closeDirections">Close</button>
</div>
<div class="sheet-body px-2">
<ul class="list-group list-group-flush mt-2" id="directionsList"></ul>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/main.js"></script>
<script src="/route.js"></script>
<script src="/geolocate.js"></script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
// Center on a default point
var map = L.map('map', { zoomControl: false }).setView([53.866237, 10.676289], 18);
var map = L.map('map', { zoomControl: false, doubleClickZoom: false }).setView([53.866237, 10.676289], 18);
// Add OSM tiles
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
@@ -26,6 +26,22 @@ if (navigator.geolocation) {
document.addEventListener('DOMContentLoaded', function() {
var zoomInBtn = document.getElementById('zoomInBtn');
var zoomOutBtn = document.getElementById('zoomOutBtn');
var themeToggle = document.getElementById('themeToggle');
var clearRouteBtn = document.getElementById('clearRouteBtn');
var twistiness = document.getElementById('twistiness');
var twistinessValue = document.getElementById('twistinessValue');
var highwayPref = document.getElementById('highwayPref');
var highwayPrefValue = document.getElementById('highwayPrefValue');
var exportModeSel = document.getElementById('exportMode');
var voiceToggle = document.getElementById('voiceToggle');
var roundTripToggle = document.getElementById('roundTripToggle');
var roundTripKm = document.getElementById('roundTripKm');
var roundTripBtn = document.getElementById('roundTripBtn');
var roundTripNoRepeat = document.getElementById('roundTripNoRepeat');
var roundTripDir = document.getElementById('roundTripDir');
var roundTripScenic = document.getElementById('roundTripScenic');
var roundTripIsochrone = document.getElementById('roundTripIsochrone');
var isochroneMinutes = document.getElementById('isochroneMinutes');
if (zoomInBtn && zoomOutBtn) {
zoomInBtn.addEventListener('click', function() {
map.zoomIn();
@@ -34,4 +50,268 @@ document.addEventListener('DOMContentLoaded', function() {
map.zoomOut();
});
}
// Theme toggle with persistence (Dracula-like)
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
if (themeToggle) themeToggle.textContent = '☀️';
} else {
document.documentElement.removeAttribute('data-theme');
if (themeToggle) themeToggle.textContent = '🌙';
}
}
var savedTheme = localStorage.getItem('freemoto-theme');
if (!savedTheme || savedTheme === 'auto') {
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
applyTheme(prefersDark ? 'dark' : 'light');
if (!savedTheme) localStorage.setItem('freemoto-theme', 'auto');
} else {
applyTheme(savedTheme);
}
if (themeToggle) {
themeToggle.addEventListener('click', function() {
var next = (localStorage.getItem('freemoto-theme') || 'light') === 'dark' ? 'light' : 'dark';
localStorage.setItem('freemoto-theme', next);
applyTheme(next);
});
}
// Clear route button
if (clearRouteBtn && typeof window.resetMarkers === 'function') {
clearRouteBtn.addEventListener('click', function() {
window.resetMarkers();
});
}
// Twistiness slider
function applyTwistiness(val) {
if (twistinessValue) twistinessValue.textContent = String(val);
localStorage.setItem('freemoto-twistiness', String(val));
}
var savedTwist = parseInt(localStorage.getItem('freemoto-twistiness') || '50', 10);
if (!isNaN(savedTwist) && twistiness) {
twistiness.value = savedTwist;
applyTwistiness(savedTwist);
}
if (twistiness) {
twistiness.addEventListener('input', function(e){
var v = parseInt(e.target.value, 10);
applyTwistiness(v);
});
twistiness.addEventListener('change', function(){
if (typeof window.recalculateRoute === 'function') {
window.recalculateRoute();
}
});
}
// Highway preference slider (0..100 -> 0..1 use_highways)
function applyHighwayPref(val) {
if (highwayPrefValue) highwayPrefValue.textContent = String(val);
localStorage.setItem('freemoto-highways', String(val));
}
var savedHigh = parseInt(localStorage.getItem('freemoto-highways') || '50', 10);
if (!isNaN(savedHigh) && highwayPref) {
highwayPref.value = savedHigh;
applyHighwayPref(savedHigh);
}
if (highwayPref) {
highwayPref.addEventListener('input', function(e){
var v = parseInt(e.target.value, 10);
applyHighwayPref(v);
});
highwayPref.addEventListener('change', function(){
if (typeof window.recalculateRoute === 'function') {
window.recalculateRoute();
}
});
}
// Persist export mode
if (exportModeSel) {
var savedMode = localStorage.getItem('freemoto-export-mode') || 'both';
exportModeSel.value = savedMode;
exportModeSel.addEventListener('change', function(){
localStorage.setItem('freemoto-export-mode', exportModeSel.value);
});
}
// Persist voice toggle
if (voiceToggle) {
var savedVoice = localStorage.getItem('freemoto-voice') === '1';
voiceToggle.checked = savedVoice;
voiceToggle.addEventListener('change', function(){
localStorage.setItem('freemoto-voice', voiceToggle.checked ? '1' : '0');
});
}
// Round Trip settings persistence
if (roundTripToggle) {
var savedRT = localStorage.getItem('freemoto-roundtrip') === '1';
roundTripToggle.checked = savedRT;
roundTripToggle.addEventListener('change', function(){
localStorage.setItem('freemoto-roundtrip', roundTripToggle.checked ? '1' : '0');
});
}
if (roundTripKm) {
var savedKm = parseInt(localStorage.getItem('freemoto-roundtrip-km') || '100', 10);
if (!isNaN(savedKm)) roundTripKm.value = savedKm;
roundTripKm.addEventListener('change', function(){
var v = parseInt(roundTripKm.value, 10);
if (!isNaN(v)) localStorage.setItem('freemoto-roundtrip-km', String(v));
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
window.createRoundTrip();
}
});
}
if (roundTripBtn) {
roundTripBtn.addEventListener('click', function(){
if (typeof window.createRoundTrip === 'function') {
window.createRoundTrip();
}
});
}
if (roundTripNoRepeat) {
var savedNR = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
roundTripNoRepeat.checked = savedNR;
roundTripNoRepeat.addEventListener('change', function(){
localStorage.setItem('freemoto-roundtrip-norepeat', roundTripNoRepeat.checked ? '1' : '0');
});
}
// Direction preference persistence and live update
if (roundTripDir) {
var savedDir = localStorage.getItem('freemoto-roundtrip-dir') || 'any';
roundTripDir.value = savedDir;
roundTripDir.addEventListener('change', function(){
localStorage.setItem('freemoto-roundtrip-dir', roundTripDir.value);
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
// Regenerate the round trip to reflect new direction
window.createRoundTrip();
}
});
}
// Scenic optimizer toggle
if (roundTripScenic) {
var savedScenic = localStorage.getItem('freemoto-roundtrip-scenic') === '1';
roundTripScenic.checked = savedScenic;
roundTripScenic.addEventListener('change', function(){
localStorage.setItem('freemoto-roundtrip-scenic', roundTripScenic.checked ? '1' : '0');
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
window.createRoundTrip();
}
});
}
// Isochrone toggle and minutes
if (roundTripIsochrone) {
var savedIso = localStorage.getItem('freemoto-roundtrip-isochrone') === '1';
roundTripIsochrone.checked = savedIso;
roundTripIsochrone.addEventListener('change', function(){
localStorage.setItem('freemoto-roundtrip-isochrone', roundTripIsochrone.checked ? '1' : '0');
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
window.createRoundTrip();
}
});
}
if (isochroneMinutes) {
var savedMin = parseInt(localStorage.getItem('freemoto-isochrone-minutes') || '60', 10);
if (!isNaN(savedMin)) isochroneMinutes.value = savedMin;
isochroneMinutes.addEventListener('change', function(){
var v = parseInt(isochroneMinutes.value, 10);
if (!isNaN(v)) localStorage.setItem('freemoto-isochrone-minutes', String(v));
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
window.createRoundTrip();
}
});
}
// Swap start/end like Google Maps
(function(){
var swapBtn = document.getElementById('swapBtn');
if (!swapBtn) return;
function swapInputs() {
var s = document.getElementById('sourceInput');
var d = document.getElementById('destInput');
if (!s || !d) return;
// Swap visible values
var tmpVal = s.value; s.value = d.value; d.value = tmpVal;
// Swap lat/lon datasets
var sLat = s.dataset.lat, sLon = s.dataset.lon;
s.dataset.lat = d.dataset.lat || '';
s.dataset.lon = d.dataset.lon || '';
d.dataset.lat = sLat || '';
d.dataset.lon = sLon || '';
// If both look valid, trigger plot
var sourceLat = parseFloat(s.dataset.lat);
var sourceLon = parseFloat(s.dataset.lon);
var destLat = parseFloat(d.dataset.lat);
var destLon = parseFloat(d.dataset.lon);
if (!isNaN(sourceLat) && !isNaN(sourceLon) && !isNaN(destLat) && !isNaN(destLon)) {
var plot = document.getElementById('plotRouteBtn');
if (plot) plot.click();
}
}
swapBtn.addEventListener('click', swapInputs);
})();
// Recenter FAB behavior
(function(){
var recenter = document.getElementById('recenterBtn');
if (!recenter) return;
recenter.addEventListener('click', function(){
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position){
var lat = position.coords.latitude;
var lon = position.coords.longitude;
map.setView([lat, lon], Math.max(map.getZoom(), 14));
});
}
});
})();
// Mobile panel toggle (portrait)
try {
var panel = document.querySelector('.nav-panel');
var toggleBtn = document.getElementById('panelToggle');
function isSmallPortrait() {
var mqW = window.matchMedia('(max-width: 576px)');
var mqP = window.matchMedia('(orientation: portrait)');
return (mqW.matches && mqP.matches);
}
// Initialize collapsed state on load for small portrait
if (panel && isSmallPortrait()) {
panel.classList.add('collapsed');
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
}
// Auto-collapse panel when user hits Plot on small portrait
var plotBtn = document.getElementById('plotRouteBtn');
if (plotBtn) {
plotBtn.addEventListener('click', function(){
if (panel && isSmallPortrait()) {
panel.classList.add('collapsed');
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
}
});
}
if (toggleBtn && panel) {
toggleBtn.addEventListener('click', function() {
panel.classList.toggle('collapsed');
var expanded = !panel.classList.contains('collapsed');
toggleBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
}
// When orientation/viewport changes, ensure panel doesn't exceed screen
window.addEventListener('resize', function(){
if (!panel) return;
if (isSmallPortrait()) {
// Keep collapsed if it would cover map too much
if (!panel.classList.contains('collapsed')) panel.classList.add('collapsed');
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
}
});
} catch (_) {}
});

View File

@@ -7,38 +7,490 @@ document.addEventListener('DOMContentLoaded', function() {
var points = [];
var markers = [];
var routePolyline = null;
var snapLines = [];
var originalPoints = [];
var lastRouteLatLngs = null; // concatenated
var lastRtePoints = []; // for GPX <rtept>
var waypointListEl = document.getElementById('waypointList');
var nextBanner = document.getElementById('nextManeuverBanner');
var voiceToggle = document.getElementById('voiceToggle');
var exportModeSel = document.getElementById('exportMode');
var roundTripToggle = document.getElementById('roundTripToggle');
var roundTripKmInput = document.getElementById('roundTripKm');
var roundTripBtn = document.getElementById('roundTripBtn');
var roundTripDirSel = document.getElementById('roundTripDir');
var roundTripScenic = document.getElementById('roundTripScenic');
var roundTripIsochrone = document.getElementById('roundTripIsochrone');
var isochroneMinutesInput = document.getElementById('isochroneMinutes');
function renderWaypointList() {
if (!waypointListEl) return;
waypointListEl.innerHTML = '';
points.forEach(function(p, idx){
var li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center';
var label = (idx === 0) ? 'Start' : (idx === points.length - 1 ? 'End' : `WP ${idx}`);
var coords = `${p.lat.toFixed(5)}, ${p.lng.toFixed(5)}`;
li.innerHTML = `<span>${label}<br><small class="text-secondary">${coords}</small></span>`;
var btns = document.createElement('div');
btns.className = 'btn-group btn-group-sm';
var up = document.createElement('button'); up.className = 'btn btn-outline-secondary'; up.textContent = '↑'; up.disabled = (idx === 0);
var down = document.createElement('button'); down.className = 'btn btn-outline-secondary'; down.textContent = '↓'; down.disabled = (idx === points.length - 1);
var del = document.createElement('button'); del.className = 'btn btn-outline-danger'; del.textContent = '✕';
up.onclick = function(){ if (idx > 0) { swapWaypoints(idx, idx-1); } };
down.onclick = function(){ if (idx < points.length - 1) { swapWaypoints(idx, idx+1); } };
del.onclick = function(){ removeWaypoint(idx); };
btns.appendChild(up); btns.appendChild(down); btns.appendChild(del);
li.appendChild(btns);
waypointListEl.appendChild(li);
});
}
function swapWaypoints(i, j) {
var tmp = points[i]; points[i] = points[j]; points[j] = tmp;
var tmpo = originalPoints[i]; originalPoints[i] = originalPoints[j]; originalPoints[j] = tmpo;
var tmpm = markers[i]; markers[i] = markers[j]; markers[j] = tmpm;
renderWaypointList();
calculateRoute();
}
function removeWaypoint(i) {
if (markers[i]) { map.removeLayer(markers[i]); }
markers.splice(i,1);
points.splice(i,1);
originalPoints.splice(i,1);
renderWaypointList();
calculateRoute();
}
// Geo helpers for round trip generation
function toRad(d){ return d * Math.PI / 180; }
function toDeg(r){ return r * 180 / Math.PI; }
function destPoint(lat, lon, distanceMeters, bearingDeg) {
var R = 6371000; // meters
var br = toRad(bearingDeg);
var φ1 = toRad(lat), λ1 = toRad(lon);
var δ = distanceMeters / R;
var sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1);
var sinδ = Math.sin(δ), cosδ = Math.cos(δ);
var sinφ2 = sinφ1 * cosδ + cosφ1 * sinδ * Math.cos(br);
var φ2 = Math.asin(sinφ2);
var y = Math.sin(br) * sinδ * cosφ1;
var x = cosδ - sinφ1 * sinφ2;
var λ2 = λ1 + Math.atan2(y, x);
return { lat: toDeg(φ2), lng: ((toDeg(λ2) + 540) % 360) - 180 };
}
function normalizeDeg(d){ return ((d % 360) + 360) % 360; }
function preferredBearing() {
var val = roundTripDirSel ? (roundTripDirSel.value || 'any') : 'any';
switch (val) {
case 'N': return 0;
case 'E': return 90;
case 'S': return 180;
case 'W': return 270;
default: return null; // any
}
}
function generateRoundTripWaypoints(start) {
var km = parseFloat(roundTripKmInput && roundTripKmInput.value ? roundTripKmInput.value : '100');
if (isNaN(km) || km < 5) km = 50;
// Approximate radius for a loop with 3 intermediate waypoints: perimeter ≈ 4 * chord ≈ distance
// Use radius r ≈ distance / (2π)
var meters = km * 1000;
var r = Math.max(500, meters / (2 * Math.PI));
// If user provided a preferred direction, bias the bearings around that center.
var center = preferredBearing();
var bearings;
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
if (center === null) {
// Any: keep previous behavior (random base, evenly spaced)
var base = (Math.random() * 360) | 0;
bearings = noRepeat
? [0, 72, 144, 216, 288].map(function(d){ return base + d; })
: [45, 165, 285].map(function(d){ return base + d; });
} else {
// Directional preference: create an arc centered on preferred bearing
// Use 3 or 5 points depending on noRepeat, within +/- 60° spread
var offsets = noRepeat ? [-60, -30, 0, 30, 60] : [-30, 0, 30];
bearings = offsets.map(function(o){ return normalizeDeg(center + o); });
}
var wps = bearings.map(function(b, i){
// Slightly push the middle point further out to emphasize the preferred direction
var scale = 1.0;
if (center !== null) {
if ((noRepeat && (i === Math.floor(bearings.length/2))) || (!noRepeat && i === 1)) {
scale = 1.15; // middle point
} else {
scale = 1.0;
}
}
return destPoint(start.lat, start.lng, r * scale, b);
});
// Ensure endpoints list: start -> wps -> start
var routePts = [start].concat(wps).concat([start]);
return routePts;
}
// ---- Scenic optimizer helpers ----
function heading(a, b) {
var φ1 = toRad(a.lat), φ2 = toRad(b.lat);
var Δλ = toRad(b.lng - a.lng);
var y = Math.sin(Δλ) * Math.cos(φ2);
var x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
return normalizeDeg(toDeg(Math.atan2(y, x)));
}
function angleDiff(a, b) {
var d = Math.abs(a - b) % 360;
return d > 180 ? 360 - d : d;
}
function polylineHeadingDeltas(latlngs) {
var deltas = [];
for (var i = 1; i < latlngs.length - 1; i++) {
var h1 = heading({lat: latlngs[i-1][0], lng: latlngs[i-1][1]}, {lat: latlngs[i][0], lng: latlngs[i][1]});
var h2 = heading({lat: latlngs[i][0], lng: latlngs[i][1]}, {lat: latlngs[i+1][0], lng: latlngs[i+1][1]});
deltas.push(angleDiff(h1, h2));
}
return deltas;
}
function distanceKm(a, b) {
var R = 6371.0;
var dLat = toRad(b[0] - a[0]);
var dLng = toRad(b[1] - a[1]);
var s1 = Math.sin(dLat/2), s2 = Math.sin(dLng/2);
var aa = s1*s1 + Math.cos(toRad(a[0]))*Math.cos(toRad(b[0]))*s2*s2;
var c = 2 * Math.atan2(Math.sqrt(aa), Math.sqrt(1-aa));
return R * c;
}
function pathLengthKm(latlngs) {
var km = 0;
for (var i = 1; i < latlngs.length; i++) km += distanceKm(latlngs[i-1], latlngs[i]);
return km;
}
function scorePolyline(latlngs) {
if (!latlngs || latlngs.length < 3) return -1e9;
var km = pathLengthKm(latlngs);
if (km <= 0) return -1e9;
var deltas = polylineHeadingDeltas(latlngs);
var sumDelta = deltas.reduce((a,b)=>a+b, 0);
var turns = deltas.filter(d => d > 12).length; // count meaningful turns
var turnDensity = turns / km; // turns per km
var curvature = sumDelta / km; // degrees per km
// Penalize very straight long segments implicitly by lower curvature
// Compose score: weight curvature more than turn count
return curvature * 0.7 + turnDensity * 0.3;
}
async function routeAndScore(pointsList, requestOptions) {
var body = {
locations: pointsList.map(function(p){ return { lat: p.lat, lon: p.lng }; }),
costing: "motorcycle",
costing_options: requestOptions.costing_options || {},
units: "kilometers"
};
if (requestOptions.shortest) body.shortest = true;
var resp = await fetch('/route', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
var data = await resp.json();
var legs = (data.trip && Array.isArray(data.trip.legs)) ? data.trip.legs : [];
var latlngs = [];
legs.forEach(function(leg){
var lls = decodePolyline6(leg.shape || '');
if (latlngs.length && lls.length) latlngs = latlngs.concat(lls.slice(1)); else latlngs = latlngs.concat(lls);
});
var score = scorePolyline(latlngs);
return {score: score, data: data, latlngs: latlngs};
}
function randomNormal() {
// Box-Muller
var u = 1 - Math.random();
var v = 1 - Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
function generateScenicCandidates(start) {
var basePts = generateRoundTripWaypoints(start);
var center = preferredBearing();
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
var count = 6;
var candidates = [];
for (var i = 0; i < count; i++) {
var scale = 1 + (Math.random()*0.25 - 0.1); // -10%..+15%
var jitter = center === null ? 30 : 15; // tighter if centered
var pts = basePts.map(function(p, idx){
if (idx === 0 || idx === basePts.length - 1) return p;
// compute bearing from start to p and re-project with jitter and scale
var br = heading(start, p);
var j = randomNormal() * jitter;
var d = distanceKm([start.lat, start.lng], [p.lat, p.lng]) * 1000 * scale; // meters
return destPoint(start.lat, start.lng, d, br + j);
});
candidates.push(pts);
}
// Ensure base version is included as well
candidates.unshift(basePts);
return candidates;
}
// ---- Isochrone helpers ----
async function fetchIsochronePolygon(start, minutes, motoOptions) {
var body = {
locations: [{ lat: start.lat, lon: start.lng }],
costing: "motorcycle",
costing_options: { motorcycle: motoOptions || {} },
contours: [{ time: Math.max(5, Math.min(240, minutes)) }],
polygons: true,
denoise: 0.5,
generalize: 100
};
var resp = await fetch('/isochrone', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
var data = await resp.json();
// Expect GeoJSON FeatureCollection
try {
var coords = data.features[0].geometry.coordinates;
// Valhalla returns [ [ [lon,lat], ... ] ]
var ring = coords[0].map(function(c){ return { lat: c[1], lng: c[0] }; });
return ring;
} catch (_) {
return null;
}
}
function sampleIsochroneSector(ring, start, centerBearing) {
if (!ring || !ring.length) return [];
var sector = [];
var spread = 60; // +/- degrees
ring.forEach(function(p){
var br = heading(start, {lat: p.lat, lng: p.lng});
if (centerBearing === null) {
sector.push(p);
} else {
if (angleDiff(br, centerBearing) <= spread) sector.push(p);
}
});
if (sector.length < 3) {
// Fallback to full ring
sector = ring.slice();
}
// Pick 3 or 5 evenly spaced points from sector
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
var n = noRepeat ? 5 : 3;
var step = Math.max(1, Math.floor(sector.length / (n + 1)));
var wps = [];
for (var i = 1; i <= n; i++) {
var idx = Math.min(sector.length - 1, i * step);
wps.push(sector[idx]);
}
return wps;
}
// Rudimentary land/water detection using backend reverse geocoding
async function isLikelyWater(lat, lon) {
try {
const resp = await fetch(`/reverse?format=json&lat=${lat}&lon=${lon}`);
const data = await resp.json();
// Heuristics: if class/type indicates water or coastline, treat as water
const cls = (data && (data.class || (data.address && data.address.natural))) || '';
const typ = (data && (data.type || (data.address && (data.address.water || data.address.waterway)))) || '';
const name = (data && data.display_name) || '';
const hay = `${cls} ${typ} ${name}`.toLowerCase();
return /\b(water|sea|bay|ocean|coast|coastline|harbor|harbour|beach|lake|fjord|inlet|river|estuary)\b/.test(hay);
} catch (_) {
// On failure, be conservative and assume not water
return false;
}
}
async function adjustPointInland(p, start) {
// Try up to ~10 steps of 1km towards start to avoid placing on water
const steps = 10;
const stepMeters = 1000;
let cand = { lat: p.lat, lng: p.lng };
// Bearing from candidate to start (move inland towards start)
function bearing(from, to) {
const φ1 = toRad(from.lat), φ2 = toRad(to.lat);
const Δλ = toRad(to.lng - from.lng);
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
return (toDeg(Math.atan2(y, x)) + 360) % 360;
}
for (let i = 0; i < steps; i++) {
const water = await isLikelyWater(cand.lat, cand.lng);
if (!water) return cand;
const brg = bearing(cand, start);
cand = destPoint(cand.lat, cand.lng, stepMeters, brg);
}
return cand;
}
async function createRoundTrip() {
if (!roundTripToggle || !roundTripToggle.checked) return;
// Determine start point: prefer first existing point, else sourceInput coords
var start = null;
if (points.length > 0) {
start = { lat: points[0].lat, lng: points[0].lng };
} else {
var sourceInput = document.getElementById('sourceInput');
var sLat = parseFloat(sourceInput && sourceInput.dataset.lat);
var sLon = parseFloat(sourceInput && sourceInput.dataset.lon);
if (!isNaN(sLat) && !isNaN(sLon)) {
start = { lat: sLat, lng: sLon };
} else {
// fallback: map center
var c = map.getCenter();
start = { lat: c.lat, lng: c.lng };
}
}
// Clear existing markers/lines
markers.forEach(function(m){ map.removeLayer(m); });
markers = [];
points = [];
originalPoints = [];
if (routePolyline) { map.removeLayer(routePolyline); routePolyline = null; }
if (snapLines && snapLines.length) { snapLines.forEach(function(sl){ map.removeLayer(sl); }); snapLines = []; }
var useIso = !!(roundTripIsochrone && roundTripIsochrone.checked);
var useScenic = !!(roundTripScenic && roundTripScenic.checked);
var pts;
if (useIso) {
// Build moto options to avoid ferries etc.
var moto = {};
if (avoidFerriesCheckbox && avoidFerriesCheckbox.checked) moto.use_ferry = 0.0;
moto.use_ferry = 0.0; // default for round trips
moto.ferry_cost = 1000;
var minutes = parseInt((isochroneMinutesInput && isochroneMinutesInput.value) || localStorage.getItem('freemoto-isochrone-minutes') || '60', 10);
var ring = await fetchIsochronePolygon(start, minutes, moto);
if (ring && ring.length) {
var center = preferredBearing();
var wps = sampleIsochroneSector(ring, start, center);
pts = [start].concat(wps).concat([start]);
}
}
if (!pts) {
pts = generateRoundTripWaypoints(start);
}
// If scenic optimizer is enabled, evaluate a few candidates and pick the twistiest
if (useScenic) {
try {
var requestOptions = { costing_options: { motorcycle: {} } };
// Avoid ferries strongly
requestOptions.costing_options.motorcycle.use_ferry = 0.0;
requestOptions.costing_options.motorcycle.ferry_cost = 1000;
var candidates = generateScenicCandidates(start);
// Replace the base candidate with our current pts at index 0
candidates[0] = pts;
var best = null;
// Evaluate sequentially to avoid hammering the backend
for (var ci = 0; ci < candidates.length; ci++) {
var cand = candidates[ci];
var result = await routeAndScore(cand, requestOptions);
if (!best || result.score > best.score) {
best = { idx: ci, score: result.score, pts: cand };
}
}
if (best) {
pts = best.pts;
}
} catch (e) { console.warn('Scenic optimizer failed', e); }
}
// Adjust intermediate points away from water before placing markers
var adjusted = await Promise.all(pts.map(async function(p, idx) {
if (idx === 0 || idx === pts.length - 1) return p; // keep start/end
try {
return await adjustPointInland(p, start);
} catch (_) {
return p;
}
}));
adjusted.forEach(function(p, idx){
var marker = L.marker(p, { draggable: true }).addTo(map);
marker.bindPopup(idx === 0 ? 'Start/End' : `WP ${idx}`).openPopup();
marker.on('dragend', function(ev){
var m = ev.target; var newLL = m.getLatLng(); var i = markers.indexOf(m);
if (i >= 0) { points[i] = { lat: newLL.lat, lng: newLL.lng }; originalPoints[i] = { lat: newLL.lat, lng: newLL.lng }; renderWaypointList(); calculateRoute(); }
});
markers.push(marker);
points.push({ lat: p.lat, lng: p.lng });
originalPoints.push({ lat: p.lat, lng: p.lng });
});
renderWaypointList();
calculateRoute();
}
function calculateRoute() {
if (points.length === 2) {
var options = {
"exclude_restrictions": true
};
if (points.length >= 2) {
var moto = {};
// Avoid highways -> lower highway usage weight
if (avoidHighwaysCheckbox && avoidHighwaysCheckbox.checked) {
options.use_highways = 0;
}
if (useShortestCheckbox && useShortestCheckbox.checked) {
options.use_shortest = true;
}
if (avoidTollRoadsCheckbox && avoidTollRoadsCheckbox.checked) {
options.avoid_toll = true;
moto.use_highways = 0.0; // 0..1 (0 avoids, 1 prefers)
}
// Avoid ferries -> lower ferry usage weight
if (avoidFerriesCheckbox && avoidFerriesCheckbox.checked) {
options.avoid_ferry = true;
moto.use_ferry = 0.0; // 0..1
}
// Avoid unpaved -> exclude unpaved roads entirely
if (avoidUnpavedCheckbox && avoidUnpavedCheckbox.checked) {
options.avoid_unpaved = true;
moto.exclude_unpaved = true;
}
// Avoid tolls -> exclude tolls
if (avoidTollRoadsCheckbox && avoidTollRoadsCheckbox.checked) {
moto.exclude_tolls = true;
}
var costing_options = { motorcycle: options };
// Twistiness slider from localStorage: 0 (straight/fast) .. 100 (very twisty)
try {
var twistVal = parseInt(localStorage.getItem('freemoto-twistiness') || '50', 10);
if (!isNaN(twistVal)) {
// Map 0..100 to highway preference 1.0..0.0 (inverse)
var hw = Math.max(0, Math.min(1, 1 - (twistVal / 100)));
// If user also checked avoidHighways, keep the stronger effect (min)
moto.use_highways = (typeof moto.use_highways === 'number') ? Math.min(moto.use_highways, hw) : hw;
}
} catch (_) {}
// Highway preference slider directly sets use_highways proportionally
try {
var hwp = parseInt(localStorage.getItem('freemoto-highways') || '50', 10);
if (!isNaN(hwp)) {
var hw2 = Math.max(0, Math.min(1, hwp / 100));
// Combine with existing (take max so user can prefer highways even if twistiness low)
moto.use_highways = (typeof moto.use_highways === 'number') ? Math.max(moto.use_highways, hw2) : hw2;
}
} catch (_) {}
// For round trips, avoid ferries by default to prevent water crossings
try {
if (roundTripToggle && roundTripToggle.checked) {
moto.use_ferry = 0.0; // strongest avoidance
// Slightly increase ferry_cost as an extra safety
moto.ferry_cost = 1000;
}
} catch (_) {}
var costing_options = { motorcycle: moto };
var requestBody = {
locations: [
{ lat: points[0].lat, lon: points[0].lng },
{ lat: points[1].lat, lon: points[1].lng }
],
locations: points.map(function(p){ return { lat: p.lat, lon: p.lng }; }),
costing: "motorcycle",
costing_options: costing_options
costing_options: costing_options,
units: "kilometers"
};
if (useShortestCheckbox && useShortestCheckbox.checked) {
requestBody.shortest = true; // top-level shortest flag
}
fetch('/route', {
method: 'POST',
@@ -47,25 +499,256 @@ document.addEventListener('DOMContentLoaded', function() {
})
.then(response => response.json())
.then(data => {
var latlngs = decodePolyline6(data.trip.legs[0].shape);
var legs = (data.trip && Array.isArray(data.trip.legs)) ? data.trip.legs : [];
var infoCard = document.getElementById('routeInfoCard');
// Sum summaries across legs if needed
var totalLen = 0, totalTime = 0;
legs.forEach(function(leg){
if (leg && leg.summary) {
if (typeof leg.summary.length === 'number') totalLen += leg.summary.length;
if (typeof leg.summary.time === 'number') totalTime += leg.summary.time;
}
});
if (legs.length > 0) {
var distanceKm = (totalLen).toFixed(1);
var durationMin = Math.round(totalTime / 60);
var info = `<strong>Distance:</strong> ${distanceKm} km<br>
<strong>Estimated Time:</strong> ${durationMin} min<br>
<strong>Motorcycle Profile</strong>`;
infoCard.innerHTML = info;
infoCard.classList.remove('d-none');
// Update summary pill in app bar
var pill = document.getElementById('summaryPill');
if (pill) {
pill.textContent = `${distanceKm} km · ${durationMin} min`;
pill.classList.remove('d-none');
}
} else {
infoCard.innerHTML = `<strong>Route info unavailable.</strong>`;
infoCard.classList.remove('d-none');
console.log('Valhalla response:', data);
}
// Draw route across all legs
var latlngs = [];
var legsLatLngs = [];
legs.forEach(function(leg){
var lls = decodePolyline6(leg.shape || '');
legsLatLngs.push(lls);
if (latlngs.length && lls.length) {
// avoid duplicating the joint point
latlngs = latlngs.concat(lls.slice(1));
} else {
latlngs = latlngs.concat(lls);
}
});
if (routePolyline) {
map.removeLayer(routePolyline);
}
routePolyline = L.polyline(latlngs, { color: 'red', weight: 5}).addTo(map);
map.fitBounds(routePolyline.getBounds());
// Build directions bottom sheet and next maneuver banner/voice
try {
var directionsSheet = document.getElementById('directionsSheet');
var directionsList = document.getElementById('directionsList');
directionsList.innerHTML = '';
lastRouteLatLngs = latlngs;
lastRtePoints = [];
// Aggregate maneuvers from all legs and render
legs.forEach(function(leg, li){
var maneuvers = leg.maneuvers || [];
var lls = legsLatLngs[li] || [];
maneuvers.forEach(function(m) {
var liEl = document.createElement('li');
liEl.className = 'list-group-item d-flex justify-content-between align-items-center';
var text = m.instruction || m.street_names?.join(', ') || 'Continue';
var meta = [];
if (typeof m.length === 'number') meta.push((m.length).toFixed(1) + ' km');
if (typeof m.time === 'number') meta.push(Math.round(m.time / 60) + ' min');
liEl.innerHTML = `<span>${text}</span><small class="text-secondary ms-2">${meta.join(' · ')}</small>`;
directionsList.appendChild(liEl);
// Store rte point for GPX
var idx = (typeof m.begin_shape_index === 'number') ? m.begin_shape_index : 0;
idx = Math.max(0, Math.min(idx, lls.length - 1));
var coord = lls[idx] || lls[0];
if (coord) {
lastRtePoints.push({ lat: coord[0], lon: coord[1], name: text });
}
});
});
// Next maneuver banner (first item)
if (directionsList.children.length > 0) {
var first = directionsList.children[0].querySelector('span');
if (first && nextBanner) {
nextBanner.textContent = first.textContent;
nextBanner.classList.remove('d-none');
}
// Voice prompt
if (voiceToggle && voiceToggle.checked && 'speechSynthesis' in window) {
try {
var utter = new SpeechSynthesisUtterance(first.textContent);
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utter);
} catch (_) {}
}
} else if (nextBanner) {
nextBanner.classList.add('d-none');
}
if (directionsList.children.length > 0) {
directionsSheet.style.display = 'block';
// default to collapsed to avoid covering full map
directionsSheet.classList.add('collapsed');
// Toggle collapse on handle click
var handle = directionsSheet.querySelector('.handle');
if (handle) {
handle.onclick = function() {
directionsSheet.classList.toggle('collapsed');
};
// Drag-to-resize
var sheetCard = directionsSheet.querySelector('.sheet-card');
var sheetBody = directionsSheet.querySelector('.sheet-body');
var dragging = false;
var startY = 0;
var startHeight = 0;
var vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
function px(val) { return `${val}px`; }
function clamp(val, min, max) { return Math.max(min, Math.min(max, val)); }
function onMove(clientY) {
var dy = startY - clientY; // drag up to expand
var newHeight = clamp(startHeight + dy, 0.28 * vh, 0.8 * vh);
sheetCard.style.maxHeight = px(newHeight);
if (sheetBody) sheetBody.style.maxHeight = px(newHeight - 42);
directionsSheet.classList.remove('collapsed');
}
handle.addEventListener('mousedown', function(ev) {
dragging = true;
startY = ev.clientY;
startHeight = sheetCard.getBoundingClientRect().height;
document.body.style.userSelect = 'none';
});
window.addEventListener('mousemove', function(ev) {
if (!dragging) return;
onMove(ev.clientY);
});
window.addEventListener('mouseup', function() {
if (dragging) {
dragging = false;
document.body.style.userSelect = '';
}
});
// Touch
handle.addEventListener('touchstart', function(ev) {
dragging = true;
startY = ev.touches[0].clientY;
startHeight = sheetCard.getBoundingClientRect().height;
}, {passive: true});
window.addEventListener('touchmove', function(ev) {
if (!dragging) return;
onMove(ev.touches[0].clientY);
}, {passive: true});
window.addEventListener('touchend', function() { dragging = false; });
}
}
} catch (e) {
console.warn('Failed to render directions sheet', e);
}
// Snap-to-roads visualization for all waypoints: snapped points are
// first of leg0, then last point of each leg i (for waypoint i)
try {
// Clear previous snap lines
if (snapLines && snapLines.length) {
snapLines.forEach(function(sl){ map.removeLayer(sl); });
snapLines = [];
}
var snapped = [];
if (legsLatLngs.length > 0) {
snapped[0] = legsLatLngs[0][0];
for (var i = 1; i < legsLatLngs.length; i++) {
var lseg = legsLatLngs[i];
if (lseg && lseg.length) {
snapped[i] = lseg[lseg.length - 1];
}
}
}
for (var j = 0; j < markers.length && j < snapped.length; j++) {
if (!snapped[j]) continue;
if (originalPoints[j]) {
var sl = L.polyline([originalPoints[j], snapped[j]], { color: '#0d6efd', weight: 2, dashArray: '6,6', opacity: 0.7 });
sl.addTo(map);
snapLines.push(sl);
}
markers[j].setLatLng(snapped[j]);
}
} catch (e) { console.warn('Snap-to-roads viz failed', e); }
});
}
}
map.on('click', function(e) {
if (points.length < 2) {
var marker = L.marker(e.latlng).addTo(map);
markers.push(marker);
points.push(e.latlng);
marker.bindPopup(points.length === 1 ? "Start" : "End").openPopup();
// Visual pulse effect at a latlng
function pulseAt(latlng) {
try {
var pulse = L.circle(latlng, { radius: 5, color: '#0d6efd', weight: 2, opacity: 0.8, fillOpacity: 0.2 });
pulse.addTo(map);
var steps = 10;
var i = 0;
var interval = setInterval(function() {
i++;
pulse.setRadius(5 + i * 20);
pulse.setStyle({ opacity: Math.max(0, 0.8 - i * 0.08), fillOpacity: Math.max(0, 0.2 - i * 0.02) });
if (i >= steps) {
clearInterval(interval);
map.removeLayer(pulse);
}
}, 30);
} catch (_) {}
}
function placePoint(latlng) {
if (points.length >= 10) { alert('Maximum of 10 waypoints.'); return; }
var marker = L.marker(latlng, { draggable: true }).addTo(map);
markers.push(marker);
points.push(latlng);
originalPoints.push({ lat: latlng.lat, lng: latlng.lng });
marker.bindPopup(points.length === 1 ? 'Start' : 'End').openPopup();
marker.on('dragend', function(ev){
var m = ev.target;
var newLL = m.getLatLng();
var idx = markers.indexOf(m);
if (idx >= 0) {
points[idx] = { lat: newLL.lat, lng: newLL.lng };
originalPoints[idx] = { lat: newLL.lat, lng: newLL.lng };
renderWaypointList();
calculateRoute();
}
});
pulseAt(latlng);
if (points.length === 1) {
reverseGeocode(latlng.lat, latlng.lng, 'sourceInput');
} else if (points.length === 2) {
reverseGeocode(latlng.lat, latlng.lng, 'destInput');
}
calculateRoute();
});
renderWaypointList();
}
function isTouchDevice() {
return (window.matchMedia && window.matchMedia('(pointer: coarse)').matches) || ('ontouchstart' in window);
}
var placeHandler = function(e) { placePoint(e.latlng); };
if (isTouchDevice()) {
map.on('click', placeHandler); // single tap on mobile
} else {
map.on('dblclick', placeHandler); // double click on desktop
}
// Listen for changes on all checkboxes
[
@@ -131,10 +814,35 @@ document.addEventListener('DOMContentLoaded', function() {
});
markers = [];
points = [];
originalPoints = [];
if (routePolyline) {
map.removeLayer(routePolyline);
routePolyline = null;
}
if (snapLines && snapLines.length) {
snapLines.forEach(function(sl){ map.removeLayer(sl); });
snapLines = [];
}
if (waypointListEl) waypointListEl.innerHTML = '';
// Clear address fields
var sourceInput = document.getElementById('sourceInput');
var destInput = document.getElementById('destInput');
sourceInput.value = '';
destInput.value = '';
delete sourceInput.dataset.lat;
delete sourceInput.dataset.lon;
delete destInput.dataset.lat;
delete destInput.dataset.lon;
// Hide and clear directions
var directionsSheet = document.getElementById('directionsSheet');
var directionsList = document.getElementById('directionsList');
if (directionsSheet) directionsSheet.style.display = 'none';
if (directionsList) directionsList.innerHTML = '';
// Hide summary pill
var pill = document.getElementById('summaryPill');
if (pill) pill.classList.add('d-none');
}
// Make resetMarkers available globally
@@ -168,4 +876,104 @@ document.addEventListener('DOMContentLoaded', function() {
alert("Please enter valid addresses for both start and destination.");
}
});
function reverseGeocode(lat, lon, inputId) {
fetch(`/reverse?format=json&lat=${lat}&lon=${lon}`)
.then(response => response.json())
.then(data => {
var input = document.getElementById(inputId);
if (!input) return;
input.value = (data && (data.display_name || (data.address && (data.address.road || data.address.city)))) || `${lat}, ${lon}`;
input.dataset.lat = lat;
input.dataset.lon = lon;
})
.catch(() => {
var input = document.getElementById(inputId);
if (!input) return;
input.value = `${lat}, ${lon}`;
input.dataset.lat = lat;
input.dataset.lon = lon;
});
}
function exportRouteAsGPX(latlngs) {
if (!latlngs || latlngs.length === 0) {
alert("No route to export.");
return;
}
const now = new Date().toISOString();
// Build <rte> using Valhalla maneuvers (snap to their begin shape indices)
var rtepts = '';
if (Array.isArray(lastRtePoints) && lastRtePoints.length) {
rtepts = lastRtePoints.map(function(p){
var safe = (p.name || 'Step').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return ` <rtept lat="${p.lat}" lon="${p.lon}"><name>${safe}</name></rtept>`;
}).join('\n');
}
var mode = exportModeSel ? (exportModeSel.value || 'both') : 'both';
var parts = [];
parts.push(`<?xml version="1.0" encoding="UTF-8"?>`);
parts.push(`<gpx version="1.1" creator="FreeMoto" xmlns="http://www.topografix.com/GPX/1/1">`);
parts.push(` <metadata>\n <name>FreeMoto Route</name>\n <time>${now}</time>\n </metadata>`);
if (mode === 'route' || mode === 'both') {
parts.push(` <rte>\n <name>FreeMoto Directions</name>`);
parts.push(rtepts);
parts.push(` </rte>`);
}
if (mode === 'track' || mode === 'both') {
parts.push(` <trk>\n <name>FreeMoto Route</name>\n <type>motorcycle</type>\n <trkseg>`);
parts.push(latlngs.map(pt => ` <trkpt lat="${pt[0]}" lon="${pt[1]}"><time>${now}</time></trkpt>`).join('\n'));
parts.push(` </trkseg>\n </trk>`);
}
parts.push(`</gpx>`);
let gpx = parts.join('\n');
let blob = new Blob([gpx], {type: 'application/gpx+xml'});
let url = URL.createObjectURL(blob);
// iOS workaround: open in new tab instead of triggering download
if (navigator.userAgent.match(/(iPad|iPhone|iPod)/i)) {
window.open(url, '_blank');
} else {
let a = document.createElement('a');
a.href = url;
a.download = 'freemoto-route.gpx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// Bind all GPX export buttons
document.querySelectorAll('.export-gpx').forEach(function(btn){
btn.addEventListener('click', function() {
if (routePolyline) {
let latlngs = routePolyline.getLatLngs().map(ll => [ll.lat, ll.lng]);
exportRouteAsGPX(latlngs);
} else {
alert('No route to export.');
}
});
});
// Close directions sheet
var closeBtn = document.getElementById('closeDirections');
if (closeBtn) {
closeBtn.addEventListener('click', function() {
var sheet = document.getElementById('directionsSheet');
if (sheet) sheet.style.display = 'none';
});
}
// Also hide summary pill when sheet closed manually
var pill = document.getElementById('summaryPill');
if (closeBtn && pill) {
closeBtn.addEventListener('click', function(){ pill.classList.add('d-none'); });
}
// Expose recalc for twistiness changes
window.recalculateRoute = function(){ calculateRoute(); };
window.createRoundTrip = function(){ createRoundTrip(); };
});

290
app/web/static/styles.css Normal file
View File

@@ -0,0 +1,290 @@
/* FreeMoto — Fresh responsive UI
Author: Cascade
*/
:root {
--panel-radius: 16px;
--pill-radius: 999px;
--glass-bg: rgba(255, 255, 255, 0.9);
--glass-border: rgba(16, 24, 40, 0.08);
--brand-start: #0ea5e9;
--brand-end: #6366f1;
--text-muted: #64748b;
--shadow-1: 0 8px 24px rgba(2, 8, 20, 0.08);
--shadow-2: 0 10px 30px rgba(2, 8, 20, 0.12);
}
html, body { height: 100%; }
body {
margin: 0;
overflow: hidden; /* map-focused app; internal sections handle their own scroll */
background: linear-gradient(180deg, #f1f5f9, #e2e8f0);
color: #0f172a;
}
/* Map canvas */
#map {
position: absolute;
inset: 0;
z-index: 1;
}
/* Top App Bar */
.appbar {
position: fixed;
inset: 0 0 auto 0;
z-index: 1002;
background: linear-gradient(90deg, var(--brand-start), var(--brand-end));
color: #fff;
box-shadow: var(--shadow-1);
}
.appbar .navbar-brand {
color: #fff;
}
.appbar .btn {
color: #fff;
border-color: rgba(255, 255, 255, 0.6);
}
.appbar .badge {
background: rgba(255, 255, 255, 0.15);
}
/* Floating Control Panel (mobile: bottom pill, desktop: floating card) */
.nav-panel {
position: fixed;
left: 12px;
right: 12px;
bottom: 12px;
z-index: 1001;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(10px) saturate(160%);
border-radius: var(--pill-radius);
box-shadow: var(--shadow-2);
padding: 10px 12px;
transition: max-height 200ms ease, transform 200ms ease, padding 200ms ease;
overflow: auto; /* allow scroll when content grows */
max-height: 60vh; /* default for mobile */
}
.nav-panel .form-control,
.nav-panel .btn {
border-radius: var(--pill-radius);
}
.nav-panel .input-group > .input-group-text {
border-radius: var(--pill-radius) 0 0 var(--pill-radius);
}
.section-title {
font-weight: 600;
color: #334155;
}
#routeInfoCard {
font-size: 0.95rem;
}
/* Suggestions dropdown */
#sourceSuggestions,
#destSuggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 2000;
max-height: 240px;
overflow: auto;
border-radius: 12px;
}
/* Directions bottom sheet */
.sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1001;
}
.sheet-card {
border-radius: var(--panel-radius) var(--panel-radius) 0 0;
max-height: 60vh;
overflow: hidden; /* header fixed; body scrolls */
box-shadow: var(--shadow-2);
}
.sheet .handle {
width: 44px;
height: 5px;
border-radius: var(--pill-radius);
background: #cbd5e1;
margin: 6px auto 6px;
}
.sheet-header {
position: sticky;
top: 0;
background: #fff;
z-index: 1;
padding: 4px 8px;
border-bottom: 1px solid #eef2f6;
}
.sheet-body { overflow: auto; max-height: calc(60vh - 42px); }
#directionsSheet { display: none; }
.sheet.collapsed .sheet-card { max-height: 28vh; }
.sheet.collapsed .sheet-body { max-height: calc(28vh - 42px); }
/* Floating actions (mobile) */
.floating-group {
position: fixed;
right: 14px;
bottom: 90px;
z-index: 1002;
display: flex;
flex-direction: column;
gap: 10px;
}
.floating-group .fab,
.floating-group .btn {
border-radius: 999px;
box-shadow: var(--shadow-2);
}
.floating-group .fab {
width: 48px;
height: 48px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Toggle button for control panel on mobile */
.panel-toggle {
position: fixed;
left: 14px;
bottom: 90px;
z-index: 1002;
}
/* Mobile portrait refinements */
@media (max-width: 576px) and (orientation: portrait) {
.nav-panel {
left: 8px;
right: 8px;
bottom: 8px;
max-height: 56vh; /* slightly taller for more controls */
overflow: auto; /* scroll internally */
}
.nav-panel.collapsed {
max-height: 56px; /* show a small header */
padding: 8px 12px;
pointer-events: none; /* let map interactions pass through when collapsed */
}
.floating-group { bottom: 76px; }
.panel-toggle { bottom: 76px; }
.section-title { font-size: 0.95rem; }
}
/* Desktop/tablet layout refinements */
@media (min-width: 768px) {
.nav-panel {
top: 88px;
bottom: 24px; /* allow taller panel while preserving map footer space */
left: 24px;
right: auto;
width: min(560px, 42vw);
border-radius: var(--panel-radius);
padding: 16px;
max-height: calc(100vh - 88px - 24px);
overflow: auto; /* scroll the panel content when needed */
}
.nav-panel .form-control,
.nav-panel .btn {
border-radius: var(--panel-radius);
}
.nav-panel .input-group > .input-group-text {
border-radius: var(--panel-radius) 0 0 var(--panel-radius);
}
}
/* Make long lists scrollable inside the panel */
.nav-panel .list-group {
max-height: 34vh;
overflow: auto;
}
/* Scrollbar styling for WebKit browsers */
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-thumb { background-color: rgba(100, 116, 139, 0.5); border-radius: 8px; }
*::-webkit-scrollbar-thumb:hover { background-color: rgba(71, 85, 105, 0.7); }
*::-webkit-scrollbar-track { background: transparent; }
/* Dark theme */
/* Improved dark mode for readability */
[data-theme="dark"] :root {
--glass-bg: rgba(17, 24, 39, 0.92); /* slate-900 w/ opacity */
--glass-border: rgba(255, 255, 255, 0.06);
--text-muted: #9ca3af; /* slate-400 */
}
[data-theme="dark"] body {
background: linear-gradient(180deg, #0b1220, #0f172a);
color: #e5e7eb; /* slate-200 */
}
[data-theme="dark"] a { color: #93c5fd; }
[data-theme="dark"] .appbar {
background: linear-gradient(90deg, #334155, #475569); /* slate gradient */
color: #e5e7eb;
}
[data-theme="dark"] .appbar .navbar-brand { color: #f1f5f9; }
[data-theme="dark"] .appbar .btn {
color: #e5e7eb;
border-color: rgba(148, 163, 184, 0.7);
}
[data-theme="dark"] .nav-panel {
background: var(--glass-bg);
border-color: var(--glass-border);
box-shadow: 0 10px 30px rgba(0,0,0,0.55);
}
[data-theme="dark"] .section-title { color: #e2e8f0; }
[data-theme="dark"] .form-control,
[data-theme="dark"] .input-group-text {
background: #111827; /* gray-900 */
color: #e5e7eb;
border-color: #374151; /* gray-700 */
}
[data-theme="dark"] .form-control::placeholder { color: #9ca3af; opacity: 1; }
[data-theme="dark"] .btn-outline-secondary { color: #e5e7eb; border-color: #93c5fd; }
[data-theme="dark"] .btn-primary { background-color: #4f46e5; border-color: #4338ca; }
[data-theme="dark"] .btn-success { background-color: #16a34a; border-color: #15803d; color: #f9fafb; }
[data-theme="dark"] .btn-warning { background-color: #f59e0b; border-color: #d97706; color: #111827; }
[data-theme="dark"] .badge,
[data-theme="dark"] .badge.text-bg-light { background-color: #374151 !important; color: #f9fafb !important; }
[data-theme="dark"] #summaryPill.badge { background-color: #1f2937 !important; color: #f9fafb !important; border: 1px solid #334155; }
[data-theme="dark"] .card,
[data-theme="dark"] .sheet-card { background: #0f172a; color: #e5e7eb; border-color: #334155; }
[data-theme="dark"] .list-group-item { background: #0b1220; color: #e5e7eb; border-color: #334155; }
[data-theme="dark"] .list-group-item-action:hover { background-color: #111827; color: #ffffff; }
[data-theme="dark"] .sheet-header { background: #0b1220; border-bottom-color: #334155; }
[data-theme="dark"] .handle { background: #475569; }
[data-theme="dark"] .alert { background-color: #0f172a; color: #e5e7eb; border-color: #334155; }
[data-theme="dark"] .alert-info { background-color: #0b1e2b; border-color: #1f3a5f; color: #dbeafe; }
[data-theme="dark"] .alert-secondary { background-color: #1f2937; border-color: #374151; color: #e5e7eb; }
/* Dark accordion */
[data-theme="dark"] .accordion-item { background-color: #0b1220; border: 1px solid #334155; }
[data-theme="dark"] .accordion-button { background-color: #0b1220; color: #e5e7eb; }
[data-theme="dark"] .accordion-button:not(.collapsed) { background-color: #0f172a; color: #ffffff; box-shadow: inset 0 -1px 0 #334155; }
[data-theme="dark"] .accordion-button:focus { box-shadow: 0 0 0 .2rem rgba(147, 197, 253, 0.25); border-color: #93c5fd; }
[data-theme="dark"] .accordion-body { background-color: #0f172a; color: #e5e7eb; }
/* Outline button hover/focus in dark */
[data-theme="dark"] .btn-outline-secondary:hover,
[data-theme="dark"] .btn-outline-secondary:focus { background-color: rgba(147, 197, 253, 0.12); color: #e5e7eb; border-color: #93c5fd; }
/* Range sliders in dark mode */
[data-theme="dark"] input[type="range"] { accent-color: #93c5fd; }
[data-theme="dark"] .form-range::-webkit-slider-thumb { background-color: #93c5fd; }
[data-theme="dark"] .form-range::-moz-range-thumb { background-color: #93c5fd; }
[data-theme="dark"] .form-range::-ms-thumb { background-color: #93c5fd; }
/* Suggestions dropdown contrast */
[data-theme="dark"] #sourceSuggestions .list-group-item,
[data-theme="dark"] #destSuggestions .list-group-item { background-color: #0f172a; color: #e5e7eb; border-color: #334155; }
[data-theme="dark"] #sourceSuggestions .list-group-item:hover,
[data-theme="dark"] #destSuggestions .list-group-item:hover { background-color: #111827; color: #ffffff; }

View File

@@ -1,9 +1,9 @@
services:
valhalla-scripted:
tty: true
container_name: valhalla
ports:
- 8002:8002
volumes:
- $PWD/custom_files:/custom_files
services:
valhalla-scripted:
tty: true
container_name: valhalla
ports:
- 8002:8002
volumes:
- $PWD/custom_files:/custom_files
image: ghcr.io/valhalla/valhalla-scripted:latest

BIN
bin/freemoto-web Normal file

Binary file not shown.

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
services:
freemoto-web:
build:
context: .
dockerfile: Dockerfile
container_name: freemoto-web
ports:
- "8080:8080"
env_file:
- .env
environment:
# Defaults are handled in the app, but you can override here if needed
# - PORT=8080
# - NOMINATIM_URL=https://nominatim.openstreetmap.org
# - NOMINATIM_USER_AGENT=FreeMoto/1.0 (+https://fm.ztsw.de/)
# - LOG_LEVEL=info
- VALHALLA_URL=http://valhalla:8002/route
depends_on:
- valhalla
valhalla:
image: ghcr.io/valhalla/valhalla-scripted:latest
container_name: valhalla
tty: true
ports:
- "8002:8002"
volumes:
# Mount a host directory for custom files and tile caching if desired
- ./custom_files:/custom_files
environment:
- tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf