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("[data-climb-attempts-chart]"); const output = document.querySelector("[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();