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)
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -325,6 +331,12 @@
|
||||
</div>
|
||||
{/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 -->
|
||||
{:else if activeTab === 'profile'}
|
||||
<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;
|
||||
/** 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 {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fullscreen ? (
|
||||
<main style="height:calc(100vh - 3rem);overflow:hidden">
|
||||
<slot />
|
||||
</main>
|
||||
) : (
|
||||
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||
<slot />
|
||||
</main>
|
||||
)}
|
||||
|
||||
<script>
|
||||
// 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