Fix Y-axis label overlap, add phase-boundary lines and warm-up/cool-down average lines, map INTERVAL to Effort
Y-axis ticks now use a fixed small count (3) snapped to round increments (30s for pace, 5-20bpm for HR depending on range) instead of Recharts' automatic placement, which was silently dropping most ticks in the ~100px-tall mini chart and occasionally leaving only one label visible. A light vertical line now marks every point the effort type changes, even between phases that don't get a background color. Warm-up and cool-down segments -- which rarely have a target band -- get a dashed line at their own average pace/HR instead, as a reference point. "Pertuis - 1500m Hill" showed no phase coloring because its lap uses Garmin's INTERVAL intensity type, which wasn't mapped -- confirmed via query that 23 laps across the account use it, alongside the 5 types already handled. It's the same concept as ACTIVE (a work/effort segment), so it now shares that color and label.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Area, AreaChart, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Area, AreaChart, Line, ReferenceArea, ReferenceLine, 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
|
// Speeds below this read as an implausibly slow "pace" (20:00/km is well
|
||||||
@@ -64,14 +64,13 @@ function robustDomain(actualValues: Array<number | null | undefined>, targetValu
|
|||||||
return [lo - pad, hi + pad];
|
return [lo - pad, hi + pad];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pace axis ticks always land on a round 30-second mark (5:30, 6:00, 6:30,
|
// Picks a small number of evenly-spaced ticks across the domain, each
|
||||||
// ...) rather than whatever raw values the domain's min/max happen to be.
|
// snapped to a round increment (30s for pace, 5-20bpm for HR depending on
|
||||||
// A fixed, small tick count (rather than every 30s multiple in range) keeps
|
// range). A fixed, small count -- rather than every increment in range --
|
||||||
// labels legible in a ~100px-tall mini chart -- more ticks than that just
|
// keeps labels legible and non-overlapping in a ~100px-tall mini chart.
|
||||||
// collide, and Recharts' own collision-avoidance would silently drop most
|
// Recharts' own collision-avoidance would otherwise silently drop most of a
|
||||||
// of them anyway, which is what left only one tick visible originally.
|
// denser tick set anyway.
|
||||||
function paceTicksEvery30s([lo, hi]: [number, number], count = 4): number[] {
|
function niceTicks([lo, hi]: [number, number], step: number, count = 3): number[] {
|
||||||
const step = 30;
|
|
||||||
const ticks = new Set<number>();
|
const ticks = new Set<number>();
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const raw = lo + ((hi - lo) * i) / (count - 1);
|
const raw = lo + ((hi - lo) * i) / (count - 1);
|
||||||
@@ -80,6 +79,13 @@ function paceTicksEvery30s([lo, hi]: [number, number], count = 4): number[] {
|
|||||||
return [...ticks].sort((a, b) => a - b);
|
return [...ticks].sort((a, b) => a - b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hrTickStep(domain: [number, number]): number {
|
||||||
|
const range = domain[1] - domain[0];
|
||||||
|
if (range > 60) return 20;
|
||||||
|
if (range > 30) return 10;
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
// the two metrics stay visually distinct from each other and from the phase
|
// the two metrics stay visually distinct from each other and from the phase
|
||||||
// bands below.
|
// bands below.
|
||||||
@@ -90,9 +96,13 @@ const HR_COLOR = "#ef4444"; // red
|
|||||||
// Colors distinguish warm-up / effort / recovery / cool-down bands behind
|
// Colors distinguish warm-up / effort / recovery / cool-down bands behind
|
||||||
// the pace/HR trace so a run's structure reads at a glance -- deliberately
|
// the pace/HR trace so a run's structure reads at a glance -- deliberately
|
||||||
// avoiding blue/red, since those are already spoken for by PACE_COLOR/HR_COLOR.
|
// avoiding blue/red, since those are already spoken for by PACE_COLOR/HR_COLOR.
|
||||||
|
// ACTIVE and INTERVAL both mean "this is a work/effort segment" -- Garmin
|
||||||
|
// uses ACTIVE for structured-workout steps and INTERVAL for manually-lapped
|
||||||
|
// or auto-detected effort segments, but they're the same concept here.
|
||||||
const PHASE_COLORS: Record<string, string> = {
|
const PHASE_COLORS: Record<string, string> = {
|
||||||
WARMUP: "#f59e0b", // amber
|
WARMUP: "#f59e0b", // amber
|
||||||
ACTIVE: "#ec4899", // pink
|
ACTIVE: "#ec4899", // pink
|
||||||
|
INTERVAL: "#ec4899", // pink
|
||||||
REST: "#14b8a6", // teal
|
REST: "#14b8a6", // teal
|
||||||
RECOVERY: "#14b8a6", // teal
|
RECOVERY: "#14b8a6", // teal
|
||||||
COOLDOWN: "#8b5cf6", // violet
|
COOLDOWN: "#8b5cf6", // violet
|
||||||
@@ -100,6 +110,7 @@ const PHASE_COLORS: Record<string, string> = {
|
|||||||
const PHASE_LABELS: Record<string, string> = {
|
const PHASE_LABELS: Record<string, string> = {
|
||||||
WARMUP: "Warm-up",
|
WARMUP: "Warm-up",
|
||||||
ACTIVE: "Effort",
|
ACTIVE: "Effort",
|
||||||
|
INTERVAL: "Effort",
|
||||||
REST: "Recovery",
|
REST: "Recovery",
|
||||||
RECOVERY: "Recovery",
|
RECOVERY: "Recovery",
|
||||||
COOLDOWN: "Cool-down",
|
COOLDOWN: "Cool-down",
|
||||||
@@ -108,8 +119,11 @@ const PHASE_LABELS: Record<string, string> = {
|
|||||||
interface LapWindow {
|
interface LapWindow {
|
||||||
start: number; // minutes, elapsed since activity start
|
start: number; // minutes, elapsed since activity start
|
||||||
end: number;
|
end: number;
|
||||||
|
intensityType: string;
|
||||||
targetPaceRange: [number, number] | null;
|
targetPaceRange: [number, number] | null;
|
||||||
targetHRRange: [number, number] | null;
|
targetHRRange: [number, number] | null;
|
||||||
|
avgPace: number | null;
|
||||||
|
avgHR: number | null;
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,8 +131,10 @@ interface Point {
|
|||||||
t: number;
|
t: number;
|
||||||
actualPace: number | null;
|
actualPace: number | null;
|
||||||
targetPaceRange: [number, number] | null;
|
targetPaceRange: [number, number] | null;
|
||||||
|
warmupCooldownAvgPace: number | null;
|
||||||
actualHR: number | null;
|
actualHR: number | null;
|
||||||
targetHRRange: [number, number] | null;
|
targetHRRange: [number, number] | null;
|
||||||
|
warmupCooldownAvgHR: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLapWindows(laps: Lap[]): LapWindow[] {
|
function buildLapWindows(laps: Lap[]): LapWindow[] {
|
||||||
@@ -136,8 +152,11 @@ function buildLapWindows(laps: Lap[]): LapWindow[] {
|
|||||||
windows.push({
|
windows.push({
|
||||||
start,
|
start,
|
||||||
end: elapsedMin,
|
end: elapsedMin,
|
||||||
|
intensityType: l.IntensityType,
|
||||||
targetPaceRange: paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null,
|
targetPaceRange: paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null,
|
||||||
targetHRRange: l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null,
|
targetHRRange: l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null,
|
||||||
|
avgPace: paceSecPerKm(l.AvgSpeedMps),
|
||||||
|
avgHR: l.AvgHR,
|
||||||
color: PHASE_COLORS[l.IntensityType],
|
color: PHASE_COLORS[l.IntensityType],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -160,10 +179,13 @@ function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => s
|
|||||||
|
|
||||||
// Shows actual pace/HR over elapsed time for every activity that has laps,
|
// Shows actual pace/HR over elapsed time for every activity that has laps,
|
||||||
// with the workout's warm-up/effort/recovery/cool-down structure shaded
|
// with the workout's warm-up/effort/recovery/cool-down structure shaded
|
||||||
// behind it. The expected band from a structured Garmin workout (see
|
// behind it (a light vertical line marks each phase change) and the
|
||||||
// backend's alignWorkoutTargets) is layered on top when available -- most
|
// expected band from a structured Garmin workout (see backend's
|
||||||
// activities aren't from a structured workout with target zones, so absence
|
// alignWorkoutTargets) layered on top when available -- most activities
|
||||||
// of that band is the common case, not an error.
|
// aren't from a structured workout with target zones, so absence of that
|
||||||
|
// band is the common case, not an error. Warm-up and cool-down segments,
|
||||||
|
// which rarely have a target, instead get a dashed line at their own
|
||||||
|
// average pace/HR so there's still a reference point to judge them against.
|
||||||
//
|
//
|
||||||
// The actual trace is built from per-second samples when available, not lap
|
// The actual trace is built from per-second samples when available, not lap
|
||||||
// averages: a lap can span many minutes, so plotting one flat value per lap
|
// averages: a lap can span many minutes, so plotting one flat value per lap
|
||||||
@@ -179,6 +201,11 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
const lapWindows = buildLapWindows(laps);
|
const lapWindows = buildLapWindows(laps);
|
||||||
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
|
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
|
||||||
|
|
||||||
|
function warmupCooldownAvg(w: LapWindow | undefined, metric: "avgPace" | "avgHR"): number | null {
|
||||||
|
if (!w || (w.intensityType !== "WARMUP" && w.intensityType !== "COOLDOWN")) return null;
|
||||||
|
return w[metric];
|
||||||
|
}
|
||||||
|
|
||||||
let points: Point[];
|
let points: Point[];
|
||||||
let elapsedMin: number;
|
let elapsedMin: number;
|
||||||
if (samples.length > 0) {
|
if (samples.length > 0) {
|
||||||
@@ -189,8 +216,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
t,
|
t,
|
||||||
actualPace: paceSecPerKm(s.SpeedMps),
|
actualPace: paceSecPerKm(s.SpeedMps),
|
||||||
targetPaceRange: w?.targetPaceRange ?? null,
|
targetPaceRange: w?.targetPaceRange ?? null,
|
||||||
|
warmupCooldownAvgPace: warmupCooldownAvg(w, "avgPace"),
|
||||||
actualHR: s.HeartRate,
|
actualHR: s.HeartRate,
|
||||||
targetHRRange: w?.targetHRRange ?? null,
|
targetHRRange: w?.targetHRRange ?? null,
|
||||||
|
warmupCooldownAvgHR: warmupCooldownAvg(w, "avgHR"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t);
|
elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t);
|
||||||
@@ -201,8 +230,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
t: w.start,
|
t: w.start,
|
||||||
actualPace: null,
|
actualPace: null,
|
||||||
targetPaceRange: w.targetPaceRange,
|
targetPaceRange: w.targetPaceRange,
|
||||||
|
warmupCooldownAvgPace: warmupCooldownAvg(w, "avgPace"),
|
||||||
actualHR: null,
|
actualHR: null,
|
||||||
targetHRRange: w.targetHRRange,
|
targetHRRange: w.targetHRRange,
|
||||||
|
warmupCooldownAvgHR: warmupCooldownAvg(w, "avgHR"),
|
||||||
}));
|
}));
|
||||||
points.push({ ...points[points.length - 1], t: lapTotalMin });
|
points.push({ ...points[points.length - 1], t: lapTotalMin });
|
||||||
elapsedMin = lapTotalMin;
|
elapsedMin = lapTotalMin;
|
||||||
@@ -210,6 +241,10 @@ 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]))];
|
||||||
|
// A light vertical line at every point the effort type changes (warm-up
|
||||||
|
// -> effort -> recovery -> ...), regardless of whether that type has a
|
||||||
|
// background color, so the workout's structure reads at a glance.
|
||||||
|
const phaseBoundaries = lapWindows.slice(1).filter((w, i) => w.intensityType !== lapWindows[i].intensityType).map((w) => w.start);
|
||||||
|
|
||||||
const paceDomain = robustDomain(
|
const paceDomain = robustDomain(
|
||||||
points.map((p) => p.actualPace),
|
points.map((p) => p.actualPace),
|
||||||
@@ -221,7 +256,8 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
points.flatMap((p) => p.targetHRRange ?? [null, null]),
|
points.flatMap((p) => p.targetHRRange ?? [null, null]),
|
||||||
5,
|
5,
|
||||||
);
|
);
|
||||||
const paceTicks = paceTicksEvery30s(paceDomain);
|
const paceTicks = niceTicks(paceDomain, 30);
|
||||||
|
const hrTicks = niceTicks(hrDomain, hrTickStep(hrDomain));
|
||||||
|
|
||||||
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`);
|
||||||
@@ -245,6 +281,9 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
{phaseBands.map((b, i) => (
|
{phaseBands.map((b, i) => (
|
||||||
<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} />
|
||||||
))}
|
))}
|
||||||
|
{phaseBoundaries.map((x, i) => (
|
||||||
|
<ReferenceLine key={i} x={x} stroke="#ffffff" strokeOpacity={0.15} />
|
||||||
|
))}
|
||||||
<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} ticks={paceTicks} interval={0} 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 }} />
|
||||||
@@ -252,6 +291,7 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
<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 />
|
||||||
)}
|
)}
|
||||||
<Area type="monotone" dataKey="actualPace" stroke={PACE_COLOR} strokeWidth={2} fill={PACE_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
|
<Area type="monotone" dataKey="actualPace" stroke={PACE_COLOR} strokeWidth={2} fill={PACE_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
|
||||||
|
<Line type="linear" dataKey="warmupCooldownAvgPace" stroke={PACE_COLOR} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Warm-up/cool-down avg" connectNulls={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,13 +302,17 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
{phaseBands.map((b, i) => (
|
{phaseBands.map((b, i) => (
|
||||||
<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} />
|
||||||
))}
|
))}
|
||||||
|
{phaseBoundaries.map((x, i) => (
|
||||||
|
<ReferenceLine key={i} x={x} stroke="#ffffff" strokeOpacity={0.15} />
|
||||||
|
))}
|
||||||
<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 domain={hrDomain} tick={{ fontSize: 10 }} width={30} tickFormatter={(v) => String(Math.round(Number(v)))} />
|
<YAxis domain={hrDomain} ticks={hrTicks} interval={0} tick={{ fontSize: 10 }} width={30} tickFormatter={(v) => String(Math.round(Number(v)))} />
|
||||||
<Tooltip formatter={hrTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} />
|
<Tooltip formatter={hrTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} />
|
||||||
{hasHRTarget && (
|
{hasHRTarget && (
|
||||||
<Area type="stepAfter" dataKey="targetHRRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
<Area type="stepAfter" dataKey="targetHRRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
||||||
)}
|
)}
|
||||||
<Area type="monotone" dataKey="actualHR" stroke={HR_COLOR} strokeWidth={2} fill={HR_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
|
<Area type="monotone" dataKey="actualHR" stroke={HR_COLOR} strokeWidth={2} fill={HR_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
|
||||||
|
<Line type="linear" dataKey="warmupCooldownAvgHR" stroke={HR_COLOR} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Warm-up/cool-down avg" connectNulls={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user