complete overhaul
Some checks failed
build-and-push / docker (push) Failing after 10m36s

This commit is contained in:
2025-09-18 00:23:21 +02:00
parent 6afcfaa5b3
commit a2743dd7fb
11 changed files with 1201 additions and 271 deletions

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,22 +0,0 @@
name: Build and Publish Docker Image
on: [workflow_dispatch]
jobs:
build-amd64:
runs-on: amd64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build AMD64
run: |
docker build -t git.ztsw.de/pedan/freemoto/freemoto-web:amd64 .
docker push git.ztsw.de/pedan/freemoto/freemoto-web:amd64
build-arm64:
runs-on: arm64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build ARM64
run: |
docker build -t git.ztsw.de/pedan/freemoto/freemoto-web:arm64 .
docker push git.ztsw.de/pedan/freemoto/freemoto-web:arm64

2
.gitignore vendored
View File

@@ -44,3 +44,5 @@ backend/custom_tiles
# Static build output
dist/
build/
custom_tiles/
custom_files/

View File

@@ -28,9 +28,10 @@ RUN apk add --no-cache ca-certificates tzdata wget
COPY --from=builder /out/freemoto-web /app/web/freemoto-web
COPY app/web/static/ /app/web/static/
# Use non-root user
RUN adduser -S -D -H -h /nonexistent appuser && \
chown -R appuser:appuser /app
# 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

112
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
@@ -42,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:
@@ -79,6 +123,8 @@ services:
- tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf
```
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`.
### 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/)`.
@@ -90,12 +136,60 @@ services:
- 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`.
## Customization
## UI Overview
- **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`
- 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,12 +1,14 @@
package main
import (
"compress/gzip"
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
@@ -89,7 +91,8 @@ func main() {
port = "8080"
}
http.Handle("/static/", withLogging(http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))))
// 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"))
@@ -105,10 +108,35 @@ func main() {
proxyToNominatimGET(w, r, nominatimBase+"/reverse", nominatimUA)
})))
http.Handle("/", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Serve index
if r.URL.Path == "/" {
http.ServeFile(w, r, "./static/index.html")
// 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)
@@ -246,4 +274,78 @@ func clientIP(r *http.Request) string {
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,171 +1,263 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>FreeMoto Navigation</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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;
}
.nav-panel {
position: fixed;
bottom: 0;
left: 0;
width: 100vw;
max-width: 100vw;
z-index: 1001;
background: rgba(255,255,255,0.98);
border-radius: 18px 18px 0 0;
box-shadow: 0 -2px 16px rgba(0,0,0,0.18);
padding: 12px 8px 8px 8px;
overflow-y: auto;
transition: box-shadow 0.2s;
}
@media (min-width: 600px) {
.nav-panel {
left: 24px;
width: 370px;
max-width: 420px;
border-radius: 18px;
top: 24px;
bottom: auto;
box-shadow: 0 2px 16px rgba(0,0,0,0.18);
<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" />
<style>
:root {
--panel-radius: 18px;
--glass-bg: rgba(255,255,255,0.82);
--glass-border: rgba(16,24,40,0.08);
}
}
#routeInfoCard {
margin-bottom: 1rem;
font-size: 1.1rem;
}
#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);
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.5rem;
margin-top: 1rem;
color: #495057;
}
.card-icon {
font-size: 2rem;
margin-right: 0.5rem;
vertical-align: middle;
}
.btn-lg, .form-control {
font-size: 1.2rem;
padding: 0.7rem 1rem;
}
.form-check-label {
font-size: 1rem;
}
.input-group-text svg,
.btn svg {
vertical-align: middle;
}
</style>
</head>
<body>
<div id="map"></div>
<div class="nav-panel shadow-lg">
<div class="text-center mb-2">
<span class="card-icon">🏍️</span>
<span class="fs-4 fw-bold text-primary">FreeMoto</span>
html, body { height: 100%; }
body { margin: 0; overflow: hidden; background: linear-gradient(180deg, #f2f4f7, #eef2f6); }
#map { position: absolute; inset: 0; z-index: 1; }
/* Top app bar - new gradient look */
.appbar {
position: fixed; inset: 0 0 auto 0; z-index: 1002;
background: linear-gradient(90deg, #0ea5e9, #6366f1);
color: #fff;
}
.appbar .navbar-brand { color: #fff; }
.appbar .btn { color: #fff; border-color: rgba(255,255,255,0.6); }
/* Floating dock panel */
.nav-panel {
position: fixed; left: 10px; right: 10px; bottom: 10px; z-index: 1001;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(10px) saturate(160%);
border-radius: 999px; /* pill */
box-shadow: 0 10px 30px rgba(2, 8, 20, 0.1);
padding: 8px 12px;
}
.nav-panel .form-control, .nav-panel .btn { border-radius: 999px; }
.nav-panel .input-group > .input-group-text { border-radius: 999px 0 0 999px; }
@media (min-width: 768px) {
.nav-panel {
top: 86px; bottom: auto; left: 24px; right: auto; width: 520px;
border-radius: var(--panel-radius);
padding: 14px;
}
.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); }
}
.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: 220px; overflow: auto; border-radius: 12px; }
/* Directions bottom sheet - improved */
.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; }
.sheet .handle { width: 44px; height: 5px; border-radius: 999px; 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-group { position: fixed; right: 14px; bottom: 86px; z-index: 1002; display: flex; flex-direction: column; gap: 10px; }
/* Dracula-like dark theme */
[data-theme="dark"] body { background: linear-gradient(180deg, #1e1f29, #282a36); color: #f8f8f2; }
[data-theme="dark"] .appbar { background: linear-gradient(90deg, #44475a, #6272a4); color: #f8f8f2; }
[data-theme="dark"] .appbar .navbar-brand { color: #f8f8f2; }
[data-theme="dark"] .appbar .btn { color: #f8f8f2; border-color: rgba(248,248,242,0.6); }
[data-theme="dark"] .nav-panel { background: rgba(40, 42, 54, 0.85); border-color: rgba(248,248,242,0.08); box-shadow: 0 10px 30px rgba(0,0,0,0.4); }
[data-theme="dark"] .form-control, [data-theme="dark"] .input-group-text { background: #282a36; color: #f8f8f2; border-color: #44475a; }
[data-theme="dark"] .btn-outline-secondary { color: #f8f8f2; border-color: #8be9fd; }
[data-theme="dark"] .btn-primary { background-color: #6272a4; border-color: #6272a4; }
[data-theme="dark"] .btn-warning { background-color: #ffb86c; border-color: #ffb86c; color: #1e1f29; }
[data-theme="dark"] .badge.text-bg-light { background-color: #44475a !important; color: #f8f8f2 !important; }
[data-theme="dark"] .card, [data-theme="dark"] .sheet-card { background: #282a36; color: #f8f8f2; }
[data-theme="dark"] .list-group-item { background: #282a36; color: #f8f8f2; border-color: #44475a; }
[data-theme="dark"] .sheet-header { background: #282a36; border-bottom-color: #44475a; }
[data-theme="dark"] .handle { background: #44475a; }
</style>
</head>
<body>
<!-- App Bar -->
<nav class="appbar navbar navbar-light px-3">
<span class="navbar-brand mb-0 h1 d-flex align-items-center gap-2">
<span style="font-size: 1.25rem">🏍️</span>
<span>FreeMoto</span>
</span>
<div class="d-flex align-items-center gap-3 flex-wrap justify-content-end">
<div class="d-flex align-items-center gap-2" title="Prefer twisty roads (less highways)">
<label for="twistiness" class="form-label mb-0 text-white-50 d-none d-md-inline">Twistiness</label>
<input type="range" class="form-range" id="twistiness" min="0" max="100" value="50" style="width:140px">
<span id="twistinessValue" class="badge rounded-pill text-bg-light">50</span>
</div>
<div class="d-flex align-items-center gap-2" title="Prefer highways (faster)">
<label for="highwayPref" class="form-label mb-0 text-white-50 d-none d-md-inline">Highways</label>
<input type="range" class="form-range" id="highwayPref" min="0" max="100" value="50" style="width:120px">
<span id="highwayPrefValue" class="badge rounded-pill text-bg-light">50</span>
</div>
<span id="summaryPill" class="badge rounded-pill text-bg-light d-none"></span>
<button class="btn btn-outline-secondary btn-sm" id="clearRouteBtn" title="Clear route">Clear</button>
<button class="btn btn-outline-secondary btn-sm" id="zoomOutBtn" title="Zoom out"></button>
<button class="btn btn-outline-secondary btn-sm" id="zoomInBtn" title="Zoom in">+</button>
<button class="btn btn-outline-secondary btn-sm" id="themeToggle" title="Toggle dark mode">🌙</button>
</div>
</nav>
<div id="map"></div>
<!-- Floating actions (mobile) -->
<div class="floating-group d-md-none">
<button class="btn btn-dark export-gpx" title="Export GPX">GPX</button>
<button class="btn btn-primary" id="plotRouteBtn" title="Plot Route">Route</button>
</div>
<div id="routeInfoCard" class="alert alert-info d-none" role="alert"></div>
<div class="mb-2">
<div class="section-title">Route</div>
<!-- Control Panel -->
<div class="nav-panel">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center gap-2">
<span class="badge text-bg-primary">Beta</span>
<span class="fw-semibold">Motorcycle route 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="alert"></div>
<div class="section-title mb-1">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="20" height="20" 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>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#0d6efd" 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="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="20" height="20" 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>
<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 class="input-group mb-2 position-relative">
<span class="input-group-text" title="Destination">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#dc3545" 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="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="20" height="20" 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>
<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 class="d-grid gap-2 mb-2">
<button type="button" id="plotRouteBtn" class="btn btn-success btn-lg">Plot Route</button>
<div class="d-grid gap-2 d-none d-md-grid mb-2">
<button type="button" id="plotRouteBtn" class="btn btn-success">Plot Route</button>
</div>
<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">
Route 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">
<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">
<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">
<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>
</div>
</div>
</div>
</div>
</div>
<div class="mb-2">
<div class="section-title">Waypoints</div>
<ul id="waypointList" class="list-group small"></ul>
</div>
<div class="d-flex gap-2 d-none d-md-flex">
<button onclick="resetMarkers()" class="btn btn-outline-primary">Reset Points</button>
<button type="button" class="btn btn-warning export-gpx">Export GPX</button>
</div>
</div>
<div class="section-title">Route Options</div>
<form>
<div class="row g-2">
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="avoidHighways">
<label class="form-check-label" for="avoidHighways">Avoid freeways</label>
<!-- Directions Bottom Sheet -->
<div id="directionsSheet" class="sheet">
<div class="card shadow-lg sheet-card">
<div class="card-body p-0">
<div class="handle"></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>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="useShortest">
<label class="form-check-label" for="useShortest">Shortest route</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="avoidTollRoads">
<label class="form-check-label" for="avoidTollRoads">Avoid tolls</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="avoidFerries">
<label class="form-check-label" for="avoidFerries">Avoid ferries</label>
</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="avoidUnpaved">
<label class="form-check-label" for="avoidUnpaved">Avoid unpaved</label>
<div class="sheet-body px-2">
<ul class="list-group list-group-flush mt-2" id="directionsList"></ul>
</div>
</div>
</div>
</form>
<div class="d-flex justify-content-between mt-3 mb-2">
<button onclick="resetMarkers()" class="btn btn-primary btn-lg flex-fill me-2">Reset Points</button>
<div class="btn-group flex-fill" role="group" aria-label="Zoom controls">
<button type="button" class="btn btn-outline-secondary btn-lg" id="zoomInBtn" title="Zoom in">+</button>
<button type="button" class="btn btn-outline-secondary btn-lg" id="zoomOutBtn" title="Zoom out"></button>
</div>
</div>
<div class="d-grid gap-2 mb-2">
<button type="button" id="exportGpxBtn" class="btn btn-warning btn-lg">Export GPX</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>
<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,37 +1,176 @@
// Center on a default point
var map = L.map('map', { zoomControl: false }).setView([53.866237, 10.676289], 18);
// Add OSM tiles
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
var userIcon = L.icon({
iconUrl: '/maps-arrow.svg', // Add a motorcycle icon to your static folder
iconSize: [40, 40]
});
// Get users location
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position) {
var lat = position.coords.latitude;
var lon = position.coords.longitude;
map.setView([lat, lon], 14);
L.marker([lat, lon], {icon: userIcon}).addTo(map).bindPopup('You are here!');
});
}
// Custom Bootstrap zoom controls
document.addEventListener('DOMContentLoaded', function() {
var zoomInBtn = document.getElementById('zoomInBtn');
var zoomOutBtn = document.getElementById('zoomOutBtn');
if (zoomInBtn && zoomOutBtn) {
zoomInBtn.addEventListener('click', function() {
map.zoomIn();
});
zoomOutBtn.addEventListener('click', function() {
map.zoomOut();
});
}
// Center on a default point
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', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
var userIcon = L.icon({
iconUrl: '/maps-arrow.svg', // Add a motorcycle icon to your static folder
iconSize: [40, 40]
});
// Get users location
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position) {
var lat = position.coords.latitude;
var lon = position.coords.longitude;
map.setView([lat, lon], 14);
L.marker([lat, lon], {icon: userIcon}).addTo(map).bindPopup('You are here!');
});
}
// Custom Bootstrap zoom controls
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');
if (zoomInBtn && zoomOutBtn) {
zoomInBtn.addEventListener('click', function() {
map.zoomIn();
});
zoomOutBtn.addEventListener('click', 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 (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');
});
}
});

View File

@@ -7,9 +7,140 @@ 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');
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 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));
// Bearings spaced around circle with a random offset to vary loops
var base = (Math.random() * 360) | 0;
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
var bearings = noRepeat
? [0, 72, 144, 216, 288].map(function(d){ return base + d; }) // 5 WPs ~ evenly spaced to reduce overlap
: [45, 165, 285].map(function(d){ return base + d; });
var wps = bearings.map(function(b){ return destPoint(start.lat, start.lng, r, b); });
// Ensure endpoints list: start -> wps -> start
var routePts = [start].concat(wps).concat([start]);
return routePts;
}
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 pts = generateRoundTripWaypoints(start);
pts.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) {
if (points.length >= 2) {
var moto = {};
// Avoid highways -> lower highway usage weight
if (avoidHighwaysCheckbox && avoidHighwaysCheckbox.checked) {
@@ -28,13 +159,31 @@ document.addEventListener('DOMContentLoaded', function() {
moto.exclude_tolls = true;
}
// 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 (_) {}
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,
units: "kilometers"
@@ -50,47 +199,256 @@ document.addEventListener('DOMContentLoaded', function() {
})
.then(response => response.json())
.then(data => {
var leg = data.trip && data.trip.legs && data.trip.legs[0];
var legs = (data.trip && Array.isArray(data.trip.legs)) ? data.trip.legs : [];
var infoCard = document.getElementById('routeInfoCard');
if (leg && leg.summary && typeof leg.summary.length === 'number' && typeof leg.summary.time === 'number') {
var distanceKm = (leg.summary.length).toFixed(1); // already in km
var durationMin = Math.round(leg.summary.time / 60); // seconds to minutes
// 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);
}
var latlngs = decodePolyline6(leg.shape);
// 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 (_) {}
}
// Reverse geocode and fill address field
if (points.length === 1) {
reverseGeocode(e.latlng.lat, e.latlng.lng, 'sourceInput');
} else if (points.length === 2) {
reverseGeocode(e.latlng.lat, e.latlng.lng, 'destInput');
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
[
@@ -156,10 +514,16 @@ 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');
@@ -169,6 +533,16 @@ document.addEventListener('DOMContentLoaded', function() {
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
@@ -208,12 +582,15 @@ document.addEventListener('DOMContentLoaded', function() {
.then(response => response.json())
.then(data => {
var input = document.getElementById(inputId);
if (data && data.address) {
// Use the same format as your autocomplete
input.value = formatAddress(data);
} else {
input.value = `${lat}, ${lon}`;
}
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;
});
@@ -224,18 +601,35 @@ document.addEventListener('DOMContentLoaded', function() {
alert("No route to export.");
return;
}
let gpx =
`<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="FreeMoto" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<name>FreeMoto Route</name>
<trkseg>
${latlngs.map(pt => ` <trkpt lat="${pt[0]}" lon="${pt[1]}"></trkpt>`).join('\n')}
</trkseg>
</trk>
</gpx>`;
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');
}
let blob = new Blob([gpx], {type: "application/gpx+xml"});
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
@@ -244,7 +638,7 @@ ${latlngs.map(pt => ` <trkpt lat="${pt[0]}" lon="${pt[1]}"></trkpt>`).join(
} else {
let a = document.createElement('a');
a.href = url;
a.download = "freemoto-route.gpx";
a.download = 'freemoto-route.gpx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -252,13 +646,34 @@ ${latlngs.map(pt => ` <trkpt lat="${pt[0]}" lon="${pt[1]}"></trkpt>`).join(
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
document.getElementById('exportGpxBtn').addEventListener('click', function() {
if (routePolyline) {
// routePolyline.getLatLngs() returns array of LatLng objects
let latlngs = routePolyline.getLatLngs().map(ll => [ll.lat, ll.lng]);
exportRouteAsGPX(latlngs);
} else {
alert("No route to export.");
}
// 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(); };
});

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
- ${PWD}/custom_files:/custom_files
environment:
- tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf