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. --- README.md | 8 +- climb/profile.go | 192 ++++++++++++++++++++++ climb/profile_test.go | 113 +++++++++++++ climb/route.go | 10 ++ rideanalysis/profile.go | 72 ++++++++ rideanalysis/profile_test.go | 56 +++++++ web/climbs.go | 81 +++------ web/climbs_test.go | 56 +++---- web/frontend/climb-profile.ts | 49 ++---- web/frontend/official-climb-profile-logic.test.ts | 102 ------------ web/frontend/official-climb-profile-logic.ts | 150 ----------------- web/frontend/official-climb-profile.ts | 53 ++++-- web/frontend/ride-detail.ts | 2 +- web/frontend/types.d.ts | 21 +++ web/server_test.go | 9 - web/templates.templ | 4 +- web/views.go | 54 ++++-- 17 files changed, 604 insertions(+), 428 deletions(-) create mode 100644 climb/profile.go create mode 100644 climb/profile_test.go create mode 100644 rideanalysis/profile.go create mode 100644 rideanalysis/profile_test.go delete mode 100644 web/frontend/official-climb-profile-logic.test.ts delete mode 100644 web/frontend/official-climb-profile-logic.ts diff --git a/README.md b/README.md index 9e933a7..34ec573 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c Rate limit aware and visible progress. **Climb detection** - Auto detect climbs with difficulty. Save official climbs for rematching. -Climb profile by 100m step. +Adaptive climb profile: drawn steps follow the slope changes. **Mountain pass** - Match climbs with mountain pass from OSM, and display on map. @@ -63,11 +63,11 @@ An OSM way is an ordered list of nodes; its `highway` tag describes the road typ - Go 1.27; SQLite via `mattn/go-sqlite3` - `geo` — coordinate distance, nearest-point, and shared `Interval` primitives; dependency-free -- `climb` — climb detection and scoring. A `Route` is the geometry-only trace (distances, elevations, coords), and `Detect` returns `[]DetectedClimb` (a climb plus its ride-relative start index). Provides slope and Cotacol difficulty scoring. Deliberately decoupled from `ride` +- `climb` — climb detection and scoring. A `Route` is the geometry-only trace (distances, elevations, coords), and `Detect` returns `[]DetectedClimb` (a climb plus its ride-relative start index). Provides slope and Cotacol difficulty scoring, and `ProfileSections` cuts a trace into the chart steps (widest bends first, bounded by a `ProfilePolicy`). Deliberately decoupled from `ride` - `ride` — GPX parsing and the ride value (embeds `climb.Route` for geometry, adding timestamps and telemetry), moving-time metrics, and SQLite persistence for imported ride metadata and versioned computed values - `official_climb` — matches official climbs against a route by endpoint coordinates and the recorded sample of the segment the climb was created from: a candidate passage outside ±50% of that sample's length or elevation gain is not the climb - `mountain_pass` — OSM PBF extraction (`mountain_pass=yes` / `natural=mountain_pass` nodes), pass storage and querying, and crossings/climb matching on `climb.Climb` -- `rideanalysis` — orchestrates a ride into analyzed climbs and mountain-pass crossings, and snapshots its official-climb attempts +- `rideanalysis` — orchestrates a ride into analyzed climbs and mountain-pass crossings, snapshots its official-climb attempts, and merges the recorded passages of an official climb into one chart profile - `strava` — OAuth2 client returning activity metadata and GPX data - `config` — typed YAML configuration and atomic persistence - `web` — HTTP server, OAuth callback, sync orchestration, and templ pages @@ -178,6 +178,7 @@ go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap # allocs ## TODO +- Do not use json tag in output, use json builders? - http://localhost:8080/rides/436: remove train part? - also http://localhost:8080/rides/438 - Garmin GPX: elevation, time, temp, HR, cadence @@ -197,7 +198,6 @@ go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap # allocs ### Official Climb - Keep trace apart from ride to display slope profile, compute cotacol -- Profile - Adaptative to show ramps - Record ride times on official climbs - Destination via Start 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]} 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 +} diff --git a/rideanalysis/profile_test.go b/rideanalysis/profile_test.go new file mode 100644 index 0000000..5fb0772 --- /dev/null +++ b/rideanalysis/profile_test.go @@ -0,0 +1,56 @@ +package rideanalysis_test + +import ( + "testing" + + "github.com/martinlehoux/biking_home/climb" + "github.com/martinlehoux/biking_home/internal/ridetest" + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/biking_home/rideanalysis" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeClimbProfileMediansRecordedPassages(t *testing.T) { + parsed, officialClimb := attemptRideForClimb(t) + officialClimb.ID = 42 + // Same trace 10 m higher: the merge must average the two passages, not pick one. + shifted := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(310), ridetest.Sampling(1000), ridetest.StartTime(attemptRideStart)) + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{officialClimb}, official_climb.DefaultMatchPolicy()) + aggregate := official_climb.AggregateAttempts(rideanalysis.Attempts(1, analysis, parsed)) + + profile := rideanalysis.MergeClimbProfile(officialClimb, []ride.Ride{parsed, shifted}, aggregate, official_climb.DefaultMatchPolicy(), climb.DefaultProfilePolicy()) + + assert.InDelta(t, 1000, profile.DistanceM, 0.01) + require.NotEmpty(t, profile.Sections) + assert.InDelta(t, 305, profile.Sections[0].StartElevationM, 1) + lastSection := profile.Sections[len(profile.Sections)-1] + assert.InDelta(t, 452, lastSection.EndElevationM, 2) + assert.InDelta(t, 1000, lastSection.EndDistanceM, 0.01) +} + +func TestMergeClimbProfileRouteKeepsRecordedPoints(t *testing.T) { + parsed, officialClimb := attemptRideForClimb(t) + officialClimb.ID = 42 + analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{officialClimb}, official_climb.DefaultMatchPolicy()) + aggregate := official_climb.AggregateAttempts(rideanalysis.Attempts(1, analysis, parsed)) + + profile := rideanalysis.MergeClimbProfile(officialClimb, []ride.Ride{parsed}, aggregate, official_climb.DefaultMatchPolicy(), climb.DefaultProfilePolicy()) + + // Full recorded precision: every matched segment point is present, not a fixed resample. + require.Len(t, profile.Route, 2) + assert.InDelta(t, parsed.Coords[0].Lon, profile.Route[0].Lon, 1e-9) + assert.InDelta(t, parsed.Coords[0].Lat, profile.Route[0].Lat, 1e-9) + assert.InDelta(t, parsed.Coords[1].Lon, profile.Route[1].Lon, 1e-9) + assert.InDelta(t, parsed.Coords[1].Lat, profile.Route[1].Lat, 1e-9) +} + +func TestMergeClimbProfileWithoutRides(t *testing.T) { + _, officialClimb := attemptRideForClimb(t) + + profile := rideanalysis.MergeClimbProfile(officialClimb, nil, official_climb.Aggregate{DistanceM: 1000}, official_climb.DefaultMatchPolicy(), climb.DefaultProfilePolicy()) + + assert.Empty(t, profile.Sections) + assert.Nil(t, profile.Route) +} 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 { diff --git a/web/climbs_test.go b/web/climbs_test.go index d79f1d8..97c7111 100644 --- a/web/climbs_test.go +++ b/web/climbs_test.go @@ -2,10 +2,12 @@ package web import ( "database/sql" + "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" + "regexp" "testing" "github.com/martinlehoux/biking_home/internal/ridetest" @@ -34,6 +36,16 @@ func storeRideFixtureFile(t *testing.T, db *sql.DB, rideID int64) { require.NoError(t, err) } +// climbProfileData reads the profile JSON the climb page embeds. +func climbProfileData(t *testing.T, body string) ClimbProfileData { + t.Helper() + match := regexp.MustCompile(`(?s)id="climb-profile-data"[^>]*>(.*?)`).FindStringSubmatch(body) + require.Len(t, match, 2, "climb page must embed its profile JSON") + var profile ClimbProfileData + require.NoError(t, json.Unmarshal([]byte(match[1]), &profile)) + return profile +} + func TestHandleClimbRendersAttemptsAndProfile(t *testing.T) { server, db := newWebTestServer(t) _, climbFixture := testRideForClimb(t) @@ -49,6 +61,13 @@ func TestHandleClimbRendersAttemptsAndProfile(t *testing.T) { body := recorder.Body.String() assert.Contains(t, body, "Col de Test") assert.Contains(t, body, "climb-profile-data") + profile := climbProfileData(t, body) + require.NotEmpty(t, profile.Sections) + assert.InDelta(t, 0, profile.Sections[0].StartKm, 0.01) + assert.InDelta(t, 300, profile.Sections[0].StartElevationM, 1) + lastSection := profile.Sections[len(profile.Sections)-1] + assert.InDelta(t, 1, lastSection.EndKm, 0.01) + assert.InDelta(t, 447, lastSection.EndElevationM, 2) assert.Contains(t, body, "Test Ride") // A ride without a power meter renders a dash, not a fabricated value. assert.Contains(t, body, `-`) @@ -94,40 +113,3 @@ func TestHandleClimbNotFound(t *testing.T) { server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/climbs/999", nil)) assert.Equal(t, http.StatusNotFound, recorder.Code) } - -func TestBuildClimbProfileMediansAcrossRides(t *testing.T) { - server, db := newWebTestServer(t) - _, climbFixture := testRideForClimb(t) - created, err := official_climb.Create(db, climbFixture) - require.NoError(t, err) - rideID := insertTestRide(t, db) - storeRideFixtureFile(t, db, rideID) - parsed, _ := testRideForClimb(t) - analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) - attempts := rideanalysis.Attempts(rideID, analysis, parsed) - - profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) - assert.InDelta(t, 1.0, profile.DistanceKm, 0.01) - require.Len(t, profile.Elevations, 50) - assert.InDelta(t, 300, profile.Elevations[0], 1) - assert.InDelta(t, 447, profile.Elevations[49], 2) -} - -func TestBuildClimbProfileRouteKeepsRecordedPoints(t *testing.T) { - server, db := newWebTestServer(t) - parsed, climbFixture := testRideForClimb(t) - created, err := official_climb.Create(db, climbFixture) - require.NoError(t, err) - rideID := insertTestRide(t, db) - storeRideFixtureFile(t, db, rideID) - analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy()) - attempts := rideanalysis.Attempts(rideID, analysis, parsed) - - profile := server.buildClimbProfile(created, official_climb.AggregateAttempts(attempts), attempts) - require.NotEmpty(t, profile.Route) - // Full recorded precision: every matched segment point is present, not a fixed resample. - assert.InDelta(t, parsed.Coords[0].Lon, profile.Route[0][0], 1e-6) - assert.InDelta(t, parsed.Coords[0].Lat, profile.Route[0][1], 1e-6) - assert.InDelta(t, parsed.Coords[1].Lon, profile.Route[1][0], 1e-6) - assert.InDelta(t, parsed.Coords[1].Lat, profile.Route[1][1], 1e-6) -} diff --git a/web/frontend/climb-profile.ts b/web/frontend/climb-profile.ts index 6051ec1..d06d291 100644 --- a/web/frontend/climb-profile.ts +++ b/web/frontend/climb-profile.ts @@ -1,32 +1,21 @@ -import { drawOfficialSections, officialProfilePlot, resolveOfficialProfileColors } from "./official-climb-profile.js"; -import { officialProfileSections } from "./official-climb-profile-logic.js"; -import type { OfficialProfileSection } from "./official-climb-profile-logic.js"; -import type { ProfilePoint } from "./types.js"; +import { + drawOfficialSections, + officialProfilePlot, + officialProfileSections, + resolveOfficialProfileColors, +} from "./official-climb-profile.js"; +import type { OfficialProfileSection, ProfileSectionPayload } from "./types.js"; interface ClimbProfilePayload { - distanceKm: number; - elevations: number[]; + sections: ProfileSectionPayload[]; } interface ClimbProfileState { canvas: HTMLCanvasElement; - payload: ClimbProfilePayload; sections: OfficialProfileSection[]; hoverOutput: HTMLOutputElement; } -const climbProfilePoints = (payload: ClimbProfilePayload): ProfilePoint[] => - payload.elevations.map((elevationM, index) => ({ - distanceKm: (payload.distanceKm * index) / (payload.elevations.length - 1), - elevationM, - })); - -const climbProfileSections = (payload: ClimbProfilePayload): OfficialProfileSection[] => { - const points = climbProfilePoints(payload); - if (points.length < 2) return []; - return officialProfileSections(points, 0, points.length - 1); -}; - const elevationAtDistance = (sections: OfficialProfileSection[], distanceKm: number): number | null => { for (const section of sections) { if (distanceKm >= section.startDistanceKm && distanceKm <= section.endDistanceKm) { @@ -64,15 +53,13 @@ const drawHoverMarker = (state: ClimbProfileState, distanceKm: number): void => state.hoverOutput.textContent = `${distanceKm.toFixed(2)} km · ${Math.round(elevation)} m`; }; -const clearHover = (state: ClimbProfileState): void => { - drawClimbProfile(state.canvas, state.payload, state.sections); - state.hoverOutput.textContent = ""; +const drawClimbProfile = (state: ClimbProfileState): void => { + drawOfficialSections(state.canvas, state.sections, resolveOfficialProfileColors()); }; -const drawClimbProfile = (canvas: HTMLCanvasElement, payload: ClimbProfilePayload, sections?: OfficialProfileSection[]): void => { - const profileSections = sections ?? climbProfileSections(payload); - if (profileSections.length === 0) return; - drawOfficialSections(canvas, profileSections, resolveOfficialProfileColors()); +const clearHover = (state: ClimbProfileState): void => { + drawClimbProfile(state); + state.hoverOutput.textContent = ""; }; const bindHover = (state: ClimbProfileState): void => { @@ -85,7 +72,7 @@ const bindHover = (state: ClimbProfileState): void => { return; } const distanceKm = plot.minDistanceKm + ((x - plot.left) / (plot.right - plot.left)) * plot.distanceSpan; - drawClimbProfile(state.canvas, state.payload, state.sections); + drawClimbProfile(state); drawHoverMarker(state, distanceKm); }); state.canvas.addEventListener("pointerleave", () => clearHover(state)); @@ -97,13 +84,11 @@ const init = (): void => { const canvas = document.querySelector("[data-climb-profile]"); if (!payloadElement || !canvas) return; const payload = JSON.parse(payloadElement.textContent ?? "null") as ClimbProfilePayload | null; - if (!payload || payload.elevations.length < 2) return; - const sections = climbProfileSections(payload); - if (sections.length === 0) return; + if (!payload || payload.sections.length === 0) return; const hoverOutput = document.querySelector("[data-climb-profile-hover]"); if (!hoverOutput) return; - const state: ClimbProfileState = { canvas, payload, sections, hoverOutput }; - drawClimbProfile(canvas, payload, sections); + const state: ClimbProfileState = { canvas, sections: officialProfileSections(payload.sections), hoverOutput }; + drawClimbProfile(state); bindHover(state); }; diff --git a/web/frontend/official-climb-profile-logic.test.ts b/web/frontend/official-climb-profile-logic.test.ts deleted file mode 100644 index ca8dea2..0000000 --- a/web/frontend/official-climb-profile-logic.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { officialProfileSections, profileBandForSlope } from "./official-climb-profile-logic.ts"; -import type { ProfilePoint } from "./types.js"; - -test("assigns each slope to its color band", () => { - assert.equal(profileBandForSlope(-0.1), "downhill"); - assert.equal(profileBandForSlope(0), "downhill"); - assert.equal(profileBandForSlope(0.1), "0-3"); - assert.equal(profileBandForSlope(2.9), "0-3"); - assert.equal(profileBandForSlope(3), "3-6"); - assert.equal(profileBandForSlope(6), "6-9"); - assert.equal(profileBandForSlope(9), "9-plus"); - assert.equal(profileBandForSlope(12), "9-plus"); -}); - -// buildProfile lays out one recorded point every 50 m along the given [length in km, slope in %] stretches. -const buildProfile = (stretches: [number, number][]): ProfilePoint[] => { - const points: ProfilePoint[] = [{ distanceKm: 0, elevationM: 0 }]; - let distanceKm = 0; - let elevationM = 0; - for (const [lengthKm, slopePercent] of stretches) { - const pointCount = Math.round(lengthKm * 20); - for (let index = 1; index <= pointCount; index++) { - const distanceStepKm = (lengthKm * index) / pointCount; - points.push({ distanceKm: distanceKm + distanceStepKm, elevationM: elevationM + distanceStepKm * 1000 * (slopePercent / 100) }); - } - distanceKm += lengthKm; - elevationM += lengthKm * 1000 * (slopePercent / 100); - } - return points; -}; - -test("keeps a short steep ramp as its own section", () => { - const points = buildProfile([ - [0.3, 4], - [0.3, 12], - [0.3, 4], - ]); - const sections = officialProfileSections(points, 0, points.length - 1); - - assert.equal(sections.length, 3); - assert.ok(Math.abs(sections[1].slopePercent - 12) < 0.01); - assert.equal(sections[1].band, "9-plus"); - assert.equal(Math.round((sections[1].endDistanceKm - sections[1].startDistanceKm) * 1000), 300); - assert.ok(Math.abs(sections[0].slopePercent - 4) < 0.01); - assert.ok(Math.abs(sections[2].slopePercent - 4) < 0.01); -}); - -test("collapses a constant slope into a single section", () => { - const points = buildProfile([[5, 6]]); - const sections = officialProfileSections(points, 0, points.length - 1); - - assert.equal(sections.length, 1); - assert.ok(Math.abs(sections[0].slopePercent - 6) < 0.01); - assert.equal(sections[0].startDistanceKm, 0); - assert.equal(sections[0].endDistanceKm, 5); -}); - -test("reports a descending stretch as downhill", () => { - const points = buildProfile([[2, -5]]); - const sections = officialProfileSections(points, 0, points.length - 1); - - assert.equal(sections.length, 1); - assert.equal(sections[0].band, "downhill"); -}); - -test("keeps a noisy profile within the readable section budget", () => { - const points: ProfilePoint[] = []; - for (let index = 0; index <= 200; index++) { - const distanceKm = index * 0.05; - points.push({ distanceKm, elevationM: distanceKm * 1000 * 0.05 + Math.sin(index) * 4 }); - } - const sections = officialProfileSections(points, 0, points.length - 1); - - assert.ok(sections.length > 10 && sections.length <= 24, `expected 10-24 sections, got ${sections.length}`); - assert.equal(sections[0].startDistanceKm, 0); - assert.equal(sections[sections.length - 1].endDistanceKm, 10); -}); - -test("reads a slice of a longer profile", () => { - const points = buildProfile([ - [1, 20], - [1, 8], - [1, 20], - ]); - const startIndex = points.findIndex((point) => point.distanceKm >= 1); - const endIndex = points.findIndex((point) => point.distanceKm >= 2); - const sections = officialProfileSections(points, startIndex, endIndex); - - assert.equal(sections.length, 1); - assert.equal(Math.round(sections[0].slopePercent), 8); - assert.equal(sections[0].startDistanceKm, points[startIndex].distanceKm); - assert.equal(sections[0].endDistanceKm, points[endIndex].distanceKm); -}); - -test("returns no section for an empty range", () => { - const points = buildProfile([[1, 6]]); - - assert.deepEqual(officialProfileSections(points, 3, 3), []); - assert.deepEqual(officialProfileSections(points, 0, points.length), []); -}); diff --git a/web/frontend/official-climb-profile-logic.ts b/web/frontend/official-climb-profile-logic.ts deleted file mode 100644 index 005e46b..0000000 --- a/web/frontend/official-climb-profile-logic.ts +++ /dev/null @@ -1,150 +0,0 @@ -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 }; diff --git a/web/frontend/official-climb-profile.ts b/web/frontend/official-climb-profile.ts index 7e93cc7..72bc8d2 100644 --- a/web/frontend/official-climb-profile.ts +++ b/web/frontend/official-climb-profile.ts @@ -1,13 +1,18 @@ -import { officialProfileSections } from "./official-climb-profile-logic.js"; -import type { OfficialProfileSection } from "./official-climb-profile-logic.js"; -import type { OfficialProfileColors, RideProfilePoint } from "./types.js"; +import type { + OfficialProfileColors, + OfficialProfileSection, + ProfileBand, + ProfileSectionPayload, + RideProfile, + RideProfileClimb, +} from "./types.js"; export class OfficialClimbProfileController { - readonly points: RideProfilePoint[]; + readonly climbs: RideProfileClimb[]; readonly colors: OfficialProfileColors; readonly cards: HTMLDetailsElement[]; - constructor(points: RideProfilePoint[]) { - this.points = points; + constructor(profile: RideProfile) { + this.climbs = profile.climbs; this.colors = this.resolveColors(); this.cards = [...document.querySelectorAll("[data-official-climb-card]")].map((card) => card as HTMLDetailsElement); for (const card of this.cards) { @@ -24,18 +29,10 @@ export class OfficialClimbProfileController { } drawOfficialProfile(profileCanvas: HTMLCanvasElement): void { - const { points } = this; - const startIndex = Number.parseInt(profileCanvas.dataset.profileStart ?? "", 10); - const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd ?? "", 10); - if ( - !Number.isInteger(startIndex) || - !Number.isInteger(endIndex) || - startIndex < 0 || - endIndex >= points.length || - startIndex >= endIndex - ) - return; - const sections = officialProfileSections(points, startIndex, endIndex); + const climbIndex = Number.parseInt(profileCanvas.dataset.profileClimbIndex ?? "", 10); + const climb = this.climbs.find((candidate) => candidate.index === climbIndex); + if (!climb?.sections) return; + const sections = officialProfileSections(climb.sections); if (sections.length === 0) return; drawOfficialSections(profileCanvas, sections, this.colors); } @@ -76,6 +73,26 @@ export function resolveOfficialProfileColors(): OfficialProfileColors { return colors; } +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"; +}; + +// officialProfileSections turns the sections the server cut out of the trace into the chart shape, +// tagging each step with the band its slope falls in. +export const officialProfileSections = (sections: ProfileSectionPayload[]): OfficialProfileSection[] => + sections.map((section) => ({ + startDistanceKm: section.startKm, + endDistanceKm: section.endKm, + startElevation: section.startElevationM, + endElevation: section.endElevationM, + slopePercent: section.slopePercent, + band: profileBandForSlope(section.slopePercent), + })); + export interface OfficialProfilePlot { left: number; right: number; diff --git a/web/frontend/ride-detail.ts b/web/frontend/ride-detail.ts index dcebbe4..2bd6622 100644 --- a/web/frontend/ride-detail.ts +++ b/web/frontend/ride-detail.ts @@ -75,7 +75,7 @@ export const mountRideDetail = (): void => { passes, colors: mapColors, }); - const officialProfileController = new OfficialClimbProfileController(points); + const officialProfileController = new OfficialClimbProfileController(profile); let boundaryController: RideBoundaryController | undefined; const profileCanvas = new RideProfileCanvas({ canvas: profileCanvasElement, diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts index a933d7d..a97d07d 100644 --- a/web/frontend/types.d.ts +++ b/web/frontend/types.d.ts @@ -2,6 +2,25 @@ export type ProfileBand = "downhill" | "0-3" | "3-6" | "6-9" | "9-plus"; export type BoundaryTarget = "start" | "end"; export type BoundarySource = "profile" | "map"; +// ProfileSectionPayload is one step of a climb profile as the server cuts it. +export interface ProfileSectionPayload { + startKm: number; + endKm: number; + startElevationM: number; + endElevationM: number; + slopePercent: number; +} + +// OfficialProfileSection is that step tagged with the colour band its slope falls in. +export interface OfficialProfileSection { + startDistanceKm: number; + endDistanceKm: number; + startElevation: number; + endElevation: number; + slopePercent: number; + band: ProfileBand; +} + export interface ProfilePoint { distanceKm: number; elevationM: number; @@ -14,6 +33,7 @@ export interface RideProfilePoint extends ProfilePoint { } export interface RideProfileClimb { + index: number; startKm: number; endKm: number; topKm: number; @@ -27,6 +47,7 @@ export interface RideProfileClimb { officialName?: string; startIndex: number; endIndex: number; + sections?: ProfileSectionPayload[]; } export interface RideProfileCrossing { diff --git a/web/server_test.go b/web/server_test.go index c468e4b..24ff581 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -169,7 +169,6 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) { assert.Contains(t, body, `class="ride-detail-main"`) assert.Contains(t, body, ``) assert.Contains(t, body, ``) - assert.NotContains(t, body, `