Compare commits

..

10 Commits

Author SHA1 Message Date
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 2252 additions and 87 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.
+24 -1
View File
@@ -1,7 +1,30 @@
#!/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_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/"
echo "Restarting bincio-planner.service…"
ssh "$VPS" "systemctl restart bincio-planner.service && systemctl is-active bincio-planner.service"
echo "Deployed to planner.bincio.org"
+9
View File
@@ -0,0 +1,9 @@
[project]
name = "bincio-planner-server"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.111",
"uvicorn[standard]>=0.29",
"pydantic>=2.0",
]
+440
View File
@@ -0,0 +1,440 @@
"""BincioPlanner API — save/load route plans.
Auth: reads bincio_session cookie, validates against shared instance.db (same strategy as bincio_wiki).
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:
SHARED_DB_PATH path to bincio_activity instance.db (default: /var/bincio/data/instance.db)
PLANS_DIR root dir for plan JSON files (default: /var/bincio_planner)
"""
from __future__ import annotations
import json
import os
import re
import secrets
import sqlite3
import time
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
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
_SHARED_DB_PATH = Path(os.environ.get("SHARED_DB_PATH", "/var/bincio/data/instance.db"))
_PLANS_DIR = Path(os.environ.get("PLANS_DIR", "/var/bincio_planner"))
_SESSION_COOKIE = "bincio_session"
_DEV_HANDLE = os.environ.get("DEV_HANDLE", "")
_SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
# ── Auth ───────────────────────────────────────────────────────────────────────
@dataclass
class User:
handle: str
display_name: str
@contextmanager
def _db():
if not _SHARED_DB_PATH.exists():
raise HTTPException(503, f"Shared DB not found at {_SHARED_DB_PATH}. "
"Set SHARED_DB_PATH or run bincio_activity first.")
con = sqlite3.connect(_SHARED_DB_PATH, check_same_thread=False)
con.row_factory = sqlite3.Row
con.execute("PRAGMA journal_mode=WAL")
try:
yield con
finally:
con.close()
def _get_session_user(token: str) -> Optional[User]:
try:
with _db() as con:
row = con.execute(
"SELECT s.handle, s.expires_at, u.display_name, u.activity_access, u.suspended "
"FROM sessions s JOIN users u ON s.handle = u.handle "
"WHERE s.token = ?",
(token,),
).fetchone()
except HTTPException:
raise
except Exception:
return None
if not row:
return None
if row["expires_at"] < int(time.time()):
return None
if not row["activity_access"]:
return None
if row["suspended"]:
return None
return User(handle=row["handle"], display_name=row["display_name"])
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 = _get_session_user(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})
+4 -1
View File
@@ -27,6 +27,7 @@
}
let authed = $state(false);
let activityAccess = $state(false);
let checking = $state(true);
onMount(async () => {
@@ -34,7 +35,9 @@
try {
const r = await fetch(`${ACTIVITY_URL}/api/me`, { credentials: 'include' });
if (r.ok) {
const me = await r.json();
authed = true;
activityAccess = me.activity_access ?? false;
} else {
window.location.href = `${ACTIVITY_URL}/login/?next=${encodeURIComponent(window.location.href)}`;
}
@@ -53,7 +56,7 @@
{#if checking}
<div class="splash">Checking login…</div>
{:else if authed}
<Planner activityUrl={ACTIVITY_URL} />
<Planner activityUrl={ACTIVITY_URL} {activityAccess} />
{/if}
<style>
+207 -19
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,23 +20,100 @@
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
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);
@@ -47,20 +129,126 @@
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)}`;
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">
<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}
<!-- area fill -->
<polygon points={profile.area} fill="#e879a022" />
<!-- line -->
<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" />
<!-- 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>
{/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>
+1523 -42
View File
File diff suppressed because it is too large Load Diff