Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15a993ecc6 | |||
| fa6225d2cd | |||
| f20407eecf | |||
| 67b721d872 | |||
| 89f76829b7 | |||
| ed90a1830b | |||
| c42e3498c2 | |||
| 8c60769fa6 | |||
| 6d095ba3ea | |||
| 626d145861 |
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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})
|
||||
+7
-4
@@ -26,15 +26,18 @@
|
||||
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)}`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+217
-29
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user