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