diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/frontend/ride-detail-logic.test.ts | 6 | ||||
| -rw-r--r-- | web/frontend/ride-detail-logic.ts | 3 | ||||
| -rw-r--r-- | web/frontend/ride-detail-map.ts | 55 | ||||
| -rw-r--r-- | web/static/style.css | 4 |
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; } |