diff options
Diffstat (limited to 'web/frontend')
| -rw-r--r-- | web/frontend/climb-attempts-logic.test.ts | 35 | ||||
| -rw-r--r-- | web/frontend/climb-attempts-logic.ts | 30 | ||||
| -rw-r--r-- | web/frontend/climb-attempts.ts | 148 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 8 |
4 files changed, 221 insertions, 0 deletions
diff --git a/web/frontend/climb-attempts-logic.test.ts b/web/frontend/climb-attempts-logic.test.ts new file mode 100644 index 0000000..6ce7353 --- /dev/null +++ b/web/frontend/climb-attempts-logic.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { attemptsDomain, formatAttemptDuration, formatPower } from "./climb-attempts-logic.ts"; +import type { ClimbAttemptPoint } from "./types.js"; + +const makePoint = (durationS: number, averagePowerW: number): ClimbAttemptPoint => ({ + durationS, + averagePowerW, + date: "01 Sep 2026, 10:00", + rideName: "ride", + url: "/rides/1", +}); + +test("formats attempt durations as mm:ss and h:mm:ss", () => { + assert.equal(formatAttemptDuration(0), "0:00"); + assert.equal(formatAttemptDuration(1110), "18:30"); + assert.equal(formatAttemptDuration(3723), "1:02:03"); + assert.equal(formatAttemptDuration(-5), "0:00"); +}); + +test("formats power in whole watts", () => { + assert.equal(formatPower(235.5), "236 W"); + assert.equal(formatPower(200), "200 W"); +}); + +test("pads zero-based axis domains above the data", () => { + const domain = attemptsDomain([makePoint(100, 250), makePoint(200, 150)]); + assert.equal(domain.maxDurationS, 216); + assert.equal(domain.maxPowerW, 270); +}); + +test("keeps a drawable domain when data is empty or zero", () => { + assert.deepEqual(attemptsDomain([]), { maxDurationS: 1, maxPowerW: 1 }); + assert.deepEqual(attemptsDomain([makePoint(0, 0)]), { maxDurationS: 1, maxPowerW: 1 }); +}); diff --git a/web/frontend/climb-attempts-logic.ts b/web/frontend/climb-attempts-logic.ts new file mode 100644 index 0000000..868545b --- /dev/null +++ b/web/frontend/climb-attempts-logic.ts @@ -0,0 +1,30 @@ +import type { ClimbAttemptPoint } from "./types.js"; + +// formatAttemptDuration renders an attempt duration as "mm:ss", or "h:mm:ss" past an hour. +export const formatAttemptDuration = (durationS: number): string => { + const total = Math.max(0, Math.round(durationS)); + const seconds = total % 60; + const minutes = Math.floor(total / 60) % 60; + const hours = Math.floor(total / 3600); + const two = (value: number): string => String(value).padStart(2, "0"); + return hours > 0 ? `${hours}:${two(minutes)}:${two(seconds)}` : `${minutes}:${two(seconds)}`; +}; + +// formatPower renders an average power in whole watts. +export const formatPower = (powerW: number): string => `${Math.round(powerW)} W`; + +export interface AttemptsDomain { + maxDurationS: number; + maxPowerW: number; +} + +// attemptsDomain returns zero-based axis bounds padded above the data; empty or degenerate data still yields a drawable unit domain. +export const attemptsDomain = (points: ClimbAttemptPoint[]): AttemptsDomain => { + let maxDurationS = 0; + let maxPowerW = 0; + for (const point of points) { + maxDurationS = Math.max(maxDurationS, point.durationS); + maxPowerW = Math.max(maxPowerW, point.averagePowerW); + } + return { maxDurationS: maxDurationS * 1.08 || 1, maxPowerW: maxPowerW * 1.08 || 1 }; +}; 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(); diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts index 74e0765..a933d7d 100644 --- a/web/frontend/types.d.ts +++ b/web/frontend/types.d.ts @@ -134,6 +134,14 @@ export interface AnalyticsPoint { year: number; } +export interface ClimbAttemptPoint { + durationS: number; + averagePowerW: number; + date: string; + rideName: string; + url: string; +} + export interface RateLimit { limit15: number; usage15: number; |
