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:
@@ -1,13 +1,24 @@
|
||||
<script lang="ts">
|
||||
import * as Plot from '@observablehq/plot';
|
||||
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 sport: string = 'cycling';
|
||||
// Linked hover: emit/receive index into timeseries arrays
|
||||
export let hoveredIdx: number | 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 PWR_ZONE_COLORS = ['#60a5fa', '#34d399', '#facc15', '#fb923c', '#f87171', '#c084fc', '#f43f5e'];
|
||||
|
||||
@@ -43,15 +54,19 @@
|
||||
})();
|
||||
|
||||
// Pre-build data array once
|
||||
$: data = timeseries.t.map((t, i) => ({
|
||||
$: data = timeseries.t.map((t, i) => {
|
||||
const spd = timeseries.speed_kmh[i];
|
||||
return {
|
||||
t,
|
||||
dist_km: dist_km ? dist_km[i] : null,
|
||||
elevation: timeseries.elevation_m[i],
|
||||
speed: timeseries.speed_kmh[i],
|
||||
speed: spd,
|
||||
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);
|
||||
$: hasCadence = timeseries.cadence_rpm.some(v => v != null);
|
||||
@@ -61,23 +76,23 @@
|
||||
$: hasDistance = dist_km !== null;
|
||||
$: hasSlope = hasElevation && hasDistance;
|
||||
|
||||
const tabLabels: Record<Tab, string> = {
|
||||
$: tabLabels = {
|
||||
elevation: 'Elevation',
|
||||
slope: 'Slope',
|
||||
speed: 'Speed',
|
||||
speed: isPace ? 'Pace' : 'Speed',
|
||||
hr: 'Heart Rate',
|
||||
cadence: 'Cadence',
|
||||
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' },
|
||||
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' },
|
||||
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' },
|
||||
};
|
||||
} as Record<Tab, { color: string; yLabel: string; yKey: string }>;
|
||||
|
||||
// ── Histogram controls ───────────────────────────────────────────────────
|
||||
let bins = 15;
|
||||
@@ -96,7 +111,7 @@
|
||||
// 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
|
||||
// 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;
|
||||
|
||||
// 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.text(
|
||||
[
|
||||
{ xv: xEnd, yv: ref.avg, label: `avg ${Math.round(ref.avg)}` },
|
||||
{ xv: xEnd, yv: ref.p20, label: `P20 ${Math.round(ref.p20)}` },
|
||||
{ xv: xEnd, yv: ref.p80, label: `P80 ${Math.round(ref.p80)}` },
|
||||
{ xv: xEnd, yv: ref.avg, label: activeTab === 'speed' && isPace ? `avg ${fmtPaceValue(ref.avg)}` : `avg ${Math.round(ref.avg)}` },
|
||||
{ xv: xEnd, yv: ref.p20, label: activeTab === 'speed' && isPace ? `P20 ${fmtPaceValue(ref.p20)}` : `P20 ${Math.round(ref.p20)}` },
|
||||
{ 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,
|
||||
fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3, fontSize: 11 },
|
||||
@@ -376,7 +391,11 @@
|
||||
marks.push(
|
||||
Plot.text(lineData, Plot.pointerX({
|
||||
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,
|
||||
fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3,
|
||||
fontSize: 11, fontWeight: '600',
|
||||
@@ -396,7 +415,11 @@
|
||||
width: w, height: h, marginLeft: 48, marginBottom: 32, marginTop: isSlope ? 36 : 20,
|
||||
style: { background: 'transparent', color: tc.axis, fontSize: '11px' },
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -488,7 +511,8 @@
|
||||
return Plot.plot({
|
||||
width: w, height: h, marginLeft: 48, marginBottom: 32,
|
||||
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 },
|
||||
marks,
|
||||
});
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
<p class="text-red-400 text-sm">{error}</p>
|
||||
{:else if timeseries && timeseries.t.length > 0}
|
||||
<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>
|
||||
{:else if !detail || timeseriesLoading}
|
||||
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse"></div>
|
||||
|
||||
Reference in New Issue
Block a user