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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user