summaryrefslogtreecommitdiff
path: root/web/static
diff options
context:
space:
mode:
Diffstat (limited to 'web/static')
-rw-r--r--web/static/ride-detail.js208
-rw-r--r--web/static/style.css26
2 files changed, 227 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();
})();
diff --git a/web/static/style.css b/web/static/style.css
index b24fd21..8a35844 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -36,6 +36,8 @@
--color-crossing: oklch(from var(--palette-leaf) l c h / 60%);
--color-crossing-label: var(--color-notice-text);
--color-hover-line: oklch(from var(--palette-forest) l c h / 60%);
+ --color-climb-route: var(--palette-leaf);
+ --color-climb-focus-fill: oklch(from var(--palette-leaf) l c h / 18%);
}
* { box-sizing: border-box; }
@@ -48,6 +50,7 @@ a { color: var(--color-leaf); }
.container { width: min(70rem, calc(100% - 2rem)); margin: 0 auto; padding: 2.5rem 0 4rem; }
.eyebrow { margin: 0 0 .4rem; color: var(--color-leaf); font-size: .78rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(2rem, 4vw, 3.2rem); line-height: 1; }
+.ride-detail-title { margin: 0 0 .4rem; color: var(--color-leaf); font-size: .78rem; font-weight: 700; letter-spacing: .12em; line-height: normal; }
.lead { max-width: 42rem; color: var(--color-muted); }
.panel { margin-top: 2rem; padding: 1.25rem; border: 1px solid var(--color-border); border-radius: 1rem; background: var(--color-surface); box-shadow: 0 1rem 2.5rem var(--color-panel-shadow); }
.toolbar { display: flex; align-items: end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
@@ -61,6 +64,9 @@ input { border: 1px solid var(--color-input-border); border-radius: .55rem; padd
progress { width: 100%; height: .8rem; accent-color: var(--color-accent); }
.notice { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: var(--color-notice-surface); color: var(--color-notice-text); }
.error { margin: 1rem 0 0; border-radius: .6rem; padding: .75rem 1rem; background: var(--color-error-surface); color: var(--color-error-text); }
+.ride-detail-grid { display: grid; grid-template-columns: minmax(16rem, .85fr) minmax(0, 1.75fr); gap: 1.25rem; margin-top: 2rem; align-items: start; }
+.ride-detail-sidebar, .ride-detail-main { display: grid; gap: 1.25rem; min-width: 0; }
+.ride-detail-grid .panel { margin-top: 0; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: .85rem .5rem; border-bottom: 1px solid var(--color-border-subtle); text-align: left; }
th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }
@@ -79,8 +85,28 @@ th { color: var(--color-subtle); font-size: .78rem; letter-spacing: .08em; text-
.profile-chart canvas { display: block; width: 100%; height: 22rem; border-radius: .65rem; background: var(--color-plot-surface); outline: none; }
.profile-chart canvas:focus-visible { box-shadow: 0 0 0 .2rem var(--color-focus); }
.profile-hover { display: block; min-height: 1.4rem; margin: .7rem 0 0; color: var(--color-muted); font-size: .9rem; }
+.climb-list { display: grid; gap: 1rem; margin-top: 1.25rem; }
+.climb-item { padding-top: 1rem; border-top: 1px solid var(--color-border-subtle); }
+.climb-item-heading { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
+.climb-number { display: inline-block; margin-right: .45rem; color: var(--color-accent); font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }
+.climb-item-heading > span, .climb-status { color: var(--color-muted); font-size: .9rem; }
+.climb-metrics { margin: .35rem 0 0; color: var(--color-muted); font-size: .9rem; }
+.climb-status { margin: .35rem 0 0; }
+.climb-status.matched { color: var(--color-notice-text); }
+.official-climb-form { display: grid; gap: .8rem; margin-top: .8rem; padding: .8rem; border: 1px solid var(--color-border-subtle); border-radius: .65rem; background: var(--color-plot-surface); }
+.boundary-controls { display: flex; gap: .75rem; flex-wrap: wrap; }
+.boundary-controls > div { display: grid; gap: .35rem; }
+.boundary-controls > div > div { display: flex; gap: .35rem; flex-wrap: wrap; }
+.boundary-controls output { color: var(--color-muted); font-size: .85rem; }
+.boundary-controls .button.active { outline: .2rem solid var(--color-focus); }
+.boundary-preview { color: var(--color-notice-text); font-size: .9rem; }
+.climb-navigation { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-top: 1.25rem; }
+.climbs-panel > .climb-navigation { margin-top: 0; }
+.climb-navigation output { color: var(--color-muted); font-size: .9rem; font-weight: 700; }
+.climb-navigation .button:disabled { opacity: .45; cursor: default; }
dl { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 0; }
dt { color: var(--color-subtle); font-size: .78rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
dd { margin: .25rem 0 0; font-size: 1.2rem; font-weight: 700; }
+@media (max-width: 900px) { .ride-detail-grid { grid-template-columns: 1fr; } }
@media (max-width: 700px) { .container { width: min(100% - 1rem, 70rem); padding-top: 1.5rem; } .site-header { padding-inline: 1rem; } th:nth-child(n+4), td:nth-child(n+4) { display: none; } .panel { padding: .8rem; } }
@media (max-width: 500px) { dl { grid-template-columns: repeat(2, 1fr); } }