Filter start-of-run pace artifacts, use a percentile-based Y scale, round pace ticks to 30s
Samples right as recording starts (before the run itself begins) can compute an implausible pace like 50+ min/km from a near-zero speed reading; these are now treated as unknown rather than plotted. The pace/HR domain is also now built from the 2nd-98th percentile of the actual trace instead of true min/max, so an occasional stray point (a brief pause, GPS noise) can't single-handedly stretch the scale and squash the rest of a steady effort -- the target band, which is deliberate data rather than noisy telemetry, is still always fully included. Pace axis ticks always land on a round 30-second mark (7:30, 8:00, 8:30, ...).
This commit is contained in:
@@ -1,9 +1,17 @@
|
|||||||
import { Area, AreaChart, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Area, AreaChart, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
import type { Lap, Sample } from "../../types/api";
|
import type { Lap, Sample } from "../../types/api";
|
||||||
|
|
||||||
|
// Speeds below this read as an implausibly slow "pace" (20:00/km is well
|
||||||
|
// past even a very slow walk) -- in practice these come from GPS/motion
|
||||||
|
// still settling right as recording starts, before the run itself begins,
|
||||||
|
// not a real pace. Treating them as unknown keeps that artifact out of both
|
||||||
|
// the trace and the axis scale it would otherwise blow out.
|
||||||
|
const MAX_PLAUSIBLE_PACE_SEC_PER_KM = 1200;
|
||||||
|
|
||||||
function paceSecPerKm(mps: number | null): number | null {
|
function paceSecPerKm(mps: number | null): number | null {
|
||||||
if (mps == null || mps <= 0) return null;
|
if (mps == null || mps <= 0) return null;
|
||||||
return 1000 / mps;
|
const pace = 1000 / mps;
|
||||||
|
return pace <= MAX_PLAUSIBLE_PACE_SEC_PER_KM ? pace : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPaceShort(secPerKm: number): string {
|
function formatPaceShort(secPerKm: number): string {
|
||||||
@@ -22,12 +30,54 @@ function formatElapsed(minutes: number): string {
|
|||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recharts' "dataMin - N" domain expressions don't combine reliably with
|
function percentile(sortedAsc: number[], p: number): number {
|
||||||
// reversed axes, so the padded numeric domain is computed directly instead.
|
const idx = (sortedAsc.length - 1) * p;
|
||||||
function paddedDomain(values: Array<number | null | undefined>, pad: number): [number, number] {
|
const lo = Math.floor(idx);
|
||||||
const nums = values.filter((v): v is number => v != null);
|
const hi = Math.ceil(idx);
|
||||||
if (nums.length === 0) return [0, 1];
|
if (lo === hi) return sortedAsc[lo];
|
||||||
return [Math.min(...nums) - pad, Math.max(...nums) + pad];
|
return sortedAsc[lo] + (sortedAsc[hi] - sortedAsc[lo]) * (idx - lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The axis scale is built from the 2nd-98th percentile of the actual trace,
|
||||||
|
// not its true min/max: a single stray sample (a brief pause, a moment of
|
||||||
|
// GPS noise) can otherwise stretch the whole scale to accommodate one point,
|
||||||
|
// squashing the rest of a steady effort into a sliver. The target band is
|
||||||
|
// never trimmed this way -- it's a handful of real, deliberate values, not
|
||||||
|
// noisy telemetry, and should always be fully visible when present.
|
||||||
|
// Recharts' "dataMin - N" domain expressions also don't combine reliably
|
||||||
|
// with reversed axes, so the padded numeric domain is computed directly.
|
||||||
|
function robustDomain(actualValues: Array<number | null | undefined>, targetValues: Array<number | null | undefined>, pad: number): [number, number] {
|
||||||
|
const actual = actualValues.filter((v): v is number => v != null).sort((a, b) => a - b);
|
||||||
|
const targets = targetValues.filter((v): v is number => v != null);
|
||||||
|
|
||||||
|
let lo = Infinity;
|
||||||
|
let hi = -Infinity;
|
||||||
|
if (actual.length > 0) {
|
||||||
|
lo = Math.min(lo, percentile(actual, 0.02));
|
||||||
|
hi = Math.max(hi, percentile(actual, 0.98));
|
||||||
|
}
|
||||||
|
for (const t of targets) {
|
||||||
|
lo = Math.min(lo, t);
|
||||||
|
hi = Math.max(hi, t);
|
||||||
|
}
|
||||||
|
if (!isFinite(lo) || !isFinite(hi)) return [0, 1];
|
||||||
|
return [lo - pad, hi + pad];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pace axis ticks always land on a round 30-second mark (5:30, 6:00, 6:30,
|
||||||
|
// ...) rather than whatever raw values the domain's min/max happen to be.
|
||||||
|
// A fixed, small tick count (rather than every 30s multiple in range) keeps
|
||||||
|
// labels legible in a ~100px-tall mini chart -- more ticks than that just
|
||||||
|
// collide, and Recharts' own collision-avoidance would silently drop most
|
||||||
|
// of them anyway, which is what left only one tick visible originally.
|
||||||
|
function paceTicksEvery30s([lo, hi]: [number, number], count = 4): number[] {
|
||||||
|
const step = 30;
|
||||||
|
const ticks = new Set<number>();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const raw = lo + ((hi - lo) * i) / (count - 1);
|
||||||
|
ticks.add(Math.round(raw / step) * step);
|
||||||
|
}
|
||||||
|
return [...ticks].sort((a, b) => a - b);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pace and HR each keep one dedicated accent color across every chart, so
|
// Pace and HR each keep one dedicated accent color across every chart, so
|
||||||
@@ -161,8 +211,17 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
const phaseBands = lapWindows.filter((w) => w.color).map((w) => ({ x1: w.start, x2: w.end, color: w.color! }));
|
const phaseBands = lapWindows.filter((w) => w.color).map((w) => ({ x1: w.start, x2: w.end, color: w.color! }));
|
||||||
const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => PHASE_COLORS[t]))];
|
const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => PHASE_COLORS[t]))];
|
||||||
|
|
||||||
const paceDomain = paddedDomain(points.flatMap((p) => [p.actualPace, ...(p.targetPaceRange ?? [])]), 10);
|
const paceDomain = robustDomain(
|
||||||
const hrDomain = paddedDomain(points.flatMap((p) => [p.actualHR, ...(p.targetHRRange ?? [])]), 5);
|
points.map((p) => p.actualPace),
|
||||||
|
points.flatMap((p) => p.targetPaceRange ?? [null, null]),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const hrDomain = robustDomain(
|
||||||
|
points.map((p) => p.actualHR),
|
||||||
|
points.flatMap((p) => p.targetHRRange ?? [null, null]),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
const paceTicks = paceTicksEvery30s(paceDomain);
|
||||||
|
|
||||||
const paceTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${formatPace(v)}`);
|
const paceTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${formatPace(v)}`);
|
||||||
const hrTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${Math.round(v)} bpm`);
|
const hrTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${Math.round(v)} bpm`);
|
||||||
@@ -187,7 +246,7 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.18} strokeOpacity={0} />
|
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.18} strokeOpacity={0} />
|
||||||
))}
|
))}
|
||||||
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
|
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
|
||||||
<YAxis reversed domain={paceDomain} tick={{ fontSize: 10 }} width={34} tickFormatter={(v) => formatPaceShort(Number(v))} />
|
<YAxis reversed domain={paceDomain} ticks={paceTicks} interval={0} tick={{ fontSize: 10 }} width={34} tickFormatter={(v) => formatPaceShort(Number(v))} />
|
||||||
<Tooltip formatter={paceTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} />
|
<Tooltip formatter={paceTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} />
|
||||||
{hasPaceTarget && (
|
{hasPaceTarget && (
|
||||||
<Area type="stepAfter" dataKey="targetPaceRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
<Area type="stepAfter" dataKey="targetPaceRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
||||||
|
|||||||
Reference in New Issue
Block a user