aboutsummaryrefslogtreecommitdiff
path: root/web/frontend
diff options
context:
space:
mode:
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;