NerdCorner VAM: filter short climbs, opacity-encode confidence, add climbing time to tooltip

- 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"
This commit is contained in:
Davide Scaini
2026-05-17 10:13:39 +02:00
parent 7a44cbbef0
commit 766da0320b
4 changed files with 84 additions and 34 deletions
+69 -27
View File
@@ -56,14 +56,30 @@
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>>(); // for VAM averaging
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' && act.climbing_vam_mh == null) 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;
@@ -77,6 +93,11 @@
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
@@ -91,13 +112,17 @@
const years = [...byYear.keys()].sort();
const maxPer = g === 'week' ? 52 : 12;
const rows: { year: string; period: number; value: number }[] = [];
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++) {
rows.push({ year: String(yr), period: p, value: pm.get(p) ?? 0 });
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);
}
}
@@ -173,28 +198,45 @@
y: { label: yLabel, grid: true, zero: true },
color: { domain: colorDomain, range: colorRange, legend: !cumulative },
marks: [
...(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)}`,
}),
] : []),
...(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);
+1
View File
@@ -67,6 +67,7 @@ export interface ActivitySummary {
avg_power_w: number | null;
mmp: MmpCurve | null;
climbing_vam_mh?: number | null;
climbing_time_s?: number | null;
source: string | null;
privacy: Privacy;
detail_url: string | null;