diff options
Diffstat (limited to 'web/frontend')
| -rw-r--r-- | web/frontend/climb-profile.ts | 49 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile-logic.test.ts | 102 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile-logic.ts | 150 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile.ts | 53 | ||||
| -rw-r--r-- | web/frontend/ride-detail.ts | 2 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 21 |
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 { |
