Compare commits
10 Commits
277898f46f
...
15a993ecc6
| 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] Auth gate with redirect to bincio login
|
||||||
- [x] Deployed to `https://planner.bincio.org` with HTTPS
|
- [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)
|
## 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.
|
- [ ] **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).
|
- [ ] **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.
|
- [ ] **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.
|
- [ ] **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
|
#!/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
|
set -e
|
||||||
|
|
||||||
VPS=root@95.216.55.151
|
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/"
|
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"
|
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);
|
document.documentElement.style.setProperty('--accent-dim', p.dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
let authed = $state(false);
|
let authed = $state(false);
|
||||||
let checking = $state(true);
|
let activityAccess = $state(false);
|
||||||
|
let checking = $state(true);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
applyPalette();
|
applyPalette();
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${ACTIVITY_URL}/api/me`, { credentials: 'include' });
|
const r = await fetch(`${ACTIVITY_URL}/api/me`, { credentials: 'include' });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
authed = true;
|
const me = await r.json();
|
||||||
|
authed = true;
|
||||||
|
activityAccess = me.activity_access ?? false;
|
||||||
} else {
|
} else {
|
||||||
window.location.href = `${ACTIVITY_URL}/login/?next=${encodeURIComponent(window.location.href)}`;
|
window.location.href = `${ACTIVITY_URL}/login/?next=${encodeURIComponent(window.location.href)}`;
|
||||||
}
|
}
|
||||||
@@ -53,7 +56,7 @@
|
|||||||
{#if checking}
|
{#if checking}
|
||||||
<div class="splash">Checking login…</div>
|
<div class="splash">Checking login…</div>
|
||||||
{:else if authed}
|
{:else if authed}
|
||||||
<Planner activityUrl={ACTIVITY_URL} />
|
<Planner activityUrl={ACTIVITY_URL} {activityAccess} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
+217
-29
@@ -1,12 +1,17 @@
|
|||||||
<script>
|
<script>
|
||||||
// Renders a simple SVG elevation profile from a Brouter GeoJSON route feature.
|
// Renders an SVG elevation profile.
|
||||||
let { route } = $props();
|
// 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 PAD = { top: 8, right: 8, bottom: 20, left: 36 };
|
||||||
const H = 140;
|
const H = 140;
|
||||||
|
|
||||||
let svgEl;
|
let svgEl;
|
||||||
let W = $state(400);
|
let W = $state(400);
|
||||||
|
let cursorFrac = $state(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!svgEl) return;
|
if (!svgEl) return;
|
||||||
@@ -15,52 +20,235 @@
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
let profile = $derived.by(() => {
|
// ── Slope helpers ──────────────────────────────────────────────────────────
|
||||||
if (!route) return null;
|
|
||||||
const coords = route.geometry?.coordinates ?? [];
|
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;
|
if (coords.length < 2) return null;
|
||||||
|
|
||||||
// Build cumulative distance + elevation arrays
|
|
||||||
const pts = [];
|
const pts = [];
|
||||||
let dist = 0;
|
let dist = 0;
|
||||||
for (let i = 0; i < coords.length; i++) {
|
for (let i = 0; i < coords.length; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
const dx = coords[i][0] - coords[i-1][0];
|
const dx = coords[i][0] - coords[i - 1][0];
|
||||||
const dy = coords[i][1] - coords[i-1][1];
|
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 totalDist = pts[pts.length - 1].d;
|
||||||
const eles = pts.map(p => p.e);
|
const eles = pts.map(p => p.e);
|
||||||
const minE = Math.min(...eles);
|
const minE = Math.min(...eles);
|
||||||
const maxE = Math.max(...eles);
|
const maxE = Math.max(...eles);
|
||||||
const rangeE = maxE - minE || 1;
|
const rangeE = maxE - minE || 1;
|
||||||
|
|
||||||
const iW = W - PAD.left - PAD.right;
|
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 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 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>
|
</script>
|
||||||
|
|
||||||
<svg bind:this={svgEl} width="100%" height={H} style="display:block">
|
<svg
|
||||||
{#if profile}
|
bind:this={svgEl}
|
||||||
<!-- area fill -->
|
role="img"
|
||||||
<polygon points={profile.area} fill="#e879a022" />
|
aria-label="Elevation profile"
|
||||||
<!-- line -->
|
width="100%"
|
||||||
<polyline points={profile.polyline} fill="none" stroke="#e879a0" stroke-width="1.5" />
|
height={H}
|
||||||
<!-- y labels -->
|
style="display:block;cursor:{labelProfile ? 'crosshair' : 'default'}"
|
||||||
<text x={PAD.left - 4} y={PAD.top + 4} text-anchor="end" font-size="9" fill="#71717a">{profile.maxE.toFixed(0)}m</text>
|
onmousemove={handleMouseMove}
|
||||||
<text x={PAD.left - 4} y={PAD.top + profile.iH} text-anchor="end" font-size="9" fill="#71717a">{profile.minE.toFixed(0)}m</text>
|
onmouseleave={handleMouseLeave}
|
||||||
<!-- 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>
|
<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}
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
+1533
-52
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user