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

386 lines
14 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format';
let all: ActivitySummary[] = [];
let sport: Sport | 'all' = 'all';
let loading = true;
onMount(async () => {
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
const index: BASIndex = await res.json();
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
loading = false;
});
$: activities = sport === 'all' ? all : all.filter(a => a.sport === sport);
// ── Tooltip ───────────────────────────────────────────────────────────────
$: activitiesByDate = (() => {
const m = new Map<string, ActivitySummary[]>();
for (const a of activities) {
const d = a.started_at.slice(0, 10);
if (!m.has(d)) m.set(d, []);
m.get(d)!.push(a);
}
return m;
})();
let hoveredDate: string | null = null;
let pinnedDate: string | null = null;
let tooltipEl: HTMLElement | null = null;
let tooltipPos = { x: 0, y: 0 };
let hideTimer: ReturnType<typeof setTimeout> | null = null;
$: tooltipDate = pinnedDate ?? hoveredDate;
$: tooltipActivities = tooltipDate ? (activitiesByDate.get(tooltipDate) ?? []) : [];
function updatePos(e: MouseEvent) {
const vw = window.innerWidth;
const vh = window.innerHeight;
tooltipPos = {
x: e.clientX > vw - 310 ? e.clientX - 305 : e.clientX + 14,
y: Math.min(e.clientY - 8, vh - 260),
};
}
function onCellEnter(date: string, e: MouseEvent) {
if (!date || !activitiesByDate.has(date)) return;
if (pinnedDate) return;
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
hoveredDate = date;
updatePos(e);
}
function onCellLeave() {
if (pinnedDate) return;
hideTimer = setTimeout(() => { hoveredDate = null; }, 120);
}
function onCellClick(date: string, e: MouseEvent) {
if (!date || !activitiesByDate.has(date)) return;
e.stopPropagation();
if (pinnedDate === date) {
pinnedDate = null;
} else {
pinnedDate = date;
updatePos(e);
}
}
function onTooltipEnter() {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
}
function onTooltipLeave() {
if (pinnedDate) return;
hoveredDate = null;
}
function onWindowClick(e: MouseEvent) {
if (!pinnedDate) return;
if (tooltipEl && tooltipEl.contains(e.target as Node)) return;
pinnedDate = null;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') pinnedDate = null;
}
// ── Heatmap data ─────────────────────────────────────────────────────────
// byDateBySport: date → sport → total distance (m)
$: byDateBySport = (() => {
const m = new Map<string, Map<string, number>>();
for (const a of activities) {
const d = a.started_at.slice(0, 10);
if (!m.has(d)) m.set(d, new Map());
const sm = m.get(d)!;
sm.set(a.sport, (sm.get(a.sport) ?? 0) + (a.distance_m ?? 0));
}
return m;
})();
$: byDate = new Map(
[...byDateBySport.entries()].map(([d, sm]) => [
d,
[...sm.values()].reduce((s, v) => s + v, 0),
])
);
// Sorted daily distances for percentile-based intensity scaling
$: sortedDaily = [...byDate.values()].sort((a, b) => a - b);
$: maxDailyKm = (sortedDaily[sortedDaily.length - 1] ?? 0) / 1000 || 1;
function pctRank(value: number, sorted: number[]): number {
if (!sorted.length) return 0;
let lo = 0, hi = sorted.length;
while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid] <= value) lo = mid + 1; else hi = mid; }
return lo / sorted.length;
}
// ── Totals ────────────────────────────────────────────────────────────────
$: totalsByYear = (() => {
const m = new Map<number, { dist: number; count: number }>();
for (const a of activities) {
const y = new Date(a.started_at).getFullYear();
const cur = m.get(y) ?? { dist: 0, count: 0 };
cur.dist += a.distance_m ?? 0;
cur.count += 1;
m.set(y, cur);
}
return m;
})();
$: allYears = [...totalsByYear.keys()].sort((a, b) => b - a);
// ── Color helpers ─────────────────────────────────────────────────────────
function hexToRgb(hex: string): [number, number, number] {
return [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
];
}
// Lerp from zinc-800 bg (#27272a = 39,39,42) toward target color
function applyIntensity(hex: string, intensity: number): string {
const [tr, tg, tb] = hexToRgb(hex);
return `rgb(${Math.round(39 + (tr - 39) * intensity)},${Math.round(39 + (tg - 39) * intensity)},${Math.round(42 + (tb - 42) * intensity)})`;
}
// Precompute date→color as a reactive Map so Svelte tracks it directly in
// the template. (Calling a plain function with a static string arg won't
// re-trigger when byDate/maxDailyKm change — the Map reference does.)
$: cellColors = (() => {
const m = new Map<string, string>();
for (const [date, sportMap] of byDateBySport) {
const total = byDate.get(date) ?? 0;
if (total === 0) { m.set(date, '#27272a'); continue; }
const intensity = 0.12 + pctRank(total, sortedDaily) * 0.88;
let tr = 0, tg = 0, tb = 0;
for (const [sp, dist] of sportMap) {
const w = dist / total;
const [cr, cg, cb] = hexToRgb(sportColor(sp as Sport));
tr += cr * w; tg += cg * w; tb += cb * w;
}
const blended = `#${Math.round(tr).toString(16).padStart(2,'0')}${Math.round(tg).toString(16).padStart(2,'0')}${Math.round(tb).toString(16).padStart(2,'0')}`;
m.set(date, applyIntensity(blended, intensity));
}
return m;
})();
// Legend: 6 swatches from dark to full sport color (or neutral for 'all')
$: legendColor = sport !== 'all' ? sportColor(sport) : '#00c8ff';
$: legendSwatches = [0, 0.18, 0.38, 0.58, 0.78, 1.0].map(t =>
t === 0 ? '#27272a' : applyIntensity(legendColor, t)
);
// Sport chips present in filtered data (for 'all' color key)
$: sportsInData = sport === 'all'
? ([...new Set(activities.map(a => a.sport))] as Sport[]).sort()
: ([] as Sport[]);
// ── Calendar helpers ──────────────────────────────────────────────────────
const now = new Date();
const years = [now.getFullYear(), now.getFullYear()-1, now.getFullYear()-2, now.getFullYear()-3];
function localISO(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function getWeeks(year: number): string[][] {
const jan1 = new Date(year, 0, 1);
const dec31 = new Date(year, 11, 31);
const start = new Date(jan1);
start.setDate(jan1.getDate() - ((jan1.getDay() + 6) % 7));
const end = new Date(dec31);
end.setDate(dec31.getDate() + (6 - (dec31.getDay() + 6) % 7));
const weeks: string[][] = [];
let cur = new Date(start);
while (cur <= end) {
const week: string[] = [];
for (let d = 0; d < 7; d++) {
week.push(cur.getFullYear() === year ? localISO(cur) : '');
cur.setDate(cur.getDate() + 1);
}
weeks.push(week);
}
return weeks;
}
const DOW = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const sports: Array<{ value: Sport | 'all'; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'cycling', label: '🚴 Cycling' },
{ value: 'running', label: '🏃 Running' },
{ value: 'hiking', label: '🥾 Hiking' },
{ value: 'walking', label: '🚶 Walking' },
{ value: 'swimming', label: '🏊 Swimming' },
{ value: 'skiing', label: '⛷️ Skiing' },
{ value: 'other', label: '⚡ Other' },
];
</script>
<!-- Filter bar -->
<div class="flex gap-2 mb-6 flex-wrap">
{#each sports as s}
<button
class="px-3 py-1 rounded-full text-sm font-medium border transition-colors"
class:border-zinc-700={sport !== s.value}
class:text-zinc-400={sport !== s.value}
class:border-[--accent]={sport === s.value}
class:text-white={sport === s.value}
style={sport === s.value ? 'background:var(--accent-dim)' : ''}
on:click={() => sport = s.value}
>
{s.label}
</button>
{/each}
{#if all.length > 0}
<span class="ml-auto text-sm text-zinc-500 self-center">
{activities.length} {activities.length === 1 ? 'activity' : 'activities'}
</span>
{/if}
</div>
{#if loading}
<div class="h-64 rounded-xl bg-zinc-800 animate-pulse mb-6" />
{:else}
<!-- Year totals -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
{#each allYears.slice(0, 4) as year}
{@const t = totalsByYear.get(year)}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<p class="text-xs text-zinc-500 mb-1">{year}</p>
<p class="text-2xl font-bold text-white">{formatDistance(t?.dist ?? 0)}</p>
<p class="text-sm text-zinc-400">{t?.count ?? 0} activities</p>
</div>
{/each}
</div>
<!-- Heatmaps per year -->
{#each years as year}
{@const weeks = getWeeks(year)}
{@const yt = totalsByYear.get(year)}
{#if yt}
<div class="mb-8" role="presentation">
<div class="flex items-baseline gap-3 mb-2">
<h2 class="text-lg font-semibold text-white">{year}</h2>
<span class="text-sm text-zinc-400">
{formatDistance(yt.dist)} · {yt.count} activities
</span>
</div>
<div class="overflow-x-auto">
<div class="inline-flex gap-[3px]">
<!-- Day-of-week labels: blank slot at top to align with month row -->
<div class="flex flex-col gap-[3px] mr-1">
<div class="h-4" />
{#each DOW as d, i}
<span class="text-[9px] text-zinc-600 h-[10px] leading-[10px] w-3 text-right">
{i % 2 === 1 ? d : ''}
</span>
{/each}
</div>
<!-- Week columns: month label at top, day cells below -->
{#each weeks as week, wi}
{@const firstDay = week.find(d => d)}
{@const prevFirstDay = wi > 0 ? weeks[wi - 1].find(d => d) : null}
{@const showMonth = firstDay && (!prevFirstDay || prevFirstDay.slice(5, 7) !== firstDay.slice(5, 7))}
<div class="flex flex-col gap-[3px]">
<div class="h-4 relative">
{#if showMonth}
<span class="text-[10px] text-zinc-500 absolute left-0 top-0 whitespace-nowrap">
{MONTHS[parseInt(firstDay.slice(5, 7)) - 1]}
</span>
{/if}
</div>
{#each week as date}
<div
class="w-[10px] h-[10px] rounded-[2px] {date && activitiesByDate.has(date) ? 'cursor-pointer' : ''} {date && date === pinnedDate ? 'ring-1 ring-white ring-offset-[1px] ring-offset-zinc-950' : ''}"
style="background:{cellColors.get(date) ?? '#27272a'}"
on:mouseenter={e => onCellEnter(date, e)}
on:mouseleave={onCellLeave}
on:click={e => onCellClick(date, e)}
/>
{/each}
</div>
{/each}
</div>
</div>
<!-- Legend -->
<div class="flex items-center gap-3 mt-2 flex-wrap">
<div class="flex items-center gap-1">
<span class="text-xs text-zinc-500 mr-1">Less</span>
{#each legendSwatches as c}
<div class="w-[10px] h-[10px] rounded-[2px]" style="background:{c}" />
{/each}
<span class="text-xs text-zinc-500 ml-1">More (percentile · max {Math.round(maxDailyKm)} km)</span>
</div>
{#if sportsInData.length > 1}
<div class="flex items-center gap-2 ml-2">
{#each sportsInData as sp}
<span class="text-xs flex items-center gap-1" style="color:{sportColor(sp)}">
<span class="w-[10px] h-[10px] rounded-[2px] inline-block" style="background:{sportColor(sp)}" />
{sportIcon(sp)}
</span>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{/each}
{/if}
<svelte:window on:click={onWindowClick} on:keydown={onKeydown} />
<!-- Day tooltip -->
{#if tooltipDate && tooltipActivities.length > 0}
<div
bind:this={tooltipEl}
class="fixed z-50 bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-3 w-[280px]"
style="left:{tooltipPos.x}px; top:{tooltipPos.y}px"
on:mouseenter={onTooltipEnter}
on:mouseleave={onTooltipLeave}
>
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-zinc-400">
{new Date(tooltipDate + 'T12:00:00').toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
</p>
{#if pinnedDate}
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors text-sm leading-none ml-2"
on:click|stopPropagation={() => pinnedDate = null}
aria-label="Close"
></button>
{/if}
</div>
<div class="flex flex-col gap-1">
{#each tooltipActivities as a}
<a
href="{import.meta.env.BASE_URL}activity/{a.id}/"
class="flex flex-col gap-0.5 rounded-lg px-2 py-1.5 hover:bg-zinc-800 transition-colors"
>
<span class="text-sm font-medium text-white truncate">
{sportIcon(a.sport)} {a.title}
</span>
<span class="text-xs text-zinc-400">
{formatDistance(a.distance_m)}
{#if a.moving_time_s ?? a.duration_s}
· {formatDuration(a.moving_time_s ?? a.duration_s)}
{/if}
<span class="ml-1" style="color:{sportColor(a.sport)}">{sportLabel(a.sport, a.sub_sport)}</span>
</span>
</a>
{/each}
</div>
</div>
{/if}