athlete page first draft
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { AthleteJson, BASIndex, ActivitySummary } from '../lib/types';
|
||||
import MmpChart from './MmpChart.svelte';
|
||||
|
||||
let athlete: AthleteJson | null = null;
|
||||
let activities: ActivitySummary[] = [];
|
||||
let loading = true;
|
||||
let error: string | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [athleteRes, indexRes] = await Promise.all([
|
||||
fetch(`${import.meta.env.BASE_URL}data/athlete.json`),
|
||||
fetch(`${import.meta.env.BASE_URL}data/index.json`),
|
||||
]);
|
||||
if (!athleteRes.ok) throw new Error('athlete.json not found — run bincio extract first');
|
||||
athlete = await athleteRes.json();
|
||||
const index: BASIndex = await indexRes.json();
|
||||
// Only activities with power data contribute to the curve
|
||||
activities = index.activities.filter(a => a.mmp && a.privacy !== 'private');
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function fmtZone(zones: [number, number][], i: number): string {
|
||||
const [lo, hi] = zones[i];
|
||||
return hi >= 9000 ? `${lo}+ W` : `${lo}–${hi} W`;
|
||||
}
|
||||
function fmtHrZone(zones: [number, number][], i: number): string {
|
||||
const [lo, hi] = zones[i];
|
||||
return hi >= 900 ? `${lo}+ bpm` : `${lo}–${hi} bpm`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-zinc-400 text-sm">Loading…</p>
|
||||
{:else if error}
|
||||
<p class="text-red-400 text-sm">{error}</p>
|
||||
{:else if athlete}
|
||||
|
||||
<!-- Power curve section -->
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Power Curve</h2>
|
||||
{#if athlete.power_curve.all_time}
|
||||
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800">
|
||||
<MmpChart {athlete} {activities} />
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-zinc-500 text-sm">No power data found. Make sure your activities include power meter data and re-run <code class="text-zinc-300">bincio extract</code>.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Profile section -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Profile</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Key numbers -->
|
||||
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800 space-y-3">
|
||||
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wide">Key numbers</h3>
|
||||
{#if athlete.max_hr}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-zinc-400">Max HR</span>
|
||||
<span class="text-white font-medium">{athlete.max_hr} bpm</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if athlete.ftp_w}
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-zinc-400">FTP</span>
|
||||
<span class="text-white font-medium">{athlete.ftp_w} W</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !athlete.max_hr && !athlete.ftp_w}
|
||||
<p class="text-zinc-500 text-sm">Set <code>athlete.max_hr</code> and <code>athlete.ftp_w</code> in your config.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- HR zones -->
|
||||
{#if athlete.hr_zones}
|
||||
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800 space-y-2">
|
||||
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wide">HR Zones</h3>
|
||||
{#each athlete.hr_zones as zone, i}
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-zinc-400">Z{i + 1}</span>
|
||||
<span class="text-white">{fmtHrZone(athlete.hr_zones, i)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Power zones -->
|
||||
{#if athlete.power_zones}
|
||||
<div class="bg-zinc-900 rounded-xl p-4 border border-zinc-800 space-y-2">
|
||||
<h3 class="text-sm font-medium text-zinc-400 uppercase tracking-wide">Power Zones</h3>
|
||||
{#each athlete.power_zones as zone, i}
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-zinc-400">Z{i + 1}</span>
|
||||
<span class="text-white">{fmtZone(athlete.power_zones, i)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/if}
|
||||
@@ -0,0 +1,199 @@
|
||||
<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[] = [];
|
||||
|
||||
// ── Range selection ────────────────────────────────────────────────────────
|
||||
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',
|
||||
};
|
||||
|
||||
// Colours for overlaid curves (cycling through a palette)
|
||||
const PALETTE = [
|
||||
'#60a5fa', // blue-400
|
||||
'#f97316', // orange-500
|
||||
'#34d399', // emerald-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];
|
||||
}
|
||||
|
||||
// ── MMP curve computation ──────────────────────────────────────────────────
|
||||
|
||||
function mergeMmps(mmps: MmpCurve[]): MmpCurve {
|
||||
const best = new Map<number, number>();
|
||||
for (const curve of mmps) {
|
||||
for (const [d, w] of curve) {
|
||||
const prev = best.get(d);
|
||||
if (prev === undefined || w > prev) best.set(d, w);
|
||||
}
|
||||
}
|
||||
return [...best.entries()].sort((a, b) => a[0] - b[0]) as MmpCurve;
|
||||
}
|
||||
|
||||
function mmpsForRange(key: RangeKey): MmpCurve | null {
|
||||
// Built-in preset ranges come from athlete.json (pre-computed at extract time)
|
||||
if (key in PRESET_LABELS) {
|
||||
return (athlete.power_curve as any)[key] ?? null;
|
||||
}
|
||||
// User-defined seasons: compute on-the-fly from per-activity mmp in index.json
|
||||
const season = seasons.find(s => s.name === key);
|
||||
if (!season) return null;
|
||||
const curves = activities
|
||||
.filter(a => a.mmp && a.started_at >= season.start && a.started_at <= season.end + 'T23:59:59')
|
||||
.map(a => a.mmp!);
|
||||
return curves.length ? mergeMmps(curves) : null;
|
||||
}
|
||||
|
||||
// ── Chart rendering ────────────────────────────────────────────────────────
|
||||
|
||||
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 = mmpsForRange(key);
|
||||
if (!curve) return [];
|
||||
return curve.map(([d, w]) => ({ d, w, label: key }));
|
||||
});
|
||||
|
||||
$: colorMap = Object.fromEntries(selectedKeys.map((k, i) => [k, curveColor(k, i)]));
|
||||
|
||||
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: 52,
|
||||
marginBottom: 40,
|
||||
style: { background: 'transparent', color: '#e4e4e7' },
|
||||
x: {
|
||||
type: 'log',
|
||||
label: 'Duration',
|
||||
tickFormat: (d: number) => formatDuration(d),
|
||||
grid: true,
|
||||
},
|
||||
y: {
|
||||
label: 'Avg power (W)',
|
||||
grid: true,
|
||||
},
|
||||
color: {
|
||||
domain: selectedKeys,
|
||||
range: selectedKeys.map((k, i) => curveColor(k, i)),
|
||||
legend: selectedKeys.length > 1,
|
||||
},
|
||||
marks: [
|
||||
Plot.line(data, {
|
||||
x: 'd',
|
||||
y: 'w',
|
||||
stroke: 'label',
|
||||
strokeWidth: 2,
|
||||
curve: 'monotone-x',
|
||||
}),
|
||||
Plot.dot(data, {
|
||||
x: 'd',
|
||||
y: 'w',
|
||||
fill: 'label',
|
||||
r: 3,
|
||||
tip: true,
|
||||
title: (d: any) => `${labelFn(d.label)}\n${formatDuration(d.d)}: ${d.w} W`,
|
||||
}),
|
||||
...(athlete.ftp_w ? [
|
||||
Plot.ruleY([athlete.ftp_w], {
|
||||
stroke: '#71717a',
|
||||
strokeDasharray: '4 3',
|
||||
}),
|
||||
Plot.text([{ x: 3600, y: athlete.ftp_w }], {
|
||||
x: 'x', y: 'y',
|
||||
text: () => `FTP ${athlete.ftp_w} W`,
|
||||
fill: '#71717a',
|
||||
fontSize: 11,
|
||||
dy: -6,
|
||||
textAnchor: 'end',
|
||||
}),
|
||||
] : []),
|
||||
],
|
||||
});
|
||||
|
||||
chartEl.appendChild(chart);
|
||||
}
|
||||
|
||||
$: renderChart(plotData, colorMap);
|
||||
|
||||
// Re-render on resize
|
||||
onMount(() => {
|
||||
const ro = new ResizeObserver(() => renderChart(plotData, colorMap));
|
||||
ro.observe(chartEl);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
// ── Toggle helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function toggleRange(key: RangeKey) {
|
||||
const next = new Set(selectedRanges);
|
||||
if (next.has(key)) {
|
||||
if (next.size > 1) next.delete(key); // always keep at least one
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
selectedRanges = next;
|
||||
}
|
||||
|
||||
const allRangeKeys = [
|
||||
...Object.keys(PRESET_LABELS),
|
||||
...seasons.map(s => s.name),
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- Range selector pills -->
|
||||
<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>
|
||||
|
||||
<!-- Chart -->
|
||||
<div bind:this={chartEl} class="w-full min-h-[320px]"></div>
|
||||
|
||||
{#if !plotData.length}
|
||||
<p class="text-zinc-500 text-sm mt-4">No power data for the selected range.</p>
|
||||
{/if}
|
||||
@@ -4,6 +4,25 @@ export type Sport = "cycling" | "running" | "hiking" | "walking" | "swimming" |
|
||||
export type SubSport = "road" | "mountain" | "gravel" | "indoor" | "trail" | "track" | "nordic" | null;
|
||||
export type Privacy = "public" | "blur_start" | "no_gps" | "private";
|
||||
|
||||
/** [duration_s, avg_watts] pairs, sorted by duration ascending. */
|
||||
export type MmpCurve = [number, number][];
|
||||
|
||||
export interface AthletePowerCurve {
|
||||
all_time: MmpCurve | null;
|
||||
last_365d: MmpCurve | null;
|
||||
last_90d: MmpCurve | null;
|
||||
}
|
||||
|
||||
export interface AthleteJson {
|
||||
bas_version: string;
|
||||
generated_at: string;
|
||||
power_curve: AthletePowerCurve;
|
||||
max_hr?: number;
|
||||
ftp_w?: number;
|
||||
hr_zones?: [number, number][];
|
||||
power_zones?: [number, number][];
|
||||
}
|
||||
|
||||
export interface ActivitySummary {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -20,6 +39,7 @@ export interface ActivitySummary {
|
||||
max_hr_bpm: number | null;
|
||||
avg_cadence_rpm: number | null;
|
||||
avg_power_w: number | null;
|
||||
mmp: MmpCurve | null;
|
||||
source: string | null;
|
||||
privacy: Privacy;
|
||||
detail_url: string | null;
|
||||
@@ -66,6 +86,7 @@ export interface ActivityDetail extends ActivitySummary {
|
||||
end_latlng: [number, number] | null;
|
||||
laps: Lap[];
|
||||
timeseries: Timeseries;
|
||||
mmp: MmpCurve | null;
|
||||
strava_id: string | null;
|
||||
duplicate_of: string | null;
|
||||
custom: Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import AthleteView from '../../components/AthleteView.svelte';
|
||||
---
|
||||
<Base title="Athlete — BincioActivity">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Athlete</h1>
|
||||
<AthleteView client:load />
|
||||
</Base>
|
||||
Reference in New Issue
Block a user