From 99e32b642e65d51aaf34a2047b82d3f4baaa3f03 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 18 Oct 2026 20:37:54 +0200 Subject: 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. --- web/frontend/official-climb-profile.ts | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) (limited to 'web/frontend/official-climb-profile.ts') 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]; -- cgit v1.3