aboutsummaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/climbs.go81
-rw-r--r--web/climbs_test.go56
-rw-r--r--web/frontend/climb-profile.ts49
-rw-r--r--web/frontend/official-climb-profile-logic.test.ts102
-rw-r--r--web/frontend/official-climb-profile-logic.ts150
-rw-r--r--web/frontend/official-climb-profile.ts53
-rw-r--r--web/frontend/ride-detail.ts2
-rw-r--r--web/frontend/types.d.ts21
-rw-r--r--web/server_test.go9
-rw-r--r--web/templates.templ4
-rw-r--r--web/views.go54
11 files changed, 157 insertions, 424 deletions
diff --git a/web/climbs.go b/web/climbs.go
index 5cd6db9..02f6eb6 100644
--- a/web/climbs.go
+++ b/web/climbs.go
@@ -2,8 +2,6 @@ package web
import (
"database/sql"
- "github.com/jftuga/geodist"
- "github.com/martinlehoux/biking_home/climb"
"log/slog"
"math"
"net/http"
@@ -12,8 +10,10 @@ import (
"strconv"
"strings"
+ "github.com/martinlehoux/biking_home/climb"
"github.com/martinlehoux/biking_home/official_climb"
"github.com/martinlehoux/biking_home/ride"
+ "github.com/martinlehoux/biking_home/rideanalysis"
"github.com/martinlehoux/kagamigo/kcore"
)
@@ -21,15 +21,9 @@ func climbDetailURL(id int64) string {
return "/climbs/" + strconv.FormatInt(id, 10)
}
-type routeCandidate struct {
- coords []geodist.Coord
- distance float64
-}
-
type ClimbProfileData struct {
- DistanceKm float64 `json:"distanceKm"`
- Elevations []float64 `json:"elevations"`
- Route [][]float64 `json:"route,omitempty"`
+ Sections []ProfileSectionView `json:"sections"`
+ Route [][]float64 `json:"route,omitempty"`
}
type ClimbAttemptView struct {
RideID int64
@@ -116,15 +110,22 @@ func (s *Server) handleClimb(w http.ResponseWriter, r *http.Request) {
}), w)
}
-// buildClimbProfile computes the median normalized elevation profile from the linked rides, resampling every occurrence onto a shared axis.
+// buildClimbProfile merges the recorded passages of a climb into one chart profile.
func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, aggregate official_climb.Aggregate, attempts []official_climb.Attempt) ClimbProfileData {
- profile := ClimbProfileData{DistanceKm: aggregate.DistanceM / 1000}
- if len(attempts) == 0 {
- return profile
+ profile := rideanalysis.MergeClimbProfile(officialClimb, s.climbAttemptRides(attempts), aggregate, s.officialClimbMatchPolicy(), climb.DefaultProfilePolicy())
+ data := ClimbProfileData{Sections: profileSectionsView(profile.Sections)}
+ if len(profile.Route) > 1 {
+ data.Route = make([][]float64, len(profile.Route))
+ for index, coord := range profile.Route {
+ data.Route[index] = []float64{coord.Lon, coord.Lat}
+ }
}
- matchPolicy := s.officialClimbMatchPolicy()
- elevationSamples := make([][]float64, 0, len(attempts))
- routeCandidates := make([]routeCandidate, 0, len(attempts))
+ return data
+}
+
+// climbAttemptRides loads and parses the rides that recorded the attempts, once per ride.
+func (s *Server) climbAttemptRides(attempts []official_climb.Attempt) []ride.Ride {
+ parsedRides := make([]ride.Ride, 0, len(attempts))
seen := make(map[int64]bool)
for _, attempt := range attempts {
if seen[attempt.RideID] {
@@ -137,52 +138,12 @@ func (s *Server) buildClimbProfile(officialClimb official_climb.OfficialClimb, a
}
parsed, err := ride.ParseStoredRide(item)
if err != nil {
- slog.Warn("Failed to parse ride for climb profile", "climb_id", officialClimb.ID, "ride_id", attempt.RideID, "error", err)
+ slog.Warn("Failed to parse ride for climb profile", "ride_id", attempt.RideID, "error", err)
continue
}
- for _, matched := range official_climb.MatchClimbs(parsed.Route, officialClimb, matchPolicy.EndpointRadiusM) {
- segment := climb.Climb{
- Distances: parsed.Distances[matched.StartIndex : matched.EndIndex+1],
- Elevations: parsed.Elevations[matched.StartIndex : matched.EndIndex+1],
- Coords: parsed.Coords[matched.StartIndex : matched.EndIndex+1]}
- if sample := official_climb.NormalizeProfile(segment); len(sample) > 0 {
- elevationSamples = append(elevationSamples, sample)
- }
- routeCandidates = append(routeCandidates, routeCandidate{
- coords: segment.Coords,
- distance: segment.Distances[len(segment.Distances)-1] - segment.Distances[0],
- })
- }
- }
- profile.Elevations = official_climb.MedianProfile(elevationSamples)
- if route := medianRoute(routeCandidates); len(route) > 1 {
- profile.Route = make([][]float64, len(route))
- for i, coord := range route {
- profile.Route[i] = []float64{coord.Lon, coord.Lat}
- }
- }
- return profile
-}
-
-// medianRoute picks the recorded route of the attempt whose distance is the median: full precision, no resampling, robust to one drifted ride.
-func medianRoute(candidates []routeCandidate) []geodist.Coord {
- if len(candidates) == 0 {
- return nil
- }
- distances := make([]float64, len(candidates))
- for i, candidate := range candidates {
- distances[i] = candidate.distance
- }
- median := official_climb.Median(distances)
- best := candidates[0]
- bestDiff := math.Abs(best.distance - median)
- for _, candidate := range candidates[1:] {
- if diff := math.Abs(candidate.distance - median); diff < bestDiff {
- best = candidate
- bestDiff = diff
- }
+ parsedRides = append(parsedRides, parsed)
}
- return best.coords
+ return parsedRides
}
func buildClimbStatsView(aggregate official_climb.Aggregate) ClimbStatsView {
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"[^>]*>(.*?)</script>`).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, `<td class="numeric">-</td>`)
@@ -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<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;
+ if (!payload || payload.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);
+ 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<boolean>(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, `<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>`)
assert.Contains(t, body, `<script type="module" src="/static/ride-detail.js"></script>`)
- assert.NotContains(t, body, `<script src="/static/official-climb-profile-logic.js"`)
assert.NotContains(t, body, `<script src="/static/official-climb-profile.js"`)
staticRequest := httptest.NewRequest(http.MethodGet, "/static/ride-detail.js", nil)
@@ -197,14 +196,6 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
assert.Equal(t, "text/javascript; charset=utf-8", profileStaticResponse.Header().Get("Content-Type"))
assert.NotEmpty(t, profileScript)
- logicStaticRequest := httptest.NewRequest(http.MethodGet, "/static/official-climb-profile-logic.js", nil)
- logicStaticResponse := httptest.NewRecorder()
- server.Handler().ServeHTTP(logicStaticResponse, logicStaticRequest)
-
- assert.Equal(t, http.StatusOK, logicStaticResponse.Code)
- assert.Equal(t, "text/javascript; charset=utf-8", logicStaticResponse.Header().Get("Content-Type"))
- assert.NotEmpty(t, logicStaticResponse.Body.String())
-
syncStaticRequest := httptest.NewRequest(http.MethodGet, "/static/sync.js", nil)
syncStaticResponse := httptest.NewRecorder()
server.Handler().ServeHTTP(syncStaticResponse, syncStaticRequest)
diff --git a/web/templates.templ b/web/templates.templ
index 7c326eb..dbdeeac 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -304,7 +304,7 @@ templ RideDetailContent(data RideDetailView) {
<span class="official-climb-chevron" aria-hidden="true">⌄</span>
</summary>
<div class="official-climb-card-body">
- <canvas class="official-climb-profile" data-official-profile data-profile-start={ formatRouteIndex(climb.StartIndex) } data-profile-end={ formatRouteIndex(climb.EndIndex) } tabindex="0" role="img" aria-label={ officialProfileLabel(climb.OfficialName) }></canvas>
+ <canvas class="official-climb-profile" data-official-profile data-profile-climb-index={ formatRouteIndex(climb.Index) } tabindex="0" role="img" aria-label={ officialProfileLabel(climb.OfficialName) }></canvas>
<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>
@@ -542,7 +542,7 @@ templ ClimbDetailContent(data ClimbDetailPageData) {
<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 {
+ if len(data.Profile.Sections) > 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>
diff --git a/web/views.go b/web/views.go
index af3dbc2..f895fc5 100644
--- a/web/views.go
+++ b/web/views.go
@@ -123,20 +123,44 @@ type RideProfilePoint struct {
}
type RideProfileClimb struct {
- Index int `json:"-"`
+ Index int `json:"index"`
+ StartKm float64 `json:"startKm"`
+ EndKm float64 `json:"endKm"`
+ TopKm float64 `json:"topKm"`
+ TopElevationM float64 `json:"topElevationM"`
+ Name string `json:"name"`
+ Category string `json:"category"`
+ DistanceKm float64 `json:"distanceKm"`
+ SlopePercent float64 `json:"slopePercent"`
+ Cotacol float64 `json:"cotacol"`
+ OfficialClimbID int64 `json:"officialClimbId,omitempty"`
+ OfficialName string `json:"officialName,omitempty"`
+ StartIndex int `json:"startIndex"`
+ EndIndex int `json:"endIndex"`
+ Sections []ProfileSectionView `json:"sections,omitempty"`
+}
+
+// ProfileSectionView is one step of a drawn climb profile: the stretch between two bends of the trace.
+type ProfileSectionView struct {
StartKm float64 `json:"startKm"`
EndKm float64 `json:"endKm"`
- TopKm float64 `json:"topKm"`
- TopElevationM float64 `json:"topElevationM"`
- Name string `json:"name"`
- Category string `json:"category"`
- DistanceKm float64 `json:"distanceKm"`
+ StartElevationM float64 `json:"startElevationM"`
+ EndElevationM float64 `json:"endElevationM"`
SlopePercent float64 `json:"slopePercent"`
- Cotacol float64 `json:"cotacol"`
- OfficialClimbID int64 `json:"officialClimbId,omitempty"`
- OfficialName string `json:"officialName,omitempty"`
- StartIndex int `json:"startIndex"`
- EndIndex int `json:"endIndex"`
+}
+
+func profileSectionsView(sections []climb.ProfileSection) []ProfileSectionView {
+ views := make([]ProfileSectionView, 0, len(sections))
+ for _, section := range sections {
+ views = append(views, ProfileSectionView{
+ StartKm: section.StartDistanceM / 1000,
+ EndKm: section.EndDistanceM / 1000,
+ StartElevationM: section.StartElevationM,
+ EndElevationM: section.EndElevationM,
+ SlopePercent: section.SlopePercent,
+ })
+ }
+ return views
}
type RideProfileCrossing struct {
@@ -366,10 +390,11 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off
}
}
analysis := rideanalysis.Analyze(parsed, passes, officialClimbs, matchPolicy)
+ profilePolicy := climb.DefaultProfilePolicy()
for _, analyzedClimb := range analysis.Climbs {
segment := analyzedClimb.Segment
topIndex, topElevationM := segment.Top()
- climb := RideProfileClimb{
+ climbView := RideProfileClimb{
Index: len(profile.Climbs),
StartKm: segment.Distances[0] / 1000,
EndKm: segment.Distances[len(segment.Distances)-1] / 1000,
@@ -385,7 +410,10 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off
StartIndex: analyzedClimb.Interval.Start,
EndIndex: analyzedClimb.Interval.End,
}
- profile.Climbs = append(profile.Climbs, climb)
+ if analyzedClimb.OfficialClimbID > 0 {
+ climbView.Sections = profileSectionsView(segment.ProfileSections(profilePolicy))
+ }
+ profile.Climbs = append(profile.Climbs, climbView)
}
for _, crossing := range analysis.Crossings {
profile.Crossings = append(profile.Crossings, RideProfileCrossing{