diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/frontend/climb-profile.ts | 4 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile-logic.test.ts | 105 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile-logic.ts | 149 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile.ts | 34 |
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]; |
