aboutsummaryrefslogtreecommitdiff
path: root/web/frontend/official-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/official-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/official-climb-profile.ts')
-rw-r--r--web/frontend/official-climb-profile.ts413
1 files changed, 227 insertions, 186 deletions
diff --git a/web/frontend/official-climb-profile.ts b/web/frontend/official-climb-profile.ts
index bc851f5..ac4d591 100644
--- a/web/frontend/official-climb-profile.ts
+++ b/web/frontend/official-climb-profile.ts
@@ -1,6 +1,6 @@
import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js";
+import type { OfficialProfileSection } from "./official-climb-profile-logic.js";
import type { OfficialProfileColors, RideProfilePoint } from "./types.js";
-
export class OfficialClimbProfileController {
readonly points: RideProfilePoint[];
readonly colors: OfficialProfileColors;
@@ -12,43 +12,19 @@ export class OfficialClimbProfileController {
this.cards = [...document.querySelectorAll("[data-official-climb-card]")].map((card) => card as HTMLDetailsElement);
for (const card of this.cards) {
card.addEventListener("toggle", () => {
- if (!card.open) return;
- for (const other of this.cards) {
- if (other !== card) other.open = false;
+ if (card.open) {
+ this.redrawOpen();
}
- const profileCanvas = card.querySelector<HTMLCanvasElement>("[data-official-profile]");
- if (profileCanvas) this.drawOfficialProfile(profileCanvas);
});
}
}
resolveColors(): OfficialProfileColors {
- const colorProbe = document.createElement("span");
- colorProbe.hidden = true;
- document.body.append(colorProbe);
- const resolveColor = (name: string): string => {
- colorProbe.style.color = `var(${name})`;
- return getComputedStyle(colorProbe).color;
- };
- const colors = {
- downhill: resolveColor("--color-profile-downhill"),
- "0-3": resolveColor("--color-profile-0-3"),
- "3-6": resolveColor("--color-profile-3-6"),
- "6-9": resolveColor("--color-profile-6-9"),
- "9-plus": resolveColor("--color-profile-9-plus"),
- plotSurface: resolveColor("--color-plot-surface"),
- grid: resolveColor("--color-plot-grid"),
- xGrid: resolveColor("--color-profile-grid-x"),
- yGrid: resolveColor("--color-profile-grid-y"),
- subtle: resolveColor("--color-subtle"),
- accent: resolveColor("--color-accent"),
- };
- colorProbe.remove();
- return colors;
+ return resolveOfficialProfileColors();
}
drawOfficialProfile(profileCanvas: HTMLCanvasElement): void {
- const { points, colors } = this;
+ const { points } = this;
const startIndex = Number.parseInt(profileCanvas.dataset.profileStart ?? "", 10);
const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd ?? "", 10);
if (
@@ -59,175 +35,240 @@ export class OfficialClimbProfileController {
startIndex >= endIndex
)
return;
- const rect = profileCanvas.getBoundingClientRect();
- if (rect.width === 0 || rect.height === 0) return;
- const context = profileCanvas.getContext("2d");
- if (!context) return;
- const ratio = window.devicePixelRatio || 1;
- profileCanvas.width = Math.floor(rect.width * ratio);
- profileCanvas.height = Math.floor(rect.height * ratio);
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
- const plot = { left: 10, right: Math.max(11, rect.width - 44), top: 16, bottom: rect.height - 34 };
const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000;
const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM));
if (sections.length === 0) return;
- let minElevation = sections[0].startElevation;
- let maxElevation = minElevation;
- for (const section of sections) {
- minElevation = Math.min(minElevation, section.startElevation, section.endElevation);
- maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation);
- }
- const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8);
- minElevation -= elevationPadding;
- maxElevation += elevationPadding;
- const minDistance = sections[0].startDistanceKm;
- const maxDistance = sections[sections.length - 1].endDistanceKm;
- const distanceSpan = Math.max(maxDistance - minDistance, 0.1);
- const elevationSpan = Math.max(maxElevation - minElevation, 1);
- const xForDistance = (distanceKm: number) => plot.left + ((distanceKm - minDistance) / distanceSpan) * (plot.right - plot.left);
- const yForElevation = (elevationM: number) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (plot.bottom - plot.top);
- context.clearRect(0, 0, rect.width, rect.height);
- context.fillStyle = colors.plotSurface;
- context.fillRect(0, 0, rect.width, rect.height);
- context.font = "9px system-ui, sans-serif";
- context.lineWidth = 1;
- context.setLineDash([4, 4]);
- context.strokeStyle = colors.yGrid;
- const yGridElevations: number[] = [];
- for (let elevationM = Math.ceil(minElevation / 100) * 100; elevationM <= maxElevation; elevationM += 100) {
- yGridElevations.push(elevationM);
- }
- context.save();
- context.beginPath();
- context.moveTo(plot.left, plot.bottom);
- context.lineTo(plot.left, yForElevation(sections[0].startElevation));
- for (const section of sections) {
- context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation));
- }
- context.lineTo(plot.right, plot.bottom);
- context.closePath();
- context.clip();
- for (const elevationM of yGridElevations) {
- const y = yForElevation(elevationM);
- context.beginPath();
- context.moveTo(plot.left, y);
- context.lineTo(plot.right, y);
- context.stroke();
- }
- context.restore();
- context.fillStyle = colors.yGrid;
- context.textAlign = "left";
- context.textBaseline = "middle";
- for (const elevationM of yGridElevations) {
- context.fillText(`${elevationM} m`, plot.right + 6, yForElevation(elevationM));
+ drawOfficialSections(profileCanvas, sections, this.colors);
+ }
+
+ redrawOpen() {
+ const profileCanvases = document.querySelectorAll<HTMLCanvasElement>("[data-official-profile]");
+ if (profileCanvases.length === 0) return;
+ for (const profileCanvas of profileCanvases) {
+ if (profileCanvas.closest("details")?.open) {
+ this.drawOfficialProfile(profileCanvas);
+ }
}
- context.setLineDash([]);
- context.strokeStyle = colors.grid;
+ }
+}
+
+export function resolveOfficialProfileColors(): OfficialProfileColors {
+ const colorProbe = document.createElement("span");
+ colorProbe.hidden = true;
+ document.body.append(colorProbe);
+ const resolveColor = (name: string): string => {
+ colorProbe.style.color = `var(${name})`;
+ return getComputedStyle(colorProbe).color;
+ };
+ const colors = {
+ downhill: resolveColor("--color-profile-downhill"),
+ "0-3": resolveColor("--color-profile-0-3"),
+ "3-6": resolveColor("--color-profile-3-6"),
+ "6-9": resolveColor("--color-profile-6-9"),
+ "9-plus": resolveColor("--color-profile-9-plus"),
+ plotSurface: resolveColor("--color-plot-surface"),
+ grid: resolveColor("--color-plot-grid"),
+ xGrid: resolveColor("--color-profile-grid-x"),
+ yGrid: resolveColor("--color-profile-grid-y"),
+ subtle: resolveColor("--color-subtle"),
+ accent: resolveColor("--color-accent"),
+ };
+ colorProbe.remove();
+ return colors;
+}
+
+export interface OfficialProfilePlot {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+ minElevation: number;
+ maxElevation: number;
+ minDistanceKm: number;
+ maxDistanceKm: number;
+ distanceSpan: number;
+}
+
+export const officialProfilePlot = (sections: OfficialProfileSection[], width: number, height: number): OfficialProfilePlot => {
+ let minElevation = sections[0].startElevation;
+ let maxElevation = minElevation;
+ for (const section of sections) {
+ minElevation = Math.min(minElevation, section.startElevation, section.endElevation);
+ maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation);
+ }
+ const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8);
+ minElevation -= elevationPadding;
+ maxElevation += elevationPadding;
+ const minDistanceKm = sections[0].startDistanceKm;
+ const maxDistanceKm = sections[sections.length - 1].endDistanceKm;
+ return {
+ left: 10,
+ right: Math.max(11, width - 44),
+ top: 16,
+ bottom: height - 34,
+ minElevation,
+ maxElevation,
+ minDistanceKm,
+ maxDistanceKm,
+ distanceSpan: Math.max(maxDistanceKm - minDistanceKm, 0.1),
+ };
+};
+
+// drawOfficialSections renders precomputed profile sections onto a canvas.
+// Shared by the ride detail official-climb cards and the climb detail page.
+export function drawOfficialSections(
+ profileCanvas: HTMLCanvasElement,
+ sections: OfficialProfileSection[],
+ colors: OfficialProfileColors,
+): void {
+ const rect = profileCanvas.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ const context = profileCanvas.getContext("2d");
+ if (!context) return;
+ const ratio = window.devicePixelRatio || 1;
+ profileCanvas.width = Math.floor(rect.width * ratio);
+ profileCanvas.height = Math.floor(rect.height * ratio);
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ const plot = officialProfilePlot(sections, rect.width, rect.height);
+ const minElevation = plot.minElevation;
+ const maxElevation = plot.maxElevation;
+ const minDistance = plot.minDistanceKm;
+ const maxDistance = plot.maxDistanceKm;
+ const elevationSpan = Math.max(maxElevation - minElevation, 1);
+ const xForDistance = (distanceKm: number) => plot.left + ((distanceKm - minDistance) / plot.distanceSpan) * (plot.right - plot.left);
+ const yForElevation = (elevationM: number) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (plot.bottom - plot.top);
+ context.clearRect(0, 0, rect.width, rect.height);
+ context.fillStyle = colors.plotSurface;
+ context.fillRect(0, 0, rect.width, rect.height);
+ context.font = "9px system-ui, sans-serif";
+ context.lineWidth = 1;
+ context.setLineDash([4, 4]);
+ context.strokeStyle = colors.yGrid;
+ const yGridElevations: number[] = [];
+ for (let elevationM = Math.ceil(minElevation / 100) * 100; elevationM <= maxElevation; elevationM += 100) {
+ yGridElevations.push(elevationM);
+ }
+ context.save();
+ context.beginPath();
+ context.moveTo(plot.left, plot.bottom);
+ context.lineTo(plot.left, yForElevation(sections[0].startElevation));
+ for (const section of sections) {
+ context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation));
+ }
+ context.lineTo(plot.right, plot.bottom);
+ context.closePath();
+ context.clip();
+ for (const elevationM of yGridElevations) {
+ const y = yForElevation(elevationM);
context.beginPath();
- context.moveTo(plot.left, plot.bottom);
- context.lineTo(plot.right, plot.bottom);
+ context.moveTo(plot.left, y);
+ context.lineTo(plot.right, y);
context.stroke();
- for (const section of sections) {
- const startX = xForDistance(section.startDistanceKm);
- const endX = xForDistance(section.endDistanceKm);
- context.beginPath();
- context.moveTo(startX, plot.bottom);
- context.lineTo(startX, yForElevation(section.startElevation));
- context.lineTo(endX, yForElevation(section.endElevation));
- context.lineTo(endX, plot.bottom);
- context.closePath();
- context.globalAlpha = 0.25;
- context.fillStyle = colors[section.band];
- context.fill();
- context.globalAlpha = 1;
- context.beginPath();
- context.moveTo(startX, yForElevation(section.startElevation));
- context.lineTo(endX, yForElevation(section.endElevation));
- context.strokeStyle = colors[section.band];
- context.lineWidth = 2.5;
- context.stroke();
- }
- context.save();
+ }
+ context.restore();
+ context.fillStyle = colors.yGrid;
+ context.textAlign = "left";
+ context.textBaseline = "middle";
+ for (const elevationM of yGridElevations) {
+ context.fillText(`${elevationM} m`, plot.right + 6, yForElevation(elevationM));
+ }
+ context.setLineDash([]);
+ context.strokeStyle = colors.grid;
+ context.beginPath();
+ context.moveTo(plot.left, plot.bottom);
+ context.lineTo(plot.right, plot.bottom);
+ context.stroke();
+ for (const section of sections) {
+ const startX = xForDistance(section.startDistanceKm);
+ const endX = xForDistance(section.endDistanceKm);
context.beginPath();
- context.moveTo(plot.left, plot.bottom);
- context.lineTo(plot.left, yForElevation(sections[0].startElevation));
- for (const section of sections) {
- context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation));
- }
- context.lineTo(plot.right, plot.bottom);
+ context.moveTo(startX, plot.bottom);
+ context.lineTo(startX, yForElevation(section.startElevation));
+ context.lineTo(endX, yForElevation(section.endElevation));
+ context.lineTo(endX, plot.bottom);
context.closePath();
- context.clip();
- context.strokeStyle = colors.xGrid;
- context.lineWidth = 1;
- context.setLineDash([4, 4]);
- for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) {
- const distanceKm = sectionIndex === sections.length ? maxDistance : sections[sectionIndex].startDistanceKm;
- const x = xForDistance(distanceKm);
- context.beginPath();
- context.moveTo(x, plot.top);
- context.lineTo(x, plot.bottom);
- context.stroke();
- }
- context.restore();
- context.setLineDash([]);
- context.font = "9px system-ui, sans-serif";
- context.fillStyle = colors.subtle;
- context.textBaseline = "bottom";
- context.textAlign = "center";
- context.save();
+ context.globalAlpha = 0.25;
+ context.fillStyle = colors[section.band];
+ context.fill();
+ context.globalAlpha = 1;
context.beginPath();
- context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
- context.clip();
- for (const section of sections) {
- const startX = xForDistance(section.startDistanceKm);
- const endX = xForDistance(section.endDistanceKm);
- const label = `${section.slopePercent.toFixed(1)}%`;
- context.fillText(label, (startX + endX) / 2, plot.bottom - 4);
- }
- context.restore();
- let topElevation = sections[0].startElevation;
- let topDistance = sections[0].startDistanceKm;
- for (const section of sections) {
- if (section.startElevation > topElevation) {
- topElevation = section.startElevation;
- topDistance = section.startDistanceKm;
- }
- if (section.endElevation > topElevation) {
- topElevation = section.endElevation;
- topDistance = section.endDistanceKm;
- }
- }
- context.fillStyle = colors.accent;
+ context.moveTo(startX, yForElevation(section.startElevation));
+ context.lineTo(endX, yForElevation(section.endElevation));
+ context.strokeStyle = colors[section.band];
+ context.lineWidth = 2.5;
+ context.stroke();
+ }
+ context.save();
+ context.beginPath();
+ context.moveTo(plot.left, plot.bottom);
+ context.lineTo(plot.left, yForElevation(sections[0].startElevation));
+ for (const section of sections) {
+ context.lineTo(xForDistance(section.endDistanceKm), yForElevation(section.endElevation));
+ }
+ context.lineTo(plot.right, plot.bottom);
+ context.closePath();
+ context.clip();
+ context.strokeStyle = colors.xGrid;
+ context.lineWidth = 1;
+ context.setLineDash([4, 4]);
+ for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) {
+ const distanceKm = sectionIndex === sections.length ? maxDistance : sections[sectionIndex].startDistanceKm;
+ const x = xForDistance(distanceKm);
context.beginPath();
- context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI);
- context.fill();
- context.font = "9px system-ui, sans-serif";
- context.fillStyle = colors.subtle;
- context.textBaseline = "top";
- context.strokeStyle = colors.grid;
- context.lineWidth = 1;
- for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) {
- const distanceKm =
- sectionIndex === sections.length ? maxDistance - minDistance : sections[sectionIndex].startDistanceKm - minDistance;
- const x = xForDistance(minDistance + distanceKm);
- context.beginPath();
- context.moveTo(x, plot.bottom);
- context.lineTo(x, plot.bottom + 3);
- context.stroke();
- context.textAlign = sectionIndex === 0 ? "left" : sectionIndex === sections.length ? "right" : "center";
- context.fillText(this.formatDistance(distanceKm), x, plot.bottom + 5);
- }
+ context.moveTo(x, plot.top);
+ context.lineTo(x, plot.bottom);
+ context.stroke();
}
-
- formatDistance(distance: number): string {
- return `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
+ context.restore();
+ context.setLineDash([]);
+ context.font = "9px system-ui, sans-serif";
+ context.fillStyle = colors.subtle;
+ context.textBaseline = "bottom";
+ context.textAlign = "center";
+ context.save();
+ context.beginPath();
+ context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
+ context.clip();
+ for (const section of sections) {
+ const startX = xForDistance(section.startDistanceKm);
+ const endX = xForDistance(section.endDistanceKm);
+ const label = `${section.slopePercent.toFixed(1)}%`;
+ context.fillText(label, (startX + endX) / 2, plot.bottom - 4);
}
-
- redrawOpen() {
- const profileCanvases = document.querySelectorAll<HTMLCanvasElement>("[data-official-profile]");
- for (const profileCanvas of profileCanvases) {
- const card = profileCanvas.closest<HTMLDetailsElement>("[data-official-climb-card]");
- if (card?.open) this.drawOfficialProfile(profileCanvas);
+ context.restore();
+ let topElevation = sections[0].startElevation;
+ let topDistance = sections[0].startDistanceKm;
+ for (const section of sections) {
+ if (section.startElevation > topElevation) {
+ topElevation = section.startElevation;
+ topDistance = section.startDistanceKm;
}
+ if (section.endElevation > topElevation) {
+ topElevation = section.endElevation;
+ topDistance = section.endDistanceKm;
+ }
+ }
+ context.fillStyle = colors.accent;
+ context.beginPath();
+ context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI);
+ context.fill();
+ context.font = "9px system-ui, sans-serif";
+ context.fillStyle = colors.subtle;
+ context.textBaseline = "top";
+ context.strokeStyle = colors.grid;
+ context.lineWidth = 1;
+ for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) {
+ const distanceKm = sectionIndex === sections.length ? maxDistance - minDistance : sections[sectionIndex].startDistanceKm - minDistance;
+ const x = xForDistance(minDistance + distanceKm);
+ context.beginPath();
+ context.moveTo(x, plot.bottom);
+ context.lineTo(x, plot.bottom + 3);
+ context.stroke();
+ context.textAlign = sectionIndex === 0 ? "left" : sectionIndex === sections.length ? "right" : "center";
+ context.fillText(formatClimbDistance(distanceKm), x, plot.bottom + 5);
}
}
+
+function formatClimbDistance(distance: number): string {
+ return `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
+}