aboutsummaryrefslogtreecommitdiff
path: root/climb
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-10-18 21:19:36 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-10-18 21:19:36 +0200
commit8a0032d08862dcf28c653bee3fd21052e967e35d (patch)
tree6cb0d8efe6f5c2bf9ce9eb3ef8306faecbe7cfeb /climb
parentd575274d00c8da251df29d5d6a636044673944a3 (diff)
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.
Diffstat (limited to 'climb')
-rw-r--r--climb/profile.go192
-rw-r--r--climb/profile_test.go113
-rw-r--r--climb/route.go10
3 files changed, 315 insertions, 0 deletions
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
+}
diff --git a/climb/profile_test.go b/climb/profile_test.go
new file mode 100644
index 0000000..1342916
--- /dev/null
+++ b/climb/profile_test.go
@@ -0,0 +1,113 @@
+package climb
+
+import (
+ "math"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// stretch describes one [lengthM, slopePercent] stretch of the buildProfile trace.
+type stretch struct {
+ lengthM float64
+ slopePercent float64
+}
+
+// buildProfile lays out one recorded point every 50 m along the given stretches.
+func buildProfile(stretches ...stretch) Route {
+ route := Route{Distances: []float64{0}, Elevations: []float64{0}}
+ distanceM, elevationM := 0.0, 0.0
+ for _, stretch := range stretches {
+ pointCount := int(math.Round(stretch.lengthM / 50))
+ for index := 1; index <= pointCount; index++ {
+ stepM := stretch.lengthM * float64(index) / float64(pointCount)
+ route.Distances = append(route.Distances, distanceM+stepM)
+ route.Elevations = append(route.Elevations, elevationM+stepM*stretch.slopePercent/100)
+ }
+ distanceM += stretch.lengthM
+ elevationM += stretch.lengthM * stretch.slopePercent / 100
+ }
+ return route
+}
+
+func TestProfileSectionsKeepsShortRamp(t *testing.T) {
+ route := buildProfile(stretch{300, 4}, stretch{300, 12}, stretch{300, 4})
+
+ sections := route.ProfileSections(DefaultProfilePolicy())
+
+ require.Len(t, sections, 3)
+ assert.InDelta(t, 4, sections[0].SlopePercent, 0.01)
+ assert.InDelta(t, 12, sections[1].SlopePercent, 0.01)
+ assert.InDelta(t, 4, sections[2].SlopePercent, 0.01)
+ assert.InDelta(t, 300, sections[1].EndDistanceM-sections[1].StartDistanceM, 0.01)
+}
+
+func TestProfileSectionsCollapsesConstantSlope(t *testing.T) {
+ route := buildProfile(stretch{5000, 6})
+
+ sections := route.ProfileSections(DefaultProfilePolicy())
+
+ require.Len(t, sections, 1)
+ assert.InDelta(t, 6, sections[0].SlopePercent, 0.01)
+ assert.Zero(t, sections[0].StartDistanceM)
+ assert.InDelta(t, 5000, sections[0].EndDistanceM, 0.01)
+}
+
+func TestProfileSectionsReportsDescent(t *testing.T) {
+ route := buildProfile(stretch{2000, -5})
+
+ sections := route.ProfileSections(DefaultProfilePolicy())
+
+ require.Len(t, sections, 1)
+ assert.InDelta(t, -5, sections[0].SlopePercent, 0.01)
+}
+
+func TestProfileSectionsHonoursBudget(t *testing.T) {
+ route := Route{}
+ for index := 0; index <= 200; index++ {
+ distanceM := float64(index) * 50
+ route.Distances = append(route.Distances, distanceM)
+ route.Elevations = append(route.Elevations, distanceM*0.05+math.Sin(float64(index))*4)
+ }
+
+ sections := route.ProfileSections(DefaultProfilePolicy())
+
+ assert.Greater(t, len(sections), 10)
+ assert.LessOrEqual(t, len(sections), DefaultProfilePolicy().MaxSections)
+
+ budgeted := route.ProfileSections(ProfilePolicy{MaxSections: 5, MinBendM: 1, BendPerBaseStep: 0.01})
+ assert.LessOrEqual(t, len(budgeted), 5)
+}
+
+func TestProfileSectionsKeepsAbsoluteDistances(t *testing.T) {
+ route := buildProfile(stretch{2000, 8})
+ for index := range route.Distances {
+ route.Distances[index] += 5000
+ }
+
+ sections := route.ProfileSections(DefaultProfilePolicy())
+
+ require.Len(t, sections, 1)
+ assert.InDelta(t, 5000, sections[0].StartDistanceM, 0.01)
+ assert.InDelta(t, 7000, sections[0].EndDistanceM, 0.01)
+}
+
+func TestProfileSectionsReadsRouteSlice(t *testing.T) {
+ route := buildProfile(stretch{1000, 20}, stretch{1000, 8}, stretch{1000, 20})
+ start := 20
+ end := 40
+
+ sections := route.Slice(start, end).ProfileSections(DefaultProfilePolicy())
+
+ require.Len(t, sections, 1)
+ assert.InDelta(t, 8, sections[0].SlopePercent, 0.01)
+ assert.InDelta(t, route.Distances[start], sections[0].StartDistanceM, 0.01)
+ assert.InDelta(t, route.Distances[end], sections[0].EndDistanceM, 0.01)
+}
+
+func TestProfileSectionsRejectsShortRoute(t *testing.T) {
+ assert.Nil(t, Route{}.ProfileSections(DefaultProfilePolicy()))
+ assert.Nil(t, Route{Distances: []float64{0}, Elevations: []float64{0}}.ProfileSections(DefaultProfilePolicy()))
+ assert.Nil(t, Route{Distances: []float64{0, 10}, Elevations: []float64{0}}.ProfileSections(DefaultProfilePolicy()))
+}
diff --git a/climb/route.go b/climb/route.go
index 4ef3465..e341bea 100644
--- a/climb/route.go
+++ b/climb/route.go
@@ -14,6 +14,16 @@ type SegmentSample struct {
ElevationGainM float64
}
+// Slice returns the sub-route covering the given point indices (inclusive). A route without coords
+// (a resampled profile) keeps them empty.
+func (route Route) Slice(start, end int) Route {
+ sliced := Route{Distances: route.Distances[start : end+1], Elevations: route.Elevations[start : end+1]}
+ if len(route.Coords) == len(route.Distances) {
+ sliced.Coords = route.Coords[start : end+1]
+ }
+ return sliced
+}
+
// Sample returns the recorded length and elevation gain of the route slice between start and end (inclusive), counting only upward steps in the gain.
func (route Route) Sample(start, end int) SegmentSample {
sample := SegmentSample{DistanceM: route.Distances[end] - route.Distances[start]}