Add VAM (climbing velocity) metric and per-duration curve

Extract pipeline now computes two VAM metrics per activity (cycling,
running, hiking, walking):
- climbing_vam_mh: VAM on ascending segments only, using 30 s forward
  lookahead to classify climbing vs. flat/descent (stored in detail JSON)
- vam_curve: [[duration_s, vam_mh], ...] best VAM per standard duration
  (60 s – 1 h), sliding window on 30 s smoothed elevation, only windows
  with ≥ 10 m net gain count (stored in summary + detail)

Athlete JSON aggregates vam_curve across all activities (all_time,
last_365d, last_90d), same structure as power_curve.

Frontend:
- ActivityDetail shows "Climbing VAM" stat (grouped with elevation)
- AthleteView adds a "VAM Curve" tab that appears only when the athlete
  has climbing data; renders VamChart (new component, mirrors MmpChart)

vam_curve stripped from combined global feed; kept in user year shards
for season-based on-the-fly aggregation in VamChart.

Requires bincio reextract to backfill existing activities.
This commit is contained in:
Davide Scaini
2026-05-16 21:34:06 +02:00
parent de602ff5d9
commit baf20b51ba
8 changed files with 369 additions and 6 deletions
@@ -183,6 +183,9 @@
stat('Distance', formatDistance(activity.distance_m)),
stat('Moving time', formatDuration(activity.moving_time_s ?? activity.duration_s)),
stat('Elevation ↑', formatElevation(activity.elevation_gain_m), 'elevation'),
...(detail?.climbing_vam_mh != null ? [
stat('Climbing VAM', `${detail.climbing_vam_mh.toLocaleString()} m/h`, 'elevation'),
] : []),
stat('Avg speed', formatSpeed(activity.avg_speed_kmh), 'speed'),
stat('Max speed', formatSpeed(activity.max_speed_kmh), 'speed'),
stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—', 'heart_rate'),
+21 -4
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types';
import MmpChart from './MmpChart.svelte';
import VamChart from './VamChart.svelte';
import RecordsView from './RecordsView.svelte';
import AthleteDrawer from './AthleteDrawer.svelte';
import Explore from './Explore.svelte';
@@ -19,12 +20,13 @@
let athlete: AthleteJson | null = null;
let activities: ActivitySummary[] = [];
let vamActivities: ActivitySummary[] = [];
let allActivities: ActivitySummary[] = [];
let loading = true;
let error: string | null = null;
let drawerOpen = false;
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd';
type Tab = 'power' | 'vam' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd';
let activeTab: Tab = 'power';
let mounted = false;
let isOwner = false;
@@ -94,7 +96,7 @@
isOwner = (e as CustomEvent<string>).detail === handle;
}, { once: true });
}
const TABS: Tab[] = ['power', 'records', 'segments', 'profile', 'explore', 'nerd'];
const TABS: Tab[] = ['power', 'vam', 'records', 'segments', 'profile', 'explore', 'nerd'];
const rawTab = new URLSearchParams(window.location.search).get('tab');
const resolved = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power';
activeTab = (resolved === 'explore' && !isOwner) ? 'power' : resolved;
@@ -131,6 +133,7 @@
athlete = resolvedAthlete;
allActivities = index.activities.filter(a => !isUnlisted(a.privacy));
activities = allActivities.filter(a => a.mmp);
vamActivities = allActivities.filter(a => a.vam_curve);
} catch (e: any) {
error = e.message;
} finally {
@@ -156,15 +159,19 @@
return hi >= 900 ? `${lo}+ bpm` : `${lo}${hi} bpm`;
}
const ALL_TABS: { key: Tab; label: string; ownerOnly?: boolean }[] = [
const ALL_TABS: { key: Tab; label: string; ownerOnly?: boolean; requiresVam?: boolean }[] = [
{ key: 'power', label: 'Power Curve' },
{ key: 'vam', label: 'VAM Curve', requiresVam: true },
{ key: 'records', label: 'Records' },
{ key: 'segments', label: 'Segments' },
{ key: 'profile', label: 'Profile' },
{ key: 'explore', label: 'Explore', ownerOnly: true },
{ key: 'nerd', label: 'Nerd Corner', ownerOnly: true },
];
$: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner);
$: TABS = ALL_TABS.filter(t =>
(!t.ownerOnly || isOwner) &&
(!t.requiresVam || athlete?.vam_curve?.all_time != null)
);
</script>
{#if loading}
@@ -216,6 +223,16 @@
<p class="text-zinc-500 text-sm">No power data found. Make sure your activities include power meter data.</p>
{/if}
<!-- VAM Curve tab -->
{:else if activeTab === 'vam'}
{#if athlete.vam_curve?.all_time}
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800">
<VamChart {athlete} activities={vamActivities} />
</div>
{:else}
<p class="text-zinc-500 text-sm">No climbing data found.</p>
{/if}
<!-- Records tab -->
{:else if activeTab === 'records'}
<RecordsView {athlete} {base} />
+188
View File
@@ -0,0 +1,188 @@
<script lang="ts">
import { onMount } from 'svelte';
import * as Plot from '@observablehq/plot';
import type { AthleteJson, MmpCurve, ActivitySummary } from '../lib/types';
export let athlete: AthleteJson;
export let activities: ActivitySummary[] = [];
type RangeKey = 'all_time' | 'last_365d' | 'last_90d' | string;
interface Season { name: string; start: string; end: string }
const seasons: Season[] = (athlete as any).seasons ?? [];
let selectedRanges: Set<RangeKey> = new Set(['all_time']);
const PRESET_LABELS: Record<string, string> = {
all_time: 'All time',
last_365d: 'Last 365 d',
last_90d: 'Last 90 d',
};
const PALETTE = [
'#34d399', // emerald-400
'#f97316', // orange-500
'#60a5fa', // blue-400
'#a78bfa', // violet-400
'#f43f5e', // rose-500
'#facc15', // yellow-400
'#22d3ee', // cyan-400
];
function curveColor(key: RangeKey, index: number): string {
return PALETTE[index % PALETTE.length];
}
function mergeVams(curves: MmpCurve[]): MmpCurve {
const best = new Map<number, number>();
for (const curve of curves) {
for (const [d, v] of curve) {
const prev = best.get(d);
if (prev === undefined || v > prev) best.set(d, v);
}
}
return [...best.entries()].sort((a, b) => a[0] - b[0]) as MmpCurve;
}
function vamForRange(key: RangeKey): MmpCurve | null {
if (key in PRESET_LABELS) {
return athlete.vam_curve?.[key as keyof typeof athlete.vam_curve] ?? null;
}
const season = seasons.find(s => s.name === key);
if (!season) return null;
const curves = activities
.filter(a => a.vam_curve && a.started_at >= season.start && a.started_at <= season.end + 'T23:59:59')
.map(a => a.vam_curve!);
return curves.length ? mergeVams(curves) : null;
}
let chartEl: HTMLElement;
function formatDuration(s: number): string {
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.round(s / 60)}min`;
return `${s / 3600}h`;
}
$: selectedKeys = [...selectedRanges];
$: plotData = selectedKeys.flatMap((key, i) => {
const curve = vamForRange(key);
if (!curve) return [];
return curve.map(([d, v]) => ({ d, v, label: key }));
});
$: colorMap = Object.fromEntries(selectedKeys.map((k, i) => [k, curveColor(k, i)]));
function getAxisColor() {
return document.documentElement.getAttribute('data-theme') === 'light' ? '#52525b' : '#a1a1aa';
}
function renderChart(data: typeof plotData, cmap: typeof colorMap) {
if (!chartEl) return;
chartEl.innerHTML = '';
if (!data.length) return;
const labelFn = (key: string) => PRESET_LABELS[key] ?? key;
const chart = Plot.plot({
width: chartEl.clientWidth || 700,
height: 320,
marginLeft: 60,
marginBottom: 40,
style: { background: 'transparent', color: getAxisColor() },
x: {
type: 'log',
label: 'Duration',
tickFormat: (d: number) => formatDuration(d),
grid: true,
domain: [data[0]?.d ?? 60, Math.max(3600, ...data.map(d => d.d))],
},
y: {
label: 'VAM (m/h)',
grid: true,
zero: true,
},
color: {
domain: selectedKeys,
range: selectedKeys.map((k, i) => curveColor(k, i)),
legend: selectedKeys.length > 1,
},
marks: [
Plot.line(data, {
x: 'd',
y: 'v',
stroke: 'label',
strokeWidth: 2,
curve: 'monotone-x',
}),
Plot.dot(data, {
x: 'd',
y: 'v',
fill: 'label',
r: 3,
tip: true,
title: (d: any) => `${labelFn(d.label)}\n${formatDuration(d.d)}: ${d.v.toLocaleString()} m/h`,
}),
],
});
chartEl.appendChild(chart);
}
$: renderChart(plotData, colorMap);
let currentPlotData = plotData;
let currentColorMap = colorMap;
$: currentPlotData = plotData;
$: currentColorMap = colorMap;
onMount(() => {
const ro = new ResizeObserver(() => renderChart(currentPlotData, currentColorMap));
ro.observe(chartEl);
const mo = new MutationObserver(() => renderChart(currentPlotData, currentColorMap));
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => { ro.disconnect(); mo.disconnect(); };
});
function toggleRange(key: RangeKey) {
const next = new Set(selectedRanges);
if (next.has(key)) {
if (next.size > 1) next.delete(key);
} else {
next.add(key);
}
selectedRanges = next;
}
const allRangeKeys = [
...Object.keys(PRESET_LABELS),
...seasons.map(s => s.name),
];
</script>
<style>
:global(.plot-tip text) { fill: #18181b !important; }
</style>
<div class="flex flex-wrap gap-2 mb-4">
{#each allRangeKeys as key, i}
{@const active = selectedRanges.has(key)}
{@const color = curveColor(key, i)}
<button
on:click={() => toggleRange(key)}
class="px-3 py-1 rounded-full text-sm font-medium border transition-colors"
style={active
? `background:${color}22; border-color:${color}; color:${color}`
: 'background:transparent; border-color:#3f3f46; color:#71717a'}
>
{PRESET_LABELS[key] ?? key}
</button>
{/each}
</div>
<div bind:this={chartEl} class="w-full min-h-[320px]"></div>
{#if !plotData.length}
<p class="text-zinc-500 text-sm mt-4">No VAM data for the selected range.</p>
{/if}