aboutsummaryrefslogtreecommitdiff
path: root/web/frontend
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-24 08:37:33 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-24 08:37:33 +0200
commit90614736f2a0c556f31eca5361b3c032f5f309f6 (patch)
tree81fda954dc2bff8ae8b0023b502343c455995272 /web/frontend
parente3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 (diff)
Match official climbs against their recorded sample
An official climb matched on endpoint coordinates alone. On a loop ridden the other way the route meets the climb's top before its bottom, so pairing each start with the first end inside its lap window credited a 5.9 km passage as the 850 m Chemin de la Tour Pey Blanc, and a 49 km one on another ride. Climbs now keep the sample of the segment they were created from: the recorded length and elevation gain of the selected route points, captured by the ride detail and preview handlers and stored NOT NULL. Matching takes the whole route and drops any candidate outside ±50% of that sample on either axis, and attempts are refreshed by upsert and prune so re-analyzing a ride keeps the recorded-at stamp of the passages that survive. The backfill leaves the web package: backfill.Run recomputes ride values then official climb attempts as a single `go run . backfill` command (climbs-backfill is gone), rideanalysis owns the attempt snapshot, and the mountain-pass loading margin is shared. Applied to the stored rides it stamps 78 samples, drops the four bogus attempts and leaves ride 800 with its three Pont Rout passages.
Diffstat (limited to 'web/frontend')
-rw-r--r--web/frontend/ride-detail-boundaries.ts26
-rw-r--r--web/frontend/ride-detail-logic.test.ts16
-rw-r--r--web/frontend/ride-detail-logic.ts32
-rw-r--r--web/frontend/types.d.ts5
4 files changed, 67 insertions, 12 deletions
diff --git a/web/frontend/ride-detail-boundaries.ts b/web/frontend/ride-detail-boundaries.ts
index a9bb8e1..f1d80e7 100644
--- a/web/frontend/ride-detail-boundaries.ts
+++ b/web/frontend/ride-detail-boundaries.ts
@@ -1,4 +1,4 @@
-import { climbMetrics, formatDistance, formatElevation } from "./ride-detail-logic.js";
+import { climbMetrics, climbSample, formatDistance, formatElevation } from "./ride-detail-logic.js";
import { requireElement } from "./dom.js";
import type { BoundarySource, BoundaryTarget, ClimbBounds, RideProfilePoint } from "./types.js";
@@ -104,11 +104,13 @@ export class RideBoundaryController {
const item = form.closest<HTMLElement>("[data-climb-item]");
if (!item) return;
const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10);
- const metrics = climbMetrics(this.points, this.climbBounds[climbIndex]);
- if (!metrics) {
+ const bounds = this.climbBounds[climbIndex];
+ const metrics = climbMetrics(this.points, bounds);
+ if (!bounds || !metrics) {
preview.textContent = "Choose an end point after the start point.";
return;
}
+ this.syncSnapshot(form, bounds);
const summary = requireElement(item.querySelector<HTMLElement>("[data-climb-summary]"), "climb summary");
const metricsOutput = requireElement(item.querySelector<HTMLElement>("[data-climb-metrics]"), "climb metrics");
summary.textContent = `${formatDistance(metrics.start.distanceKm)}–${formatDistance(metrics.end.distanceKm)}`;
@@ -116,6 +118,24 @@ export class RideBoundaryController {
preview.textContent = `Preview: ${formatDistance(metrics.distanceKm)} · ${metrics.elevationGain >= 0 ? "+" : ""}${formatElevation(metrics.elevationGain)} · ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`;
}
+ // syncSnapshot fills the fields the preview save posts: the preview ride is never persisted.
+ syncSnapshot(form: HTMLFormElement, bounds: ClimbBounds): void {
+ const sample = climbSample(this.points, bounds);
+ if (!sample) return;
+ const snapshot: Array<[string, number]> = [
+ ["start_lat", this.points[bounds.startIndex].latitude],
+ ["start_lon", this.points[bounds.startIndex].longitude],
+ ["end_lat", this.points[bounds.endIndex].latitude],
+ ["end_lon", this.points[bounds.endIndex].longitude],
+ ["sample_distance_m", sample.distanceM],
+ ["sample_elevation_gain_m", sample.elevationGainM],
+ ];
+ for (const [name, value] of snapshot) {
+ const input = form.querySelector<HTMLInputElement>(`input[name="${name}"]`);
+ if (input) input.value = String(value);
+ }
+ }
+
boundaryLabel(index: number): string {
const point = this.points[index];
const idx = point.index ?? index;
diff --git a/web/frontend/ride-detail-logic.test.ts b/web/frontend/ride-detail-logic.test.ts
index 471eb49..5104516 100644
--- a/web/frontend/ride-detail-logic.test.ts
+++ b/web/frontend/ride-detail-logic.test.ts
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
categoryForCotacol,
climbMetrics,
+ climbSample,
cotacolForClimb,
elevationAtDistance,
formatClimbLabel,
@@ -41,6 +42,21 @@ test("calculates climb metrics with Cotacol", () => {
assert.equal(metrics.category, categoryForCotacol(metrics.cotacol));
});
+test("computes the recorded sample of a climb segment", () => {
+ assert.deepEqual(climbSample(points, { startIndex: 0, endIndex: 3 }), { distanceM: 250, elevationGainM: 20 });
+ assert.equal(climbSample(points, { startIndex: 3, endIndex: 0 }), null);
+});
+
+test("climb sample counts only upward steps", () => {
+ const rolling = [
+ { distanceKm: 0, elevationM: 100 },
+ { distanceKm: 0.1, elevationM: 130 },
+ { distanceKm: 0.2, elevationM: 110 },
+ { distanceKm: 0.3, elevationM: 140 },
+ ];
+ assert.deepEqual(climbSample(rolling, { startIndex: 0, endIndex: 3 }), { distanceM: 300, elevationGainM: 60 });
+});
+
test("formats profile climb labels with category", () => {
assert.equal(formatClimbLabel({ name: "", category: "Cat 4", cotacol: 45.9, kind: "detected" }), "Cat 4 45.9");
assert.equal(formatClimbLabel({ name: "Col de Test", category: "Cat 4", cotacol: 45.9, kind: "detected" }), "Col de Test");
diff --git a/web/frontend/ride-detail-logic.ts b/web/frontend/ride-detail-logic.ts
index b4d4dba..e7fc822 100644
--- a/web/frontend/ride-detail-logic.ts
+++ b/web/frontend/ride-detail-logic.ts
@@ -1,4 +1,4 @@
-import type { ClimbBounds, ClimbMetrics, ProfilePoint } from "./types.js";
+import type { ClimbBounds, ClimbMetrics, ClimbSample, ProfilePoint } from "./types.js";
export const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
@@ -71,15 +71,13 @@ export const cotacolForClimb = (points: ProfilePoint[], startIndex: number, endI
return cotacol;
};
+const boundsAreValid = (points: ProfilePoint[], bounds: ClimbBounds | undefined): bounds is ClimbBounds => {
+ if (!bounds || !Number.isInteger(bounds.startIndex) || !Number.isInteger(bounds.endIndex)) return false;
+ return bounds.startIndex >= 0 && bounds.endIndex < points.length && bounds.startIndex < bounds.endIndex;
+};
+
export const climbMetrics = (points: ProfilePoint[], bounds: ClimbBounds | undefined): ClimbMetrics | null => {
- if (
- !bounds ||
- !Number.isInteger(bounds.startIndex) ||
- !Number.isInteger(bounds.endIndex) ||
- bounds.startIndex < 0 ||
- bounds.endIndex >= points.length ||
- bounds.startIndex >= bounds.endIndex
- ) {
+ if (!boundsAreValid(points, bounds)) {
return null;
}
const start = points[bounds.startIndex];
@@ -98,3 +96,19 @@ export const climbMetrics = (points: ProfilePoint[], bounds: ClimbBounds | undef
category: categoryForCotacol(cotacol),
};
};
+
+// climbSample returns the segment's recorded length and upward-only gain, as the server computes it for an official climb sample.
+export const climbSample = (points: ProfilePoint[], bounds: ClimbBounds | undefined): ClimbSample | null => {
+ if (!boundsAreValid(points, bounds)) {
+ return null;
+ }
+ const sample: ClimbSample = {
+ distanceM: (points[bounds.endIndex].distanceKm - points[bounds.startIndex].distanceKm) * 1000,
+ elevationGainM: 0,
+ };
+ for (let index = bounds.startIndex + 1; index <= bounds.endIndex; index++) {
+ const gain = points[index].elevationM - points[index - 1].elevationM;
+ if (gain > 0) sample.elevationGainM += gain;
+ }
+ return sample;
+};
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
index 87be024..74e0765 100644
--- a/web/frontend/types.d.ts
+++ b/web/frontend/types.d.ts
@@ -81,6 +81,11 @@ export interface ClimbMetrics {
category: string;
}
+export interface ClimbSample {
+ distanceM: number;
+ elevationGainM: number;
+}
+
export interface OfficialProfileColors {
downhill: string;
"0-3": string;