Files
bincio-activity/site/src/components/Explore.svelte
T

425 lines
16 KiB
Svelte

<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;
let selectedMonth: number | null = null;
// View
let viewMode: 'lines' | 'heatmap' = 'heatmap';
let heatmapMode: 'global' | 'bytype' = 'bytype';
// Tile layers — same as planner
const TILES: Record<string, { tiles: string[]; attribution: string; label: string }> = {
grey: { label: 'Grey', attribution: '© CARTO | © OpenStreetMap contributors', tiles: ['https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png','https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png','https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png'] },
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 = ['grey', 'cyclosm', 'osm', 'topo', 'satellite'];
let tileKey = 'grey';
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();
let typesInitialized = false;
$: if (allTypes.length > 0 && !typesInitialized) { selectedTypes = new Set(allTypes); typesInitialized = true; }
$: 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; selectedMonth = null; dateFrom = ''; dateTo = ''; return; }
selectedYear = y;
selectedMonth = null;
dateFrom = `${y}-01-01`;
dateTo = `${y}-12-31`;
}
function setMonth(m: number) { // m: 1-12
if (!selectedYear) return;
selectedMonth = m;
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 clearMonth() {
if (!selectedYear) return;
selectedMonth = null;
dateFrom = `${selectedYear}-01-01`;
dateTo = `${selectedYear}-12-31`;
}
function clearDates() { dateFrom = ''; dateTo = ''; selectedYear = null; selectedMonth = null; }
// ── Map ────────────────────────────────────────────────────────────────────
let _bbox: { w: number; e: number; s: number; n: number } | null = null;
function _getBbox() {
if (!map) return null;
const b = map.getBounds();
return { w: b.getWest(), e: b.getEast(), s: b.getSouth(), n: b.getNorth() };
}
function _inBbox(lng: number, lat: number): boolean {
if (!_bbox) return true;
return lng >= _bbox.w && lng <= _bbox.e && lat >= _bbox.s && lat <= _bbox.n;
}
function _linesGeoJSON(ts: Track[]) {
const visible = _bbox ? ts.filter(t => t.coords.some(([lng, lat]) => _inBbox(lng, lat))) : ts;
return { type: 'FeatureCollection', features: visible.map(t => ({
type: 'Feature',
geometry: { type: 'LineString', coordinates: t.coords },
properties: { type: t.type },
}))};
}
function _empty() { return { type: 'FeatureCollection', features: [] }; }
function _updateMap(filtered: Track[], view: string, heatMode: string) {
const linesSrc = map.getSource('explore-lines');
if (!linesSrc) return;
linesSrc.setData(_linesGeoJSON(filtered));
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' && selectedTypes.has(t) ? '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.grey.tiles, tileSize: 256, attribution: TILES.grey.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() });
// Normal lines — color by type, readable opacity
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],
},
});
const heatWidth = ['interpolate', ['linear'], ['zoom'], 5, 2, 10, 4, 14, 7, 18, 10];
const heatOpacity = ['interpolate', ['linear'], ['zoom'], 5, 0.10, 10, 0.12, 14, 0.15, 18, 0.18];
// Global heatmap — all lines in warm amber, very low opacity so overlapping routes stack up
map.addLayer({ id: 'explore-heat-global', type: 'line', source: 'explore-lines', layout: { visibility: 'none' },
paint: { 'line-width': heatWidth, 'line-opacity': heatOpacity, 'line-blur': 1, 'line-color': '#f97316' },
});
// Per-type heatmap layers — same accumulation trick, type-specific colour
for (const [type, hex] of Object.entries(TYPE_COLORS)) {
map.addLayer({ id: `explore-heat-${type}`, type: 'line', source: 'explore-lines',
filter: ['==', ['get', 'type'], type], layout: { visibility: 'none' },
paint: { 'line-width': heatWidth, 'line-opacity': heatOpacity, 'line-blur': 1, 'line-color': hex },
});
}
mapReady = true;
_bbox = _getBbox();
_updateMap(tracks, viewMode, heatmapMode);
_fitBounds(tracks);
});
map.on('moveend', () => {
_bbox = _getBbox();
if (mapReady) _updateMap(filteredTracks, viewMode, heatmapMode);
});
});
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">
<button class="pill small" class:active={selectedMonth === null} onclick={clearMonth}>All</button>
{#each MONTHS as m, i}
<button class="pill small"
class:active={selectedMonth === i + 1}
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>