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)
+5
View File
@@ -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 ─────────────────────────────────────────────────
+16 -4
View File
@@ -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">
+410
View File
@@ -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>
+12 -4
View File
@@ -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>
)}
<main class="max-w-7xl mx-auto px-4 py-6">
<slot />
</main>
{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>