Compare commits
12 Commits
8b4a8e9539
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c37ca4e8b4 | |||
| c8aa3fb3b2 | |||
| a2743dd7fb | |||
| 6afcfaa5b3 | |||
| ca55fb7e9c | |||
| 4f3d350260 | |||
| 0a21d5534d | |||
| 8ae9f1afdf | |||
| 58f701c1eb | |||
| d666d2203f | |||
| b35d22dd29 | |||
|
|
b01743d9c4 |
4
.devcontainer/devcontainer.json
Normal file
4
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "go-devcontainer",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/go:1-1.24-bookworm"
|
||||||
|
}
|
||||||
37
.dockerignore
Normal file
37
.dockerignore
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Ignore VCS and IDE files
|
||||||
|
.git/
|
||||||
|
.gitea/
|
||||||
|
.devcontainer/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS/editor junk
|
||||||
|
**/.DS_Store
|
||||||
|
**/*.swp
|
||||||
|
**/*.swo
|
||||||
|
**/*.tmp
|
||||||
|
|
||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Logs and coverage
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
out/
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# Node (if any subprojects)
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Python caches (if any scripts)
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
|
||||||
|
# Go vendor (if used)
|
||||||
|
vendor/
|
||||||
15
.env.example
Normal file
15
.env.example
Normal 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
|
||||||
61
.gitea/workflows/build-and-push.yml
Normal file
61
.gitea/workflows/build-and-push.yml
Normal 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 }}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
name: Build and Publish Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.ztsw.de
|
|
||||||
username: ${{ secrets.CR_USERNAME }}
|
|
||||||
password: ${{ secrets.CR_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: git.ztsw.de/pedan/freemoto/freemoto-web:latest
|
|
||||||
94
.gitignore
vendored
94
.gitignore
vendored
@@ -1,46 +1,48 @@
|
|||||||
# 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/
|
||||||
|
custom_tiles/
|
||||||
|
custom_files/
|
||||||
44
Dockerfile
44
Dockerfile
@@ -1,11 +1,47 @@
|
|||||||
FROM golang:1.24-alpine
|
## Build stage
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Install build dependencies (none needed for static build) and enable static build
|
||||||
|
# Make the build architecture-aware for buildx multi-arch builds
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
ENV CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH}
|
||||||
|
|
||||||
|
# Cache go modules
|
||||||
|
COPY app/web/go.mod ./app/web/
|
||||||
|
RUN cd app/web && go mod download
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
COPY app/web/ ./app/web/
|
||||||
|
|
||||||
|
# Build
|
||||||
|
RUN cd app/web && go build -o /out/freemoto-web ./
|
||||||
|
|
||||||
|
## Runtime stage
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
WORKDIR /app/web
|
WORKDIR /app/web
|
||||||
|
|
||||||
COPY app/web/ .
|
# Add CA certificates for any outbound HTTPS (future-proofing)
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata wget
|
||||||
|
|
||||||
RUN go mod tidy
|
# Copy binary and static files
|
||||||
|
COPY --from=builder /out/freemoto-web /app/web/freemoto-web
|
||||||
|
COPY app/web/static/ /app/web/static/
|
||||||
|
|
||||||
|
# Use non-root user (create group explicitly for Alpine)
|
||||||
|
RUN addgroup -S appuser \
|
||||||
|
&& adduser -S -D -H -h /nonexistent -G appuser appuser \
|
||||||
|
&& chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
ENV PORT=8080
|
||||||
EXPOSE 8080
|
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
36
LICENSE
@@ -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.
|
||||||
|
|||||||
133
README.md
133
README.md
@@ -1,12 +1,30 @@
|
|||||||
# FreeMoto Web
|
# FreeMoto Web
|
||||||
|
|
||||||
A web-based motorcycle route planner built with Go, Leaflet, Bootstrap, and Valhalla routing, ready for containerized deployment.
|
A modern, mobile‑first motorcycle route planner. Backend in Go (proxy + static), frontend with Bootstrap + Leaflet, and Valhalla as the routing engine. Container‑ready with docker‑compose and CI for multi‑arch images.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Interactive map with OpenStreetMap tiles
|
- Modern, mobile‑friendly UI (single tap on mobile, double‑click on desktop)
|
||||||
- Route planning with options (avoid highways, tolls, ferries, unpaved, shortest route)
|
- Multi‑waypoint routing with draggable markers and reorder/remove list
|
||||||
- Docker-ready and configurable via `.env`
|
- 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)
|
||||||
|
- Next‑maneuver banner; optional voice prompt
|
||||||
|
- Export GPX
|
||||||
|
- Track (`<trk>/<trkseg>/<trkpt>`) and/or Route (`<rte>/<rtept>`)
|
||||||
|
- OSMAnd‑friendly
|
||||||
|
- Theming and UX
|
||||||
|
- Dracula‑style 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
|
## Getting Started
|
||||||
|
|
||||||
@@ -24,6 +42,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
|
||||||
@@ -35,6 +60,32 @@ go run main.go
|
|||||||
|
|
||||||
Visit [http://localhost:8080](http://localhost:8080) in your browser.
|
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
|
### Docker
|
||||||
|
|
||||||
Build and run the container:
|
Build and run the container:
|
||||||
@@ -59,6 +110,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,12 +123,73 @@ services:
|
|||||||
- tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf
|
- tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf
|
||||||
```
|
```
|
||||||
|
|
||||||
## Customization
|
Alternatively, use the provided root-level `docker-compose.yml` for a local build of the web app and a Valhalla companion service, then run `docker compose up -d --build`.
|
||||||
|
|
||||||
- **Map UI:** Edit `static/index.html` and `static/main.js`
|
### Notes on Nominatim
|
||||||
- **Routing logic:** Edit `static/route.js`
|
|
||||||
- **Geolocation/autocomplete:** Edit `static/geolocate.js`
|
- 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/)`.
|
||||||
- **Backend proxy:** Edit `main.go`
|
- You can point `NOMINATIM_URL` to your own Nominatim instance or keep the default public endpoint.
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Configure verbosity with `LOG_LEVEL`.
|
||||||
|
- Supported values: `debug`, `info` (default), `warn`, `error`.
|
||||||
|
- Incoming requests are logged at `info` level. Upstream success traces (Valhalla/Nominatim) are at `debug`. Errors are at `error`.
|
||||||
|
|
||||||
|
## UI Overview
|
||||||
|
|
||||||
|
- Start/End fields with autocomplete and reverse geocoding
|
||||||
|
- Tap/double‑click to add waypoints; drag markers to adjust
|
||||||
|
- Waypoint list for reorder/remove
|
||||||
|
- Route Options include twistiness, highways, and avoid toggles
|
||||||
|
- Directions sheet (collapsible, draggable) + Next‑maneuver 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`
|
||||||
|
|
||||||
|
## Multi‑arch build and Registry
|
||||||
|
|
||||||
|
Build and push multi‑arch (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 multi‑arch 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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
433
app/web/main.go
433
app/web/main.go
@@ -1,53 +1,410 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"compress/gzip"
|
||||||
"log"
|
"context"
|
||||||
"net/http"
|
"fmt"
|
||||||
"os"
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const ctxReqID ctxKey = "reqID"
|
||||||
|
|
||||||
|
// atomic counter for simple request IDs
|
||||||
|
var reqIDCounter uint64
|
||||||
|
|
||||||
|
// log levels
|
||||||
|
const (
|
||||||
|
levelDebug = iota
|
||||||
|
levelInfo
|
||||||
|
levelWarn
|
||||||
|
levelError
|
||||||
|
)
|
||||||
|
|
||||||
|
var currentLogLevel = levelInfo
|
||||||
|
|
||||||
|
func parseLogLevel(s string) int {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "debug":
|
||||||
|
return levelDebug
|
||||||
|
case "info", "":
|
||||||
|
return levelInfo
|
||||||
|
case "warn", "warning":
|
||||||
|
return levelWarn
|
||||||
|
case "error", "err":
|
||||||
|
return levelError
|
||||||
|
default:
|
||||||
|
return levelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyToValhallaPOST forwards a POST with JSON body to a specific Valhalla endpoint (e.g., /isochrone)
|
||||||
|
func proxyToValhallaPOST(w http.ResponseWriter, r *http.Request, target string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header = r.Header.Clone()
|
||||||
|
if req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := http.Client{Timeout: 20 * time.Second}
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
|
||||||
|
logf(levelError, "rid=%s upstream=valhalla target=%s error=%v", rid, target, err)
|
||||||
|
} else {
|
||||||
|
logf(levelError, "upstream=valhalla target=%s error=%v", target, err)
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Forward common headers and status
|
||||||
|
for key, values := range resp.Header {
|
||||||
|
if key == "Content-Type" || key == "Content-Encoding" || key == "Cache-Control" || key == "Vary" {
|
||||||
|
for _, v := range values {
|
||||||
|
w.Header().Add(key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
|
||||||
|
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
|
||||||
|
logf(levelDebug, "rid=%s upstream=valhalla target=%s status=%d dur=%s", rid, target, resp.StatusCode, time.Since(start))
|
||||||
|
} else {
|
||||||
|
logf(levelDebug, "upstream=valhalla target=%s status=%d dur=%s", target, resp.StatusCode, time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logf(level int, format string, args ...any) {
|
||||||
|
if level < currentLogLevel {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefix := "INFO"
|
||||||
|
switch level {
|
||||||
|
case levelDebug:
|
||||||
|
prefix = "DEBUG"
|
||||||
|
case levelInfo:
|
||||||
|
prefix = "INFO"
|
||||||
|
case levelWarn:
|
||||||
|
prefix = "WARN"
|
||||||
|
case levelError:
|
||||||
|
prefix = "ERROR"
|
||||||
|
}
|
||||||
|
// Prepend the prefix as the first argument
|
||||||
|
log.Printf("[%s] "+format, append([]any{prefix}, args...)...)
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
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"
|
||||||
}
|
}
|
||||||
|
// Derive Valhalla base for other endpoints like /isochrone
|
||||||
|
valhallaBase := strings.TrimSuffix(valhallaURL, "/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
|
// 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.ServeFile(w, r, "./static/"+r.URL.Path[1:])
|
http.Handle("/healthz", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
})
|
w.WriteHeader(http.StatusOK)
|
||||||
log.Printf("Listening on :%s", port)
|
_, _ = w.Write([]byte("ok"))
|
||||||
http.ListenAndServe(":"+port, nil)
|
})))
|
||||||
|
http.Handle("/route", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
proxyToValhalla(w, r, valhallaURL)
|
||||||
|
})))
|
||||||
|
http.Handle("/isochrone", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Forward to Valhalla isochrone endpoint (POST JSON)
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := valhallaBase + "/isochrone"
|
||||||
|
proxyToValhallaPOST(w, r, target)
|
||||||
|
})))
|
||||||
|
// Nominatim proxy endpoints
|
||||||
|
http.Handle("/geocode", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
proxyToNominatimGET(w, r, nominatimBase+"/search", nominatimUA)
|
||||||
|
})))
|
||||||
|
http.Handle("/reverse", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
proxyToNominatimGET(w, r, nominatimBase+"/reverse", nominatimUA)
|
||||||
|
})))
|
||||||
|
http.Handle("/", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Serve index
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
// Short cache for HTML entrypoint
|
||||||
|
setShortCache(w)
|
||||||
|
maybeGzipAndServeFile(w, r, "./static/index.html")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Serve other assets from ./static with long cache for versioned files
|
||||||
|
// Decide cache and compression based on extension
|
||||||
|
ext := strings.ToLower(filepath.Ext(r.URL.Path))
|
||||||
|
if isTextLikeExt(ext) {
|
||||||
|
setLongCache(w)
|
||||||
|
w.Header().Add("Vary", "Accept-Encoding")
|
||||||
|
// Use gzip when supported
|
||||||
|
if acceptsGzip(r) {
|
||||||
|
gz := gzip.NewWriter(w)
|
||||||
|
defer gz.Close()
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
// Avoid Content-Length when gzipping
|
||||||
|
w.Header().Del("Content-Length")
|
||||||
|
gzw := &gzipResponseWriter{ResponseWriter: w, gw: gz}
|
||||||
|
http.ServeFile(gzw, r, "./static/"+r.URL.Path[1:])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeFile(w, r, "./static/"+r.URL.Path[1:])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Non-text assets: just set long cache
|
||||||
|
setLongCache(w)
|
||||||
|
http.ServeFile(w, r, "./static/"+r.URL.Path[1:])
|
||||||
|
})))
|
||||||
|
logf(levelInfo, "Listening on :%s", port)
|
||||||
|
http.ListenAndServe(":"+port, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func proxyToValhalla(w http.ResponseWriter, r *http.Request, valhallaURL string) {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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)
|
||||||
}
|
}
|
||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -1,171 +1,245 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>FreeMoto Navigation</title>
|
<meta charset="utf-8" />
|
||||||
<meta charset="utf-8" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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://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 href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" rel="stylesheet" />
|
||||||
<style>
|
<link href="/styles.css?v=20250918" rel="stylesheet" />
|
||||||
html, body, #map {
|
</head>
|
||||||
height: 100%;
|
<body>
|
||||||
margin: 0;
|
<!-- App Bar (Neutral minimal, glassy) -->
|
||||||
padding: 0;
|
<header class="appbar navbar px-3">
|
||||||
}
|
<div class="container-fluid d-flex align-items-center justify-content-between">
|
||||||
#map {
|
<div class="d-flex align-items-center gap-2">
|
||||||
min-height: 100vh;
|
<span class="navbar-brand mb-0 h1 d-flex align-items-center gap-2">
|
||||||
min-width: 100vw;
|
<span aria-hidden="true">🏍️</span>
|
||||||
z-index: 1;
|
<span>FreeMoto</span>
|
||||||
}
|
</span>
|
||||||
.nav-panel {
|
<span id="summaryPill" class="badge rounded-pill text-bg-light d-none"></span>
|
||||||
position: fixed;
|
</div>
|
||||||
bottom: 0;
|
<div class="d-flex align-items-center gap-2">
|
||||||
left: 0;
|
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom controls">
|
||||||
width: 100vw;
|
<button class="btn btn-outline-secondary" id="zoomOutBtn" title="Zoom out">−</button>
|
||||||
max-width: 100vw;
|
<button class="btn btn-outline-secondary" id="zoomInBtn" title="Zoom in">+</button>
|
||||||
z-index: 1001;
|
</div>
|
||||||
background: rgba(255,255,255,0.98);
|
<button class="btn btn-outline-secondary btn-sm" id="clearRouteBtn" title="Clear route">Clear</button>
|
||||||
border-radius: 18px 18px 0 0;
|
<button class="btn btn-outline-secondary btn-sm" id="themeToggle" title="Toggle dark mode">🌙</button>
|
||||||
box-shadow: 0 -2px 16px rgba(0,0,0,0.18);
|
</div>
|
||||||
padding: 12px 8px 8px 8px;
|
</div>
|
||||||
overflow-y: auto;
|
</header>
|
||||||
transition: box-shadow 0.2s;
|
|
||||||
}
|
<!-- Map canvas -->
|
||||||
@media (min-width: 600px) {
|
<div id="map" aria-label="Map"></div>
|
||||||
.nav-panel {
|
|
||||||
left: 24px;
|
<!-- Control Panel (neutral, glassy, scrollable) -->
|
||||||
width: 370px;
|
<aside class="nav-panel shadow">
|
||||||
max-width: 420px;
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
border-radius: 18px;
|
<div class="d-flex align-items-center gap-2">
|
||||||
top: 24px;
|
<span class="fw-semibold">Planner</span>
|
||||||
bottom: auto;
|
</div>
|
||||||
box-shadow: 0 2px 16px rgba(0,0,0,0.18);
|
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline" onclick="resetMarkers()">Reset</button>
|
||||||
}
|
</div>
|
||||||
}
|
|
||||||
#routeInfoCard {
|
<div id="routeInfoCard" class="alert alert-info d-none" role="alert"></div>
|
||||||
margin-bottom: 1rem;
|
<div id="nextManeuverBanner" class="alert alert-secondary py-2 px-3 d-none" role="status"></div>
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
<!-- Route inputs (compact) -->
|
||||||
#sourceSuggestions,
|
<div class="section-title mb-1">Route</div>
|
||||||
#destSuggestions {
|
<div class="row g-2 align-items-stretch">
|
||||||
position: absolute;
|
<div class="col-12">
|
||||||
top: 100%;
|
<div class="input-group position-relative">
|
||||||
left: 0;
|
<span class="input-group-text" title="Start" aria-label="Start">A</span>
|
||||||
z-index: 2000;
|
<input type="text" class="form-control" id="sourceInput" placeholder="Start address" autocomplete="off" />
|
||||||
width: 100%;
|
<button class="btn btn-outline-secondary" type="button" id="useCurrentSource" title="Use current location">📍</button>
|
||||||
max-height: 220px;
|
<div id="sourceSuggestions" class="list-group position-absolute w-100"></div>
|
||||||
overflow-y: auto;
|
</div>
|
||||||
border-radius: 0 0 0.5rem 0.5rem;
|
</div>
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
<div class="col-12 d-flex justify-content-center">
|
||||||
}
|
<button id="swapBtn" class="btn btn-light border rounded-circle shadow-sm" type="button" title="Swap start/end">⇅</button>
|
||||||
.section-title {
|
</div>
|
||||||
font-size: 1.1rem;
|
<div class="col-12">
|
||||||
font-weight: 600;
|
<div class="input-group position-relative">
|
||||||
margin-bottom: 0.5rem;
|
<span class="input-group-text" title="Destination" aria-label="Destination">B</span>
|
||||||
margin-top: 1rem;
|
<input type="text" class="form-control" id="destInput" placeholder="Destination address" autocomplete="off" />
|
||||||
color: #495057;
|
<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>
|
||||||
.card-icon {
|
</div>
|
||||||
font-size: 2rem;
|
</div>
|
||||||
margin-right: 0.5rem;
|
</div>
|
||||||
vertical-align: middle;
|
|
||||||
}
|
<!-- Quick sliders -->
|
||||||
.btn-lg, .form-control {
|
<div class="row g-3 mt-1">
|
||||||
font-size: 1.2rem;
|
<div class="col-12">
|
||||||
padding: 0.7rem 1rem;
|
<label for="twistiness" class="form-label mb-1 text-muted">Twistiness</label>
|
||||||
}
|
<div class="d-flex align-items-center gap-2">
|
||||||
.form-check-label {
|
<input type="range" class="form-range" id="twistiness" min="0" max="100" value="50" />
|
||||||
font-size: 1rem;
|
<span id="twistinessValue" class="badge rounded-pill text-bg-light">50</span>
|
||||||
}
|
</div>
|
||||||
.input-group-text svg,
|
</div>
|
||||||
.btn svg {
|
<div class="col-12">
|
||||||
vertical-align: middle;
|
<label for="highwayPref" class="form-label mb-1 text-muted">Highways</label>
|
||||||
}
|
<div class="d-flex align-items-center gap-2">
|
||||||
</style>
|
<input type="range" class="form-range" id="highwayPref" min="0" max="100" value="50" />
|
||||||
</head>
|
<span id="highwayPrefValue" class="badge rounded-pill text-bg-light">50</span>
|
||||||
<body>
|
</div>
|
||||||
<div id="map"></div>
|
</div>
|
||||||
<div class="nav-panel shadow-lg">
|
</div>
|
||||||
<div class="text-center mb-2">
|
|
||||||
<span class="card-icon">🏍️</span>
|
<div class="d-grid gap-2 my-2 d-none d-md-grid">
|
||||||
<span class="fs-4 fw-bold text-primary">FreeMoto</span>
|
<button type="button" id="plotRouteBtn" class="btn btn-success">Plot Route</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="routeInfoCard" class="alert alert-info d-none" role="alert"></div>
|
|
||||||
<div class="mb-2">
|
<!-- Options (collapsible minimal) -->
|
||||||
<div class="section-title">Route</div>
|
<div class="accordion mb-2" id="optionsAccordion">
|
||||||
<div class="input-group mb-2 position-relative">
|
<div class="accordion-item">
|
||||||
<span class="input-group-text" title="Start">
|
<h2 class="accordion-header">
|
||||||
<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>
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#optionsCollapse" aria-expanded="false" aria-controls="optionsCollapse">
|
||||||
</span>
|
Options
|
||||||
<input type="text" class="form-control" id="sourceInput" placeholder="Start address" autocomplete="off">
|
</button>
|
||||||
<button class="btn btn-outline-secondary" type="button" id="useCurrentSource" title="Use current location">
|
</h2>
|
||||||
<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>
|
<div id="optionsCollapse" class="accordion-collapse collapse" data-bs-parent="#optionsAccordion">
|
||||||
</button>
|
<div class="accordion-body">
|
||||||
<div id="sourceSuggestions" class="list-group position-absolute w-100" style="z-index: 2000;"></div>
|
<div class="form-check mb-1">
|
||||||
</div>
|
<input class="form-check-input" type="checkbox" id="useShortest">
|
||||||
<div class="input-group mb-2 position-relative">
|
<label class="form-check-label" for="useShortest">Shortest route</label>
|
||||||
<span class="input-group-text" title="Destination">
|
</div>
|
||||||
<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>
|
<div class="form-check mb-1">
|
||||||
</span>
|
<input class="form-check-input" type="checkbox" id="avoidHighways">
|
||||||
<input type="text" class="form-control" id="destInput" placeholder="Destination address" autocomplete="off">
|
<label class="form-check-label" for="avoidHighways">Avoid freeways</label>
|
||||||
<button class="btn btn-outline-secondary" type="button" id="useCurrentDest" title="Use current location">
|
</div>
|
||||||
<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>
|
<div class="form-check mb-1">
|
||||||
</button>
|
<input class="form-check-input" type="checkbox" id="avoidTollRoads">
|
||||||
<div id="destSuggestions" class="list-group position-absolute w-100" style="z-index: 2000;"></div>
|
<label class="form-check-label" for="avoidTollRoads">Avoid tolls</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid gap-2 mb-2">
|
<div class="form-check mb-1">
|
||||||
<button type="button" id="plotRouteBtn" class="btn btn-success btn-lg">Plot Route</button>
|
<input class="form-check-input" type="checkbox" id="avoidFerries">
|
||||||
</div>
|
<label class="form-check-label" for="avoidFerries">Avoid ferries</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-title">Route Options</div>
|
<div class="form-check mb-2">
|
||||||
<form>
|
<input class="form-check-input" type="checkbox" id="avoidUnpaved">
|
||||||
<div class="row g-2">
|
<label class="form-check-label" for="avoidUnpaved">Avoid unpaved</label>
|
||||||
<div class="col-12">
|
</div>
|
||||||
<div class="form-check">
|
<hr>
|
||||||
<input class="form-check-input" type="checkbox" id="avoidHighways">
|
<div class="row g-2 align-items-center mb-2">
|
||||||
<label class="form-check-label" for="avoidHighways">Avoid freeways</label>
|
<div class="col-12 col-md-6 d-flex align-items-center gap-2">
|
||||||
</div>
|
<div class="form-check form-switch">
|
||||||
</div>
|
<input class="form-check-input" type="checkbox" id="roundTripToggle">
|
||||||
<div class="col-12">
|
<label class="form-check-label" for="roundTripToggle">Round Trip</label>
|
||||||
<div class="form-check">
|
</div>
|
||||||
<input class="form-check-input" type="checkbox" id="useShortest">
|
</div>
|
||||||
<label class="form-check-label" for="useShortest">Shortest route</label>
|
<div class="col-8 col-md-4">
|
||||||
</div>
|
<div class="input-group input-group-sm">
|
||||||
</div>
|
<input type="number" min="5" max="500" step="5" id="roundTripKm" class="form-control" placeholder="Distance (km)" value="100">
|
||||||
<div class="col-12">
|
<span class="input-group-text">km</span>
|
||||||
<div class="form-check">
|
</div>
|
||||||
<input class="form-check-input" type="checkbox" id="avoidTollRoads">
|
</div>
|
||||||
<label class="form-check-label" for="avoidTollRoads">Avoid tolls</label>
|
<div class="col-4 col-md-2 d-grid">
|
||||||
</div>
|
<button type="button" id="roundTripBtn" class="btn btn-sm btn-outline-primary">Create</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
</div>
|
||||||
<div class="form-check">
|
<div class="row g-2 align-items-center mb-2">
|
||||||
<input class="form-check-input" type="checkbox" id="avoidFerries">
|
<div class="col-12 col-md-6">
|
||||||
<label class="form-check-label" for="avoidFerries">Avoid ferries</label>
|
<label for="roundTripDir" class="form-label mb-0 text-muted">Direction preference</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-12 col-md-6">
|
||||||
<div class="col-12">
|
<select id="roundTripDir" class="form-select form-select-sm">
|
||||||
<div class="form-check">
|
<option value="any" selected>Any</option>
|
||||||
<input class="form-check-input" type="checkbox" id="avoidUnpaved">
|
<option value="N">North</option>
|
||||||
<label class="form-check-label" for="avoidUnpaved">Avoid unpaved</label>
|
<option value="E">East</option>
|
||||||
</div>
|
<option value="S">South</option>
|
||||||
</div>
|
<option value="W">West</option>
|
||||||
</div>
|
</select>
|
||||||
</form>
|
</div>
|
||||||
<div class="d-flex justify-content-between mt-3 mb-2">
|
</div>
|
||||||
<button onclick="resetMarkers()" class="btn btn-primary btn-lg flex-fill me-2">Reset Points</button>
|
<div class="row g-2 align-items-center mb-2">
|
||||||
<div class="btn-group flex-fill" role="group" aria-label="Zoom controls">
|
<div class="col-12 col-md-6">
|
||||||
<button type="button" class="btn btn-outline-secondary btn-lg" id="zoomInBtn" title="Zoom in">+</button>
|
<div class="form-check">
|
||||||
<button type="button" class="btn btn-outline-secondary btn-lg" id="zoomOutBtn" title="Zoom out">−</button>
|
<input class="form-check-input" type="checkbox" id="roundTripScenic">
|
||||||
</div>
|
<label class="form-check-label" for="roundTripScenic">Scenic optimizer (twistier)</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid gap-2 mb-2">
|
</div>
|
||||||
<button type="button" id="exportGpxBtn" class="btn btn-warning btn-lg">Export GPX</button>
|
</div>
|
||||||
</div>
|
<div class="row g-2 align-items-center mb-2">
|
||||||
</div>
|
<div class="col-12 col-md-6">
|
||||||
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
|
<div class="form-check">
|
||||||
<script src="/main.js"></script>
|
<input class="form-check-input" type="checkbox" id="roundTripIsochrone">
|
||||||
<script src="/route.js"></script>
|
<label class="form-check-label" for="roundTripIsochrone">Use isochrone guidance</label>
|
||||||
<script src="/geolocate.js"></script>
|
</div>
|
||||||
</body>
|
</div>
|
||||||
</html>
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text">Time</span>
|
||||||
|
<input type="number" min="10" max="240" step="5" id="isochroneMinutes" class="form-control" placeholder="Minutes" value="60">
|
||||||
|
<span class="input-group-text">min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 align-items-center mb-2">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="roundTripNoRepeat">
|
||||||
|
<label class="form-check-label" for="roundTripNoRepeat">Avoid repeated segments</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<label class="form-label mb-0">Export</label>
|
||||||
|
<select id="exportMode" class="form-select form-select-sm" style="max-width: 180px;">
|
||||||
|
<option value="both" selected>Track + Route</option>
|
||||||
|
<option value="track">Track only</option>
|
||||||
|
<option value="route">Route only</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-check form-switch ms-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="voiceToggle">
|
||||||
|
<label class="form-check-label" for="voiceToggle">Voice</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-warning btn-sm export-gpx">Export GPX</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="section-title">Waypoints</div>
|
||||||
|
<ul id="waypointList" class="list-group small"></ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Panel toggle button (mobile portrait) -->
|
||||||
|
<button id="panelToggle" class="btn btn-outline-secondary btn-sm panel-toggle d-md-none" type="button" aria-expanded="true" title="Toggle panel">☰</button>
|
||||||
|
|
||||||
|
<!-- Floating actions (mobile) -->
|
||||||
|
<div class="floating-group d-md-none">
|
||||||
|
<button class="btn btn-primary fab" id="recenterBtn" title="Recenter">◎</button>
|
||||||
|
<button class="btn btn-primary" id="plotRouteBtn" title="Plot Route">Route</button>
|
||||||
|
<button class="btn btn-dark export-gpx" title="Export GPX">GPX</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Directions Bottom Sheet -->
|
||||||
|
<div id="directionsSheet" class="sheet" aria-live="polite">
|
||||||
|
<div class="card shadow-lg sheet-card">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="handle" role="separator" aria-label="Resize directions"></div>
|
||||||
|
<div class="sheet-header d-flex align-items-center justify-content-between px-3 py-2">
|
||||||
|
<div class="fw-semibold">Directions</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" id="closeDirections">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-body px-2">
|
||||||
|
<ul class="list-group list-group-flush mt-2" id="directionsList"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="/main.js"></script>
|
||||||
|
<script src="/route.js"></script>
|
||||||
|
<script src="/geolocate.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// 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, doubleClickZoom: 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', {
|
||||||
@@ -26,6 +26,22 @@ if (navigator.geolocation) {
|
|||||||
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');
|
||||||
|
var themeToggle = document.getElementById('themeToggle');
|
||||||
|
var clearRouteBtn = document.getElementById('clearRouteBtn');
|
||||||
|
var twistiness = document.getElementById('twistiness');
|
||||||
|
var twistinessValue = document.getElementById('twistinessValue');
|
||||||
|
var highwayPref = document.getElementById('highwayPref');
|
||||||
|
var highwayPrefValue = document.getElementById('highwayPrefValue');
|
||||||
|
var exportModeSel = document.getElementById('exportMode');
|
||||||
|
var voiceToggle = document.getElementById('voiceToggle');
|
||||||
|
var roundTripToggle = document.getElementById('roundTripToggle');
|
||||||
|
var roundTripKm = document.getElementById('roundTripKm');
|
||||||
|
var roundTripBtn = document.getElementById('roundTripBtn');
|
||||||
|
var roundTripNoRepeat = document.getElementById('roundTripNoRepeat');
|
||||||
|
var roundTripDir = document.getElementById('roundTripDir');
|
||||||
|
var roundTripScenic = document.getElementById('roundTripScenic');
|
||||||
|
var roundTripIsochrone = document.getElementById('roundTripIsochrone');
|
||||||
|
var isochroneMinutes = document.getElementById('isochroneMinutes');
|
||||||
if (zoomInBtn && zoomOutBtn) {
|
if (zoomInBtn && zoomOutBtn) {
|
||||||
zoomInBtn.addEventListener('click', function() {
|
zoomInBtn.addEventListener('click', function() {
|
||||||
map.zoomIn();
|
map.zoomIn();
|
||||||
@@ -34,4 +50,268 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
map.zoomOut();
|
map.zoomOut();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme toggle with persistence (Dracula-like)
|
||||||
|
function applyTheme(theme) {
|
||||||
|
if (theme === 'dark') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark');
|
||||||
|
if (themeToggle) themeToggle.textContent = '☀️';
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
if (themeToggle) themeToggle.textContent = '🌙';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var savedTheme = localStorage.getItem('freemoto-theme');
|
||||||
|
if (!savedTheme || savedTheme === 'auto') {
|
||||||
|
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
applyTheme(prefersDark ? 'dark' : 'light');
|
||||||
|
if (!savedTheme) localStorage.setItem('freemoto-theme', 'auto');
|
||||||
|
} else {
|
||||||
|
applyTheme(savedTheme);
|
||||||
|
}
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function() {
|
||||||
|
var next = (localStorage.getItem('freemoto-theme') || 'light') === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem('freemoto-theme', next);
|
||||||
|
applyTheme(next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear route button
|
||||||
|
if (clearRouteBtn && typeof window.resetMarkers === 'function') {
|
||||||
|
clearRouteBtn.addEventListener('click', function() {
|
||||||
|
window.resetMarkers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twistiness slider
|
||||||
|
function applyTwistiness(val) {
|
||||||
|
if (twistinessValue) twistinessValue.textContent = String(val);
|
||||||
|
localStorage.setItem('freemoto-twistiness', String(val));
|
||||||
|
}
|
||||||
|
var savedTwist = parseInt(localStorage.getItem('freemoto-twistiness') || '50', 10);
|
||||||
|
if (!isNaN(savedTwist) && twistiness) {
|
||||||
|
twistiness.value = savedTwist;
|
||||||
|
applyTwistiness(savedTwist);
|
||||||
|
}
|
||||||
|
if (twistiness) {
|
||||||
|
twistiness.addEventListener('input', function(e){
|
||||||
|
var v = parseInt(e.target.value, 10);
|
||||||
|
applyTwistiness(v);
|
||||||
|
});
|
||||||
|
twistiness.addEventListener('change', function(){
|
||||||
|
if (typeof window.recalculateRoute === 'function') {
|
||||||
|
window.recalculateRoute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highway preference slider (0..100 -> 0..1 use_highways)
|
||||||
|
function applyHighwayPref(val) {
|
||||||
|
if (highwayPrefValue) highwayPrefValue.textContent = String(val);
|
||||||
|
localStorage.setItem('freemoto-highways', String(val));
|
||||||
|
}
|
||||||
|
var savedHigh = parseInt(localStorage.getItem('freemoto-highways') || '50', 10);
|
||||||
|
if (!isNaN(savedHigh) && highwayPref) {
|
||||||
|
highwayPref.value = savedHigh;
|
||||||
|
applyHighwayPref(savedHigh);
|
||||||
|
}
|
||||||
|
if (highwayPref) {
|
||||||
|
highwayPref.addEventListener('input', function(e){
|
||||||
|
var v = parseInt(e.target.value, 10);
|
||||||
|
applyHighwayPref(v);
|
||||||
|
});
|
||||||
|
highwayPref.addEventListener('change', function(){
|
||||||
|
if (typeof window.recalculateRoute === 'function') {
|
||||||
|
window.recalculateRoute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist export mode
|
||||||
|
if (exportModeSel) {
|
||||||
|
var savedMode = localStorage.getItem('freemoto-export-mode') || 'both';
|
||||||
|
exportModeSel.value = savedMode;
|
||||||
|
exportModeSel.addEventListener('change', function(){
|
||||||
|
localStorage.setItem('freemoto-export-mode', exportModeSel.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist voice toggle
|
||||||
|
if (voiceToggle) {
|
||||||
|
var savedVoice = localStorage.getItem('freemoto-voice') === '1';
|
||||||
|
voiceToggle.checked = savedVoice;
|
||||||
|
voiceToggle.addEventListener('change', function(){
|
||||||
|
localStorage.setItem('freemoto-voice', voiceToggle.checked ? '1' : '0');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round Trip settings persistence
|
||||||
|
if (roundTripToggle) {
|
||||||
|
var savedRT = localStorage.getItem('freemoto-roundtrip') === '1';
|
||||||
|
roundTripToggle.checked = savedRT;
|
||||||
|
roundTripToggle.addEventListener('change', function(){
|
||||||
|
localStorage.setItem('freemoto-roundtrip', roundTripToggle.checked ? '1' : '0');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (roundTripKm) {
|
||||||
|
var savedKm = parseInt(localStorage.getItem('freemoto-roundtrip-km') || '100', 10);
|
||||||
|
if (!isNaN(savedKm)) roundTripKm.value = savedKm;
|
||||||
|
roundTripKm.addEventListener('change', function(){
|
||||||
|
var v = parseInt(roundTripKm.value, 10);
|
||||||
|
if (!isNaN(v)) localStorage.setItem('freemoto-roundtrip-km', String(v));
|
||||||
|
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
|
||||||
|
window.createRoundTrip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (roundTripBtn) {
|
||||||
|
roundTripBtn.addEventListener('click', function(){
|
||||||
|
if (typeof window.createRoundTrip === 'function') {
|
||||||
|
window.createRoundTrip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roundTripNoRepeat) {
|
||||||
|
var savedNR = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
|
||||||
|
roundTripNoRepeat.checked = savedNR;
|
||||||
|
roundTripNoRepeat.addEventListener('change', function(){
|
||||||
|
localStorage.setItem('freemoto-roundtrip-norepeat', roundTripNoRepeat.checked ? '1' : '0');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction preference persistence and live update
|
||||||
|
if (roundTripDir) {
|
||||||
|
var savedDir = localStorage.getItem('freemoto-roundtrip-dir') || 'any';
|
||||||
|
roundTripDir.value = savedDir;
|
||||||
|
roundTripDir.addEventListener('change', function(){
|
||||||
|
localStorage.setItem('freemoto-roundtrip-dir', roundTripDir.value);
|
||||||
|
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
|
||||||
|
// Regenerate the round trip to reflect new direction
|
||||||
|
window.createRoundTrip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenic optimizer toggle
|
||||||
|
if (roundTripScenic) {
|
||||||
|
var savedScenic = localStorage.getItem('freemoto-roundtrip-scenic') === '1';
|
||||||
|
roundTripScenic.checked = savedScenic;
|
||||||
|
roundTripScenic.addEventListener('change', function(){
|
||||||
|
localStorage.setItem('freemoto-roundtrip-scenic', roundTripScenic.checked ? '1' : '0');
|
||||||
|
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
|
||||||
|
window.createRoundTrip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isochrone toggle and minutes
|
||||||
|
if (roundTripIsochrone) {
|
||||||
|
var savedIso = localStorage.getItem('freemoto-roundtrip-isochrone') === '1';
|
||||||
|
roundTripIsochrone.checked = savedIso;
|
||||||
|
roundTripIsochrone.addEventListener('change', function(){
|
||||||
|
localStorage.setItem('freemoto-roundtrip-isochrone', roundTripIsochrone.checked ? '1' : '0');
|
||||||
|
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
|
||||||
|
window.createRoundTrip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isochroneMinutes) {
|
||||||
|
var savedMin = parseInt(localStorage.getItem('freemoto-isochrone-minutes') || '60', 10);
|
||||||
|
if (!isNaN(savedMin)) isochroneMinutes.value = savedMin;
|
||||||
|
isochroneMinutes.addEventListener('change', function(){
|
||||||
|
var v = parseInt(isochroneMinutes.value, 10);
|
||||||
|
if (!isNaN(v)) localStorage.setItem('freemoto-isochrone-minutes', String(v));
|
||||||
|
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
|
||||||
|
window.createRoundTrip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap start/end like Google Maps
|
||||||
|
(function(){
|
||||||
|
var swapBtn = document.getElementById('swapBtn');
|
||||||
|
if (!swapBtn) return;
|
||||||
|
function swapInputs() {
|
||||||
|
var s = document.getElementById('sourceInput');
|
||||||
|
var d = document.getElementById('destInput');
|
||||||
|
if (!s || !d) return;
|
||||||
|
// Swap visible values
|
||||||
|
var tmpVal = s.value; s.value = d.value; d.value = tmpVal;
|
||||||
|
// Swap lat/lon datasets
|
||||||
|
var sLat = s.dataset.lat, sLon = s.dataset.lon;
|
||||||
|
s.dataset.lat = d.dataset.lat || '';
|
||||||
|
s.dataset.lon = d.dataset.lon || '';
|
||||||
|
d.dataset.lat = sLat || '';
|
||||||
|
d.dataset.lon = sLon || '';
|
||||||
|
// If both look valid, trigger plot
|
||||||
|
var sourceLat = parseFloat(s.dataset.lat);
|
||||||
|
var sourceLon = parseFloat(s.dataset.lon);
|
||||||
|
var destLat = parseFloat(d.dataset.lat);
|
||||||
|
var destLon = parseFloat(d.dataset.lon);
|
||||||
|
if (!isNaN(sourceLat) && !isNaN(sourceLon) && !isNaN(destLat) && !isNaN(destLon)) {
|
||||||
|
var plot = document.getElementById('plotRouteBtn');
|
||||||
|
if (plot) plot.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
swapBtn.addEventListener('click', swapInputs);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Recenter FAB behavior
|
||||||
|
(function(){
|
||||||
|
var recenter = document.getElementById('recenterBtn');
|
||||||
|
if (!recenter) return;
|
||||||
|
recenter.addEventListener('click', function(){
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(function(position){
|
||||||
|
var lat = position.coords.latitude;
|
||||||
|
var lon = position.coords.longitude;
|
||||||
|
map.setView([lat, lon], Math.max(map.getZoom(), 14));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Mobile panel toggle (portrait)
|
||||||
|
try {
|
||||||
|
var panel = document.querySelector('.nav-panel');
|
||||||
|
var toggleBtn = document.getElementById('panelToggle');
|
||||||
|
function isSmallPortrait() {
|
||||||
|
var mqW = window.matchMedia('(max-width: 576px)');
|
||||||
|
var mqP = window.matchMedia('(orientation: portrait)');
|
||||||
|
return (mqW.matches && mqP.matches);
|
||||||
|
}
|
||||||
|
// Initialize collapsed state on load for small portrait
|
||||||
|
if (panel && isSmallPortrait()) {
|
||||||
|
panel.classList.add('collapsed');
|
||||||
|
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
// Auto-collapse panel when user hits Plot on small portrait
|
||||||
|
var plotBtn = document.getElementById('plotRouteBtn');
|
||||||
|
if (plotBtn) {
|
||||||
|
plotBtn.addEventListener('click', function(){
|
||||||
|
if (panel && isSmallPortrait()) {
|
||||||
|
panel.classList.add('collapsed');
|
||||||
|
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (toggleBtn && panel) {
|
||||||
|
toggleBtn.addEventListener('click', function() {
|
||||||
|
panel.classList.toggle('collapsed');
|
||||||
|
var expanded = !panel.classList.contains('collapsed');
|
||||||
|
toggleBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// When orientation/viewport changes, ensure panel doesn't exceed screen
|
||||||
|
window.addEventListener('resize', function(){
|
||||||
|
if (!panel) return;
|
||||||
|
if (isSmallPortrait()) {
|
||||||
|
// Keep collapsed if it would cover map too much
|
||||||
|
if (!panel.classList.contains('collapsed')) panel.classList.add('collapsed');
|
||||||
|
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
@@ -7,38 +7,490 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
var points = [];
|
var points = [];
|
||||||
var markers = [];
|
var markers = [];
|
||||||
var routePolyline = null;
|
var routePolyline = null;
|
||||||
|
var snapLines = [];
|
||||||
|
var originalPoints = [];
|
||||||
|
var lastRouteLatLngs = null; // concatenated
|
||||||
|
var lastRtePoints = []; // for GPX <rtept>
|
||||||
|
var waypointListEl = document.getElementById('waypointList');
|
||||||
|
var nextBanner = document.getElementById('nextManeuverBanner');
|
||||||
|
var voiceToggle = document.getElementById('voiceToggle');
|
||||||
|
var exportModeSel = document.getElementById('exportMode');
|
||||||
|
var roundTripToggle = document.getElementById('roundTripToggle');
|
||||||
|
var roundTripKmInput = document.getElementById('roundTripKm');
|
||||||
|
var roundTripBtn = document.getElementById('roundTripBtn');
|
||||||
|
var roundTripDirSel = document.getElementById('roundTripDir');
|
||||||
|
var roundTripScenic = document.getElementById('roundTripScenic');
|
||||||
|
var roundTripIsochrone = document.getElementById('roundTripIsochrone');
|
||||||
|
var isochroneMinutesInput = document.getElementById('isochroneMinutes');
|
||||||
|
|
||||||
|
function renderWaypointList() {
|
||||||
|
if (!waypointListEl) return;
|
||||||
|
waypointListEl.innerHTML = '';
|
||||||
|
points.forEach(function(p, idx){
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||||
|
var label = (idx === 0) ? 'Start' : (idx === points.length - 1 ? 'End' : `WP ${idx}`);
|
||||||
|
var coords = `${p.lat.toFixed(5)}, ${p.lng.toFixed(5)}`;
|
||||||
|
li.innerHTML = `<span>${label}<br><small class="text-secondary">${coords}</small></span>`;
|
||||||
|
var btns = document.createElement('div');
|
||||||
|
btns.className = 'btn-group btn-group-sm';
|
||||||
|
var up = document.createElement('button'); up.className = 'btn btn-outline-secondary'; up.textContent = '↑'; up.disabled = (idx === 0);
|
||||||
|
var down = document.createElement('button'); down.className = 'btn btn-outline-secondary'; down.textContent = '↓'; down.disabled = (idx === points.length - 1);
|
||||||
|
var del = document.createElement('button'); del.className = 'btn btn-outline-danger'; del.textContent = '✕';
|
||||||
|
up.onclick = function(){ if (idx > 0) { swapWaypoints(idx, idx-1); } };
|
||||||
|
down.onclick = function(){ if (idx < points.length - 1) { swapWaypoints(idx, idx+1); } };
|
||||||
|
del.onclick = function(){ removeWaypoint(idx); };
|
||||||
|
btns.appendChild(up); btns.appendChild(down); btns.appendChild(del);
|
||||||
|
li.appendChild(btns);
|
||||||
|
waypointListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapWaypoints(i, j) {
|
||||||
|
var tmp = points[i]; points[i] = points[j]; points[j] = tmp;
|
||||||
|
var tmpo = originalPoints[i]; originalPoints[i] = originalPoints[j]; originalPoints[j] = tmpo;
|
||||||
|
var tmpm = markers[i]; markers[i] = markers[j]; markers[j] = tmpm;
|
||||||
|
renderWaypointList();
|
||||||
|
calculateRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWaypoint(i) {
|
||||||
|
if (markers[i]) { map.removeLayer(markers[i]); }
|
||||||
|
markers.splice(i,1);
|
||||||
|
points.splice(i,1);
|
||||||
|
originalPoints.splice(i,1);
|
||||||
|
renderWaypointList();
|
||||||
|
calculateRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geo helpers for round trip generation
|
||||||
|
function toRad(d){ return d * Math.PI / 180; }
|
||||||
|
function toDeg(r){ return r * 180 / Math.PI; }
|
||||||
|
function destPoint(lat, lon, distanceMeters, bearingDeg) {
|
||||||
|
var R = 6371000; // meters
|
||||||
|
var br = toRad(bearingDeg);
|
||||||
|
var φ1 = toRad(lat), λ1 = toRad(lon);
|
||||||
|
var δ = distanceMeters / R;
|
||||||
|
var sinφ1 = Math.sin(φ1), cosφ1 = Math.cos(φ1);
|
||||||
|
var sinδ = Math.sin(δ), cosδ = Math.cos(δ);
|
||||||
|
var sinφ2 = sinφ1 * cosδ + cosφ1 * sinδ * Math.cos(br);
|
||||||
|
var φ2 = Math.asin(sinφ2);
|
||||||
|
var y = Math.sin(br) * sinδ * cosφ1;
|
||||||
|
var x = cosδ - sinφ1 * sinφ2;
|
||||||
|
var λ2 = λ1 + Math.atan2(y, x);
|
||||||
|
return { lat: toDeg(φ2), lng: ((toDeg(λ2) + 540) % 360) - 180 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeg(d){ return ((d % 360) + 360) % 360; }
|
||||||
|
|
||||||
|
function preferredBearing() {
|
||||||
|
var val = roundTripDirSel ? (roundTripDirSel.value || 'any') : 'any';
|
||||||
|
switch (val) {
|
||||||
|
case 'N': return 0;
|
||||||
|
case 'E': return 90;
|
||||||
|
case 'S': return 180;
|
||||||
|
case 'W': return 270;
|
||||||
|
default: return null; // any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRoundTripWaypoints(start) {
|
||||||
|
var km = parseFloat(roundTripKmInput && roundTripKmInput.value ? roundTripKmInput.value : '100');
|
||||||
|
if (isNaN(km) || km < 5) km = 50;
|
||||||
|
// Approximate radius for a loop with 3 intermediate waypoints: perimeter ≈ 4 * chord ≈ distance
|
||||||
|
// Use radius r ≈ distance / (2π)
|
||||||
|
var meters = km * 1000;
|
||||||
|
var r = Math.max(500, meters / (2 * Math.PI));
|
||||||
|
// If user provided a preferred direction, bias the bearings around that center.
|
||||||
|
var center = preferredBearing();
|
||||||
|
var bearings;
|
||||||
|
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
|
||||||
|
if (center === null) {
|
||||||
|
// Any: keep previous behavior (random base, evenly spaced)
|
||||||
|
var base = (Math.random() * 360) | 0;
|
||||||
|
bearings = noRepeat
|
||||||
|
? [0, 72, 144, 216, 288].map(function(d){ return base + d; })
|
||||||
|
: [45, 165, 285].map(function(d){ return base + d; });
|
||||||
|
} else {
|
||||||
|
// Directional preference: create an arc centered on preferred bearing
|
||||||
|
// Use 3 or 5 points depending on noRepeat, within +/- 60° spread
|
||||||
|
var offsets = noRepeat ? [-60, -30, 0, 30, 60] : [-30, 0, 30];
|
||||||
|
bearings = offsets.map(function(o){ return normalizeDeg(center + o); });
|
||||||
|
}
|
||||||
|
|
||||||
|
var wps = bearings.map(function(b, i){
|
||||||
|
// Slightly push the middle point further out to emphasize the preferred direction
|
||||||
|
var scale = 1.0;
|
||||||
|
if (center !== null) {
|
||||||
|
if ((noRepeat && (i === Math.floor(bearings.length/2))) || (!noRepeat && i === 1)) {
|
||||||
|
scale = 1.15; // middle point
|
||||||
|
} else {
|
||||||
|
scale = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return destPoint(start.lat, start.lng, r * scale, b);
|
||||||
|
});
|
||||||
|
// Ensure endpoints list: start -> wps -> start
|
||||||
|
var routePts = [start].concat(wps).concat([start]);
|
||||||
|
return routePts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Scenic optimizer helpers ----
|
||||||
|
function heading(a, b) {
|
||||||
|
var φ1 = toRad(a.lat), φ2 = toRad(b.lat);
|
||||||
|
var Δλ = toRad(b.lng - a.lng);
|
||||||
|
var y = Math.sin(Δλ) * Math.cos(φ2);
|
||||||
|
var x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
||||||
|
return normalizeDeg(toDeg(Math.atan2(y, x)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function angleDiff(a, b) {
|
||||||
|
var d = Math.abs(a - b) % 360;
|
||||||
|
return d > 180 ? 360 - d : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polylineHeadingDeltas(latlngs) {
|
||||||
|
var deltas = [];
|
||||||
|
for (var i = 1; i < latlngs.length - 1; i++) {
|
||||||
|
var h1 = heading({lat: latlngs[i-1][0], lng: latlngs[i-1][1]}, {lat: latlngs[i][0], lng: latlngs[i][1]});
|
||||||
|
var h2 = heading({lat: latlngs[i][0], lng: latlngs[i][1]}, {lat: latlngs[i+1][0], lng: latlngs[i+1][1]});
|
||||||
|
deltas.push(angleDiff(h1, h2));
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceKm(a, b) {
|
||||||
|
var R = 6371.0;
|
||||||
|
var dLat = toRad(b[0] - a[0]);
|
||||||
|
var dLng = toRad(b[1] - a[1]);
|
||||||
|
var s1 = Math.sin(dLat/2), s2 = Math.sin(dLng/2);
|
||||||
|
var aa = s1*s1 + Math.cos(toRad(a[0]))*Math.cos(toRad(b[0]))*s2*s2;
|
||||||
|
var c = 2 * Math.atan2(Math.sqrt(aa), Math.sqrt(1-aa));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathLengthKm(latlngs) {
|
||||||
|
var km = 0;
|
||||||
|
for (var i = 1; i < latlngs.length; i++) km += distanceKm(latlngs[i-1], latlngs[i]);
|
||||||
|
return km;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scorePolyline(latlngs) {
|
||||||
|
if (!latlngs || latlngs.length < 3) return -1e9;
|
||||||
|
var km = pathLengthKm(latlngs);
|
||||||
|
if (km <= 0) return -1e9;
|
||||||
|
var deltas = polylineHeadingDeltas(latlngs);
|
||||||
|
var sumDelta = deltas.reduce((a,b)=>a+b, 0);
|
||||||
|
var turns = deltas.filter(d => d > 12).length; // count meaningful turns
|
||||||
|
var turnDensity = turns / km; // turns per km
|
||||||
|
var curvature = sumDelta / km; // degrees per km
|
||||||
|
// Penalize very straight long segments implicitly by lower curvature
|
||||||
|
// Compose score: weight curvature more than turn count
|
||||||
|
return curvature * 0.7 + turnDensity * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routeAndScore(pointsList, requestOptions) {
|
||||||
|
var body = {
|
||||||
|
locations: pointsList.map(function(p){ return { lat: p.lat, lon: p.lng }; }),
|
||||||
|
costing: "motorcycle",
|
||||||
|
costing_options: requestOptions.costing_options || {},
|
||||||
|
units: "kilometers"
|
||||||
|
};
|
||||||
|
if (requestOptions.shortest) body.shortest = true;
|
||||||
|
var resp = await fetch('/route', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
||||||
|
var data = await resp.json();
|
||||||
|
var legs = (data.trip && Array.isArray(data.trip.legs)) ? data.trip.legs : [];
|
||||||
|
var latlngs = [];
|
||||||
|
legs.forEach(function(leg){
|
||||||
|
var lls = decodePolyline6(leg.shape || '');
|
||||||
|
if (latlngs.length && lls.length) latlngs = latlngs.concat(lls.slice(1)); else latlngs = latlngs.concat(lls);
|
||||||
|
});
|
||||||
|
var score = scorePolyline(latlngs);
|
||||||
|
return {score: score, data: data, latlngs: latlngs};
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomNormal() {
|
||||||
|
// Box-Muller
|
||||||
|
var u = 1 - Math.random();
|
||||||
|
var v = 1 - Math.random();
|
||||||
|
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateScenicCandidates(start) {
|
||||||
|
var basePts = generateRoundTripWaypoints(start);
|
||||||
|
var center = preferredBearing();
|
||||||
|
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
|
||||||
|
var count = 6;
|
||||||
|
var candidates = [];
|
||||||
|
for (var i = 0; i < count; i++) {
|
||||||
|
var scale = 1 + (Math.random()*0.25 - 0.1); // -10%..+15%
|
||||||
|
var jitter = center === null ? 30 : 15; // tighter if centered
|
||||||
|
var pts = basePts.map(function(p, idx){
|
||||||
|
if (idx === 0 || idx === basePts.length - 1) return p;
|
||||||
|
// compute bearing from start to p and re-project with jitter and scale
|
||||||
|
var br = heading(start, p);
|
||||||
|
var j = randomNormal() * jitter;
|
||||||
|
var d = distanceKm([start.lat, start.lng], [p.lat, p.lng]) * 1000 * scale; // meters
|
||||||
|
return destPoint(start.lat, start.lng, d, br + j);
|
||||||
|
});
|
||||||
|
candidates.push(pts);
|
||||||
|
}
|
||||||
|
// Ensure base version is included as well
|
||||||
|
candidates.unshift(basePts);
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Isochrone helpers ----
|
||||||
|
async function fetchIsochronePolygon(start, minutes, motoOptions) {
|
||||||
|
var body = {
|
||||||
|
locations: [{ lat: start.lat, lon: start.lng }],
|
||||||
|
costing: "motorcycle",
|
||||||
|
costing_options: { motorcycle: motoOptions || {} },
|
||||||
|
contours: [{ time: Math.max(5, Math.min(240, minutes)) }],
|
||||||
|
polygons: true,
|
||||||
|
denoise: 0.5,
|
||||||
|
generalize: 100
|
||||||
|
};
|
||||||
|
var resp = await fetch('/isochrone', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||||
|
var data = await resp.json();
|
||||||
|
// Expect GeoJSON FeatureCollection
|
||||||
|
try {
|
||||||
|
var coords = data.features[0].geometry.coordinates;
|
||||||
|
// Valhalla returns [ [ [lon,lat], ... ] ]
|
||||||
|
var ring = coords[0].map(function(c){ return { lat: c[1], lng: c[0] }; });
|
||||||
|
return ring;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleIsochroneSector(ring, start, centerBearing) {
|
||||||
|
if (!ring || !ring.length) return [];
|
||||||
|
var sector = [];
|
||||||
|
var spread = 60; // +/- degrees
|
||||||
|
ring.forEach(function(p){
|
||||||
|
var br = heading(start, {lat: p.lat, lng: p.lng});
|
||||||
|
if (centerBearing === null) {
|
||||||
|
sector.push(p);
|
||||||
|
} else {
|
||||||
|
if (angleDiff(br, centerBearing) <= spread) sector.push(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (sector.length < 3) {
|
||||||
|
// Fallback to full ring
|
||||||
|
sector = ring.slice();
|
||||||
|
}
|
||||||
|
// Pick 3 or 5 evenly spaced points from sector
|
||||||
|
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
|
||||||
|
var n = noRepeat ? 5 : 3;
|
||||||
|
var step = Math.max(1, Math.floor(sector.length / (n + 1)));
|
||||||
|
var wps = [];
|
||||||
|
for (var i = 1; i <= n; i++) {
|
||||||
|
var idx = Math.min(sector.length - 1, i * step);
|
||||||
|
wps.push(sector[idx]);
|
||||||
|
}
|
||||||
|
return wps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rudimentary land/water detection using backend reverse geocoding
|
||||||
|
async function isLikelyWater(lat, lon) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/reverse?format=json&lat=${lat}&lon=${lon}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
// Heuristics: if class/type indicates water or coastline, treat as water
|
||||||
|
const cls = (data && (data.class || (data.address && data.address.natural))) || '';
|
||||||
|
const typ = (data && (data.type || (data.address && (data.address.water || data.address.waterway)))) || '';
|
||||||
|
const name = (data && data.display_name) || '';
|
||||||
|
const hay = `${cls} ${typ} ${name}`.toLowerCase();
|
||||||
|
return /\b(water|sea|bay|ocean|coast|coastline|harbor|harbour|beach|lake|fjord|inlet|river|estuary)\b/.test(hay);
|
||||||
|
} catch (_) {
|
||||||
|
// On failure, be conservative and assume not water
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adjustPointInland(p, start) {
|
||||||
|
// Try up to ~10 steps of 1km towards start to avoid placing on water
|
||||||
|
const steps = 10;
|
||||||
|
const stepMeters = 1000;
|
||||||
|
let cand = { lat: p.lat, lng: p.lng };
|
||||||
|
// Bearing from candidate to start (move inland towards start)
|
||||||
|
function bearing(from, to) {
|
||||||
|
const φ1 = toRad(from.lat), φ2 = toRad(to.lat);
|
||||||
|
const Δλ = toRad(to.lng - from.lng);
|
||||||
|
const y = Math.sin(Δλ) * Math.cos(φ2);
|
||||||
|
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
||||||
|
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
const water = await isLikelyWater(cand.lat, cand.lng);
|
||||||
|
if (!water) return cand;
|
||||||
|
const brg = bearing(cand, start);
|
||||||
|
cand = destPoint(cand.lat, cand.lng, stepMeters, brg);
|
||||||
|
}
|
||||||
|
return cand;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRoundTrip() {
|
||||||
|
if (!roundTripToggle || !roundTripToggle.checked) return;
|
||||||
|
// Determine start point: prefer first existing point, else sourceInput coords
|
||||||
|
var start = null;
|
||||||
|
if (points.length > 0) {
|
||||||
|
start = { lat: points[0].lat, lng: points[0].lng };
|
||||||
|
} else {
|
||||||
|
var sourceInput = document.getElementById('sourceInput');
|
||||||
|
var sLat = parseFloat(sourceInput && sourceInput.dataset.lat);
|
||||||
|
var sLon = parseFloat(sourceInput && sourceInput.dataset.lon);
|
||||||
|
if (!isNaN(sLat) && !isNaN(sLon)) {
|
||||||
|
start = { lat: sLat, lng: sLon };
|
||||||
|
} else {
|
||||||
|
// fallback: map center
|
||||||
|
var c = map.getCenter();
|
||||||
|
start = { lat: c.lat, lng: c.lng };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing markers/lines
|
||||||
|
markers.forEach(function(m){ map.removeLayer(m); });
|
||||||
|
markers = [];
|
||||||
|
points = [];
|
||||||
|
originalPoints = [];
|
||||||
|
if (routePolyline) { map.removeLayer(routePolyline); routePolyline = null; }
|
||||||
|
if (snapLines && snapLines.length) { snapLines.forEach(function(sl){ map.removeLayer(sl); }); snapLines = []; }
|
||||||
|
|
||||||
|
var useIso = !!(roundTripIsochrone && roundTripIsochrone.checked);
|
||||||
|
var useScenic = !!(roundTripScenic && roundTripScenic.checked);
|
||||||
|
|
||||||
|
var pts;
|
||||||
|
if (useIso) {
|
||||||
|
// Build moto options to avoid ferries etc.
|
||||||
|
var moto = {};
|
||||||
|
if (avoidFerriesCheckbox && avoidFerriesCheckbox.checked) moto.use_ferry = 0.0;
|
||||||
|
moto.use_ferry = 0.0; // default for round trips
|
||||||
|
moto.ferry_cost = 1000;
|
||||||
|
var minutes = parseInt((isochroneMinutesInput && isochroneMinutesInput.value) || localStorage.getItem('freemoto-isochrone-minutes') || '60', 10);
|
||||||
|
var ring = await fetchIsochronePolygon(start, minutes, moto);
|
||||||
|
if (ring && ring.length) {
|
||||||
|
var center = preferredBearing();
|
||||||
|
var wps = sampleIsochroneSector(ring, start, center);
|
||||||
|
pts = [start].concat(wps).concat([start]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!pts) {
|
||||||
|
pts = generateRoundTripWaypoints(start);
|
||||||
|
}
|
||||||
|
// If scenic optimizer is enabled, evaluate a few candidates and pick the twistiest
|
||||||
|
if (useScenic) {
|
||||||
|
try {
|
||||||
|
var requestOptions = { costing_options: { motorcycle: {} } };
|
||||||
|
// Avoid ferries strongly
|
||||||
|
requestOptions.costing_options.motorcycle.use_ferry = 0.0;
|
||||||
|
requestOptions.costing_options.motorcycle.ferry_cost = 1000;
|
||||||
|
|
||||||
|
var candidates = generateScenicCandidates(start);
|
||||||
|
// Replace the base candidate with our current pts at index 0
|
||||||
|
candidates[0] = pts;
|
||||||
|
var best = null;
|
||||||
|
// Evaluate sequentially to avoid hammering the backend
|
||||||
|
for (var ci = 0; ci < candidates.length; ci++) {
|
||||||
|
var cand = candidates[ci];
|
||||||
|
var result = await routeAndScore(cand, requestOptions);
|
||||||
|
if (!best || result.score > best.score) {
|
||||||
|
best = { idx: ci, score: result.score, pts: cand };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
pts = best.pts;
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('Scenic optimizer failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust intermediate points away from water before placing markers
|
||||||
|
var adjusted = await Promise.all(pts.map(async function(p, idx) {
|
||||||
|
if (idx === 0 || idx === pts.length - 1) return p; // keep start/end
|
||||||
|
try {
|
||||||
|
return await adjustPointInland(p, start);
|
||||||
|
} catch (_) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
adjusted.forEach(function(p, idx){
|
||||||
|
var marker = L.marker(p, { draggable: true }).addTo(map);
|
||||||
|
marker.bindPopup(idx === 0 ? 'Start/End' : `WP ${idx}`).openPopup();
|
||||||
|
marker.on('dragend', function(ev){
|
||||||
|
var m = ev.target; var newLL = m.getLatLng(); var i = markers.indexOf(m);
|
||||||
|
if (i >= 0) { points[i] = { lat: newLL.lat, lng: newLL.lng }; originalPoints[i] = { lat: newLL.lat, lng: newLL.lng }; renderWaypointList(); calculateRoute(); }
|
||||||
|
});
|
||||||
|
markers.push(marker);
|
||||||
|
points.push({ lat: p.lat, lng: p.lng });
|
||||||
|
originalPoints.push({ lat: p.lat, lng: p.lng });
|
||||||
|
});
|
||||||
|
renderWaypointList();
|
||||||
|
calculateRoute();
|
||||||
|
}
|
||||||
|
|
||||||
function calculateRoute() {
|
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 };
|
// Twistiness slider from localStorage: 0 (straight/fast) .. 100 (very twisty)
|
||||||
|
try {
|
||||||
|
var twistVal = parseInt(localStorage.getItem('freemoto-twistiness') || '50', 10);
|
||||||
|
if (!isNaN(twistVal)) {
|
||||||
|
// Map 0..100 to highway preference 1.0..0.0 (inverse)
|
||||||
|
var hw = Math.max(0, Math.min(1, 1 - (twistVal / 100)));
|
||||||
|
// If user also checked avoidHighways, keep the stronger effect (min)
|
||||||
|
moto.use_highways = (typeof moto.use_highways === 'number') ? Math.min(moto.use_highways, hw) : hw;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Highway preference slider directly sets use_highways proportionally
|
||||||
|
try {
|
||||||
|
var hwp = parseInt(localStorage.getItem('freemoto-highways') || '50', 10);
|
||||||
|
if (!isNaN(hwp)) {
|
||||||
|
var hw2 = Math.max(0, Math.min(1, hwp / 100));
|
||||||
|
// Combine with existing (take max so user can prefer highways even if twistiness low)
|
||||||
|
moto.use_highways = (typeof moto.use_highways === 'number') ? Math.max(moto.use_highways, hw2) : hw2;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// For round trips, avoid ferries by default to prevent water crossings
|
||||||
|
try {
|
||||||
|
if (roundTripToggle && roundTripToggle.checked) {
|
||||||
|
moto.use_ferry = 0.0; // strongest avoidance
|
||||||
|
// Slightly increase ferry_cost as an extra safety
|
||||||
|
moto.ferry_cost = 1000;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
var costing_options = { motorcycle: moto };
|
||||||
|
|
||||||
var requestBody = {
|
var requestBody = {
|
||||||
locations: [
|
locations: points.map(function(p){ return { lat: p.lat, lon: p.lng }; }),
|
||||||
{ lat: points[0].lat, lon: points[0].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',
|
||||||
@@ -47,47 +499,256 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.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');
|
var infoCard = document.getElementById('routeInfoCard');
|
||||||
if (leg && leg.summary && typeof leg.summary.length === 'number' && typeof leg.summary.time === 'number') {
|
// Sum summaries across legs if needed
|
||||||
var distanceKm = (leg.summary.length / 1000).toFixed(1); // meters to km
|
var totalLen = 0, totalTime = 0;
|
||||||
var durationMin = Math.round(leg.summary.time / 60); // seconds to minutes
|
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>
|
var info = `<strong>Distance:</strong> ${distanceKm} km<br>
|
||||||
<strong>Estimated Time:</strong> ${durationMin} min<br>
|
<strong>Estimated Time:</strong> ${durationMin} min<br>
|
||||||
<strong>Motorcycle Profile</strong>`;
|
<strong>Motorcycle Profile</strong>`;
|
||||||
infoCard.innerHTML = info;
|
infoCard.innerHTML = info;
|
||||||
infoCard.classList.remove('d-none');
|
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 {
|
} else {
|
||||||
infoCard.innerHTML = `<strong>Route info unavailable.</strong>`;
|
infoCard.innerHTML = `<strong>Route info unavailable.</strong>`;
|
||||||
infoCard.classList.remove('d-none');
|
infoCard.classList.remove('d-none');
|
||||||
console.log('Valhalla response:', data);
|
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) {
|
if (routePolyline) {
|
||||||
map.removeLayer(routePolyline);
|
map.removeLayer(routePolyline);
|
||||||
}
|
}
|
||||||
routePolyline = L.polyline(latlngs, { color: 'red', weight: 5}).addTo(map);
|
routePolyline = L.polyline(latlngs, { color: 'red', weight: 5}).addTo(map);
|
||||||
map.fitBounds(routePolyline.getBounds());
|
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) {
|
// Visual pulse effect at a latlng
|
||||||
if (points.length < 2) {
|
function pulseAt(latlng) {
|
||||||
var marker = L.marker(e.latlng).addTo(map);
|
try {
|
||||||
markers.push(marker);
|
var pulse = L.circle(latlng, { radius: 5, color: '#0d6efd', weight: 2, opacity: 0.8, fillOpacity: 0.2 });
|
||||||
points.push(e.latlng);
|
pulse.addTo(map);
|
||||||
marker.bindPopup(points.length === 1 ? "Start" : "End").openPopup();
|
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
|
function placePoint(latlng) {
|
||||||
if (points.length === 1) {
|
if (points.length >= 10) { alert('Maximum of 10 waypoints.'); return; }
|
||||||
reverseGeocode(e.latlng.lat, e.latlng.lng, 'sourceInput');
|
var marker = L.marker(latlng, { draggable: true }).addTo(map);
|
||||||
} else if (points.length === 2) {
|
markers.push(marker);
|
||||||
reverseGeocode(e.latlng.lat, e.latlng.lng, 'destInput');
|
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();
|
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
|
// Listen for changes on all checkboxes
|
||||||
[
|
[
|
||||||
@@ -153,10 +814,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
markers = [];
|
markers = [];
|
||||||
points = [];
|
points = [];
|
||||||
|
originalPoints = [];
|
||||||
if (routePolyline) {
|
if (routePolyline) {
|
||||||
map.removeLayer(routePolyline);
|
map.removeLayer(routePolyline);
|
||||||
routePolyline = null;
|
routePolyline = null;
|
||||||
}
|
}
|
||||||
|
if (snapLines && snapLines.length) {
|
||||||
|
snapLines.forEach(function(sl){ map.removeLayer(sl); });
|
||||||
|
snapLines = [];
|
||||||
|
}
|
||||||
|
if (waypointListEl) waypointListEl.innerHTML = '';
|
||||||
// Clear address fields
|
// Clear address fields
|
||||||
var sourceInput = document.getElementById('sourceInput');
|
var sourceInput = document.getElementById('sourceInput');
|
||||||
var destInput = document.getElementById('destInput');
|
var destInput = document.getElementById('destInput');
|
||||||
@@ -166,6 +833,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
delete sourceInput.dataset.lon;
|
delete sourceInput.dataset.lon;
|
||||||
delete destInput.dataset.lat;
|
delete destInput.dataset.lat;
|
||||||
delete destInput.dataset.lon;
|
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
|
// Make resetMarkers available globally
|
||||||
@@ -201,16 +878,19 @@ 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);
|
||||||
if (data && data.address) {
|
if (!input) return;
|
||||||
// Use the same format as your autocomplete
|
input.value = (data && (data.display_name || (data.address && (data.address.road || data.address.city)))) || `${lat}, ${lon}`;
|
||||||
input.value = formatAddress(data);
|
input.dataset.lat = lat;
|
||||||
} else {
|
input.dataset.lon = lon;
|
||||||
input.value = `${lat}, ${lon}`;
|
})
|
||||||
}
|
.catch(() => {
|
||||||
|
var input = document.getElementById(inputId);
|
||||||
|
if (!input) return;
|
||||||
|
input.value = `${lat}, ${lon}`;
|
||||||
input.dataset.lat = lat;
|
input.dataset.lat = lat;
|
||||||
input.dataset.lon = lon;
|
input.dataset.lon = lon;
|
||||||
});
|
});
|
||||||
@@ -221,34 +901,79 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
alert("No route to export.");
|
alert("No route to export.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let gpx =
|
const now = new Date().toISOString();
|
||||||
`<?xml version="1.0" encoding="UTF-8"?>
|
// Build <rte> using Valhalla maneuvers (snap to their begin shape indices)
|
||||||
<gpx version="1.1" creator="FreeMoto" xmlns="http://www.topografix.com/GPX/1/1">
|
var rtepts = '';
|
||||||
<trk>
|
if (Array.isArray(lastRtePoints) && lastRtePoints.length) {
|
||||||
<name>FreeMoto Route</name>
|
rtepts = lastRtePoints.map(function(p){
|
||||||
<trkseg>
|
var safe = (p.name || 'Step').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
${latlngs.map(pt => ` <trkpt lat="${pt[0]}" lon="${pt[1]}"></trkpt>`).join('\n')}
|
return ` <rtept lat="${p.lat}" lon="${p.lon}"><name>${safe}</name></rtept>`;
|
||||||
</trkseg>
|
}).join('\n');
|
||||||
</trk>
|
}
|
||||||
</gpx>`;
|
|
||||||
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);
|
let url = URL.createObjectURL(blob);
|
||||||
let a = document.createElement('a');
|
|
||||||
a.href = url;
|
// iOS workaround: open in new tab instead of triggering download
|
||||||
a.download = "freemoto-route.gpx";
|
if (navigator.userAgent.match(/(iPad|iPhone|iPod)/i)) {
|
||||||
document.body.appendChild(a);
|
window.open(url, '_blank');
|
||||||
a.click();
|
} else {
|
||||||
document.body.removeChild(a);
|
let a = document.createElement('a');
|
||||||
URL.revokeObjectURL(url);
|
a.href = url;
|
||||||
|
a.download = 'freemoto-route.gpx';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('exportGpxBtn').addEventListener('click', function() {
|
// Bind all GPX export buttons
|
||||||
if (routePolyline) {
|
document.querySelectorAll('.export-gpx').forEach(function(btn){
|
||||||
// routePolyline.getLatLngs() returns array of LatLng objects
|
btn.addEventListener('click', function() {
|
||||||
let latlngs = routePolyline.getLatLngs().map(ll => [ll.lat, ll.lng]);
|
if (routePolyline) {
|
||||||
exportRouteAsGPX(latlngs);
|
let latlngs = routePolyline.getLatLngs().map(ll => [ll.lat, ll.lng]);
|
||||||
} else {
|
exportRouteAsGPX(latlngs);
|
||||||
alert("No route to export.");
|
} else {
|
||||||
}
|
alert('No route to export.');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close directions sheet
|
||||||
|
var closeBtn = document.getElementById('closeDirections');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', function() {
|
||||||
|
var sheet = document.getElementById('directionsSheet');
|
||||||
|
if (sheet) sheet.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also hide summary pill when sheet closed manually
|
||||||
|
var pill = document.getElementById('summaryPill');
|
||||||
|
if (closeBtn && pill) {
|
||||||
|
closeBtn.addEventListener('click', function(){ pill.classList.add('d-none'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose recalc for twistiness changes
|
||||||
|
window.recalculateRoute = function(){ calculateRoute(); };
|
||||||
|
window.createRoundTrip = function(){ createRoundTrip(); };
|
||||||
});
|
});
|
||||||
290
app/web/static/styles.css
Normal file
290
app/web/static/styles.css
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/* FreeMoto — Fresh responsive UI
|
||||||
|
Author: Cascade
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--panel-radius: 16px;
|
||||||
|
--pill-radius: 999px;
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.9);
|
||||||
|
--glass-border: rgba(16, 24, 40, 0.08);
|
||||||
|
--brand-start: #0ea5e9;
|
||||||
|
--brand-end: #6366f1;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--shadow-1: 0 8px 24px rgba(2, 8, 20, 0.08);
|
||||||
|
--shadow-2: 0 10px 30px rgba(2, 8, 20, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden; /* map-focused app; internal sections handle their own scroll */
|
||||||
|
background: linear-gradient(180deg, #f1f5f9, #e2e8f0);
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map canvas */
|
||||||
|
#map {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top App Bar */
|
||||||
|
.appbar {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
z-index: 1002;
|
||||||
|
background: linear-gradient(90deg, var(--brand-start), var(--brand-end));
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--shadow-1);
|
||||||
|
}
|
||||||
|
.appbar .navbar-brand {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.appbar .btn {
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
.appbar .badge {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Control Panel (mobile: bottom pill, desktop: floating card) */
|
||||||
|
.nav-panel {
|
||||||
|
position: fixed;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 1001;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
backdrop-filter: blur(10px) saturate(160%);
|
||||||
|
border-radius: var(--pill-radius);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
padding: 10px 12px;
|
||||||
|
transition: max-height 200ms ease, transform 200ms ease, padding 200ms ease;
|
||||||
|
overflow: auto; /* allow scroll when content grows */
|
||||||
|
max-height: 60vh; /* default for mobile */
|
||||||
|
}
|
||||||
|
.nav-panel .form-control,
|
||||||
|
.nav-panel .btn {
|
||||||
|
border-radius: var(--pill-radius);
|
||||||
|
}
|
||||||
|
.nav-panel .input-group > .input-group-text {
|
||||||
|
border-radius: var(--pill-radius) 0 0 var(--pill-radius);
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
#routeInfoCard {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suggestions dropdown */
|
||||||
|
#sourceSuggestions,
|
||||||
|
#destSuggestions {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions bottom sheet */
|
||||||
|
.sheet {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
.sheet-card {
|
||||||
|
border-radius: var(--panel-radius) var(--panel-radius) 0 0;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow: hidden; /* header fixed; body scrolls */
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
.sheet .handle {
|
||||||
|
width: 44px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: var(--pill-radius);
|
||||||
|
background: #cbd5e1;
|
||||||
|
margin: 6px auto 6px;
|
||||||
|
}
|
||||||
|
.sheet-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #fff;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid #eef2f6;
|
||||||
|
}
|
||||||
|
.sheet-body { overflow: auto; max-height: calc(60vh - 42px); }
|
||||||
|
#directionsSheet { display: none; }
|
||||||
|
.sheet.collapsed .sheet-card { max-height: 28vh; }
|
||||||
|
.sheet.collapsed .sheet-body { max-height: calc(28vh - 42px); }
|
||||||
|
|
||||||
|
/* Floating actions (mobile) */
|
||||||
|
.floating-group {
|
||||||
|
position: fixed;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 90px;
|
||||||
|
z-index: 1002;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-group .fab,
|
||||||
|
.floating-group .btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-group .fab {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle button for control panel on mobile */
|
||||||
|
.panel-toggle {
|
||||||
|
position: fixed;
|
||||||
|
left: 14px;
|
||||||
|
bottom: 90px;
|
||||||
|
z-index: 1002;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile portrait refinements */
|
||||||
|
@media (max-width: 576px) and (orientation: portrait) {
|
||||||
|
.nav-panel {
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
max-height: 56vh; /* slightly taller for more controls */
|
||||||
|
overflow: auto; /* scroll internally */
|
||||||
|
}
|
||||||
|
.nav-panel.collapsed {
|
||||||
|
max-height: 56px; /* show a small header */
|
||||||
|
padding: 8px 12px;
|
||||||
|
pointer-events: none; /* let map interactions pass through when collapsed */
|
||||||
|
}
|
||||||
|
.floating-group { bottom: 76px; }
|
||||||
|
.panel-toggle { bottom: 76px; }
|
||||||
|
.section-title { font-size: 0.95rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop/tablet layout refinements */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.nav-panel {
|
||||||
|
top: 88px;
|
||||||
|
bottom: 24px; /* allow taller panel while preserving map footer space */
|
||||||
|
left: 24px;
|
||||||
|
right: auto;
|
||||||
|
width: min(560px, 42vw);
|
||||||
|
border-radius: var(--panel-radius);
|
||||||
|
padding: 16px;
|
||||||
|
max-height: calc(100vh - 88px - 24px);
|
||||||
|
overflow: auto; /* scroll the panel content when needed */
|
||||||
|
}
|
||||||
|
.nav-panel .form-control,
|
||||||
|
.nav-panel .btn {
|
||||||
|
border-radius: var(--panel-radius);
|
||||||
|
}
|
||||||
|
.nav-panel .input-group > .input-group-text {
|
||||||
|
border-radius: var(--panel-radius) 0 0 var(--panel-radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make long lists scrollable inside the panel */
|
||||||
|
.nav-panel .list-group {
|
||||||
|
max-height: 34vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for WebKit browsers */
|
||||||
|
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
*::-webkit-scrollbar-thumb { background-color: rgba(100, 116, 139, 0.5); border-radius: 8px; }
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background-color: rgba(71, 85, 105, 0.7); }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
/* Dark theme */
|
||||||
|
/* Improved dark mode for readability */
|
||||||
|
[data-theme="dark"] :root {
|
||||||
|
--glass-bg: rgba(17, 24, 39, 0.92); /* slate-900 w/ opacity */
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.06);
|
||||||
|
--text-muted: #9ca3af; /* slate-400 */
|
||||||
|
}
|
||||||
|
[data-theme="dark"] body {
|
||||||
|
background: linear-gradient(180deg, #0b1220, #0f172a);
|
||||||
|
color: #e5e7eb; /* slate-200 */
|
||||||
|
}
|
||||||
|
[data-theme="dark"] a { color: #93c5fd; }
|
||||||
|
[data-theme="dark"] .appbar {
|
||||||
|
background: linear-gradient(90deg, #334155, #475569); /* slate gradient */
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .appbar .navbar-brand { color: #f1f5f9; }
|
||||||
|
[data-theme="dark"] .appbar .btn {
|
||||||
|
color: #e5e7eb;
|
||||||
|
border-color: rgba(148, 163, 184, 0.7);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .nav-panel {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border-color: var(--glass-border);
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.55);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .section-title { color: #e2e8f0; }
|
||||||
|
[data-theme="dark"] .form-control,
|
||||||
|
[data-theme="dark"] .input-group-text {
|
||||||
|
background: #111827; /* gray-900 */
|
||||||
|
color: #e5e7eb;
|
||||||
|
border-color: #374151; /* gray-700 */
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .form-control::placeholder { color: #9ca3af; opacity: 1; }
|
||||||
|
[data-theme="dark"] .btn-outline-secondary { color: #e5e7eb; border-color: #93c5fd; }
|
||||||
|
[data-theme="dark"] .btn-primary { background-color: #4f46e5; border-color: #4338ca; }
|
||||||
|
[data-theme="dark"] .btn-success { background-color: #16a34a; border-color: #15803d; color: #f9fafb; }
|
||||||
|
[data-theme="dark"] .btn-warning { background-color: #f59e0b; border-color: #d97706; color: #111827; }
|
||||||
|
[data-theme="dark"] .badge,
|
||||||
|
[data-theme="dark"] .badge.text-bg-light { background-color: #374151 !important; color: #f9fafb !important; }
|
||||||
|
[data-theme="dark"] #summaryPill.badge { background-color: #1f2937 !important; color: #f9fafb !important; border: 1px solid #334155; }
|
||||||
|
[data-theme="dark"] .card,
|
||||||
|
[data-theme="dark"] .sheet-card { background: #0f172a; color: #e5e7eb; border-color: #334155; }
|
||||||
|
[data-theme="dark"] .list-group-item { background: #0b1220; color: #e5e7eb; border-color: #334155; }
|
||||||
|
[data-theme="dark"] .list-group-item-action:hover { background-color: #111827; color: #ffffff; }
|
||||||
|
[data-theme="dark"] .sheet-header { background: #0b1220; border-bottom-color: #334155; }
|
||||||
|
[data-theme="dark"] .handle { background: #475569; }
|
||||||
|
[data-theme="dark"] .alert { background-color: #0f172a; color: #e5e7eb; border-color: #334155; }
|
||||||
|
[data-theme="dark"] .alert-info { background-color: #0b1e2b; border-color: #1f3a5f; color: #dbeafe; }
|
||||||
|
[data-theme="dark"] .alert-secondary { background-color: #1f2937; border-color: #374151; color: #e5e7eb; }
|
||||||
|
|
||||||
|
/* Dark accordion */
|
||||||
|
[data-theme="dark"] .accordion-item { background-color: #0b1220; border: 1px solid #334155; }
|
||||||
|
[data-theme="dark"] .accordion-button { background-color: #0b1220; color: #e5e7eb; }
|
||||||
|
[data-theme="dark"] .accordion-button:not(.collapsed) { background-color: #0f172a; color: #ffffff; box-shadow: inset 0 -1px 0 #334155; }
|
||||||
|
[data-theme="dark"] .accordion-button:focus { box-shadow: 0 0 0 .2rem rgba(147, 197, 253, 0.25); border-color: #93c5fd; }
|
||||||
|
[data-theme="dark"] .accordion-body { background-color: #0f172a; color: #e5e7eb; }
|
||||||
|
|
||||||
|
/* Outline button hover/focus in dark */
|
||||||
|
[data-theme="dark"] .btn-outline-secondary:hover,
|
||||||
|
[data-theme="dark"] .btn-outline-secondary:focus { background-color: rgba(147, 197, 253, 0.12); color: #e5e7eb; border-color: #93c5fd; }
|
||||||
|
|
||||||
|
/* Range sliders in dark mode */
|
||||||
|
[data-theme="dark"] input[type="range"] { accent-color: #93c5fd; }
|
||||||
|
[data-theme="dark"] .form-range::-webkit-slider-thumb { background-color: #93c5fd; }
|
||||||
|
[data-theme="dark"] .form-range::-moz-range-thumb { background-color: #93c5fd; }
|
||||||
|
[data-theme="dark"] .form-range::-ms-thumb { background-color: #93c5fd; }
|
||||||
|
|
||||||
|
/* Suggestions dropdown contrast */
|
||||||
|
[data-theme="dark"] #sourceSuggestions .list-group-item,
|
||||||
|
[data-theme="dark"] #destSuggestions .list-group-item { background-color: #0f172a; color: #e5e7eb; border-color: #334155; }
|
||||||
|
[data-theme="dark"] #sourceSuggestions .list-group-item:hover,
|
||||||
|
[data-theme="dark"] #destSuggestions .list-group-item:hover { background-color: #111827; color: #ffffff; }
|
||||||
@@ -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
BIN
bin/freemoto-web
Normal file
Binary file not shown.
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
freemoto-web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: freemoto-web
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
# Defaults are handled in the app, but you can override here if needed
|
||||||
|
# - PORT=8080
|
||||||
|
# - NOMINATIM_URL=https://nominatim.openstreetmap.org
|
||||||
|
# - NOMINATIM_USER_AGENT=FreeMoto/1.0 (+https://fm.ztsw.de/)
|
||||||
|
# - LOG_LEVEL=info
|
||||||
|
- VALHALLA_URL=http://valhalla:8002/route
|
||||||
|
depends_on:
|
||||||
|
- valhalla
|
||||||
|
|
||||||
|
valhalla:
|
||||||
|
image: ghcr.io/valhalla/valhalla-scripted:latest
|
||||||
|
container_name: valhalla
|
||||||
|
tty: true
|
||||||
|
ports:
|
||||||
|
- "8002:8002"
|
||||||
|
volumes:
|
||||||
|
# Mount a host directory for custom files and tile caching if desired
|
||||||
|
- ./custom_files:/custom_files
|
||||||
|
environment:
|
||||||
|
- tile_urls=https://download.geofabrik.de/europe/germany-latest.osm.pbf
|
||||||
Reference in New Issue
Block a user