aboutsummaryrefslogtreecommitdiff
path: root/web/frontend/official-climb-profile-logic.ts
diff options
context:
space:
mode:
Diffstat (limited to 'web/frontend/official-climb-profile-logic.ts')
-rw-r--r--web/frontend/official-climb-profile-logic.ts149
1 files changed, 122 insertions, 27 deletions
diff --git a/web/frontend/official-climb-profile-logic.ts b/web/frontend/official-climb-profile-logic.ts
index 893588d..005e46b 100644
--- a/web/frontend/official-climb-profile-logic.ts
+++ b/web/frontend/official-climb-profile-logic.ts
@@ -9,10 +9,11 @@ export interface OfficialProfileSection {
band: ProfileBand;
}
-const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
-const profileStepSizesM: number[] = [100, 200, 500, 1000];
-const displayStepForLength = (lengthM: number): number =>
- profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1];
+interface ProfileNode {
+ distanceM: number;
+ elevationM: number;
+}
+
const profileBandForSlope = (slopePercent: number): ProfileBand => {
if (slopePercent <= 0) return "downhill";
if (slopePercent < 3) return "0-3";
@@ -20,31 +21,113 @@ const profileBandForSlope = (slopePercent: number): ProfileBand => {
if (slopePercent < 9) return "6-9";
return "9-plus";
};
-const officialProfileSections = (points: ProfilePoint[], startIndex: number, endIndex: number, stepM: number): OfficialProfileSection[] => {
- const startDistanceM = points[startIndex].distanceKm * 1000;
- const endDistanceM = points[endIndex].distanceKm * 1000;
- const sections: OfficialProfileSection[] = [];
- let pointIndex = startIndex;
- const elevationAtDistance = (distanceM: number) => {
- while (pointIndex < endIndex - 1 && points[pointIndex + 1].distanceKm * 1000 < distanceM) pointIndex++;
- const nextIndex = Math.min(pointIndex + 1, endIndex);
- const first = points[pointIndex];
+
+// The base grid the profile is simplified from: fine enough to localize a ramp,
+// coarse enough to average GPS noise out.
+const baseStepSizesM: number[] = [25, 50, 100, 200, 500, 1000];
+const maxBaseCells = 120;
+const baseStepForLength = (lengthM: number): number =>
+ baseStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= maxBaseCells) ?? Math.ceil(lengthM / maxBaseCells);
+
+// Simplification: a bend smaller than the tolerance cannot be seen on the chart, so it is
+// averaged away. The tolerance tracks the base grid (1 % of a base cell, at least a meter), so a
+// gentle 4 km climb keeps the same relative detail as a 400 m wall.
+const minToleranceM = 1;
+const tolerancePerBaseStep = 0.01;
+const maxProfileSections = 24;
+
+const elevationAtDistance = (points: ProfilePoint[], startIndex: number, endIndex: number): ((distanceM: number) => number) => {
+ let index = startIndex;
+ return (distanceM: number): number => {
+ while (index < endIndex - 1 && points[index + 1].distanceKm * 1000 < distanceM) index++;
+ const nextIndex = Math.min(index + 1, endIndex);
+ const first = points[index];
const next = points[nextIndex];
- const distanceSpan = next.distanceKm * 1000 - first.distanceKm * 1000;
- if (distanceSpan <= 0) return first.elevationM;
- const fraction = (distanceM - first.distanceKm * 1000) / distanceSpan;
- return first.elevationM + clamp(fraction, 0, 1) * (next.elevationM - first.elevationM);
+ const distanceSpanM = next.distanceKm * 1000 - first.distanceKm * 1000;
+ if (distanceSpanM <= 0) return first.elevationM;
+ const fraction = (distanceM - first.distanceKm * 1000) / distanceSpanM;
+ return first.elevationM + Math.max(0, Math.min(1, fraction)) * (next.elevationM - first.elevationM);
};
- for (let sectionStartM = startDistanceM; sectionStartM < endDistanceM; sectionStartM += stepM) {
- const sectionEndM = Math.min(sectionStartM + stepM, endDistanceM);
- const startElevation = elevationAtDistance(sectionStartM);
- const endElevation = elevationAtDistance(sectionEndM);
- const slopePercent = ((endElevation - startElevation) / (sectionEndM - sectionStartM)) * 100;
+};
+
+const baseNodes = (points: ProfilePoint[], startIndex: number, endIndex: number, stepM: number): ProfileNode[] => {
+ const startM = points[startIndex].distanceKm * 1000;
+ const endM = points[endIndex].distanceKm * 1000;
+ const elevationAt = elevationAtDistance(points, startIndex, endIndex);
+ const nodes: ProfileNode[] = [];
+ for (let distanceM = startM; distanceM < endM; distanceM += stepM) {
+ nodes.push({ distanceM, elevationM: elevationAt(distanceM) });
+ }
+ nodes.push({ distanceM: endM, elevationM: elevationAt(endM) });
+ return nodes;
+};
+
+const chordDeviationM = (first: ProfileNode, last: ProfileNode, node: ProfileNode): number => {
+ const spanM = last.distanceM - first.distanceM;
+ if (spanM <= 0) return node.elevationM - first.elevationM;
+ const chordElevationM = first.elevationM + ((node.distanceM - first.distanceM) / spanM) * (last.elevationM - first.elevationM);
+ return node.elevationM - chordElevationM;
+};
+
+interface PendingSegment {
+ firstIndex: number;
+ lastIndex: number;
+ splitIndex: number;
+ deviationM: number;
+}
+
+const bestSplit = (nodes: ProfileNode[], firstIndex: number, lastIndex: number): PendingSegment => {
+ let splitIndex = -1;
+ let deviationM = 0;
+ for (let index = firstIndex + 1; index < lastIndex; index++) {
+ const candidateM = Math.abs(chordDeviationM(nodes[firstIndex], nodes[lastIndex], nodes[index]));
+ if (candidateM > deviationM) {
+ deviationM = candidateM;
+ splitIndex = index;
+ }
+ }
+ return { firstIndex, lastIndex, splitIndex, deviationM };
+};
+
+// simplifyNodes keeps splitting the segment with the widest bend until the profile either fits the
+// section budget or has no bend left above toleranceM: a constant stretch collapses into one long
+// section, a ramp keeps its own short ones, and the cuts land on the most prominent bends first.
+const simplifyNodes = (nodes: ProfileNode[], toleranceM: number): ProfileNode[] => {
+ if (nodes.length <= 2) return nodes;
+ const finalIndex = nodes.length - 1;
+ const keep = new Array<boolean>(nodes.length).fill(false);
+ keep[0] = true;
+ keep[finalIndex] = true;
+ const pending: PendingSegment[] = [bestSplit(nodes, 0, finalIndex)];
+ let sectionCount = 1;
+ while (sectionCount < maxProfileSections) {
+ let widestIndex = -1;
+ for (let index = 0; index < pending.length; index++) {
+ if (pending[index].splitIndex === -1) continue;
+ if (widestIndex === -1 || pending[index].deviationM > pending[widestIndex].deviationM) widestIndex = index;
+ }
+ if (widestIndex === -1 || pending[widestIndex].deviationM <= toleranceM) break;
+ const [widest] = pending.splice(widestIndex, 1);
+ keep[widest.splitIndex] = true;
+ sectionCount++;
+ pending.push(bestSplit(nodes, widest.firstIndex, widest.splitIndex), bestSplit(nodes, widest.splitIndex, widest.lastIndex));
+ }
+ return nodes.filter((_, index) => keep[index]);
+};
+
+const sectionsFromNodes = (nodes: ProfileNode[]): OfficialProfileSection[] => {
+ const sections: OfficialProfileSection[] = [];
+ for (let index = 1; index < nodes.length; index++) {
+ const start = nodes[index - 1];
+ const end = nodes[index];
+ const spanM = end.distanceM - start.distanceM;
+ if (spanM <= 0) continue;
+ const slopePercent = ((end.elevationM - start.elevationM) / spanM) * 100;
sections.push({
- startDistanceKm: sectionStartM / 1000,
- endDistanceKm: sectionEndM / 1000,
- startElevation,
- endElevation,
+ startDistanceKm: start.distanceM / 1000,
+ endDistanceKm: end.distanceM / 1000,
+ startElevation: start.elevationM,
+ endElevation: end.elevationM,
slopePercent,
band: profileBandForSlope(slopePercent),
});
@@ -52,4 +135,16 @@ const officialProfileSections = (points: ProfilePoint[], startIndex: number, end
return sections;
};
-export { displayStepForLength, profileBandForSlope, officialProfileSections };
+// officialProfileSections turns the recorded profile into chart sections whose width
+// follows the profile's bends, so short steep ramps survive instead of being averaged
+// into a fixed distance bucket.
+const officialProfileSections = (points: ProfilePoint[], startIndex: number, endIndex: number): OfficialProfileSection[] => {
+ if (startIndex < 0 || endIndex >= points.length || endIndex - startIndex < 1) return [];
+ const lengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000;
+ if (lengthM <= 0) return [];
+ const baseStepM = baseStepForLength(lengthM);
+ const nodes = baseNodes(points, startIndex, endIndex, baseStepM);
+ return sectionsFromNodes(simplifyNodes(nodes, Math.max(minToleranceM, tolerancePerBaseStep * baseStepM)));
+};
+
+export { officialProfileSections, profileBandForSlope };