diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 13:25:21 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 13:25:21 +0200 |
| commit | 2d18d177f48285eb123e7da8680fc00e7a6fe19c (patch) | |
| tree | 2b339bcec21c244242f6e26d4444feebdf3b73d0 /web/static/ride-detail.js | |
| parent | be78280c669a04c112afaf1801f22ed2eec7c55a (diff) | |
refactor(web): split ride detail JavaScript
Diffstat (limited to 'web/static/ride-detail.js')
| -rw-r--r-- | web/static/ride-detail.js | 485 |
1 files changed, 63 insertions, 422 deletions
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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(); |