From 706edc84d855ea46d3c8faee82090cfaf0a7b515 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Mon, 17 Aug 2026 13:18:25 +0200 Subject: feat(web): mark climb boundaries on ride maps --- web/frontend/ride-detail-logic.test.ts | 6 ++++ web/frontend/ride-detail-logic.ts | 3 ++ web/frontend/ride-detail-map.ts | 55 ++++++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 2 deletions(-) (limited to 'web/frontend') 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: '', + iconSize: [26, 26], + iconAnchor: [13, 13], + }); + const climbEndIcon = leaflet.divIcon({ + className: "ride-map-climb-end-icon", + html: '', + 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)) { -- cgit v1.2.3