aboutsummaryrefslogtreecommitdiff
path: root/web/frontend/climb-profile.ts
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-13 21:55:59 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-13 21:55:59 +0200
commite3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 (patch)
treeec8234d3442c498ec170b6444bd2cfae5b29a476 /web/frontend/climb-profile.ts
parentdb4d2b40895db41ce188549f49a6f277ba95c890 (diff)
Persist official climb attempts with climbs pages
Record one attempt row per (ride, climb, occurrence) when a ride detail loads, snapshotting distance, elevation gain, slope, cotacol and attempt timestamps; a climbs-backfill CLI command seeds all existing rides. New /climbs list page aggregates attempts (medians computed in Go, best time as min duration) with sortable columns, and /climbs/{id} shows the median scalar stats, a median normalized elevation profile (50 steps over each attempt's own length), the full-precision recorded route of the median-distance attempt on an OSM map, and every attempt with time at second precision.
Diffstat (limited to 'web/frontend/climb-profile.ts')
-rw-r--r--web/frontend/climb-profile.ts110
1 files changed, 110 insertions, 0 deletions
diff --git a/web/frontend/climb-profile.ts b/web/frontend/climb-profile.ts
new file mode 100644
index 0000000..8910349
--- /dev/null
+++ b/web/frontend/climb-profile.ts
@@ -0,0 +1,110 @@
+import { drawOfficialSections, officialProfilePlot, resolveOfficialProfileColors } from "./official-climb-profile.js";
+import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js";
+import type { OfficialProfileSection } from "./official-climb-profile-logic.js";
+import type { ProfilePoint } from "./types.js";
+
+interface ClimbProfilePayload {
+ distanceKm: number;
+ elevations: number[];
+}
+
+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, displayStepForLength(payload.distanceKm * 1000));
+};
+
+const elevationAtDistance = (sections: OfficialProfileSection[], distanceKm: number): number | null => {
+ for (const section of sections) {
+ if (distanceKm >= section.startDistanceKm && distanceKm <= section.endDistanceKm) {
+ const span = section.endDistanceKm - section.startDistanceKm;
+ const fraction = span > 0 ? (distanceKm - section.startDistanceKm) / span : 0;
+ return section.startElevation + fraction * (section.endElevation - section.startElevation);
+ }
+ }
+ return null;
+};
+
+const drawHoverMarker = (state: ClimbProfileState, distanceKm: number): void => {
+ const colors = resolveOfficialProfileColors();
+ const rect = state.canvas.getBoundingClientRect();
+ const context = state.canvas.getContext("2d");
+ if (!context) return;
+ const plot = officialProfilePlot(state.sections, rect.width, rect.height);
+ const elevationSpan = Math.max(plot.maxElevation - plot.minElevation, 1);
+ const x = plot.left + ((distanceKm - plot.minDistanceKm) / plot.distanceSpan) * (plot.right - plot.left);
+ const elevation = elevationAtDistance(state.sections, distanceKm);
+ if (elevation === null) return;
+ const y = plot.bottom - ((elevation - plot.minElevation) / elevationSpan) * (plot.bottom - plot.top);
+ context.strokeStyle = colors.subtle;
+ context.lineWidth = 1;
+ context.setLineDash([3, 3]);
+ context.beginPath();
+ context.moveTo(x, plot.top);
+ context.lineTo(x, plot.bottom);
+ context.stroke();
+ context.setLineDash([]);
+ context.fillStyle = colors.accent;
+ context.beginPath();
+ context.arc(x, y, 5, 0, 2 * Math.PI);
+ context.fill();
+ 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 = (canvas: HTMLCanvasElement, payload: ClimbProfilePayload, sections?: OfficialProfileSection[]): void => {
+ const profileSections = sections ?? climbProfileSections(payload);
+ if (profileSections.length === 0) return;
+ drawOfficialSections(canvas, profileSections, resolveOfficialProfileColors());
+};
+
+const bindHover = (state: ClimbProfileState): void => {
+ state.canvas.addEventListener("pointermove", (event: PointerEvent) => {
+ const rect = state.canvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ const plot = officialProfilePlot(state.sections, rect.width, rect.height);
+ if (x < plot.left || x > plot.right) {
+ clearHover(state);
+ return;
+ }
+ const distanceKm = plot.minDistanceKm + ((x - plot.left) / (plot.right - plot.left)) * plot.distanceSpan;
+ drawClimbProfile(state.canvas, state.payload, state.sections);
+ drawHoverMarker(state, distanceKm);
+ });
+ state.canvas.addEventListener("pointerleave", () => clearHover(state));
+ state.canvas.addEventListener("pointercancel", () => clearHover(state));
+};
+
+const init = (): void => {
+ const payloadElement = document.getElementById("climb-profile-data");
+ 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;
+ const hoverOutput = document.querySelector<HTMLOutputElement>("[data-climb-profile-hover]");
+ if (!hoverOutput) return;
+ const state: ClimbProfileState = { canvas, payload, sections, hoverOutput };
+ drawClimbProfile(canvas, payload, sections);
+ bindHover(state);
+};
+
+init();