aboutsummaryrefslogtreecommitdiff
path: root/web/frontend
diff options
context:
space:
mode:
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 {