aboutsummaryrefslogtreecommitdiff
path: root/rideanalysis/profile.go
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 /rideanalysis/profile.go
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 'rideanalysis/profile.go')
-rw-r--r--rideanalysis/profile.go72
1 files changed, 72 insertions, 0 deletions
diff --git a/rideanalysis/profile.go b/rideanalysis/profile.go
new file mode 100644
index 0000000..d9fd94f
--- /dev/null
+++ b/rideanalysis/profile.go
@@ -0,0 +1,72 @@
+package rideanalysis
+
+import (
+ "math"
+
+ "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/climb"
+ "github.com/martinlehoux/biking_home/official_climb"
+ "github.com/martinlehoux/biking_home/ride"
+)
+
+// ClimbProfile is the chart profile of one official climb, merged from the rides that recorded it.
+type ClimbProfile struct {
+ DistanceM float64
+ Route []geodist.Coord
+ Sections []climb.ProfileSection
+}
+
+// MergeClimbProfile merges every recorded passage of an official climb into one profile: the median
+// elevation at every step of an axis as long as the climb's aggregate, the recorded route of the
+// median-length passage (full precision, no resampling), and that trace cut into display sections.
+func MergeClimbProfile(officialClimb official_climb.OfficialClimb, rides []ride.Ride, aggregate official_climb.Aggregate, matchPolicy official_climb.MatchPolicy, profilePolicy climb.ProfilePolicy) ClimbProfile {
+ profile := ClimbProfile{DistanceM: aggregate.DistanceM}
+ elevationSamples := make([][]float64, 0, len(rides))
+ passages := make([]climb.Climb, 0, len(rides))
+ for _, parsed := range rides {
+ for _, matched := range official_climb.MatchClimbs(parsed.Route, officialClimb, matchPolicy.EndpointRadiusM) {
+ passage := climb.Climb{Route: parsed.Route.Slice(matched.StartIndex, matched.EndIndex)}
+ if sample := official_climb.NormalizeProfile(passage); len(sample) > 0 {
+ elevationSamples = append(elevationSamples, sample)
+ }
+ passages = append(passages, passage)
+ }
+ }
+ profile.Route = medianPassageRoute(passages)
+ profile.Sections = profileRoute(aggregate.DistanceM, official_climb.MedianProfile(elevationSamples)).ProfileSections(profilePolicy)
+ return profile
+}
+
+// medianPassageRoute picks the recorded route of the passage whose length is the median: robust to
+// one drifted ride.
+func medianPassageRoute(passages []climb.Climb) []geodist.Coord {
+ if len(passages) == 0 {
+ return nil
+ }
+ distances := make([]float64, len(passages))
+ for index, passage := range passages {
+ distances[index] = passage.Distances[len(passage.Distances)-1] - passage.Distances[0]
+ }
+ median := official_climb.Median(distances)
+ best := passages[0]
+ bestDiff := math.Abs(distances[0] - median)
+ for index := 1; index < len(passages); index++ {
+ if diff := math.Abs(distances[index] - median); diff < bestDiff {
+ best = passages[index]
+ bestDiff = diff
+ }
+ }
+ return best.Coords
+}
+
+// profileRoute lays the merged elevation samples on a uniform axis as long as the climb.
+func profileRoute(distanceM float64, elevations []float64) climb.Route {
+ if len(elevations) < 2 {
+ return climb.Route{}
+ }
+ route := climb.Route{Distances: make([]float64, len(elevations)), Elevations: elevations}
+ for index := range elevations {
+ route.Distances[index] = distanceM * float64(index) / float64(len(elevations)-1)
+ }
+ return route
+}