aboutsummaryrefslogtreecommitdiff
path: root/web/frontend/official-climb-profile-logic.ts
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-10-18 21:19:36 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-10-18 21:19:36 +0200
commit8a0032d08862dcf28c653bee3fd21052e967e35d (patch)
tree6cb0d8efe6f5c2bf9ce9eb3ef8306faecbe7cfeb /web/frontend/official-climb-profile-logic.ts
parentd575274d00c8da251df29d5d6a636044673944a3 (diff)
Cut climb profile sections in the backend
The chart steps are computed in Go and shipped as finished sections, next to the profile statistics the climb package already owns: climb.ProfileSections cuts a Route at its widest bends under a ProfilePolicy, and rideanalysis.MergeClimbProfile takes over the merge that lived in web/climbs.go, so web only loads the attempt rides and maps the DTOs. The payload carries sections instead of raw elevations, and the browser keeps what needs the canvas: slope bands, labels, distance ticks and colours. official-climb-profile-logic.ts and its tests are gone, replaced by climb/profile_test.go and a page test reading the embedded profile JSON.
Diffstat (limited to 'web/frontend/official-climb-profile-logic.ts')
-rw-r--r--web/frontend/official-climb-profile-logic.ts150
1 files changed, 0 insertions, 150 deletions
diff --git a/web/frontend/official-climb-profile-logic.ts b/web/frontend/official-climb-profile-logic.ts
deleted file mode 100644
index 005e46b..0000000
--- a/web/frontend/official-climb-profile-logic.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import type { ProfileBand, ProfilePoint } from "./types.js";
-
-export interface OfficialProfileSection {
- startDistanceKm: number;
- endDistanceKm: number;
- startElevation: number;
- endElevation: number;
- slopePercent: number;
- band: ProfileBand;
-}
-
-interface ProfileNode {
- distanceM: number;
- elevationM: number;
-}
-
-const profileBandForSlope = (slopePercent: number): ProfileBand => {
- if (slopePercent <= 0) return "downhill";
- if (slopePercent < 3) return "0-3";
- if (slopePercent < 6) return "3-6";
- if (slopePercent < 9) return "6-9";
- return "9-plus";
-};
-
-// 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 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);
- };
-};
-
-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: start.distanceM / 1000,
- endDistanceKm: end.distanceM / 1000,
- startElevation: start.elevationM,
- endElevation: end.elevationM,
- slopePercent,
- band: profileBandForSlope(slopePercent),
- });
- }
- return sections;
-};
-
-// 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 };