aboutsummaryrefslogtreecommitdiff
path: root/web/frontend/climb-profile.ts
blob: 89103497b0e41eb7e8b821d8a1a12ddd624b1c8b (plain) (blame)
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();