aboutsummaryrefslogtreecommitdiff
path: root/web/frontend/official-climb-profile.ts
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/official-climb-profile.ts
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/official-climb-profile.ts')
-rw-r--r--web/frontend/official-climb-profile.ts34
1 files changed, 23 insertions, 11 deletions
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];