summaryrefslogtreecommitdiff
path: root/web/static/ride-detail.js
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 13:19:22 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 13:19:22 +0200
commitbe78280c669a04c112afaf1801f22ed2eec7c55a (patch)
tree0d630af1d0502aae12b1a8cf4ee609a9cba9e4e3 /web/static/ride-detail.js
parente7bd55d20974f4ee5b892cd2e81c62b5c5408920 (diff)
refactor(web): establish JavaScript module tooling
Diffstat (limited to 'web/static/ride-detail.js')
-rw-r--r--web/static/ride-detail.js137
1 files changed, 95 insertions, 42 deletions
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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();