diff options
Diffstat (limited to 'web/frontend/official-climb-profile-logic.ts')
| -rw-r--r-- | web/frontend/official-climb-profile-logic.ts | 150 |
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 }; |
