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-logic.test.ts | 105 +++++++++++++++++----- 1 file changed, 84 insertions(+), 21 deletions(-) (limited to 'web/frontend/official-climb-profile-logic.test.ts') 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), []); }); -- cgit v1.3