1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
|
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 };
|