From eac1145e29e1a2a2ff5d9654ca010071a5840843 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Fri, 14 Aug 2026 15:23:48 +0200 Subject: feat: add official climb workflow --- web/static/ride-detail.js | 208 ++++++++++++++++++++++++++++++++++++++++++++-- web/static/style.css | 26 ++++++ 2 files changed, 227 insertions(+), 7 deletions(-) (limited to 'web/static') diff --git a/web/static/ride-detail.js b/web/static/ride-detail.js index 178b486..bd36da4 100644 --- a/web/static/ride-detail.js +++ b/web/static/ride-detail.js @@ -24,6 +24,8 @@ crossing: resolveColor("--color-crossing"), crossingLabel: resolveColor("--color-crossing-label"), hoverLine: resolveColor("--color-hover-line"), + climbRoute: resolveColor("--color-climb-route"), + climbFocusFill: resolveColor("--color-climb-focus-fill"), }; colorProbe.remove(); const route = JSON.parse(routeElement.textContent); @@ -44,6 +46,19 @@ const points = profile.points || []; if (points.length === 0) return; + const climbItems = [...document.querySelectorAll("[data-climb-item]")]; + const previousClimbButton = document.querySelector("[data-climb-previous]"); + const nextClimbButton = document.querySelector("[data-climb-next]"); + const climbPosition = document.querySelector("[data-climb-position]"); + const climbLayers = []; + for (const climb of profile.climbs || []) { + if (!Number.isInteger(climb.startIndex) || !Number.isInteger(climb.endIndex) || climb.startIndex < 0 || climb.endIndex >= points.length || climb.startIndex >= climb.endIndex) { + climbLayers.push(null); + continue; + } + const coordinates = points.slice(climb.startIndex, climb.endIndex + 1).map((point) => [point.latitude, point.longitude]); + climbLayers.push(L.polyline(coordinates, { color: colors.climbRoute, weight: 7, opacity: 0.65, lineCap: "round", lineJoin: "round", interactive: false }).addTo(map)); + } const routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], { color: colors.forest, fillColor: colors.accent, @@ -56,7 +71,14 @@ const context = canvas.getContext("2d"); if (!context) return; - const state = { hoveredIndex: -1, plot: null, width: 0, height: 0 }; + const state = { + hoveredIndex: -1, + focusedClimbIndex: 0, + climbBounds: (profile.climbs || []).map((climb) => ({ startIndex: climb.startIndex, endIndex: climb.endIndex })), + plot: null, + width: 0, + height: 0, + }; const minDistance = points[0].distanceKm; const maxDistance = points[points.length - 1].distanceKm; let minElevation = points[0].elevationM; @@ -92,6 +114,135 @@ const previous = points[low - 1]; return distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low; }; + const nearestMapPointIndex = (latitude, longitude) => { + let nearestIndex = 0; + let nearestDistance = Infinity; + for (let index = 0; index < points.length; index++) { + const point = points[index]; + const distance = L.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]); + if (distance < nearestDistance) { + nearestIndex = index; + nearestDistance = distance; + } + } + return nearestIndex; + }; + const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")]; + let activeBoundary = null; + const boundaryLabel = (index) => { + const point = points[index]; + return `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`; + }; + const categoryForScore = (score) => { + if (score < 35) return "NO"; + if (score < 80) return "Cat 4"; + if (score < 180) return "Cat 3"; + if (score < 250) return "Cat 2"; + if (score < 600) return "Cat 1"; + return "HC"; + }; + const elevationAtDistance = (index, distanceM) => { + if (index + 1 >= points.length) return points[index].elevationM; + const startDistanceM = points[index].distanceKm * 1000; + const endDistanceM = points[index + 1].distanceKm * 1000; + const fraction = (distanceM - startDistanceM) / (endDistanceM - startDistanceM); + return points[index].elevationM + fraction * (points[index + 1].elevationM - points[index].elevationM); + }; + const cotacolForClimb = (startIndex, endIndex) => { + const startDistanceM = points[startIndex].distanceKm * 1000; + const lastDistanceM = points[endIndex].distanceKm * 1000; + if (lastDistanceM <= startDistanceM) return 0; + let score = 0; + let pointIndex = startIndex; + for (let segmentStartM = startDistanceM; segmentStartM < lastDistanceM; segmentStartM += 100) { + const segmentEndM = Math.min(segmentStartM + 100, lastDistanceM); + while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 <= segmentStartM) pointIndex++; + const startElevation = elevationAtDistance(pointIndex, segmentStartM); + while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 < segmentEndM) pointIndex++; + const endElevation = elevationAtDistance(pointIndex, segmentEndM); + const slope = (endElevation - startElevation) / (segmentEndM - segmentStartM); + if (slope > 0) score += (segmentEndM - segmentStartM) / 1000 * (slope * 100) ** 2; + } + return score; + }; + const climbMetrics = (index) => { + const bounds = state.climbBounds[index]; + if (!bounds || !Number.isInteger(bounds.startIndex) || !Number.isInteger(bounds.endIndex) || bounds.startIndex < 0 || bounds.endIndex >= points.length || bounds.startIndex >= bounds.endIndex) return null; + const start = points[bounds.startIndex]; + const end = points[bounds.endIndex]; + const distanceKm = end.distanceKm - start.distanceKm; + const elevationGain = end.elevationM - start.elevationM; + const slope = distanceKm > 0 ? elevationGain / (distanceKm * 10) : 0; + const score = distanceKm > 0 ? Math.abs(elevationGain) * elevationGain / (distanceKm * 1000) * 10 : 0; + return { start, end, distanceKm, elevationGain, slope, score, cotacol: cotacolForClimb(bounds.startIndex, bounds.endIndex), category: categoryForScore(score) }; + }; + const clearBoundarySelection = () => { + activeBoundary = null; + for (const form of boundaryForms) { + for (const button of form.querySelectorAll("[data-boundary-button]")) button.classList.remove("active"); + } + }; + const updateBoundaryPreview = (form) => { + const preview = form.querySelector("[data-boundary-preview]"); + const item = form.closest("[data-climb-item]"); + const climbIndex = Number.parseInt(item.dataset.climbIndex, 10); + const metrics = climbMetrics(climbIndex); + if (!metrics) { + preview.textContent = "Choose an end point after the start point."; + return; + } + const summary = item.querySelector("[data-climb-summary]"); + const metricsOutput = item.querySelector("[data-climb-metrics]"); + summary.textContent = `${formatDistance(metrics.start.distanceKm)}–${formatDistance(metrics.end.distanceKm)}`; + metricsOutput.textContent = `${metrics.category} · ${formatDistance(metrics.distanceKm)} at ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`; + preview.textContent = `Preview: ${formatDistance(metrics.distanceKm)} · ${metrics.elevationGain >= 0 ? "+" : ""}${formatElevation(metrics.elevationGain)} · ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`; + }; + const updateClimbLayer = (index) => { + const layer = climbLayers[index]; + const metrics = climbMetrics(index); + if (!layer) return; + if (!metrics) { + layer.setLatLngs([]); + return; + } + const bounds = state.climbBounds[index]; + layer.setLatLngs(points.slice(bounds.startIndex, bounds.endIndex + 1).map((point) => [point.latitude, point.longitude])); + const active = index === state.focusedClimbIndex; + layer.setStyle({ weight: active ? 9 : 7, opacity: active ? 1 : 0.65 }); + }; + const chooseBoundary = (index) => { + if (!activeBoundary) return; + const { form, target } = activeBoundary; + const item = form.closest("[data-climb-item]"); + const climbIndex = Number.parseInt(item.dataset.climbIndex, 10); + const input = form.querySelector(`[data-boundary-input="${target}"]`); + const output = form.querySelector(`[data-boundary-output="${target}"]`); + input.value = index; + output.textContent = boundaryLabel(index); + state.climbBounds[climbIndex][`${target}Index`] = index; + updateBoundaryPreview(form); + updateClimbLayer(climbIndex); + clearBoundarySelection(); + showPoint(index); + }; + for (const form of boundaryForms) { + const item = form.closest("[data-climb-item]"); + const climbIndex = Number.parseInt(item.dataset.climbIndex, 10); + state.climbBounds[climbIndex] = { + startIndex: Number.parseInt(form.querySelector('[data-boundary-input="start"]').value, 10), + endIndex: Number.parseInt(form.querySelector('[data-boundary-input="end"]').value, 10), + }; + updateBoundaryPreview(form); + for (const button of form.querySelectorAll("[data-boundary-button]")) { + button.addEventListener("click", () => { + clearBoundarySelection(); + activeBoundary = { form, target: button.dataset.boundaryButton, source: button.dataset.boundarySource }; + button.classList.add("active"); + const surface = activeBoundary.source === "map" ? "map" : "profile"; + hoverOutput.textContent = `Click the ${surface} to select the ${activeBoundary.target} point.`; + }); + } + } const draw = () => { const rect = canvas.getBoundingClientRect(); @@ -125,12 +276,15 @@ context.fillText(formatElevation(elevation), plot.left - 8, y); } - for (const climb of profile.climbs || []) { - const startX = clamp(xForDistance(climb.startKm), plot.left, plot.right); - const endX = clamp(xForDistance(climb.endKm), plot.left, plot.right); - context.fillStyle = colors.accentFill; + for (let climbIndex = 0; climbIndex < (profile.climbs || []).length; climbIndex++) { + const climb = profile.climbs[climbIndex]; + const metrics = climbMetrics(climbIndex); + if (!metrics) continue; + const startX = clamp(xForDistance(metrics.start.distanceKm), plot.left, plot.right); + const endX = clamp(xForDistance(metrics.end.distanceKm), plot.left, plot.right); + context.fillStyle = climbIndex === state.focusedClimbIndex ? colors.climbFocusFill : colors.accentFill; context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top); - const label = climb.name || `${climb.category} ${Math.round(climb.score)}`; + const label = climb.name || `${metrics.category} ${Math.round(metrics.score)}`; context.fillStyle = colors.climbLabel; context.textAlign = "center"; context.textBaseline = "top"; @@ -220,6 +374,33 @@ hoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`; draw(); }; + const zoomToClimb = (index) => { + const climbBounds = state.climbBounds[index]; + if (!climbBounds || !Number.isInteger(climbBounds.startIndex) || !Number.isInteger(climbBounds.endIndex)) return; + const climbPoints = points.slice(climbBounds.startIndex, climbBounds.endIndex + 1); + const mapBounds = L.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude])); + if (mapBounds.isValid()) map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 }); + }; + const updateClimbFocus = (index, zoom) => { + if (climbItems.length === 0) return; + state.focusedClimbIndex = clamp(index, 0, climbItems.length - 1); + for (let itemIndex = 0; itemIndex < climbItems.length; itemIndex++) { + const active = itemIndex === state.focusedClimbIndex; + climbItems[itemIndex].hidden = !active; + climbItems[itemIndex].setAttribute("aria-hidden", String(!active)); + if (climbLayers[itemIndex]) { + updateClimbLayer(itemIndex); + } + } + if (climbPosition) climbPosition.textContent = `Climb ${state.focusedClimbIndex + 1} of ${climbItems.length}`; + if (previousClimbButton) previousClimbButton.disabled = state.focusedClimbIndex === 0; + if (nextClimbButton) nextClimbButton.disabled = state.focusedClimbIndex === climbItems.length - 1; + clearBoundarySelection(); + draw(); + if (zoom) zoomToClimb(state.focusedClimbIndex); + }; + if (previousClimbButton) previousClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbIndex - 1, true)); + if (nextClimbButton) nextClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbIndex + 1, true)); canvas.addEventListener("pointermove", (event) => { if (!state.plot) return; const rect = canvas.getBoundingClientRect(); @@ -233,6 +414,18 @@ }); canvas.addEventListener("pointerleave", clearHover); canvas.addEventListener("pointercancel", clearHover); + canvas.addEventListener("click", (event) => { + if (!activeBoundary || activeBoundary.source !== "profile" || !state.plot) return; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + if (x < state.plot.left || x > state.plot.right) return; + const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance); + chooseBoundary(nearestPointIndex(distance)); + }); + map.on("click", (event) => { + if (!activeBoundary || activeBoundary.source !== "map") return; + chooseBoundary(nearestMapPointIndex(event.latlng.lat, event.latlng.lng)); + }); canvas.addEventListener("keydown", (event) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; event.preventDefault(); @@ -241,5 +434,6 @@ showPoint(index); }); window.addEventListener("resize", draw); - draw(); + if (climbItems.length > 0) updateClimbFocus(0, false); + else draw(); })(); diff --git a/web/static/style.css b/web/static/style.css index b24fd21..8a35844 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -36,6 +36,8 @@ --color-crossing: oklch(from var(--palette-leaf) l c h / 60%); --color-crossing-label: var(--color-notice-text); --color-hover-line: oklch(from var(--palette-forest) l c h / 60%); + --color-climb-route: var(--palette-leaf); + --color-climb-focus-fill: oklch(from var(--palette-leaf) l c h / 18%); } * { box-sizing: border-box; } @@ -48,6 +50,7 @@ a { color: var(--color-leaf); } .container { width: min(70rem, calc(100% - 2rem)); margin: 0 auto; padding: 2.5rem 0 4rem; } .eyebrow { margin: 0 0 .4rem; color: var(--color-leaf); font-size: .78rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } h1 { margin: 0; font-size: clamp(2rem, 4vw, 3.2rem); line-height: 1; } +.ride-detail-title { margin: 0 0 .4rem; color: var(--color-leaf); font-size: .78rem; font-weight: 700; letter-spacing: .12em; line-height: normal; } .lead { max-width: 42rem; color: var(--color-muted); } .panel { margin-top: 2rem; padding: 1.25rem; border: 1px solid var(--color-border); border-radius: 1rem; background: var(--color-surface); box-shadow: 0 1rem 2.5rem var(--color-panel-shadow); } .toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } @@ -61,6 +64,9 @@ input { border: 1px solid var(--color-input-border); border-radius: .55rem; padd progress { width: 100%; height: .8rem; accent-color: var(--color-accent); } .notice { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: var(--color-notice-surface); color: var(--color-notice-text); } .error { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: var(--color-error-surface); color: var(--color-error-text); } +.ride-detail-grid { display: grid; grid-template-columns: minmax(16rem, .85fr) minmax(0, 1.75fr); gap: 1.25rem; margin-top: 2rem; align-items: start; } +.ride-detail-sidebar, .ride-detail-main { display: grid; gap: 1.25rem; min-width: 0; } +.ride-detail-grid .panel { margin-top: 0; } table { width: 100%; border-collapse: collapse; } th, td { padding: .85rem .5rem; border-bottom: 1px solid var(--color-border-subtle); text-align: left; } th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; } @@ -79,8 +85,28 @@ th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text- .profile-chart canvas { display: block; width: 100%; height: 22rem; border-radius: .65rem; background: var(--color-plot-surface); outline: none; } .profile-chart canvas:focus-visible { box-shadow: 0 0 0 .2rem var(--color-focus); } .profile-hover { display: block; min-height: 1.4rem; margin: .7rem 0 0; color: var(--color-muted); font-size: .9rem; } +.climb-list { display: grid; gap: 1rem; margin-top: 1.25rem; } +.climb-item { padding-top: 1rem; border-top: 1px solid var(--color-border-subtle); } +.climb-item-heading { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.climb-number { display: inline-block; margin-right: .45rem; color: var(--color-accent); font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; } +.climb-item-heading > span, .climb-status { color: var(--color-muted); font-size: .9rem; } +.climb-metrics { margin: .35rem 0 0; color: var(--color-muted); font-size: .9rem; } +.climb-status { margin: .35rem 0 0; } +.climb-status.matched { color: var(--color-notice-text); } +.official-climb-form { display: grid; gap: .8rem; margin-top: .8rem; padding: .8rem; border: 1px solid var(--color-border-subtle); border-radius: .65rem; background: var(--color-plot-surface); } +.boundary-controls { display: flex; gap: .75rem; flex-wrap: wrap; } +.boundary-controls > div { display: grid; gap: .35rem; } +.boundary-controls > div > div { display: flex; gap: .35rem; flex-wrap: wrap; } +.boundary-controls output { color: var(--color-muted); font-size: .85rem; } +.boundary-controls .button.active { outline: .2rem solid var(--color-focus); } +.boundary-preview { color: var(--color-notice-text); font-size: .9rem; } +.climb-navigation { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-top: 1.25rem; } +.climbs-panel > .climb-navigation { margin-top: 0; } +.climb-navigation output { color: var(--color-muted); font-size: .9rem; font-weight: 700; } +.climb-navigation .button:disabled { opacity: .45; cursor: default; } dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 0; } dt { color: var(--color-subtle); font-size: .78rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } dd { margin: .25rem 0 0; font-size: 1.2rem; font-weight: 700; } +@media (max-width: 900px) { .ride-detail-grid { grid-template-columns: 1fr; } } @media (max-width: 700px) { .container { width: min(100% - 1rem, 70rem); padding-top: 1.5rem; } .site-header { padding-inline: 1rem; } th:nth-child(n+4), td:nth-child(n+4) { display: none; } .panel { padding: .8rem; } } @media (max-width: 500px) { dl { grid-template-columns: repeat(2, 1fr); } } -- cgit v1.2.3