summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 14:55:26 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 14:55:26 +0200
commitdf7c9b5130ba193fc918a947a461f2a70dc2dd3c (patch)
tree0366e0b60338e268d758511621b9adf721be8322 /web
parent53ab332e6d2afe9818914e0026310e4cadcc4202 (diff)
fix(web): use Cotacol for climb difficulty
Diffstat (limited to 'web')
-rw-r--r--web/frontend/ride-detail-canvas.ts4
-rw-r--r--web/frontend/ride-detail-logic.test.ts12
-rw-r--r--web/frontend/ride-detail-logic.ts27
-rw-r--r--web/frontend/types.d.ts2
-rw-r--r--web/server.go6
-rw-r--r--web/server_test.go1
-rw-r--r--web/views.go10
7 files changed, 33 insertions, 29 deletions
diff --git a/web/frontend/ride-detail-canvas.ts b/web/frontend/ride-detail-canvas.ts
index 1453013..c61afee 100644
--- a/web/frontend/ride-detail-canvas.ts
+++ b/web/frontend/ride-detail-canvas.ts
@@ -1,4 +1,4 @@
-import { clamp, climbMetrics, formatDistance, formatElevation, nearestPointIndex } from "./ride-detail-logic.js";
+import { clamp, climbMetrics, formatClimbLabel, formatDistance, formatElevation, nearestPointIndex } from "./ride-detail-logic.js";
import type { BoundarySource, ClimbBounds, RideDetailColors, RideProfile, RideProfilePoint } from "./types.js";
interface Plot {
@@ -137,7 +137,7 @@ export class RideProfileCanvas {
const endX = clamp(this.xForDistance(metrics.end.distanceKm), plot.left, plot.right);
this.context.fillStyle = climbIndex === this.focusedClimbIndex ? this.colors.climbFocusFill : this.colors.accentFill;
this.context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top);
- const label = climb.name || `${metrics.category} ${Math.round(metrics.score)}`;
+ const label = formatClimbLabel(climb.name, metrics.category, metrics.cotacol);
this.context.fillStyle = this.colors.climbLabel;
this.context.textAlign = "center";
this.context.textBaseline = "top";
diff --git a/web/frontend/ride-detail-logic.test.ts b/web/frontend/ride-detail-logic.test.ts
index 024f018..b848f7e 100644
--- a/web/frontend/ride-detail-logic.test.ts
+++ b/web/frontend/ride-detail-logic.test.ts
@@ -1,10 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
- categoryForScore,
+ categoryForCotacol,
climbMetrics,
cotacolForClimb,
elevationAtDistance,
+ formatClimbLabel,
formatDistance,
formatElevation,
nearestPointIndex,
@@ -28,7 +29,7 @@ test("interpolates elevation between route points", () => {
assert.equal(elevationAtDistance(points, 2, 250), 120);
});
-test("calculates climb metrics with the Cotacol score", () => {
+test("calculates climb metrics with Cotacol", () => {
const metrics = climbMetrics(points, { startIndex: 0, endIndex: 3 });
assert.ok(metrics);
@@ -36,7 +37,12 @@ test("calculates climb metrics with the Cotacol score", () => {
assert.equal(metrics.elevationGain, 20);
assert.equal(metrics.slope, 8);
assert.equal(metrics.cotacol, cotacolForClimb(points, 0, 3));
- assert.equal(metrics.category, categoryForScore(metrics.score));
+ assert.equal(metrics.category, categoryForCotacol(metrics.cotacol));
+});
+
+test("formats profile climb labels with Cotacol", () => {
+ assert.equal(formatClimbLabel("", "Cat 4", 45.9), "Cat 4 45.9");
+ assert.equal(formatClimbLabel("Col de Test", "Cat 4", 45.9), "Col de Test");
});
test("formats profile labels consistently", () => {
diff --git a/web/frontend/ride-detail-logic.ts b/web/frontend/ride-detail-logic.ts
index 825bb2c..a2ba80a 100644
--- a/web/frontend/ride-detail-logic.ts
+++ b/web/frontend/ride-detail-logic.ts
@@ -19,15 +19,17 @@ export const nearestPointIndex = (points: ProfilePoint[], distance: number): num
return distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low;
};
-export const categoryForScore = (score: number): string => {
- if (score < 35) return "NO";
- if (score < 80) return "Cat 4";
- if (score < 180) return "Cat 3";
- if (score < 250) return "Cat 2";
- if (score < 600) return "Cat 1";
+export const categoryForCotacol = (cotacol: number): string => {
+ if (cotacol < 35) return "NO";
+ if (cotacol < 80) return "Cat 4";
+ if (cotacol < 180) return "Cat 3";
+ if (cotacol < 250) return "Cat 2";
+ if (cotacol < 600) return "Cat 1";
return "HC";
};
+export const formatClimbLabel = (name: string, category: string, cotacol: number): string => name || `${category} ${cotacol.toFixed(1)}`;
+
export const elevationAtDistance = (points: ProfilePoint[], index: number, distanceM: number): number => {
if (index + 1 >= points.length) return points[index].elevationM;
const startDistanceM = points[index].distanceKm * 1000;
@@ -40,7 +42,7 @@ export const cotacolForClimb = (points: ProfilePoint[], startIndex: number, endI
const startDistanceM = points[startIndex].distanceKm * 1000;
const lastDistanceM = points[endIndex].distanceKm * 1000;
if (lastDistanceM <= startDistanceM) return 0;
- let score = 0;
+ let cotacol = 0;
let pointIndex = startIndex;
for (let segmentStartM = startDistanceM; segmentStartM < lastDistanceM; segmentStartM += 100) {
const segmentEndM = Math.min(segmentStartM + 100, lastDistanceM);
@@ -49,9 +51,9 @@ export const cotacolForClimb = (points: ProfilePoint[], startIndex: number, endI
while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 < segmentEndM) pointIndex++;
const endElevation = elevationAtDistance(points, pointIndex, segmentEndM);
const slope = (endElevation - startElevation) / (segmentEndM - segmentStartM);
- if (slope > 0) score += ((segmentEndM - segmentStartM) / 1000) * (slope * 100) ** 2;
+ if (slope > 0) cotacol += ((segmentEndM - segmentStartM) / 1000) * (slope * 100) ** 2;
}
- return score;
+ return cotacol;
};
export const climbMetrics = (points: ProfilePoint[], bounds: ClimbBounds | undefined): ClimbMetrics | null => {
@@ -70,15 +72,14 @@ export const climbMetrics = (points: ProfilePoint[], bounds: ClimbBounds | undef
const distanceKm = end.distanceKm - start.distanceKm;
const elevationGain = end.elevationM - start.elevationM;
const slope = distanceKm > 0 ? elevationGain / (distanceKm * 10) : 0;
- const score = distanceKm > 0 ? ((Math.abs(elevationGain) * elevationGain) / (distanceKm * 1000)) * 10 : 0;
+ const cotacol = cotacolForClimb(points, bounds.startIndex, bounds.endIndex);
return {
start,
end,
distanceKm,
elevationGain,
slope,
- score,
- cotacol: cotacolForClimb(points, bounds.startIndex, bounds.endIndex),
- category: categoryForScore(score),
+ cotacol,
+ category: categoryForCotacol(cotacol),
};
};
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
index 66f1ad2..3642971 100644
--- a/web/frontend/types.d.ts
+++ b/web/frontend/types.d.ts
@@ -18,7 +18,6 @@ export interface RideProfileClimb {
topKm: number;
topElevationM: number;
name: string;
- score: number;
category: string;
distanceKm: number;
slopePercent: number;
@@ -69,7 +68,6 @@ export interface ClimbMetrics {
distanceKm: number;
elevationGain: number;
slope: number;
- score: number;
cotacol: number;
category: string;
}
diff --git a/web/server.go b/web/server.go
index 5d5e239..42ee21d 100644
--- a/web/server.go
+++ b/web/server.go
@@ -218,10 +218,10 @@ func buildRideViews(items []rides.Ride) []RideView {
func buildRideView(item rides.Ride) RideView {
view := RideView{Ride: item, Cotacol: "-", CotacolPer100Km: "-"}
- score, ready := item.CotacolScore()
+ cotacol, ready := item.CotacolScore()
if ready && item.CotacolAlgorithmVersion() == ride.CotacolAlgorithmVersion && item.DistanceM > 0 {
- view.Cotacol = formatCotacol(score)
- view.CotacolPer100Km = formatCotacolPer100Km(score, item.DistanceM)
+ view.Cotacol = formatCotacol(cotacol)
+ view.CotacolPer100Km = formatCotacolPer100Km(cotacol, item.DistanceM)
}
return view
}
diff --git a/web/server_test.go b/web/server_test.go
index 654d6d5..f719372 100644
--- a/web/server_test.go
+++ b/web/server_test.go
@@ -151,6 +151,7 @@ func TestHandlerRendersRideDetailWithEmbeddedRoute(t *testing.T) {
assert.Contains(t, body, `"type":"FeatureCollection"`)
assert.Contains(t, body, `"type":"LineString"`)
assert.Contains(t, body, `"coordinates":[[5,43]`)
+ assert.NotContains(t, body, `"score":`)
profileStaticRequest := httptest.NewRequest(http.MethodGet, "/static/official-climb-profile.js", nil)
profileStaticResponse := httptest.NewRecorder()
diff --git a/web/views.go b/web/views.go
index f8a45d3..551a74f 100644
--- a/web/views.go
+++ b/web/views.go
@@ -68,7 +68,6 @@ type RideProfileClimb struct {
TopKm float64 `json:"topKm"`
TopElevationM float64 `json:"topElevationM"`
Name string `json:"name"`
- Score float64 `json:"score"`
Category string `json:"category"`
DistanceKm float64 `json:"distanceKm"`
SlopePercent float64 `json:"slopePercent"`
@@ -269,7 +268,6 @@ func buildRideProfile(parsed ride.Ride, passes []mountain_pass.MountainPass, off
TopKm: segment.TopDistanceM() / 1000,
TopElevationM: segment.TopElevationM(),
Name: analyzedClimb.Name,
- Score: analyzedClimb.Score,
Category: analyzedClimb.Category,
DistanceKm: analyzedClimb.DistanceM / 1000,
SlopePercent: analyzedClimb.SlopePercent,
@@ -327,16 +325,16 @@ func formatElevation(meters float64) string {
return fmt.Sprintf("%.0f m", meters)
}
-func formatCotacol(score float64) string {
- return fmt.Sprintf("%.1f", score)
+func formatCotacol(cotacol float64) string {
+ return fmt.Sprintf("%.1f", cotacol)
}
-func formatCotacolPer100Km(score, distanceM float64) string {
+func formatCotacolPer100Km(cotacol, distanceM float64) string {
distanceKm := distanceM / 1000
if distanceKm <= 0 {
return "-"
}
- return fmt.Sprintf("%.1f", score*100/distanceKm)
+ return fmt.Sprintf("%.1f", cotacol*100/distanceKm)
}
func formatRouteIndex(index int) string {