From e3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 13 Sep 2026 21:55:59 +0200 Subject: Persist official climb attempts with climbs pages Record one attempt row per (ride, climb, occurrence) when a ride detail loads, snapshotting distance, elevation gain, slope, cotacol and attempt timestamps; a climbs-backfill CLI command seeds all existing rides. New /climbs list page aggregates attempts (medians computed in Go, best time as min duration) with sortable columns, and /climbs/{id} shows the median scalar stats, a median normalized elevation profile (50 steps over each attempt's own length), the full-precision recorded route of the median-distance attempt on an OSM map, and every attempt with time at second precision. --- web/frontend/climb-map.ts | 65 ++++++ web/frontend/climb-profile.ts | 110 +++++++++ web/frontend/official-climb-profile.ts | 413 ++++++++++++++++++--------------- 3 files changed, 402 insertions(+), 186 deletions(-) create mode 100644 web/frontend/climb-map.ts create mode 100644 web/frontend/climb-profile.ts (limited to 'web/frontend') diff --git a/web/frontend/climb-map.ts b/web/frontend/climb-map.ts new file mode 100644 index 0000000..909614d --- /dev/null +++ b/web/frontend/climb-map.ts @@ -0,0 +1,65 @@ +import type { Map as LeafletMap } from "leaflet"; + +interface ClimbMapPayload { + distanceKm: number; + elevations: number[]; + route?: [number, number][]; +} + +interface ClimbMapEndpoints { + start: [number, number]; + end: [number, number]; + name: string; +} + +const mountClimbMap = (): void => { + const element = document.getElementById("climb-map"); + const endpointsElement = document.getElementById("climb-endpoints"); + const profileElement = document.getElementById("climb-profile-data"); + const leaflet = window.L; + if (!element || !endpointsElement || !profileElement || !leaflet) return; + const endpoints = JSON.parse(endpointsElement.textContent ?? "null") as ClimbMapEndpoints | null; + const profile = JSON.parse(profileElement.textContent ?? "null") as ClimbMapPayload | null; + if (!endpoints) return; + const route = (profile?.route ?? []).map(([lon, lat]) => [lat, lon] as [number, number]); + const line: [number, number][] = route.length > 1 ? route : [endpoints.start, endpoints.end]; + + const map: LeafletMap = leaflet.map(element); + leaflet + .tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: '© OpenStreetMap contributors', + }) + .addTo(map); + + const startIcon = leaflet.divIcon({ + className: "ride-map-climb-start-icon", + html: '', + iconSize: [26, 26], + iconAnchor: [13, 13], + }); + const endIcon = leaflet.divIcon({ + className: "ride-map-climb-end-icon", + html: '', + iconSize: [26, 26], + iconAnchor: [13, 13], + }); + leaflet + .polyline(line, { + color: "var(--color-climb-route)", + weight: 4, + opacity: 0.9, + }) + .addTo(map); + leaflet + .marker(endpoints.start, { icon: startIcon, alt: `${endpoints.name} start` }) + .addTo(map) + .bindTooltip("Start"); + leaflet + .marker(endpoints.end, { icon: endIcon, alt: `${endpoints.name} end` }) + .addTo(map) + .bindTooltip("End"); + map.fitBounds(leaflet.latLngBounds(line), { padding: [40, 40], maxZoom: 16 }); +}; + +mountClimbMap(); diff --git a/web/frontend/climb-profile.ts b/web/frontend/climb-profile.ts new file mode 100644 index 0000000..8910349 --- /dev/null +++ b/web/frontend/climb-profile.ts @@ -0,0 +1,110 @@ +import { drawOfficialSections, officialProfilePlot, resolveOfficialProfileColors } from "./official-climb-profile.js"; +import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js"; +import type { OfficialProfileSection } from "./official-climb-profile-logic.js"; +import type { ProfilePoint } from "./types.js"; + +interface ClimbProfilePayload { + distanceKm: number; + elevations: number[]; +} + +interface ClimbProfileState { + canvas: HTMLCanvasElement; + payload: ClimbProfilePayload; + sections: OfficialProfileSection[]; + hoverOutput: HTMLOutputElement; +} + +const climbProfilePoints = (payload: ClimbProfilePayload): ProfilePoint[] => + payload.elevations.map((elevationM, index) => ({ + distanceKm: (payload.distanceKm * index) / (payload.elevations.length - 1), + elevationM, + })); + +const climbProfileSections = (payload: ClimbProfilePayload): OfficialProfileSection[] => { + const points = climbProfilePoints(payload); + if (points.length < 2) return []; + return officialProfileSections(points, 0, points.length - 1, displayStepForLength(payload.distanceKm * 1000)); +}; + +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 clearHover = (state: ClimbProfileState): void => { + drawClimbProfile(state.canvas, state.payload, state.sections); + state.hoverOutput.textContent = ""; +}; + +const drawClimbProfile = (canvas: HTMLCanvasElement, payload: ClimbProfilePayload, sections?: OfficialProfileSection[]): void => { + const profileSections = sections ?? climbProfileSections(payload); + if (profileSections.length === 0) return; + drawOfficialSections(canvas, profileSections, resolveOfficialProfileColors()); +}; + +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.canvas, state.payload, state.sections); + 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.elevations.length < 2) return; + const sections = climbProfileSections(payload); + if (sections.length === 0) return; + const hoverOutput = document.querySelector("[data-climb-profile-hover]"); + if (!hoverOutput) return; + const state: ClimbProfileState = { canvas, payload, sections, hoverOutput }; + drawClimbProfile(canvas, payload, sections); + bindHover(state); +}; + +init(); diff --git a/web/frontend/official-climb-profile.ts b/web/frontend/official-climb-profile.ts index bc851f5..ac4d591 100644 --- a/web/frontend/official-climb-profile.ts +++ b/web/frontend/official-climb-profile.ts @@ -1,6 +1,6 @@ import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js"; +import type { OfficialProfileSection } from "./official-climb-profile-logic.js"; import type { OfficialProfileColors, RideProfilePoint } from "./types.js"; - export class OfficialClimbProfileController { readonly points: RideProfilePoint[]; readonly colors: OfficialProfileColors; @@ -12,43 +12,19 @@ export class OfficialClimbProfileController { 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) return; - for (const other of this.cards) { - if (other !== card) other.open = false; + if (card.open) { + this.redrawOpen(); } - const profileCanvas = card.querySelector("[data-official-profile]"); - if (profileCanvas) this.drawOfficialProfile(profileCanvas); }); } } resolveColors(): 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; + return resolveOfficialProfileColors(); } drawOfficialProfile(profileCanvas: HTMLCanvasElement): void { - const { points, colors } = this; + const { points } = this; const startIndex = Number.parseInt(profileCanvas.dataset.profileStart ?? "", 10); const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd ?? "", 10); if ( @@ -59,175 +35,240 @@ export class OfficialClimbProfileController { startIndex >= endIndex ) return; - 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 = { left: 10, right: Math.max(11, rect.width - 44), top: 16, bottom: rect.height - 34 }; const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000; const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM)); if (sections.length === 0) return; - 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 minDistance = sections[0].startDistanceKm; - const maxDistance = sections[sections.length - 1].endDistanceKm; - const distanceSpan = Math.max(maxDistance - minDistance, 0.1); - const elevationSpan = Math.max(maxElevation - minElevation, 1); - const xForDistance = (distanceKm: number) => plot.left + ((distanceKm - minDistance) / distanceSpan) * (plot.right - plot.left); - const yForElevation = (elevationM: number) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (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 = "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)); + 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); + } } - context.setLineDash([]); - context.strokeStyle = colors.grid; + } +} + +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; +} + +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 maxDistance = plot.maxDistanceKm; + 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); + 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, plot.bottom); - context.lineTo(plot.right, plot.bottom); + context.moveTo(plot.left, y); + context.lineTo(plot.right, y); 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.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(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.moveTo(startX, plot.bottom); + context.lineTo(startX, yForElevation(section.startElevation)); + context.lineTo(endX, yForElevation(section.endElevation)); + context.lineTo(endX, plot.bottom); context.closePath(); - context.clip(); - context.strokeStyle = colors.xGrid; - context.lineWidth = 1; - context.setLineDash([4, 4]); - for (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { - const distanceKm = sectionIndex === sections.length ? maxDistance : sections[sectionIndex].startDistanceKm; - const x = xForDistance(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.globalAlpha = 0.25; + context.fillStyle = colors[section.band]; + context.fill(); + context.globalAlpha = 1; context.beginPath(); - context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top); - context.clip(); - for (const section of sections) { - const startX = xForDistance(section.startDistanceKm); - const endX = xForDistance(section.endDistanceKm); - const label = `${section.slopePercent.toFixed(1)}%`; - context.fillText(label, (startX + endX) / 2, plot.bottom - 4); - } - 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.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 (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { + const distanceKm = sectionIndex === sections.length ? maxDistance : sections[sectionIndex].startDistanceKm; + const x = xForDistance(distanceKm); 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 (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { - const distanceKm = - sectionIndex === sections.length ? maxDistance - minDistance : sections[sectionIndex].startDistanceKm - minDistance; - const x = xForDistance(minDistance + distanceKm); - context.beginPath(); - context.moveTo(x, plot.bottom); - context.lineTo(x, plot.bottom + 3); - context.stroke(); - context.textAlign = sectionIndex === 0 ? "left" : sectionIndex === sections.length ? "right" : "center"; - context.fillText(this.formatDistance(distanceKm), x, plot.bottom + 5); - } + context.moveTo(x, plot.top); + context.lineTo(x, plot.bottom); + context.stroke(); } - - formatDistance(distance: number): string { - return `${distance.toFixed(distance < 10 ? 1 : 0)} km`; + 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(); + for (const section of sections) { + const startX = xForDistance(section.startDistanceKm); + const endX = xForDistance(section.endDistanceKm); + const label = `${section.slopePercent.toFixed(1)}%`; + context.fillText(label, (startX + endX) / 2, plot.bottom - 4); } - - redrawOpen() { - const profileCanvases = document.querySelectorAll("[data-official-profile]"); - for (const profileCanvas of profileCanvases) { - const card = profileCanvas.closest("[data-official-climb-card]"); - if (card?.open) this.drawOfficialProfile(profileCanvas); + 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 (let sectionIndex = 0; sectionIndex <= sections.length; sectionIndex++) { + const distanceKm = sectionIndex === sections.length ? maxDistance - minDistance : sections[sectionIndex].startDistanceKm - minDistance; + const x = xForDistance(minDistance + distanceKm); + context.beginPath(); + context.moveTo(x, plot.bottom); + context.lineTo(x, plot.bottom + 3); + context.stroke(); + context.textAlign = sectionIndex === 0 ? "left" : sectionIndex === sections.length ? "right" : "center"; + context.fillText(formatClimbDistance(distanceKm), x, plot.bottom + 5); } } + +function formatClimbDistance(distance: number): string { + return `${distance.toFixed(distance < 10 ? 1 : 0)} km`; +} -- cgit v1.3