aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/climb-backfill.go81
-rw-r--r--web/climbs.go398
-rw-r--r--web/climbs_test.go97
-rw-r--r--web/frontend/climb-map.ts65
-rw-r--r--web/frontend/climb-profile.ts110
-rw-r--r--web/frontend/official-climb-profile.ts413
-rw-r--r--web/official-climbs.go60
-rw-r--r--web/official-climbs_test.go81
-rw-r--r--web/rides.go1
-rw-r--r--web/server.go4
-rw-r--r--web/templates.templ88
-rw-r--r--web/views.go6
12 files changed, 1215 insertions, 189 deletions
diff --git a/web/climb-backfill.go b/web/climb-backfill.go
new file mode 100644
index 0000000..13a8c02
--- /dev/null
+++ b/web/climb-backfill.go
@@ -0,0 +1,81 @@
+package web
+
+import (
+ "database/sql"
+ "log/slog"
+
+ "github.com/martinlehoux/biking_home/config"
+ "github.com/martinlehoux/biking_home/mountain_pass"
+ "github.com/martinlehoux/biking_home/official_climb"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/biking_home/rideanalysis"
+ "github.com/martinlehoux/kagamigo/kcore"
+ "github.com/schollz/progressbar/v3"
+)
+
+// BackfillOfficialClimbAttempts parses every route-bearing ride, matches its
+// official climbs, and persists the resulting attempts. An optional reporter
+// is invoked with progress for CLI rendering.
+func BackfillOfficialClimbAttempts(db *sql.DB, configPath string, reporters ...func(current, total int)) (int, error) {
+ appConfig, err := config.Load(configPath)
+ if err != nil {
+ return 0, err
+ }
+ matchPolicy := official_climb.DefaultMatchPolicy()
+ matchPolicy.EndpointRadiusM = appConfig.OfficialClimb.MatchRadiusM
+ items, err := ride.List(db)
+ if err != nil {
+ return 0, err
+ }
+ officialClimbs, err := official_climb.List(db)
+ if err != nil {
+ return 0, err
+ }
+ var bar *progressbar.ProgressBar
+ report := func(int, int) {}
+ if len(reporters) > 0 {
+ report = reporters[0]
+ }
+ backfilled := 0
+ for i, item := range items {
+ report(i+1, len(items))
+ if item.FitPath == "" && item.GPXPath == "" {
+ continue
+ }
+ parsed, err := ride.ParseStoredRide(item)
+ if err != nil {
+ slog.Warn("Failed to parse ride for climb backfill", "ride_id", item.ID, "error", err)
+ continue
+ }
+ passes, err := mountain_pass.LoadMountainPassesAroundRide(db, parsed.Coords, rideDetailMountainPassMarginM)
+ if err != nil {
+ slog.Warn("Failed to load passes for climb backfill", "ride_id", item.ID, "error", err)
+ }
+ analysis := rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy)
+ attempts := attemptsFromAnalysis(item.ID, analysis, parsed)
+ if err := official_climb.ReplaceRideAttempts(db, item.ID, attempts); err != nil {
+ slog.Warn("Failed to record climb attempts", "ride_id", item.ID, "error", err)
+ continue
+ }
+ backfilled++
+ }
+ if bar != nil {
+ bar.Finish()
+ }
+ return backfilled, nil
+}
+
+// BackfillOfficialClimbAttemptsCmd runs the climb attempt backfill from the CLI.
+func BackfillOfficialClimbAttemptsCmd(db *sql.DB, configPath string) {
+ var bar *progressbar.ProgressBar
+ count, err := BackfillOfficialClimbAttempts(db, configPath, func(current, total int) {
+ if bar == nil && mountain_pass.ProgressBarEnabled() {
+ bar = progressbar.Default(int64(total), "Backfilling climb attempts")
+ }
+ if bar != nil {
+ bar.Set(current)
+ }
+ })
+ kcore.Expect(err, "failed to backfill climb attempts")
+ slog.Info("Backfilled climb attempts", "rides", count)
+}
diff --git a/web/climbs.go b/web/climbs.go
new file mode 100644
index 0000000..6859545
--- /dev/null
+++ b/web/climbs.go
@@ -0,0 +1,398 @@
+package web
+
+import (
+ "database/sql"
+ "github.com/jftuga/geodist"
+ "github.com/martinlehoux/biking_home/climb"
+ "log/slog"
+ "math"
+ "net/http"
+ "net/url"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/martinlehoux/biking_home/official_climb"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/kagamigo/kcore"
+)
+
+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"`
+}
+type ClimbAttemptView struct {
+ RideID int64
+ RideName string
+ Date string
+ Duration string
+ Distance string
+ Cotacol string
+}
+
+type ClimbStatsView struct {
+ Attempts string
+ Distance string
+ Slope string
+ Cotacol string
+ Category string
+ BestTime string
+ ElevationG string
+}
+
+type ClimbDetailPageData struct {
+ Climb official_climb.OfficialClimb
+ Stats ClimbStatsView
+ Attempts []ClimbAttemptView
+ Profile ClimbProfileData
+ Endpoints ClimbMapEndpoints
+}
+
+type ClimbMapEndpoints struct {
+ Start []float64 `json:"start"`
+ End []float64 `json:"end"`
+ Name string `json:"name"`
+}
+
+func (s *Server) handleClimb(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil || id <= 0 {
+ http.NotFound(w, r)
+ return
+ }
+ climb, found, err := official_climb.GetByID(s.db, id)
+ if err != nil {
+ slog.Error("Failed to load official climb", "climb_id", id, "error", err)
+ http.Error(w, "failed to load climb", http.StatusInternalServerError)
+ return
+ }
+ if !found {
+ http.NotFound(w, r)
+ return
+ }
+ attempts, err := official_climb.AttemptsForClimb(s.db, id)
+ if err != nil {
+ slog.Error("Failed to load climb attempts", "climb_id", id, "error", err)
+ http.Error(w, "failed to load climb attempts", http.StatusInternalServerError)
+ return
+ }
+ aggregate := official_climb.AggregateAttempts(attempts)
+ profile := s.buildClimbProfile(climb, aggregate, attempts)
+ kcore.RenderPage(r.Context(), ClimbDetailPage(ClimbDetailPageData{
+ Climb: climb,
+ Stats: buildClimbStatsView(aggregate),
+ Attempts: buildClimbAttemptViews(s.db, attempts),
+ Profile: profile,
+ Endpoints: ClimbMapEndpoints{
+ Start: []float64{climb.StartCoord.Lat, climb.StartCoord.Lon},
+ End: []float64{climb.EndCoord.Lat, climb.EndCoord.Lon},
+ Name: climb.Name,
+ },
+ }), w)
+}
+
+// buildClimbProfile computes the median normalized elevation profile from the
+// linked rides: each ride is parsed, its climb occurrences matched, and every
+// occurrence resampled onto the shared normalized axis.
+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
+ }
+ matchPolicy := s.officialClimbMatchPolicy()
+ elevationSamples := make([][]float64, 0, len(attempts))
+ routeCandidates := make([]routeCandidate, 0, len(attempts))
+ seen := make(map[int64]bool)
+ for _, attempt := range attempts {
+ if seen[attempt.RideID] {
+ continue
+ }
+ seen[attempt.RideID] = true
+ item, found, err := ride.GetByID(s.db, attempt.RideID)
+ if err != nil || !found {
+ continue
+ }
+ 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)
+ continue
+ }
+ for _, matched := range official_climb.MatchClimbs(parsed.Coords, officialClimb, matchPolicy.EndpointRadiusM) {
+ segment := climb.Climb{Route: climb.Route{
+ 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 across occurrences: full recorded precision, no resampling, and
+// robust to one GPS-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
+ }
+ }
+ return best.coords
+}
+
+func buildClimbStatsView(aggregate official_climb.Aggregate) ClimbStatsView {
+ stats := ClimbStatsView{
+ Attempts: strconv.Itoa(aggregate.Attempts),
+ Distance: formatDistance(aggregate.DistanceM),
+ Slope: formatSlope(aggregate.SlopePercent),
+ Cotacol: formatCotacol(aggregate.Cotacol),
+ Category: aggregate.Category.String(),
+ ElevationG: formatElevation(aggregate.ElevationGainM),
+ BestTime: "-",
+ }
+ if aggregate.HasBest {
+ stats.BestTime = formatDuration(int64(aggregate.BestDuration.Seconds()))
+ }
+ return stats
+}
+
+func buildClimbAttemptViews(db *sql.DB, attempts []official_climb.Attempt) []ClimbAttemptView {
+ names := make(map[int64]string)
+ views := make([]ClimbAttemptView, len(attempts))
+ for i, attempt := range attempts {
+ name, known := names[attempt.RideID]
+ if !known {
+ if item, found, err := ride.GetByID(db, attempt.RideID); err == nil && found {
+ name = item.Name
+ }
+ names[attempt.RideID] = name
+ }
+ views[i] = ClimbAttemptView{
+ RideID: attempt.RideID,
+ RideName: name,
+ Date: formatRideDate(attempt.StartedAt),
+ Duration: formatDuration(int64(attempt.EndedAt.Sub(attempt.StartedAt).Seconds())),
+ Distance: formatDistance(attempt.DistanceM),
+ Cotacol: formatCotacol(attempt.Cotacol),
+ }
+ }
+ return views
+}
+
+func climbsListURL() string {
+ return "/climbs"
+}
+
+type ClimbSort struct {
+ Column string
+ Descending bool
+}
+
+const (
+ climbSortName = "name"
+ climbSortCategory = "category"
+ climbSortAttempts = "attempts"
+ climbSortDistance = "distance"
+ climbSortSlope = "slope"
+ climbSortCotacol = "cotacol"
+ climbSortBestTime = "best_time"
+)
+
+var climbSortColumns = map[string]bool{
+ climbSortName: true,
+ climbSortCategory: true,
+ climbSortAttempts: true,
+ climbSortDistance: true,
+ climbSortSlope: true,
+ climbSortCotacol: true,
+ climbSortBestTime: true,
+}
+
+func parseClimbSort(query url.Values) ClimbSort {
+ column := query.Get("sort")
+ if !climbSortColumns[column] {
+ column = climbSortCotacol
+ }
+ direction := query.Get("dir")
+ return ClimbSort{Column: column, Descending: direction != "asc"}
+}
+
+func sortListedClimbs(climbs []official_climb.ListedClimb, sort ClimbSort) []official_climb.ListedClimb {
+ ordered := make([]official_climb.ListedClimb, len(climbs))
+ copy(ordered, climbs)
+ slices.SortStableFunc(ordered, func(a, b official_climb.ListedClimb) int {
+ var cmp int
+ switch sort.Column {
+ case climbSortName:
+ cmp = strings.Compare(a.Name, b.Name)
+ case climbSortCategory:
+ cmp = int(climb.CategoryOf(a.Cotacol)) - int(climb.CategoryOf(b.Cotacol))
+ case climbSortAttempts:
+ cmp = a.Attempts - b.Attempts
+ case climbSortDistance:
+ cmp = compareFloat(a.DistanceM, b.DistanceM)
+ case climbSortSlope:
+ cmp = compareFloat(a.SlopePercent, b.SlopePercent)
+ case climbSortBestTime:
+ cmp = compareFloat(bestTimeOrInfinity(a), bestTimeOrInfinity(b))
+ default:
+ cmp = compareFloat(a.Cotacol, b.Cotacol)
+ }
+ if sort.Descending {
+ return -cmp
+ }
+ return cmp
+ })
+ return ordered
+}
+
+func bestTimeOrInfinity(climb official_climb.ListedClimb) float64 {
+ if !climb.HasBest {
+ return math.Inf(1)
+ }
+ return climb.BestDurationS
+}
+
+func compareFloat(a, b float64) int {
+ switch {
+ case a < b:
+ return -1
+ case a > b:
+ return 1
+ default:
+ return 0
+ }
+}
+
+type ClimbListView struct {
+ Climb official_climb.ListedClimb
+ Category string
+ Distance string
+ Slope string
+ Cotacol string
+ BestTime string
+ URL string
+}
+
+type ClimbsPageData struct {
+ Items []ClimbListView
+ Headers []RideSortHeader
+}
+
+func buildClimbListViews(climbs []official_climb.ListedClimb) []ClimbListView {
+ views := make([]ClimbListView, len(climbs))
+ for i, listed := range climbs {
+ bestTime := "-"
+ if listed.HasBest {
+ bestTime = formatDuration(int64(listed.BestDurationS))
+ }
+ views[i] = ClimbListView{
+ Climb: listed,
+ Category: listed.Category(),
+ Distance: formatDistance(listed.DistanceM),
+ Slope: formatSlope(listed.SlopePercent),
+ Cotacol: formatCotacol(listed.Cotacol),
+ BestTime: bestTime,
+ URL: climbDetailURL(listed.ID),
+ }
+ }
+ return views
+}
+
+func (s *Server) handleClimbs(w http.ResponseWriter, r *http.Request) {
+ sort := parseClimbSort(r.URL.Query())
+ climbs, err := official_climb.ListWithAggregates(s.db)
+ if err != nil {
+ slog.Error("Failed to load climbs", "error", err)
+ http.Error(w, "failed to load climbs", http.StatusInternalServerError)
+ return
+ }
+ climbs = sortListedClimbs(climbs, sort)
+ kcore.RenderPage(r.Context(), ClimbsPage(ClimbsPageData{
+ Items: buildClimbListViews(climbs),
+ Headers: climbSortHeaders(sort),
+ }), w)
+}
+
+func climbSortHeaders(current ClimbSort) []RideSortHeader {
+ columns := []struct {
+ key string
+ label string
+ class string
+ }{
+ {key: climbSortName, label: "Climb"},
+ {key: climbSortCategory, label: "Category"},
+ {key: climbSortAttempts, label: "Attempts", class: "numeric"},
+ {key: climbSortDistance, label: "Distance", class: "numeric"},
+ {key: climbSortSlope, label: "Avg slope", class: "numeric"},
+ {key: climbSortCotacol, label: "Cotacol", class: "numeric"},
+ {key: climbSortBestTime, label: "Best time", class: "numeric"},
+ }
+ headers := make([]RideSortHeader, 0, len(columns))
+ for _, column := range columns {
+ descending := false
+ active := current.Column == column.key
+ if active {
+ descending = !current.Descending
+ }
+ query := url.Values{}
+ query.Set("sort", column.key)
+ query.Set("dir", rideSortDirection(RideSort{Column: column.key, Descending: descending}))
+ headerClass := column.class
+ ariaSort := "none"
+ indicator := ""
+ if active {
+ headerClass += " active"
+ if current.Descending {
+ ariaSort = "descending"
+ indicator = "↓"
+ } else {
+ ariaSort = "ascending"
+ indicator = "↑"
+ }
+ }
+ headers = append(headers, RideSortHeader{
+ Label: column.label,
+ Class: headerClass,
+ URL: climbsListURL() + "?" + query.Encode(),
+ AriaSort: ariaSort,
+ Indicator: indicator,
+ })
+ }
+ return headers
+}
diff --git a/web/climbs_test.go b/web/climbs_test.go
new file mode 100644
index 0000000..fbaf527
--- /dev/null
+++ b/web/climbs_test.go
@@ -0,0 +1,97 @@
+package web
+
+import (
+ "database/sql"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "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 recordFixtureAttempts(t *testing.T, server *Server, rideID int64, climb official_climb.OfficialClimb) {
+ t.Helper()
+ parsed, _ := testRideForClimb(t)
+ analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{climb}, official_climb.DefaultMatchPolicy())
+ server.recordOfficialClimbAttempts(rideID, parsed, analysis)
+}
+func storeRideFixtureFile(t *testing.T, db *sql.DB, rideID int64) {
+ t.Helper()
+ parsed, _ := testRideForClimb(t)
+ fitPath := filepath.Join(t.TempDir(), "ride.fit")
+ fitFile, err := os.Create(fitPath)
+ require.NoError(t, err)
+ require.NoError(t, ride.EncodeFIT(parsed, fitFile))
+ require.NoError(t, fitFile.Close())
+ _, err = db.Exec("UPDATE rides SET fit_path = ? WHERE id = ?", fitPath, rideID)
+ require.NoError(t, err)
+}
+
+func TestHandleClimbRendersAttemptsAndProfile(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)
+ recordFixtureAttempts(t, server, rideID, created)
+
+ recorder := httptest.NewRecorder()
+ server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, climbDetailURL(created.ID), nil))
+ require.Equal(t, http.StatusOK, recorder.Code)
+ body := recorder.Body.String()
+ assert.Contains(t, body, "Col de Test")
+ assert.Contains(t, body, "climb-profile-data")
+ assert.Contains(t, body, "Test Ride")
+}
+
+func TestHandleClimbNotFound(t *testing.T) {
+ server, _ := newWebTestServer(t)
+ recorder := httptest.NewRecorder()
+ 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 := attemptsFromAnalysis(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 := attemptsFromAnalysis(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 50-step 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-map.ts b/web/frontend/climb-map.ts
new file mode 100644
index 0000000..909614d
--- /dev/null
+++ b/web/frontend/climb-map.ts
@@ -0,0 +1,65 @@
+import type { Map as LeafletMap } from "leaflet";
+
+interface ClimbMapPayload {
+ distanceKm: number;
+ elevations: number[];
+ route?: [number, number][];
+}
+
+interface ClimbMapEndpoints {
+ start: [number, number];
+ end: [number, number];
+ name: string;
+}
+
+const mountClimbMap = (): void => {
+ const element = document.getElementById("climb-map");
+ const endpointsElement = document.getElementById("climb-endpoints");
+ const profileElement = document.getElementById("climb-profile-data");
+ const leaflet = window.L;
+ if (!element || !endpointsElement || !profileElement || !leaflet) return;
+ const endpoints = JSON.parse(endpointsElement.textContent ?? "null") as ClimbMapEndpoints | null;
+ const profile = JSON.parse(profileElement.textContent ?? "null") as ClimbMapPayload | null;
+ if (!endpoints) return;
+ const route = (profile?.route ?? []).map(([lon, lat]) => [lat, lon] as [number, number]);
+ const line: [number, number][] = route.length > 1 ? route : [endpoints.start, endpoints.end];
+
+ const map: LeafletMap = leaflet.map(element);
+ leaflet
+ .tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ maxZoom: 19,
+ attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
+ })
+ .addTo(map);
+
+ const startIcon = leaflet.divIcon({
+ className: "ride-map-climb-start-icon",
+ html: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="6" fill="currentColor"/></svg>',
+ iconSize: [26, 26],
+ iconAnchor: [13, 13],
+ });
+ const endIcon = leaflet.divIcon({
+ className: "ride-map-climb-end-icon",
+ html: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3v18" stroke="currentColor" stroke-width="2"/><path d="M6 4h13v8H6Z" fill="currentColor"/><path d="M6 4h3.25v4H6ZM12.5 4h3.25v4H12.5ZM9.25 8h3.25v4H9.25ZM15.75 8H19v4h-3.25Z" fill="var(--color-forest)"/></svg>',
+ iconSize: [26, 26],
+ iconAnchor: [13, 13],
+ });
+ leaflet
+ .polyline(line, {
+ color: "var(--color-climb-route)",
+ weight: 4,
+ opacity: 0.9,
+ })
+ .addTo(map);
+ leaflet
+ .marker(endpoints.start, { icon: startIcon, alt: `${endpoints.name} start` })
+ .addTo(map)
+ .bindTooltip("Start");
+ leaflet
+ .marker(endpoints.end, { icon: endIcon, alt: `${endpoints.name} end` })
+ .addTo(map)
+ .bindTooltip("End");
+ map.fitBounds(leaflet.latLngBounds(line), { padding: [40, 40], maxZoom: 16 });
+};
+
+mountClimbMap();
diff --git a/web/frontend/climb-profile.ts b/web/frontend/climb-profile.ts
new file mode 100644
index 0000000..8910349
--- /dev/null
+++ b/web/frontend/climb-profile.ts
@@ -0,0 +1,110 @@
+import { drawOfficialSections, officialProfilePlot, resolveOfficialProfileColors } from "./official-climb-profile.js";
+import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js";
+import type { OfficialProfileSection } from "./official-climb-profile-logic.js";
+import type { ProfilePoint } from "./types.js";
+
+interface ClimbProfilePayload {
+ distanceKm: number;
+ elevations: number[];
+}
+
+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, displayStepForLength(payload.distanceKm * 1000));
+};
+
+const elevationAtDistance = (sections: OfficialProfileSection[], distanceKm: number): number | null => {
+ for (const section of sections) {
+ if (distanceKm >= section.startDistanceKm && distanceKm <= section.endDistanceKm) {
+ const span = section.endDistanceKm - section.startDistanceKm;
+ const fraction = span > 0 ? (distanceKm - section.startDistanceKm) / span : 0;
+ return section.startElevation + fraction * (section.endElevation - section.startElevation);
+ }
+ }
+ return null;
+};
+
+const drawHoverMarker = (state: ClimbProfileState, distanceKm: number): void => {
+ const colors = resolveOfficialProfileColors();
+ const rect = state.canvas.getBoundingClientRect();
+ const context = state.canvas.getContext("2d");
+ if (!context) return;
+ const plot = officialProfilePlot(state.sections, rect.width, rect.height);
+ const elevationSpan = Math.max(plot.maxElevation - plot.minElevation, 1);
+ const x = plot.left + ((distanceKm - plot.minDistanceKm) / plot.distanceSpan) * (plot.right - plot.left);
+ const elevation = elevationAtDistance(state.sections, distanceKm);
+ if (elevation === null) return;
+ const y = plot.bottom - ((elevation - plot.minElevation) / elevationSpan) * (plot.bottom - plot.top);
+ context.strokeStyle = colors.subtle;
+ context.lineWidth = 1;
+ context.setLineDash([3, 3]);
+ context.beginPath();
+ context.moveTo(x, plot.top);
+ context.lineTo(x, plot.bottom);
+ context.stroke();
+ context.setLineDash([]);
+ context.fillStyle = colors.accent;
+ context.beginPath();
+ context.arc(x, y, 5, 0, 2 * Math.PI);
+ context.fill();
+ 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 = (canvas: HTMLCanvasElement, payload: ClimbProfilePayload, sections?: OfficialProfileSection[]): void => {
+ const profileSections = sections ?? climbProfileSections(payload);
+ if (profileSections.length === 0) return;
+ drawOfficialSections(canvas, profileSections, resolveOfficialProfileColors());
+};
+
+const bindHover = (state: ClimbProfileState): void => {
+ state.canvas.addEventListener("pointermove", (event: PointerEvent) => {
+ const rect = state.canvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ const plot = officialProfilePlot(state.sections, rect.width, rect.height);
+ if (x < plot.left || x > plot.right) {
+ clearHover(state);
+ return;
+ }
+ const distanceKm = plot.minDistanceKm + ((x - plot.left) / (plot.right - plot.left)) * plot.distanceSpan;
+ drawClimbProfile(state.canvas, state.payload, state.sections);
+ drawHoverMarker(state, distanceKm);
+ });
+ state.canvas.addEventListener("pointerleave", () => clearHover(state));
+ state.canvas.addEventListener("pointercancel", () => clearHover(state));
+};
+
+const init = (): void => {
+ const payloadElement = document.getElementById("climb-profile-data");
+ const canvas = document.querySelector<HTMLCanvasElement>("[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;
+ const hoverOutput = document.querySelector<HTMLOutputElement>("[data-climb-profile-hover]");
+ if (!hoverOutput) return;
+ const state: ClimbProfileState = { canvas, payload, sections, hoverOutput };
+ drawClimbProfile(canvas, payload, sections);
+ bindHover(state);
+};
+
+init();
diff --git a/web/frontend/official-climb-profile.ts b/web/frontend/official-climb-profile.ts
index bc851f5..ac4d591 100644
--- a/web/frontend/official-climb-profile.ts
+++ b/web/frontend/official-climb-profile.ts
@@ -1,6 +1,6 @@
import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js";
+import type { OfficialProfileSection } from "./official-climb-profile-logic.js";
import type { OfficialProfileColors, RideProfilePoint } from "./types.js";
-
export class OfficialClimbProfileController {
readonly points: RideProfilePoint[];
readonly colors: OfficialProfileColors;
@@ -12,43 +12,19 @@ export class OfficialClimbProfileController {
this.cards = [...document.querySelectorAll("[data-official-climb-card]")].map((card) => card as HTMLDetailsElement);
for (const card of this.cards) {
card.addEventListener("toggle", () => {
- if (!card.open) return;
- for (const other of this.cards) {
- if (other !== card) other.open = false;
+ if (card.open) {
+ this.redrawOpen();
}
- const profileCanvas = card.querySelector<HTMLCanvasElement>("[data-official-profile]");
- if (profileCanvas) this.drawOfficialProfile(profileCanvas);
});
}
}
resolveColors(): OfficialProfileColors {
- const colorProbe = document.createElement("span");
- colorProbe.hidden = true;
- document.body.append(colorProbe);
- const resolveColor = (name: string): string => {
- colorProbe.style.color = `var(${name})`;
- return getComputedStyle(colorProbe).color;
- };
- const colors = {
- downhill: resolveColor("--color-profile-downhill"),
- "0-3": resolveColor("--color-profile-0-3"),
- "3-6": resolveColor("--color-profile-3-6"),
- "6-9": resolveColor("--color-profile-6-9"),
- "9-plus": resolveColor("--color-profile-9-plus"),
- plotSurface: resolveColor("--color-plot-surface"),
- grid: resolveColor("--color-plot-grid"),
- xGrid: resolveColor("--color-profile-grid-x"),
- yGrid: resolveColor("--color-profile-grid-y"),
- subtle: resolveColor("--color-subtle"),
- accent: resolveColor("--color-accent"),
- };
- colorProbe.remove();
- return colors;
+ return resolveOfficialProfileColors();
}
drawOfficialProfile(profileCanvas: HTMLCanvasElement): void {
- const { points, colors } = this;
+ const { points } = this;
const startIndex = Number.parseInt(profileCanvas.dataset.profileStart ?? "", 10);
const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd ?? "", 10);
if (
@@ -59,175 +35,240 @@ export class OfficialClimbProfileController {
startIndex >= endIndex
)
return;
- const rect = profileCanvas.getBoundingClientRect();
- if (rect.width === 0 || rect.height === 0) return;
- const context = profileCanvas.getContext("2d");
- if (!context) return;
- const ratio = window.devicePixelRatio || 1;
- profileCanvas.width = Math.floor(rect.width * ratio);
- profileCanvas.height = Math.floor(rect.height * ratio);
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
- const plot = { left: 10, right: Math.max(11, rect.width - 44), top: 16, bottom: rect.height - 34 };
const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000;
const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM));
if (sections.length === 0) return;
- let minElevation = sections[0].startElevation;
- let maxElevation = minElevation;
- for (const section of sections) {
- minElevation = Math.min(minElevation, section.startElevation, section.endElevation);
- maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation);
- }
- const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8);
- minElevation -= elevationPadding;
- maxElevation += elevationPadding;
- const minDistance = sections[0].startDistanceKm;
- const maxDistance = sections[sections.length - 1].endDistanceKm;
- const distanceSpan = Math.max(maxDistance - minDistance, 0.1);
- const elevationSpan = Math.max(maxElevation - minElevation, 1);
- const xForDistance = (distanceKm: number) => plot.left + ((distanceKm - minDistance) / distanceSpan) * (plot.right - plot.left);
- const yForElevation = (elevationM: number) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (plot.bottom - plot.top);
- context.clearRect(0, 0, rect.width, rect.height);
- context.fillStyle = colors.plotSurface;
- context.fillRect(0, 0, rect.width, rect.height);
- context.font = "9px system-ui, sans-serif";
- context.lineWidth = 1;
- context.setLineDash([4, 4]);
- context.strokeStyle = colors.yGrid;
- const yGridElevations: number[] = [];
- for (let elevationM = Math.ceil(minElevation / 100) * 100; elevationM <= maxElevation; elevationM += 100) {
- yGridElevations.push(elevationM);
- }
- context.save();
- context.beginPath();
- context.moveTo(plot.left, plot.bottom);
- context.lineTo(plot.left, yForElevation(sections[0].startElevation));
- for (const section of sections) {
- context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation));
- }
- context.lineTo(plot.right, plot.bottom);
- context.closePath();
- context.clip();
- for (const elevationM of yGridElevations) {
- const y = yForElevation(elevationM);
- context.beginPath();
- context.moveTo(plot.left, y);
- context.lineTo(plot.right, y);
- context.stroke();
- }
- context.restore();
- context.fillStyle = colors.yGrid;
- context.textAlign = "left";
- context.textBaseline = "middle";
- for (const elevationM of yGridElevations) {
- context.fillText(`${elevationM} m`, plot.right + 6, yForElevation(elevationM));
+ drawOfficialSections(profileCanvas, sections, this.colors);
+ }
+
+ redrawOpen() {
+ const profileCanvases = document.querySelectorAll<HTMLCanvasElement>("[data-official-profile]");
+ if (profileCanvases.length === 0) return;
+ for (const profileCanvas of profileCanvases) {
+ if (profileCanvas.closest("details")?.open) {
+ this.drawOfficialProfile(profileCanvas);
+ }
}
- context.setLineDash([]);
- context.strokeStyle = colors.grid;
+ }
+}
+
+export function resolveOfficialProfileColors(): OfficialProfileColors {
+ const colorProbe = document.createElement("span");
+ colorProbe.hidden = true;
+ document.body.append(colorProbe);
+ const resolveColor = (name: string): string => {
+ colorProbe.style.color = `var(${name})`;
+ return getComputedStyle(colorProbe).color;
+ };
+ const colors = {
+ downhill: resolveColor("--color-profile-downhill"),
+ "0-3": resolveColor("--color-profile-0-3"),
+ "3-6": resolveColor("--color-profile-3-6"),
+ "6-9": resolveColor("--color-profile-6-9"),
+ "9-plus": resolveColor("--color-profile-9-plus"),
+ plotSurface: resolveColor("--color-plot-surface"),
+ grid: resolveColor("--color-plot-grid"),
+ xGrid: resolveColor("--color-profile-grid-x"),
+ yGrid: resolveColor("--color-profile-grid-y"),
+ subtle: resolveColor("--color-subtle"),
+ accent: resolveColor("--color-accent"),
+ };
+ colorProbe.remove();
+ return colors;
+}
+
+export interface OfficialProfilePlot {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+ minElevation: number;
+ maxElevation: number;
+ minDistanceKm: number;
+ maxDistanceKm: number;
+ distanceSpan: number;
+}
+
+export const officialProfilePlot = (sections: OfficialProfileSection[], width: number, height: number): OfficialProfilePlot => {
+ let minElevation = sections[0].startElevation;
+ let maxElevation = minElevation;
+ for (const section of sections) {
+ minElevation = Math.min(minElevation, section.startElevation, section.endElevation);
+ maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation);
+ }
+ const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8);
+ minElevation -= elevationPadding;
+ maxElevation += elevationPadding;
+ const minDistanceKm = sections[0].startDistanceKm;
+ const maxDistanceKm = sections[sections.length - 1].endDistanceKm;
+ return {
+ left: 10,
+ right: Math.max(11, width - 44),
+ top: 16,
+ bottom: height - 34,
+ minElevation,
+ maxElevation,
+ minDistanceKm,
+ maxDistanceKm,
+ distanceSpan: Math.max(maxDistanceKm - minDistanceKm, 0.1),
+ };
+};
+
+// drawOfficialSections renders precomputed profile sections onto a canvas.
+// Shared by the ride detail official-climb cards and the climb detail page.
+export function drawOfficialSections(
+ profileCanvas: HTMLCanvasElement,
+ sections: OfficialProfileSection[],
+ colors: OfficialProfileColors,
+): void {
+ const rect = profileCanvas.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ const context = profileCanvas.getContext("2d");
+ if (!context) return;
+ const ratio = window.devicePixelRatio || 1;
+ profileCanvas.width = Math.floor(rect.width * ratio);
+ profileCanvas.height = Math.floor(rect.height * ratio);
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ const plot = officialProfilePlot(sections, rect.width, rect.height);
+ const minElevation = plot.minElevation;
+ const maxElevation = plot.maxElevation;
+ const minDistance = plot.minDistanceKm;
+ const maxDistance = plot.maxDistanceKm;
+ const elevationSpan = Math.max(maxElevation - minElevation, 1);
+ const xForDistance = (distanceKm: number) => plot.left + ((distanceKm - minDistance) / plot.distanceSpan) * (plot.right - plot.left);
+ const yForElevation = (elevationM: number) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (plot.bottom - plot.top);
+ context.clearRect(0, 0, rect.width, rect.height);
+ context.fillStyle = colors.plotSurface;
+ context.fillRect(0, 0, rect.width, rect.height);
+ context.font = "9px system-ui, sans-serif";
+ context.lineWidth = 1;
+ context.setLineDash([4, 4]);
+ context.strokeStyle = colors.yGrid;
+ const yGridElevations: number[] = [];
+ for (let elevationM = Math.ceil(minElevation / 100) * 100; elevationM <= maxElevation; elevationM += 100) {
+ yGridElevations.push(elevationM);
+ }
+ context.save();
+ context.beginPath();
+ context.moveTo(plot.left, plot.bottom);
+ context.lineTo(plot.left, yForElevation(sections[0].startElevation));
+ for (const section of sections) {
+ context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation));
+ }
+ context.lineTo(plot.right, plot.bottom);
+ context.closePath();
+ context.clip();
+ for (const elevationM of yGridElevations) {
+ const y = yForElevation(elevationM);
context.beginPath();
- context.moveTo(plot.left, plot.bottom);
- context.lineTo(plot.right, plot.bottom);
+ context.moveTo(plot.left, y);
+ context.lineTo(plot.right, y);
context.stroke();
- for (const section of sections) {
- const startX = xForDistance(section.startDistanceKm);
- const endX = xForDistance(section.endDistanceKm);
- context.beginPath();
- context.moveTo(startX, plot.bottom);
- context.lineTo(startX, yForElevation(section.startElevation));
- context.lineTo(endX, yForElevation(section.endElevation));
- context.lineTo(endX, plot.bottom);
- context.closePath();
- context.globalAlpha = 0.25;
- context.fillStyle = colors[section.band];
- context.fill();
- context.globalAlpha = 1;
- context.beginPath();
- context.moveTo(startX, yForElevation(section.startElevation));
- context.lineTo(endX, yForElevation(section.endElevation));
- context.strokeStyle = colors[section.band];
- context.lineWidth = 2.5;
- context.stroke();
- }
- context.save();
+ }
+ context.restore();
+ context.fillStyle = colors.yGrid;
+ context.textAlign = "left";
+ context.textBaseline = "middle";
+ for (const elevationM of yGridElevations) {
+ context.fillText(`${elevationM} m`, plot.right + 6, yForElevation(elevationM));
+ }
+ context.setLineDash([]);
+ context.strokeStyle = colors.grid;
+ context.beginPath();
+ context.moveTo(plot.left, plot.bottom);
+ context.lineTo(plot.right, plot.bottom);
+ context.stroke();
+ for (const section of sections) {
+ const startX = xForDistance(section.startDistanceKm);
+ const endX = xForDistance(section.endDistanceKm);
context.beginPath();
- context.moveTo(plot.left, plot.bottom);
- context.lineTo(plot.left, yForElevation(sections[0].startElevation));
- for (const section of sections) {
- context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation));
- }
- context.lineTo(plot.right, plot.bottom);
+ context.moveTo(startX, plot.bottom);
+ context.lineTo(startX, yForElevation(section.startElevation));
+ context.lineTo(endX, yForElevation(section.endElevation));
+ context.lineTo(endX, plot.bottom);
context.closePath();
- context.clip();
- context.strokeStyle = colors.xGrid;
- context.lineWidth = 1;
- context.setLineDash([4, 4]);
- for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) {
- const distanceKm = sectionIndex === sections.length ? maxDistance : sections[sectionIndex].startDistanceKm;
- const x = xForDistance(distanceKm);
- context.beginPath();
- context.moveTo(x, plot.top);
- context.lineTo(x, plot.bottom);
- context.stroke();
- }
- context.restore();
- context.setLineDash([]);
- context.font = "9px system-ui, sans-serif";
- context.fillStyle = colors.subtle;
- context.textBaseline = "bottom";
- context.textAlign = "center";
- context.save();
+ context.globalAlpha = 0.25;
+ context.fillStyle = colors[section.band];
+ context.fill();
+ context.globalAlpha = 1;
context.beginPath();
- context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
- context.clip();
- for (const section of sections) {
- const startX = xForDistance(section.startDistanceKm);
- const endX = xForDistance(section.endDistanceKm);
- const label = `${section.slopePercent.toFixed(1)}%`;
- context.fillText(label, (startX + endX) / 2, plot.bottom - 4);
- }
- context.restore();
- let topElevation = sections[0].startElevation;
- let topDistance = sections[0].startDistanceKm;
- for (const section of sections) {
- if (section.startElevation > topElevation) {
- topElevation = section.startElevation;
- topDistance = section.startDistanceKm;
- }
- if (section.endElevation > topElevation) {
- topElevation = section.endElevation;
- topDistance = section.endDistanceKm;
- }
- }
- context.fillStyle = colors.accent;
+ context.moveTo(startX, yForElevation(section.startElevation));
+ context.lineTo(endX, yForElevation(section.endElevation));
+ context.strokeStyle = colors[section.band];
+ context.lineWidth = 2.5;
+ context.stroke();
+ }
+ context.save();
+ context.beginPath();
+ context.moveTo(plot.left, plot.bottom);
+ context.lineTo(plot.left, yForElevation(sections[0].startElevation));
+ for (const section of sections) {
+ context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation));
+ }
+ context.lineTo(plot.right, plot.bottom);
+ context.closePath();
+ context.clip();
+ context.strokeStyle = colors.xGrid;
+ context.lineWidth = 1;
+ context.setLineDash([4, 4]);
+ for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) {
+ const distanceKm = sectionIndex === sections.length ? maxDistance : sections[sectionIndex].startDistanceKm;
+ const x = xForDistance(distanceKm);
context.beginPath();
- context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI);
- context.fill();
- context.font = "9px system-ui, sans-serif";
- context.fillStyle = colors.subtle;
- context.textBaseline = "top";
- context.strokeStyle = colors.grid;
- context.lineWidth = 1;
- for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) {
- const distanceKm =
- sectionIndex === sections.length ? maxDistance - minDistance : sections[sectionIndex].startDistanceKm - minDistance;
- const x = xForDistance(minDistance + distanceKm);
- context.beginPath();
- context.moveTo(x, plot.bottom);
- context.lineTo(x, plot.bottom + 3);
- context.stroke();
- context.textAlign = sectionIndex === 0 ? "left" : sectionIndex === sections.length ? "right" : "center";
- context.fillText(this.formatDistance(distanceKm), x, plot.bottom + 5);
- }
+ context.moveTo(x, plot.top);
+ context.lineTo(x, plot.bottom);
+ context.stroke();
}
-
- formatDistance(distance: number): string {
- return `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
+ context.restore();
+ context.setLineDash([]);
+ context.font = "9px system-ui, sans-serif";
+ context.fillStyle = colors.subtle;
+ context.textBaseline = "bottom";
+ context.textAlign = "center";
+ context.save();
+ context.beginPath();
+ context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
+ context.clip();
+ for (const section of sections) {
+ const startX = xForDistance(section.startDistanceKm);
+ const endX = xForDistance(section.endDistanceKm);
+ const label = `${section.slopePercent.toFixed(1)}%`;
+ context.fillText(label, (startX + endX) / 2, plot.bottom - 4);
}
-
- redrawOpen() {
- const profileCanvases = document.querySelectorAll<HTMLCanvasElement>("[data-official-profile]");
- for (const profileCanvas of profileCanvases) {
- const card = profileCanvas.closest<HTMLDetailsElement>("[data-official-climb-card]");
- if (card?.open) this.drawOfficialProfile(profileCanvas);
+ context.restore();
+ let topElevation = sections[0].startElevation;
+ let topDistance = sections[0].startDistanceKm;
+ for (const section of sections) {
+ if (section.startElevation > topElevation) {
+ topElevation = section.startElevation;
+ topDistance = section.startDistanceKm;
}
+ if (section.endElevation > topElevation) {
+ topElevation = section.endElevation;
+ topDistance = section.endDistanceKm;
+ }
+ }
+ context.fillStyle = colors.accent;
+ context.beginPath();
+ context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI);
+ context.fill();
+ context.font = "9px system-ui, sans-serif";
+ context.fillStyle = colors.subtle;
+ context.textBaseline = "top";
+ context.strokeStyle = colors.grid;
+ context.lineWidth = 1;
+ for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) {
+ const distanceKm = sectionIndex === sections.length ? maxDistance - minDistance : sections[sectionIndex].startDistanceKm - minDistance;
+ const x = xForDistance(minDistance + distanceKm);
+ context.beginPath();
+ context.moveTo(x, plot.bottom);
+ context.lineTo(x, plot.bottom + 3);
+ context.stroke();
+ context.textAlign = sectionIndex === 0 ? "left" : sectionIndex === sections.length ? "right" : "center";
+ context.fillText(formatClimbDistance(distanceKm), x, plot.bottom + 5);
}
}
+
+function formatClimbDistance(distance: number): string {
+ return `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
+}
diff --git a/web/official-climbs.go b/web/official-climbs.go
new file mode 100644
index 0000000..ce9d7cc
--- /dev/null
+++ b/web/official-climbs.go
@@ -0,0 +1,60 @@
+package web
+
+import (
+ "log/slog"
+
+ "github.com/martinlehoux/biking_home/mountain_pass"
+ "github.com/martinlehoux/biking_home/official_climb"
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/biking_home/rideanalysis"
+)
+
+// analysisFor runs the ride analysis pipeline the same way the ride profile
+// does, so recorded attempts and displayed climbs always agree.
+func analysisFor(parsed ride.Ride, passes []mountain_pass.MountainPass, officialClimbs []official_climb.OfficialClimb, matchPolicy official_climb.MatchPolicy) rideanalysis.Result {
+ return rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy)
+}
+
+// attemptsFromAnalysis extracts one attempt per official-climb occurrence.
+// Occurrences are numbered per climb in route order (0, 1, … for laps).
+func attemptsFromAnalysis(rideID int64, analysis rideanalysis.Result, parsed ride.Ride) []official_climb.Attempt {
+ occurrences := make(map[int64]int)
+ attempts := make([]official_climb.Attempt, 0)
+ for _, analyzed := range analysis.Climbs {
+ if analyzed.OfficialClimbID == 0 {
+ continue
+ }
+ occurrence := occurrences[analyzed.OfficialClimbID]
+ occurrences[analyzed.OfficialClimbID] = occurrence + 1
+ attempts = append(attempts, official_climb.AttemptFromStats(rideID, occurrence, official_climb.AttemptStats{
+ OfficialClimbID: analyzed.OfficialClimbID,
+ DistanceM: analyzed.DistanceM,
+ ElevationGainM: elevationGain(analyzed),
+ SlopePercent: analyzed.SlopePercent,
+ Cotacol: analyzed.Cotacol,
+ StartIndex: analyzed.Interval.Start,
+ EndIndex: analyzed.Interval.End,
+ }, parsed.Timestamps()))
+ }
+ return attempts
+}
+
+func elevationGain(analyzed rideanalysis.AnalyzedClimb) float64 {
+ gain := 0.0
+ for i := 1; i < len(analyzed.Segment.Elevations); i++ {
+ if diff := analyzed.Segment.Elevations[i] - analyzed.Segment.Elevations[i-1]; diff > 0 {
+ gain += diff
+ }
+ }
+ return gain
+}
+
+// recordOfficialClimbAttempts persists the ride's official-climb attempts,
+// replacing any previously recorded ones. Failures are logged, never fatal:
+// attempts are an optimization over on-the-fly matching.
+func (s *Server) recordOfficialClimbAttempts(rideID int64, parsed ride.Ride, analysis rideanalysis.Result) {
+ attempts := attemptsFromAnalysis(rideID, analysis, parsed)
+ if err := official_climb.ReplaceRideAttempts(s.db, rideID, attempts); err != nil {
+ slog.Warn("Failed to record official climb attempts", "ride_id", rideID, "error", err)
+ }
+}
diff --git a/web/official-climbs_test.go b/web/official-climbs_test.go
new file mode 100644
index 0000000..ba71f19
--- /dev/null
+++ b/web/official-climbs_test.go
@@ -0,0 +1,81 @@
+package web
+
+import (
+ "database/sql"
+ "github.com/martinlehoux/biking_home/internal/ridetest"
+ "testing"
+ "time"
+
+ "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"
+)
+
+var testRideStart = time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
+
+func testRideForClimb(t *testing.T) (ride.Ride, official_climb.OfficialClimb) {
+ t.Helper()
+ parsed := ridetest.MustRide("1km at 14.7% | 1km at -6.7%", ridetest.StartCoord(43.61, 5.42), ridetest.StartElevation(300), ridetest.Sampling(1000), ridetest.StartTime(testRideStart))
+ return parsed, official_climb.OfficialClimb{Name: "Col de Test", StartCoord: parsed.Coords[0], EndCoord: parsed.Coords[1]}
+}
+
+func insertTestRide(t *testing.T, db *sql.DB) int64 {
+ t.Helper()
+ result, err := db.Exec(`
+ INSERT INTO rides (external_id, gpx_path, name, external_sport_type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `, "strava:climb-test", "missing.gpx", "Test Ride", "Ride", "2026-09-01T08:00:00Z", 20_000, 0, 0, 0, 0)
+ require.NoError(t, err)
+ id, err := result.LastInsertId()
+ require.NoError(t, err)
+ return id
+}
+
+func TestAttemptsFromAnalysisSnapshotsOfficialClimbs(t *testing.T) {
+ parsed, climb := testRideForClimb(t)
+ analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{{ID: 42, Name: climb.Name, StartCoord: climb.StartCoord, EndCoord: climb.EndCoord}}, official_climb.DefaultMatchPolicy())
+
+ attempts := attemptsFromAnalysis(7, analysis, parsed)
+ require.Len(t, attempts, 1)
+ assert.Equal(t, int64(7), attempts[0].RideID)
+ assert.Equal(t, int64(42), attempts[0].ClimbID)
+ assert.Equal(t, 0, attempts[0].Occurrence)
+ assert.InDelta(t, 1000, attempts[0].DistanceM, 1)
+ assert.InDelta(t, 147, attempts[0].ElevationGainM, 1)
+ assert.InDelta(t, 14.7, attempts[0].SlopePercent, 0.1)
+ assert.Positive(t, attempts[0].Cotacol)
+ assert.Equal(t, "v1", attempts[0].CotacolAlgoVersion)
+ assert.Equal(t, testRideStart, attempts[0].StartedAt.UTC())
+ assert.False(t, attempts[0].EndedAt.IsZero(), "attempt end must come from ride timestamps")
+}
+
+func TestAttemptsFromAnalysisSkipsUnmatchedClimbs(t *testing.T) {
+ parsed, _ := testRideForClimb(t)
+ analysis := rideanalysis.Analyze(parsed, nil, nil, official_climb.DefaultMatchPolicy())
+
+ attempts := attemptsFromAnalysis(7, analysis, parsed)
+ assert.Empty(t, attempts)
+}
+
+func TestRecordOfficialClimbAttemptsPersistsAndReplaces(t *testing.T) {
+ server, db := newWebTestServer(t)
+ rideID := insertTestRide(t, db)
+ parsed, climb := testRideForClimb(t)
+ created, err := official_climb.Create(db, climb)
+ require.NoError(t, err)
+ analysis := rideanalysis.Analyze(parsed, nil, []official_climb.OfficialClimb{created}, official_climb.DefaultMatchPolicy())
+
+ server.recordOfficialClimbAttempts(rideID, parsed, analysis)
+ attempts, err := official_climb.AttemptsForClimb(db, created.ID)
+ require.NoError(t, err)
+ require.Len(t, attempts, 1)
+ assert.Equal(t, rideID, attempts[0].RideID)
+
+ // Re-recording replaces, never duplicates.
+ server.recordOfficialClimbAttempts(rideID, parsed, analysis)
+ attempts, err = official_climb.AttemptsForClimb(db, created.ID)
+ require.NoError(t, err)
+ require.Len(t, attempts, 1)
+}
diff --git a/web/rides.go b/web/rides.go
index 37340b8..10fbb75 100644
--- a/web/rides.go
+++ b/web/rides.go
@@ -87,6 +87,7 @@ func (s *Server) handleRide(w http.ResponseWriter, r *http.Request) {
slog.Warn("Failed to load official climbs for ride detail", "ride_id", id, "error", err)
}
matchPolicy := s.officialClimbMatchPolicy()
+ s.recordOfficialClimbAttempts(id, parsed, analysisFor(parsed, passes, officialClimbs, matchPolicy))
slog.Info("Loaded ride detail", "ride_id", id)
view := buildRideDetailView(buildRideView(item), parsed, passes, officialClimbs, matchPolicy)
view.Notice = rideDetailNotice(r)
diff --git a/web/server.go b/web/server.go
index c588696..fc0e526 100644
--- a/web/server.go
+++ b/web/server.go
@@ -50,8 +50,10 @@ func (s *Server) Handler() http.Handler {
handleRoute(mux, "POST /preview/official-climbs", s.handlePreviewOfficialClimb)
handleRoute(mux, "GET /rides/{id}", s.handleRide)
handleRoute(mux, "GET /analytics", s.handleAnalytics)
- handleRoute(mux, "POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb)
+ handleRoute(mux, "GET /climbs", s.handleClimbs)
+ handleRoute(mux, "GET /climbs/{id}", s.handleClimb)
handleRoute(mux, "POST /rides/{id}/categories", s.handleUpdateRideCategories)
+ handleRoute(mux, "POST /rides/{id}/official-climbs", s.handleCreateOfficialClimb)
handleRoute(mux, "GET /data-quality", s.handleDataQuality)
handleRoute(mux, "POST /data-quality/categories", s.handleBulkUpdateCategories)
handleRoute(mux, "GET /sync", s.handleSyncForm)
diff --git a/web/templates.templ b/web/templates.templ
index 14e9aab..d41dd39 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -15,7 +15,7 @@ templ Layout(title string, content templ.Component) {
<body>
<header class="site-header">
<a class="brand" href="/">biking_home</a>
- <nav class="nav"><a href="/">Rides</a><a href="/analytics">Analytics</a><a href="/preview">Preview</a><a href="/data-quality">Data quality</a><a href="/sync">Sync Strava</a></nav>
+ <nav class="nav"><a href="/">Rides</a><a href="/climbs">Climbs</a><a href="/analytics">Analytics</a><a href="/preview">Preview</a><a href="/data-quality">Data quality</a><a href="/sync">Sync Strava</a></nav>
</header>
<main class="container">
@content
@@ -485,3 +485,89 @@ templ DataQualityContent(data DataQualityPageData) {
}
</section>
}
+templ ClimbsPage(data ClimbsPageData) {
+ @Layout("Climbs", ClimbsContent(data))
+}
+
+templ ClimbsContent(data ClimbsPageData) {
+ <p class="eyebrow">Official climbs</p>
+ <section class="panel">
+ if len(data.Items) == 0 {
+ <div class="empty">No official climbs yet. Create one from a ride detail page.</div>
+ } else {
+ <table>
+ <thead><tr>
+ for _, header := range data.Headers {
+ <th class={ header.Class } scope="col" aria-sort={ header.AriaSort }><a class="sort-link" href={ templ.URL(header.URL) }>{ header.Label }<span class="sort-indicator" aria-hidden="true">{ header.Indicator }</span></a></th>
+ }
+ </tr></thead>
+ <tbody>
+ for _, item := range data.Items {
+ <tr><td><strong><a href={ templ.URL(item.URL) }>{ item.Climb.Name }</a></strong></td><td>{ item.Category }</td><td class="numeric">{ formatClimbCount(item.Climb.Attempts) }</td><td class="numeric">{ item.Distance }</td><td class="numeric">{ item.Slope }</td><td class="numeric">{ item.Cotacol }</td><td class="numeric">{ item.BestTime }</td></tr>
+ }
+ </tbody>
+ </table>
+ }
+ </section>
+}
+
+templ ClimbDetailPage(data ClimbDetailPageData) {
+ @Layout("Climb", ClimbDetailContent(data))
+}
+
+templ ClimbDetailContent(data ClimbDetailPageData) {
+ <div class="toolbar">
+ <div><h1 class="ride-detail-title">{ data.Climb.Name }</h1><p class="lead">Official climb · { data.Stats.Category }</p></div>
+ <div class="toolbar-actions"><a class="button secondary" href="/climbs">Back to climbs</a></div>
+ </div>
+ <section class="panel summary-panel">
+ <dl>
+ <div><dt>Attempts</dt><dd>{ data.Stats.Attempts }</dd></div>
+ <div><dt>Distance</dt><dd>{ data.Stats.Distance }</dd></div>
+ <div><dt>Elevation</dt><dd>{ data.Stats.ElevationG }</dd></div>
+ <div><dt>Avg slope</dt><dd>{ data.Stats.Slope }</dd></div>
+ <div><dt>Cotacol</dt><dd>{ data.Stats.Cotacol }</dd></div>
+ <div><dt>Best time</dt><dd>{ data.Stats.BestTime }</dd></div>
+ </dl>
+ </section>
+ <section class="panel map-panel">
+ <div id="climb-map" class="ride-map" role="img" aria-label="Official climb map"></div>
+ </section>
+ if len(data.Profile.Elevations) > 0 {
+ <section class="panel">
+ <div class="section-heading"><div><p class="eyebrow">Profile</p><h2>Median profile</h2></div></div>
+ <div class="profile-chart"><canvas id="climb-profile" data-climb-profile tabindex="0" role="img" aria-label={ officialProfileLabel(data.Climb.Name) }></canvas></div>
+ <p class="profile-hover" data-climb-profile-hover aria-live="polite">Hover or focus the profile to inspect the climb at a distance.</p>
+ <div class="official-climb-profile-legend" role="list" aria-label="Average slope color scale">
+ <span role="listitem"><i class="profile-band-swatch profile-band-downhill" aria-hidden="true"></i>&lt;=0%</span>
+ <span role="listitem"><i class="profile-band-swatch profile-band-0-3" aria-hidden="true"></i>0–3%</span>
+ <span role="listitem"><i class="profile-band-swatch profile-band-3-6" aria-hidden="true"></i>3–6%</span>
+ <span role="listitem"><i class="profile-band-swatch profile-band-6-9" aria-hidden="true"></i>6–9%</span>
+ <span role="listitem"><i class="profile-band-swatch profile-band-9-plus" aria-hidden="true"></i>9%+</span>
+ </div>
+ </section>
+ }
+ <section class="panel">
+ <div class="section-heading">
+ <div><p class="eyebrow">History</p><h2>Attempts</h2></div>
+ <span class="section-count">{ formatClimbCount(len(data.Attempts)) }</span>
+ </div>
+ if len(data.Attempts) == 0 {
+ <div class="empty">No attempts recorded yet. Open a ride containing this climb to record one.</div>
+ } else {
+ <table>
+ <thead><tr><th>Date</th><th>Ride</th><th class="numeric">Time</th><th class="numeric">Distance</th><th class="numeric">Cotacol</th></tr></thead>
+ <tbody>
+ for _, attempt := range data.Attempts {
+ <tr><td>{ attempt.Date }</td><td><a href={ templ.URL(rideDetailURL(attempt.RideID)) }>{ attempt.RideName }</a></td><td class="numeric">{ attempt.Duration }</td><td class="numeric">{ attempt.Distance }</td><td class="numeric">{ attempt.Cotacol }</td></tr>
+ }
+ </tbody>
+ </table>
+ }
+ </section>
+ @templ.JSONScript("climb-profile-data", data.Profile)
+ @templ.JSONScript("climb-endpoints", data.Endpoints)
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+ <script type="module" src="/static/climb-profile.js"></script>
+ <script type="module" src="/static/climb-map.js"></script>
+}
diff --git a/web/views.go b/web/views.go
index bfbfe09..5dfc0e6 100644
--- a/web/views.go
+++ b/web/views.go
@@ -437,7 +437,11 @@ func formatSpeed(metersPerSecond float64) string {
func formatDuration(seconds int64) string {
hours := seconds / 3600
minutes := (seconds % 3600) / 60
- return fmt.Sprintf("%dh %02dm", hours, minutes)
+ secondsOnly := seconds % 60
+ if hours > 0 {
+ return fmt.Sprintf("%dh %02dm %02ds", hours, minutes, secondsOnly)
+ }
+ return fmt.Sprintf("%dm %02ds", minutes, secondsOnly)
}
func formatElevation(meters float64) string {