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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user