aboutsummaryrefslogtreecommitdiff
path: root/web/climbs.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 /web/climbs.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 'web/climbs.go')
-rw-r--r--web/climbs.go81
1 files changed, 21 insertions, 60 deletions
diff --git a/web/climbs.go b/web/climbs.go
index 5cd6db9..02f6eb6 100644
--- a/web/climbs.go
+++ b/web/climbs.go
@@ -2,8 +2,6 @@ package web
import (
"database/sql"
- "github.com/jftuga/geodist"
- "github.com/martinlehoux/biking_home/climb"
"log/slog"
"math"
"net/http"
@@ -12,8 +10,10 @@ import (
"strconv"
"strings"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/biking_home/rideanalysis"
"github.com/martinlehoux/kagamigo/kcore"
)
@@ -21,15 +21,9 @@ func climbDetailURL(id int64) string {
return "/climbs/" + strconv.FormatInt(id, 10)
}
-type routeCandidate struct {
- coords []geodist.Coord
- distance float64
-}
-
type ClimbProfileData struct {
- DistanceKm float64 `json:"distanceKm"`
- Elevations []float64 `json:"elevations"`
- Route [][]float64 `json:"route,omitempty"`
+ Sections []ProfileSectionView `json:"sections"`
+ Route [][]float64 `json:"route,omitempty"`
}
type ClimbAttemptView struct {
RideID int64
@@ -116,15 +110,22 @@ func (s *Server) handleClimb(w http.ResponseWriter, r *http.Request) {
}), w)
}
-// buildClimbProfile computes the median normalized elevation profile from the linked rides, resampling every occurrence onto a shared axis.
+// buildClimbProfile merges the recorded passages of a climb into one chart profile.
func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, aggregate official_climb.Aggregate, attempts []official_climb.Attempt) ClimbProfileData {
- profile := ClimbProfileData{DistanceKm: aggregate.DistanceM / 1000}
- if len(attempts) == 0 {
- return profile
+ profile := rideanalysis.MergeClimbProfile(officialClimb, s.climbAttemptRides(attempts), aggregate, s.officialClimbMatchPolicy(), climb.DefaultProfilePolicy())
+ data := ClimbProfileData{Sections: profileSectionsView(profile.Sections)}
+ if len(profile.Route) > 1 {
+ data.Route = make([][]float64, len(profile.Route))
+ for index, coord := range profile.Route {
+ data.Route[index] = []float64{coord.Lon, coord.Lat}
+ }
}
- matchPolicy := s.officialClimbMatchPolicy()
- elevationSamples := make([][]float64, 0, len(attempts))
- routeCandidates := make([]routeCandidate, 0, len(attempts))
+ return data
+}
+
+// climbAttemptRides loads and parses the rides that recorded the attempts, once per ride.
+func (s *Server) climbAttemptRides(attempts []official_climb.Attempt) []ride.Ride {
+ parsedRides := make([]ride.Ride, 0, len(attempts))
seen := make(map[int64]bool)
for _, attempt := range attempts {
if seen[attempt.RideID] {
@@ -137,52 +138,12 @@ func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, a
}
parsed, err := ride.ParseStoredRide(item)
if err != nil {
- slog.Warn("Failed to parse ride for climb profile", "climb_id", officialClimb.ID, "ride_id", attempt.RideID, "error", err)
+ slog.Warn("Failed to parse ride for climb profile", "ride_id", attempt.RideID, "error", err)
continue
}
- for _, matched := range official_climb.MatchClimbs(parsed.Route, officialClimb, matchPolicy.EndpointRadiusM) {
- segment := climb.Climb{
- Distances: parsed.Distances[matched.StartIndex : matched.EndIndex+1],
- Elevations: parsed.Elevations[matched.StartIndex : matched.EndIndex+1],
- Coords: parsed.Coords[matched.StartIndex : matched.EndIndex+1]}
- if sample := official_climb.NormalizeProfile(segment); len(sample) > 0 {
- elevationSamples = append(elevationSamples, sample)
- }
- routeCandidates = append(routeCandidates, routeCandidate{
- coords: segment.Coords,
- distance: segment.Distances[len(segment.Distances)-1] - segment.Distances[0],
- })
- }
- }
- profile.Elevations = official_climb.MedianProfile(elevationSamples)
- if route := medianRoute(routeCandidates); len(route) > 1 {
- profile.Route = make([][]float64, len(route))
- for i, coord := range route {
- profile.Route[i] = []float64{coord.Lon, coord.Lat}
- }
- }
- return profile
-}
-
-// medianRoute picks the recorded route of the attempt whose distance is the median: full precision, no resampling, robust to one drifted ride.
-func medianRoute(candidates []routeCandidate) []geodist.Coord {
- if len(candidates) == 0 {
- return nil
- }
- distances := make([]float64, len(candidates))
- for i, candidate := range candidates {
- distances[i] = candidate.distance
- }
- median := official_climb.Median(distances)
- best := candidates[0]
- bestDiff := math.Abs(best.distance - median)
- for _, candidate := range candidates[1:] {
- if diff := math.Abs(candidate.distance - median); diff < bestDiff {
- best = candidate
- bestDiff = diff
- }
+ parsedRides = append(parsedRides, parsed)
}
- return best.coords
+ return parsedRides
}
func buildClimbStatsView(aggregate official_climb.Aggregate) ClimbStatsView {