aboutsummaryrefslogtreecommitdiff
path: root/web/frontend/climb-attempts.ts
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-25 08:46:58 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-25 08:46:58 +0200
commit077aa1700fbcaf3f499c2178e0e58e89ae9dcaf8 (patch)
tree020d0d8b34ab7b47cf551b50034c1e086a247af3 /web/frontend/climb-attempts.ts
parent90614736f2a0c556f31eca5361b3c032f5f309f6 (diff)
Chart power vs time for official climb attempts
Snapshot average power per attempt in a nullable average_power_w column (backfilled from stored rides), plot each powered attempt on the climb page as duration vs average watts with ride hover and click-through, and show avg power in the attempts table.
Diffstat (limited to 'web/frontend/climb-attempts.ts')
-rw-r--r--web/frontend/climb-attempts.ts148
1 files changed, 148 insertions, 0 deletions
diff --git a/web/frontend/climb-attempts.ts b/web/frontend/climb-attempts.ts
new file mode 100644
index 0000000..e29dffc
--- /dev/null
+++ b/web/frontend/climb-attempts.ts
@@ -0,0 +1,148 @@
+import { ticks } from "./analytics-logic.js";
+import { attemptsDomain, formatAttemptDuration, formatPower } from "./climb-attempts-logic.js";
+import { resolveOfficialProfileColors } from "./official-climb-profile.js";
+import type { ClimbAttemptPoint, OfficialProfileColors } from "./types.js";
+
+interface Plot {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+}
+
+interface AttemptsChartState {
+ canvas: HTMLCanvasElement;
+ points: ClimbAttemptPoint[];
+ colors: OfficialProfileColors;
+ output: HTMLOutputElement;
+ hoveredIndex: number;
+}
+
+const POINT_RADIUS = 4;
+const HOVER_RADIUS = 7;
+const HIT_RADIUS = 12;
+
+const draw = (state: AttemptsChartState): void => {
+ const { canvas, points, colors } = state;
+ const rect = canvas.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ const context = canvas.getContext("2d");
+ if (!context) return;
+ const ratio = window.devicePixelRatio || 1;
+ canvas.width = Math.floor(rect.width * ratio);
+ canvas.height = Math.floor(rect.height * ratio);
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ const plot: Plot = { left: 44, right: rect.width - 16, top: 16, bottom: rect.height - 30 };
+ const domain = attemptsDomain(points);
+ const xForDuration = (durationS: number): number => plot.left + (durationS / domain.maxDurationS) * (plot.right - plot.left);
+ const yForPower = (powerW: number): number => plot.bottom - (powerW / domain.maxPowerW) * (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 = "11px system-ui, sans-serif";
+ context.lineWidth = 1;
+ // Duration ticks step in minutes so labels stay round.
+ for (const tick of ticks(0, domain.maxDurationS / 60)) {
+ const x = xForDuration(tick * 60);
+ context.strokeStyle = colors.grid;
+ context.beginPath();
+ context.moveTo(x, plot.top);
+ context.lineTo(x, plot.bottom);
+ context.stroke();
+ context.fillStyle = colors.subtle;
+ context.textAlign = "center";
+ context.textBaseline = "top";
+ context.fillText(formatAttemptDuration(tick * 60), x, plot.bottom + 8);
+ }
+ for (const tick of ticks(0, domain.maxPowerW)) {
+ const y = yForPower(tick);
+ context.strokeStyle = colors.grid;
+ context.beginPath();
+ context.moveTo(plot.left, y);
+ context.lineTo(plot.right, y);
+ context.stroke();
+ context.fillStyle = colors.subtle;
+ context.textAlign = "right";
+ context.textBaseline = "middle";
+ context.fillText(formatPower(tick), plot.left - 8, y);
+ }
+ for (let index = 0; index < points.length; index++) {
+ const point = points[index];
+ context.fillStyle = colors.accent;
+ context.beginPath();
+ context.arc(xForDuration(point.durationS), yForPower(point.averagePowerW), POINT_RADIUS, 0, 2 * Math.PI);
+ context.fill();
+ if (index === state.hoveredIndex) {
+ context.strokeStyle = colors.accent;
+ context.lineWidth = 2;
+ context.beginPath();
+ context.arc(xForDuration(point.durationS), yForPower(point.averagePowerW), HOVER_RADIUS, 0, 2 * Math.PI);
+ context.stroke();
+ }
+ }
+};
+
+const nearestPoint = (state: AttemptsChartState, x: number, y: number): number => {
+ const { canvas, points } = state;
+ const rect = canvas.getBoundingClientRect();
+ const plot: Plot = { left: 44, right: rect.width - 16, top: 16, bottom: rect.height - 30 };
+ const domain = attemptsDomain(points);
+ let nearest = -1;
+ let best = Infinity;
+ for (let index = 0; index < points.length; index++) {
+ const point = points[index];
+ const dx = plot.left + (point.durationS / domain.maxDurationS) * (plot.right - plot.left) - x;
+ const dy = plot.bottom - (point.averagePowerW / domain.maxPowerW) * (plot.bottom - plot.top) - y;
+ const distance = Math.hypot(dx, dy);
+ if (distance < best) {
+ best = distance;
+ nearest = index;
+ }
+ }
+ return best <= HIT_RADIUS ? nearest : -1;
+};
+
+const setHovered = (state: AttemptsChartState, index: number): void => {
+ if (index === state.hoveredIndex) return;
+ state.hoveredIndex = index;
+ if (index < 0) {
+ state.output.textContent = "Hover a point to inspect an attempt.";
+ } else {
+ const point = state.points[index];
+ state.output.textContent = `${point.date} · ${point.rideName} · ${formatAttemptDuration(point.durationS)} · ${formatPower(point.averagePowerW)} · click for details`;
+ }
+ draw(state);
+};
+
+const bindEvents = (state: AttemptsChartState): void => {
+ state.canvas.addEventListener("pointermove", (event: PointerEvent) => {
+ const rect = state.canvas.getBoundingClientRect();
+ const index = nearestPoint(state, event.clientX - rect.left, event.clientY - rect.top);
+ state.canvas.style.cursor = index >= 0 ? "pointer" : "default";
+ setHovered(state, index);
+ });
+ state.canvas.addEventListener("pointerleave", () => {
+ state.canvas.style.cursor = "default";
+ setHovered(state, -1);
+ });
+ state.canvas.addEventListener("pointercancel", () => setHovered(state, -1));
+ state.canvas.addEventListener("click", () => {
+ if (state.hoveredIndex < 0) return;
+ window.open(state.points[state.hoveredIndex].url, "_blank", "noopener");
+ });
+};
+
+const init = (): void => {
+ const payloadElement = document.getElementById("climb-attempt-points");
+ const canvas = document.querySelector<HTMLCanvasElement>("[data-climb-attempts-chart]");
+ const output = document.querySelector<HTMLOutputElement>("[data-attempts-hover]");
+ if (!payloadElement || !canvas || !output) return;
+ const points = JSON.parse(payloadElement.textContent ?? "[]") as ClimbAttemptPoint[];
+ if (points.length === 0) return;
+ const state: AttemptsChartState = { canvas, points, colors: resolveOfficialProfileColors(), output, hoveredIndex: -1 };
+ draw(state);
+ bindEvents(state);
+ window.addEventListener("resize", () => draw(state));
+};
+
+init();