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.sync_strava import sync_strava_cmd # noqa: E402
|
||||||
from bincio.segments.cli import segments_group # 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(extract)
|
||||||
main.add_command(render)
|
main.add_command(render)
|
||||||
main.add_command(edit)
|
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.")
|
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:
|
def _write_root_manifest(data: Path) -> None:
|
||||||
"""Rewrite the root index.json shard manifest from current user dirs."""
|
"""Rewrite the root index.json shard manifest from current user dirs."""
|
||||||
import json
|
import json
|
||||||
@@ -194,6 +207,7 @@ def render(
|
|||||||
console.print(f"Data: [cyan]{data}[/cyan]")
|
console.print(f"Data: [cyan]{data}[/cyan]")
|
||||||
|
|
||||||
_merge_edits(data, handle=handle)
|
_merge_edits(data, handle=handle)
|
||||||
|
_bake_tracks(data, handle=handle)
|
||||||
_write_root_manifest(data)
|
_write_root_manifest(data)
|
||||||
|
|
||||||
if no_build:
|
if no_build:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
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 import deps, tasks
|
||||||
from bincio.serve.db import (
|
from bincio.serve.db import (
|
||||||
@@ -43,7 +43,7 @@ def _wipe_user_activities(user_dir: Path) -> int:
|
|||||||
if d.exists():
|
if d.exists():
|
||||||
shutil.rmtree(d)
|
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
|
f = user_dir / name
|
||||||
if f.exists():
|
if f.exists():
|
||||||
f.unlink()
|
f.unlink()
|
||||||
@@ -52,6 +52,16 @@ def _wipe_user_activities(user_dir: Path) -> int:
|
|||||||
return deleted
|
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")
|
@router.get("/api/me/storage")
|
||||||
async def me_storage(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
async def me_storage(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
|
||||||
"""Return per-category disk usage for the logged-in user."""
|
"""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)
|
user.handle, imported_count, error_count)
|
||||||
if any_imported:
|
if any_imported:
|
||||||
merge_all(dd)
|
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)
|
tasks._trigger_rebuild(user.handle)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.error("strava-zip[%s]: fatal error: %s", user.handle, exc, exc_info=True)
|
log.error("strava-zip[%s]: fatal error: %s", user.handle, exc, exc_info=True)
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ def prepare_serve() -> None:
|
|||||||
_write_root_manifest(DATA_DIR)
|
_write_root_manifest(DATA_DIR)
|
||||||
ok("root manifest updated")
|
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 ─────────────────────────────────────────────────
|
# ── 4. Hand off to bincio dev ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import MmpChart from './MmpChart.svelte';
|
import MmpChart from './MmpChart.svelte';
|
||||||
import RecordsView from './RecordsView.svelte';
|
import RecordsView from './RecordsView.svelte';
|
||||||
import AthleteDrawer from './AthleteDrawer.svelte';
|
import AthleteDrawer from './AthleteDrawer.svelte';
|
||||||
|
import Explore from './Explore.svelte';
|
||||||
import { isUnlisted, formatElapsed, formatDistance, sportIcon } from '../lib/format';
|
import { isUnlisted, formatElapsed, formatDistance, sportIcon } from '../lib/format';
|
||||||
import { loadIndex, loadAthlete } from '../lib/dataloader';
|
import { loadIndex, loadAthlete } from '../lib/dataloader';
|
||||||
|
|
||||||
@@ -21,9 +22,10 @@
|
|||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
let drawerOpen = false;
|
let drawerOpen = false;
|
||||||
|
|
||||||
type Tab = 'power' | 'records' | 'segments' | 'profile';
|
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore';
|
||||||
let activeTab: Tab = 'power';
|
let activeTab: Tab = 'power';
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
let isOwner = false;
|
||||||
|
|
||||||
interface SegmentSummaryItem {
|
interface SegmentSummaryItem {
|
||||||
segment: { id: string; name: string; sport: string | null; distance_m: number };
|
segment: { id: string; name: string; sport: string | null; distance_m: number };
|
||||||
@@ -80,9 +82,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
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');
|
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;
|
mounted = true;
|
||||||
|
|
||||||
// Resolve handle for the segments endpoint
|
// Resolve handle for the segments endpoint
|
||||||
@@ -140,12 +144,14 @@
|
|||||||
return hi >= 900 ? `${lo}+ bpm` : `${lo}–${hi} bpm`;
|
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: 'power', label: 'Power Curve' },
|
||||||
{ key: 'records', label: 'Records' },
|
{ key: 'records', label: 'Records' },
|
||||||
{ key: 'segments', label: 'Segments' },
|
{ key: 'segments', label: 'Segments' },
|
||||||
{ key: 'profile', label: 'Profile' },
|
{ key: 'profile', label: 'Profile' },
|
||||||
|
{ key: 'explore', label: 'Explore', ownerOnly: true },
|
||||||
];
|
];
|
||||||
|
$: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -325,6 +331,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Explore tab -->
|
||||||
|
{:else if activeTab === 'explore'}
|
||||||
|
<div style="height: calc(100vh - 200px); margin: 0 -1rem -1.5rem;">
|
||||||
|
<Explore {handle} {base} embedded={true} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Profile tab -->
|
<!-- Profile tab -->
|
||||||
{:else if activeTab === 'profile'}
|
{:else if activeTab === 'profile'}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
|
||||||
|
export let handle: string;
|
||||||
|
export let base: string;
|
||||||
|
export let embedded: boolean = false;
|
||||||
|
|
||||||
|
interface Track { id: string; date: string; type: string; name: string; dist: number; coords: [number,number][]; }
|
||||||
|
|
||||||
|
let mapEl: HTMLDivElement;
|
||||||
|
let map: any;
|
||||||
|
let mapReady = false;
|
||||||
|
|
||||||
|
let tracks: Track[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
let selectedTypes: Set<string> = new Set();
|
||||||
|
let dateFrom = '';
|
||||||
|
let dateTo = '';
|
||||||
|
let selectedYear: string | null = null;
|
||||||
|
|
||||||
|
// View
|
||||||
|
let viewMode: 'lines' | 'heatmap' = 'lines';
|
||||||
|
let heatmapMode: 'global' | 'bytype' = 'global';
|
||||||
|
|
||||||
|
// Tile layers — same as planner
|
||||||
|
const TILES: Record<string, { tiles: string[]; attribution: string; label: string }> = {
|
||||||
|
cyclosm: { label: 'Cycle', attribution: '© CyclOSM | © OpenStreetMap contributors', tiles: ['https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png','https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png','https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png'] },
|
||||||
|
osm: { label: 'OSM', attribution: '© OpenStreetMap contributors', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'] },
|
||||||
|
topo: { label: 'Topo', attribution: '© OpenTopoMap | © OpenStreetMap contributors', tiles: ['https://a.tile.opentopomap.org/{z}/{x}/{y}.png','https://b.tile.opentopomap.org/{z}/{x}/{y}.png','https://c.tile.opentopomap.org/{z}/{x}/{y}.png'] },
|
||||||
|
satellite: { label: 'Sat', attribution: '© Esri World Imagery', tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'] },
|
||||||
|
};
|
||||||
|
const TILE_ORDER = ['cyclosm', 'osm', 'topo', 'satellite'];
|
||||||
|
let tileKey = 'cyclosm';
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
cycling: '#e879a0',
|
||||||
|
running: '#60a5fa',
|
||||||
|
hiking: '#4ade80',
|
||||||
|
skiing: '#93c5fd',
|
||||||
|
other: '#a78bfa',
|
||||||
|
};
|
||||||
|
const HEAT_TYPES = Object.keys(TYPE_COLORS);
|
||||||
|
|
||||||
|
function typeColor(t: string): string { return TYPE_COLORS[t] ?? TYPE_COLORS.other; }
|
||||||
|
|
||||||
|
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$: allTypes = [...new Set(tracks.map(t => t.type))].sort();
|
||||||
|
$: availableYears = [...new Set(tracks.map(t => t.date.slice(0,4)).filter(Boolean))].sort().reverse();
|
||||||
|
|
||||||
|
$: if (allTypes.length > 0 && selectedTypes.size === 0) selectedTypes = new Set(allTypes);
|
||||||
|
|
||||||
|
$: filteredTracks = tracks.filter(t => {
|
||||||
|
if (!selectedTypes.has(t.type)) return false;
|
||||||
|
if (dateFrom && t.date < dateFrom) return false;
|
||||||
|
if (dateTo && t.date > dateTo) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$: if (mapReady) _updateMap(filteredTracks, viewMode, heatmapMode);
|
||||||
|
|
||||||
|
// ── Filters ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setTile(key: string) {
|
||||||
|
tileKey = key;
|
||||||
|
map?.getSource('base')?.setTiles(TILES[key].tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleType(t: string) {
|
||||||
|
const s = new Set(selectedTypes);
|
||||||
|
s.has(t) ? s.delete(t) : s.add(t);
|
||||||
|
selectedTypes = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllTypes() { selectedTypes = new Set(allTypes); }
|
||||||
|
function clearAllTypes() { selectedTypes = new Set(); }
|
||||||
|
|
||||||
|
function setYear(y: string) {
|
||||||
|
if (selectedYear === y) { selectedYear = null; dateFrom = ''; dateTo = ''; return; }
|
||||||
|
selectedYear = y;
|
||||||
|
dateFrom = `${y}-01-01`;
|
||||||
|
dateTo = `${y}-12-31`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMonth(m: number) { // m: 1-12
|
||||||
|
if (!selectedYear) return;
|
||||||
|
const mm = String(m).padStart(2, '0');
|
||||||
|
const last = new Date(+selectedYear, m, 0).getDate();
|
||||||
|
dateFrom = `${selectedYear}-${mm}-01`;
|
||||||
|
dateTo = `${selectedYear}-${mm}-${String(last).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDates() { dateFrom = ''; dateTo = ''; selectedYear = null; }
|
||||||
|
|
||||||
|
// ── Map ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _linesGeoJSON(ts: Track[]) {
|
||||||
|
return { type: 'FeatureCollection', features: ts.map(t => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'LineString', coordinates: t.coords },
|
||||||
|
properties: { type: t.type },
|
||||||
|
}))};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _heatGeoJSON(ts: Track[]) {
|
||||||
|
const features: any[] = [];
|
||||||
|
for (const t of ts)
|
||||||
|
for (const [lng, lat] of t.coords)
|
||||||
|
features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] }, properties: { type: t.type } });
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _empty() { return { type: 'FeatureCollection', features: [] }; }
|
||||||
|
|
||||||
|
function _hexRgb(hex: string): [number,number,number] {
|
||||||
|
const m = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
||||||
|
return m ? [parseInt(m[1],16), parseInt(m[2],16), parseInt(m[3],16)] : [160,160,160];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateMap(filtered: Track[], view: string, heatMode: string) {
|
||||||
|
const linesSrc = map.getSource('explore-lines');
|
||||||
|
const heatSrc = map.getSource('explore-heat');
|
||||||
|
if (!linesSrc || !heatSrc) return;
|
||||||
|
|
||||||
|
linesSrc.setData(view === 'lines' ? _linesGeoJSON(filtered) : _empty());
|
||||||
|
heatSrc.setData(view === 'heatmap' ? _heatGeoJSON(filtered) : _empty());
|
||||||
|
|
||||||
|
map.setLayoutProperty('explore-lines', 'visibility', view === 'lines' ? 'visible' : 'none');
|
||||||
|
map.setLayoutProperty('explore-heat-global', 'visibility', view === 'heatmap' && heatMode === 'global' ? 'visible' : 'none');
|
||||||
|
for (const t of HEAT_TYPES)
|
||||||
|
map.setLayoutProperty(`explore-heat-${t}`, 'visibility', view === 'heatmap' && heatMode === 'bytype' ? 'visible' : 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fitBounds(ts: Track[]) {
|
||||||
|
if (!ts.length) return;
|
||||||
|
let w = Infinity, e = -Infinity, s = Infinity, n = -Infinity;
|
||||||
|
for (const t of ts) for (const [lng, lat] of t.coords) {
|
||||||
|
if (lng < w) w = lng; if (lng > e) e = lng;
|
||||||
|
if (lat < s) s = lat; if (lat > n) n = lat;
|
||||||
|
}
|
||||||
|
if (w === Infinity) return;
|
||||||
|
map.fitBounds([[w,s],[e,n]], { padding: 40, maxZoom: 14 });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/me/tracks', { credentials: 'include' });
|
||||||
|
if (!r.ok) { error = r.status === 404 ? 'No tracks baked yet — upload activities first.' : `Error ${r.status}`; loading = false; return; }
|
||||||
|
const d = await r.json();
|
||||||
|
tracks = d.tracks ?? [];
|
||||||
|
} catch (e: any) { error = e.message ?? 'Failed to load'; loading = false; return; }
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
map = new maplibregl.Map({
|
||||||
|
container: mapEl,
|
||||||
|
style: { version: 8,
|
||||||
|
sources: { base: { type: 'raster', tiles: TILES.cyclosm.tiles, tileSize: 256, attribution: TILES.cyclosm.attribution } },
|
||||||
|
layers: [{ id: 'base', type: 'raster', source: 'base' }],
|
||||||
|
},
|
||||||
|
center: [12, 42], zoom: 5,
|
||||||
|
});
|
||||||
|
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
map.addSource('explore-lines', { type: 'geojson', data: _empty() });
|
||||||
|
map.addSource('explore-heat', { type: 'geojson', data: _empty() });
|
||||||
|
|
||||||
|
// Lines layer — color by type
|
||||||
|
map.addLayer({ id: 'explore-lines', type: 'line', source: 'explore-lines', layout: { visibility: 'none' },
|
||||||
|
paint: { 'line-width': 2, 'line-opacity': 0.5,
|
||||||
|
'line-color': ['match', ['get', 'type'],
|
||||||
|
'cycling', TYPE_COLORS.cycling, 'running', TYPE_COLORS.running,
|
||||||
|
'hiking', TYPE_COLORS.hiking, 'skiing', TYPE_COLORS.skiing,
|
||||||
|
TYPE_COLORS.other],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global heatmap
|
||||||
|
map.addLayer({ id: 'explore-heat-global', type: 'heatmap', source: 'explore-heat', layout: { visibility: 'none' },
|
||||||
|
paint: { 'heatmap-radius': 14, 'heatmap-opacity': 0.85,
|
||||||
|
'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'],
|
||||||
|
0, 'rgba(0,0,0,0)', 0.2, '#4ade80', 0.5, '#facc15', 0.8, '#f97316', 1, '#ef4444'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-type heatmap layers
|
||||||
|
for (const [type, hex] of Object.entries(TYPE_COLORS)) {
|
||||||
|
const [r,g,b] = _hexRgb(hex);
|
||||||
|
map.addLayer({ id: `explore-heat-${type}`, type: 'heatmap', source: 'explore-heat',
|
||||||
|
filter: ['==', ['get', 'type'], type], layout: { visibility: 'none' },
|
||||||
|
paint: { 'heatmap-radius': 14, 'heatmap-opacity': 0.85,
|
||||||
|
'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'],
|
||||||
|
0, `rgba(${r},${g},${b},0)`, 0.3, `rgba(${r},${g},${b},0.4)`, 1, `rgba(${r},${g},${b},1)`],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mapReady = true;
|
||||||
|
_updateMap(tracks, viewMode, heatmapMode);
|
||||||
|
_fitBounds(tracks);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => { if (map) map.remove(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="explore-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
{#if !embedded}
|
||||||
|
<header class="sidebar-header">
|
||||||
|
<span class="sidebar-title">Explore</span>
|
||||||
|
<a href="{base}u/{handle}/athlete/" class="back-link">← Athlete</a>
|
||||||
|
</header>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tile layer -->
|
||||||
|
<section class="section">
|
||||||
|
<p class="label">Map</p>
|
||||||
|
<div class="pills">
|
||||||
|
{#each TILE_ORDER as key}
|
||||||
|
<button class="pill" class:active={tileKey === key} onclick={() => setTile(key)}>{TILES[key].label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Type filter -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="label-row">
|
||||||
|
<p class="label">Type</p>
|
||||||
|
<div class="label-actions">
|
||||||
|
<button class="mini-btn" onclick={selectAllTypes}>all</button>
|
||||||
|
<button class="mini-btn" onclick={clearAllTypes}>none</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pills">
|
||||||
|
{#each allTypes as t}
|
||||||
|
<button class="pill type-pill" class:active={selectedTypes.has(t)} onclick={() => toggleType(t)}
|
||||||
|
style:--type-color={typeColor(t)}>
|
||||||
|
<span class="type-dot"></span>{t}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Date filter -->
|
||||||
|
<section class="section">
|
||||||
|
<p class="label">Date</p>
|
||||||
|
<div class="pills year-pills">
|
||||||
|
<button class="pill" class:active={!dateFrom && !dateTo} onclick={clearDates}>All</button>
|
||||||
|
{#each availableYears as y}
|
||||||
|
<button class="pill" class:active={selectedYear === y} onclick={() => setYear(y)}>{y}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if selectedYear}
|
||||||
|
<div class="pills month-pills">
|
||||||
|
{#each MONTHS as m, i}
|
||||||
|
<button class="pill small"
|
||||||
|
class:active={dateFrom === `${selectedYear}-${String(i+1).padStart(2,'0')}-01`}
|
||||||
|
onclick={() => setMonth(i + 1)}>{m}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="date-inputs">
|
||||||
|
<input type="date" class="date-input" bind:value={dateFrom} placeholder="From" />
|
||||||
|
<input type="date" class="date-input" bind:value={dateTo} placeholder="To" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- View toggle -->
|
||||||
|
<section class="section">
|
||||||
|
<p class="label">View</p>
|
||||||
|
<div class="pills">
|
||||||
|
<button class="pill" class:active={viewMode === 'lines'} onclick={() => viewMode = 'lines'}>Lines</button>
|
||||||
|
<button class="pill" class:active={viewMode === 'heatmap'} onclick={() => viewMode = 'heatmap'}>Heatmap</button>
|
||||||
|
</div>
|
||||||
|
{#if viewMode === 'heatmap'}
|
||||||
|
<div class="pills" style="margin-top:0.375rem">
|
||||||
|
<button class="pill small" class:active={heatmapMode === 'global'} onclick={() => heatmapMode = 'global'}>Global</button>
|
||||||
|
<button class="pill small" class:active={heatmapMode === 'bytype'} onclick={() => heatmapMode = 'bytype'}>By type</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<section class="section">
|
||||||
|
<p class="stat-line">
|
||||||
|
<span class="stat-val">{filteredTracks.length}</span>
|
||||||
|
<span class="muted"> / {tracks.length} tracks</span>
|
||||||
|
</p>
|
||||||
|
{#if filteredTracks.length > 0}
|
||||||
|
<p class="stat-line muted small">
|
||||||
|
{(filteredTracks.reduce((s,t) => s + t.dist, 0) / 1000).toFixed(0)} km total
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if loading}<p class="status">Loading tracks…</p>{/if}
|
||||||
|
{#if error}<p class="status error">{error}</p>{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Map -->
|
||||||
|
<div class="map-wrap" bind:this={mapEl}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.explore-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.sidebar-title { font-size: 0.9rem; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.back-link { font-size: 0.72rem; color: var(--text-5); text-decoration: none; }
|
||||||
|
.back-link:hover { color: var(--text-4); }
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-sub, var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-5);
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.45rem; }
|
||||||
|
.label-row .label { margin-bottom: 0; }
|
||||||
|
.label-actions { display: flex; gap: 0.25rem; }
|
||||||
|
.mini-btn { background: none; border: none; font-size: 0.65rem; color: var(--text-5); cursor: pointer; padding: 0 0.2rem; }
|
||||||
|
.mini-btn:hover { color: var(--text-4); }
|
||||||
|
|
||||||
|
.pills { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
||||||
|
.pill {
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-4);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.pill:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.pill.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
|
||||||
|
.pill.small { padding: 0.15rem 0.45rem; font-size: 0.67rem; }
|
||||||
|
|
||||||
|
.type-pill.active { background: color-mix(in srgb, var(--type-color) 15%, transparent); border-color: var(--type-color); color: var(--type-color); }
|
||||||
|
.type-pill:hover { border-color: var(--type-color); color: var(--type-color); }
|
||||||
|
.type-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--type-color); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.year-pills { margin-bottom: 0.4rem; }
|
||||||
|
.month-pills { margin-bottom: 0.4rem; }
|
||||||
|
|
||||||
|
.date-inputs { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.4rem; }
|
||||||
|
.date-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated, var(--bg));
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.date-input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
.stat-line { margin: 0 0 0.2rem; font-size: 0.8rem; }
|
||||||
|
.stat-val { font-weight: 600; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.status { font-size: 0.72rem; color: var(--text-5); padding: 0.5rem 1rem 0; margin: 0; }
|
||||||
|
.status.error { color: #f87171; }
|
||||||
|
|
||||||
|
.muted { color: var(--text-5); }
|
||||||
|
.small { font-size: 0.72rem; margin: 0; }
|
||||||
|
|
||||||
|
.map-wrap { flex: 1; height: 100%; }
|
||||||
|
</style>
|
||||||
@@ -7,8 +7,10 @@ interface Props {
|
|||||||
description?: string;
|
description?: string;
|
||||||
/** Set true on pages that must remain accessible without auth (login, register). */
|
/** Set true on pages that must remain accessible without auth (login, register). */
|
||||||
public?: boolean;
|
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 editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||||||
const wikiUrl = import.meta.env.PUBLIC_WIKI_URL ?? '';
|
const wikiUrl = import.meta.env.PUBLIC_WIKI_URL ?? '';
|
||||||
const plannerUrl = import.meta.env.PUBLIC_PLANNER_URL ?? '';
|
const plannerUrl = import.meta.env.PUBLIC_PLANNER_URL ?? '';
|
||||||
@@ -547,9 +549,15 @@ try {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main class="max-w-7xl mx-auto px-4 py-6">
|
{fullscreen ? (
|
||||||
<slot />
|
<main style="height:calc(100vh - 3rem);overflow:hidden">
|
||||||
</main>
|
<slot />
|
||||||
|
</main>
|
||||||
|
) : (
|
||||||
|
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Register service worker for local activity storage (offline support)
|
// Register service worker for local activity storage (offline support)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../../../../layouts/Base.astro';
|
||||||
|
import Explore from '../../../../../components/Explore.svelte';
|
||||||
|
import { readShardHandles } from '../../../../../lib/manifest';
|
||||||
|
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return readShardHandles().map(({ handle }) => ({
|
||||||
|
params: { handle },
|
||||||
|
props: { handle },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { handle } = Astro.props as { handle: string };
|
||||||
|
const base = import.meta.env.BASE_URL;
|
||||||
|
---
|
||||||
|
<Base title={`@${handle} Explore — BincioActivity`} fullscreen>
|
||||||
|
<Explore {handle} {base} client:only="svelte" />
|
||||||
|
</Base>
|
||||||
Reference in New Issue
Block a user