VAM: drop duration curve, show avg climbing VAM in Nerd Corner
Remove the per-duration VAM curve everywhere (metrics, summaries, detail JSON, athlete.json, VamChart.svelte, AthleteView VAM tab). Keep only climbing_vam_mh per activity. Add it to activity summaries so NerdCorner can plot average climbing VAM per week/month year-over-year alongside distance/elevation/time. Add --backfill-vam-summary flag to copy the field from existing detail JSONs into index.json without re-extracting.
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
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';
|
||||
@@ -20,13 +19,12 @@
|
||||
|
||||
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' | 'vam' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd';
|
||||
type Tab = 'power' | 'records' | 'segments' | 'profile' | 'explore' | 'nerd';
|
||||
let activeTab: Tab = 'power';
|
||||
let mounted = false;
|
||||
let isOwner = false;
|
||||
@@ -96,7 +94,7 @@
|
||||
isOwner = (e as CustomEvent<string>).detail === handle;
|
||||
}, { once: true });
|
||||
}
|
||||
const TABS: Tab[] = ['power', 'vam', 'records', 'segments', 'profile', 'explore', 'nerd'];
|
||||
const TABS: Tab[] = ['power', '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;
|
||||
@@ -133,7 +131,6 @@
|
||||
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 {
|
||||
@@ -159,19 +156,15 @@
|
||||
return hi >= 900 ? `${lo}+ bpm` : `${lo}–${hi} bpm`;
|
||||
}
|
||||
|
||||
const ALL_TABS: { key: Tab; label: string; ownerOnly?: boolean; requiresVam?: boolean }[] = [
|
||||
const ALL_TABS: { key: Tab; label: string; ownerOnly?: 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 },
|
||||
{ key: 'explore', label: 'Explore', ownerOnly: true },
|
||||
{ key: 'nerd', label: 'Nerd Corner', ownerOnly: true },
|
||||
];
|
||||
$: TABS = ALL_TABS.filter(t =>
|
||||
(!t.ownerOnly || isOwner) &&
|
||||
(!t.requiresVam || athlete?.vam_curve?.all_time != null)
|
||||
);
|
||||
$: TABS = ALL_TABS.filter(t => !t.ownerOnly || isOwner);
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -223,16 +216,6 @@
|
||||
<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} />
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
export let activities: ActivitySummary[] = [];
|
||||
|
||||
type Metric = 'distance' | 'elevation' | 'time';
|
||||
type Metric = 'distance' | 'elevation' | 'time' | 'vam';
|
||||
type Granularity = 'week' | 'month';
|
||||
|
||||
let metric: Metric = 'distance';
|
||||
@@ -15,11 +15,13 @@
|
||||
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
|
||||
@@ -57,18 +59,34 @@
|
||||
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
|
||||
|
||||
for (const act of acts) {
|
||||
if (!act.started_at) continue;
|
||||
if (m === 'vam' && act.climbing_vam_mh == null) 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());
|
||||
const ym = byYear.get(yr)!;
|
||||
ym.set(per, (ym.get(per) ?? 0) + val);
|
||||
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);
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -241,6 +259,7 @@
|
||||
<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>
|
||||
@@ -250,8 +269,10 @@
|
||||
|
||||
<div bind:this={chartEl} class="w-full min-h-[320px]"></div>
|
||||
|
||||
<p class="section-label">Cumulative</p>
|
||||
<div bind:this={chartCumEl} 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>
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
<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}
|
||||
@@ -36,17 +36,10 @@ export interface BestClimb {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface AthleteVamCurve {
|
||||
all_time: MmpCurve | null;
|
||||
last_365d: MmpCurve | null;
|
||||
last_90d: MmpCurve | null;
|
||||
}
|
||||
|
||||
export interface AthleteJson {
|
||||
bas_version: string;
|
||||
generated_at: string;
|
||||
power_curve: AthletePowerCurve;
|
||||
vam_curve?: AthleteVamCurve | null;
|
||||
records?: Record<string, Record<string, EffortRecord | ValueRecord>>;
|
||||
best_climbs?: BestClimb[];
|
||||
max_hr?: number;
|
||||
@@ -73,7 +66,7 @@ export interface ActivitySummary {
|
||||
avg_cadence_rpm: number | null;
|
||||
avg_power_w: number | null;
|
||||
mmp: MmpCurve | null;
|
||||
vam_curve?: MmpCurve | null;
|
||||
climbing_vam_mh?: number | null;
|
||||
source: string | null;
|
||||
privacy: Privacy;
|
||||
detail_url: string | null;
|
||||
@@ -130,7 +123,6 @@ export interface ActivityDetail extends Omit<ActivitySummary, 'detail_url' | 'tr
|
||||
/** URL to fetch the timeseries — present for server-extracted activities. */
|
||||
timeseries_url?: string | null;
|
||||
mmp: MmpCurve | null;
|
||||
climbing_vam_mh?: number | null;
|
||||
strava_id: string | null;
|
||||
duplicate_of: string | null;
|
||||
source_file?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user