Explore: personal GPS heatmap tab under Athlete page

- bincio/explore.py: bake_tracks() simplifies GPS coords (RDP ε=0.0001),
  strips to [lng,lat], groups by sport type, writes per-handle tracks.json
- bake-tracks CLI command; render CLI calls _bake_tracks() after each build;
  strava_zip runs it once at end of batch
- /api/me/tracks endpoint serves the baked file; wipe_user cleans it up
- Explore.svelte: MapLibre full-screen map with sidebar — type pills,
  year/month date filter, Lines / Heatmap (global or by-type) view modes
- AthleteView: Explore tab visible only to profile owner (checks __bincioMe)
- Base.astro: fullscreen prop + Planner nav link
This commit is contained in:
Davide Scaini
2026-05-14 14:31:21 +02:00
parent 2daa66d7b0
commit 5307ae287c
10 changed files with 607 additions and 10 deletions
+18
View File
@@ -22,6 +22,24 @@ from bincio.reextract_cmd import reextract_originals # noqa: E402
from bincio.sync_strava import sync_strava_cmd # noqa: E402
from bincio.segments.cli import segments_group # noqa: E402
@main.command("bake-tracks")
@click.option("--data-dir", required=True, help="BAS data store directory.")
@click.option("--handle", default=None, help="Bake one user only (default: all).")
def bake_tracks_cmd(data_dir: str, handle: str | None) -> None:
"""Pre-bake GPS tracks.json for the Explore heatmap page."""
from pathlib import Path
from bincio.explore import bake_tracks
from bincio.render.cli import _user_dirs
from rich.console import Console
console = Console()
data = Path(data_dir).expanduser().resolve()
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
n = bake_tracks(user_dir.name, data)
console.print(f" [cyan]{user_dir.name}[/cyan]: {n} track(s) baked")
main.add_command(extract)
main.add_command(render)
main.add_command(edit)
+97
View File
@@ -0,0 +1,97 @@
"""Pre-bake per-handle GPS tracks for the Explore page.
Reads all activity GeoJSON files for a handle, applies RDP simplification,
and writes a single tracks.json for fast client-side heatmap rendering.
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from bincio.extract.simplify import _rdp_mask
_VERSION = 1
_RDP_EPSILON = 0.0001 # ~10 m on the ground
_SPORT_MAP: dict[str, str] = {
"cycling": "cycling", "road_cycling": "cycling", "gravel_cycling": "cycling",
"mountain_biking": "cycling", "e_biking": "cycling", "indoor_cycling": "cycling",
"biking": "cycling", "bike": "cycling", "ride": "cycling",
"running": "running", "trail_running": "running", "treadmill_running": "running",
"jogging": "running",
"hiking": "hiking", "walking": "hiking", "trekking": "hiking",
"mountaineering": "hiking",
"skiing": "skiing", "cross_country_skiing": "skiing", "alpine_skiing": "skiing",
"snowboarding": "skiing",
}
def _sport_to_type(sport: str | None) -> str:
if not sport:
return "other"
return _SPORT_MAP.get(sport.lower(), "other")
def bake_tracks(handle: str, data_dir: Path) -> int:
"""Build tracks.json for handle. Returns number of tracks included."""
acts_dir = data_dir / handle / "activities"
if not acts_dir.exists():
return 0
tracks = []
for gj_path in sorted(acts_dir.glob("*.geojson")):
act_id = gj_path.stem
meta: dict = {}
meta_path = acts_dir / f"{act_id}.json"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
if meta.get("virtual"):
continue
try:
gj = json.loads(gj_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
raw_coords = gj.get("geometry", {}).get("coordinates") or []
if len(raw_coords) < 2:
continue
lng_lat = [[float(c[0]), float(c[1])] for c in raw_coords if len(c) >= 2]
if len(lng_lat) < 2:
continue
mask = _rdp_mask(lng_lat, epsilon=_RDP_EPSILON)
simplified = [pt for pt, keep in zip(lng_lat, mask) if keep]
if len(simplified) < 2:
continue
tracks.append({
"id": act_id,
"date": (meta.get("started_at") or "")[:10],
"type": _sport_to_type(meta.get("sport")),
"name": meta.get("title") or act_id,
"dist": int(meta.get("distance_m") or 0),
"coords": simplified,
})
tracks.sort(key=lambda t: t["date"], reverse=True)
out = data_dir / handle / "tracks.json"
out.write_text(
json.dumps({
"v": _VERSION,
"handle": handle,
"generated_at": int(time.time()),
"tracks": tracks,
}),
encoding="utf-8",
)
return len(tracks)
+14
View File
@@ -92,6 +92,19 @@ def _merge_edits(data: Path, handle: str | None = None) -> None:
console.print("No sidecars found — _merged/ dirs mirror extracted data.")
def _bake_tracks(data: Path, handle: str | None = None) -> None:
"""Bake tracks.json for one user or all users."""
from bincio.explore import bake_tracks
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
try:
n = bake_tracks(user_dir.name, data)
console.print(f" [cyan]{user_dir.name}[/cyan]: {n} track(s) baked")
except Exception as exc:
console.print(f" [yellow]{user_dir.name}[/yellow]: bake_tracks failed: {exc}")
def _write_root_manifest(data: Path) -> None:
"""Rewrite the root index.json shard manifest from current user dirs."""
import json
@@ -194,6 +207,7 @@ def render(
console.print(f"Data: [cyan]{data}[/cyan]")
_merge_edits(data, handle=handle)
_bake_tracks(data, handle=handle)
_write_root_manifest(data)
if no_build:
+12 -2
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Any
from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, Response
from bincio.serve import deps, tasks
from bincio.serve.db import (
@@ -43,7 +43,7 @@ def _wipe_user_activities(user_dir: Path) -> int:
if d.exists():
shutil.rmtree(d)
for name in ("index.json", "athlete.json", ".bincio_cache.json"):
for name in ("index.json", "athlete.json", ".bincio_cache.json", "tracks.json"):
f = user_dir / name
if f.exists():
f.unlink()
@@ -52,6 +52,16 @@ def _wipe_user_activities(user_dir: Path) -> int:
return deleted
@router.get("/api/me/tracks")
async def me_tracks(bincio_session: str | None = Cookie(default=None)) -> Response:
"""Return the pre-baked tracks.json for the logged-in user (Explore page)."""
user = deps._require_user(bincio_session)
tracks_path = deps._get_data_dir() / user.handle / "tracks.json"
if not tracks_path.exists():
raise HTTPException(404, "Tracks not yet baked — upload an activity first")
return Response(content=tracks_path.read_bytes(), media_type="application/json")
@router.get("/api/me/storage")
async def me_storage(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Return per-category disk usage for the logged-in user."""
+5
View File
@@ -487,6 +487,11 @@ async def upload_strava_zip(
user.handle, imported_count, error_count)
if any_imported:
merge_all(dd)
try:
from bincio.explore import bake_tracks
bake_tracks(user.handle, deps._get_data_dir())
except Exception as exc:
log.warning("strava-zip[%s]: bake_tracks failed (non-fatal): %s", user.handle, exc)
tasks._trigger_rebuild(user.handle)
except Exception as exc:
log.error("strava-zip[%s]: fatal error: %s", user.handle, exc, exc_info=True)