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];
|
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) {
|
function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => string) {
|
||||||
return (value: unknown, name: unknown): [string, string] => {
|
return (value: unknown, name: unknown): [string, string] => {
|
||||||
if (Array.isArray(value)) return [`${formatValue(value[0])}–${formatValue(value[1])}`, rangeLabel];
|
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 lapWindows = buildLapWindows(laps);
|
||||||
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
|
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
|
||||||
|
const phaseSegments = buildPhaseSegments(lapWindows);
|
||||||
|
|
||||||
function phaseAvg(w: LapWindow | undefined, metric: "avgPace" | "avgHR"): number | null {
|
function phaseAvg(t: number, metric: "avgPace" | "avgHR"): number | null {
|
||||||
if (!w || (w.intensityType !== "WARMUP" && w.intensityType !== "COOLDOWN")) return null;
|
const seg = phaseSegmentAt(t, phaseSegments);
|
||||||
return w[metric];
|
if (!seg || (seg.intensityType !== "WARMUP" && seg.intensityType !== "COOLDOWN")) return null;
|
||||||
|
return seg[metric];
|
||||||
}
|
}
|
||||||
|
|
||||||
let points: Point[];
|
let points: Point[];
|
||||||
@@ -239,10 +280,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,
|
||||||
phaseAvgPace: phaseAvg(w, "avgPace"),
|
phaseAvgPace: phaseAvg(t, "avgPace"),
|
||||||
actualHR: s.HeartRate,
|
actualHR: s.HeartRate,
|
||||||
targetHRRange: w?.targetHRRange ?? null,
|
targetHRRange: w?.targetHRRange ?? null,
|
||||||
phaseAvgHR: phaseAvg(w, "avgHR"),
|
phaseAvgHR: phaseAvg(t, "avgHR"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t);
|
elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t);
|
||||||
@@ -253,10 +294,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,
|
||||||
phaseAvgPace: phaseAvg(w, "avgPace"),
|
phaseAvgPace: phaseAvg(w.start, "avgPace"),
|
||||||
actualHR: null,
|
actualHR: null,
|
||||||
targetHRRange: w.targetHRRange,
|
targetHRRange: w.targetHRRange,
|
||||||
phaseAvgHR: phaseAvg(w, "avgHR"),
|
phaseAvgHR: phaseAvg(w.start, "avgHR"),
|
||||||
}));
|
}));
|
||||||
points.push({ ...points[points.length - 1], t: lapTotalMin });
|
points.push({ ...points[points.length - 1], t: lapTotalMin });
|
||||||
elapsedMin = lapTotalMin;
|
elapsedMin = lapTotalMin;
|
||||||
|
|||||||
Reference in New Issue
Block a user