diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-13 21:55:59 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-13 21:55:59 +0200 |
| commit | e3d0beee5be3dd2854b0f4fc10905a4fcdc08f47 (patch) | |
| tree | ec8234d3442c498ec170b6444bd2cfae5b29a476 /web/frontend/climb-map.ts | |
| parent | db4d2b40895db41ce188549f49a6f277ba95c890 (diff) | |
Persist official climb attempts with climbs pages
Record one attempt row per (ride, climb, occurrence) when a ride detail
loads, snapshotting distance, elevation gain, slope, cotacol and attempt
timestamps; a climbs-backfill CLI command seeds all existing rides.
New /climbs list page aggregates attempts (medians computed in Go, best
time as min duration) with sortable columns, and /climbs/{id} shows the
median scalar stats, a median normalized elevation profile (50 steps
over each attempt's own length), the full-precision recorded route of
the median-distance attempt on an OSM map, and every attempt with time
at second precision.
Diffstat (limited to 'web/frontend/climb-map.ts')
| -rw-r--r-- | web/frontend/climb-map.ts | 65 |
1 files changed, 65 insertions, 0 deletions
diff --git a/web/frontend/climb-map.ts b/web/frontend/climb-map.ts new file mode 100644 index 0000000..909614d --- /dev/null +++ b/web/frontend/climb-map.ts @@ -0,0 +1,65 @@ +import type { Map as LeafletMap } from "leaflet"; + +interface ClimbMapPayload { + distanceKm: number; + elevations: number[]; + route?: [number, number][]; +} + +interface ClimbMapEndpoints { + start: [number, number]; + end: [number, number]; + name: string; +} + +const mountClimbMap = (): void => { + const element = document.getElementById("climb-map"); + const endpointsElement = document.getElementById("climb-endpoints"); + const profileElement = document.getElementById("climb-profile-data"); + const leaflet = window.L; + if (!element || !endpointsElement || !profileElement || !leaflet) return; + const endpoints = JSON.parse(endpointsElement.textContent ?? "null") as ClimbMapEndpoints | null; + const profile = JSON.parse(profileElement.textContent ?? "null") as ClimbMapPayload | null; + if (!endpoints) return; + const route = (profile?.route ?? []).map(([lon, lat]) => [lat, lon] as [number, number]); + const line: [number, number][] = route.length > 1 ? route : [endpoints.start, endpoints.end]; + + const map: LeafletMap = leaflet.map(element); + leaflet + .tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', + }) + .addTo(map); + + const startIcon = 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 endIcon = 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], + }); + leaflet + .polyline(line, { + color: "var(--color-climb-route)", + weight: 4, + opacity: 0.9, + }) + .addTo(map); + leaflet + .marker(endpoints.start, { icon: startIcon, alt: `${endpoints.name} start` }) + .addTo(map) + .bindTooltip("Start"); + leaflet + .marker(endpoints.end, { icon: endIcon, alt: `${endpoints.name} end` }) + .addTo(map) + .bindTooltip("End"); + map.fitBounds(leaflet.latLngBounds(line), { padding: [40, 40], maxZoom: 16 }); +}; + +mountClimbMap(); |
