summaryrefslogtreecommitdiff
path: root/web/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'web/frontend')
-rw-r--r--web/frontend/ride-detail-canvas.ts128
-rw-r--r--web/frontend/ride-detail-logic.test.ts6
-rw-r--r--web/frontend/ride-detail-logic.ts13
-rw-r--r--web/frontend/ride-detail-map.ts6
-rw-r--r--web/frontend/ride-detail.ts19
-rw-r--r--web/frontend/types.d.ts10
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;