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.ts | 149 ++++++++++++++++++++++----- 1 file changed, 122 insertions(+), 27 deletions(-) (limited to 'web/frontend/official-climb-profile-logic.ts') 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(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 }; -- cgit v1.3