diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-13 21:55:59 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-13 21:55:59 +0200 |
| commit | e3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 (patch) | |
| tree | ec8234d3442c498ec170b6444bd2cfae5b29a476 /web/frontend/climb-profile.ts | |
| parent | db4d2b40895db41ce188549f49a6f277ba95c890 (diff) | |
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.
Diffstat (limited to 'web/frontend/climb-profile.ts')
| -rw-r--r-- | web/frontend/climb-profile.ts | 110 |
1 files changed, 110 insertions, 0 deletions
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<HTMLCanvasElement>("[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<HTMLOutputElement>("[data-climb-profile-hover]"); + if (!hoverOutput) return; + const state: ClimbProfileState = { canvas, payload, sections, hoverOutput }; + drawClimbProfile(canvas, payload, sections); + bindHover(state); +}; + +init(); |
