Compare commits

..

12 Commits

Author SHA1 Message Date
Davide Scaini 62bb474908 Auth: support RS256 OIDC tokens from bincio-auth 2026-06-03 21:24:03 +02:00
Davide Scaini 3d09097eb3 Switch auth to bincio-auth JWT; fix login redirect to bincio.org 2026-06-03 21:00:29 +02:00
Davide Scaini 15a993ecc6 Drag-and-drop waypoint reordering in sidebar list 2026-05-22 14:23:19 +02:00
Davide Scaini fa6225d2cd Show route description in plan cards 2026-05-22 12:13:09 +02:00
Davide Scaini f20407eecf Add optional route description field 2026-05-22 12:06:17 +02:00
Davide Scaini 67b721d872 many changes... added color to slope 2026-05-18 21:24:11 +02:00
Davide Scaini 89f76829b7 Route insertion: larger snap marker, wider hit-test, thicker route line 2026-05-14 10:10:57 +02:00
Davide Scaini ed90a1830b Remove brbike profile (not on public Brouter instance) 2026-05-14 09:59:04 +02:00
Davide Scaini c42e3498c2 Profiles: add BrBike and Safety; rename Hiking -> Trekking 2026-05-14 09:56:31 +02:00
Davide Scaini 8c60769fa6 server: replace requirements.txt with pyproject.toml (use uv) 2026-05-14 09:48:40 +02:00
Davide Scaini 6d095ba3ea Add save/load plans: backend server + frontend UI 2026-05-14 09:46:52 +02:00
Davide Scaini 626d145861 Map: sidebar pills for 4 tile layers (CyclOSM/OSM/Topo/Sat); fix elevation ascend property 2026-05-14 09:43:46 +02:00
7 changed files with 2262 additions and 88 deletions
+22 -1
View File
@@ -45,10 +45,31 @@ The bincio_activity server must allow CORS from `planner.bincio.org` — this is
- [x] Auth gate with redirect to bincio login
- [x] Deployed to `https://planner.bincio.org` with HTTPS
## Activity-year colour palette
Year-coded colours used for GPX reference tracks (and potentially other per-year data). Defined as `GPX_PALETTE` in `Planner.svelte`.
| Year | Color | Description |
|------|-------|-------------|
| 2014 | `hsl(265, 38%, 52%)` | muted purple |
| 2015 | `hsl(248, 40%, 53%)` | slate-indigo |
| 2016 | `hsl(232, 43%, 54%)` | slate-blue |
| 2017 | `hsl(208, 48%, 52%)` | steel blue |
| 2018 | `hsl(185, 52%, 50%)` | teal |
| 2019 | `hsl(165, 50%, 49%)` | green-teal |
| 2020 | `hsl(145, 48%, 47%)` | green |
| 2021 | `hsl(100, 57%, 49%)` | yellow-green |
| 2022 | `hsl(50, 72%, 52%)` | amber |
| 2023 | `hsl(34, 75%, 53%)` | orange |
| 2024 | `hsl(16, 77%, 56%)` | red-orange |
| 2025 | `hsl(5, 70%, 57%)` | warm coral |
| 2026 | `#60a5fa` | bright blue |
| — | `#71717a` | grey (undated) |
## What's missing (from original plan)
- [ ] **Map tile switcher** — toggle between OSM and OpenCycleMap (Thunderforest, requires free API key). OpenCycleMap is significantly better for route planning.
- [ ] **Drag-to-modify route polyline** — click and drag a point on the route line to insert a new waypoint. Deferred: requires hit-testing which segment was dragged, then inserting a waypoint at the right index. Doable but fiddly.
- [x] **Drag-to-modify route polyline** — click and drag a point on the route line to insert a new waypoint. Deferred: requires hit-testing which segment was dragged, then inserting a waypoint at the right index. Doable but fiddly.
- [ ] **Heatmap overlay** — nice-to-have. Our own data is too sparse (~20 users). Public options: Strava global heatmap (legally gray), Waymarked Trails (clean, OSM-based).
- [ ] **Palette flash** — the race-calendar palette runs in `onMount`, so there's a brief flash from the default CSS vars to the correct palette. In bincio_activity this is avoided by an inline `<script>` in `<head>`. Svelte 5 makes this harder — low priority.
- [ ] **Self-hosted Brouter** — public instance is fine for current traffic. Worth revisiting if usage grows or uptime becomes an issue.
+25 -1
View File
@@ -1,7 +1,31 @@
#!/usr/bin/env bash
# deploy.sh — build and deploy BincioPlanner to planner.bincio.org
#
# Frontend: built by Vite, rsynced to /var/www/planner/
# Backend: server/ rsynced to the WorkingDirectory of bincio-planner.service,
# then the service is restarted to pick up changes.
set -e
VPS=root@95.216.55.151
VITE_ACTIVITY_URL=https://activity.bincio.org npm run build
# ── Frontend ──────────────────────────────────────────────────────────────────
echo "Building frontend…"
VITE_ACTIVITY_URL=https://activity.bincio.org VITE_AUTH_URL=https://bincio.org VITE_PLANNER_API_URL= npm run build
echo "Deploying frontend…"
rsync -az --delete dist/ "$VPS:/var/www/planner/"
# ── Backend ───────────────────────────────────────────────────────────────────
echo "Deploying backend…"
SERVER_DIR=$(ssh "$VPS" "systemctl show bincio-planner.service -p WorkingDirectory --value")
if [[ -z "$SERVER_DIR" ]]; then
echo "ERROR: could not read WorkingDirectory from bincio-planner.service" >&2
exit 1
fi
rsync -az server/server.py server/pyproject.toml "$VPS:$SERVER_DIR/"
ssh "$VPS" "cd $SERVER_DIR && uv sync -q"
echo "Restarting bincio-planner.service…"
ssh "$VPS" "systemctl restart bincio-planner.service && systemctl is-active bincio-planner.service"
echo "Deployed to planner.bincio.org"
+11
View File
@@ -0,0 +1,11 @@
[project]
name = "bincio-planner-server"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.111",
"uvicorn[standard]>=0.29",
"pydantic>=2.0",
"PyJWT>=2.8",
"cryptography>=42",
]
+445
View File
@@ -0,0 +1,445 @@
"""BincioPlanner API — save/load route plans.
Auth: validates bincio_session JWT from bincio-auth.
Accepts RS256 OIDC tokens (BINCIO_OIDC_ISSUER) and HS256 session tokens (BINCIO_AUTH_JWT_SECRET).
Storage: JSON files at $PLANS_DIR/{handle}/{plan_id}.json
Collections: $PLANS_DIR/{handle}/_collections.json
Shared plans: $PLANS_DIR/_shared/{plan_id}.json (any authed user can read/write/delete)
Run: uvicorn server:app --host 127.0.0.1 --port 8002
Env vars:
BINCIO_OIDC_ISSUER OIDC issuer URL — JWKS fetched from {issuer}/.well-known/jwks.json
BINCIO_AUTH_JWT_SECRET HS256 fallback secret
PLANS_DIR root dir for plan JSON files (default: /var/bincio_planner)
DEV_HANDLE bypass auth in local dev
"""
from __future__ import annotations
import json
import os
import re
import secrets
import time
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import jwt as _jwt
from jwt.algorithms import RSAAlgorithm as _RSAAlgorithm
from fastapi import Cookie, Depends, FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
_JWT_SECRET = os.environ.get("BINCIO_AUTH_JWT_SECRET", "")
_OIDC_ISSUER = os.environ.get("BINCIO_OIDC_ISSUER", "")
_PLANS_DIR = Path(os.environ.get("PLANS_DIR", "/var/bincio_planner"))
_DEV_HANDLE = os.environ.get("DEV_HANDLE", "")
_SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
# Load RS256 public key from JWKS at startup.
_rs256_key = None
if _OIDC_ISSUER:
try:
with urllib.request.urlopen(f"{_OIDC_ISSUER}/.well-known/jwks.json", timeout=5) as _r:
_jwks = json.loads(_r.read())
for _k in _jwks.get("keys", []):
if _k.get("kty") == "RSA":
_rs256_key = _RSAAlgorithm.from_jwk(json.dumps(_k))
break
except Exception as _e:
print(f"Warning: could not load JWKS from {_OIDC_ISSUER}: {_e}")
# ── Auth ───────────────────────────────────────────────────────────────────────
@dataclass
class User:
handle: str
display_name: str
def _decode_session(token: str) -> Optional[User]:
# Try RS256 (OIDC id_token from bincio-auth)
if _rs256_key:
try:
payload = _jwt.decode(token, _rs256_key, algorithms=["RS256"],
options={"verify_aud": False})
handle = payload.get("sub")
if handle and payload.get("activity_access"):
return User(handle=handle,
display_name=payload.get("name") or payload.get("display_name") or handle)
except _jwt.PyJWTError:
pass
# Fall back to HS256 session token
if not _JWT_SECRET:
return None
try:
payload = _jwt.decode(token, _JWT_SECRET, algorithms=["HS256"])
except _jwt.PyJWTError:
return None
handle = payload.get("sub")
if not handle or not payload.get("activity_access"):
return None
return User(handle=handle, display_name=payload.get("display_name", handle))
async def require_auth(bincio_session: Optional[str] = Cookie(default=None)) -> User:
if _DEV_HANDLE:
return User(handle=_DEV_HANDLE, display_name=_DEV_HANDLE)
if not bincio_session:
raise HTTPException(401, "Authentication required")
user = _decode_session(bincio_session)
if not user:
raise HTTPException(401, "Authentication required")
return user
# ── App ────────────────────────────────────────────────────────────────────────
app = FastAPI(title="BincioPlanner API", docs_url=None, redoc_url=None)
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"https?://localhost(:\d+)?",
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type"],
)
# ── Storage helpers ────────────────────────────────────────────────────────────
def _user_dir(handle: str) -> Path:
d = _PLANS_DIR / handle
d.mkdir(parents=True, exist_ok=True)
return d
def _plan_path(handle: str, plan_id: str) -> Path:
if not _SAFE_ID.match(plan_id):
raise HTTPException(400, "Invalid plan id")
return _PLANS_DIR / handle / f"{plan_id}.json"
def _collections_path(handle: str) -> Path:
return _PLANS_DIR / handle / "_collections.json"
def _read_collections(handle: str) -> list[dict]:
p = _collections_path(handle)
if not p.exists():
return []
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return []
def _write_collections(handle: str, cols: list[dict]) -> None:
_user_dir(handle)
_collections_path(handle).write_text(json.dumps(cols), encoding="utf-8")
def _shared_dir() -> Path:
d = _PLANS_DIR / "_shared"
d.mkdir(parents=True, exist_ok=True)
return d
def _shared_plan_path(plan_id: str) -> Path:
if not _SAFE_ID.match(plan_id):
raise HTTPException(400, "Invalid plan id")
return _PLANS_DIR / "_shared" / f"{plan_id}.json"
# ── Endpoints ──────────────────────────────────────────────────────────────────
class PlanBody(BaseModel):
name: str
waypoints: list[dict]
profile: str
description: str | None = None
geojson: dict | None = None
gpxTrack: dict | None = None # full GeoJSON of reference track, if any GPX segments used
gpxColorIdx: int | None = None
gpxOpacity: float | None = None
collection_id: str | None = None
dist_km: float | None = None
elevation_gain: int | None = None
class CollectionBody(BaseModel):
name: str
@app.get("/api/plans")
async def list_plans(user: User = Depends(require_auth)) -> JSONResponse:
d = _PLANS_DIR / user.handle
if not d.exists():
return JSONResponse({"plans": []})
plans = []
_STRIP = {"geojson", "gpxTrack"}
for p in sorted(d.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True):
if p.name.startswith("_"):
continue
try:
data = json.loads(p.read_text(encoding="utf-8"))
plans.append({k: v for k, v in data.items() if k not in _STRIP})
except Exception:
pass
return JSONResponse({"plans": plans})
@app.post("/api/plans")
async def create_plan(body: PlanBody, user: User = Depends(require_auth)) -> JSONResponse:
plan_id = secrets.token_urlsafe(12)
now = int(time.time())
data: dict = {
"id": plan_id,
"name": body.name.strip() or "Unnamed route",
"waypoints": body.waypoints,
"profile": body.profile,
"created_at": now,
"updated_at": now,
}
if body.description:
data["description"] = body.description
if body.collection_id:
data["collection_id"] = body.collection_id
if body.dist_km is not None:
data["dist_km"] = body.dist_km
if body.elevation_gain is not None:
data["elevation_gain"] = body.elevation_gain
if body.geojson is not None:
data["geojson"] = body.geojson
if body.gpxTrack is not None:
data["gpxTrack"] = body.gpxTrack
data["gpxColorIdx"] = body.gpxColorIdx
data["gpxOpacity"] = body.gpxOpacity
_user_dir(user.handle).joinpath(f"{plan_id}.json").write_text(
json.dumps(data), encoding="utf-8"
)
return JSONResponse({"id": plan_id, "created_at": now})
@app.get("/api/plans/{plan_id}")
async def get_plan(plan_id: str, user: User = Depends(require_auth)) -> JSONResponse:
p = _plan_path(user.handle, plan_id)
if not p.exists():
raise HTTPException(404, "Plan not found")
return JSONResponse(json.loads(p.read_text(encoding="utf-8")))
@app.put("/api/plans/{plan_id}")
async def update_plan(
plan_id: str, body: PlanBody, user: User = Depends(require_auth)
) -> JSONResponse:
p = _plan_path(user.handle, plan_id)
if not p.exists():
raise HTTPException(404, "Plan not found")
data = json.loads(p.read_text(encoding="utf-8"))
data.update({
"name": body.name.strip() or data.get("name", "Unnamed route"),
"waypoints": body.waypoints,
"profile": body.profile,
"updated_at": int(time.time()),
})
# description: non-null sets, null clears
if body.description:
data["description"] = body.description
else:
data.pop("description", None)
# collection_id: non-null assigns, null unassigns
if body.collection_id:
data["collection_id"] = body.collection_id
else:
data.pop("collection_id", None)
if body.dist_km is not None:
data["dist_km"] = body.dist_km
if body.elevation_gain is not None:
data["elevation_gain"] = body.elevation_gain
if body.geojson is not None:
data["geojson"] = body.geojson
if body.gpxTrack is not None:
data["gpxTrack"] = body.gpxTrack
data["gpxColorIdx"] = body.gpxColorIdx
data["gpxOpacity"] = body.gpxOpacity
elif "gpxTrack" in data:
del data["gpxTrack"]
data.pop("gpxColorIdx", None)
data.pop("gpxOpacity", None)
p.write_text(json.dumps(data), encoding="utf-8")
return JSONResponse({"id": plan_id, "updated": True})
@app.delete("/api/plans/{plan_id}")
async def delete_plan(plan_id: str, user: User = Depends(require_auth)) -> JSONResponse:
p = _plan_path(user.handle, plan_id)
if not p.exists():
raise HTTPException(404, "Plan not found")
p.unlink()
return JSONResponse({"id": plan_id, "deleted": True})
# ── Collection endpoints ───────────────────────────────────────────────────────
@app.get("/api/collections")
async def list_collections(user: User = Depends(require_auth)) -> JSONResponse:
return JSONResponse({"collections": _read_collections(user.handle)})
@app.post("/api/collections")
async def create_collection(body: CollectionBody, user: User = Depends(require_auth)) -> JSONResponse:
col_id = secrets.token_urlsafe(12)
now = int(time.time())
cols = _read_collections(user.handle)
cols.append({"id": col_id, "name": body.name.strip() or "Unnamed collection", "created_at": now})
_write_collections(user.handle, cols)
return JSONResponse({"id": col_id, "created_at": now})
@app.put("/api/collections/{col_id}")
async def update_collection(
col_id: str, body: CollectionBody, user: User = Depends(require_auth)
) -> JSONResponse:
cols = _read_collections(user.handle)
for c in cols:
if c["id"] == col_id:
c["name"] = body.name.strip() or c["name"]
_write_collections(user.handle, cols)
return JSONResponse({"id": col_id, "updated": True})
raise HTTPException(404, "Collection not found")
@app.delete("/api/collections/{col_id}")
async def delete_collection(
col_id: str,
mode: str = Query(default="unassign"),
user: User = Depends(require_auth),
) -> JSONResponse:
cols = _read_collections(user.handle)
if not any(c["id"] == col_id for c in cols):
raise HTTPException(404, "Collection not found")
_write_collections(user.handle, [c for c in cols if c["id"] != col_id])
d = _PLANS_DIR / user.handle
if d.exists():
for p in d.glob("*.json"):
if p.name.startswith("_"):
continue
try:
data = json.loads(p.read_text(encoding="utf-8"))
if data.get("collection_id") == col_id:
if mode == "delete":
p.unlink()
else:
data.pop("collection_id", None)
p.write_text(json.dumps(data), encoding="utf-8")
except Exception:
pass
return JSONResponse({"id": col_id, "deleted": True})
# ── Shared plan endpoints ──────────────────────────────────────────────────────
_SHARED_STRIP = {"geojson", "gpxTrack"}
@app.get("/api/shared")
async def list_shared(user: User = Depends(require_auth)) -> JSONResponse:
d = _PLANS_DIR / "_shared"
if not d.exists():
return JSONResponse({"plans": []})
plans = []
for p in sorted(d.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True):
try:
data = json.loads(p.read_text(encoding="utf-8"))
plans.append({k: v for k, v in data.items() if k not in _SHARED_STRIP})
except Exception:
pass
return JSONResponse({"plans": plans})
@app.post("/api/shared")
async def create_shared(body: PlanBody, user: User = Depends(require_auth)) -> JSONResponse:
plan_id = secrets.token_urlsafe(12)
now = int(time.time())
data: dict = {
"id": plan_id,
"author": user.handle,
"name": body.name.strip() or "Unnamed route",
"waypoints": body.waypoints,
"profile": body.profile,
"created_at": now,
"updated_at": now,
}
if body.description:
data["description"] = body.description
if body.dist_km is not None:
data["dist_km"] = body.dist_km
if body.elevation_gain is not None:
data["elevation_gain"] = body.elevation_gain
if body.geojson is not None:
data["geojson"] = body.geojson
if body.gpxTrack is not None:
data["gpxTrack"] = body.gpxTrack
data["gpxColorIdx"] = body.gpxColorIdx
data["gpxOpacity"] = body.gpxOpacity
_shared_dir().joinpath(f"{plan_id}.json").write_text(json.dumps(data), encoding="utf-8")
return JSONResponse({"id": plan_id, "created_at": now})
@app.get("/api/shared/{plan_id}")
async def get_shared(plan_id: str, user: User = Depends(require_auth)) -> JSONResponse:
p = _shared_plan_path(plan_id)
if not p.exists():
raise HTTPException(404, "Shared plan not found")
return JSONResponse(json.loads(p.read_text(encoding="utf-8")))
@app.put("/api/shared/{plan_id}")
async def update_shared(
plan_id: str, body: PlanBody, user: User = Depends(require_auth)
) -> JSONResponse:
p = _shared_plan_path(plan_id)
if not p.exists():
raise HTTPException(404, "Shared plan not found")
data = json.loads(p.read_text(encoding="utf-8"))
data.update({
"name": body.name.strip() or data.get("name", "Unnamed route"),
"waypoints": body.waypoints,
"profile": body.profile,
"updated_at": int(time.time()),
})
if body.description:
data["description"] = body.description
else:
data.pop("description", None)
if body.dist_km is not None:
data["dist_km"] = body.dist_km
if body.elevation_gain is not None:
data["elevation_gain"] = body.elevation_gain
if body.geojson is not None:
data["geojson"] = body.geojson
if body.gpxTrack is not None:
data["gpxTrack"] = body.gpxTrack
data["gpxColorIdx"] = body.gpxColorIdx
data["gpxOpacity"] = body.gpxOpacity
elif "gpxTrack" in data:
del data["gpxTrack"]
data.pop("gpxColorIdx", None)
data.pop("gpxOpacity", None)
p.write_text(json.dumps(data), encoding="utf-8")
return JSONResponse({"id": plan_id, "updated": True})
@app.delete("/api/shared/{plan_id}")
async def delete_shared(plan_id: str, user: User = Depends(require_auth)) -> JSONResponse:
p = _shared_plan_path(plan_id)
if not p.exists():
raise HTTPException(404, "Shared plan not found")
p.unlink()
return JSONResponse({"id": plan_id, "deleted": True})
+9 -5
View File
@@ -3,6 +3,7 @@
import Planner from './Planner.svelte';
const ACTIVITY_URL = import.meta.env.VITE_ACTIVITY_URL ?? 'https://activity.bincio.org';
const AUTH_URL = import.meta.env.VITE_AUTH_URL ?? 'https://bincio.org';
const PALETTES = {
default: { accent: '#60a5fa', dim: 'rgba(96,165,250,0.15)' },
@@ -26,17 +27,20 @@
document.documentElement.style.setProperty('--accent-dim', p.dim);
}
let authed = $state(false);
let checking = $state(true);
let authed = $state(false);
let activityAccess = $state(false);
let checking = $state(true);
onMount(async () => {
applyPalette();
try {
const r = await fetch(`${ACTIVITY_URL}/api/me`, { credentials: 'include' });
if (r.ok) {
authed = true;
const me = await r.json();
authed = true;
activityAccess = me.activity_access ?? false;
} else {
window.location.href = `${ACTIVITY_URL}/login/?next=${encodeURIComponent(window.location.href)}`;
window.location.href = `${AUTH_URL}/login/?next=${encodeURIComponent(window.location.href)}`;
}
} catch {
window.location.href = `${ACTIVITY_URL}/login/?next=${encodeURIComponent(window.location.href)}`;
@@ -53,7 +57,7 @@
{#if checking}
<div class="splash">Checking login…</div>
{:else if authed}
<Planner activityUrl={ACTIVITY_URL} />
<Planner activityUrl={ACTIVITY_URL} {activityAccess} />
{/if}
<style>
+217 -29
View File
@@ -1,12 +1,17 @@
<script>
// Renders a simple SVG elevation profile from a Brouter GeoJSON route feature.
let { route } = $props();
// Renders an SVG elevation profile.
// gpxTrack: optional GeoJSON Feature — drawn in grey behind the route.
// route: optional GeoJSON Feature — drawn in pink in front.
// hoverFrac: 0-1 dist frac from map hover → vertical bar + dot on route profile.
// onhover: callback(0-1 | null) when user hovers the chart.
let { route = null, gpxTrack = null, gpxColor = '#71717a', gpxOpacity = 0.8, hoverFrac = null, onhover = null } = $props();
const PAD = { top: 8, right: 8, bottom: 20, left: 36 };
const H = 140;
let svgEl;
let W = $state(400);
let cursorFrac = $state(null);
$effect(() => {
if (!svgEl) return;
@@ -15,52 +20,235 @@
return () => ro.disconnect();
});
let profile = $derived.by(() => {
if (!route) return null;
const coords = route.geometry?.coordinates ?? [];
// ── Slope helpers ──────────────────────────────────────────────────────────
function slopeColor(pct) {
if (pct < -1) return '#60a5fa'; // descent — blue
if (pct < 2) return '#4ade80'; // flat — green
if (pct < 5) return '#facc15'; // easy — yellow
if (pct < 8) return '#f97316'; // moderate — orange
if (pct < 12) return '#ef4444'; // steep — red
return '#a855f7'; // wall — purple
}
// Distance-weighted sliding-window slope smoothing (O(n) with advancing pointers).
function computeSmoothedSlopes(pts, windowM = 400) {
if (pts.length < 2) return pts.map(() => 0);
const half = windowM / 2;
// Raw slope for each segment, stored at the segment midpoint.
const segs = [];
for (let i = 0; i < pts.length - 1; i++) {
const len = pts[i + 1].d - pts[i].d;
segs.push({
d: (pts[i].d + pts[i + 1].d) / 2,
slope: len > 0.5 ? (pts[i + 1].e - pts[i].e) / len * 100 : 0,
len,
});
}
// Sliding window with two advancing pointers.
const slopes = new Array(pts.length);
let lo = 0, hi = 0;
let sumW = 0, sumS = 0;
for (let i = 0; i < pts.length; i++) {
const center = pts[i].d;
// Expand right
while (hi < segs.length && segs[hi].d <= center + half) {
sumW += segs[hi].len; sumS += segs[hi].slope * segs[hi].len; hi++;
}
// Shrink left
while (lo < hi && segs[lo].d < center - half) {
sumW -= segs[lo].len; sumS -= segs[lo].slope * segs[lo].len; lo++;
}
slopes[i] = sumW > 0.5 ? sumS / sumW : 0;
}
return slopes;
}
// Interpolate slope at a given cumulative distance.
function slopeAtDist(pts, dist) {
let lo = pts.length - 2;
for (let i = 0; i < pts.length - 1; i++) {
if (pts[i + 1].d >= dist) { lo = i; break; }
}
const seg = pts[lo + 1].d - pts[lo].d;
const t = seg > 0 ? Math.min(1, (dist - pts[lo].d) / seg) : 0;
return pts[lo].slope + t * (pts[lo + 1].slope - pts[lo].slope);
}
// Build sub-sampled gradient stops (≤ 80 stops regardless of track length).
function buildGradientStops(pts) {
const total = pts[pts.length - 1].d;
const N = Math.min(pts.length - 1, 80);
const stops = [];
for (let s = 0; s <= N; s++) {
const d = (s / N) * total;
stops.push({
offset: ((s / N) * 100).toFixed(2) + '%',
color: slopeColor(slopeAtDist(pts, d)),
});
}
return stops;
}
// ── Profile builder ────────────────────────────────────────────────────────
function buildProfile(track) {
if (!track) return null;
const coords = track.geometry?.coordinates ?? [];
if (coords.length < 2) return null;
// Build cumulative distance + elevation arrays
const pts = [];
let dist = 0;
for (let i = 0; i < coords.length; i++) {
if (i > 0) {
const dx = coords[i][0] - coords[i-1][0];
const dy = coords[i][1] - coords[i-1][1];
dist += Math.sqrt(dx*dx + dy*dy) * 111320; // rough metres
const dx = coords[i][0] - coords[i - 1][0];
const dy = coords[i][1] - coords[i - 1][1];
dist += Math.sqrt(dx * dx + dy * dy) * 111320;
}
pts.push({ d: dist, e: coords[i][2] ?? 0 });
pts.push({ d: dist, e: coords[i][2] ?? 0, slope: 0 });
}
const smoothed = computeSmoothedSlopes(pts);
pts.forEach((p, i) => { p.slope = smoothed[i]; });
const totalDist = pts[pts.length - 1].d;
const eles = pts.map(p => p.e);
const minE = Math.min(...eles);
const maxE = Math.max(...eles);
const eles = pts.map(p => p.e);
const minE = Math.min(...eles);
const maxE = Math.max(...eles);
const rangeE = maxE - minE || 1;
const iW = W - PAD.left - PAD.right;
const iH = H - PAD.top - PAD.bottom;
const iH = H - PAD.top - PAD.bottom;
const toX = d => PAD.left + (d / totalDist) * iW;
const toY = e => PAD.top + iH - ((e - minE) / rangeE) * iH;
const toY = e => PAD.top + iH - ((e - minE) / rangeE) * iH;
const polyline = pts.map(p => `${toX(p.d).toFixed(1)},${toY(p.e).toFixed(1)}`).join(' ');
const area = `${toX(0)},${(PAD.top + iH).toFixed(1)} ` + polyline + ` ${toX(totalDist)},${(PAD.top + iH).toFixed(1)}`;
const area = `${toX(0)},${(PAD.top + iH).toFixed(1)} ` + polyline + ` ${toX(totalDist)},${(PAD.top + iH).toFixed(1)}`;
return { polyline, area, minE, maxE, totalDist, iW, iH };
});
const slopeStops = buildGradientStops(pts);
return { polyline, area, minE, maxE, totalDist, iW, iH, pts, toX, toY, slopeStops };
}
let profile = $derived(buildProfile(route));
let gpxProfile = $derived(buildProfile(gpxTrack));
// Interpolate elevation at a given cumulative distance within a pts array.
function elevAtDist(pts, dist) {
let lo = pts.length - 2;
for (let i = 0; i < pts.length - 1; i++) {
if (pts[i + 1].d >= dist) { lo = i; break; }
}
const seg = pts[lo + 1].d - pts[lo].d;
const t = seg > 0 ? Math.min(1, (dist - pts[lo].d) / seg) : 0;
return pts[lo].e + t * (pts[lo + 1].e - pts[lo].e);
}
function handleMouseMove(e) {
if (!profile && !gpxProfile) return;
const rect = svgEl.getBoundingClientRect();
const iW = W - PAD.left - PAD.right;
const f = Math.max(0, Math.min(1, (e.clientX - rect.left - PAD.left) / iW));
cursorFrac = f;
if (onhover) onhover(f);
}
function handleMouseLeave() {
cursorFrac = null;
if (onhover) onhover(null);
}
// iW is the same for both profiles since PAD and W are shared.
let iW = $derived(W - PAD.left - PAD.right);
let iH = $derived(H - PAD.top - PAD.bottom);
// Axis labels come from route profile if it exists, otherwise gpx.
let labelProfile = $derived(profile ?? gpxProfile);
</script>
<svg bind:this={svgEl} width="100%" height={H} style="display:block">
{#if profile}
<!-- area fill -->
<polygon points={profile.area} fill="#e879a022" />
<!-- line -->
<polyline points={profile.polyline} fill="none" stroke="#e879a0" stroke-width="1.5" />
<!-- y labels -->
<text x={PAD.left - 4} y={PAD.top + 4} text-anchor="end" font-size="9" fill="#71717a">{profile.maxE.toFixed(0)}m</text>
<text x={PAD.left - 4} y={PAD.top + profile.iH} text-anchor="end" font-size="9" fill="#71717a">{profile.minE.toFixed(0)}m</text>
<!-- x label -->
<text x={PAD.left + profile.iW / 2} y={H - 4} text-anchor="middle" font-size="9" fill="#71717a">{(profile.totalDist / 1000).toFixed(1)} km</text>
<svg
bind:this={svgEl}
role="img"
aria-label="Elevation profile"
width="100%"
height={H}
style="display:block;cursor:{labelProfile ? 'crosshair' : 'default'}"
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
>
<defs>
{#if profile}
<linearGradient id="slope-grad" gradientUnits="userSpaceOnUse"
x1="{PAD.left}" y1="0" x2="{PAD.left + iW}" y2="0">
{#each profile.slopeStops as stop}
<stop offset={stop.offset} stop-color={stop.color} />
{/each}
</linearGradient>
{/if}
</defs>
{#if labelProfile}
<!-- GPX reference profile — behind, user-chosen colour/opacity -->
{#if gpxProfile}
<polygon points={gpxProfile.area} fill={gpxColor} fill-opacity="0.12" />
<polyline points={gpxProfile.polyline} fill="none" stroke={gpxColor} stroke-width="1.5" opacity={gpxOpacity} />
{/if}
<!-- Route profile — slope-coloured fill + pink stroke -->
{#if profile}
<polygon points={profile.area} fill="url(#slope-grad)" fill-opacity="0.45" />
<polyline points={profile.polyline} fill="none" stroke="#e879a0" stroke-width="1.5" />
{/if}
<!-- y-axis labels -->
<text x={PAD.left - 4} y={PAD.top + 4} text-anchor="end" font-size="9" fill="#71717a">{labelProfile.maxE.toFixed(0)}m</text>
<text x={PAD.left - 4} y={PAD.top + iH} text-anchor="end" font-size="9" fill="#71717a">{labelProfile.minE.toFixed(0)}m</text>
<!-- x-axis distance label(s) -->
{#if profile}
<text x={PAD.left + iW / 2} y={H - 4} text-anchor="middle" font-size="9" fill="#71717a">{(profile.totalDist / 1000).toFixed(1)} km</text>
{/if}
{#if gpxProfile && !profile}
<text x={PAD.left + iW / 2} y={H - 4} text-anchor="middle" font-size="9" fill="#71717a">{(gpxProfile.totalDist / 1000).toFixed(1)} km</text>
{/if}
{#if gpxProfile && profile}
<text x={PAD.left + iW - 2} y={PAD.top + 12} text-anchor="end" font-size="8" fill="#71717a" opacity="0.65">gpx {(gpxProfile.totalDist / 1000).toFixed(1)} km</text>
{/if}
<!-- Cursor bar + dot + tooltip while hovering chart -->
{#if cursorFrac !== null}
{@const cX = PAD.left + cursorFrac * iW}
{@const cDist = cursorFrac * (profile ?? gpxProfile).totalDist}
<line x1={cX} y1={PAD.top} x2={cX} y2={PAD.top + iH} stroke="#e879a0" stroke-width="1" opacity="0.7" />
{#if profile}
{@const cEle = elevAtDist(profile.pts, cDist)}
{@const cSlope = slopeAtDist(profile.pts, cDist)}
{@const cY = profile.toY(cEle)}
{@const tipLeft = cursorFrac > 0.72}
{@const tipX = tipLeft ? cX - 62 : cX + 6}
<circle cx={cX} cy={cY} r="4" fill="#e879a0" stroke="#18181b" stroke-width="1.5" />
<rect x={tipX} y={PAD.top + 2} width="56" height="30" rx="3" fill="#09090b" opacity="0.88" />
<text x={tipX + 28} y={PAD.top + 14} text-anchor="middle" font-size="10" font-weight="700" fill={slopeColor(cSlope)}>
{cSlope > 0.05 ? '+' : ''}{cSlope.toFixed(1)}%
</text>
<text x={tipX + 28} y={PAD.top + 26} text-anchor="middle" font-size="9" fill="#71717a">{cEle.toFixed(0)} m</text>
{:else if gpxProfile}
{@const cY = gpxProfile.toY(elevAtDist(gpxProfile.pts, cDist))}
<circle cx={cX} cy={cY} r="4" fill={gpxColor} stroke="#18181b" stroke-width="1.5" />
{/if}
{/if}
<!-- Map-hover indicator: bar + dot on route profile -->
{#if hoverFrac !== null && profile}
{@const hDist = hoverFrac * profile.totalDist}
{@const hX = profile.toX(hDist)}
{@const hY = profile.toY(elevAtDist(profile.pts, hDist))}
<line x1={hX} y1={PAD.top} x2={hX} y2={PAD.top + iH} stroke="#e879a0" stroke-width="1" opacity="0.55" />
<circle cx={hX} cy={hY} r="4" fill="#e879a0" stroke="#18181b" stroke-width="1.5" />
{/if}
{/if}
</svg>
+1533 -52
View File
File diff suppressed because it is too large Load Diff