feat: pace chart and spm cadence for running/hiking/walking/other

Speed tab becomes Pace tab (min/km, y-axis inverted so faster = top).
Cadence label switches to spm. Tooltip and reference lines use m:ss format.
This commit is contained in:
Davide Scaini
2026-06-02 16:46:16 +02:00
parent a142e8732f
commit c59fc0073f
2 changed files with 53 additions and 29 deletions
+52 -28
View File
@@ -1,13 +1,24 @@
<script lang="ts"> <script lang="ts">
import * as Plot from '@observablehq/plot'; import * as Plot from '@observablehq/plot';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import type { Timeseries, AthleteZones } from '../lib/types'; import type { Timeseries, AthleteZones, Sport } from '../lib/types';
import { isPaceSport } from '../lib/format';
export let timeseries: Timeseries; export let timeseries: Timeseries;
export let sport: string = 'cycling';
// Linked hover: emit/receive index into timeseries arrays // Linked hover: emit/receive index into timeseries arrays
export let hoveredIdx: number | null = null; export let hoveredIdx: number | null = null;
export let athlete: AthleteZones | null = null; export let athlete: AthleteZones | null = null;
$: isPace = isPaceSport(sport as Sport);
function fmtPaceValue(v: number | null): string {
if (v == null || v <= 0) return '—';
const m = Math.floor(v);
const s = Math.round((v - m) * 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
const HR_ZONE_COLORS = ['#60a5fa', '#4ade80', '#facc15', '#fb923c', '#f87171']; const HR_ZONE_COLORS = ['#60a5fa', '#4ade80', '#facc15', '#fb923c', '#f87171'];
const PWR_ZONE_COLORS = ['#60a5fa', '#34d399', '#facc15', '#fb923c', '#f87171', '#c084fc', '#f43f5e']; const PWR_ZONE_COLORS = ['#60a5fa', '#34d399', '#facc15', '#fb923c', '#f87171', '#c084fc', '#f43f5e'];
@@ -43,15 +54,19 @@
})(); })();
// Pre-build data array once // Pre-build data array once
$: data = timeseries.t.map((t, i) => ({ $: data = timeseries.t.map((t, i) => {
t, const spd = timeseries.speed_kmh[i];
dist_km: dist_km ? dist_km[i] : null, return {
elevation: timeseries.elevation_m[i], t,
speed: timeseries.speed_kmh[i], dist_km: dist_km ? dist_km[i] : null,
hr: timeseries.hr_bpm[i], elevation: timeseries.elevation_m[i],
cadence: timeseries.cadence_rpm[i], speed: spd,
power: timeseries.power_w[i], pace: (spd != null && spd > 0) ? 60 / spd : null,
})); hr: timeseries.hr_bpm[i],
cadence: timeseries.cadence_rpm[i],
power: timeseries.power_w[i],
};
});
$: hasHR = timeseries.hr_bpm.some(v => v != null); $: hasHR = timeseries.hr_bpm.some(v => v != null);
$: hasCadence = timeseries.cadence_rpm.some(v => v != null); $: hasCadence = timeseries.cadence_rpm.some(v => v != null);
@@ -61,23 +76,23 @@
$: hasDistance = dist_km !== null; $: hasDistance = dist_km !== null;
$: hasSlope = hasElevation && hasDistance; $: hasSlope = hasElevation && hasDistance;
const tabLabels: Record<Tab, string> = { $: tabLabels = {
elevation: 'Elevation', elevation: 'Elevation',
slope: 'Slope', slope: 'Slope',
speed: 'Speed', speed: isPace ? 'Pace' : 'Speed',
hr: 'Heart Rate', hr: 'Heart Rate',
cadence: 'Cadence', cadence: 'Cadence',
power: 'Power', power: 'Power',
}; } as Record<Tab, string>;
const tabMeta: Record<Tab, { color: string; yLabel: string; yKey: string }> = { $: tabMeta = {
elevation: { color: '#00c8ff', yLabel: 'Elevation (m)', yKey: 'elevation' }, elevation: { color: '#00c8ff', yLabel: 'Elevation (m)', yKey: 'elevation' },
slope: { color: '#e4e4e7', yLabel: 'Elevation (m)', yKey: 'elevation' }, slope: { color: '#e4e4e7', yLabel: 'Elevation (m)', yKey: 'elevation' },
speed: { color: '#ff6b35', yLabel: 'Speed (km/h)', yKey: 'speed' }, speed: { color: '#ff6b35', yLabel: isPace ? 'Pace (min/km)' : 'Speed (km/h)', yKey: isPace ? 'pace' : 'speed' },
hr: { color: '#f87171', yLabel: 'Heart Rate (bpm)', yKey: 'hr' }, hr: { color: '#f87171', yLabel: 'Heart Rate (bpm)', yKey: 'hr' },
cadence: { color: '#a78bfa', yLabel: 'Cadence (rpm)', yKey: 'cadence' }, cadence: { color: '#a78bfa', yLabel: isPace ? 'Cadence (spm)' : 'Cadence (rpm)', yKey: 'cadence' },
power: { color: '#facc15', yLabel: 'Power (W)', yKey: 'power' }, power: { color: '#facc15', yLabel: 'Power (W)', yKey: 'power' },
}; } as Record<Tab, { color: string; yLabel: string; yKey: string }>;
// ── Histogram controls ─────────────────────────────────────────────────── // ── Histogram controls ───────────────────────────────────────────────────
let bins = 15; let bins = 15;
@@ -96,7 +111,7 @@
// Plot auto-infers different domains when the x-channel changes because it only // Plot auto-infers different domains when the x-channel changes because it only
// considers plottable points, but we want the scale to stay anchored to the // considers plottable points, but we want the scale to stay anchored to the
// full dataset. areaY extends down to 0, so include 0 in the minimum. // full dataset. areaY extends down to 0, so include 0 in the minimum.
$: lineDomainMin = Math.min(0, dataMin); $: lineDomainMin = (activeTab === 'speed' && isPace) ? dataMin : Math.min(0, dataMin);
$: lineDomainMax = dataMax; $: lineDomainMax = dataMax;
// Range handles — reset whenever the metric or chart type changes // Range handles — reset whenever the metric or chart type changes
@@ -335,9 +350,9 @@
Plot.ruleY([ref.p20, ref.p80], { stroke: tc.axis, strokeWidth: 1, strokeDasharray: '3,4', strokeOpacity: 0.55 }), Plot.ruleY([ref.p20, ref.p80], { stroke: tc.axis, strokeWidth: 1, strokeDasharray: '3,4', strokeOpacity: 0.55 }),
Plot.text( Plot.text(
[ [
{ xv: xEnd, yv: ref.avg, label: `avg ${Math.round(ref.avg)}` }, { xv: xEnd, yv: ref.avg, label: activeTab === 'speed' && isPace ? `avg ${fmtPaceValue(ref.avg)}` : `avg ${Math.round(ref.avg)}` },
{ xv: xEnd, yv: ref.p20, label: `P20 ${Math.round(ref.p20)}` }, { xv: xEnd, yv: ref.p20, label: activeTab === 'speed' && isPace ? `P20 ${fmtPaceValue(ref.p20)}` : `P20 ${Math.round(ref.p20)}` },
{ xv: xEnd, yv: ref.p80, label: `P80 ${Math.round(ref.p80)}` }, { xv: xEnd, yv: ref.p80, label: activeTab === 'speed' && isPace ? `P80 ${fmtPaceValue(ref.p80)}` : `P80 ${Math.round(ref.p80)}` },
], ],
{ x: 'xv', y: 'yv', text: 'label', textAnchor: 'end', dx: -4, dy: -6, { x: 'xv', y: 'yv', text: 'label', textAnchor: 'end', dx: -4, dy: -6,
fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3, fontSize: 11 }, fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3, fontSize: 11 },
@@ -376,7 +391,11 @@
marks.push( marks.push(
Plot.text(lineData, Plot.pointerX({ Plot.text(lineData, Plot.pointerX({
x, y: yKey, x, y: yKey,
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '', text: (d: any) => {
const v = d[yKey];
if (v == null) return '';
return (activeTab === 'speed' && isPace) ? fmtPaceValue(v) : `${Math.round(v)}`;
},
dy: -12, dy: -12,
fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3, fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3,
fontSize: 11, fontWeight: '600', fontSize: 11, fontWeight: '600',
@@ -396,7 +415,11 @@
width: w, height: h, marginLeft: 48, marginBottom: 32, marginTop: isSlope ? 36 : 20, width: w, height: h, marginLeft: 48, marginBottom: 32, marginTop: isSlope ? 36 : 20,
style: { background: 'transparent', color: tc.axis, fontSize: '11px' }, style: { background: 'transparent', color: tc.axis, fontSize: '11px' },
x: { label: null, tickFormat: xTickFormat, grid: false, ticks: 6 }, x: { label: null, tickFormat: xTickFormat, grid: false, ticks: 6 },
y: { label: yLabel, grid: true, tickCount: 4, domain: [lineDomainMin, lineDomainMax] }, y: {
label: yLabel, grid: true, tickCount: 4,
domain: (activeTab === 'speed' && isPace) ? [lineDomainMax, lineDomainMin] : [lineDomainMin, lineDomainMax],
tickFormat: (activeTab === 'speed' && isPace) ? (v: number) => fmtPaceValue(v) : undefined,
},
marks, marks,
}); });
@@ -488,7 +511,8 @@
return Plot.plot({ return Plot.plot({
width: w, height: h, marginLeft: 48, marginBottom: 32, width: w, height: h, marginLeft: 48, marginBottom: 32,
style: { background: 'transparent', color: tc.axis, fontSize: '11px' }, style: { background: 'transparent', color: tc.axis, fontSize: '11px' },
x: { label: yLabel, grid: false, ticks: 6, domain: [trimMin, trimMax] }, x: { label: yLabel, grid: false, ticks: 6, domain: [trimMin, trimMax],
tickFormat: (activeTab === 'speed' && isPace) ? (v: number) => fmtPaceValue(v) : undefined },
y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat }, y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat },
marks, marks,
}); });
+1 -1
View File
@@ -445,7 +445,7 @@
<p class="text-red-400 text-sm">{error}</p> <p class="text-red-400 text-sm">{error}</p>
{:else if timeseries && timeseries.t.length > 0} {:else if timeseries && timeseries.t.length > 0}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4"> <div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<ActivityCharts {timeseries} bind:hoveredIdx {athlete} /> <ActivityCharts {timeseries} sport={activity.sport} bind:hoveredIdx {athlete} />
</div> </div>
{:else if !detail || timeseriesLoading} {:else if !detail || timeseriesLoading}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse"></div> <div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse"></div>