From 7c39bc7124f89aa28aaeaffb60b71af9e6335295 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Mon, 17 Aug 2026 10:34:06 +0200 Subject: refactor(web): split ride detail rendering concerns --- web/frontend/ride-detail-canvas.ts | 128 +++++++++++++++++++++------------ web/frontend/ride-detail-logic.test.ts | 6 +- web/frontend/ride-detail-logic.ts | 13 +++- web/frontend/ride-detail-map.ts | 6 +- web/frontend/ride-detail.ts | 19 ++--- web/frontend/types.d.ts | 10 ++- 6 files changed, 119 insertions(+), 63 deletions(-) diff --git a/web/frontend/ride-detail-canvas.ts b/web/frontend/ride-detail-canvas.ts index 23f9621..9db90a1 100644 --- a/web/frontend/ride-detail-canvas.ts +++ b/web/frontend/ride-detail-canvas.ts @@ -1,5 +1,5 @@ import { clamp, climbMetrics, formatClimbLabel, formatDistance, formatElevation, nearestPointIndex } from "./ride-detail-logic.js"; -import type { BoundarySource, ClimbBounds, RideDetailColors, RideProfile, RideProfilePoint } from "./types.js"; +import type { BoundarySource, ClimbBounds, RideProfile, RideProfileColors, RideProfilePoint } from "./types.js"; interface Plot { left: number; @@ -12,7 +12,7 @@ interface RideProfileCanvasOptions { canvas: HTMLCanvasElement; points: RideProfilePoint[]; profile: RideProfile; - colors: RideDetailColors; + colors: RideProfileColors; getClimbBounds: () => ClimbBounds[]; canSelectPoint: (source: BoundarySource) => boolean; onPointSelected: (index: number) => void; @@ -23,7 +23,7 @@ export class RideProfileCanvas { private readonly canvas: HTMLCanvasElement; private readonly points: RideProfilePoint[]; private readonly profile: RideProfile; - private readonly colors: RideDetailColors; + private readonly colors: RideProfileColors; private readonly getClimbBounds: () => ClimbBounds[]; private readonly canSelectPoint: (source: BoundarySource) => boolean; private readonly onPointSelected: (index: number) => void; @@ -112,7 +112,20 @@ export class RideProfileCanvas { this.context.fillRect(0, 0, rect.width, rect.height); this.context.font = "12px system-ui, sans-serif"; - this.context.textBaseline = "middle"; + this.drawGrid(plot); + const climbBounds = this.getClimbBounds(); + this.drawProfileArea(plot); + this.drawClimbBands(plot, climbBounds); + this.drawCrossingLines(plot); + this.drawProfileLine(); + this.drawClimbLabels(plot, climbBounds); + this.drawCrossingLabels(plot); + this.drawDistanceLabels(plot); + + if (this.hoveredIndex >= 0) this.drawHover(plot); + } + + private drawGrid(plot: Plot): void { for (let step = 0; step <= 4; step++) { const fraction = step / 4; const y = plot.top + fraction * (plot.bottom - plot.top); @@ -127,32 +140,30 @@ export class RideProfileCanvas { this.context.textAlign = "right"; this.context.fillText(formatElevation(elevation), plot.left - 8, y); } + } - const climbBounds = this.getClimbBounds(); + private drawProfileArea(plot: Plot): void { + 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.profileFill; + this.context.fill(); + } + + private drawClimbBands(plot: Plot, climbBounds: ClimbBounds[]): void { 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 = formatClimbLabel(climb.name, metrics.category, metrics.cotacol, climb.officialClimbId !== undefined); - this.context.fillStyle = this.colors.climbLabel; - this.context.textAlign = "center"; - this.context.textBaseline = "middle"; - const labelWidth = this.context.measureText(label).width; - const labelX = clamp((startX + endX) / 2, plot.left + 8, plot.right - 8); - const labelAngle = -Math.PI / 3; - const labelHeight = Math.abs(Math.sin(labelAngle)) * labelWidth + Math.abs(Math.cos(labelAngle)) * 14; - const labelY = clamp(plot.top + labelHeight / 2 + 4, plot.top + labelHeight / 2, plot.bottom - labelHeight / 2); - this.context.save(); - this.context.translate(labelX, labelY); - this.context.rotate(labelAngle); - this.context.fillText(label, 0, 0); - this.context.restore(); } + } + private drawCrossingLines(plot: Plot): void { for (const crossing of this.profile.crossings || []) { const x = this.xForDistance(crossing.distanceKm); this.context.strokeStyle = this.colors.crossing; @@ -164,14 +175,9 @@ export class RideProfileCanvas { 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.profileFill; - this.context.fill(); + private drawProfileLine(): void { this.context.beginPath(); for (let index = 0; index < this.points.length; index++) { const point = this.points[index]; @@ -181,6 +187,38 @@ export class RideProfileCanvas { this.context.strokeStyle = this.colors.profileLine; this.context.lineWidth = 2.5; this.context.stroke(); + } + + private drawClimbLabels(plot: Plot, climbBounds: ClimbBounds[]): void { + 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); + const label = formatClimbLabel({ + name: climb.name, + category: metrics.category, + cotacol: metrics.cotacol, + kind: climb.officialClimbId !== undefined ? "official" : "detected", + }); + this.context.fillStyle = this.colors.climbLabel; + this.context.textAlign = "center"; + this.context.textBaseline = "middle"; + const labelWidth = this.context.measureText(label).width; + const labelX = clamp((startX + endX) / 2, plot.left + 8, plot.right - 8); + const labelAngle = -Math.PI / 3; + const labelHeight = Math.abs(Math.sin(labelAngle)) * labelWidth + Math.abs(Math.cos(labelAngle)) * 14; + const labelY = clamp(plot.top + labelHeight / 2 + 4, plot.top + labelHeight / 2, plot.bottom - labelHeight / 2); + this.context.save(); + this.context.translate(labelX, labelY); + this.context.rotate(labelAngle); + this.context.fillText(label, 0, 0); + this.context.restore(); + } + } + + private drawCrossingLabels(plot: Plot): void { for (const crossing of this.profile.crossings || []) { const x = this.xForDistance(crossing.distanceKm); const label = `${crossing.name} ${Math.round(crossing.passElevationM)} m`; @@ -194,7 +232,9 @@ export class RideProfileCanvas { this.context.textBaseline = "bottom"; this.context.fillText(label, labelX, labelY); } + } + private drawDistanceLabels(plot: Plot): void { this.context.fillStyle = this.colors.subtle; this.context.textAlign = "center"; this.context.textBaseline = "top"; @@ -202,24 +242,24 @@ export class RideProfileCanvas { 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(); - } + private drawHover(plot: Plot): void { + 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 { diff --git a/web/frontend/ride-detail-logic.test.ts b/web/frontend/ride-detail-logic.test.ts index 6f27191..a3f143e 100644 --- a/web/frontend/ride-detail-logic.test.ts +++ b/web/frontend/ride-detail-logic.test.ts @@ -41,9 +41,9 @@ test("calculates climb metrics with Cotacol", () => { }); test("formats profile climb labels with category", () => { - assert.equal(formatClimbLabel("", "Cat 4", 45.9), "Cat 4 45.9"); - assert.equal(formatClimbLabel("Col de Test", "Cat 4", 45.9), "Col de Test"); - assert.equal(formatClimbLabel("Ventoux", "HC", 600, true), "Ventoux (HC)"); + assert.equal(formatClimbLabel({ name: "", category: "Cat 4", cotacol: 45.9, kind: "detected" }), "Cat 4 45.9"); + assert.equal(formatClimbLabel({ name: "Col de Test", category: "Cat 4", cotacol: 45.9, kind: "detected" }), "Col de Test"); + assert.equal(formatClimbLabel({ name: "Ventoux", category: "HC", cotacol: 600, kind: "official" }), "Ventoux (HC)"); }); test("formats profile labels consistently", () => { diff --git a/web/frontend/ride-detail-logic.ts b/web/frontend/ride-detail-logic.ts index f8eb040..e0f7281 100644 --- a/web/frontend/ride-detail-logic.ts +++ b/web/frontend/ride-detail-logic.ts @@ -28,8 +28,17 @@ export const categoryForCotacol = (cotacol: number): string => { return "HC"; }; -export const formatClimbLabel = (name: string, category: string, cotacol: number, officialClimb = false): string => { - if (name) return officialClimb ? `${name} (${category})` : name; +type ClimbLabelKind = "official" | "detected"; + +interface ClimbLabelOptions { + name: string; + category: string; + cotacol: number; + kind: ClimbLabelKind; +} + +export const formatClimbLabel = ({ name, category, cotacol, kind }: ClimbLabelOptions): string => { + if (name) return kind === "official" ? `${name} (${category})` : name; return `${category} ${cotacol.toFixed(1)}`; }; diff --git a/web/frontend/ride-detail-map.ts b/web/frontend/ride-detail-map.ts index bd19412..dcc2b60 100644 --- a/web/frontend/ride-detail-map.ts +++ b/web/frontend/ride-detail-map.ts @@ -1,5 +1,5 @@ import { clamp } from "./ride-detail-logic.js"; -import type { ClimbBounds, RideDetailColors, RideProfilePoint, RideRoute } from "./types.js"; +import type { ClimbBounds, RideMapColors, RideProfilePoint, RideRoute } from "./types.js"; import type { CircleMarker, LeafletMouseEvent, Map as LeafletMap, Polyline } from "leaflet"; type LeafletApi = typeof import("leaflet"); @@ -10,13 +10,13 @@ interface RideDetailMapOptions { route: RideRoute; points: RideProfilePoint[]; climbs: ClimbBounds[]; - colors: RideDetailColors; + colors: RideMapColors; } export class RideDetailMap { private readonly leaflet: LeafletApi; private readonly points: RideProfilePoint[]; - private readonly colors: RideDetailColors; + private readonly colors: RideMapColors; private readonly map: LeafletMap; private readonly climbLayers: (Polyline | null)[]; private readonly routeCursor: CircleMarker; diff --git a/web/frontend/ride-detail.ts b/web/frontend/ride-detail.ts index d59cbe9..7394d5a 100644 --- a/web/frontend/ride-detail.ts +++ b/web/frontend/ride-detail.ts @@ -3,7 +3,7 @@ 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"; +import type { ClimbBounds, RideMapColors, RideProfile, RideProfileColors, RideProfilePoint, RideRoute } from "./types.js"; export const mountRideDetail = (): void => { const mapElement = document.getElementById("ride-map"); @@ -25,23 +25,26 @@ export const mountRideDetail = (): void => { colorProbe.style.color = `var(${name})`; return getComputedStyle(colorProbe).color; }; - const colors: RideDetailColors = { - accent: resolveColor("--color-accent"), - forest: resolveColor("--color-forest"), + const profileColors: RideProfileColors = { 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"), profileFill: resolveColor("--color-profile-fill"), profileLine: resolveColor("--color-profile-line"), + 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"), + forest: resolveColor("--color-forest"), climbFocusFill: resolveColor("--color-climb-focus-fill"), }; + const mapColors: RideMapColors = { + accent: resolveColor("--color-accent"), + forest: resolveColor("--color-forest"), + climbRoute: resolveColor("--color-climb-route"), + }; colorProbe.remove(); const route = JSON.parse(routeScript.textContent ?? "") as RideRoute; @@ -66,7 +69,7 @@ export const mountRideDetail = (): void => { route, points, climbs: climbBounds, - colors, + colors: mapColors, }); const officialProfileController = new OfficialClimbProfileController(points); let boundaryController: RideBoundaryController | undefined; @@ -74,7 +77,7 @@ export const mountRideDetail = (): void => { canvas: profileCanvasElement, points, profile, - colors, + colors: profileColors, getClimbBounds: () => climbBounds, canSelectPoint: (source) => boundaryController?.isSelecting(source) ?? false, onPointSelected: (index) => boundaryController?.choosePoint(index), diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts index a5e83b8..c53b7e5 100644 --- a/web/frontend/types.d.ts +++ b/web/frontend/types.d.ts @@ -84,8 +84,7 @@ export interface OfficialProfileColors { accent: string; } -export interface RideDetailColors { - accent: string; +export interface RideProfileColors { subtle: string; plotSurface: string; grid: string; @@ -98,10 +97,15 @@ export interface RideDetailColors { crossing: string; crossingLabel: string; hoverLine: string; - climbRoute: string; climbFocusFill: string; } +export interface RideMapColors { + accent: string; + forest: string; + climbRoute: string; +} + export interface SyncProgress { total: number; completed: number; -- cgit v1.2.3