Updated backend/frontend

This commit is contained in:
2025-09-17 10:39:49 +00:00
parent 58f701c1eb
commit 8ae9f1afdf
17 changed files with 676 additions and 383 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/

View File

@@ -1,36 +1,36 @@
name: Build and Publish Docker Image name: Build and Publish Docker Image
on: [workflow_dispatch] on: [workflow_dispatch]
jobs: jobs:
build-and-push: build-and-push:
strategy: strategy:
matrix: matrix:
arch: [amd64, arm64] arch: [amd64, arm64]
runs-on: ${{ matrix.arch }} runs-on: ${{ matrix.arch }}
#jobs: #jobs:
# build-and-push: # build-and-push:
# runs-on: ubuntu-latest # runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry - name: Log in to Gitea Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: git.ztsw.de registry: git.ztsw.de
username: ${{ secrets.CR_USERNAME }} username: ${{ secrets.CR_USERNAME }}
password: ${{ secrets.CR_PASSWORD }} password: ${{ secrets.CR_PASSWORD }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
platforms: linux/${{ matrix.arch }} platforms: linux/${{ matrix.arch }}
tags: git.ztsw.de/pedan/nosori/nosori:latest tags: git.ztsw.de/pedan/nosori/nosori:latest

92
.gitignore vendored
View File

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

View File

@@ -1,11 +1,43 @@
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
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
# 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 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
RUN adduser -S -D -H -h /nonexistent appuser && \
chown -R appuser:appuser /app
USER appuser
ENV PORT=8080
EXPOSE 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 MIT License
Copyright (c) 2025 pedan Copyright (c) 2025 pedan
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 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 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 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 copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions: following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software. portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 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 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 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 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. USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -24,6 +24,13 @@ Create a `.env` file in the project root:
``` ```
VALHALLA_URL=http://valhalla:8002/route VALHALLA_URL=http://valhalla:8002/route
PORT=8080 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 ### Local Development
@@ -59,6 +66,9 @@ services:
#environment: #environment:
# - VALHALLA_URL=http://10.200.0.15:8002/route # - VALHALLA_URL=http://10.200.0.15:8002/route
# - PORT=8080 # - PORT=8080
# - NOMINATIM_URL=https://nominatim.openstreetmap.org
# - NOMINATIM_USER_AGENT=FreeMoto/1.0 (+https://fm.ztsw.de/)
# - LOG_LEVEL=debug
valhalla-scripted: valhalla-scripted:
image: ghcr.io/valhalla/valhalla-scripted:latest image: ghcr.io/valhalla/valhalla-scripted:latest
ports: ports:
@@ -69,6 +79,17 @@ services:
- tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf - tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf
``` ```
### 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`.
## Customization ## Customization
- **Map UI:** Edit `static/index.html` and `static/main.js` - **Map UI:** Edit `static/index.html` and `static/main.js`

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 PORT=8080

View File

@@ -1,5 +1,5 @@
module pedan/freemoto module pedan/freemoto
go 1.24.5 go 1.24.5
require github.com/joho/godotenv v1.5.1 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=

View File

@@ -1,53 +1,249 @@
package main package main
import ( import (
"io" "context"
"log" "fmt"
"net/http" "io"
"os" "log"
"net/http"
"os"
"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
}
}
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() { func main() {
_ = godotenv.Load(".env") // Load .env file _ = godotenv.Load(".env") // Load .env file
valhallaURL := os.Getenv("VALHALLA_URL") valhallaURL := os.Getenv("VALHALLA_URL")
if valhallaURL == "" { if valhallaURL == "" {
valhallaURL = "http://10.200.0.15:8002/route" valhallaURL = "http://valhalla:8002/route"
} }
port := os.Getenv("PORT") nominatimBase := os.Getenv("NOMINATIM_URL")
if port == "" { if nominatimBase == "" {
port = "8080" 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")))) currentLogLevel = parseLogLevel(os.Getenv("LOG_LEVEL"))
http.HandleFunc("/route", func(w http.ResponseWriter, r *http.Request) {
proxyToValhalla(w, r, valhallaURL) port := os.Getenv("PORT")
}) if port == "" {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { port = "8080"
if r.URL.Path == "/" { }
http.ServeFile(w, r, "./static/index.html")
return http.Handle("/static/", withLogging(http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))))
} http.Handle("/healthz", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./static/"+r.URL.Path[1:]) w.WriteHeader(http.StatusOK)
}) _, _ = w.Write([]byte("ok"))
log.Printf("Listening on :%s", port) })))
http.ListenAndServe(":"+port, nil) http.Handle("/route", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyToValhalla(w, r, valhallaURL)
})))
// 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) {
if r.URL.Path == "/" {
http.ServeFile(w, r, "./static/index.html")
return
}
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) { func proxyToValhalla(w http.ResponseWriter, r *http.Request, valhallaURL string) {
req, _ := http.NewRequest("POST", valhallaURL, r.Body) // Create outbound request with caller context and preserve headers
req.Header = r.Header 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{} client := http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req) start := time.Now()
if err != nil { resp, err := client.Do(req)
http.Error(w, err.Error(), http.StatusBadGateway) if err != nil {
return if rid, ok := r.Context().Value(ctxReqID).(string); ok {
} logf(levelError, "rid=%s upstream=valhalla error=%v", rid, err)
defer resp.Body.Close() } else {
w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) logf(levelError, "upstream=valhalla error=%v", err)
io.Copy(w, resp.Body) }
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
} }

View File

@@ -1,6 +1,6 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
function geocode(query, callback) { 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(response => response.json())
.then(data => { .then(data => {
if (data && data.length > 0) { if (data && data.length > 0) {
@@ -16,7 +16,7 @@ document.addEventListener('DOMContentLoaded', function() {
navigator.geolocation.getCurrentPosition(function(position) { navigator.geolocation.getCurrentPosition(function(position) {
var lat = position.coords.latitude; var lat = position.coords.latitude;
var lon = position.coords.longitude; 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(response => response.json())
.then(data => { .then(data => {
document.getElementById(inputId).value = data.display_name || `${lat},${lon}`; document.getElementById(inputId).value = data.display_name || `${lat},${lon}`;
@@ -54,7 +54,7 @@ document.addEventListener('DOMContentLoaded', function() {
suggestionsBox.style.display = 'none'; suggestionsBox.style.display = 'none';
return; return;
} }
fetch('https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&q=' + encodeURIComponent(value)) fetch('/geocode?format=json&addressdetails=1&q=' + encodeURIComponent(value))
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
suggestionsBox.innerHTML = ''; suggestionsBox.innerHTML = '';

View File

@@ -1,171 +1,171 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>FreeMoto Navigation</title> <title>FreeMoto Navigation</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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 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" /> <link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
<style> <style>
html, body, #map { html, body, #map {
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
#map { #map {
min-height: 100vh; min-height: 100vh;
min-width: 100vw; min-width: 100vw;
z-index: 1; z-index: 1;
} }
.nav-panel { .nav-panel {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 100vw; width: 100vw;
max-width: 100vw; max-width: 100vw;
z-index: 1001; z-index: 1001;
background: rgba(255,255,255,0.98); background: rgba(255,255,255,0.98);
border-radius: 18px 18px 0 0; border-radius: 18px 18px 0 0;
box-shadow: 0 -2px 16px rgba(0,0,0,0.18); box-shadow: 0 -2px 16px rgba(0,0,0,0.18);
padding: 12px 8px 8px 8px; padding: 12px 8px 8px 8px;
overflow-y: auto; overflow-y: auto;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
} }
@media (min-width: 600px) { @media (min-width: 600px) {
.nav-panel { .nav-panel {
left: 24px; left: 24px;
width: 370px; width: 370px;
max-width: 420px; max-width: 420px;
border-radius: 18px; border-radius: 18px;
top: 24px; top: 24px;
bottom: auto; bottom: auto;
box-shadow: 0 2px 16px rgba(0,0,0,0.18); box-shadow: 0 2px 16px rgba(0,0,0,0.18);
} }
} }
#routeInfoCard { #routeInfoCard {
margin-bottom: 1rem; margin-bottom: 1rem;
font-size: 1.1rem; font-size: 1.1rem;
} }
#sourceSuggestions, #sourceSuggestions,
#destSuggestions { #destSuggestions {
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 0; left: 0;
z-index: 2000; z-index: 2000;
width: 100%; width: 100%;
max-height: 220px; max-height: 220px;
overflow-y: auto; overflow-y: auto;
border-radius: 0 0 0.5rem 0.5rem; border-radius: 0 0 0.5rem 0.5rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.08); box-shadow: 0 4px 12px rgba(0,0,0,0.08);
} }
.section-title { .section-title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
margin-top: 1rem; margin-top: 1rem;
color: #495057; color: #495057;
} }
.card-icon { .card-icon {
font-size: 2rem; font-size: 2rem;
margin-right: 0.5rem; margin-right: 0.5rem;
vertical-align: middle; vertical-align: middle;
} }
.btn-lg, .form-control { .btn-lg, .form-control {
font-size: 1.2rem; font-size: 1.2rem;
padding: 0.7rem 1rem; padding: 0.7rem 1rem;
} }
.form-check-label { .form-check-label {
font-size: 1rem; font-size: 1rem;
} }
.input-group-text svg, .input-group-text svg,
.btn svg { .btn svg {
vertical-align: middle; vertical-align: middle;
} }
</style> </style>
</head> </head>
<body> <body>
<div id="map"></div> <div id="map"></div>
<div class="nav-panel shadow-lg"> <div class="nav-panel shadow-lg">
<div class="text-center mb-2"> <div class="text-center mb-2">
<span class="card-icon">🏍️</span> <span class="card-icon">🏍️</span>
<span class="fs-4 fw-bold text-primary">FreeMoto</span> <span class="fs-4 fw-bold text-primary">FreeMoto</span>
</div> </div>
<div id="routeInfoCard" class="alert alert-info d-none" role="alert"></div> <div id="routeInfoCard" class="alert alert-info d-none" role="alert"></div>
<div class="mb-2"> <div class="mb-2">
<div class="section-title">Route</div> <div class="section-title">Route</div>
<div class="input-group mb-2 position-relative"> <div class="input-group mb-2 position-relative">
<span class="input-group-text" title="Start"> <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" 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> </span>
<input type="text" class="form-control" id="sourceInput" placeholder="Start address" autocomplete="off"> <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 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> <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> </button>
<div id="sourceSuggestions" class="list-group position-absolute w-100" style="z-index: 2000;"></div> <div id="sourceSuggestions" class="list-group position-absolute w-100" style="z-index: 2000;"></div>
</div> </div>
<div class="input-group mb-2 position-relative"> <div class="input-group mb-2 position-relative">
<span class="input-group-text" title="Destination"> <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" 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> </span>
<input type="text" class="form-control" id="destInput" placeholder="Destination address" autocomplete="off"> <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 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> <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> </button>
<div id="destSuggestions" class="list-group position-absolute w-100" style="z-index: 2000;"></div> <div id="destSuggestions" class="list-group position-absolute w-100" style="z-index: 2000;"></div>
</div> </div>
<div class="d-grid gap-2 mb-2"> <div class="d-grid gap-2 mb-2">
<button type="button" id="plotRouteBtn" class="btn btn-success btn-lg">Plot Route</button> <button type="button" id="plotRouteBtn" class="btn btn-success btn-lg">Plot Route</button>
</div> </div>
</div> </div>
<div class="section-title">Route Options</div> <div class="section-title">Route Options</div>
<form> <form>
<div class="row g-2"> <div class="row g-2">
<div class="col-12"> <div class="col-12">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="avoidHighways"> <input class="form-check-input" type="checkbox" id="avoidHighways">
<label class="form-check-label" for="avoidHighways">Avoid freeways</label> <label class="form-check-label" for="avoidHighways">Avoid freeways</label>
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="useShortest"> <input class="form-check-input" type="checkbox" id="useShortest">
<label class="form-check-label" for="useShortest">Shortest route</label> <label class="form-check-label" for="useShortest">Shortest route</label>
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="avoidTollRoads"> <input class="form-check-input" type="checkbox" id="avoidTollRoads">
<label class="form-check-label" for="avoidTollRoads">Avoid tolls</label> <label class="form-check-label" for="avoidTollRoads">Avoid tolls</label>
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="avoidFerries"> <input class="form-check-input" type="checkbox" id="avoidFerries">
<label class="form-check-label" for="avoidFerries">Avoid ferries</label> <label class="form-check-label" for="avoidFerries">Avoid ferries</label>
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="avoidUnpaved"> <input class="form-check-input" type="checkbox" id="avoidUnpaved">
<label class="form-check-label" for="avoidUnpaved">Avoid unpaved</label> <label class="form-check-label" for="avoidUnpaved">Avoid unpaved</label>
</div> </div>
</div> </div>
</div> </div>
</form> </form>
<div class="d-flex justify-content-between mt-3 mb-2"> <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> <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"> <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="zoomInBtn" title="Zoom in">+</button>
<button type="button" class="btn btn-outline-secondary btn-lg" id="zoomOutBtn" title="Zoom out"></button> <button type="button" class="btn btn-outline-secondary btn-lg" id="zoomOutBtn" title="Zoom out"></button>
</div> </div>
</div> </div>
<div class="d-grid gap-2 mb-2"> <div class="d-grid gap-2 mb-2">
<button type="button" id="exportGpxBtn" class="btn btn-warning btn-lg">Export GPX</button> <button type="button" id="exportGpxBtn" class="btn btn-warning btn-lg">Export GPX</button>
</div> </div>
</div> </div>
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
<script src="/main.js"></script> <script src="/main.js"></script>
<script src="/route.js"></script> <script src="/route.js"></script>
<script src="/geolocate.js"></script> <script src="/geolocate.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,37 +1,37 @@
// Center on a default point // 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 }).setView([53.866237, 10.676289], 18);
// Add OSM tiles // Add OSM tiles
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map); }).addTo(map);
var userIcon = L.icon({ var userIcon = L.icon({
iconUrl: '/maps-arrow.svg', // Add a motorcycle icon to your static folder iconUrl: '/maps-arrow.svg', // Add a motorcycle icon to your static folder
iconSize: [40, 40] iconSize: [40, 40]
}); });
// Get users location // Get users location
if (navigator.geolocation) { if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position) { navigator.geolocation.getCurrentPosition(function(position) {
var lat = position.coords.latitude; var lat = position.coords.latitude;
var lon = position.coords.longitude; var lon = position.coords.longitude;
map.setView([lat, lon], 14); map.setView([lat, lon], 14);
L.marker([lat, lon], {icon: userIcon}).addTo(map).bindPopup('You are here!'); L.marker([lat, lon], {icon: userIcon}).addTo(map).bindPopup('You are here!');
}); });
} }
// Custom Bootstrap zoom controls // Custom Bootstrap zoom controls
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var zoomInBtn = document.getElementById('zoomInBtn'); var zoomInBtn = document.getElementById('zoomInBtn');
var zoomOutBtn = document.getElementById('zoomOutBtn'); var zoomOutBtn = document.getElementById('zoomOutBtn');
if (zoomInBtn && zoomOutBtn) { if (zoomInBtn && zoomOutBtn) {
zoomInBtn.addEventListener('click', function() { zoomInBtn.addEventListener('click', function() {
map.zoomIn(); map.zoomIn();
}); });
zoomOutBtn.addEventListener('click', function() { zoomOutBtn.addEventListener('click', function() {
map.zoomOut(); map.zoomOut();
}); });
} }
}); });

View File

@@ -10,26 +10,25 @@ document.addEventListener('DOMContentLoaded', function() {
function calculateRoute() { function calculateRoute() {
if (points.length === 2) { if (points.length === 2) {
var options = { var moto = {};
"exclude_restrictions": true // Avoid highways -> lower highway usage weight
};
if (avoidHighwaysCheckbox && avoidHighwaysCheckbox.checked) { if (avoidHighwaysCheckbox && avoidHighwaysCheckbox.checked) {
options.use_highways = 0; moto.use_highways = 0.0; // 0..1 (0 avoids, 1 prefers)
}
if (useShortestCheckbox && useShortestCheckbox.checked) {
options.use_shortest = true;
}
if (avoidTollRoadsCheckbox && avoidTollRoadsCheckbox.checked) {
options.avoid_toll = true;
} }
// Avoid ferries -> lower ferry usage weight
if (avoidFerriesCheckbox && avoidFerriesCheckbox.checked) { 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) { 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 }; var costing_options = { motorcycle: moto };
var requestBody = { var requestBody = {
locations: [ locations: [
@@ -37,8 +36,12 @@ document.addEventListener('DOMContentLoaded', function() {
{ lat: points[1].lat, lon: points[1].lng } { lat: points[1].lat, lon: points[1].lng }
], ],
costing: "motorcycle", 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', { fetch('/route', {
method: 'POST', method: 'POST',
@@ -50,7 +53,7 @@ document.addEventListener('DOMContentLoaded', function() {
var leg = data.trip && data.trip.legs && data.trip.legs[0]; var leg = data.trip && data.trip.legs && data.trip.legs[0];
var infoCard = document.getElementById('routeInfoCard'); var infoCard = document.getElementById('routeInfoCard');
if (leg && leg.summary && typeof leg.summary.length === 'number' && typeof leg.summary.time === 'number') { if (leg && leg.summary && typeof leg.summary.length === 'number' && typeof leg.summary.time === 'number') {
var distanceKm = (leg.summary.length / 1000).toFixed(1); // meters to km var distanceKm = (leg.summary.length).toFixed(1); // already in km
var durationMin = Math.round(leg.summary.time / 60); // seconds to minutes var durationMin = Math.round(leg.summary.time / 60); // seconds to minutes
var info = `<strong>Distance:</strong> ${distanceKm} km<br> var info = `<strong>Distance:</strong> ${distanceKm} km<br>
<strong>Estimated Time:</strong> ${durationMin} min<br> <strong>Estimated Time:</strong> ${durationMin} min<br>
@@ -201,7 +204,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
function reverseGeocode(lat, lon, inputId) { function reverseGeocode(lat, lon, inputId) {
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(response => response.json())
.then(data => { .then(data => {
var input = document.getElementById(inputId); var input = document.getElementById(inputId);

View File

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

BIN
bin/freemoto-web Normal file

Binary file not shown.