766da0320b
- Exclude per-activity VAM contributions where climbing_time_s < 10 min; short punchy efforts don't represent aerobic fitness and were skewing monthly averages - Store climbing_time_s alongside climbing_vam_mh in metrics, detail JSON, and summary JSON so the frontend has the data to reason about confidence - Accumulate total climbing time per period; opacity scales from 0.25 (10 min, minimum threshold) to 1.0 (≥ 1 h) so thin-evidence months read as faint dots - Render VAM as dots only (no lines) since each period is an independent average, not a cumulative — lines implied continuity that isn't there - Tooltip now shows "1060 m/h · 38 min climbing"
322 lines
12 KiB
Svelte
322 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import * as Plot from '@observablehq/plot';
|
|
import type { ActivitySummary } from '../lib/types';
|
|
|
|
export let activities: ActivitySummary[] = [];
|
|
|
|
type Metric = 'distance' | 'elevation' | 'time' | 'vam';
|
|
type Granularity = 'week' | 'month';
|
|
|
|
let metric: Metric = 'distance';
|
|
let granularity: Granularity = 'week';
|
|
|
|
const METRIC_LABEL: Record<Metric, string> = {
|
|
distance: 'Distance (km)',
|
|
elevation: 'Elevation gain (m)',
|
|
time: 'Moving time (h)',
|
|
vam: 'Avg climbing VAM (m/h)',
|
|
};
|
|
const METRIC_FMT: Record<Metric, (v: number) => string> = {
|
|
distance: v => `${Math.round(v)} km`,
|
|
elevation: v => `${Math.round(v)} m`,
|
|
time: v => `${v.toFixed(1)} h`,
|
|
vam: v => `${Math.round(v)} m/h`,
|
|
};
|
|
|
|
// Cool→warm ramp for past years; current year is always blue-400
|
|
// t=0 → oldest past year (muted purple), t=1 → most recent past year (warm coral)
|
|
// Stops: [t, hue, sat%, light%]
|
|
const _RAMP: [number, number, number, number][] = [
|
|
[0.00, 265, 38, 52], // muted purple
|
|
[0.18, 230, 45, 55], // slate-blue
|
|
[0.36, 185, 52, 50], // teal
|
|
[0.54, 145, 48, 47], // green
|
|
[0.70, 50, 72, 52], // amber-yellow
|
|
[0.84, 25, 80, 55], // orange
|
|
[1.00, 5, 70, 57], // warm coral-red
|
|
];
|
|
|
|
function _rampColor(t: number): string {
|
|
let i = 0;
|
|
while (i < _RAMP.length - 2 && t > _RAMP[i + 1][0]) i++;
|
|
const [t0, h0, s0, l0] = _RAMP[i];
|
|
const [t1, h1, s1, l1] = _RAMP[i + 1];
|
|
const f = (t - t0) / (t1 - t0);
|
|
return `hsl(${Math.round(h0 + (h1 - h0) * f)},${Math.round(s0 + (s1 - s0) * f)}%,${Math.round(l0 + (l1 - l0) * f)}%)`;
|
|
}
|
|
|
|
function dayOfYear(d: Date): number {
|
|
return Math.floor((d.getTime() - new Date(d.getFullYear(), 0, 0).getTime()) / 86400000);
|
|
}
|
|
function weekOfYear(d: Date): number {
|
|
return Math.min(52, Math.ceil(dayOfYear(d) / 7));
|
|
}
|
|
|
|
const _now = new Date();
|
|
const _currentYear = _now.getFullYear();
|
|
|
|
// Minimum climbing time per activity to count in the VAM chart (10 min).
|
|
const VAM_MIN_CLIMB_S = 600;
|
|
// Climbing time range for full confidence opacity (10 min → 1 h).
|
|
const VAM_OPACITY_MIN_S = 600;
|
|
const VAM_OPACITY_MAX_S = 3600;
|
|
|
|
function vamOpacity(climbTime: number | undefined): number {
|
|
if (!climbTime) return 0.25;
|
|
const t = Math.min(1, Math.max(0, (climbTime - VAM_OPACITY_MIN_S) / (VAM_OPACITY_MAX_S - VAM_OPACITY_MIN_S)));
|
|
return 0.25 + t * 0.75;
|
|
}
|
|
|
|
function buildData(acts: ActivitySummary[], m: Metric, g: Granularity) {
|
|
const curPeriod = g === 'week' ? weekOfYear(_now) : _now.getMonth() + 1;
|
|
const byYear = new Map<number, Map<number, number>>();
|
|
const byYearCnt = new Map<number, Map<number, number>>();
|
|
const byYearClimbTime = new Map<number, Map<number, number>>();
|
|
|
|
for (const act of acts) {
|
|
if (!act.started_at) continue;
|
|
if (m === 'vam') {
|
|
if (act.climbing_vam_mh == null) continue;
|
|
if ((act.climbing_time_s ?? 0) < VAM_MIN_CLIMB_S) continue;
|
|
}
|
|
const d = new Date(act.started_at);
|
|
const yr = d.getFullYear();
|
|
const per = g === 'week' ? weekOfYear(d) : d.getMonth() + 1;
|
|
const val = m === 'distance' ? (act.distance_m ?? 0) / 1000
|
|
: m === 'elevation' ? (act.elevation_gain_m ?? 0)
|
|
: m === 'vam' ? (act.climbing_vam_mh ?? 0)
|
|
: (act.moving_time_s ?? 0) / 3600;
|
|
if (!byYear.has(yr)) byYear.set(yr, new Map());
|
|
if (!byYearCnt.has(yr)) byYearCnt.set(yr, new Map());
|
|
const ym = byYear.get(yr)!;
|
|
const ymc = byYearCnt.get(yr)!;
|
|
ym.set(per, (ym.get(per) ?? 0) + val);
|
|
ymc.set(per, (ymc.get(per) ?? 0) + 1);
|
|
if (m === 'vam') {
|
|
if (!byYearClimbTime.has(yr)) byYearClimbTime.set(yr, new Map());
|
|
const yct = byYearClimbTime.get(yr)!;
|
|
yct.set(per, (yct.get(per) ?? 0) + (act.climbing_time_s ?? 0));
|
|
}
|
|
}
|
|
|
|
// VAM: convert sums to averages
|
|
if (m === 'vam') {
|
|
for (const [yr, ym] of byYear) {
|
|
const ymc = byYearCnt.get(yr)!;
|
|
for (const [per, sum] of ym) {
|
|
ym.set(per, sum / (ymc.get(per) ?? 1));
|
|
}
|
|
}
|
|
}
|
|
|
|
const years = [...byYear.keys()].sort();
|
|
const maxPer = g === 'week' ? 52 : 12;
|
|
const rows: { year: string; period: number; value: number; climbTime?: number }[] = [];
|
|
|
|
for (const yr of years) {
|
|
const pm = byYear.get(yr)!;
|
|
const ct = byYearClimbTime.get(yr);
|
|
const limit = yr === _currentYear ? curPeriod : maxPer;
|
|
for (let p = 1; p <= limit; p++) {
|
|
const row: { year: string; period: number; value: number; climbTime?: number } =
|
|
{ year: String(yr), period: p, value: pm.get(p) ?? 0 };
|
|
if (ct?.has(p)) row.climbTime = ct.get(p);
|
|
rows.push(row);
|
|
}
|
|
}
|
|
|
|
const colorDomain = years.map(String);
|
|
const pastYears = years.filter(y => y !== _currentYear);
|
|
const colorRange = years.map(y => {
|
|
if (y === _currentYear) return '#60a5fa';
|
|
if (y < 2000) return '#71717a'; // undated bucket (year 0 / "0000")
|
|
const i = pastYears.indexOf(y);
|
|
const t = pastYears.length <= 1 ? 0.5 : i / (pastYears.length - 1);
|
|
return _rampColor(t);
|
|
});
|
|
|
|
return { rows, colorDomain, colorRange };
|
|
}
|
|
|
|
$: ({ rows, colorDomain, colorRange } = buildData(activities, metric, granularity));
|
|
|
|
let chartEl: HTMLElement;
|
|
let chartCumEl: HTMLElement;
|
|
|
|
function renderChartInto(
|
|
el: HTMLElement,
|
|
rows: { year: string; period: number; value: number }[],
|
|
colorDomain: string[],
|
|
colorRange: string[],
|
|
m: Metric,
|
|
g: Granularity,
|
|
cumulative: boolean,
|
|
) {
|
|
if (!el) return;
|
|
el.innerHTML = '';
|
|
if (!rows.length) return;
|
|
|
|
// For the cumulative chart, convert per-period rows to running sums.
|
|
// rows are ordered: for each year (sorted asc), periods 1..limit in order.
|
|
let displayRows = rows;
|
|
if (cumulative) {
|
|
const acc = new Map<string, number>();
|
|
displayRows = rows.map(r => {
|
|
const prev = acc.get(r.year) ?? 0;
|
|
const cum = prev + r.value;
|
|
acc.set(r.year, cum);
|
|
return { ...r, value: cum };
|
|
});
|
|
}
|
|
|
|
const maxPer = g === 'week' ? 52 : 12;
|
|
const xLabel = g === 'week' ? 'Week' : 'Month';
|
|
const axColor = document.documentElement.getAttribute('data-theme') === 'light' ? '#52525b' : '#a1a1aa';
|
|
const fmt = METRIC_FMT[m];
|
|
const curYear = String(_currentYear);
|
|
const pastRows = displayRows.filter(r => r.year !== curYear);
|
|
const curRows = displayRows.filter(r => r.year === curYear);
|
|
const yLabel = cumulative ? `Cumulative ${METRIC_LABEL[m]}` : METRIC_LABEL[m];
|
|
|
|
const MONTH_LABELS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
|
|
const chart = Plot.plot({
|
|
width: el.clientWidth || 700,
|
|
height: 320,
|
|
marginLeft: 60,
|
|
marginBottom: 40,
|
|
style: { background: 'transparent', color: axColor },
|
|
x: {
|
|
label: xLabel,
|
|
domain: [1, maxPer],
|
|
grid: true,
|
|
tickFormat: g === 'month'
|
|
? (d: number) => MONTH_LABELS[d - 1]
|
|
: (d: number) => String(d),
|
|
},
|
|
y: { label: yLabel, grid: true, zero: true },
|
|
color: { domain: colorDomain, range: colorRange, legend: !cumulative },
|
|
marks: [
|
|
...(m === 'vam' ? (() => {
|
|
// VAM: dots only, no lines — opacity encodes total climbing time in period.
|
|
const vamRows = [...pastRows, ...curRows].filter((r: any) => r.value > 0);
|
|
return vamRows.length ? [
|
|
Plot.dot(vamRows, {
|
|
x: 'period', y: 'value', fill: 'year',
|
|
r: 5,
|
|
fillOpacity: (d: any) => vamOpacity(d.climbTime),
|
|
tip: true,
|
|
title: (d: any) => {
|
|
const mins = d.climbTime ? `${Math.round(d.climbTime / 60)} min climbing` : '';
|
|
return `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}${mins ? '\n' + mins : ''}`;
|
|
},
|
|
}),
|
|
] : [];
|
|
})() : [
|
|
...(pastRows.length ? [
|
|
Plot.line(pastRows, {
|
|
x: 'period', y: 'value', stroke: 'year',
|
|
strokeWidth: 1.5, curve: 'monotone-x',
|
|
}),
|
|
Plot.dot(pastRows, {
|
|
x: 'period', y: 'value', fill: 'year', r: 2, fillOpacity: 0,
|
|
tip: true,
|
|
title: (d: any) => `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}`,
|
|
}),
|
|
] : []),
|
|
...(curRows.length ? [
|
|
Plot.line(curRows, {
|
|
x: 'period', y: 'value', stroke: 'year',
|
|
strokeWidth: 2.5, curve: 'monotone-x',
|
|
}),
|
|
Plot.dot(curRows, {
|
|
x: 'period', y: 'value', fill: 'year', r: 2, fillOpacity: 0,
|
|
tip: true,
|
|
title: (d: any) => `${d.year} · ${xLabel} ${d.period}\n${fmt(d.value)}`,
|
|
}),
|
|
] : []),
|
|
]),
|
|
],
|
|
});
|
|
el.appendChild(chart);
|
|
}
|
|
|
|
function renderBoth(
|
|
rows: { year: string; period: number; value: number }[],
|
|
colorDomain: string[],
|
|
colorRange: string[],
|
|
m: Metric,
|
|
g: Granularity,
|
|
) {
|
|
renderChartInto(chartEl, rows, colorDomain, colorRange, m, g, false);
|
|
renderChartInto(chartCumEl, rows, colorDomain, colorRange, m, g, true);
|
|
}
|
|
|
|
$: renderBoth(rows, colorDomain, colorRange, metric, granularity);
|
|
|
|
// Keep current values for resize / theme callbacks
|
|
let _r = rows, _cd = colorDomain, _cr = colorRange, _m = metric, _g = granularity;
|
|
$: _r = rows; $: _cd = colorDomain; $: _cr = colorRange; $: _m = metric; $: _g = granularity;
|
|
|
|
onMount(() => {
|
|
const redraw = () => renderBoth(_r, _cd, _cr, _m, _g);
|
|
const ro = new ResizeObserver(redraw);
|
|
ro.observe(chartEl);
|
|
const mo = new MutationObserver(redraw);
|
|
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
return () => { ro.disconnect(); mo.disconnect(); };
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
:global(.plot-tip text) { fill: #18181b !important; }
|
|
.controls { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1.25rem; align-items: center; }
|
|
.pill-group { display: flex; gap: 0.375rem; }
|
|
.pill {
|
|
padding: 0.2rem 0.65rem;
|
|
border-radius: 9999px;
|
|
font-size: 0.78rem;
|
|
font-weight: 500;
|
|
border: 1px solid #3f3f46;
|
|
color: #71717a;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
transition: all 0.12s;
|
|
}
|
|
.pill.active { background: #1d4ed822; border-color: #60a5fa; color: #60a5fa; }
|
|
.pill:hover:not(.active) { border-color: #a1a1aa; color: #d4d4d8; }
|
|
.section-label {
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: #52525b;
|
|
margin: 2rem 0 0.5rem;
|
|
}
|
|
</style>
|
|
|
|
<div class="controls">
|
|
<div class="pill-group">
|
|
<button class="pill" class:active={metric === 'distance'} on:click={() => metric = 'distance'}>Distance</button>
|
|
<button class="pill" class:active={metric === 'elevation'} on:click={() => metric = 'elevation'}>Elevation</button>
|
|
<button class="pill" class:active={metric === 'time'} on:click={() => metric = 'time'}>Time</button>
|
|
<button class="pill" class:active={metric === 'vam'} on:click={() => metric = 'vam'}>Climbing VAM</button>
|
|
</div>
|
|
<div class="pill-group">
|
|
<button class="pill" class:active={granularity === 'week'} on:click={() => granularity = 'week'}>Weekly</button>
|
|
<button class="pill" class:active={granularity === 'month'} on:click={() => granularity = 'month'}>Monthly</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div bind:this={chartEl} class="w-full min-h-[320px]"></div>
|
|
|
|
{#if metric !== 'vam'}
|
|
<p class="section-label">Cumulative</p>
|
|
<div bind:this={chartCumEl} class="w-full min-h-[320px]"></div>
|
|
{/if}
|
|
|
|
{#if !rows.length}
|
|
<p class="text-zinc-500 text-sm mt-4">No activity data to display.</p>
|
|
{/if}
|