aboutsummaryrefslogtreecommitdiff
path: root/web/frontend/official-climb-profile-logic.ts
diff options
context:
space:
mode:
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 };