diff --git a/frontend/src/components/charts/ExpectedVsActualChart.tsx b/frontend/src/components/charts/ExpectedVsActualChart.tsx index bd96cb7..c39f2a0 100644 --- a/frontend/src/components/charts/ExpectedVsActualChart.tsx +++ b/frontend/src/components/charts/ExpectedVsActualChart.tsx @@ -1,9 +1,17 @@ import { Area, AreaChart, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; 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 { 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 { @@ -22,12 +30,54 @@ function formatElapsed(minutes: number): string { return `${m}:${s.toString().padStart(2, "0")}`; } -// Recharts' "dataMin - N" domain expressions don't combine reliably with -// reversed axes, so the padded numeric domain is computed directly instead. -function paddedDomain(values: Array, pad: number): [number, number] { - const nums = values.filter((v): v is number => v != null); - if (nums.length === 0) return [0, 1]; - return [Math.min(...nums) - pad, Math.max(...nums) + pad]; +function percentile(sortedAsc: number[], p: number): number { + const idx = (sortedAsc.length - 1) * p; + const lo = Math.floor(idx); + const hi = Math.ceil(idx); + if (lo === hi) return sortedAsc[lo]; + 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, targetValues: Array, 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(); + 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 @@ -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 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 hrDomain = paddedDomain(points.flatMap((p) => [p.actualHR, ...(p.targetHRRange ?? [])]), 5); + const paceDomain = robustDomain( + 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 hrTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${Math.round(v)} bpm`); @@ -187,7 +246,7 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: ))} `${Math.round(Number(v))}m`} /> - formatPaceShort(Number(v))} /> + formatPaceShort(Number(v))} /> `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} /> {hasPaceTarget && (