import { drawOfficialSections, officialProfilePlot, officialProfileSections, resolveOfficialProfileColors, } from "./official-climb-profile.js"; import type { OfficialProfileSection, ProfileSectionPayload } from "./types.js"; interface ClimbProfilePayload { sections: ProfileSectionPayload[]; } interface ClimbProfileState { canvas: HTMLCanvasElement; sections: OfficialProfileSection[]; hoverOutput: HTMLOutputElement; } 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 drawClimbProfile = (state: ClimbProfileState): void => { drawOfficialSections(state.canvas, state.sections, resolveOfficialProfileColors()); }; const clearHover = (state: ClimbProfileState): void => { drawClimbProfile(state); state.hoverOutput.textContent = ""; }; 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); 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("[data-climb-profile]"); if (!payloadElement || !canvas) return; const payload = JSON.parse(payloadElement.textContent ?? "null") as ClimbProfilePayload | null; if (!payload || payload.sections.length === 0) return; const hoverOutput = document.querySelector("[data-climb-profile-hover]"); if (!hoverOutput) return; const state: ClimbProfileState = { canvas, sections: officialProfileSections(payload.sections), hoverOutput }; drawClimbProfile(state); bindHover(state); }; init();