aboutsummaryrefslogtreecommitdiff
path: root/web/frontend
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-10-18 21:19:36 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-10-18 21:19:36 +0200
commit8a0032d08862dcf28c653bee3fd21052e967e35d (patch)
tree6cb0d8efe6f5c2bf9ce9eb3ef8306faecbe7cfeb /web/frontend
parentd575274d00c8da251df29d5d6a636044673944a3 (diff)
Cut climb profile sections in the backend
The chart steps are computed in Go and shipped as finished sections, next to the profile statistics the climb package already owns: climb.ProfileSections cuts a Route at its widest bends under a ProfilePolicy, and rideanalysis.MergeClimbProfile takes over the merge that lived in web/climbs.go, so web only loads the attempt rides and maps the DTOs. The payload carries sections instead of raw elevations, and the browser keeps what needs the canvas: slope bands, labels, distance ticks and colours. official-climb-profile-logic.ts and its tests are gone, replaced by climb/profile_test.go and a page test reading the embedded profile JSON.
Diffstat (limited to 'web/frontend')
-rw-r--r--web/frontend/climb-profile.ts49
-rw-r--r--web/frontend/official-climb-profile-logic.test.ts102
-rw-r--r--web/frontend/official-climb-profile-logic.ts150
-rw-r--r--web/frontend/official-climb-profile.ts53
-rw-r--r--web/frontend/ride-detail.ts2
-rw-r--r--web/frontend/types.d.ts21
6 files changed, 74 insertions, 303 deletions
diff --git a/web/frontend/climb-profile.ts b/web/frontend/climb-profile.ts
index 6051ec1..d06d291 100644
--- a/web/frontend/climb-profile.ts
+++ b/web/frontend/climb-profile.ts
@@ -1,32 +1,21 @@
-import { drawOfficialSections, officialProfilePlot, resolveOfficialProfileColors } from "./official-climb-profile.js";
-import { officialProfileSections } from "./official-climb-profile-logic.js";
-import type { OfficialProfileSection } from "./official-climb-profile-logic.js";
-import type { ProfilePoint } from "./types.js";
+import {
+ drawOfficialSections,
+ officialProfilePlot,
+ officialProfileSections,
+ resolveOfficialProfileColors,
+} from "./official-climb-profile.js";
+import type { OfficialProfileSection, ProfileSectionPayload } from "./types.js";
interface ClimbProfilePayload {
- distanceKm: number;
- elevations: number[];
+ sections: ProfileSectionPayload[];
}
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);
-};
-
const elevationAtDistance = (sections: OfficialProfileSection[], distanceKm: number): number | null => {
for (const section of sections) {
if (distanceKm >= section.startDistanceKm && distanceKm <= section.endDistanceKm) {
@@ -64,15 +53,13 @@ const drawHoverMarker = (state: ClimbProfileState, distanceKm: number): void =>
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 = (state: ClimbProfileState): void => {
+ drawOfficialSections(state.canvas, state.sections, resolveOfficialProfileColors());
};
-const drawClimbProfile = (canvas: HTMLCanvasElement, payload: ClimbProfilePayload, sections?: OfficialProfileSection[]): void => {
- const profileSections = sections ?? climbProfileSections(payload);
- if (profileSections.length === 0) return;
- drawOfficialSections(canvas, profileSections, resolveOfficialProfileColors());
+const clearHover = (state: ClimbProfileState): void => {
+ drawClimbProfile(state);
+ state.hoverOutput.textContent = "";
};
const bindHover = (state: ClimbProfileState): void => {
@@ -85,7 +72,7 @@ const bindHover = (state: ClimbProfileState): void => {
return;
}
const distanceKm = plot.minDistanceKm + ((x - plot.left) / (plot.right - plot.left)) * plot.distanceSpan;
- drawClimbProfile(state.canvas, state.payload, state.sections);
+ drawClimbProfile(state);
drawHoverMarker(state, distanceKm);
});
state.canvas.addEventListener("pointerleave", () => clearHover(state));
@@ -97,13 +84,11 @@ const init = (): void => {
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;
+ if (!payload || payload.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);
+ const state: ClimbProfileState = { canvas, sections: officialProfileSections(payload.sections), hoverOutput };
+ drawClimbProfile(state);
bindHover(state);
};
diff --git a/web/frontend/official-climb-profile-logic.test.ts b/web/frontend/official-climb-profile-logic.test.ts
deleted file mode 100644
index ca8dea2..0000000
--- a/web/frontend/official-climb-profile-logic.test.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import test from "node:test";
-import assert from "node:assert/strict";
-import { officialProfileSections, profileBandForSlope } from "./official-climb-profile-logic.ts";
-import type { ProfilePoint } from "./types.js";
-
-test("assigns each slope to its color band", () => {
- assert.equal(profileBandForSlope(-0.1), "downhill");
- assert.equal(profileBandForSlope(0), "downhill");
- assert.equal(profileBandForSlope(0.1), "0-3");
- assert.equal(profileBandForSlope(2.9), "0-3");
- assert.equal(profileBandForSlope(3), "3-6");
- assert.equal(profileBandForSlope(6), "6-9");
- assert.equal(profileBandForSlope(9), "9-plus");
- assert.equal(profileBandForSlope(12), "9-plus");
-});
-
-// buildProfile lays out one recorded point every 50 m along the given [length in km, slope in %] stretches.
-const buildProfile = (stretches: [number, number][]): ProfilePoint[] => {
- const points: ProfilePoint[] = [{ distanceKm: 0, elevationM: 0 }];
- let distanceKm = 0;
- let elevationM = 0;
- for (const [lengthKm, slopePercent] of stretches) {
- const pointCount = Math.round(lengthKm * 20);
- for (let index = 1; index <= pointCount; index++) {
- const distanceStepKm = (lengthKm * index) / pointCount;
- points.push({ distanceKm: distanceKm + distanceStepKm, elevationM: elevationM + distanceStepKm * 1000 * (slopePercent / 100) });
- }
- distanceKm += lengthKm;
- elevationM += lengthKm * 1000 * (slopePercent / 100);
- }
- return points;
-};
-
-test("keeps a short steep ramp as its own section", () => {
- const points = buildProfile([
- [0.3, 4],
- [0.3, 12],
- [0.3, 4],
- ]);
- const sections = officialProfileSections(points, 0, points.length - 1);
-
- assert.equal(sections.length, 3);
- assert.ok(Math.abs(sections[1].slopePercent - 12) < 0.01);
- assert.equal(sections[1].band, "9-plus");
- assert.equal(Math.round((sections[1].endDistanceKm - sections[1].startDistanceKm) * 1000), 300);
- assert.ok(Math.abs(sections[0].slopePercent - 4) < 0.01);
- assert.ok(Math.abs(sections[2].slopePercent - 4) < 0.01);
-});
-
-test("collapses a constant slope into a single section", () => {
- const points = buildProfile([[5, 6]]);
- const sections = officialProfileSections(points, 0, points.length - 1);
-
- assert.equal(sections.length, 1);
- assert.ok(Math.abs(sections[0].slopePercent - 6) < 0.01);
- assert.equal(sections[0].startDistanceKm, 0);
- assert.equal(sections[0].endDistanceKm, 5);
-});
-
-test("reports a descending stretch as downhill", () => {
- const points = buildProfile([[2, -5]]);
- const sections = officialProfileSections(points, 0, points.length - 1);
-
- assert.equal(sections.length, 1);
- assert.equal(sections[0].band, "downhill");
-});
-
-test("keeps a noisy profile within the readable section budget", () => {
- const points: ProfilePoint[] = [];
- for (let index = 0; index <= 200; index++) {
- const distanceKm = index * 0.05;
- points.push({ distanceKm, elevationM: distanceKm * 1000 * 0.05 + Math.sin(index) * 4 });
- }
- const sections = officialProfileSections(points, 0, points.length - 1);
-
- assert.ok(sections.length > 10 && sections.length <= 24, `expected 10-24 sections, got ${sections.length}`);
- assert.equal(sections[0].startDistanceKm, 0);
- assert.equal(sections[sections.length - 1].endDistanceKm, 10);
-});
-
-test("reads a slice of a longer profile", () => {
- const points = buildProfile([
- [1, 20],
- [1, 8],
- [1, 20],
- ]);
- const startIndex = points.findIndex((point) => point.distanceKm >= 1);
- const endIndex = points.findIndex((point) => point.distanceKm >= 2);
- const sections = officialProfileSections(points, startIndex, endIndex);
-
- assert.equal(sections.length, 1);
- assert.equal(Math.round(sections[0].slopePercent), 8);
- assert.equal(sections[0].startDistanceKm, points[startIndex].distanceKm);
- assert.equal(sections[0].endDistanceKm, points[endIndex].distanceKm);
-});
-
-test("returns no section for an empty range", () => {
- const points = buildProfile([[1, 6]]);
-
- assert.deepEqual(officialProfileSections(points, 3, 3), []);
- assert.deepEqual(officialProfileSections(points, 0, points.length), []);
-});
diff --git a/web/frontend/official-climb-profile-logic.ts b/web/frontend/official-climb-profile-logic.ts
deleted file mode 100644
index 005e46b..0000000
--- a/web/frontend/official-climb-profile-logic.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import type { ProfileBand, ProfilePoint } from "./types.js";
-
-export interface OfficialProfileSection {
- startDistanceKm: number;
- endDistanceKm: number;
- startElevation: number;
- endElevation: number;
- slopePercent: number;
- band: ProfileBand;
-}
-
-interface ProfileNode {
- distanceM: number;
- elevationM: number;
-}
-
-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";
- return "9-plus";
-};
-
-// The base grid the profile is simplified from: fine enough to localize a ramp,
-// coarse enough to average GPS noise out.
-const baseStepSizesM: number[] = [25, 50, 100, 200, 500, 1000];
-const maxBaseCells = 120;
-const baseStepForLength = (lengthM: number): number =>
- baseStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= maxBaseCells) ?? Math.ceil(lengthM / maxBaseCells);
-
-// Simplification: a bend smaller than the tolerance cannot be seen on the chart, so it is
-// averaged away. The tolerance tracks the base grid (1 % of a base cell, at least a meter), so a
-// gentle 4 km climb keeps the same relative detail as a 400 m wall.
-const minToleranceM = 1;
-const tolerancePerBaseStep = 0.01;
-const maxProfileSections = 24;
-
-const elevationAtDistance = (points: ProfilePoint[], startIndex: number, endIndex: number): ((distanceM: number) => number) => {
- let index = startIndex;
- return (distanceM: number): number => {
- while (index < endIndex - 1 && points[index + 1].distanceKm * 1000 < distanceM) index++;
- const nextIndex = Math.min(index + 1, endIndex);
- const first = points[index];
- const next = points[nextIndex];
- const distanceSpanM = next.distanceKm * 1000 - first.distanceKm * 1000;
- if (distanceSpanM <= 0) return first.elevationM;
- const fraction = (distanceM - first.distanceKm * 1000) / distanceSpanM;
- return first.elevationM + Math.max(0, Math.min(1, fraction)) * (next.elevationM - first.elevationM);
- };
-};
-
-const baseNodes = (points: ProfilePoint[], startIndex: number, endIndex: number, stepM: number): ProfileNode[] => {
- const startM = points[startIndex].distanceKm * 1000;
- const endM = points[endIndex].distanceKm * 1000;
- const elevationAt = elevationAtDistance(points, startIndex, endIndex);
- const nodes: ProfileNode[] = [];
- for (let distanceM = startM; distanceM < endM; distanceM += stepM) {
- nodes.push({ distanceM, elevationM: elevationAt(distanceM) });
- }
- nodes.push({ distanceM: endM, elevationM: elevationAt(endM) });
- return nodes;
-};
-
-const chordDeviationM = (first: ProfileNode, last: ProfileNode, node: ProfileNode): number => {
- const spanM = last.distanceM - first.distanceM;
- if (spanM <= 0) return node.elevationM - first.elevationM;
- const chordElevationM = first.elevationM + ((node.distanceM - first.distanceM) / spanM) * (last.elevationM - first.elevationM);
- return node.elevationM - chordElevationM;
-};
-
-interface PendingSegment {
- firstIndex: number;
- lastIndex: number;
- splitIndex: number;
- deviationM: number;
-}
-
-const bestSplit = (nodes: ProfileNode[], firstIndex: number, lastIndex: number): PendingSegment => {
- let splitIndex = -1;
- let deviationM = 0;
- for (let index = firstIndex + 1; index < lastIndex; index++) {
- const candidateM = Math.abs(chordDeviationM(nodes[firstIndex], nodes[lastIndex], nodes[index]));
- if (candidateM > deviationM) {
- deviationM = candidateM;
- splitIndex = index;
- }
- }
- return { firstIndex, lastIndex, splitIndex, deviationM };
-};
-
-// simplifyNodes keeps splitting the segment with the widest bend until the profile either fits the
-// section budget or has no bend left above toleranceM: a constant stretch collapses into one long
-// section, a ramp keeps its own short ones, and the cuts land on the most prominent bends first.
-const simplifyNodes = (nodes: ProfileNode[], toleranceM: number): ProfileNode[] => {
- if (nodes.length <= 2) return nodes;
- const finalIndex = nodes.length - 1;
- const keep = new Array<boolean>(nodes.length).fill(false);
- keep[0] = true;
- keep[finalIndex] = true;
- const pending: PendingSegment[] = [bestSplit(nodes, 0, finalIndex)];
- let sectionCount = 1;
- while (sectionCount < maxProfileSections) {
- let widestIndex = -1;
- for (let index = 0; index < pending.length; index++) {
- if (pending[index].splitIndex === -1) continue;
- if (widestIndex === -1 || pending[index].deviationM > pending[widestIndex].deviationM) widestIndex = index;
- }
- if (widestIndex === -1 || pending[widestIndex].deviationM <= toleranceM) break;
- const [widest] = pending.splice(widestIndex, 1);
- keep[widest.splitIndex] = true;
- sectionCount++;
- pending.push(bestSplit(nodes, widest.firstIndex, widest.splitIndex), bestSplit(nodes, widest.splitIndex, widest.lastIndex));
- }
- return nodes.filter((_, index) => keep[index]);
-};
-
-const sectionsFromNodes = (nodes: ProfileNode[]): OfficialProfileSection[] => {
- const sections: OfficialProfileSection[] = [];
- for (let index = 1; index < nodes.length; index++) {
- const start = nodes[index - 1];
- const end = nodes[index];
- const spanM = end.distanceM - start.distanceM;
- if (spanM <= 0) continue;
- const slopePercent = ((end.elevationM - start.elevationM) / spanM) * 100;
- sections.push({
- startDistanceKm: start.distanceM / 1000,
- endDistanceKm: end.distanceM / 1000,
- startElevation: start.elevationM,
- endElevation: end.elevationM,
- slopePercent,
- band: profileBandForSlope(slopePercent),
- });
- }
- return sections;
-};
-
-// officialProfileSections turns the recorded profile into chart sections whose width
-// follows the profile's bends, so short steep ramps survive instead of being averaged
-// into a fixed distance bucket.
-const officialProfileSections = (points: ProfilePoint[], startIndex: number, endIndex: number): OfficialProfileSection[] => {
- if (startIndex < 0 || endIndex >= points.length || endIndex - startIndex < 1) return [];
- const lengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000;
- if (lengthM <= 0) return [];
- const baseStepM = baseStepForLength(lengthM);
- const nodes = baseNodes(points, startIndex, endIndex, baseStepM);
- return sectionsFromNodes(simplifyNodes(nodes, Math.max(minToleranceM, tolerancePerBaseStep * baseStepM)));
-};
-
-export { officialProfileSections, profileBandForSlope };
diff --git a/web/frontend/official-climb-profile.ts b/web/frontend/official-climb-profile.ts
index 7e93cc7..72bc8d2 100644
--- a/web/frontend/official-climb-profile.ts
+++ b/web/frontend/official-climb-profile.ts
@@ -1,13 +1,18 @@
-import { officialProfileSections } from "./official-climb-profile-logic.js";
-import type { OfficialProfileSection } from "./official-climb-profile-logic.js";
-import type { OfficialProfileColors, RideProfilePoint } from "./types.js";
+import type {
+ OfficialProfileColors,
+ OfficialProfileSection,
+ ProfileBand,
+ ProfileSectionPayload,
+ RideProfile,
+ RideProfileClimb,
+} from "./types.js";
export class OfficialClimbProfileController {
- readonly points: RideProfilePoint[];
+ readonly climbs: RideProfileClimb[];
readonly colors: OfficialProfileColors;
readonly cards: HTMLDetailsElement[];
- constructor(points: RideProfilePoint[]) {
- this.points = points;
+ constructor(profile: RideProfile) {
+ this.climbs = profile.climbs;
this.colors = this.resolveColors();
this.cards = [...document.querySelectorAll("[data-official-climb-card]")].map((card) => card as HTMLDetailsElement);
for (const card of this.cards) {
@@ -24,18 +29,10 @@ export class OfficialClimbProfileController {
}
drawOfficialProfile(profileCanvas: HTMLCanvasElement): void {
- const { points } = 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 sections = officialProfileSections(points, startIndex, endIndex);
+ const climbIndex = Number.parseInt(profileCanvas.dataset.profileClimbIndex ?? "", 10);
+ const climb = this.climbs.find((candidate) => candidate.index === climbIndex);
+ if (!climb?.sections) return;
+ const sections = officialProfileSections(climb.sections);
if (sections.length === 0) return;
drawOfficialSections(profileCanvas, sections, this.colors);
}
@@ -76,6 +73,26 @@ export function resolveOfficialProfileColors(): OfficialProfileColors {
return colors;
}
+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";
+ return "9-plus";
+};
+
+// officialProfileSections turns the sections the server cut out of the trace into the chart shape,
+// tagging each step with the band its slope falls in.
+export const officialProfileSections = (sections: ProfileSectionPayload[]): OfficialProfileSection[] =>
+ sections.map((section) => ({
+ startDistanceKm: section.startKm,
+ endDistanceKm: section.endKm,
+ startElevation: section.startElevationM,
+ endElevation: section.endElevationM,
+ slopePercent: section.slopePercent,
+ band: profileBandForSlope(section.slopePercent),
+ }));
+
export interface OfficialProfilePlot {
left: number;
right: number;
diff --git a/web/frontend/ride-detail.ts b/web/frontend/ride-detail.ts
index dcebbe4..2bd6622 100644
--- a/web/frontend/ride-detail.ts
+++ b/web/frontend/ride-detail.ts
@@ -75,7 +75,7 @@ export const mountRideDetail = (): void => {
passes,
colors: mapColors,
});
- const officialProfileController = new OfficialClimbProfileController(points);
+ const officialProfileController = new OfficialClimbProfileController(profile);
let boundaryController: RideBoundaryController | undefined;
const profileCanvas = new RideProfileCanvas({
canvas: profileCanvasElement,
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
index a933d7d..a97d07d 100644
--- a/web/frontend/types.d.ts
+++ b/web/frontend/types.d.ts
@@ -2,6 +2,25 @@ export type ProfileBand = "downhill" | "0-3" | "3-6" | "6-9" | "9-plus";
export type BoundaryTarget = "start" | "end";
export type BoundarySource = "profile" | "map";
+// ProfileSectionPayload is one step of a climb profile as the server cuts it.
+export interface ProfileSectionPayload {
+ startKm: number;
+ endKm: number;
+ startElevationM: number;
+ endElevationM: number;
+ slopePercent: number;
+}
+
+// OfficialProfileSection is that step tagged with the colour band its slope falls in.
+export interface OfficialProfileSection {
+ startDistanceKm: number;
+ endDistanceKm: number;
+ startElevation: number;
+ endElevation: number;
+ slopePercent: number;
+ band: ProfileBand;
+}
+
export interface ProfilePoint {
distanceKm: number;
elevationM: number;
@@ -14,6 +33,7 @@ export interface RideProfilePoint extends ProfilePoint {
}
export interface RideProfileClimb {
+ index: number;
startKm: number;
endKm: number;
topKm: number;
@@ -27,6 +47,7 @@ export interface RideProfileClimb {
officialName?: string;
startIndex: number;
endIndex: number;
+ sections?: ProfileSectionPayload[];
}
export interface RideProfileCrossing {