From 5307ae287c4e3e91a63aae62f13290566d6953ed Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Thu, 14 May 2026 14:31:21 +0200 Subject: [PATCH] Explore: personal GPS heatmap tab under Athlete page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bincio/cli.py | 18 + bincio/explore.py | 97 +++++ bincio/render/cli.py | 14 + bincio/serve/routers/me.py | 14 +- bincio/serve/routers/uploads.py | 5 + scripts/dev_test.py | 5 + site/src/components/AthleteView.svelte | 20 +- site/src/components/Explore.svelte | 410 ++++++++++++++++++ site/src/layouts/Base.astro | 16 +- .../u/[handle]/athlete/explore/index.astro | 18 + 10 files changed, 607 insertions(+), 10 deletions(-) create mode 100644 bincio/explore.py create mode 100644 site/src/components/Explore.svelte create mode 100644 site/src/pages/u/[handle]/athlete/explore/index.astro diff --git a/bincio/cli.py b/bincio/cli.py index 0adae7b..7b740d7 100644 --- a/bincio/cli.py +++ b/bincio/cli.py @@ -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) diff --git a/bincio/explore.py b/bincio/explore.py new file mode 100644 index 0000000..f1ea156 --- /dev/null +++ b/bincio/explore.py @@ -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) diff --git a/bincio/render/cli.py b/bincio/render/cli.py index e3697d3..b2efaa4 100644 --- a/bincio/render/cli.py +++ b/bincio/render/cli.py @@ -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: diff --git a/bincio/serve/routers/me.py b/bincio/serve/routers/me.py index 8c55956..dc3371b 100644 --- a/bincio/serve/routers/me.py +++ b/bincio/serve/routers/me.py @@ -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.""" diff --git a/bincio/serve/routers/uploads.py b/bincio/serve/routers/uploads.py index 5b78e7f..85536ee 100644 --- a/bincio/serve/routers/uploads.py +++ b/bincio/serve/routers/uploads.py @@ -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) diff --git a/scripts/dev_test.py b/scripts/dev_test.py index d515046..146a8a1 100755 --- a/scripts/dev_test.py +++ b/scripts/dev_test.py @@ -142,6 +142,11 @@ def prepare_serve() -> None: _write_root_manifest(DATA_DIR) ok("root manifest updated") + from bincio.explore import bake_tracks + for handle in ("dave", "brut"): + n = bake_tracks(handle, DATA_DIR) + ok(f"{handle}: {n} track(s) baked for explore") + # ── 4. Hand off to bincio dev ───────────────────────────────────────────────── diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index c6975d4..2b6d301 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -4,6 +4,7 @@ import MmpChart from './MmpChart.svelte'; import RecordsView from './RecordsView.svelte'; import AthleteDrawer from './AthleteDrawer.svelte'; + import Explore from './Explore.svelte'; import { isUnlisted, formatElapsed, formatDistance, sportIcon } from '../lib/format'; import { loadIndex, loadAthlete } from '../lib/dataloader'; @@ -21,9 +22,10 @@ let error: string | null = null; let drawerOpen = false; - type Tab = 'power' | 'records' | 'segments' | 'profile'; + type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore'; let activeTab: Tab = 'power'; let mounted = false; + let isOwner = false; interface SegmentSummaryItem { segment: { id: string; name: string; sport: string | null; distance_m: number }; @@ -80,9 +82,11 @@ } onMount(async () => { - const TABS: Tab[] = ['power', 'records', 'segments', 'profile']; + isOwner = (window as any).__bincioMe === handle; + const TABS: Tab[] = ['power', 'records', 'segments', 'profile', 'explore']; const rawTab = new URLSearchParams(window.location.search).get('tab'); - activeTab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power'; + const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power'; + activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved; mounted = true; // Resolve handle for the segments endpoint @@ -140,12 +144,14 @@ return hi >= 900 ? `${lo}+ bpm` : `${lo}–${hi} bpm`; } - const TABS: { key: Tab; label: string }[] = [ + const ALL_TABS: { key: Tab; label: string; ownerOnly?: boolean }[] = [ { key: 'power', label: 'Power Curve' }, { key: 'records', label: 'Records' }, { key: 'segments', label: 'Segments' }, { key: 'profile', label: 'Profile' }, + { key: 'explore', label: 'Explore', ownerOnly: true }, ]; + $: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner); {#if loading} @@ -325,6 +331,12 @@ {/if} + + {:else if activeTab === 'explore'} +
+ +
+ {:else if activeTab === 'profile'}
diff --git a/site/src/components/Explore.svelte b/site/src/components/Explore.svelte new file mode 100644 index 0000000..4b4dd77 --- /dev/null +++ b/site/src/components/Explore.svelte @@ -0,0 +1,410 @@ + + +
+ + + + +
+
+ + diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 12805bc..62b00c4 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -7,8 +7,10 @@ interface Props { description?: string; /** Set true on pages that must remain accessible without auth (login, register). */ public?: boolean; + /** Remove the content wrapper so a child can fill the remaining viewport. */ + fullscreen?: boolean; } -const { title = 'BincioActivity', description = 'Your personal activity stats', public: isPublicPage = false } = Astro.props; +const { title = 'BincioActivity', description = 'Your personal activity stats', public: isPublicPage = false, fullscreen = false } = Astro.props; const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; const wikiUrl = import.meta.env.PUBLIC_WIKI_URL ?? ''; const plannerUrl = import.meta.env.PUBLIC_PLANNER_URL ?? ''; @@ -547,9 +549,15 @@ try {
)} -
- -
+ {fullscreen ? ( +
+ +
+ ) : ( +
+ +
+ )}