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

92
.gitignore vendored
View File

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

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
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
CMD ["go", "run", "main.go"]
# Simple healthcheck against /healthz
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:${PORT}/healthz || exit 1
ENTRYPOINT ["/app/web/freemoto-web"]

36
LICENSE
View File

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

View File

@@ -24,6 +24,13 @@ Create a `.env` file in the project root:
```
VALHALLA_URL=http://valhalla:8002/route
PORT=8080
# Optional: Nominatim base URL (defaults to https://nominatim.openstreetmap.org)
NOMINATIM_URL=https://nominatim.openstreetmap.org
# Recommended: configure a descriptive User-Agent per Nominatim usage policy
# Example format: "AppName/Version (+contact-url-or-email)"
NOMINATIM_USER_AGENT=FreeMoto/1.0 (+https://fm.ztsw.de/)
# Optional: log level (debug, info, warn, error). Default: info
LOG_LEVEL=info
```
### Local Development
@@ -59,6 +66,9 @@ services:
#environment:
# - VALHALLA_URL=http://10.200.0.15:8002/route
# - PORT=8080
# - NOMINATIM_URL=https://nominatim.openstreetmap.org
# - NOMINATIM_USER_AGENT=FreeMoto/1.0 (+https://fm.ztsw.de/)
# - LOG_LEVEL=debug
valhalla-scripted:
image: ghcr.io/valhalla/valhalla-scripted:latest
ports:
@@ -69,6 +79,17 @@ services:
- 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
- **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

View File

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

View File

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

View File

@@ -1,53 +1,249 @@
package main
import (
"io"
"log"
"net/http"
"os"
"context"
"fmt"
"io"
"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() {
_ = godotenv.Load(".env") // Load .env file
_ = godotenv.Load(".env") // Load .env file
valhallaURL := os.Getenv("VALHALLA_URL")
if valhallaURL == "" {
valhallaURL = "http://10.200.0.15:8002/route"
}
valhallaURL := os.Getenv("VALHALLA_URL")
if valhallaURL == "" {
valhallaURL = "http://valhalla:8002/route"
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
nominatimBase := os.Getenv("NOMINATIM_URL")
if nominatimBase == "" {
nominatimBase = "https://nominatim.openstreetmap.org"
}
nominatimUA := os.Getenv("NOMINATIM_USER_AGENT")
if nominatimUA == "" {
nominatimUA = "FreeMoto/1.0 (+https://fm.ztsw.de/)"
}
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))
http.HandleFunc("/route", func(w http.ResponseWriter, r *http.Request) {
proxyToValhalla(w, r, valhallaURL)
})
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.ServeFile(w, r, "./static/index.html")
return
}
http.ServeFile(w, r, "./static/"+r.URL.Path[1:])
})
log.Printf("Listening on :%s", port)
http.ListenAndServe(":"+port, nil)
currentLogLevel = parseLogLevel(os.Getenv("LOG_LEVEL"))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
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) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})))
http.Handle("/route", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyToValhalla(w, r, valhallaURL)
})))
// 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) {
req, _ := http.NewRequest("POST", valhallaURL, r.Body)
req.Header = r.Header
// Create outbound request with caller context and preserve headers
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, valhallaURL, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Header = r.Header.Clone()
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(w, resp.Body)
client := http.Client{Timeout: 15 * time.Second}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
logf(levelError, "rid=%s upstream=valhalla error=%v", rid, err)
} else {
logf(levelError, "upstream=valhalla error=%v", err)
}
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Propagate relevant headers and status code
for key, values := range resp.Header {
// Optionally filter headers; here we forward common ones
if key == "Content-Type" || key == "Content-Encoding" || key == "Cache-Control" || key == "Vary" {
for _, v := range values {
w.Header().Add(key, v)
}
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
logf(levelDebug, "rid=%s upstream=valhalla status=%d dur=%s", rid, resp.StatusCode, time.Since(start))
} else {
logf(levelDebug, "upstream=valhalla status=%d dur=%s", resp.StatusCode, time.Since(start))
}
}
// proxyToNominatimGET proxies GET requests to Nominatim endpoints (search/reverse),
// sets a descriptive User-Agent, and enforces a timeout.
func proxyToNominatimGET(w http.ResponseWriter, r *http.Request, target string, userAgent string) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Preserve original query parameters
url := target
if rq := r.URL.RawQuery; rq != "" {
url = url + "?" + rq
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Set a proper User-Agent per Nominatim usage policy
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json")
client := http.Client{Timeout: 10 * time.Second}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
logf(levelError, "rid=%s upstream=nominatim url=%s error=%v", rid, url, err)
} else {
logf(levelError, "upstream=nominatim url=%s error=%v", url, err)
}
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Forward selected headers and status code
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
logf(levelDebug, "rid=%s upstream=nominatim status=%d dur=%s", rid, resp.StatusCode, time.Since(start))
} else {
logf(levelDebug, "upstream=nominatim status=%d dur=%s", resp.StatusCode, time.Since(start))
}
}
// withLogging wraps handlers to add request ID, status, duration logging
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := fmt.Sprintf("%x", atomic.AddUint64(&reqIDCounter, 1))
ctx := context.WithValue(r.Context(), ctxReqID, rid)
r = r.WithContext(ctx)
lrw := &loggingResponseWriter{ResponseWriter: w, status: 200}
start := time.Now()
next.ServeHTTP(lrw, r)
dur := time.Since(start)
logf(levelInfo, "rid=%s method=%s path=%s status=%d bytes=%d dur=%s ua=\"%s\" ip=%s",
rid, r.Method, r.URL.Path, lrw.status, lrw.bytes, dur, r.UserAgent(), clientIP(r))
})
}
type loggingResponseWriter struct {
http.ResponseWriter
status int
bytes int
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.status = code
lrw.ResponseWriter.WriteHeader(code)
}
func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
n, err := lrw.ResponseWriter.Write(b)
lrw.bytes += n
return n, err
}
func clientIP(r *http.Request) string {
// honor X-Forwarded-For if present (first IP)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return xff
}
return r.RemoteAddr
}

View File

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

View File

@@ -1,171 +1,171 @@
<!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);
}
}
#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>
</div>
<div id="routeInfoCard" class="alert alert-info d-none" role="alert"></div>
<div class="mb-2">
<div class="section-title">Route</div>
<div class="input-group mb-2 position-relative">
<span class="input-group-text" title="Start">
<svg xmlns="http://www.w3.org/2000/svg" width="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>
<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>
</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>
</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>
</div>
<div class="d-grid gap-2 mb-2">
<button type="button" id="plotRouteBtn" class="btn btn-success btn-lg">Plot Route</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>
</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>
</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>
</html>
<!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);
}
}
#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>
</div>
<div id="routeInfoCard" class="alert alert-info d-none" role="alert"></div>
<div class="mb-2">
<div class="section-title">Route</div>
<div class="input-group mb-2 position-relative">
<span class="input-group-text" title="Start">
<svg xmlns="http://www.w3.org/2000/svg" width="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>
<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>
</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>
</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>
</div>
<div class="d-grid gap-2 mb-2">
<button type="button" id="plotRouteBtn" class="btn btn-success btn-lg">Plot Route</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>
</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>
</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>
</html>

View File

@@ -1,37 +1,37 @@
// 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 }).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();
});
}
});

View File

@@ -10,26 +10,25 @@ document.addEventListener('DOMContentLoaded', function() {
function calculateRoute() {
if (points.length === 2) {
var options = {
"exclude_restrictions": true
};
var moto = {};
// Avoid highways -> lower highway usage weight
if (avoidHighwaysCheckbox && avoidHighwaysCheckbox.checked) {
options.use_highways = 0;
}
if (useShortestCheckbox && useShortestCheckbox.checked) {
options.use_shortest = true;
}
if (avoidTollRoadsCheckbox && avoidTollRoadsCheckbox.checked) {
options.avoid_toll = true;
moto.use_highways = 0.0; // 0..1 (0 avoids, 1 prefers)
}
// Avoid ferries -> lower ferry usage weight
if (avoidFerriesCheckbox && avoidFerriesCheckbox.checked) {
options.avoid_ferry = true;
moto.use_ferry = 0.0; // 0..1
}
// Avoid unpaved -> exclude unpaved roads entirely
if (avoidUnpavedCheckbox && avoidUnpavedCheckbox.checked) {
options.avoid_unpaved = true;
moto.exclude_unpaved = true;
}
// Avoid tolls -> exclude tolls
if (avoidTollRoadsCheckbox && avoidTollRoadsCheckbox.checked) {
moto.exclude_tolls = true;
}
var costing_options = { motorcycle: options };
var costing_options = { motorcycle: moto };
var requestBody = {
locations: [
@@ -37,8 +36,12 @@ document.addEventListener('DOMContentLoaded', function() {
{ lat: points[1].lat, lon: points[1].lng }
],
costing: "motorcycle",
costing_options: costing_options
costing_options: costing_options,
units: "kilometers"
};
if (useShortestCheckbox && useShortestCheckbox.checked) {
requestBody.shortest = true; // top-level shortest flag
}
fetch('/route', {
method: 'POST',
@@ -50,7 +53,7 @@ document.addEventListener('DOMContentLoaded', function() {
var leg = data.trip && data.trip.legs && data.trip.legs[0];
var infoCard = document.getElementById('routeInfoCard');
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 info = `<strong>Distance:</strong> ${distanceKm} km<br>
<strong>Estimated Time:</strong> ${durationMin} min<br>
@@ -201,7 +204,7 @@ document.addEventListener('DOMContentLoaded', function() {
});
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(data => {
var input = document.getElementById(inputId);

View File

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

BIN
bin/freemoto-web Normal file

Binary file not shown.