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">
|
<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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user