From 8a0032d08862dcf28c653bee3fd21052e967e35d Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 18 Oct 2026 21:19:36 +0200 Subject: 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. --- climb/profile.go | 192 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 climb/profile.go (limited to 'climb/profile.go') diff --git a/climb/profile.go b/climb/profile.go new file mode 100644 index 0000000..e88b2fa --- /dev/null +++ b/climb/profile.go @@ -0,0 +1,192 @@ +package climb + +import "math" + +// ProfileSection is one step of the displayed elevation profile: the straight stretch between two +// bends of the trace. +type ProfileSection struct { + StartDistanceM float64 + EndDistanceM float64 + StartElevationM float64 + EndElevationM float64 + SlopePercent float64 +} + +// ProfilePolicy tunes how a trace is cut into display sections. +type ProfilePolicy struct { + // MaxSections bounds the drawn steps: the widest bends are kept, the rest is averaged away. + MaxSections int + // MinBendM is the smallest elevation deviation worth its own step. + MinBendM float64 + // BendPerBaseStep raises MinBendM with the sampling step, so a long climb tolerates + // proportionally more smoothing than a short one. + BendPerBaseStep float64 +} + +// DefaultProfilePolicy is the chart policy the web pages draw with. +func DefaultProfilePolicy() ProfilePolicy { + return ProfilePolicy{ + MaxSections: 24, + MinBendM: 1, + BendPerBaseStep: 0.01, + } +} + +// profileBaseStepsM is the sampling ladder: the finest step that keeps a trace under +// profileMaxBaseCells samples, so GPS noise averages out and a ramp still localizes. +var profileBaseStepsM = []float64{25, 50, 100, 200, 500, 1000} + +// profileMaxBaseCells bounds the sampling grid, whatever the trace length. +const profileMaxBaseCells = 120 + +type profileNode struct { + distanceM float64 + elevationM float64 +} + +type profileSplit struct { + firstIndex int + lastIndex int + splitIndex int + deviationM float64 +} + +// ProfileSections cuts the trace into sections whose width follows its bends: a constant stretch +// collapses into one long section, a short steep ramp keeps its own, instead of both being averaged +// into a fixed distance bucket. +func (route Route) ProfileSections(policy ProfilePolicy) []ProfileSection { + if len(route.Distances) != len(route.Elevations) || len(route.Distances) < 2 { + return nil + } + lengthM := route.Distances[len(route.Distances)-1] - route.Distances[0] + if lengthM <= 0 { + return nil + } + baseStepM := profileBaseStepM(lengthM) + nodes := profileNodes(route, baseStepM) + bendM := math.Max(policy.MinBendM, policy.BendPerBaseStep*baseStepM) + return sectionsFromNodes(simplifyNodes(nodes, bendM, policy.MaxSections)) +} + +// profileBaseStepM picks the finest sampling step that keeps the grid under profileMaxBaseCells. +func profileBaseStepM(lengthM float64) float64 { + for _, stepM := range profileBaseStepsM { + if math.Ceil(lengthM/stepM) <= profileMaxBaseCells { + return stepM + } + } + return math.Ceil(lengthM / profileMaxBaseCells) +} + +// profileNodes resamples the trace on a uniform grid, interpolating between recorded points. +func profileNodes(route Route, stepM float64) []profileNode { + startM := route.Distances[0] + endM := route.Distances[len(route.Distances)-1] + elevationAt := elevationInterpolator(route) + nodes := make([]profileNode, 0, int((endM-startM)/stepM)+2) + for distanceM := startM; distanceM < endM; distanceM += stepM { + nodes = append(nodes, profileNode{distanceM: distanceM, elevationM: elevationAt(distanceM)}) + } + return append(nodes, profileNode{distanceM: endM, elevationM: elevationAt(endM)}) +} + +// elevationInterpolator returns the trace elevation at a distance. Callers walk it forward, so the +// point cursor only moves on. +func elevationInterpolator(route Route) func(distanceM float64) float64 { + index := 0 + lastIndex := len(route.Distances) - 1 + return func(distanceM float64) float64 { + for index < lastIndex-1 && route.Distances[index+1] < distanceM { + index++ + } + nextIndex := min(index+1, lastIndex) + spanM := route.Distances[nextIndex] - route.Distances[index] + if spanM <= 0 { + return route.Elevations[index] + } + fraction := (distanceM - route.Distances[index]) / spanM + return route.Elevations[index] + min(max(fraction, 0), 1)*(route.Elevations[nextIndex]-route.Elevations[index]) + } +} + +// chordDeviationM is how far the node sits above the chord joining the two ends of its segment. +func chordDeviationM(first, last, node profileNode) float64 { + spanM := last.distanceM - first.distanceM + if spanM <= 0 { + return node.elevationM - first.elevationM + } + chordElevationM := first.elevationM + ((node.distanceM-first.distanceM)/spanM)*(last.elevationM-first.elevationM) + return node.elevationM - chordElevationM +} + +func bestSplit(nodes []profileNode, firstIndex, lastIndex int) profileSplit { + split := profileSplit{firstIndex: firstIndex, lastIndex: lastIndex, splitIndex: -1} + for index := firstIndex + 1; index < lastIndex; index++ { + deviationM := math.Abs(chordDeviationM(nodes[firstIndex], nodes[lastIndex], nodes[index])) + if deviationM > split.deviationM { + split.deviationM = deviationM + split.splitIndex = index + } + } + return split +} + +// simplifyNodes keeps splitting the segment holding the widest bend until the trace fits the section +// budget or has no bend left above bendM, so the cuts land on the most prominent bends first. +func simplifyNodes(nodes []profileNode, bendM float64, maxSections int) []profileNode { + if len(nodes) <= 2 { + return nodes + } + lastIndex := len(nodes) - 1 + kept := make([]bool, len(nodes)) + kept[0] = true + kept[lastIndex] = true + pending := []profileSplit{bestSplit(nodes, 0, lastIndex)} + sectionCount := 1 + for sectionCount < maxSections { + widest := -1 + for index, split := range pending { + if split.splitIndex == -1 { + continue + } + if widest == -1 || split.deviationM > pending[widest].deviationM { + widest = index + } + } + if widest == -1 || pending[widest].deviationM <= bendM { + break + } + split := pending[widest] + pending = append(pending[:widest], pending[widest+1:]...) + kept[split.splitIndex] = true + sectionCount++ + pending = append(pending, bestSplit(nodes, split.firstIndex, split.splitIndex), bestSplit(nodes, split.splitIndex, split.lastIndex)) + } + simplified := make([]profileNode, 0, sectionCount+1) + for index, node := range nodes { + if kept[index] { + simplified = append(simplified, node) + } + } + return simplified +} + +func sectionsFromNodes(nodes []profileNode) []ProfileSection { + sections := make([]ProfileSection, 0, len(nodes)-1) + for index := 1; index < len(nodes); index++ { + start := nodes[index-1] + end := nodes[index] + spanM := end.distanceM - start.distanceM + if spanM <= 0 { + continue + } + sections = append(sections, ProfileSection{ + StartDistanceM: start.distanceM, + EndDistanceM: end.distanceM, + StartElevationM: start.elevationM, + EndElevationM: end.elevationM, + SlopePercent: (end.elevationM - start.elevationM) / spanM * 100, + }) + } + return sections +} -- cgit v1.3