aboutsummaryrefslogtreecommitdiff
path: root/web/frontend
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-10-18 20:37:54 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-10-18 20:37:54 +0200
commit99e32b642e65d51aaf34a2047b82d3f4baaa3f03 (patch)
tree64ea4d4d80bfcc092bb09eb98e8de47ad9262622 /web/frontend
parente82599233fa579f665c62636389ab2dc27b10d2a (diff)
Draw climb profile sections where the slope changes
Fixed distance buckets averaged short steep ramps into their bucket: a 300 m @ 12 % pitch inside a 10 km climb reported ~7-8 % for 500 m. Sections now come from the base-grid elevation polyline split at its widest bends, up to a 24 section budget, so ramps keep their own step and label. Labels are dropped only when they would collide, and the distance axis uses a uniform 0.1-10 km step instead of one tick per (now irregular) section.
Diffstat (limited to 'web/frontend')
-rw-r--r--web/frontend/climb-profile.ts4
-rw-r--r--web/frontend/official-climb-profile-logic.test.ts105
-rw-r--r--web/frontend/official-climb-profile-logic.ts149
-rw-r--r--web/frontend/official-climb-profile.ts34
4 files changed, 231 insertions, 61 deletions
diff --git a/web/frontend/climb-profile.ts b/web/frontend/climb-profile.ts
index 8910349..6051ec1 100644
--- a/web/frontend/climb-profile.ts
+++ b/web/frontend/climb-profile.ts
@@ -1,5 +1,5 @@
import { drawOfficialSections, officialProfilePlot, resolveOfficialProfileColors } from "./official-climb-profile.js";
-import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js";
+import { officialProfileSections } from "./official-climb-profile-logic.js";
import type { OfficialProfileSection } from "./official-climb-profile-logic.js";
import type { ProfilePoint } from "./types.js";
@@ -24,7 +24,7 @@ const climbProfilePoints = (payload: ClimbProfilePayload): ProfilePoint[] =>
const climbProfileSections = (payload: ClimbProfilePayload): OfficialProfileSection[] => {
const points = climbProfilePoints(payload);
if (points.length < 2) return [];
- return officialProfileSections(points, 0, points.length - 1, displayStepForLength(payload.distanceKm * 1000));
+ return officialProfileSections(points, 0, points.length - 1);
};
const elevationAtDistance = (sections: OfficialProfileSection[], distanceKm: number): number | null => {
diff --git a/web/frontend/official-climb-profile-logic.test.ts b/web/frontend/official-climb-profile-logic.test.ts
index 32521da..ca8dea2 100644
--- a/web/frontend/official-climb-profile-logic.test.ts
+++ b/web/frontend/official-climb-profile-logic.test.ts
@@ -1,13 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
-import { displayStepForLength, profileBandForSlope, officialProfileSections } from "./official-climb-profile-logic.ts";
-
-test("selects a display step that keeps long profiles readable", () => {
- assert.equal(displayStepForLength(2_400), 100);
- assert.equal(displayStepForLength(4_500), 200);
- assert.equal(displayStepForLength(7_000), 500);
- assert.equal(displayStepForLength(20_000), 1000);
-});
+import { officialProfileSections, profileBandForSlope } from "./official-climb-profile-logic.ts";
+import type { ProfilePoint } from "./types.js";
test("assigns each slope to its color band", () => {
assert.equal(profileBandForSlope(-0.1), "downhill");
@@ -20,20 +14,89 @@ test("assigns each slope to its color band", () => {
assert.equal(profileBandForSlope(12), "9-plus");
});
-test("interpolates profile sections at the selected step", () => {
- const points = [
- { distanceKm: 0, elevationM: 100 },
- { distanceKm: 0.12, elevationM: 106 },
- { distanceKm: 0.2, elevationM: 114 },
- { distanceKm: 0.25, elevationM: 120 },
- ];
- const sections = officialProfileSections(points, 0, 3, 100);
+// buildProfile lays out one recorded point every 50 m along the given [length in km, slope in %] stretches.
+const buildProfile = (stretches: [number, number][]): ProfilePoint[] => {
+ const points: ProfilePoint[] = [{ distanceKm: 0, elevationM: 0 }];
+ let distanceKm = 0;
+ let elevationM = 0;
+ for (const [lengthKm, slopePercent] of stretches) {
+ const pointCount = Math.round(lengthKm * 20);
+ for (let index = 1; index <= pointCount; index++) {
+ const distanceStepKm = (lengthKm * index) / pointCount;
+ points.push({ distanceKm: distanceKm + distanceStepKm, elevationM: elevationM + distanceStepKm * 1000 * (slopePercent / 100) });
+ }
+ distanceKm += lengthKm;
+ elevationM += lengthKm * 1000 * (slopePercent / 100);
+ }
+ return points;
+};
+
+test("keeps a short steep ramp as its own section", () => {
+ const points = buildProfile([
+ [0.3, 4],
+ [0.3, 12],
+ [0.3, 4],
+ ]);
+ const sections = officialProfileSections(points, 0, points.length - 1);
assert.equal(sections.length, 3);
+ assert.ok(Math.abs(sections[1].slopePercent - 12) < 0.01);
+ assert.equal(sections[1].band, "9-plus");
+ assert.equal(Math.round((sections[1].endDistanceKm - sections[1].startDistanceKm) * 1000), 300);
+ assert.ok(Math.abs(sections[0].slopePercent - 4) < 0.01);
+ assert.ok(Math.abs(sections[2].slopePercent - 4) < 0.01);
+});
+
+test("collapses a constant slope into a single section", () => {
+ const points = buildProfile([[5, 6]]);
+ const sections = officialProfileSections(points, 0, points.length - 1);
+
+ assert.equal(sections.length, 1);
+ assert.ok(Math.abs(sections[0].slopePercent - 6) < 0.01);
assert.equal(sections[0].startDistanceKm, 0);
- assert.equal(sections[0].endDistanceKm, 0.1);
- assert.equal(sections[0].startElevation, 100);
- assert.equal(sections[0].endElevation, 105);
- assert.equal(sections[2].endDistanceKm, 0.25);
- assert.equal(sections[2].endElevation, 120);
+ assert.equal(sections[0].endDistanceKm, 5);
+});
+
+test("reports a descending stretch as downhill", () => {
+ const points = buildProfile([[2, -5]]);
+ const sections = officialProfileSections(points, 0, points.length - 1);
+
+ assert.equal(sections.length, 1);
+ assert.equal(sections[0].band, "downhill");
+});
+
+test("keeps a noisy profile within the readable section budget", () => {
+ const points: ProfilePoint[] = [];
+ for (let index = 0; index <= 200; index++) {
+ const distanceKm = index * 0.05;
+ points.push({ distanceKm, elevationM: distanceKm * 1000 * 0.05 + Math.sin(index) * 4 });
+ }
+ const sections = officialProfileSections(points, 0, points.length - 1);
+
+ assert.ok(sections.length > 10 && sections.length <= 24, `expected 10-24 sections, got ${sections.length}`);
+ assert.equal(sections[0].startDistanceKm, 0);
+ assert.equal(sections[sections.length - 1].endDistanceKm, 10);
+});
+
+test("reads a slice of a longer profile", () => {
+ const points = buildProfile([
+ [1, 20],
+ [1, 8],
+ [1, 20],
+ ]);
+ const startIndex = points.findIndex((point) => point.distanceKm >= 1);
+ const endIndex = points.findIndex((point) => point.distanceKm >= 2);
+ const sections = officialProfileSections(points, startIndex, endIndex);
+
+ assert.equal(sections.length, 1);
+ assert.equal(Math.round(sections[0].slopePercent), 8);
+ assert.equal(sections[0].startDistanceKm, points[startIndex].distanceKm);
+ assert.equal(sections[0].endDistanceKm, points[endIndex].distanceKm);
+});
+
+test("returns no section for an empty range", () => {
+ const points = buildProfile([[1, 6]]);
+
+ assert.deepEqual(officialProfileSections(points, 3, 3), []);
+ assert.deepEqual(officialProfileSections(points, 0, points.length), []);
});
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 };
diff --git a/web/frontend/official-climb-profile.ts b/web/frontend/official-climb-profile.ts
index ac4d591..7e93cc7 100644
--- a/web/frontend/official-climb-profile.ts
+++ b/web/frontend/official-climb-profile.ts
@@ -1,4 +1,4 @@
-import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js";
+import { officialProfileSections } from "./official-climb-profile-logic.js";
import type { OfficialProfileSection } from "./official-climb-profile-logic.js";
import type { OfficialProfileColors, RideProfilePoint } from "./types.js";
export class OfficialClimbProfileController {
@@ -35,8 +35,7 @@ export class OfficialClimbProfileController {
startIndex >= endIndex
)
return;
- const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000;
- const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM));
+ const sections = officialProfileSections(points, startIndex, endIndex);
if (sections.length === 0) return;
drawOfficialSections(profileCanvas, sections, this.colors);
}
@@ -133,10 +132,14 @@ export function drawOfficialSections(
const minElevation = plot.minElevation;
const maxElevation = plot.maxElevation;
const minDistance = plot.minDistanceKm;
- const maxDistance = plot.maxDistanceKm;
const elevationSpan = Math.max(maxElevation - minElevation, 1);
const xForDistance = (distanceKm: number) => plot.left + ((distanceKm - minDistance) / plot.distanceSpan) * (plot.right - plot.left);
const yForElevation = (elevationM: number) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (plot.bottom - plot.top);
+ const tickStepKm = tickStepForSpan(plot.distanceSpan);
+ const tickDistancesKm: number[] = [];
+ for (let index = 0; index * tickStepKm <= plot.distanceSpan + 1e-9; index++) {
+ tickDistancesKm.push(index * tickStepKm);
+ }
context.clearRect(0, 0, rect.width, rect.height);
context.fillStyle = colors.plotSurface;
context.fillRect(0, 0, rect.width, rect.height);
@@ -211,9 +214,8 @@ export function drawOfficialSections(
context.strokeStyle = colors.xGrid;
context.lineWidth = 1;
context.setLineDash([4, 4]);
- for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) {
- const distanceKm = sectionIndex === sections.length ? maxDistance : sections[sectionIndex].startDistanceKm;
- const x = xForDistance(distanceKm);
+ for (const distanceKm of tickDistancesKm) {
+ const x = xForDistance(minDistance + distanceKm);
context.beginPath();
context.moveTo(x, plot.top);
context.lineTo(x, plot.bottom);
@@ -229,11 +231,17 @@ export function drawOfficialSections(
context.beginPath();
context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
context.clip();
+ let lastLabelRight = Number.NEGATIVE_INFINITY;
for (const section of sections) {
const startX = xForDistance(section.startDistanceKm);
const endX = xForDistance(section.endDistanceKm);
const label = `${section.slopePercent.toFixed(1)}%`;
- context.fillText(label, (startX + endX) / 2, plot.bottom - 4);
+ const labelWidth = context.measureText(label).width;
+ const centerX = (startX + endX) / 2;
+ // Adaptive sections can be narrow: skip a slope label that would run into the previous one.
+ if (centerX - labelWidth / 2 < lastLabelRight + 6) continue;
+ context.fillText(label, centerX, plot.bottom - 4);
+ lastLabelRight = centerX + labelWidth / 2;
}
context.restore();
let topElevation = sections[0].startElevation;
@@ -257,14 +265,13 @@ export function drawOfficialSections(
context.textBaseline = "top";
context.strokeStyle = colors.grid;
context.lineWidth = 1;
- for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) {
- const distanceKm = sectionIndex === sections.length ? maxDistance - minDistance : sections[sectionIndex].startDistanceKm - minDistance;
+ for (const [index, distanceKm] of tickDistancesKm.entries()) {
const x = xForDistance(minDistance + distanceKm);
context.beginPath();
context.moveTo(x, plot.bottom);
context.lineTo(x, plot.bottom + 3);
context.stroke();
- context.textAlign = sectionIndex === 0 ? "left" : sectionIndex === sections.length ? "right" : "center";
+ context.textAlign = index === 0 ? "left" : "center";
context.fillText(formatClimbDistance(distanceKm), x, plot.bottom + 5);
}
}
@@ -272,3 +279,8 @@ export function drawOfficialSections(
function formatClimbDistance(distance: number): string {
return `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
}
+
+// The distance axis is cut at a round step, at most eight ticks, whatever the climb length.
+const tickStepsKm: number[] = [0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10];
+const tickStepForSpan = (spanKm: number): number =>
+ tickStepsKm.find((stepKm) => spanKm / stepKm <= 8) ?? tickStepsKm[tickStepsKm.length - 1];