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(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 };