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:
@@ -14,6 +14,11 @@ from bincio.extract.models import DataPoint, ParsedActivity
|
||||
# Standard MMP durations (seconds). Log-spaced so the curve looks good on a log-x axis.
|
||||
MMP_DURATIONS_S = [1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 300, 600, 1200, 1800, 3600]
|
||||
|
||||
# VAM curve durations — start at 60 s (shorter windows are too noisy for elevation data).
|
||||
VAM_DURATIONS_S = [60, 120, 180, 300, 600, 1200, 1800, 3600]
|
||||
_VAM_SPORTS = frozenset({"cycling", "running", "hiking", "walking"})
|
||||
_MIN_CLIMB_GAIN_M = 10.0 # minimum net gain in a window for VAM to be meaningful
|
||||
|
||||
# Standard best-effort distances (km) per sport.
|
||||
BEST_EFFORT_DISTANCES: dict[str, list[float]] = {
|
||||
"running": [0.4, 1.0, 1.609, 5.0, 10.0, 21.097, 42.195],
|
||||
@@ -62,6 +67,8 @@ class ComputedMetrics:
|
||||
# [[distance_km, time_s], ...] sorted by distance — None if sport has no distance targets
|
||||
best_efforts: Optional[list[list[float]]]
|
||||
best_climb_m: Optional[float] # max net elevation gain in one contiguous window (cycling only)
|
||||
climbing_vam_mh: Optional[int] # VAM on ascending segments only (m/h)
|
||||
vam_curve: Optional[list[list[int]]] # [[duration_s, vam_mh], ...]
|
||||
|
||||
|
||||
def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
@@ -81,6 +88,7 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
start_ll, end_ll = _endpoints(pts)
|
||||
mmp = compute_mmp(pts, activity.started_at)
|
||||
best_efforts, best_climb_m = compute_best_efforts(pts, activity.started_at, activity.sport)
|
||||
climbing_vam_mh, vam_curve = compute_vam(pts, activity.started_at, activity.sport)
|
||||
|
||||
return ComputedMetrics(
|
||||
distance_m=distance_m,
|
||||
@@ -102,6 +110,8 @@ def compute(activity: ParsedActivity) -> ComputedMetrics:
|
||||
mmp=mmp,
|
||||
best_efforts=best_efforts,
|
||||
best_climb_m=best_climb_m,
|
||||
climbing_vam_mh=climbing_vam_mh,
|
||||
vam_curve=vam_curve,
|
||||
)
|
||||
|
||||
|
||||
@@ -161,6 +171,114 @@ def compute_mmp(pts: list[DataPoint], started_at: datetime) -> Optional[list[lis
|
||||
return results if results else None
|
||||
|
||||
|
||||
# ── VAM (Velocità Ascensionale Media) ────────────────────────────────────────
|
||||
|
||||
def _rolling_mean_ele(data: list[float], win: int) -> list[float]:
|
||||
"""O(n) rolling mean via prefix sums."""
|
||||
n = len(data)
|
||||
prefix = [0.0] * (n + 1)
|
||||
for i, v in enumerate(data):
|
||||
prefix[i + 1] = prefix[i] + v
|
||||
half = win // 2
|
||||
result = []
|
||||
for i in range(n):
|
||||
lo = max(0, i - half)
|
||||
hi = min(n, i + half + 1)
|
||||
result.append((prefix[hi] - prefix[lo]) / (hi - lo))
|
||||
return result
|
||||
|
||||
|
||||
def compute_vam(
|
||||
pts: list[DataPoint],
|
||||
started_at: datetime,
|
||||
sport: str,
|
||||
) -> tuple[Optional[int], Optional[list[list[int]]]]:
|
||||
"""Compute climbing VAM and VAM duration curve.
|
||||
|
||||
Returns (climbing_vam_mh, vam_curve).
|
||||
climbing_vam_mh: VAM on ascending segments only (m/h), or None.
|
||||
vam_curve: [[duration_s, vam_mh], ...] best VAM per standard duration, or None.
|
||||
Only computed for cycling, running, hiking, walking.
|
||||
"""
|
||||
if sport not in _VAM_SPORTS:
|
||||
return None, None
|
||||
|
||||
# Build dense 1 Hz elevation array, forward-filling gaps
|
||||
sparse: dict[int, Optional[float]] = {}
|
||||
last_t = -1
|
||||
for p in pts:
|
||||
t = int((p.timestamp - started_at).total_seconds())
|
||||
if t < 0 or t == last_t:
|
||||
continue
|
||||
sparse[t] = p.elevation_m
|
||||
last_t = t
|
||||
|
||||
if not sparse:
|
||||
return None, None
|
||||
|
||||
t_min = min(sparse)
|
||||
t_max = max(sparse)
|
||||
if t_max - t_min > 7 * 24 * 3600:
|
||||
return None, None
|
||||
|
||||
ele_raw: list[Optional[float]] = []
|
||||
last_known: Optional[float] = None
|
||||
for t in range(t_min, t_max + 1):
|
||||
v = sparse.get(t)
|
||||
if v is not None:
|
||||
last_known = v
|
||||
ele_raw.append(last_known)
|
||||
|
||||
if sum(1 for e in ele_raw if e is not None) < 60:
|
||||
return None, None
|
||||
|
||||
first_valid = next((e for e in ele_raw if e is not None), None)
|
||||
if first_valid is None:
|
||||
return None, None
|
||||
ele_1hz: list[float] = [e if e is not None else first_valid for e in ele_raw]
|
||||
|
||||
n = len(ele_1hz)
|
||||
ele_smooth = _rolling_mean_ele(ele_1hz, 30)
|
||||
|
||||
# VAM curve: sliding window per duration, only windows with net gain above threshold
|
||||
vam_results: list[list[int]] = []
|
||||
for d in VAM_DURATIONS_S:
|
||||
if d >= n:
|
||||
break
|
||||
best_vam: Optional[float] = None
|
||||
for i in range(n - d):
|
||||
net_gain = ele_smooth[i + d] - ele_smooth[i]
|
||||
if net_gain < _MIN_CLIMB_GAIN_M:
|
||||
continue
|
||||
vam = net_gain * 3600.0 / d
|
||||
if best_vam is None or vam > best_vam:
|
||||
best_vam = vam
|
||||
if best_vam is not None:
|
||||
vam_results.append([d, round(best_vam)])
|
||||
vam_curve: Optional[list[list[int]]] = vam_results if vam_results else None
|
||||
|
||||
# Climbing VAM: accumulate gain and time only on ascending seconds.
|
||||
# A second is climbing if the 30 s forward elevation gain exceeds 2 m
|
||||
# (roughly 1 % gradient at 7 km/h).
|
||||
_LOOK = 30
|
||||
_THRESH = 2.0
|
||||
climbing_gain = 0.0
|
||||
climbing_time = 0
|
||||
for i in range(n - 1):
|
||||
look = min(i + _LOOK, n - 1)
|
||||
if ele_smooth[look] - ele_smooth[i] >= _THRESH:
|
||||
inst = ele_smooth[i + 1] - ele_smooth[i]
|
||||
if inst > 0:
|
||||
climbing_gain += inst
|
||||
climbing_time += 1
|
||||
|
||||
climbing_vam_mh: Optional[int] = None
|
||||
if climbing_time >= 60 and climbing_gain >= 5.0:
|
||||
climbing_vam_mh = round(climbing_gain * 3600.0 / climbing_time)
|
||||
|
||||
return climbing_vam_mh, vam_curve
|
||||
|
||||
|
||||
# ── best efforts & best climb ─────────────────────────────────────────────────
|
||||
|
||||
def compute_best_efforts(
|
||||
@@ -524,4 +642,5 @@ def _empty() -> ComputedMetrics:
|
||||
avg_cadence_rpm=None, avg_power_w=None, np_power_w=None, max_power_w=None,
|
||||
bbox=None, start_latlng=None, end_latlng=None,
|
||||
mmp=None, best_efforts=None, best_climb_m=None,
|
||||
climbing_vam_mh=None, vam_curve=None,
|
||||
)
|
||||
|
||||
@@ -101,6 +101,8 @@ def write_activity(
|
||||
"mmp": metrics.mmp,
|
||||
"best_efforts": metrics.best_efforts,
|
||||
"best_climb_m": metrics.best_climb_m,
|
||||
"climbing_vam_mh": metrics.climbing_vam_mh,
|
||||
"vam_curve": metrics.vam_curve,
|
||||
"laps": [_serialise_lap(lap) for lap in activity.laps],
|
||||
"timeseries_url": f"activities/{activity_id}.timeseries.json" if timeseries else None,
|
||||
"source": source,
|
||||
@@ -257,6 +259,7 @@ def build_summary(
|
||||
"mmp": metrics.mmp,
|
||||
"best_efforts": metrics.best_efforts,
|
||||
"best_climb_m": metrics.best_climb_m,
|
||||
"vam_curve": metrics.vam_curve,
|
||||
"source": _infer_source(activity),
|
||||
"privacy": privacy,
|
||||
"detail_url": f"activities/{activity_id}.json",
|
||||
@@ -300,6 +303,25 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
||||
mmps_365 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_365]
|
||||
mmps_90 = [s["mmp"] for s in summaries if s.get("mmp") and _is_outdoor(s) and s["started_at"] >= cutoff_90]
|
||||
|
||||
# ── VAM curve aggregation ─────────────────────────────────────────────────
|
||||
|
||||
def _merge_vam_curves(vam_lists: list[list[list[int]]]) -> list[list[int]]:
|
||||
best: dict[int, int] = {}
|
||||
for vc in vam_lists:
|
||||
for d, v in vc:
|
||||
if d not in best or v > best[d]:
|
||||
best[d] = v
|
||||
return [[d, v] for d, v in sorted(best.items())]
|
||||
|
||||
_VAM_SPORTS = {"cycling", "running", "hiking", "walking"}
|
||||
|
||||
def _has_vam(s: dict) -> bool:
|
||||
return bool(s.get("vam_curve")) and s.get("sport") in _VAM_SPORTS and _is_outdoor(s)
|
||||
|
||||
all_vams = [s["vam_curve"] for s in summaries if _has_vam(s)]
|
||||
vams_365 = [s["vam_curve"] for s in summaries if _has_vam(s) and s["started_at"] >= cutoff_365]
|
||||
vams_90 = [s["vam_curve"] for s in summaries if _has_vam(s) and s["started_at"] >= cutoff_90]
|
||||
|
||||
# ── Personal records aggregation ──────────────────────────────────────────
|
||||
# records[sport][distance_km] = {time_s, activity_id, started_at, title}
|
||||
# best_climb[activity_id] = {climb_m, started_at, title}
|
||||
@@ -368,6 +390,11 @@ def write_athlete_json(summaries: list[dict], output_dir: Path, athlete_config:
|
||||
"last_365d": _merge_mmps(mmps_365) if mmps_365 else None,
|
||||
"last_90d": _merge_mmps(mmps_90) if mmps_90 else None,
|
||||
},
|
||||
"vam_curve": {
|
||||
"all_time": _merge_vam_curves(all_vams) if all_vams else None,
|
||||
"last_365d": _merge_vam_curves(vams_365) if vams_365 else None,
|
||||
"last_90d": _merge_vam_curves(vams_90) if vams_90 else None,
|
||||
},
|
||||
"records": {
|
||||
sport: _serialise_sport_records(records[sport])
|
||||
for sport in SPORTS
|
||||
|
||||
@@ -116,7 +116,7 @@ def _rebuild_athlete_json(data: Path, handle: str | None = None) -> None:
|
||||
from bincio.render.merge import parse_sidecar, _apply_sidecar_summary
|
||||
|
||||
targets = [data / handle] if handle else _user_dirs(data)
|
||||
_COMPUTED = {"bas_version", "generated_at", "power_curve", "records", "best_climbs"}
|
||||
_COMPUTED = {"bas_version", "generated_at", "power_curve", "vam_curve", "records", "best_climbs"}
|
||||
for user_dir in targets:
|
||||
index_path = user_dir / "index.json"
|
||||
if not index_path.exists():
|
||||
|
||||
@@ -421,7 +421,7 @@ FEED_PAGE_SIZE = 50
|
||||
# Extra fields stripped from the combined feed — preview_coords is the biggest
|
||||
# contributor (~24% of shard size) but the feed cards need it for thumbnails,
|
||||
# so we keep it. mmp is never displayed in feed cards.
|
||||
_COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp"}
|
||||
_COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp", "vam_curve"}
|
||||
|
||||
|
||||
def write_combined_feed(data_dir: Path) -> int:
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
@@ -36,10 +36,17 @@ 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;
|
||||
@@ -66,6 +73,7 @@ export interface ActivitySummary {
|
||||
avg_cadence_rpm: number | null;
|
||||
avg_power_w: number | null;
|
||||
mmp: MmpCurve | null;
|
||||
vam_curve?: MmpCurve | null;
|
||||
source: string | null;
|
||||
privacy: Privacy;
|
||||
detail_url: string | null;
|
||||
@@ -122,6 +130,7 @@ 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