From be78280c669a04c112afaf1801f22ed2eec7c55a Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 16 Aug 2026 13:19:22 +0200 Subject: refactor(web): establish JavaScript module tooling --- web/static/ride-detail.js | 137 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 95 insertions(+), 42 deletions(-) (limited to 'web/static/ride-detail.js') diff --git a/web/static/ride-detail.js b/web/static/ride-detail.js index 70878a1..75cb54a 100644 --- a/web/static/ride-detail.js +++ b/web/static/ride-detail.js @@ -1,10 +1,15 @@ -(() => { +import { OfficialClimbProfileController } from "./official-climb-profile.js"; + +export const mountRideDetail = () => { const mapElement = document.getElementById("ride-map"); - const routeElement = document.getElementById("ride-route"); - const profileElement = document.getElementById("ride-profile"); - const canvas = document.getElementById("ride-profile-chart"); + const routeElement = /** @type {HTMLScriptElement | null} */ (document.getElementById("ride-route")); + const profileElement = /** @type {HTMLScriptElement | null} */ (document.getElementById("ride-profile")); + const canvas = /** @type {HTMLCanvasElement | null} */ (document.getElementById("ride-profile-chart")); + /** @type {HTMLElement | null} */ const hoverOutput = document.getElementById("ride-profile-hover"); 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); @@ -30,15 +35,17 @@ colorProbe.remove(); const route = JSON.parse(routeElement.textContent); const profile = JSON.parse(profileElement.textContent); - const map = L.map(mapElement); - const tiles = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + 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 = L.geoJSON(route, { - style: { color: colors.accent, weight: 4, opacity: 0.9 }, - }).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 }); @@ -46,29 +53,52 @@ const points = profile.points || []; if (points.length === 0) return; - const climbItems = [...document.querySelectorAll("[data-climb-item]")]; + 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} */ const previousClimbButton = document.querySelector("[data-climb-previous]"); + /** @type {HTMLButtonElement | null} */ 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) { + 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)); + climbLayers.push( + leaflet + .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, - fillOpacity: 0, - opacity: 0, - radius: 7, - weight: 3, - 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; @@ -96,14 +126,14 @@ 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 officialProfileController = window.createOfficialClimbProfileController(points); + 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); + 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); + return state.plot.bottom - ((elevation - minElevation) / span) * (state.plot.bottom - state.plot.top); }; const nearestPointIndex = (distance) => { let low = 0; @@ -122,7 +152,7 @@ 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]); + const distance = leaflet.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]); if (distance < nearestDistance) { nearestIndex = index; nearestDistance = distance; @@ -130,7 +160,7 @@ } return nearestIndex; }; - const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")]; + const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")].map((form) => /** @type {HTMLFormElement} */ (form)); let activeBoundary = null; const boundaryLabel = (index) => { const point = points[index]; @@ -164,20 +194,37 @@ 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; + 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; + 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 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; @@ -187,6 +234,7 @@ }; 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); @@ -216,10 +264,11 @@ 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 = form.querySelector(`[data-boundary-input="${target}"]`); - const output = form.querySelector(`[data-boundary-output="${target}"]`); + 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; @@ -229,14 +278,16 @@ 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(form.querySelector('[data-boundary-input="start"]').value, 10), - endIndex: Number.parseInt(form.querySelector('[data-boundary-input="end"]').value, 10), + 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); - for (const button of form.querySelectorAll("[data-boundary-button]")) { + 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 }; @@ -340,7 +391,7 @@ context.textAlign = "center"; context.textBaseline = "top"; for (let step = 0; step <= 4; step++) { - const distance = minDistance + step / 4 * (maxDistance - minDistance); + const distance = minDistance + (step / 4) * (maxDistance - minDistance); context.fillText(formatDistance(distance), xForDistance(distance), plot.bottom + 10); } @@ -381,7 +432,7 @@ 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])); + 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) => { @@ -415,21 +466,21 @@ clearHover(); return; } - const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance); + 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 || activeBoundary.source !== "profile" || !state.plot) return; + 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); + 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; + if (activeBoundary?.source !== "map") return; chooseBoundary(nearestMapPointIndex(event.latlng.lat, event.latlng.lng)); }); canvas.addEventListener("keydown", (event) => { @@ -445,4 +496,6 @@ }); if (climbItems.length > 0) updateClimbFocus(0, false); else draw(); -})(); +}; + +mountRideDetail(); -- cgit v1.2.3