diff options
Diffstat (limited to 'web/frontend')
| -rw-r--r-- | web/frontend/ride-detail-boundaries.ts | 26 | ||||
| -rw-r--r-- | web/frontend/ride-detail-logic.test.ts | 16 | ||||
| -rw-r--r-- | web/frontend/ride-detail-logic.ts | 32 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 5 |
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; |
