From 2d18d177f48285eb123e7da8680fc00e7a6fe19c Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 16 Aug 2026 13:25:21 +0200 Subject: refactor(web): split ride detail JavaScript --- web/static/ride-detail-boundaries.js | 84 ++++++ web/static/ride-detail-canvas.js | 211 +++++++++++++ web/static/ride-detail-logic.js | 82 +++++ web/static/ride-detail-map.js | 106 +++++++ web/static/ride-detail.js | 485 ++++-------------------------- web/static_test/ride-detail-logic.test.js | 45 +++ 6 files changed, 591 insertions(+), 422 deletions(-) create mode 100644 web/static/ride-detail-boundaries.js create mode 100644 web/static/ride-detail-canvas.js create mode 100644 web/static/ride-detail-logic.js create mode 100644 web/static/ride-detail-map.js create mode 100644 web/static_test/ride-detail-logic.test.js (limited to 'web') diff --git a/web/static/ride-detail-boundaries.js b/web/static/ride-detail-boundaries.js new file mode 100644 index 0000000..98e0990 --- /dev/null +++ b/web/static/ride-detail-boundaries.js @@ -0,0 +1,84 @@ +import { climbMetrics, formatDistance, formatElevation } from "./ride-detail-logic.js"; + +export class RideBoundaryController { + constructor({ forms, points, climbBounds, onBoundaryChanged, onPointSelected, setMessage }) { + this.forms = forms; + this.points = points; + this.climbBounds = climbBounds; + this.onBoundaryChanged = onBoundaryChanged; + this.onPointSelected = onPointSelected; + this.setMessage = setMessage; + this.activeBoundary = null; + this.initialize(); + } + + initialize() { + for (const form of this.forms) { + const item = /** @type {HTMLElement} */ (form.closest("[data-climb-item]")); + const climbIndex = Number.parseInt(item.dataset.climbIndex, 10); + this.climbBounds[climbIndex] = { + startIndex: Number.parseInt(/** @type {HTMLInputElement} */ (form.querySelector('[data-boundary-input="start"]')).value, 10), + endIndex: Number.parseInt(/** @type {HTMLInputElement} */ (form.querySelector('[data-boundary-input="end"]')).value, 10), + }; + this.updatePreview(form); + const buttons = [...form.querySelectorAll("[data-boundary-button]")].map((button) => /** @type {HTMLElement} */ (button)); + for (const button of buttons) { + button.addEventListener("click", () => { + this.clearSelection(); + this.activeBoundary = { form, target: button.dataset.boundaryButton, source: button.dataset.boundarySource }; + button.classList.add("active"); + const surface = this.activeBoundary.source === "map" ? "map" : "profile"; + this.setMessage(`Click the ${surface} to select the ${this.activeBoundary.target} point.`); + }); + } + } + } + + isSelecting(source) { + return this.activeBoundary?.source === source; + } + + clearSelection() { + this.activeBoundary = null; + for (const form of this.forms) { + for (const button of form.querySelectorAll("[data-boundary-button]")) button.classList.remove("active"); + } + } + + choosePoint(index) { + if (!this.activeBoundary) return; + const { form, target } = this.activeBoundary; + const item = /** @type {HTMLElement} */ (form.closest("[data-climb-item]")); + const climbIndex = Number.parseInt(item.dataset.climbIndex, 10); + const input = /** @type {HTMLInputElement} */ (form.querySelector(`[data-boundary-input="${target}"]`)); + const output = /** @type {HTMLOutputElement} */ (form.querySelector(`[data-boundary-output="${target}"]`)); + input.value = index; + output.textContent = this.boundaryLabel(index); + this.climbBounds[climbIndex][`${target}Index`] = index; + this.updatePreview(form); + this.onBoundaryChanged(climbIndex); + this.clearSelection(); + this.onPointSelected(index); + } + + updatePreview(form) { + const preview = /** @type {HTMLElement} */ (form.querySelector("[data-boundary-preview]")); + const item = /** @type {HTMLElement} */ (form.closest("[data-climb-item]")); + const climbIndex = Number.parseInt(item.dataset.climbIndex, 10); + const metrics = climbMetrics(this.points, this.climbBounds[climbIndex]); + if (!metrics) { + preview.textContent = "Choose an end point after the start point."; + return; + } + const summary = /** @type {HTMLElement} */ (item.querySelector("[data-climb-summary]")); + const metricsOutput = /** @type {HTMLElement} */ (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)}`; + } + + boundaryLabel(index) { + const point = this.points[index]; + return `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`; + } +} diff --git a/web/static/ride-detail-canvas.js b/web/static/ride-detail-canvas.js new file mode 100644 index 0000000..99b09c1 --- /dev/null +++ b/web/static/ride-detail-canvas.js @@ -0,0 +1,211 @@ +import { clamp, climbMetrics, formatDistance, formatElevation, nearestPointIndex } from "./ride-detail-logic.js"; + +export class RideProfileCanvas { + constructor({ canvas, points, profile, colors, getClimbBounds, canSelectPoint, onPointSelected, onPointHover }) { + this.canvas = canvas; + this.points = points; + this.profile = profile; + this.colors = colors; + this.getClimbBounds = getClimbBounds; + this.canSelectPoint = canSelectPoint; + this.onPointSelected = onPointSelected; + this.onPointHover = onPointHover; + this.context = canvas.getContext("2d"); + if (!this.context) throw new Error("Ride profile canvas is unavailable"); + this.hoveredIndex = -1; + this.focusedClimbIndex = 0; + this.plot = null; + this.minDistance = points[0].distanceKm; + this.maxDistance = points[points.length - 1].distanceKm; + let minElevation = points[0].elevationM; + let maxElevation = points[0].elevationM; + for (const point of points) { + minElevation = Math.min(minElevation, point.elevationM); + maxElevation = Math.max(maxElevation, point.elevationM); + } + const elevationPadding = Math.max((maxElevation - minElevation) * 0.1, 20); + this.minElevation = minElevation - elevationPadding; + this.maxElevation = maxElevation + elevationPadding; + this.bindEvents(); + } + + setFocusedClimbIndex(index) { + this.focusedClimbIndex = index; + this.draw(); + } + + redraw() { + this.draw(); + } + + xForDistance(distance) { + const span = Math.max(this.maxDistance - this.minDistance, 1); + return this.plot.left + ((distance - this.minDistance) / span) * (this.plot.right - this.plot.left); + } + + yForElevation(elevation) { + const span = Math.max(this.maxElevation - this.minElevation, 1); + return this.plot.bottom - ((elevation - this.minElevation) / span) * (this.plot.bottom - this.plot.top); + } + + draw() { + const rect = this.canvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const ratio = window.devicePixelRatio || 1; + this.canvas.width = Math.floor(rect.width * ratio); + this.canvas.height = Math.floor(rect.height * ratio); + this.context.setTransform(ratio, 0, 0, ratio, 0, 0); + this.plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 }; + const plot = this.plot; + this.context.clearRect(0, 0, rect.width, rect.height); + this.context.fillStyle = this.colors.plotSurface; + this.context.fillRect(0, 0, rect.width, rect.height); + + this.context.font = "12px system-ui, sans-serif"; + this.context.textBaseline = "middle"; + for (let step = 0; step <= 4; step++) { + const fraction = step / 4; + const y = plot.top + fraction * (plot.bottom - plot.top); + const elevation = this.maxElevation - fraction * (this.maxElevation - this.minElevation); + this.context.strokeStyle = this.colors.grid; + this.context.lineWidth = 1; + this.context.beginPath(); + this.context.moveTo(plot.left, y); + this.context.lineTo(plot.right, y); + this.context.stroke(); + this.context.fillStyle = this.colors.subtle; + this.context.textAlign = "right"; + this.context.fillText(formatElevation(elevation), plot.left - 8, y); + } + + const climbBounds = this.getClimbBounds(); + for (let climbIndex = 0; climbIndex < (this.profile.climbs || []).length; climbIndex++) { + const climb = this.profile.climbs[climbIndex]; + const metrics = climbMetrics(this.points, climbBounds[climbIndex]); + if (!metrics) continue; + const startX = clamp(this.xForDistance(metrics.start.distanceKm), plot.left, plot.right); + const endX = clamp(this.xForDistance(metrics.end.distanceKm), plot.left, plot.right); + this.context.fillStyle = climbIndex === this.focusedClimbIndex ? this.colors.climbFocusFill : this.colors.accentFill; + this.context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top); + const label = climb.name || `${metrics.category} ${Math.round(metrics.score)}`; + this.context.fillStyle = this.colors.climbLabel; + this.context.textAlign = "center"; + this.context.textBaseline = "top"; + this.context.fillText(label, clamp((startX + endX) / 2, plot.left + 24, plot.right - 24), plot.top + 4); + } + + for (const crossing of this.profile.crossings || []) { + const x = this.xForDistance(crossing.distanceKm); + this.context.strokeStyle = this.colors.crossing; + this.context.lineWidth = 1; + this.context.setLineDash([4, 3]); + this.context.beginPath(); + this.context.moveTo(x, plot.top); + this.context.lineTo(x, plot.bottom); + this.context.stroke(); + this.context.setLineDash([]); + } + + this.context.beginPath(); + this.context.moveTo(this.xForDistance(this.points[0].distanceKm), plot.bottom); + for (const point of this.points) this.context.lineTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM)); + this.context.lineTo(this.xForDistance(this.points[this.points.length - 1].distanceKm), plot.bottom); + this.context.closePath(); + this.context.fillStyle = this.colors.accentFill; + this.context.fill(); + this.context.beginPath(); + for (let index = 0; index < this.points.length; index++) { + const point = this.points[index]; + if (index === 0) this.context.moveTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM)); + else this.context.lineTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM)); + } + this.context.strokeStyle = this.colors.accent; + this.context.lineWidth = 2.5; + this.context.stroke(); + for (const crossing of this.profile.crossings || []) { + const x = this.xForDistance(crossing.distanceKm); + const label = `${crossing.name} ${Math.round(crossing.passElevationM)} m`; + const labelWidth = this.context.measureText(label).width; + const labelX = clamp(x, plot.left + labelWidth / 2 + 3, plot.right - labelWidth / 2 - 3); + const labelY = plot.top - 6; + this.context.fillStyle = this.colors.plotSurfaceOverlay; + this.context.fillRect(labelX - labelWidth / 2 - 3, labelY - 15, labelWidth + 6, 16); + this.context.fillStyle = this.colors.crossingLabel; + this.context.textAlign = "center"; + this.context.textBaseline = "bottom"; + this.context.fillText(label, labelX, labelY); + } + + this.context.fillStyle = this.colors.subtle; + this.context.textAlign = "center"; + this.context.textBaseline = "top"; + for (let step = 0; step <= 4; step++) { + const distance = this.minDistance + (step / 4) * (this.maxDistance - this.minDistance); + this.context.fillText(formatDistance(distance), this.xForDistance(distance), plot.bottom + 10); + } + + if (this.hoveredIndex >= 0) { + const point = this.points[this.hoveredIndex]; + const x = this.xForDistance(point.distanceKm); + const y = this.yForElevation(point.elevationM); + this.context.strokeStyle = this.colors.hoverLine; + this.context.lineWidth = 1; + this.context.setLineDash([3, 3]); + this.context.beginPath(); + this.context.moveTo(x, plot.top); + this.context.lineTo(x, plot.bottom); + this.context.stroke(); + this.context.setLineDash([]); + this.context.fillStyle = this.colors.forest; + this.context.beginPath(); + this.context.arc(x, y, 5, 0, 2 * Math.PI); + this.context.fill(); + } + } + + clearHover() { + this.hoveredIndex = -1; + this.onPointHover(-1); + this.draw(); + } + + showPoint(index) { + this.hoveredIndex = clamp(index, 0, this.points.length - 1); + this.onPointHover(this.hoveredIndex); + this.draw(); + } + + pointIndexAtX(x) { + const distance = this.minDistance + ((x - this.plot.left) / (this.plot.right - this.plot.left)) * (this.maxDistance - this.minDistance); + return nearestPointIndex(this.points, distance); + } + + bindEvents() { + this.canvas.addEventListener("pointermove", (event) => { + if (!this.plot) return; + const rect = this.canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + if (x < this.plot.left || x > this.plot.right) { + this.clearHover(); + return; + } + this.showPoint(this.pointIndexAtX(x)); + }); + this.canvas.addEventListener("pointerleave", () => this.clearHover()); + this.canvas.addEventListener("pointercancel", () => this.clearHover()); + this.canvas.addEventListener("click", (event) => { + if (!this.canSelectPoint("profile") || !this.plot) return; + const rect = this.canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + if (x < this.plot.left || x > this.plot.right) return; + this.onPointSelected(this.pointIndexAtX(x)); + }); + this.canvas.addEventListener("keydown", (event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const direction = event.key === "ArrowRight" ? 1 : -1; + const index = this.hoveredIndex < 0 ? (direction > 0 ? 0 : this.points.length - 1) : this.hoveredIndex + direction; + this.showPoint(index); + }); + } +} diff --git a/web/static/ride-detail-logic.js b/web/static/ride-detail-logic.js new file mode 100644 index 0000000..4ab408b --- /dev/null +++ b/web/static/ride-detail-logic.js @@ -0,0 +1,82 @@ +export const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + +export const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`; + +export const formatElevation = (elevation) => `${Math.round(elevation)} m`; + +export const nearestPointIndex = (points, distance) => { + let low = 0; + let high = points.length - 1; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if (points[middle].distanceKm < distance) low = middle + 1; + else high = middle; + } + if (low === 0) return low; + const previous = points[low - 1]; + return distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low; +}; + +export 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"; +}; + +export const elevationAtDistance = (points, 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); +}; + +export const cotacolForClimb = (points, 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(points, pointIndex, segmentStartM); + while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 < segmentEndM) pointIndex++; + const endElevation = elevationAtDistance(points, pointIndex, segmentEndM); + const slope = (endElevation - startElevation) / (segmentEndM - segmentStartM); + if (slope > 0) score += ((segmentEndM - segmentStartM) / 1000) * (slope * 100) ** 2; + } + return score; +}; + +export const climbMetrics = (points, bounds) => { + 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(points, bounds.startIndex, bounds.endIndex), + category: categoryForScore(score), + }; +}; diff --git a/web/static/ride-detail-map.js b/web/static/ride-detail-map.js new file mode 100644 index 0000000..e8e7240 --- /dev/null +++ b/web/static/ride-detail-map.js @@ -0,0 +1,106 @@ +import { clamp } from "./ride-detail-logic.js"; + +export class RideDetailMap { + constructor({ leaflet, element, route, points, climbs, colors }) { + this.leaflet = leaflet; + this.points = points; + this.colors = colors; + this.map = leaflet.map(element); + const tiles = leaflet.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: '© OpenStreetMap contributors', + }); + tiles.addTo(this.map); + const routeLayer = leaflet + .geoJSON(route, { + style: { color: colors.accent, weight: 4, opacity: 0.9 }, + }) + .addTo(this.map); + const bounds = routeLayer.getBounds(); + if (bounds.isValid()) this.map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 }); + this.climbLayers = climbs.map((climb) => this.createClimbLayer(climb)); + this.routeCursor = leaflet + .circleMarker([points[0].latitude, points[0].longitude], { + color: colors.forest, + fillColor: colors.accent, + fillOpacity: 0, + opacity: 0, + radius: 7, + weight: 3, + interactive: false, + }) + .addTo(this.map); + } + + createClimbLayer(climb) { + if (!this.isValidBounds(climb)) return null; + const coordinates = this.points.slice(climb.startIndex, climb.endIndex + 1).map((point) => [point.latitude, point.longitude]); + return this.leaflet + .polyline(coordinates, { + color: this.colors.climbRoute, + weight: 7, + opacity: 0.65, + lineCap: "round", + lineJoin: "round", + interactive: false, + }) + .addTo(this.map); + } + + isValidBounds(bounds) { + return ( + bounds && + Number.isInteger(bounds.startIndex) && + Number.isInteger(bounds.endIndex) && + bounds.startIndex >= 0 && + bounds.endIndex < this.points.length && + bounds.startIndex < bounds.endIndex + ); + } + + updateClimbLayer(index, bounds, active) { + const layer = this.climbLayers[index]; + if (!layer) return; + if (!this.isValidBounds(bounds)) { + layer.setLatLngs([]); + return; + } + layer.setLatLngs(this.points.slice(bounds.startIndex, bounds.endIndex + 1).map((point) => [point.latitude, point.longitude])); + layer.setStyle({ weight: active ? 9 : 7, opacity: active ? 1 : 0.65 }); + } + + nearestPointIndex(latitude, longitude) { + let nearestIndex = 0; + let nearestDistance = Infinity; + for (let index = 0; index < this.points.length; index++) { + const point = this.points[index]; + const distance = this.leaflet.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]); + if (distance < nearestDistance) { + nearestIndex = index; + nearestDistance = distance; + } + } + return nearestIndex; + } + + showPoint(index) { + const point = this.points[clamp(index, 0, this.points.length - 1)]; + this.routeCursor.setLatLng([point.latitude, point.longitude]); + this.routeCursor.setStyle({ opacity: 1, fillOpacity: 0.9 }); + } + + clearPoint() { + this.routeCursor.setStyle({ opacity: 0, fillOpacity: 0 }); + } + + zoomToClimb(bounds) { + if (!this.isValidBounds(bounds)) return; + const climbPoints = this.points.slice(bounds.startIndex, bounds.endIndex + 1); + const mapBounds = this.leaflet.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude])); + if (mapBounds.isValid()) this.map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 }); + } + + onClick(listener) { + this.map.on("click", listener); + } +} diff --git a/web/static/ride-detail.js b/web/static/ride-detail.js index 75cb54a..67c87ae 100644 --- a/web/static/ride-detail.js +++ b/web/static/ride-detail.js @@ -1,4 +1,8 @@ import { OfficialClimbProfileController } from "./official-climb-profile.js"; +import { RideBoundaryController } from "./ride-detail-boundaries.js"; +import { RideProfileCanvas } from "./ride-detail-canvas.js"; +import { RideDetailMap } from "./ride-detail-map.js"; +import { formatDistance, formatElevation } from "./ride-detail-logic.js"; export const mountRideDetail = () => { const mapElement = document.getElementById("ride-map"); @@ -10,6 +14,7 @@ export const mountRideDetail = () => { if (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return; const leaflet = window.L; if (!leaflet) return; + const colorProbe = document.createElement("span"); colorProbe.hidden = true; document.body.append(colorProbe); @@ -33,26 +38,12 @@ export const mountRideDetail = () => { climbFocusFill: resolveColor("--color-climb-focus-fill"), }; colorProbe.remove(); + const route = JSON.parse(routeElement.textContent); const profile = JSON.parse(profileElement.textContent); - const map = leaflet.map(mapElement); - const tiles = leaflet.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - maxZoom: 19, - attribution: '© OpenStreetMap contributors', - }); - tiles.addTo(map); - const routeLayer = leaflet - .geoJSON(route, { - style: { color: colors.accent, weight: 4, opacity: 0.9 }, - }) - .addTo(map); - const bounds = routeLayer.getBounds(); - if (bounds.isValid()) { - map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 }); - } - const points = profile.points || []; if (points.length === 0) return; + const climbItems = [...document.querySelectorAll("[data-climb-item]")].map((item) => /** @type {HTMLElement} */ (item)); const climbItemIndices = climbItems.map((item) => Number.parseInt(item.dataset.climbIndex, 10)); /** @type {HTMLButtonElement | null} */ @@ -61,441 +52,91 @@ export const mountRideDetail = () => { const nextClimbButton = document.querySelector("[data-climb-next]"); /** @type {HTMLElement | null} */ const climbPosition = document.querySelector("[data-climb-position]"); - /** @type {(import("leaflet").Polyline | null)[]} */ - 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( - leaflet - .polyline(coordinates, { - color: colors.climbRoute, - weight: 7, - opacity: 0.65, - lineCap: "round", - lineJoin: "round", - interactive: false, - }) - .addTo(map), - ); - } - const routeCursor = leaflet - .circleMarker([points[0].latitude, points[0].longitude], { - color: colors.forest, - fillColor: colors.accent, - fillOpacity: 0, - opacity: 0, - radius: 7, - weight: 3, - interactive: false, - }) - .addTo(map); - - const context = canvas.getContext("2d"); - if (!context) return; + const climbBounds = (profile.climbs || []).map((climb) => ({ startIndex: climb.startIndex, endIndex: climb.endIndex })); const state = { - hoveredIndex: -1, focusedClimbItemIndex: 0, focusedClimbIndex: climbItemIndices[0] ?? 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; - let maxElevation = points[0].elevationM; - for (const point of points) { - minElevation = Math.min(minElevation, point.elevationM); - maxElevation = Math.max(maxElevation, point.elevationM); - } - const elevationPadding = Math.max((maxElevation - minElevation) * 0.1, 20); - minElevation -= elevationPadding; - maxElevation += elevationPadding; - const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`; - const formatElevation = (elevation) => `${Math.round(elevation)} m`; - const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + const mapController = new RideDetailMap({ + leaflet, + element: mapElement, + route, + points, + climbs: climbBounds, + colors, + }); const officialProfileController = new OfficialClimbProfileController(points); - const xForDistance = (distance) => { - const span = Math.max(maxDistance - minDistance, 1); - return state.plot.left + ((distance - minDistance) / span) * (state.plot.right - state.plot.left); - }; - const yForElevation = (elevation) => { - const span = Math.max(maxElevation - minElevation, 1); - return state.plot.bottom - ((elevation - minElevation) / span) * (state.plot.bottom - state.plot.top); - }; - const nearestPointIndex = (distance) => { - let low = 0; - let high = points.length - 1; - while (low < high) { - const middle = Math.floor((low + high) / 2); - if (points[middle].distanceKm < distance) low = middle + 1; - else high = middle; - } - if (low === 0) return low; - 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 = leaflet.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]); - if (distance < nearestDistance) { - nearestIndex = index; - nearestDistance = distance; + let boundaryController; + const profileCanvas = new RideProfileCanvas({ + canvas, + points, + profile, + colors, + getClimbBounds: () => climbBounds, + canSelectPoint: (source) => boundaryController?.isSelecting(source) ?? false, + onPointSelected: (index) => boundaryController?.choosePoint(index), + onPointHover: (index) => { + if (index < 0) { + mapController.clearPoint(); + hoverOutput.textContent = "Hover or focus the profile to inspect elevation."; + return; } - } - return nearestIndex; - }; - const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")].map((form) => /** @type {HTMLFormElement} */ (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]"); - /** @type {HTMLElement} */ - 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; - /** @type {HTMLElement} */ - const item = form.closest("[data-climb-item]"); - const climbIndex = Number.parseInt(item.dataset.climbIndex, 10); - const input = /** @type {HTMLInputElement} */ (form.querySelector(`[data-boundary-input="${target}"]`)); - const output = /** @type {HTMLOutputElement} */ (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) { - /** @type {HTMLElement} */ - const item = form.closest("[data-climb-item]"); - const climbIndex = Number.parseInt(item.dataset.climbIndex, 10); - state.climbBounds[climbIndex] = { - startIndex: Number.parseInt(/** @type {HTMLInputElement} */ (form.querySelector('[data-boundary-input="start"]')).value, 10), - endIndex: Number.parseInt(/** @type {HTMLInputElement} */ (form.querySelector('[data-boundary-input="end"]')).value, 10), - }; - updateBoundaryPreview(form); - const boundaryButtons = [...form.querySelectorAll("[data-boundary-button]")].map((button) => /** @type {HTMLElement} */ (button)); - for (const button of boundaryButtons) { - 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(); - if (rect.width === 0 || rect.height === 0) return; - const ratio = window.devicePixelRatio || 1; - canvas.width = Math.floor(rect.width * ratio); - canvas.height = Math.floor(rect.height * ratio); - context.setTransform(ratio, 0, 0, ratio, 0, 0); - state.width = rect.width; - state.height = rect.height; - state.plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 }; - const plot = state.plot; - context.clearRect(0, 0, rect.width, rect.height); - context.fillStyle = colors.plotSurface; - context.fillRect(0, 0, rect.width, rect.height); - - context.font = "12px system-ui, sans-serif"; - context.textBaseline = "middle"; - for (let step = 0; step <= 4; step++) { - const fraction = step / 4; - const y = plot.top + fraction * (plot.bottom - plot.top); - const elevation = maxElevation - fraction * (maxElevation - minElevation); - context.strokeStyle = colors.grid; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(plot.left, y); - context.lineTo(plot.right, y); - context.stroke(); - context.fillStyle = colors.subtle; - context.textAlign = "right"; - context.fillText(formatElevation(elevation), plot.left - 8, y); - } - - 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 || `${metrics.category} ${Math.round(metrics.score)}`; - context.fillStyle = colors.climbLabel; - context.textAlign = "center"; - context.textBaseline = "top"; - context.fillText(label, clamp((startX + endX) / 2, plot.left + 24, plot.right - 24), plot.top + 4); - } - - for (const crossing of profile.crossings || []) { - const x = xForDistance(crossing.distanceKm); - context.strokeStyle = colors.crossing; - context.lineWidth = 1; - context.setLineDash([4, 3]); - context.beginPath(); - context.moveTo(x, plot.top); - context.lineTo(x, plot.bottom); - context.stroke(); - context.setLineDash([]); - } - - context.beginPath(); - context.moveTo(xForDistance(points[0].distanceKm), plot.bottom); - for (const point of points) context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM)); - context.lineTo(xForDistance(points[points.length - 1].distanceKm), plot.bottom); - context.closePath(); - context.fillStyle = colors.accentFill; - context.fill(); - context.beginPath(); - for (let i = 0; i < points.length; i++) { - const point = points[i]; - if (i === 0) context.moveTo(xForDistance(point.distanceKm), yForElevation(point.elevationM)); - else context.lineTo(xForDistance(point.distanceKm), yForElevation(point.elevationM)); - } - context.strokeStyle = colors.accent; - context.lineWidth = 2.5; - context.stroke(); - for (const crossing of profile.crossings || []) { - const x = xForDistance(crossing.distanceKm); - const label = `${crossing.name} ${Math.round(crossing.passElevationM)} m`; - const labelWidth = context.measureText(label).width; - const labelX = clamp(x, plot.left + labelWidth / 2 + 3, plot.right - labelWidth / 2 - 3); - const labelY = plot.top - 6; - context.fillStyle = colors.plotSurfaceOverlay; - context.fillRect(labelX - labelWidth / 2 - 3, labelY - 15, labelWidth + 6, 16); - context.fillStyle = colors.crossingLabel; - context.textAlign = "center"; - context.textBaseline = "bottom"; - context.fillText(label, labelX, labelY); - } + const point = points[index]; + mapController.showPoint(index); + hoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`; + }, + }); - context.fillStyle = colors.subtle; - context.textAlign = "center"; - context.textBaseline = "top"; - for (let step = 0; step <= 4; step++) { - const distance = minDistance + (step / 4) * (maxDistance - minDistance); - context.fillText(formatDistance(distance), xForDistance(distance), plot.bottom + 10); - } + const updateClimbLayer = (index) => { + mapController.updateClimbLayer(index, climbBounds[index], index === state.focusedClimbIndex); + }; + boundaryController = new RideBoundaryController({ + forms: [...document.querySelectorAll("[data-official-climb-form]")].map((form) => /** @type {HTMLFormElement} */ (form)), + points, + climbBounds, + onBoundaryChanged: (index) => { + updateClimbLayer(index); + profileCanvas.redraw(); + }, + onPointSelected: (index) => profileCanvas.showPoint(index), + setMessage: (message) => { + hoverOutput.textContent = message; + }, + }); - if (state.hoveredIndex >= 0) { - const point = points[state.hoveredIndex]; - const x = xForDistance(point.distanceKm); - const y = yForElevation(point.elevationM); - context.strokeStyle = colors.hoverLine; - context.lineWidth = 1; - context.setLineDash([3, 3]); - context.beginPath(); - context.moveTo(x, plot.top); - context.lineTo(x, plot.bottom); - context.stroke(); - context.setLineDash([]); - context.fillStyle = colors.forest; - context.beginPath(); - context.arc(x, y, 5, 0, 2 * Math.PI); - context.fill(); - } - }; + mapController.onClick((event) => { + if (!boundaryController.isSelecting("map")) return; + boundaryController.choosePoint(mapController.nearestPointIndex(event.latlng.lat, event.latlng.lng)); + }); - const clearHover = () => { - state.hoveredIndex = -1; - routeCursor.setStyle({ opacity: 0, fillOpacity: 0 }); - hoverOutput.textContent = "Hover or focus the profile to inspect elevation."; - draw(); - }; - const showPoint = (index) => { - state.hoveredIndex = clamp(index, 0, points.length - 1); - const point = points[state.hoveredIndex]; - routeCursor.setLatLng([point.latitude, point.longitude]); - routeCursor.setStyle({ opacity: 1, fillOpacity: 0.9 }); - 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 = leaflet.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.focusedClimbItemIndex = clamp(index, 0, climbItems.length - 1); + state.focusedClimbItemIndex = Math.max(0, Math.min(climbItems.length - 1, index)); state.focusedClimbIndex = climbItemIndices[state.focusedClimbItemIndex]; for (let itemIndex = 0; itemIndex < climbItems.length; itemIndex++) { const active = itemIndex === state.focusedClimbItemIndex; climbItems[itemIndex].hidden = !active; climbItems[itemIndex].setAttribute("aria-hidden", String(!active)); climbItems[itemIndex].classList.toggle("focused", active); - const climbIndex = climbItemIndices[itemIndex]; - if (climbLayers[climbIndex]) { - updateClimbLayer(climbIndex); - } + updateClimbLayer(climbItemIndices[itemIndex]); } if (climbPosition) climbPosition.textContent = `Climb ${state.focusedClimbItemIndex + 1} of ${climbItems.length}`; if (previousClimbButton) previousClimbButton.disabled = state.focusedClimbItemIndex === 0; if (nextClimbButton) nextClimbButton.disabled = state.focusedClimbItemIndex === climbItems.length - 1; - clearBoundarySelection(); - draw(); - if (zoom) zoomToClimb(state.focusedClimbIndex); + boundaryController.clearSelection(); + profileCanvas.setFocusedClimbIndex(state.focusedClimbIndex); + if (zoom) mapController.zoomToClimb(climbBounds[state.focusedClimbIndex]); }; + if (previousClimbButton) previousClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbItemIndex - 1, true)); if (nextClimbButton) nextClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbItemIndex + 1, true)); - canvas.addEventListener("pointermove", (event) => { - if (!state.plot) return; - const rect = canvas.getBoundingClientRect(); - const x = event.clientX - rect.left; - if (x < state.plot.left || x > state.plot.right) { - clearHover(); - return; - } - const distance = minDistance + ((x - state.plot.left) / (state.plot.right - state.plot.left)) * (maxDistance - minDistance); - showPoint(nearestPointIndex(distance)); - }); - canvas.addEventListener("pointerleave", clearHover); - canvas.addEventListener("pointercancel", clearHover); - canvas.addEventListener("click", (event) => { - if (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?.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(); - const direction = event.key === "ArrowRight" ? 1 : -1; - const index = state.hoveredIndex < 0 ? (direction > 0 ? 0 : points.length - 1) : state.hoveredIndex + direction; - showPoint(index); - }); window.addEventListener("resize", () => { - draw(); + profileCanvas.redraw(); officialProfileController.redrawOpen(); }); if (climbItems.length > 0) updateClimbFocus(0, false); - else draw(); + else profileCanvas.redraw(); }; mountRideDetail(); diff --git a/web/static_test/ride-detail-logic.test.js b/web/static_test/ride-detail-logic.test.js new file mode 100644 index 0000000..2d9a695 --- /dev/null +++ b/web/static_test/ride-detail-logic.test.js @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + categoryForScore, + climbMetrics, + cotacolForClimb, + elevationAtDistance, + formatDistance, + formatElevation, + nearestPointIndex, +} from "../static/ride-detail-logic.js"; + +const points = [ + { distanceKm: 0, elevationM: 100 }, + { distanceKm: 0.12, elevationM: 106 }, + { distanceKm: 0.2, elevationM: 114 }, + { distanceKm: 0.25, elevationM: 120 }, +]; + +test("finds the nearest profile point by distance", () => { + assert.equal(nearestPointIndex(points, 0.01), 0); + assert.equal(nearestPointIndex(points, 0.15), 1); + assert.equal(nearestPointIndex(points, 0.24), 3); +}); + +test("interpolates elevation between route points", () => { + assert.equal(elevationAtDistance(points, 0, 100), 105); + assert.equal(elevationAtDistance(points, 2, 250), 120); +}); + +test("calculates climb metrics with the Cotacol score", () => { + const metrics = climbMetrics(points, { startIndex: 0, endIndex: 3 }); + + assert.equal(metrics.distanceKm, 0.25); + assert.equal(metrics.elevationGain, 20); + assert.equal(metrics.slope, 8); + assert.equal(metrics.cotacol, cotacolForClimb(points, 0, 3)); + assert.equal(metrics.category, categoryForScore(metrics.score)); +}); + +test("formats profile labels consistently", () => { + assert.equal(formatDistance(2.4), "2.4 km"); + assert.equal(formatDistance(12.4), "12 km"); + assert.equal(formatElevation(123.6), "124 m"); +}); -- cgit v1.2.3