summaryrefslogtreecommitdiff
path: root/web/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'web/frontend')
-rw-r--r--web/frontend/dom.ts4
-rw-r--r--web/frontend/official-climb-profile-logic.test.ts37
-rw-r--r--web/frontend/official-climb-profile-logic.ts56
-rw-r--r--web/frontend/official-climb-profile.ts169
-rw-r--r--web/frontend/ride-detail-boundaries.ts123
-rw-r--r--web/frontend/ride-detail-canvas.ts263
-rw-r--r--web/frontend/ride-detail-logic.test.ts46
-rw-r--r--web/frontend/ride-detail-logic.ts84
-rw-r--r--web/frontend/ride-detail-map.ts130
-rw-r--r--web/frontend/ride-detail.ts142
-rw-r--r--web/frontend/sync.ts105
-rw-r--r--web/frontend/types.d.ts116
12 files changed, 1275 insertions, 0 deletions
diff --git a/web/frontend/dom.ts b/web/frontend/dom.ts
new file mode 100644
index 0000000..a3a1765
--- /dev/null
+++ b/web/frontend/dom.ts
@@ -0,0 +1,4 @@
+export const requireElement = <T extends Element>(element: T | null, description: string): T => {
+ if (!element) throw new Error(`Missing ${description} element`);
+ return element;
+};
diff --git a/web/frontend/official-climb-profile-logic.test.ts b/web/frontend/official-climb-profile-logic.test.ts
new file mode 100644
index 0000000..31391b2
--- /dev/null
+++ b/web/frontend/official-climb-profile-logic.test.ts
@@ -0,0 +1,37 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { displayStepForLength, profileBandForSlope, officialProfileSections } from "./official-climb-profile-logic.ts";
+
+test("selects a display step that keeps long profiles readable", () => {
+ assert.equal(displayStepForLength(2_400), 100);
+ assert.equal(displayStepForLength(4_500), 200);
+ assert.equal(displayStepForLength(7_000), 500);
+ assert.equal(displayStepForLength(20_000), 1000);
+});
+
+test("assigns each slope to its color band", () => {
+ assert.equal(profileBandForSlope(-0.1), "downhill");
+ assert.equal(profileBandForSlope(2.9), "0-3");
+ assert.equal(profileBandForSlope(3), "3-6");
+ assert.equal(profileBandForSlope(6), "6-9");
+ assert.equal(profileBandForSlope(9), "9-12");
+ assert.equal(profileBandForSlope(12), "12-plus");
+});
+
+test("interpolates profile sections at the selected step", () => {
+ const points = [
+ { distanceKm: 0, elevationM: 100 },
+ { distanceKm: 0.12, elevationM: 106 },
+ { distanceKm: 0.2, elevationM: 114 },
+ { distanceKm: 0.25, elevationM: 120 },
+ ];
+ const sections = officialProfileSections(points, 0, 3, 100);
+
+ assert.equal(sections.length, 3);
+ assert.equal(sections[0].startDistanceKm, 0);
+ assert.equal(sections[0].endDistanceKm, 0.1);
+ assert.equal(sections[0].startElevation, 100);
+ assert.equal(sections[0].endElevation, 105);
+ assert.equal(sections[2].endDistanceKm, 0.25);
+ assert.equal(sections[2].endElevation, 120);
+});
diff --git a/web/frontend/official-climb-profile-logic.ts b/web/frontend/official-climb-profile-logic.ts
new file mode 100644
index 0000000..1b2bc83
--- /dev/null
+++ b/web/frontend/official-climb-profile-logic.ts
@@ -0,0 +1,56 @@
+import type { ProfileBand, ProfilePoint } from "./types.js";
+
+export interface OfficialProfileSection {
+ startDistanceKm: number;
+ endDistanceKm: number;
+ startElevation: number;
+ endElevation: number;
+ slopePercent: number;
+ band: ProfileBand;
+}
+
+const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
+const profileStepSizesM: number[] = [100, 200, 500, 1000];
+const displayStepForLength = (lengthM: number): number =>
+ profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1];
+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";
+ if (slopePercent < 12) return "9-12";
+ return "12-plus";
+};
+const officialProfileSections = (points: ProfilePoint[], startIndex: number, endIndex: number, stepM: number): OfficialProfileSection[] => {
+ const startDistanceM = points[startIndex].distanceKm * 1000;
+ const endDistanceM = points[endIndex].distanceKm * 1000;
+ const sections: OfficialProfileSection[] = [];
+ let pointIndex = startIndex;
+ const elevationAtDistance = (distanceM: number) => {
+ while (pointIndex < endIndex - 1 && points[pointIndex + 1].distanceKm * 1000 < distanceM) pointIndex++;
+ const nextIndex = Math.min(pointIndex + 1, endIndex);
+ const first = points[pointIndex];
+ const next = points[nextIndex];
+ const distanceSpan = next.distanceKm * 1000 - first.distanceKm * 1000;
+ if (distanceSpan <= 0) return first.elevationM;
+ const fraction = (distanceM - first.distanceKm * 1000) / distanceSpan;
+ return first.elevationM + clamp(fraction, 0, 1) * (next.elevationM - first.elevationM);
+ };
+ for (let sectionStartM = startDistanceM; sectionStartM < endDistanceM; sectionStartM += stepM) {
+ const sectionEndM = Math.min(sectionStartM + stepM, endDistanceM);
+ const startElevation = elevationAtDistance(sectionStartM);
+ const endElevation = elevationAtDistance(sectionEndM);
+ const slopePercent = ((endElevation - startElevation) / (sectionEndM - sectionStartM)) * 100;
+ sections.push({
+ startDistanceKm: sectionStartM / 1000,
+ endDistanceKm: sectionEndM / 1000,
+ startElevation,
+ endElevation,
+ slopePercent,
+ band: profileBandForSlope(slopePercent),
+ });
+ }
+ return sections;
+};
+
+export { displayStepForLength, profileBandForSlope, officialProfileSections };
diff --git a/web/frontend/official-climb-profile.ts b/web/frontend/official-climb-profile.ts
new file mode 100644
index 0000000..82ecc01
--- /dev/null
+++ b/web/frontend/official-climb-profile.ts
@@ -0,0 +1,169 @@
+import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js";
+import type { OfficialProfileColors, RideProfilePoint } from "./types.js";
+
+export class OfficialClimbProfileController {
+ readonly points: RideProfilePoint[];
+ readonly colors: OfficialProfileColors;
+ readonly cards: HTMLDetailsElement[];
+
+ constructor(points: RideProfilePoint[]) {
+ this.points = points;
+ 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) return;
+ for (const other of this.cards) {
+ if (other !== card) other.open = false;
+ }
+ const profileCanvas = card.querySelector<HTMLCanvasElement>("[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-12": resolveColor("--color-profile-9-12"),
+ "12-plus": resolveColor("--color-profile-12-plus"),
+ plotSurface: resolveColor("--color-plot-surface"),
+ grid: resolveColor("--color-plot-grid"),
+ subtle: resolveColor("--color-subtle"),
+ accent: resolveColor("--color-accent"),
+ };
+ colorProbe.remove();
+ return colors;
+ }
+
+ drawOfficialProfile(profileCanvas: HTMLCanvasElement): void {
+ const { points, colors } = this;
+ const startIndex = Number.parseInt(profileCanvas.dataset.profileStart ?? "", 10);
+ const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd ?? "", 10);
+ if (
+ !Number.isInteger(startIndex) ||
+ !Number.isInteger(endIndex) ||
+ startIndex < 0 ||
+ endIndex >= points.length ||
+ 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: rect.width - 10, top: 16, bottom: rect.height - 24 };
+ 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.strokeStyle = colors.grid;
+ context.lineWidth = 1;
+ 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.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)}%`;
+ const labelY = Math.max(plot.top + 10, Math.min(yForElevation(section.startElevation), yForElevation(section.endElevation)) - 4);
+ context.fillText(label, (startX + endX) / 2, labelY);
+ }
+ 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 = "11px system-ui, sans-serif";
+ context.fillStyle = colors.subtle;
+ context.textBaseline = "top";
+ context.textAlign = "left";
+ context.fillText(this.formatDistance(0), plot.left, rect.height - 16);
+ context.textAlign = "right";
+ context.fillText(this.formatDistance(maxDistance - minDistance), plot.right, rect.height - 16);
+ }
+
+ formatDistance(distance: number): string {
+ return `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
+ }
+
+ redrawOpen() {
+ const profileCanvases = document.querySelectorAll<HTMLCanvasElement>("[data-official-profile]");
+ for (const profileCanvas of profileCanvases) {
+ const card = profileCanvas.closest<HTMLDetailsElement>("[data-official-climb-card]");
+ if (card?.open) this.drawOfficialProfile(profileCanvas);
+ }
+ }
+}
diff --git a/web/frontend/ride-detail-boundaries.ts b/web/frontend/ride-detail-boundaries.ts
new file mode 100644
index 0000000..22a1255
--- /dev/null
+++ b/web/frontend/ride-detail-boundaries.ts
@@ -0,0 +1,123 @@
+import { climbMetrics, formatDistance, formatElevation } from "./ride-detail-logic.js";
+import { requireElement } from "./dom.js";
+import type { BoundarySource, BoundaryTarget, ClimbBounds, RideProfilePoint } from "./types.js";
+
+interface ActiveBoundary {
+ form: HTMLFormElement;
+ target: BoundaryTarget;
+ source: BoundarySource;
+}
+
+interface RideBoundaryControllerOptions {
+ forms: HTMLFormElement[];
+ points: RideProfilePoint[];
+ climbBounds: ClimbBounds[];
+ onBoundaryChanged: (index: number) => void;
+ onPointSelected: (index: number) => void;
+ setMessage: (message: string) => void;
+}
+
+export class RideBoundaryController {
+ private readonly forms: HTMLFormElement[];
+ private readonly points: RideProfilePoint[];
+ private readonly climbBounds: ClimbBounds[];
+ private readonly onBoundaryChanged: (index: number) => void;
+ private readonly onPointSelected: (index: number) => void;
+ private readonly setMessage: (message: string) => void;
+ private activeBoundary: ActiveBoundary | null;
+
+ constructor({ forms, points, climbBounds, onBoundaryChanged, onPointSelected, setMessage }: RideBoundaryControllerOptions) {
+ this.forms = forms;
+ this.points = points;
+ this.climbBounds = climbBounds;
+ this.onBoundaryChanged = onBoundaryChanged;
+ this.onPointSelected = onPointSelected;
+ this.setMessage = setMessage;
+ this.activeBoundary = null;
+ this.initialize();
+ }
+
+ initialize(): void {
+ for (const form of this.forms) {
+ const item = form.closest<HTMLElement>("[data-climb-item]");
+ if (!item) continue;
+ const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10);
+ this.climbBounds[climbIndex] = {
+ startIndex: Number.parseInt(
+ requireElement(form.querySelector<HTMLInputElement>('[data-boundary-input="start"]'), "start boundary input").value,
+ 10,
+ ),
+ endIndex: Number.parseInt(
+ requireElement(form.querySelector<HTMLInputElement>('[data-boundary-input="end"]'), "end boundary input").value,
+ 10,
+ ),
+ };
+ this.updatePreview(form);
+ const buttons = [...form.querySelectorAll<HTMLElement>("[data-boundary-button]")];
+ for (const button of buttons) {
+ button.addEventListener("click", () => {
+ this.clearSelection();
+ this.activeBoundary = {
+ form,
+ target: button.dataset.boundaryButton as BoundaryTarget,
+ source: button.dataset.boundarySource as BoundarySource,
+ };
+ button.classList.add("active");
+ const surface = this.activeBoundary.source === "map" ? "map" : "profile";
+ this.setMessage(`Click the ${surface} to select the ${this.activeBoundary.target} point.`);
+ });
+ }
+ }
+ }
+
+ isSelecting(source: BoundarySource): boolean {
+ return this.activeBoundary?.source === source;
+ }
+
+ clearSelection(): void {
+ this.activeBoundary = null;
+ for (const form of this.forms) {
+ for (const button of form.querySelectorAll("[data-boundary-button]")) button.classList.remove("active");
+ }
+ }
+
+ choosePoint(index: number): void {
+ if (!this.activeBoundary) return;
+ const { form, target } = this.activeBoundary;
+ const item = form.closest<HTMLElement>("[data-climb-item]");
+ if (!item) return;
+ const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10);
+ const input = requireElement(form.querySelector<HTMLInputElement>(`[data-boundary-input="${target}"]`), `${target} boundary input`);
+ const output = requireElement(form.querySelector<HTMLOutputElement>(`[data-boundary-output="${target}"]`), `${target} boundary output`);
+ input.value = String(index);
+ output.textContent = this.boundaryLabel(index);
+ if (target === "start") this.climbBounds[climbIndex].startIndex = index;
+ else this.climbBounds[climbIndex].endIndex = index;
+ this.updatePreview(form);
+ this.onBoundaryChanged(climbIndex);
+ this.clearSelection();
+ this.onPointSelected(index);
+ }
+
+ updatePreview(form: HTMLFormElement): void {
+ const preview = requireElement(form.querySelector<HTMLElement>("[data-boundary-preview]"), "boundary preview");
+ const item = form.closest<HTMLElement>("[data-climb-item]");
+ if (!item) return;
+ const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10);
+ const metrics = climbMetrics(this.points, this.climbBounds[climbIndex]);
+ if (!metrics) {
+ preview.textContent = "Choose an end point after the start point.";
+ return;
+ }
+ const summary = requireElement(item.querySelector<HTMLElement>("[data-climb-summary]"), "climb summary");
+ const metricsOutput = requireElement(item.querySelector<HTMLElement>("[data-climb-metrics]"), "climb metrics");
+ summary.textContent = `${formatDistance(metrics.start.distanceKm)}–${formatDistance(metrics.end.distanceKm)}`;
+ metricsOutput.textContent = `${metrics.category} · ${formatDistance(metrics.distanceKm)} at ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`;
+ preview.textContent = `Preview: ${formatDistance(metrics.distanceKm)} · ${metrics.elevationGain >= 0 ? "+" : ""}${formatElevation(metrics.elevationGain)} · ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`;
+ }
+
+ boundaryLabel(index: number): string {
+ const point = this.points[index];
+ return `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
+ }
+}
diff --git a/web/frontend/ride-detail-canvas.ts b/web/frontend/ride-detail-canvas.ts
new file mode 100644
index 0000000..1453013
--- /dev/null
+++ b/web/frontend/ride-detail-canvas.ts
@@ -0,0 +1,263 @@
+import { clamp, climbMetrics, formatDistance, formatElevation, nearestPointIndex } from "./ride-detail-logic.js";
+import type { BoundarySource, ClimbBounds, RideDetailColors, RideProfile, RideProfilePoint } from "./types.js";
+
+interface Plot {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+}
+
+interface RideProfileCanvasOptions {
+ canvas: HTMLCanvasElement;
+ points: RideProfilePoint[];
+ profile: RideProfile;
+ colors: RideDetailColors;
+ getClimbBounds: () => ClimbBounds[];
+ canSelectPoint: (source: BoundarySource) => boolean;
+ onPointSelected: (index: number) => void;
+ onPointHover: (index: number) => void;
+}
+
+export class RideProfileCanvas {
+ private readonly canvas: HTMLCanvasElement;
+ private readonly points: RideProfilePoint[];
+ private readonly profile: RideProfile;
+ private readonly colors: RideDetailColors;
+ private readonly getClimbBounds: () => ClimbBounds[];
+ private readonly canSelectPoint: (source: BoundarySource) => boolean;
+ private readonly onPointSelected: (index: number) => void;
+ private readonly onPointHover: (index: number) => void;
+ private readonly context: CanvasRenderingContext2D;
+ private hoveredIndex: number;
+ private focusedClimbIndex: number;
+ private plot: Plot | null;
+ private readonly minDistance: number;
+ private readonly maxDistance: number;
+ private readonly minElevation: number;
+ private readonly maxElevation: number;
+
+ constructor({
+ canvas,
+ points,
+ profile,
+ colors,
+ getClimbBounds,
+ canSelectPoint,
+ onPointSelected,
+ onPointHover,
+ }: RideProfileCanvasOptions) {
+ this.canvas = canvas;
+ this.points = points;
+ this.profile = profile;
+ this.colors = colors;
+ this.getClimbBounds = getClimbBounds;
+ this.canSelectPoint = canSelectPoint;
+ this.onPointSelected = onPointSelected;
+ this.onPointHover = onPointHover;
+ const context = canvas.getContext("2d");
+ if (!context) throw new Error("Ride profile canvas is unavailable");
+ this.context = context;
+ this.hoveredIndex = -1;
+ this.focusedClimbIndex = 0;
+ this.plot = null;
+ this.minDistance = points[0].distanceKm;
+ this.maxDistance = points[points.length - 1].distanceKm;
+ let minElevation = points[0].elevationM;
+ let maxElevation = points[0].elevationM;
+ for (const point of points) {
+ minElevation = Math.min(minElevation, point.elevationM);
+ maxElevation = Math.max(maxElevation, point.elevationM);
+ }
+ const elevationPadding = Math.max((maxElevation - minElevation) * 0.1, 20);
+ this.minElevation = minElevation - elevationPadding;
+ this.maxElevation = maxElevation + elevationPadding;
+ this.bindEvents();
+ }
+
+ setFocusedClimbIndex(index: number): void {
+ this.focusedClimbIndex = index;
+ this.draw();
+ }
+
+ redraw(): void {
+ this.draw();
+ }
+
+ xForDistance(distance: number): number {
+ const plot = this.plot;
+ if (!plot) throw new Error("Ride profile canvas has not been drawn");
+ const span = Math.max(this.maxDistance - this.minDistance, 1);
+ return plot.left + ((distance - this.minDistance) / span) * (plot.right - plot.left);
+ }
+
+ yForElevation(elevation: number): number {
+ const plot = this.plot;
+ if (!plot) throw new Error("Ride profile canvas has not been drawn");
+ const span = Math.max(this.maxElevation - this.minElevation, 1);
+ return plot.bottom - ((elevation - this.minElevation) / span) * (plot.bottom - plot.top);
+ }
+
+ draw(): void {
+ const rect = this.canvas.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ const ratio = window.devicePixelRatio || 1;
+ this.canvas.width = Math.floor(rect.width * ratio);
+ this.canvas.height = Math.floor(rect.height * ratio);
+ this.context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ const plot: Plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 };
+ this.plot = plot;
+ this.context.clearRect(0, 0, rect.width, rect.height);
+ this.context.fillStyle = this.colors.plotSurface;
+ this.context.fillRect(0, 0, rect.width, rect.height);
+
+ this.context.font = "12px system-ui, sans-serif";
+ this.context.textBaseline = "middle";
+ for (let step = 0; step <= 4; step++) {
+ const fraction = step / 4;
+ const y = plot.top + fraction * (plot.bottom - plot.top);
+ const elevation = this.maxElevation - fraction * (this.maxElevation - this.minElevation);
+ this.context.strokeStyle = this.colors.grid;
+ this.context.lineWidth = 1;
+ this.context.beginPath();
+ this.context.moveTo(plot.left, y);
+ this.context.lineTo(plot.right, y);
+ this.context.stroke();
+ this.context.fillStyle = this.colors.subtle;
+ this.context.textAlign = "right";
+ this.context.fillText(formatElevation(elevation), plot.left - 8, y);
+ }
+
+ const climbBounds = this.getClimbBounds();
+ for (let climbIndex = 0; climbIndex < (this.profile.climbs || []).length; climbIndex++) {
+ const climb = this.profile.climbs[climbIndex];
+ const metrics = climbMetrics(this.points, climbBounds[climbIndex]);
+ if (!metrics) continue;
+ const startX = clamp(this.xForDistance(metrics.start.distanceKm), plot.left, plot.right);
+ const endX = clamp(this.xForDistance(metrics.end.distanceKm), plot.left, plot.right);
+ this.context.fillStyle = climbIndex === this.focusedClimbIndex ? this.colors.climbFocusFill : this.colors.accentFill;
+ this.context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top);
+ const label = climb.name || `${metrics.category} ${Math.round(metrics.score)}`;
+ this.context.fillStyle = this.colors.climbLabel;
+ this.context.textAlign = "center";
+ this.context.textBaseline = "top";
+ this.context.fillText(label, clamp((startX + endX) / 2, plot.left + 24, plot.right - 24), plot.top + 4);
+ }
+
+ for (const crossing of this.profile.crossings || []) {
+ const x = this.xForDistance(crossing.distanceKm);
+ this.context.strokeStyle = this.colors.crossing;
+ this.context.lineWidth = 1;
+ this.context.setLineDash([4, 3]);
+ this.context.beginPath();
+ this.context.moveTo(x, plot.top);
+ this.context.lineTo(x, plot.bottom);
+ this.context.stroke();
+ this.context.setLineDash([]);
+ }
+
+ this.context.beginPath();
+ this.context.moveTo(this.xForDistance(this.points[0].distanceKm), plot.bottom);
+ for (const point of this.points) this.context.lineTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM));
+ this.context.lineTo(this.xForDistance(this.points[this.points.length - 1].distanceKm), plot.bottom);
+ this.context.closePath();
+ this.context.fillStyle = this.colors.accentFill;
+ this.context.fill();
+ this.context.beginPath();
+ for (let index = 0; index < this.points.length; index++) {
+ const point = this.points[index];
+ if (index === 0) this.context.moveTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM));
+ else this.context.lineTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM));
+ }
+ this.context.strokeStyle = this.colors.accent;
+ this.context.lineWidth = 2.5;
+ this.context.stroke();
+ for (const crossing of this.profile.crossings || []) {
+ const x = this.xForDistance(crossing.distanceKm);
+ const label = `${crossing.name} ${Math.round(crossing.passElevationM)} m`;
+ const labelWidth = this.context.measureText(label).width;
+ const labelX = clamp(x, plot.left + labelWidth / 2 + 3, plot.right - labelWidth / 2 - 3);
+ const labelY = plot.top - 6;
+ this.context.fillStyle = this.colors.plotSurfaceOverlay;
+ this.context.fillRect(labelX - labelWidth / 2 - 3, labelY - 15, labelWidth + 6, 16);
+ this.context.fillStyle = this.colors.crossingLabel;
+ this.context.textAlign = "center";
+ this.context.textBaseline = "bottom";
+ this.context.fillText(label, labelX, labelY);
+ }
+
+ this.context.fillStyle = this.colors.subtle;
+ this.context.textAlign = "center";
+ this.context.textBaseline = "top";
+ for (let step = 0; step <= 4; step++) {
+ const distance = this.minDistance + (step / 4) * (this.maxDistance - this.minDistance);
+ this.context.fillText(formatDistance(distance), this.xForDistance(distance), plot.bottom + 10);
+ }
+
+ if (this.hoveredIndex >= 0) {
+ const point = this.points[this.hoveredIndex];
+ const x = this.xForDistance(point.distanceKm);
+ const y = this.yForElevation(point.elevationM);
+ this.context.strokeStyle = this.colors.hoverLine;
+ this.context.lineWidth = 1;
+ this.context.setLineDash([3, 3]);
+ this.context.beginPath();
+ this.context.moveTo(x, plot.top);
+ this.context.lineTo(x, plot.bottom);
+ this.context.stroke();
+ this.context.setLineDash([]);
+ this.context.fillStyle = this.colors.forest;
+ this.context.beginPath();
+ this.context.arc(x, y, 5, 0, 2 * Math.PI);
+ this.context.fill();
+ }
+ }
+
+ clearHover(): void {
+ this.hoveredIndex = -1;
+ this.onPointHover(-1);
+ this.draw();
+ }
+
+ showPoint(index: number): void {
+ this.hoveredIndex = clamp(index, 0, this.points.length - 1);
+ this.onPointHover(this.hoveredIndex);
+ this.draw();
+ }
+
+ pointIndexAtX(x: number): number {
+ const plot = this.plot;
+ if (!plot) throw new Error("Ride profile canvas has not been drawn");
+ const distance = this.minDistance + ((x - plot.left) / (plot.right - plot.left)) * (this.maxDistance - this.minDistance);
+ return nearestPointIndex(this.points, distance);
+ }
+
+ bindEvents(): void {
+ this.canvas.addEventListener("pointermove", (event: PointerEvent) => {
+ if (!this.plot) return;
+ const rect = this.canvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ if (x < this.plot.left || x > this.plot.right) {
+ this.clearHover();
+ return;
+ }
+ this.showPoint(this.pointIndexAtX(x));
+ });
+ this.canvas.addEventListener("pointerleave", () => this.clearHover());
+ this.canvas.addEventListener("pointercancel", () => this.clearHover());
+ this.canvas.addEventListener("click", (event: MouseEvent) => {
+ if (!this.canSelectPoint("profile") || !this.plot) return;
+ const rect = this.canvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ if (x < this.plot.left || x > this.plot.right) return;
+ this.onPointSelected(this.pointIndexAtX(x));
+ });
+ this.canvas.addEventListener("keydown", (event: KeyboardEvent) => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
+ event.preventDefault();
+ const direction = event.key === "ArrowRight" ? 1 : -1;
+ const index = this.hoveredIndex < 0 ? (direction > 0 ? 0 : this.points.length - 1) : this.hoveredIndex + direction;
+ this.showPoint(index);
+ });
+ }
+}
diff --git a/web/frontend/ride-detail-logic.test.ts b/web/frontend/ride-detail-logic.test.ts
new file mode 100644
index 0000000..024f018
--- /dev/null
+++ b/web/frontend/ride-detail-logic.test.ts
@@ -0,0 +1,46 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ categoryForScore,
+ climbMetrics,
+ cotacolForClimb,
+ elevationAtDistance,
+ formatDistance,
+ formatElevation,
+ nearestPointIndex,
+} from "./ride-detail-logic.ts";
+
+const points = [
+ { distanceKm: 0, elevationM: 100 },
+ { distanceKm: 0.12, elevationM: 106 },
+ { distanceKm: 0.2, elevationM: 114 },
+ { distanceKm: 0.25, elevationM: 120 },
+];
+
+test("finds the nearest profile point by distance", () => {
+ assert.equal(nearestPointIndex(points, 0.01), 0);
+ assert.equal(nearestPointIndex(points, 0.15), 1);
+ assert.equal(nearestPointIndex(points, 0.24), 3);
+});
+
+test("interpolates elevation between route points", () => {
+ assert.equal(elevationAtDistance(points, 0, 100), 105);
+ assert.equal(elevationAtDistance(points, 2, 250), 120);
+});
+
+test("calculates climb metrics with the Cotacol score", () => {
+ const metrics = climbMetrics(points, { startIndex: 0, endIndex: 3 });
+ assert.ok(metrics);
+
+ assert.equal(metrics.distanceKm, 0.25);
+ assert.equal(metrics.elevationGain, 20);
+ assert.equal(metrics.slope, 8);
+ assert.equal(metrics.cotacol, cotacolForClimb(points, 0, 3));
+ assert.equal(metrics.category, categoryForScore(metrics.score));
+});
+
+test("formats profile labels consistently", () => {
+ assert.equal(formatDistance(2.4), "2.4 km");
+ assert.equal(formatDistance(12.4), "12 km");
+ assert.equal(formatElevation(123.6), "124 m");
+});
diff --git a/web/frontend/ride-detail-logic.ts b/web/frontend/ride-detail-logic.ts
new file mode 100644
index 0000000..825bb2c
--- /dev/null
+++ b/web/frontend/ride-detail-logic.ts
@@ -0,0 +1,84 @@
+import type { ClimbBounds, ClimbMetrics, ProfilePoint } from "./types.js";
+
+export const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
+
+export const formatDistance = (distance: number): string => `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
+
+export const formatElevation = (elevation: number): string => `${Math.round(elevation)} m`;
+
+export const nearestPointIndex = (points: ProfilePoint[], distance: number): number => {
+ let low = 0;
+ let high = points.length - 1;
+ while (low < high) {
+ const middle = Math.floor((low + high) / 2);
+ if (points[middle].distanceKm < distance) low = middle + 1;
+ else high = middle;
+ }
+ if (low === 0) return low;
+ const previous = points[low - 1];
+ return distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low;
+};
+
+export const categoryForScore = (score: number): string => {
+ if (score < 35) return "NO";
+ if (score < 80) return "Cat 4";
+ if (score < 180) return "Cat 3";
+ if (score < 250) return "Cat 2";
+ if (score < 600) return "Cat 1";
+ return "HC";
+};
+
+export const elevationAtDistance = (points: ProfilePoint[], index: number, distanceM: number): number => {
+ if (index + 1 >= points.length) return points[index].elevationM;
+ const startDistanceM = points[index].distanceKm * 1000;
+ const endDistanceM = points[index + 1].distanceKm * 1000;
+ const fraction = (distanceM - startDistanceM) / (endDistanceM - startDistanceM);
+ return points[index].elevationM + fraction * (points[index + 1].elevationM - points[index].elevationM);
+};
+
+export const cotacolForClimb = (points: ProfilePoint[], startIndex: number, endIndex: number): number => {
+ const startDistanceM = points[startIndex].distanceKm * 1000;
+ const lastDistanceM = points[endIndex].distanceKm * 1000;
+ if (lastDistanceM <= startDistanceM) return 0;
+ let score = 0;
+ let pointIndex = startIndex;
+ for (let segmentStartM = startDistanceM; segmentStartM < lastDistanceM; segmentStartM += 100) {
+ const segmentEndM = Math.min(segmentStartM + 100, lastDistanceM);
+ while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 <= segmentStartM) pointIndex++;
+ const startElevation = elevationAtDistance(points, pointIndex, segmentStartM);
+ while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 < segmentEndM) pointIndex++;
+ const endElevation = elevationAtDistance(points, pointIndex, segmentEndM);
+ const slope = (endElevation - startElevation) / (segmentEndM - segmentStartM);
+ if (slope > 0) score += ((segmentEndM - segmentStartM) / 1000) * (slope * 100) ** 2;
+ }
+ return score;
+};
+
+export const climbMetrics = (points: ProfilePoint[], bounds: ClimbBounds | undefined): ClimbMetrics | null => {
+ if (
+ !bounds ||
+ !Number.isInteger(bounds.startIndex) ||
+ !Number.isInteger(bounds.endIndex) ||
+ bounds.startIndex < 0 ||
+ bounds.endIndex >= points.length ||
+ bounds.startIndex >= bounds.endIndex
+ ) {
+ return null;
+ }
+ const start = points[bounds.startIndex];
+ const end = points[bounds.endIndex];
+ const distanceKm = end.distanceKm - start.distanceKm;
+ const elevationGain = end.elevationM - start.elevationM;
+ const slope = distanceKm > 0 ? elevationGain / (distanceKm * 10) : 0;
+ const score = distanceKm > 0 ? ((Math.abs(elevationGain) * elevationGain) / (distanceKm * 1000)) * 10 : 0;
+ return {
+ start,
+ end,
+ distanceKm,
+ elevationGain,
+ slope,
+ score,
+ cotacol: cotacolForClimb(points, bounds.startIndex, bounds.endIndex),
+ category: categoryForScore(score),
+ };
+};
diff --git a/web/frontend/ride-detail-map.ts b/web/frontend/ride-detail-map.ts
new file mode 100644
index 0000000..bd19412
--- /dev/null
+++ b/web/frontend/ride-detail-map.ts
@@ -0,0 +1,130 @@
+import { clamp } from "./ride-detail-logic.js";
+import type { ClimbBounds, RideDetailColors, RideProfilePoint, RideRoute } from "./types.js";
+import type { CircleMarker, LeafletMouseEvent, Map as LeafletMap, Polyline } from "leaflet";
+
+type LeafletApi = typeof import("leaflet");
+
+interface RideDetailMapOptions {
+ leaflet: LeafletApi;
+ element: HTMLElement;
+ route: RideRoute;
+ points: RideProfilePoint[];
+ climbs: ClimbBounds[];
+ colors: RideDetailColors;
+}
+
+export class RideDetailMap {
+ private readonly leaflet: LeafletApi;
+ private readonly points: RideProfilePoint[];
+ private readonly colors: RideDetailColors;
+ private readonly map: LeafletMap;
+ private readonly climbLayers: (Polyline | null)[];
+ private readonly routeCursor: CircleMarker;
+
+ constructor({ leaflet, element, route, points, climbs, colors }: RideDetailMapOptions) {
+ this.leaflet = leaflet;
+ this.points = points;
+ this.colors = colors;
+ this.map = leaflet.map(element);
+ const tiles = leaflet.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ maxZoom: 19,
+ attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
+ });
+ tiles.addTo(this.map);
+ const routeLayer = leaflet
+ .geoJSON(route, {
+ style: { color: colors.accent, weight: 4, opacity: 0.9 },
+ })
+ .addTo(this.map);
+ const bounds = routeLayer.getBounds();
+ if (bounds.isValid()) this.map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 });
+ this.climbLayers = climbs.map((climb) => this.createClimbLayer(climb));
+ this.routeCursor = leaflet
+ .circleMarker([points[0].latitude, points[0].longitude], {
+ color: colors.forest,
+ fillColor: colors.accent,
+ fillOpacity: 0,
+ opacity: 0,
+ radius: 7,
+ weight: 3,
+ interactive: false,
+ })
+ .addTo(this.map);
+ }
+
+ createClimbLayer(climb: ClimbBounds): Polyline | null {
+ if (!this.isValidBounds(climb)) return null;
+ const coordinates: [number, number][] = this.points
+ .slice(climb.startIndex, climb.endIndex + 1)
+ .map((point) => [point.latitude, point.longitude]);
+ return this.leaflet
+ .polyline(coordinates, {
+ color: this.colors.climbRoute,
+ weight: 7,
+ opacity: 0.65,
+ lineCap: "round",
+ lineJoin: "round",
+ interactive: false,
+ })
+ .addTo(this.map);
+ }
+
+ isValidBounds(bounds: ClimbBounds | undefined): boolean {
+ return Boolean(
+ bounds &&
+ Number.isInteger(bounds.startIndex) &&
+ Number.isInteger(bounds.endIndex) &&
+ bounds.startIndex >= 0 &&
+ bounds.endIndex < this.points.length &&
+ bounds.startIndex < bounds.endIndex,
+ );
+ }
+
+ updateClimbLayer(index: number, bounds: ClimbBounds | undefined, active: boolean): void {
+ const layer = this.climbLayers[index];
+ if (!layer) return;
+ if (!bounds || !this.isValidBounds(bounds)) {
+ layer.setLatLngs([]);
+ return;
+ }
+ layer.setLatLngs(
+ this.points.slice(bounds.startIndex, bounds.endIndex + 1).map((point) => [point.latitude, point.longitude] as [number, number]),
+ );
+ layer.setStyle({ weight: active ? 9 : 7, opacity: active ? 1 : 0.65 });
+ }
+
+ nearestPointIndex(latitude: number, longitude: number): number {
+ let nearestIndex = 0;
+ let nearestDistance = Infinity;
+ for (let index = 0; index < this.points.length; index++) {
+ const point = this.points[index];
+ const distance = this.leaflet.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]);
+ if (distance < nearestDistance) {
+ nearestIndex = index;
+ nearestDistance = distance;
+ }
+ }
+ return nearestIndex;
+ }
+
+ showPoint(index: number): void {
+ const point = this.points[clamp(index, 0, this.points.length - 1)];
+ this.routeCursor.setLatLng([point.latitude, point.longitude]);
+ this.routeCursor.setStyle({ opacity: 1, fillOpacity: 0.9 });
+ }
+
+ clearPoint() {
+ this.routeCursor.setStyle({ opacity: 0, fillOpacity: 0 });
+ }
+
+ zoomToClimb(bounds: ClimbBounds | undefined): void {
+ if (!bounds || !this.isValidBounds(bounds)) return;
+ const climbPoints = this.points.slice(bounds.startIndex, bounds.endIndex + 1);
+ const mapBounds = this.leaflet.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude] as [number, number]));
+ if (mapBounds.isValid()) this.map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 });
+ }
+
+ onClick(listener: (event: LeafletMouseEvent) => void): void {
+ this.map.on("click", listener);
+ }
+}
diff --git a/web/frontend/ride-detail.ts b/web/frontend/ride-detail.ts
new file mode 100644
index 0000000..fb75c19
--- /dev/null
+++ b/web/frontend/ride-detail.ts
@@ -0,0 +1,142 @@
+import { OfficialClimbProfileController } from "./official-climb-profile.js";
+import { RideBoundaryController } from "./ride-detail-boundaries.js";
+import { RideProfileCanvas } from "./ride-detail-canvas.js";
+import { RideDetailMap } from "./ride-detail-map.js";
+import { formatDistance, formatElevation } from "./ride-detail-logic.js";
+import type { ClimbBounds, RideDetailColors, RideProfile, RideProfilePoint, RideRoute } from "./types.js";
+
+export const mountRideDetail = (): void => {
+ const mapElement = document.getElementById("ride-map");
+ const routeElement = document.getElementById("ride-route");
+ const profileElement = document.getElementById("ride-profile");
+ const canvas = document.getElementById("ride-profile-chart");
+ const hoverOutput = document.getElementById("ride-profile-hover");
+ if (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return;
+ const routeScript = routeElement as HTMLScriptElement;
+ const profileScript = profileElement as HTMLScriptElement;
+ const profileCanvasElement = canvas as HTMLCanvasElement;
+ const leaflet = window.L;
+ if (!leaflet) return;
+
+ 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: RideDetailColors = {
+ accent: resolveColor("--color-accent"),
+ forest: resolveColor("--color-forest"),
+ subtle: resolveColor("--color-subtle"),
+ plotSurface: resolveColor("--color-plot-surface"),
+ plotSurfaceOverlay: resolveColor("--color-plot-surface-overlay"),
+ grid: resolveColor("--color-plot-grid"),
+ accentFill: resolveColor("--color-accent-fill"),
+ climbLabel: resolveColor("--color-climb-label"),
+ crossing: resolveColor("--color-crossing"),
+ crossingLabel: resolveColor("--color-crossing-label"),
+ hoverLine: resolveColor("--color-hover-line"),
+ climbRoute: resolveColor("--color-climb-route"),
+ climbFocusFill: resolveColor("--color-climb-focus-fill"),
+ };
+ colorProbe.remove();
+
+ const route = JSON.parse(routeScript.textContent ?? "") as RideRoute;
+ const profile = JSON.parse(profileScript.textContent ?? "") as RideProfile;
+ const points: RideProfilePoint[] = profile.points;
+ if (points.length === 0) return;
+
+ const climbItems = [...document.querySelectorAll<HTMLElement>("[data-climb-item]")];
+ const climbItemIndices = climbItems.map((item) => Number.parseInt(item.dataset.climbIndex ?? "", 10));
+ const previousClimbButton = document.querySelector<HTMLButtonElement>("[data-climb-previous]");
+ const nextClimbButton = document.querySelector<HTMLButtonElement>("[data-climb-next]");
+ const climbPosition = document.querySelector<HTMLElement>("[data-climb-position]");
+ const climbBounds: ClimbBounds[] = profile.climbs.map((climb) => ({ startIndex: climb.startIndex, endIndex: climb.endIndex }));
+ const state = {
+ focusedClimbItemIndex: 0,
+ focusedClimbIndex: climbItemIndices[0] ?? 0,
+ };
+
+ const mapController = new RideDetailMap({
+ leaflet,
+ element: mapElement,
+ route,
+ points,
+ climbs: climbBounds,
+ colors,
+ });
+ const officialProfileController = new OfficialClimbProfileController(points);
+ let boundaryController: RideBoundaryController | undefined;
+ const profileCanvas = new RideProfileCanvas({
+ canvas: profileCanvasElement,
+ points,
+ profile,
+ colors,
+ getClimbBounds: () => climbBounds,
+ canSelectPoint: (source) => boundaryController?.isSelecting(source) ?? false,
+ onPointSelected: (index) => boundaryController?.choosePoint(index),
+ onPointHover: (index) => {
+ if (index < 0) {
+ mapController.clearPoint();
+ hoverOutput.textContent = "Hover or focus the profile to inspect elevation.";
+ return;
+ }
+ const point = points[index];
+ mapController.showPoint(index);
+ hoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
+ },
+ });
+
+ const updateClimbLayer = (index: number): void => {
+ mapController.updateClimbLayer(index, climbBounds[index], index === state.focusedClimbIndex);
+ };
+ boundaryController = new RideBoundaryController({
+ forms: [...document.querySelectorAll<HTMLFormElement>("[data-official-climb-form]")],
+ points,
+ climbBounds,
+ onBoundaryChanged: (index) => {
+ updateClimbLayer(index);
+ profileCanvas.redraw();
+ },
+ onPointSelected: (index) => profileCanvas.showPoint(index),
+ setMessage: (message) => {
+ hoverOutput.textContent = message;
+ },
+ });
+
+ mapController.onClick((event) => {
+ if (!boundaryController?.isSelecting("map")) return;
+ boundaryController.choosePoint(mapController.nearestPointIndex(event.latlng.lat, event.latlng.lng));
+ });
+
+ const updateClimbFocus = (index: number, zoom: boolean): void => {
+ if (climbItems.length === 0) return;
+ state.focusedClimbItemIndex = Math.max(0, Math.min(climbItems.length - 1, index));
+ state.focusedClimbIndex = climbItemIndices[state.focusedClimbItemIndex];
+ for (let itemIndex = 0; itemIndex < climbItems.length; itemIndex++) {
+ const active = itemIndex === state.focusedClimbItemIndex;
+ climbItems[itemIndex].hidden = !active;
+ climbItems[itemIndex].setAttribute("aria-hidden", String(!active));
+ climbItems[itemIndex].classList.toggle("focused", active);
+ updateClimbLayer(climbItemIndices[itemIndex]);
+ }
+ if (climbPosition) climbPosition.textContent = `Climb ${state.focusedClimbItemIndex + 1} of ${climbItems.length}`;
+ if (previousClimbButton) previousClimbButton.disabled = state.focusedClimbItemIndex === 0;
+ if (nextClimbButton) nextClimbButton.disabled = state.focusedClimbItemIndex === climbItems.length - 1;
+ boundaryController?.clearSelection();
+ profileCanvas.setFocusedClimbIndex(state.focusedClimbIndex);
+ if (zoom) mapController.zoomToClimb(climbBounds[state.focusedClimbIndex]);
+ };
+
+ if (previousClimbButton) previousClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbItemIndex - 1, true));
+ if (nextClimbButton) nextClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbItemIndex + 1, true));
+ window.addEventListener("resize", () => {
+ profileCanvas.redraw();
+ officialProfileController.redrawOpen();
+ });
+ if (climbItems.length > 0) updateClimbFocus(0, false);
+ else profileCanvas.redraw();
+};
+
+mountRideDetail();
diff --git a/web/frontend/sync.ts b/web/frontend/sync.ts
new file mode 100644
index 0000000..000dcfc
--- /dev/null
+++ b/web/frontend/sync.ts
@@ -0,0 +1,105 @@
+import type { SyncErrorEvent, SyncProgress } from "./types.js";
+import { requireElement } from "./dom.js";
+
+const isSyncProgress = (data: SyncProgress | SyncErrorEvent): data is SyncProgress => "total" in data;
+
+export const mountSync = (): void => {
+ const form = document.getElementById("sync-form") as HTMLFormElement | null;
+ if (!form) return;
+ const button = requireElement(form.querySelector<HTMLButtonElement>("button[type=submit]"), "sync submit button");
+ const progressPanel = requireElement(document.getElementById("sync-progress"), "sync progress");
+ const progressBar = requireElement(document.getElementById("sync-progress-bar") as HTMLProgressElement | null, "sync progress bar");
+ const progressCount = requireElement(document.getElementById("sync-progress-count"), "sync progress count");
+ const progressDetails = requireElement(document.getElementById("sync-progress-details"), "sync progress details");
+ const notice = requireElement(document.getElementById("sync-notice"), "sync notice");
+ const error = requireElement(document.getElementById("sync-error"), "sync error");
+
+ const showError = (message: string): void => {
+ error.textContent = message;
+ error.hidden = false;
+ };
+ const updateProgress = (progress: SyncProgress): void => {
+ progressPanel.hidden = false;
+ progressBar.max = Math.max(progress.total, 1);
+ progressBar.value = progress.completed;
+ progressCount.textContent = `${progress.completed} / ${progress.total}`;
+ progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;
+ };
+ const handleEvent = (eventName: string, data: SyncProgress | SyncErrorEvent): boolean => {
+ if (eventName === "progress") {
+ if (!isSyncProgress(data)) throw new Error("Invalid Strava sync progress event");
+ updateProgress(data);
+ return false;
+ }
+ if (eventName === "complete") {
+ if (!isSyncProgress(data)) throw new Error("Invalid Strava sync completion event");
+ updateProgress(data);
+ notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`;
+ notice.hidden = false;
+ return true;
+ }
+ if (eventName === "error") {
+ if ("progress" in data && data.progress) updateProgress(data.progress);
+ throw new Error("message" in data ? data.message || "Strava sync failed" : "Strava sync failed");
+ }
+ return false;
+ };
+ const consumeEvents = async (response: Response): Promise<void> => {
+ if (!response.body) throw new Error("The browser does not support streaming responses");
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let completed = false;
+ const consumeBlock = (block: string): void => {
+ let eventName = "message";
+ let data = "";
+ for (const line of block.split(/\r?\n/)) {
+ if (line.startsWith("event: ")) eventName = line.slice(7);
+ if (line.startsWith("data: ")) data += line.slice(6);
+ }
+ if (data) completed = handleEvent(eventName, JSON.parse(data) as SyncProgress | SyncErrorEvent) || completed;
+ };
+ while (true) {
+ const { value, done } = await reader.read();
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
+ const blocks = buffer.split(/\r?\n\r?\n/);
+ buffer = blocks.pop() ?? "";
+ for (const block of blocks) consumeBlock(block);
+ if (done) break;
+ }
+ if (buffer.trim()) consumeBlock(buffer);
+ if (!completed) throw new Error("Strava sync ended before completion");
+ };
+
+ form.addEventListener("submit", async (event: SubmitEvent) => {
+ event.preventDefault();
+ button.disabled = true;
+ button.textContent = "Syncing...";
+ notice.hidden = true;
+ error.hidden = true;
+ progressPanel.hidden = false;
+ progressCount.textContent = "0 / 0";
+ progressDetails.textContent = "Preparing import...";
+ try {
+ const response = await fetch(form.action, {
+ method: "POST",
+ body: new FormData(form),
+ headers: { Accept: "text/event-stream" },
+ });
+ if (response.redirected) {
+ window.location.assign(response.url);
+ return;
+ }
+ if (!response.ok) throw new Error((await response.text()) || `Sync failed (HTTP ${response.status})`);
+ if (!response.body) throw new Error("The browser does not support streaming responses");
+ await consumeEvents(response);
+ } catch (caught) {
+ showError(caught instanceof Error ? caught.message : "Strava sync failed");
+ } finally {
+ button.disabled = false;
+ button.textContent = "Sync rides";
+ }
+ });
+};
+
+mountSync();
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
new file mode 100644
index 0000000..66f1ad2
--- /dev/null
+++ b/web/frontend/types.d.ts
@@ -0,0 +1,116 @@
+export type ProfileBand = "downhill" | "0-3" | "3-6" | "6-9" | "9-12" | "12-plus";
+export type BoundaryTarget = "start" | "end";
+export type BoundarySource = "profile" | "map";
+
+export interface ProfilePoint {
+ distanceKm: number;
+ elevationM: number;
+}
+
+export interface RideProfilePoint extends ProfilePoint {
+ latitude: number;
+ longitude: number;
+}
+
+export interface RideProfileClimb {
+ startKm: number;
+ endKm: number;
+ topKm: number;
+ topElevationM: number;
+ name: string;
+ score: number;
+ category: string;
+ distanceKm: number;
+ slopePercent: number;
+ cotacol: number;
+ officialClimbId?: number;
+ officialName?: string;
+ startIndex: number;
+ endIndex: number;
+}
+
+export interface RideProfileCrossing {
+ distanceKm: number;
+ passElevationM: number;
+ rideElevationM: number;
+ distanceToM: number;
+ elevationDiffM: number;
+ name: string;
+}
+
+export interface RideProfile {
+ points: RideProfilePoint[];
+ climbs: RideProfileClimb[];
+ crossings: RideProfileCrossing[];
+}
+
+export interface RideRoute {
+ type: "FeatureCollection";
+ features: RideRouteFeature[];
+}
+
+export interface RideRouteFeature {
+ type: "Feature";
+ geometry: {
+ type: "LineString";
+ coordinates: [number, number][];
+ };
+ properties?: Record<string, unknown>;
+}
+
+export interface ClimbBounds {
+ startIndex: number;
+ endIndex: number;
+}
+
+export interface ClimbMetrics {
+ start: ProfilePoint;
+ end: ProfilePoint;
+ distanceKm: number;
+ elevationGain: number;
+ slope: number;
+ score: number;
+ cotacol: number;
+ category: string;
+}
+
+export interface OfficialProfileColors {
+ downhill: string;
+ "0-3": string;
+ "3-6": string;
+ "6-9": string;
+ "9-12": string;
+ "12-plus": string;
+ plotSurface: string;
+ grid: string;
+ subtle: string;
+ accent: string;
+}
+
+export interface RideDetailColors {
+ accent: string;
+ subtle: string;
+ plotSurface: string;
+ grid: string;
+ forest: string;
+ plotSurfaceOverlay: string;
+ accentFill: string;
+ climbLabel: string;
+ crossing: string;
+ crossingLabel: string;
+ hoverLine: string;
+ climbRoute: string;
+ climbFocusFill: string;
+}
+
+export interface SyncProgress {
+ total: number;
+ completed: number;
+ imported: number;
+ skipped: number;
+}
+
+export interface SyncErrorEvent {
+ message: string;
+ progress?: SyncProgress;
+}