feat: DEM-based elevation recalculation via edit drawer button
Adds a "Recalculate from terrain map (DEM)" button to the activity edit
drawer. On click it queries an Open-Elevation-compatible API to replace
GPS altitude with SRTM terrain data, applies 5m hysteresis, and updates
the activity's elevation stats and timeseries chart in place.
- bincio/extract/dem.py: lookup_elevations() (batched HTTP POST) +
recalculate_elevation() (subsample → DEM → interpolate → hysteresis →
patch activity JSON, timeseries JSON, index.json)
- POST /api/activity/{id}/recalculate-elevation on both serve and edit
servers; serve endpoint is auth-gated and triggers merge + rebuild
- --dem-url flag (also DEM_URL env var) on bincio serve and bincio edit;
logged at startup; missing URL returns a clear 503 with setup instructions
- /api/me response gains dem_configured bool
- EditDrawer: button with loading state, shows new ↑/↓ values on success
This commit is contained in:
@@ -24,6 +24,8 @@ console = Console()
|
|||||||
help="Strava API client ID (enables Strava sync in the UI). Also reads STRAVA_CLIENT_ID env var.")
|
help="Strava API client ID (enables Strava sync in the UI). Also reads STRAVA_CLIENT_ID env var.")
|
||||||
@click.option("--strava-client-secret", default=None, envvar="STRAVA_CLIENT_SECRET",
|
@click.option("--strava-client-secret", default=None, envvar="STRAVA_CLIENT_SECRET",
|
||||||
help="Strava API client secret. Also reads STRAVA_CLIENT_SECRET env var.")
|
help="Strava API client secret. Also reads STRAVA_CLIENT_SECRET env var.")
|
||||||
|
@click.option("--dem-url", default=None, envvar="DEM_URL",
|
||||||
|
help="Base URL of an Open-Elevation-compatible API (enables 'Recalculate elevation' button).")
|
||||||
def edit(
|
def edit(
|
||||||
data_dir: Optional[str],
|
data_dir: Optional[str],
|
||||||
port: int,
|
port: int,
|
||||||
@@ -31,6 +33,7 @@ def edit(
|
|||||||
config_path: Optional[str],
|
config_path: Optional[str],
|
||||||
strava_client_id: Optional[str],
|
strava_client_id: Optional[str],
|
||||||
strava_client_secret: Optional[str],
|
strava_client_secret: Optional[str],
|
||||||
|
dem_url: Optional[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Start a local web UI for editing activity sidecar files.
|
"""Start a local web UI for editing activity sidecar files.
|
||||||
|
|
||||||
@@ -69,11 +72,16 @@ def edit(
|
|||||||
srv.site_url = site_url
|
srv.site_url = site_url
|
||||||
srv.strava_client_id = strava_client_id or ""
|
srv.strava_client_id = strava_client_id or ""
|
||||||
srv.strava_client_secret = strava_client_secret or ""
|
srv.strava_client_secret = strava_client_secret or ""
|
||||||
|
srv.dem_url = dem_url or ""
|
||||||
|
|
||||||
if strava_client_id:
|
if strava_client_id:
|
||||||
console.print(f"Strava sync: [green]enabled[/green] (client {strava_client_id})")
|
console.print(f"Strava sync: [green]enabled[/green] (client {strava_client_id})")
|
||||||
else:
|
else:
|
||||||
console.print("Strava sync: [yellow]disabled[/yellow] (pass --strava-client-id to enable)")
|
console.print("Strava sync: [yellow]disabled[/yellow] (pass --strava-client-id to enable)")
|
||||||
|
if dem_url:
|
||||||
|
console.print(f"DEM: [green]enabled[/green] ({dem_url})")
|
||||||
|
else:
|
||||||
|
console.print("DEM: [yellow]disabled[/yellow] (pass --dem-url to enable elevation recalculation)")
|
||||||
|
|
||||||
uvicorn.run(srv.app, host="127.0.0.1", port=port, log_level="warning")
|
uvicorn.run(srv.app, host="127.0.0.1", port=port, log_level="warning")
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ data_dir: Path | None = None
|
|||||||
site_url: str = "http://localhost:4321"
|
site_url: str = "http://localhost:4321"
|
||||||
strava_client_id: str = ""
|
strava_client_id: str = ""
|
||||||
strava_client_secret: str = ""
|
strava_client_secret: str = ""
|
||||||
|
dem_url: str = "" # Open-Elevation-compatible API base URL; empty = feature disabled
|
||||||
|
|
||||||
# In-memory CSRF state tokens for OAuth flows (token → True); cleared after use
|
# In-memory CSRF state tokens for OAuth flows (token → True); cleared after use
|
||||||
_oauth_states: set[str] = set()
|
_oauth_states: set[str] = set()
|
||||||
@@ -427,6 +428,32 @@ async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONRespon
|
|||||||
return JSONResponse({"ok": True, "sidecar": str(sidecar_path)})
|
return JSONResponse({"ok": True, "sidecar": str(sidecar_path)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/activity/{activity_id}/recalculate-elevation")
|
||||||
|
async def recalculate_elevation_endpoint(activity_id: str) -> JSONResponse:
|
||||||
|
"""Replace GPS altitude with DEM terrain elevation and recompute gain/loss.
|
||||||
|
|
||||||
|
Requires --dem-url to be set when starting bincio edit.
|
||||||
|
"""
|
||||||
|
if not dem_url:
|
||||||
|
raise HTTPException(
|
||||||
|
503,
|
||||||
|
"DEM URL not configured. "
|
||||||
|
"Pass --dem-url <api-url> to bincio edit (e.g. https://api.open-elevation.com).",
|
||||||
|
)
|
||||||
|
dd = _get_data_dir()
|
||||||
|
_check_id(activity_id)
|
||||||
|
try:
|
||||||
|
from bincio.extract.dem import recalculate_elevation
|
||||||
|
from bincio.render.merge import merge_one
|
||||||
|
result = recalculate_elevation(dd, activity_id, dem_url)
|
||||||
|
merge_one(dd, activity_id)
|
||||||
|
return JSONResponse(result)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(404, str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/activity/{activity_id}/images")
|
@app.post("/api/activity/{activity_id}/images")
|
||||||
async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONResponse:
|
async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONResponse:
|
||||||
dd = _get_data_dir()
|
dd = _get_data_dir()
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""DEM (Digital Elevation Model) lookup and elevation recalculation.
|
||||||
|
|
||||||
|
Queries any Open-Elevation-compatible HTTP API to replace noisy GPS altitude
|
||||||
|
with terrain altitude, then re-applies hysteresis-based accumulation.
|
||||||
|
|
||||||
|
Compatible APIs:
|
||||||
|
- https://api.open-elevation.com (free, SRTM, accepts large batches)
|
||||||
|
- https://api.opentopodata.org/v1/srtm30m (more reliable, max 100 pts/req)
|
||||||
|
|
||||||
|
Pass the base URL (without path) to bincio serve/edit via --dem-url.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Sample one GPS point per N seconds when building the DEM query.
|
||||||
|
# SRTM30 resolution is ~30 m; at 30 km/h cycling that's ~3 s per tile —
|
||||||
|
# sampling every 10 s is more than enough.
|
||||||
|
_DEFAULT_SAMPLE_INTERVAL_S = 10
|
||||||
|
|
||||||
|
# Maximum locations per API request. Open-Elevation supports ~512 per POST.
|
||||||
|
_DEFAULT_BATCH_SIZE = 512
|
||||||
|
|
||||||
|
# Hysteresis threshold after DEM correction. DEM data is already smooth
|
||||||
|
# (30 m horizontal resolution) so 5 m is a generous dead-band.
|
||||||
|
_DEM_HYSTERESIS_M = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_elevations(
|
||||||
|
latlons: list[tuple[float, float]],
|
||||||
|
api_url: str,
|
||||||
|
batch_size: int = _DEFAULT_BATCH_SIZE,
|
||||||
|
timeout_s: int = 30,
|
||||||
|
) -> list[Optional[float]]:
|
||||||
|
"""Query a DEM API for terrain elevation at the given (lat, lon) pairs.
|
||||||
|
|
||||||
|
Uses the Open-Elevation API format::
|
||||||
|
|
||||||
|
POST {api_url}/api/v1/lookup
|
||||||
|
{"locations": [{"latitude": lat, "longitude": lon}, ...]}
|
||||||
|
|
||||||
|
Returns a list the same length as *latlons*. Elements are ``None``
|
||||||
|
wherever the API returned no data (network error, ocean, etc.).
|
||||||
|
"""
|
||||||
|
if not latlons:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results: list[Optional[float]] = [None] * len(latlons)
|
||||||
|
url = f"{api_url.rstrip('/')}/api/v1/lookup"
|
||||||
|
|
||||||
|
for start in range(0, len(latlons), batch_size):
|
||||||
|
batch = latlons[start : start + batch_size]
|
||||||
|
payload = json.dumps(
|
||||||
|
{"locations": [{"latitude": lat, "longitude": lon} for lat, lon in batch]}
|
||||||
|
).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
for i, item in enumerate(data.get("results", [])):
|
||||||
|
elev = item.get("elevation")
|
||||||
|
if elev is not None:
|
||||||
|
results[start + i] = float(elev)
|
||||||
|
except (urllib.error.URLError, json.JSONDecodeError, KeyError, ValueError):
|
||||||
|
pass # leave this batch as None; caller checks overall coverage
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def recalculate_elevation(
|
||||||
|
user_dir: Path,
|
||||||
|
activity_id: str,
|
||||||
|
dem_url: str,
|
||||||
|
sample_interval_s: int = _DEFAULT_SAMPLE_INTERVAL_S,
|
||||||
|
) -> dict:
|
||||||
|
"""Replace GPS elevation with DEM terrain elevation and recompute gain/loss.
|
||||||
|
|
||||||
|
Algorithm
|
||||||
|
---------
|
||||||
|
1. Read the activity's 1 Hz timeseries for lat / lon / t arrays.
|
||||||
|
2. Subsample GPS points every *sample_interval_s* seconds.
|
||||||
|
3. Query the DEM API for those points (batched).
|
||||||
|
4. Linearly interpolate DEM elevation back to every GPS-valid second.
|
||||||
|
5. Apply :data:`_DEM_HYSTERESIS_M` hysteresis to compute gain / loss.
|
||||||
|
6. Write the updated ``elevation_m`` array to the timeseries JSON.
|
||||||
|
7. Patch ``elevation_gain_m`` / ``elevation_loss_m`` in the detail JSON.
|
||||||
|
8. Patch ``elevation_gain_m`` in ``index.json`` (summary entry for the feed).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict with keys ``elevation_gain_m``, ``elevation_loss_m``,
|
||||||
|
``points_queried`` (DEM responses received).
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
FileNotFoundError
|
||||||
|
Activity detail or timeseries file not found.
|
||||||
|
ValueError
|
||||||
|
Activity has no GPS coordinates, or the DEM API returned too few results.
|
||||||
|
"""
|
||||||
|
acts_dir = user_dir / "activities"
|
||||||
|
json_path = acts_dir / f"{activity_id}.json"
|
||||||
|
ts_path = acts_dir / f"{activity_id}.timeseries.json"
|
||||||
|
|
||||||
|
if not json_path.exists():
|
||||||
|
raise FileNotFoundError(f"Activity not found: {activity_id}")
|
||||||
|
if not ts_path.exists():
|
||||||
|
raise ValueError("Activity has no timeseries data")
|
||||||
|
|
||||||
|
ts = json.loads(ts_path.read_text(encoding="utf-8"))
|
||||||
|
lat_arr: list[Optional[float]] = ts.get("lat") or []
|
||||||
|
lon_arr: list[Optional[float]] = ts.get("lon") or []
|
||||||
|
t_arr: list[int] = ts.get("t") or []
|
||||||
|
|
||||||
|
if not lat_arr or not lon_arr:
|
||||||
|
raise ValueError(
|
||||||
|
"Activity has no GPS coordinates "
|
||||||
|
"(privacy=no_gps or data recorded without GPS)"
|
||||||
|
)
|
||||||
|
|
||||||
|
n = len(t_arr)
|
||||||
|
|
||||||
|
# ── 1. Subsample GPS-valid indices ────────────────────────────────────────
|
||||||
|
gps_idx = [
|
||||||
|
i for i in range(n)
|
||||||
|
if lat_arr[i] is not None and lon_arr[i] is not None
|
||||||
|
]
|
||||||
|
if len(gps_idx) < 2:
|
||||||
|
raise ValueError("Too few GPS points for DEM lookup")
|
||||||
|
|
||||||
|
sampled_idx = gps_idx[::sample_interval_s]
|
||||||
|
if gps_idx[-1] not in sampled_idx:
|
||||||
|
sampled_idx.append(gps_idx[-1]) # always include the last point
|
||||||
|
|
||||||
|
# ── 2. Query DEM ──────────────────────────────────────────────────────────
|
||||||
|
latlons = [(float(lat_arr[i]), float(lon_arr[i])) for i in sampled_idx] # type: ignore[arg-type]
|
||||||
|
dem_elev = lookup_elevations(latlons, dem_url)
|
||||||
|
|
||||||
|
# Build (t, elevation) pairs for valid DEM responses only
|
||||||
|
valid_pairs: list[tuple[int, float]] = [
|
||||||
|
(t_arr[sampled_idx[k]], dem_elev[k])
|
||||||
|
for k in range(len(sampled_idx))
|
||||||
|
if dem_elev[k] is not None
|
||||||
|
]
|
||||||
|
n_queried = len(valid_pairs)
|
||||||
|
if n_queried < 2:
|
||||||
|
raise ValueError(
|
||||||
|
f"DEM API returned too few results "
|
||||||
|
f"({n_queried} of {len(sampled_idx)} points). "
|
||||||
|
f"Check the DEM URL: {dem_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 3. Linear interpolation → full 1 Hz series ───────────────────────────
|
||||||
|
new_ele: list[Optional[float]] = [None] * n
|
||||||
|
j = 0
|
||||||
|
for i in gps_idx:
|
||||||
|
t = t_arr[i]
|
||||||
|
# Advance j so valid_pairs[j] is the left anchor for t
|
||||||
|
while j + 1 < len(valid_pairs) - 1 and valid_pairs[j + 1][0] <= t:
|
||||||
|
j += 1
|
||||||
|
t0, e0 = valid_pairs[j]
|
||||||
|
if j + 1 < len(valid_pairs):
|
||||||
|
t1, e1 = valid_pairs[j + 1]
|
||||||
|
frac = max(0.0, min(1.0, (t - t0) / (t1 - t0))) if t1 > t0 else 0.0
|
||||||
|
new_ele[i] = round(e0 + frac * (e1 - e0), 1)
|
||||||
|
else:
|
||||||
|
new_ele[i] = round(e0, 1)
|
||||||
|
|
||||||
|
# ── 4. Hysteresis accumulation ────────────────────────────────────────────
|
||||||
|
valid_eles = [e for e in new_ele if e is not None]
|
||||||
|
gain = loss = 0.0
|
||||||
|
committed = valid_eles[0]
|
||||||
|
for e in valid_eles[1:]:
|
||||||
|
diff = e - committed
|
||||||
|
if abs(diff) >= _DEM_HYSTERESIS_M:
|
||||||
|
if diff > 0:
|
||||||
|
gain += diff
|
||||||
|
else:
|
||||||
|
loss += diff
|
||||||
|
committed = e
|
||||||
|
|
||||||
|
gain_r = round(gain, 1)
|
||||||
|
loss_r = round(abs(loss), 1)
|
||||||
|
|
||||||
|
# ── 5. Write timeseries ───────────────────────────────────────────────────
|
||||||
|
ts["elevation_m"] = new_ele
|
||||||
|
ts_path.write_text(json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
# ── 6. Patch activity detail JSON ─────────────────────────────────────────
|
||||||
|
detail = json.loads(json_path.read_text(encoding="utf-8"))
|
||||||
|
detail["elevation_gain_m"] = gain_r
|
||||||
|
detail["elevation_loss_m"] = loss_r
|
||||||
|
json_path.write_text(json.dumps(detail, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
# ── 7. Patch index.json summary (so merge_all picks up the new value) ─────
|
||||||
|
index_path = user_dir / "index.json"
|
||||||
|
if index_path.exists():
|
||||||
|
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
for s in index.get("activities", []):
|
||||||
|
if s.get("id") == activity_id:
|
||||||
|
s["elevation_gain_m"] = gain_r
|
||||||
|
break
|
||||||
|
index_path.write_text(
|
||||||
|
json.dumps(index, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"elevation_gain_m": gain_r,
|
||||||
|
"elevation_loss_m": loss_r,
|
||||||
|
"points_queried": n_queried,
|
||||||
|
}
|
||||||
+6
-1
@@ -21,10 +21,11 @@ console = Console()
|
|||||||
@click.option("--max-users", default=None, type=int, help="Override max users for this instance (0 = unlimited; updates the DB setting)")
|
@click.option("--max-users", default=None, type=int, help="Override max users for this instance (0 = unlimited; updates the DB setting)")
|
||||||
@click.option("--public-url", default=None, envvar="PUBLIC_URL", help="Public base URL (e.g. https://yourdomain.com). Required for Strava OAuth to work behind a reverse proxy.")
|
@click.option("--public-url", default=None, envvar="PUBLIC_URL", help="Public base URL (e.g. https://yourdomain.com). Required for Strava OAuth to work behind a reverse proxy.")
|
||||||
@click.option("--webroot", default=None, type=click.Path(), help="Nginx webroot (e.g. /var/www/bincio). When set, uploads trigger a full Astro build + rsync so new activity pages are immediately accessible without a git push.")
|
@click.option("--webroot", default=None, type=click.Path(), help="Nginx webroot (e.g. /var/www/bincio). When set, uploads trigger a full Astro build + rsync so new activity pages are immediately accessible without a git push.")
|
||||||
|
@click.option("--dem-url", default=None, envvar="DEM_URL", help="Base URL of an Open-Elevation-compatible API (enables 'Recalculate elevation' button in the edit drawer).")
|
||||||
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||||
strava_client_id: Optional[str], strava_client_secret: Optional[str],
|
strava_client_id: Optional[str], strava_client_secret: Optional[str],
|
||||||
max_users: Optional[int], public_url: Optional[str],
|
max_users: Optional[int], public_url: Optional[str],
|
||||||
webroot: Optional[str]) -> None:
|
webroot: Optional[str], dem_url: Optional[str]) -> None:
|
||||||
"""Start the bincio multi-user application server.
|
"""Start the bincio multi-user application server.
|
||||||
|
|
||||||
Handles auth, user management, and write operations.
|
Handles auth, user management, and write operations.
|
||||||
@@ -58,6 +59,8 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
|||||||
srv.public_url = public_url
|
srv.public_url = public_url
|
||||||
if webroot and site_dir:
|
if webroot and site_dir:
|
||||||
srv.webroot = Path(webroot).expanduser().resolve()
|
srv.webroot = Path(webroot).expanduser().resolve()
|
||||||
|
if dem_url:
|
||||||
|
srv.dem_url = dem_url
|
||||||
|
|
||||||
db = open_db(dd)
|
db = open_db(dd)
|
||||||
current_limit = get_setting(db, "max_users")
|
current_limit = get_setting(db, "max_users")
|
||||||
@@ -74,6 +77,8 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
|||||||
console.print(f" Users: [yellow]max {current_limit}[/yellow]")
|
console.print(f" Users: [yellow]max {current_limit}[/yellow]")
|
||||||
else:
|
else:
|
||||||
console.print(f" Users: [dim]unlimited[/dim]")
|
console.print(f" Users: [dim]unlimited[/dim]")
|
||||||
|
if dem_url:
|
||||||
|
console.print(f" DEM: [cyan]{dem_url}[/cyan]")
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
log_config = uvicorn.config.LOGGING_CONFIG.copy()
|
log_config = uvicorn.config.LOGGING_CONFIG.copy()
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ webroot: Path | None = None # nginx webroot — when set, trigger full rebuil
|
|||||||
strava_client_id: str = ""
|
strava_client_id: str = ""
|
||||||
strava_client_secret: str = ""
|
strava_client_secret: str = ""
|
||||||
public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redirect URIs
|
public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redirect URIs
|
||||||
|
dem_url: str = "" # Open-Elevation-compatible API base URL; empty = feature disabled
|
||||||
_db = None # sqlite3.Connection, opened lazily
|
_db = None # sqlite3.Connection, opened lazily
|
||||||
|
|
||||||
|
|
||||||
@@ -404,6 +405,7 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon
|
|||||||
"display_name": user.display_name,
|
"display_name": user.display_name,
|
||||||
"is_admin": user.is_admin,
|
"is_admin": user.is_admin,
|
||||||
"store_originals_default": store_orig != "false",
|
"store_originals_default": store_orig != "false",
|
||||||
|
"dem_configured": bool(dem_url),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1265,6 +1267,39 @@ async def post_activity(
|
|||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/activity/{activity_id}/recalculate-elevation")
|
||||||
|
async def recalculate_elevation_endpoint(
|
||||||
|
activity_id: str,
|
||||||
|
bincio_session: Optional[str] = Cookie(default=None),
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Replace GPS altitude with DEM terrain elevation and recompute gain/loss.
|
||||||
|
|
||||||
|
Requires --dem-url to be set when starting bincio serve.
|
||||||
|
"""
|
||||||
|
user = _require_user(bincio_session)
|
||||||
|
_check_id(activity_id)
|
||||||
|
if not dem_url:
|
||||||
|
raise HTTPException(
|
||||||
|
503,
|
||||||
|
"DEM URL not configured. "
|
||||||
|
"Pass --dem-url <api-url> to bincio serve (e.g. https://api.open-elevation.com).",
|
||||||
|
)
|
||||||
|
dd = _get_data_dir() / user.handle
|
||||||
|
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||||
|
raise HTTPException(404, "Activity not found")
|
||||||
|
try:
|
||||||
|
from bincio.extract.dem import recalculate_elevation
|
||||||
|
from bincio.render.merge import merge_one
|
||||||
|
result = recalculate_elevation(dd, activity_id, dem_url)
|
||||||
|
merge_one(dd, activity_id)
|
||||||
|
_trigger_rebuild(user.handle)
|
||||||
|
return JSONResponse(result)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(404, str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/activity/{activity_id}", response_model=GenericResponse)
|
@app.delete("/api/activity/{activity_id}", response_model=GenericResponse)
|
||||||
async def delete_activity(
|
async def delete_activity(
|
||||||
activity_id: str,
|
activity_id: str,
|
||||||
|
|||||||
@@ -24,6 +24,11 @@
|
|||||||
let confirmDelete = false;
|
let confirmDelete = false;
|
||||||
let deleting = false;
|
let deleting = false;
|
||||||
|
|
||||||
|
// Elevation recalculation from DEM
|
||||||
|
let recalculating = false;
|
||||||
|
let recalcStatus = '';
|
||||||
|
let recalcOk = false;
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
let title = '';
|
let title = '';
|
||||||
let sport: Sport = 'cycling';
|
let sport: Sport = 'cycling';
|
||||||
@@ -119,6 +124,26 @@
|
|||||||
: [...hideStats, key];
|
: [...hideStats, key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function recalculateElevation() {
|
||||||
|
recalculating = true;
|
||||||
|
recalcStatus = '';
|
||||||
|
recalcOk = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${api}/recalculate-elevation`, { method: 'POST' });
|
||||||
|
const d = await res.json();
|
||||||
|
if (!res.ok) throw new Error(d.detail ?? await res.text());
|
||||||
|
recalcOk = true;
|
||||||
|
const gain = d.elevation_gain_m != null ? `↑ ${Math.round(d.elevation_gain_m)} m` : '';
|
||||||
|
const loss = d.elevation_loss_m != null ? `↓ ${Math.round(d.elevation_loss_m)} m` : '';
|
||||||
|
recalcStatus = [gain, loss].filter(Boolean).join(' ');
|
||||||
|
} catch (e: any) {
|
||||||
|
recalcStatus = e.message;
|
||||||
|
recalcOk = false;
|
||||||
|
} finally {
|
||||||
|
recalculating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteActivity() {
|
async function deleteActivity() {
|
||||||
if (!confirmDelete) { confirmDelete = true; return; }
|
if (!confirmDelete) { confirmDelete = true; return; }
|
||||||
deleting = true;
|
deleting = true;
|
||||||
@@ -264,6 +289,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Elevation recalculation -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-xs text-zinc-500 mb-2">Elevation</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-zinc-700 text-xs text-zinc-400 hover:border-zinc-500 hover:text-zinc-200 transition-colors disabled:opacity-40"
|
||||||
|
disabled={recalculating}
|
||||||
|
on:click={recalculateElevation}
|
||||||
|
>
|
||||||
|
{recalculating ? 'Querying terrain data…' : '⛰ Recalculate from terrain map (DEM)'}
|
||||||
|
</button>
|
||||||
|
{#if recalcStatus}
|
||||||
|
<p class="text-xs mt-1.5 text-center" class:text-green-400={recalcOk} class:text-red-400={!recalcOk}>
|
||||||
|
{recalcStatus}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Flags -->
|
<!-- Flags -->
|
||||||
<div class="flex gap-3 mb-2">
|
<div class="flex gap-3 mb-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user