import type { OfficialProfileColors, OfficialProfileSection, ProfileBand, ProfileSectionPayload, RideProfile, RideProfileClimb, } from "./types.js"; export class OfficialClimbProfileController { readonly climbs: RideProfileClimb[]; readonly colors: OfficialProfileColors; readonly cards: HTMLDetailsElement[]; constructor(profile: RideProfile) { this.climbs = profile.climbs; this.colors = this.resolveColors(); 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) { this.redrawOpen(); } }); } } resolveColors(): OfficialProfileColors { return resolveOfficialProfileColors(); } drawOfficialProfile(profileCanvas: HTMLCanvasElement): void { const climbIndex = Number.parseInt(profileCanvas.dataset.profileClimbIndex ?? "", 10); const climb = this.climbs.find((candidate) => candidate.index === climbIndex); if (!climb?.sections) return; const sections = officialProfileSections(climb.sections); if (sections.length === 0) return; drawOfficialSections(profileCanvas, sections, this.colors); } redrawOpen() { const profileCanvases = document.querySelectorAll("[data-official-profile]"); if (profileCanvases.length === 0) return; for (const profileCanvas of profileCanvases) { if (profileCanvas.closest("details")?.open) { this.drawOfficialProfile(profileCanvas); } } } } 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; } const profileBandForSlope = (slopePercent: number): ProfileBand => { if (slopePercent <= 0) return "downhill"; if (slopePercent < 3) return "0-3"; if (slopePercent < 6) return "3-6"; if (slopePercent < 9) return "6-9"; return "9-plus"; }; // officialProfileSections turns the sections the server cut out of the trace into the chart shape, // tagging each step with the band its slope falls in. export const officialProfileSections = (sections: ProfileSectionPayload[]): OfficialProfileSection[] => sections.map((section) => ({ startDistanceKm: section.startKm, endDistanceKm: section.endKm, startElevation: section.startElevationM, endElevation: section.endElevationM, slopePercent: section.slopePercent, band: profileBandForSlope(section.slopePercent), })); 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 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); const tickStepKm = tickStepForSpan(plot.distanceSpan); const tickDistancesKm: number[] = []; for (let index = 0; index * tickStepKm <= plot.distanceSpan + 1e-9; index++) { tickDistancesKm.push(index * tickStepKm); } 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)); } 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(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.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 (const distanceKm of tickDistancesKm) { const x = xForDistance(minDistance + 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.beginPath(); context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top); context.clip(); let lastLabelRight = Number.NEGATIVE_INFINITY; for (const section of sections) { const startX = xForDistance(section.startDistanceKm); const endX = xForDistance(section.endDistanceKm); const label = `${section.slopePercent.toFixed(1)}%`; const labelWidth = context.measureText(label).width; const centerX = (startX + endX) / 2; // Adaptive sections can be narrow: skip a slope label that would run into the previous one. if (centerX - labelWidth / 2 < lastLabelRight + 6) continue; context.fillText(label, centerX, plot.bottom - 4); lastLabelRight = centerX + labelWidth / 2; } 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 (const [index, distanceKm] of tickDistancesKm.entries()) { const x = xForDistance(minDistance + distanceKm); context.beginPath(); context.moveTo(x, plot.bottom); context.lineTo(x, plot.bottom + 3); context.stroke(); context.textAlign = index === 0 ? "left" : "center"; context.fillText(formatClimbDistance(distanceKm), x, plot.bottom + 5); } } function formatClimbDistance(distance: number): string { return `${distance.toFixed(distance < 10 ? 1 : 0)} km`; } // The distance axis is cut at a round step, at most eight ticks, whatever the climb length. const tickStepsKm: number[] = [0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10]; const tickStepForSpan = (spanKm: number): number => tickStepsKm.find((stepKm) => spanKm / stepKm <= 8) ?? tickStepsKm[tickStepsKm.length - 1];