1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
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();
|