summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-17 13:18:25 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-17 13:18:25 +0200
commit706edc84d855ea46d3c8faee82090cfaf0a7b515 (patch)
treefbb6700d5d6f515734dcf410310d01134cfeeaea
parentb626f15b6e40311143cdec91728046dc79e0d328 (diff)
feat(web): mark climb boundaries on ride maps
-rw-r--r--web/frontend/ride-detail-logic.test.ts6
-rw-r--r--web/frontend/ride-detail-logic.ts3
-rw-r--r--web/frontend/ride-detail-map.ts55
-rw-r--r--web/static/style.css4
4 files changed, 66 insertions, 2 deletions
diff --git a/web/frontend/ride-detail-logic.test.ts b/web/frontend/ride-detail-logic.test.ts
index a3f143e..471eb49 100644
--- a/web/frontend/ride-detail-logic.test.ts
+++ b/web/frontend/ride-detail-logic.test.ts
@@ -6,6 +6,7 @@ import {
cotacolForClimb,
elevationAtDistance,
formatClimbLabel,
+ formatClimbBoundaryLabel,
formatDistance,
formatElevation,
nearestPointIndex,
@@ -46,6 +47,11 @@ test("formats profile climb labels with category", () => {
assert.equal(formatClimbLabel({ name: "Ventoux", category: "HC", cotacol: 600, kind: "official" }), "Ventoux (HC)");
});
+test("formats climb boundary labels", () => {
+ assert.equal(formatClimbBoundaryLabel(0, "start"), "Climb 1 start");
+ assert.equal(formatClimbBoundaryLabel(2, "end"), "Climb 3 end");
+});
+
test("formats profile labels consistently", () => {
assert.equal(formatDistance(2.4), "2.4 km");
assert.equal(formatDistance(12.4), "12 km");
diff --git a/web/frontend/ride-detail-logic.ts b/web/frontend/ride-detail-logic.ts
index e0f7281..b4d4dba 100644
--- a/web/frontend/ride-detail-logic.ts
+++ b/web/frontend/ride-detail-logic.ts
@@ -29,6 +29,7 @@ export const categoryForCotacol = (cotacol: number): string => {
};
type ClimbLabelKind = "official" | "detected";
+export type ClimbBoundary = "start" | "end";
interface ClimbLabelOptions {
name: string;
@@ -42,6 +43,8 @@ export const formatClimbLabel = ({ name, category, cotacol, kind }: ClimbLabelOp
return `${category} ${cotacol.toFixed(1)}`;
};
+export const formatClimbBoundaryLabel = (index: number, boundary: ClimbBoundary): string => `Climb ${index + 1} ${boundary}`;
+
export const elevationAtDistance = (points: ProfilePoint[], index: number, distanceM: number): number => {
if (index + 1 >= points.length) return points[index].elevationM;
const startDistanceM = points[index].distanceKm * 1000;
diff --git a/web/frontend/ride-detail-map.ts b/web/frontend/ride-detail-map.ts
index 81367f3..f571378 100644
--- a/web/frontend/ride-detail-map.ts
+++ b/web/frontend/ride-detail-map.ts
@@ -1,8 +1,9 @@
-import { clamp } from "./ride-detail-logic.js";
+import { clamp, formatClimbBoundaryLabel } from "./ride-detail-logic.js";
import type { ClimbBounds, RideMapColors, RideMapPass, RideProfilePoint, RideRoute } from "./types.js";
-import type { CircleMarker, LeafletMouseEvent, Map as LeafletMap, Polyline } from "leaflet";
+import type { CircleMarker, DivIcon, LeafletMouseEvent, Map as LeafletMap, Marker, Polyline } from "leaflet";
type LeafletApi = typeof import("leaflet");
+type ClimbBoundaryMarkers = { start: Marker; end: Marker };
interface RideDetailMapOptions {
leaflet: LeafletApi;
@@ -20,6 +21,7 @@ export class RideDetailMap {
private readonly colors: RideMapColors;
private readonly map: LeafletMap;
private readonly climbLayers: (Polyline | null)[];
+ private readonly climbBoundaryMarkers: (ClimbBoundaryMarkers | null)[];
private readonly routeCursor: CircleMarker;
constructor({ leaflet, element, route, points, climbs, passes, colors }: RideDetailMapOptions) {
@@ -53,6 +55,19 @@ export class RideDetailMap {
}
if (bounds.isValid()) this.map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 });
this.climbLayers = climbs.map((climb) => this.createClimbLayer(climb));
+ const climbStartIcon = leaflet.divIcon({
+ className: "ride-map-climb-start-icon",
+ html: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="6" fill="currentColor"/></svg>',
+ iconSize: [26, 26],
+ iconAnchor: [13, 13],
+ });
+ const climbEndIcon = leaflet.divIcon({
+ className: "ride-map-climb-end-icon",
+ html: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3v18" stroke="currentColor" stroke-width="2"/><path d="M6 4h13v8H6Z" fill="currentColor"/><path d="M6 4h3.25v4H6ZM12.5 4h3.25v4H12.5ZM9.25 8h3.25v4H9.25ZM15.75 8H19v4h-3.25Z" fill="var(--color-forest)"/></svg>',
+ iconSize: [26, 26],
+ iconAnchor: [13, 13],
+ });
+ this.climbBoundaryMarkers = climbs.map((climb, index) => this.createClimbBoundaryMarkers(index, climb, climbStartIcon, climbEndIcon));
this.routeCursor = leaflet
.circleMarker([points[0].latitude, points[0].longitude], {
color: colors.forest,
@@ -66,6 +81,37 @@ export class RideDetailMap {
.addTo(this.map);
}
+ createClimbBoundaryMarkers(index: number, bounds: ClimbBounds, startIcon: DivIcon, endIcon: DivIcon): ClimbBoundaryMarkers {
+ const markers = {
+ start: this.createClimbBoundaryMarker(startIcon, formatClimbBoundaryLabel(index, "start"), bounds.startIndex),
+ end: this.createClimbBoundaryMarker(endIcon, formatClimbBoundaryLabel(index, "end"), bounds.endIndex),
+ };
+ return markers;
+ }
+
+ createClimbBoundaryMarker(icon: DivIcon, label: string, pointIndex: number): Marker {
+ const marker = this.leaflet.marker([0, 0], { icon, alt: label });
+ const tooltip = document.createElement("span");
+ tooltip.textContent = label;
+ marker.bindTooltip(tooltip);
+ this.updateBoundaryMarker(marker, pointIndex);
+ return marker;
+ }
+
+ updateBoundaryMarker(marker: Marker, pointIndex: number | undefined): void {
+ if (!this.isValidPointIndex(pointIndex)) {
+ marker.remove();
+ return;
+ }
+ const point = this.points[pointIndex];
+ marker.setLatLng([point.latitude, point.longitude]);
+ if (!this.map.hasLayer(marker)) marker.addTo(this.map);
+ }
+
+ isValidPointIndex(index: number | undefined): index is number {
+ return typeof index === "number" && Number.isInteger(index) && index >= 0 && index < this.points.length;
+ }
+
createClimbLayer(climb: ClimbBounds): Polyline | null {
if (!this.isValidBounds(climb)) return null;
const coordinates: [number, number][] = this.points
@@ -95,6 +141,11 @@ export class RideDetailMap {
}
updateClimbLayer(index: number, bounds: ClimbBounds | undefined, active: boolean): void {
+ const markers = this.climbBoundaryMarkers[index];
+ if (markers) {
+ this.updateBoundaryMarker(markers.start, bounds?.startIndex);
+ this.updateBoundaryMarker(markers.end, bounds?.endIndex);
+ }
const layer = this.climbLayers[index];
if (!layer) return;
if (!bounds || !this.isValidBounds(bounds)) {
diff --git a/web/static/style.css b/web/static/style.css
index 002407b..b984da8 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -90,6 +90,10 @@ th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text-
.ride-map { min-height: 24rem; height: min(65vh, 40rem); }
.ride-map-pass-icon { display: grid; place-items: center; width: 1.75rem !important; height: 1.75rem !important; border: 2px solid var(--color-surface); border-radius: 50%; background: var(--color-leaf); color: var(--color-on-dark); box-shadow: 0 .15rem .4rem var(--color-panel-shadow); }
.ride-map-pass-icon svg { width: 1.15rem; height: 1.15rem; }
+.ride-map-climb-start-icon, .ride-map-climb-end-icon { display: grid; place-items: center; width: 1.625rem !important; height: 1.625rem !important; border: 2px solid var(--color-surface); box-shadow: 0 .15rem .4rem var(--color-panel-shadow); }
+.ride-map-climb-start-icon { border-radius: 50%; background: var(--color-forest); color: var(--color-on-dark); }
+.ride-map-climb-end-icon { border-radius: .3rem; background: var(--color-accent); color: var(--color-on-dark); }
+.ride-map-climb-start-icon svg, .ride-map-climb-end-icon svg { width: 1.1rem; height: 1.1rem; }
.profile-panel { padding-bottom: 1rem; }
.profile-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.profile-heading h2 { margin: 0; font-size: 1.2rem; }