summaryrefslogtreecommitdiff
path: root/web/static/ride-detail.js
diff options
context:
space:
mode:
Diffstat (limited to 'web/static/ride-detail.js')
-rw-r--r--web/static/ride-detail.js208
1 files changed, 201 insertions, 7 deletions
diff --git a/web/static/ride-detail.js b/web/static/ride-detail.js
index 178b486..bd36da4 100644
--- a/web/static/ride-detail.js
+++ b/web/static/ride-detail.js
@@ -24,6 +24,8 @@
crossing: resolveColor("--color-crossing"),
crossingLabel: resolveColor("--color-crossing-label"),
hoverLine: resolveColor("--color-hover-line"),
+ climbRoute: resolveColor("--color-climb-route"),
+ climbFocusFill: resolveColor("--color-climb-focus-fill"),
};
colorProbe.remove();
const route = JSON.parse(routeElement.textContent);
@@ -44,6 +46,19 @@
const points = profile.points || [];
if (points.length === 0) return;
+ const climbItems = [...document.querySelectorAll("[data-climb-item]")];
+ const previousClimbButton = document.querySelector("[data-climb-previous]");
+ const nextClimbButton = document.querySelector("[data-climb-next]");
+ const climbPosition = document.querySelector("[data-climb-position]");
+ const climbLayers = [];
+ for (const climb of profile.climbs || []) {
+ if (!Number.isInteger(climb.startIndex) || !Number.isInteger(climb.endIndex) || climb.startIndex < 0 || climb.endIndex >= points.length || climb.startIndex >= climb.endIndex) {
+ climbLayers.push(null);
+ continue;
+ }
+ const coordinates = points.slice(climb.startIndex, climb.endIndex + 1).map((point) => [point.latitude, point.longitude]);
+ climbLayers.push(L.polyline(coordinates, { color: colors.climbRoute, weight: 7, opacity: 0.65, lineCap: "round", lineJoin: "round", interactive: false }).addTo(map));
+ }
const routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], {
color: colors.forest,
fillColor: colors.accent,
@@ -56,7 +71,14 @@
const context = canvas.getContext("2d");
if (!context) return;
- const state = { hoveredIndex: -1, plot: null, width: 0, height: 0 };
+ const state = {
+ hoveredIndex: -1,
+ focusedClimbIndex: 0,
+ climbBounds: (profile.climbs || []).map((climb) => ({ startIndex: climb.startIndex, endIndex: climb.endIndex })),
+ plot: null,
+ width: 0,
+ height: 0,
+ };
const minDistance = points[0].distanceKm;
const maxDistance = points[points.length - 1].distanceKm;
let minElevation = points[0].elevationM;
@@ -92,6 +114,135 @@
const previous = points[low - 1];
return distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low;
};
+ const nearestMapPointIndex = (latitude, longitude) => {
+ let nearestIndex = 0;
+ let nearestDistance = Infinity;
+ for (let index = 0; index < points.length; index++) {
+ const point = points[index];
+ const distance = L.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]);
+ if (distance < nearestDistance) {
+ nearestIndex = index;
+ nearestDistance = distance;
+ }
+ }
+ return nearestIndex;
+ };
+ const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")];
+ let activeBoundary = null;
+ const boundaryLabel = (index) => {
+ const point = points[index];
+ return `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
+ };
+ const categoryForScore = (score) => {
+ if (score < 35) return "NO";
+ if (score < 80) return "Cat 4";
+ if (score < 180) return "Cat 3";
+ if (score < 250) return "Cat 2";
+ if (score < 600) return "Cat 1";
+ return "HC";
+ };
+ const elevationAtDistance = (index, distanceM) => {
+ if (index + 1 >= points.length) return points[index].elevationM;
+ const startDistanceM = points[index].distanceKm * 1000;
+ const endDistanceM = points[index + 1].distanceKm * 1000;
+ const fraction = (distanceM - startDistanceM) / (endDistanceM - startDistanceM);
+ return points[index].elevationM + fraction * (points[index + 1].elevationM - points[index].elevationM);
+ };
+ const cotacolForClimb = (startIndex, endIndex) => {
+ const startDistanceM = points[startIndex].distanceKm * 1000;
+ const lastDistanceM = points[endIndex].distanceKm * 1000;
+ if (lastDistanceM <= startDistanceM) return 0;
+ let score = 0;
+ let pointIndex = startIndex;
+ for (let segmentStartM = startDistanceM; segmentStartM < lastDistanceM; segmentStartM += 100) {
+ const segmentEndM = Math.min(segmentStartM + 100, lastDistanceM);
+ while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 <= segmentStartM) pointIndex++;
+ const startElevation = elevationAtDistance(pointIndex, segmentStartM);
+ while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 < segmentEndM) pointIndex++;
+ const endElevation = elevationAtDistance(pointIndex, segmentEndM);
+ const slope = (endElevation - startElevation) / (segmentEndM - segmentStartM);
+ if (slope > 0) score += (segmentEndM - segmentStartM) / 1000 * (slope * 100) ** 2;
+ }
+ return score;
+ };
+ const climbMetrics = (index) => {
+ const bounds = state.climbBounds[index];
+ if (!bounds || !Number.isInteger(bounds.startIndex) || !Number.isInteger(bounds.endIndex) || bounds.startIndex < 0 || bounds.endIndex >= points.length || bounds.startIndex >= bounds.endIndex) return null;
+ const start = points[bounds.startIndex];
+ const end = points[bounds.endIndex];
+ const distanceKm = end.distanceKm - start.distanceKm;
+ const elevationGain = end.elevationM - start.elevationM;
+ const slope = distanceKm > 0 ? elevationGain / (distanceKm * 10) : 0;
+ const score = distanceKm > 0 ? Math.abs(elevationGain) * elevationGain / (distanceKm * 1000) * 10 : 0;
+ return { start, end, distanceKm, elevationGain, slope, score, cotacol: cotacolForClimb(bounds.startIndex, bounds.endIndex), category: categoryForScore(score) };
+ };
+ const clearBoundarySelection = () => {
+ activeBoundary = null;
+ for (const form of boundaryForms) {
+ for (const button of form.querySelectorAll("[data-boundary-button]")) button.classList.remove("active");
+ }
+ };
+ const updateBoundaryPreview = (form) => {
+ const preview = form.querySelector("[data-boundary-preview]");
+ const item = form.closest("[data-climb-item]");
+ const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
+ const metrics = climbMetrics(climbIndex);
+ if (!metrics) {
+ preview.textContent = "Choose an end point after the start point.";
+ return;
+ }
+ const summary = item.querySelector("[data-climb-summary]");
+ const metricsOutput = item.querySelector("[data-climb-metrics]");
+ summary.textContent = `${formatDistance(metrics.start.distanceKm)}–${formatDistance(metrics.end.distanceKm)}`;
+ metricsOutput.textContent = `${metrics.category} · ${formatDistance(metrics.distanceKm)} at ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`;
+ preview.textContent = `Preview: ${formatDistance(metrics.distanceKm)} · ${metrics.elevationGain >= 0 ? "+" : ""}${formatElevation(metrics.elevationGain)} · ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`;
+ };
+ const updateClimbLayer = (index) => {
+ const layer = climbLayers[index];
+ const metrics = climbMetrics(index);
+ if (!layer) return;
+ if (!metrics) {
+ layer.setLatLngs([]);
+ return;
+ }
+ const bounds = state.climbBounds[index];
+ layer.setLatLngs(points.slice(bounds.startIndex, bounds.endIndex + 1).map((point) => [point.latitude, point.longitude]));
+ const active = index === state.focusedClimbIndex;
+ layer.setStyle({ weight: active ? 9 : 7, opacity: active ? 1 : 0.65 });
+ };
+ const chooseBoundary = (index) => {
+ if (!activeBoundary) return;
+ const { form, target } = activeBoundary;
+ const item = form.closest("[data-climb-item]");
+ const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
+ const input = form.querySelector(`[data-boundary-input="${target}"]`);
+ const output = form.querySelector(`[data-boundary-output="${target}"]`);
+ input.value = index;
+ output.textContent = boundaryLabel(index);
+ state.climbBounds[climbIndex][`${target}Index`] = index;
+ updateBoundaryPreview(form);
+ updateClimbLayer(climbIndex);
+ clearBoundarySelection();
+ showPoint(index);
+ };
+ for (const form of boundaryForms) {
+ const item = form.closest("[data-climb-item]");
+ const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
+ state.climbBounds[climbIndex] = {
+ startIndex: Number.parseInt(form.querySelector('[data-boundary-input="start"]').value, 10),
+ endIndex: Number.parseInt(form.querySelector('[data-boundary-input="end"]').value, 10),
+ };
+ updateBoundaryPreview(form);
+ for (const button of form.querySelectorAll("[data-boundary-button]")) {
+ button.addEventListener("click", () => {
+ clearBoundarySelection();
+ activeBoundary = { form, target: button.dataset.boundaryButton, source: button.dataset.boundarySource };
+ button.classList.add("active");
+ const surface = activeBoundary.source === "map" ? "map" : "profile";
+ hoverOutput.textContent = `Click the ${surface} to select the ${activeBoundary.target} point.`;
+ });
+ }
+ }
const draw = () => {
const rect = canvas.getBoundingClientRect();
@@ -125,12 +276,15 @@
context.fillText(formatElevation(elevation), plot.left - 8, y);
}
- for (const climb of profile.climbs || []) {
- const startX = clamp(xForDistance(climb.startKm), plot.left, plot.right);
- const endX = clamp(xForDistance(climb.endKm), plot.left, plot.right);
- context.fillStyle = colors.accentFill;
+ for (let climbIndex = 0; climbIndex < (profile.climbs || []).length; climbIndex++) {
+ const climb = profile.climbs[climbIndex];
+ const metrics = climbMetrics(climbIndex);
+ if (!metrics) continue;
+ const startX = clamp(xForDistance(metrics.start.distanceKm), plot.left, plot.right);
+ const endX = clamp(xForDistance(metrics.end.distanceKm), plot.left, plot.right);
+ context.fillStyle = climbIndex === state.focusedClimbIndex ? colors.climbFocusFill : colors.accentFill;
context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top);
- const label = climb.name || `${climb.category} ${Math.round(climb.score)}`;
+ const label = climb.name || `${metrics.category} ${Math.round(metrics.score)}`;
context.fillStyle = colors.climbLabel;
context.textAlign = "center";
context.textBaseline = "top";
@@ -220,6 +374,33 @@
hoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
draw();
};
+ const zoomToClimb = (index) => {
+ const climbBounds = state.climbBounds[index];
+ if (!climbBounds || !Number.isInteger(climbBounds.startIndex) || !Number.isInteger(climbBounds.endIndex)) return;
+ const climbPoints = points.slice(climbBounds.startIndex, climbBounds.endIndex + 1);
+ const mapBounds = L.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude]));
+ if (mapBounds.isValid()) map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 });
+ };
+ const updateClimbFocus = (index, zoom) => {
+ if (climbItems.length === 0) return;
+ state.focusedClimbIndex = clamp(index, 0, climbItems.length - 1);
+ for (let itemIndex = 0; itemIndex < climbItems.length; itemIndex++) {
+ const active = itemIndex === state.focusedClimbIndex;
+ climbItems[itemIndex].hidden = !active;
+ climbItems[itemIndex].setAttribute("aria-hidden", String(!active));
+ if (climbLayers[itemIndex]) {
+ updateClimbLayer(itemIndex);
+ }
+ }
+ if (climbPosition) climbPosition.textContent = `Climb ${state.focusedClimbIndex + 1} of ${climbItems.length}`;
+ if (previousClimbButton) previousClimbButton.disabled = state.focusedClimbIndex === 0;
+ if (nextClimbButton) nextClimbButton.disabled = state.focusedClimbIndex === climbItems.length - 1;
+ clearBoundarySelection();
+ draw();
+ if (zoom) zoomToClimb(state.focusedClimbIndex);
+ };
+ if (previousClimbButton) previousClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbIndex - 1, true));
+ if (nextClimbButton) nextClimbButton.addEventListener("click", () => updateClimbFocus(state.focusedClimbIndex + 1, true));
canvas.addEventListener("pointermove", (event) => {
if (!state.plot) return;
const rect = canvas.getBoundingClientRect();
@@ -233,6 +414,18 @@
});
canvas.addEventListener("pointerleave", clearHover);
canvas.addEventListener("pointercancel", clearHover);
+ canvas.addEventListener("click", (event) => {
+ if (!activeBoundary || activeBoundary.source !== "profile" || !state.plot) return;
+ const rect = canvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ if (x < state.plot.left || x > state.plot.right) return;
+ const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance);
+ chooseBoundary(nearestPointIndex(distance));
+ });
+ map.on("click", (event) => {
+ if (!activeBoundary || activeBoundary.source !== "map") return;
+ chooseBoundary(nearestMapPointIndex(event.latlng.lat, event.latlng.lng));
+ });
canvas.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
@@ -241,5 +434,6 @@
showPoint(index);
});
window.addEventListener("resize", draw);
- draw();
+ if (climbItems.length > 0) updateClimbFocus(0, false);
+ else draw();
})();