Merge consecutive same-phase laps before computing the average reference line

A cool-down (or warm-up) split across two or more laps was drawing each
lap's own average as a separate flat segment, so a workout with a 2-lap
cool-down showed two different "cool-down averages" back to back instead
of one. Consecutive laps sharing an IntensityType are now merged into a
single phase segment with one duration-weighted average across all of them.
This commit is contained in:
2026-07-19 16:57:47 +02:00
parent 2c2d4966b2
commit 1fb00f5bc6

View File

@@ -177,6 +177,45 @@ function lapWindowAt(t: number, windows: LapWindow[]): LapWindow | undefined {
return windows.find((w) => t >= w.start && t < w.end) ?? windows[windows.length - 1];
}
interface PhaseSegment {
start: number;
end: number;
intensityType: string;
avgPace: number | null;
avgHR: number | null;
}
// Merges consecutive laps of the same IntensityType into one segment (e.g. a
// two-lap cool-down becomes one), with a single duration-weighted average
// across all of its laps -- otherwise each lap would draw its own average
// line, showing e.g. two different "cool-down averages" back to back where
// there's really just one cool-down.
function buildPhaseSegments(windows: LapWindow[]): PhaseSegment[] {
const segments: PhaseSegment[] = [];
let group: LapWindow[] = [];
function flush() {
if (group.length === 0) return;
segments.push({
start: group[0].start,
end: group[group.length - 1].end,
intensityType: group[0].intensityType,
avgPace: weightedMean(group.map((w) => [w.avgPace, w.end - w.start])),
avgHR: weightedMean(group.map((w) => [w.avgHR, w.end - w.start])),
});
group = [];
}
for (const w of windows) {
if (group.length > 0 && group[group.length - 1].intensityType !== w.intensityType) flush();
group.push(w);
}
flush();
return segments;
}
function phaseSegmentAt(t: number, segments: PhaseSegment[]): PhaseSegment | undefined {
return segments.find((s) => t >= s.start && t < s.end) ?? segments[segments.length - 1];
}
function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => string) {
return (value: unknown, name: unknown): [string, string] => {
if (Array.isArray(value)) return [`${formatValue(value[0])}${formatValue(value[1])}`, rangeLabel];
@@ -223,10 +262,12 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
const lapWindows = buildLapWindows(laps);
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
const phaseSegments = buildPhaseSegments(lapWindows);
function phaseAvg(w: LapWindow | undefined, metric: "avgPace" | "avgHR"): number | null {
if (!w || (w.intensityType !== "WARMUP" && w.intensityType !== "COOLDOWN")) return null;
return w[metric];
function phaseAvg(t: number, metric: "avgPace" | "avgHR"): number | null {
const seg = phaseSegmentAt(t, phaseSegments);
if (!seg || (seg.intensityType !== "WARMUP" && seg.intensityType !== "COOLDOWN")) return null;
return seg[metric];
}
let points: Point[];
@@ -239,10 +280,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
t,
actualPace: paceSecPerKm(s.SpeedMps),
targetPaceRange: w?.targetPaceRange ?? null,
phaseAvgPace: phaseAvg(w, "avgPace"),
phaseAvgPace: phaseAvg(t, "avgPace"),
actualHR: s.HeartRate,
targetHRRange: w?.targetHRRange ?? null,
phaseAvgHR: phaseAvg(w, "avgHR"),
phaseAvgHR: phaseAvg(t, "avgHR"),
};
});
elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t);
@@ -253,10 +294,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
t: w.start,
actualPace: null,
targetPaceRange: w.targetPaceRange,
phaseAvgPace: phaseAvg(w, "avgPace"),
phaseAvgPace: phaseAvg(w.start, "avgPace"),
actualHR: null,
targetHRRange: w.targetHRRange,
phaseAvgHR: phaseAvg(w, "avgHR"),
phaseAvgHR: phaseAvg(w.start, "avgHR"),
}));
points.push({ ...points[points.length - 1], t: lapTotalMin });
elapsedMin = lapTotalMin;