summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--web/static/ride-detail-boundaries.js84
-rw-r--r--web/static/ride-detail-canvas.js211
-rw-r--r--web/static/ride-detail-logic.js82
-rw-r--r--web/static/ride-detail-map.js106
-rw-r--r--web/static/ride-detail.js485
-rw-r--r--web/static_test/ride-detail-logic.test.js45
6 files changed, 591 insertions, 422 deletions
diff --git a/web/static/ride-detail-boundaries.js b/web/static/ride-detail-boundaries.js
new file mode 100644
index 0000000..98e0990
--- /dev/null
+++ b/web/static/ride-detail-boundaries.js
@@ -0,0 +1,84 @@
+import { climbMetrics, formatDistance, formatElevation } from "./ride-detail-logic.js";
+
+export class RideBoundaryController {
+ constructor({ forms, points, climbBounds, onBoundaryChanged, onPointSelected, setMessage }) {
+ this.forms = forms;
+ this.points = points;
+ this.climbBounds = climbBounds;
+ this.onBoundaryChanged = onBoundaryChanged;
+ this.onPointSelected = onPointSelected;
+ this.setMessage = setMessage;
+ this.activeBoundary = null;
+ this.initialize();
+ }
+
+ initialize() {
+ for (const form of this.forms) {
+ const item = /** @type {HTMLElement} */ (form.closest("[data-climb-item]"));
+ const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
+ this.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),
+ };
+ this.updatePreview(form);
+ const buttons = [...form.querySelectorAll("[data-boundary-button]")].map((button) => /** @type {HTMLElement} */ (button));
+ for (const button of buttons) {
+ button.addEventListener("click", () => {
+ this.clearSelection();
+ this.activeBoundary = { form, target: button.dataset.boundaryButton, source: button.dataset.boundarySource };
+ button.classList.add("active");
+ const surface = this.activeBoundary.source === "map" ? "map" : "profile";
+ this.setMessage(`Click the ${surface} to select the ${this.activeBoundary.target} point.`);
+ });
+ }
+ }
+ }
+
+ isSelecting(source) {
+ return this.activeBoundary?.source === source;
+ }
+
+ clearSelection() {
+ this.activeBoundary = null;
+ for (const form of this.forms) {
+ for (const button of form.querySelectorAll("[data-boundary-button]")) button.classList.remove("active");
+ }
+ }
+
+ choosePoint(index) {
+ if (!this.activeBoundary) return;
+ const { form, target } = this.activeBoundary;
+ const item = /** @type {HTMLElement} */ (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 = this.boundaryLabel(index);
+ this.climbBounds[climbIndex][`${target}Index`] = index;
+ this.updatePreview(form);
+ this.onBoundaryChanged(climbIndex);
+ this.clearSelection();
+ this.onPointSelected(index);
+ }
+
+ updatePreview(form) {
+ const preview = /** @type {HTMLElement} */ (form.querySelector("[data-boundary-preview]"));
+ const item = /** @type {HTMLElement} */ (form.closest("[data-climb-item]"));
+ const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
+ const metrics = climbMetrics(this.points, this.climbBounds[climbIndex]);
+ if (!metrics) {
+ preview.textContent = "Choose an end point after the start point.";
+ return;
+ }
+ const summary = /** @type {HTMLElement} */ (item.querySelector("[data-climb-summary]"));
+ const metricsOutput = /** @type {HTMLElement} */ (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)}`;
+ }
+
+ boundaryLabel(index) {
+ const point = this.points[index];
+ return `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
+ }
+}
diff --git a/web/static/ride-detail-canvas.js b/web/static/ride-detail-canvas.js
new file mode 100644
index 0000000..99b09c1
--- /dev/null
+++ b/web/static/ride-detail-canvas.js
@@ -0,0 +1,211 @@
+import { clamp, climbMetrics, formatDistance, formatElevation, nearestPointIndex } from "./ride-detail-logic.js";
+
+export class RideProfileCanvas {
+ constructor({ canvas, points, profile, colors, getClimbBounds, canSelectPoint, onPointSelected, onPointHover }) {
+ this.canvas = canvas;
+ this.points = points;
+ this.profile = profile;
+ this.colors = colors;
+ this.getClimbBounds = getClimbBounds;
+ this.canSelectPoint = canSelectPoint;
+ this.onPointSelected = onPointSelected;
+ this.onPointHover = onPointHover;
+ this.context = canvas.getContext("2d");
+ if (!this.context) throw new Error("Ride profile canvas is unavailable");
+ this.hoveredIndex = -1;
+ this.focusedClimbIndex = 0;
+ this.plot = null;
+ this.minDistance = points[0].distanceKm;
+ this.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);
+ this.minElevation = minElevation - elevationPadding;
+ this.maxElevation = maxElevation + elevationPadding;
+ this.bindEvents();
+ }
+
+ setFocusedClimbIndex(index) {
+ this.focusedClimbIndex = index;
+ this.draw();
+ }
+
+ redraw() {
+ this.draw();
+ }
+
+ xForDistance(distance) {
+ const span = Math.max(this.maxDistance - this.minDistance, 1);
+ return this.plot.left + ((distance - this.minDistance) / span) * (this.plot.right - this.plot.left);
+ }
+
+ yForElevation(elevation) {
+ const span = Math.max(this.maxElevation - this.minElevation, 1);
+ return this.plot.bottom - ((elevation - this.minElevation) / span) * (this.plot.bottom - this.plot.top);
+ }
+
+ draw() {
+ const rect = this.canvas.getBoundingClientRect();
+ if (rect.width === 0 || rect.height === 0) return;
+ const ratio = window.devicePixelRatio || 1;
+ this.canvas.width = Math.floor(rect.width * ratio);
+ this.canvas.height = Math.floor(rect.height * ratio);
+ this.context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ this.plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 };
+ const plot = this.plot;
+ this.context.clearRect(0, 0, rect.width, rect.height);
+ this.context.fillStyle = this.colors.plotSurface;
+ this.context.fillRect(0, 0, rect.width, rect.height);
+
+ this.context.font = "12px system-ui, sans-serif";
+ this.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 = this.maxElevation - fraction * (this.maxElevation - this.minElevation);
+ this.context.strokeStyle = this.colors.grid;
+ this.context.lineWidth = 1;
+ this.context.beginPath();
+ this.context.moveTo(plot.left, y);
+ this.context.lineTo(plot.right, y);
+ this.context.stroke();
+ this.context.fillStyle = this.colors.subtle;
+ this.context.textAlign = "right";
+ this.context.fillText(formatElevation(elevation), plot.left - 8, y);
+ }
+
+ const climbBounds = this.getClimbBounds();
+ for (let climbIndex = 0; climbIndex < (this.profile.climbs || []).length; climbIndex++) {
+ const climb = this.profile.climbs[climbIndex];
+ const metrics = climbMetrics(this.points, climbBounds[climbIndex]);
+ if (!metrics) continue;
+ const startX = clamp(this.xForDistance(metrics.start.distanceKm), plot.left, plot.right);
+ const endX = clamp(this.xForDistance(metrics.end.distanceKm), plot.left, plot.right);
+ this.context.fillStyle = climbIndex === this.focusedClimbIndex ? this.colors.climbFocusFill : this.colors.accentFill;
+ this.context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top);
+ const label = climb.name || `${metrics.category} ${Math.round(metrics.score)}`;
+ this.context.fillStyle = this.colors.climbLabel;
+ this.context.textAlign = "center";
+ this.context.textBaseline = "top";
+ this.context.fillText(label, clamp((startX + endX) / 2, plot.left + 24, plot.right - 24), plot.top + 4);
+ }
+
+ for (const crossing of this.profile.crossings || []) {
+ const x = this.xForDistance(crossing.distanceKm);
+ this.context.strokeStyle = this.colors.crossing;
+ this.context.lineWidth = 1;
+ this.context.setLineDash([4, 3]);
+ this.context.beginPath();
+ this.context.moveTo(x, plot.top);
+ this.context.lineTo(x, plot.bottom);
+ this.context.stroke();
+ this.context.setLineDash([]);
+ }
+
+ this.context.beginPath();
+ this.context.moveTo(this.xForDistance(this.points[0].distanceKm), plot.bottom);
+ for (const point of this.points) this.context.lineTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM));
+ this.context.lineTo(this.xForDistance(this.points[this.points.length - 1].distanceKm), plot.bottom);
+ this.context.closePath();
+ this.context.fillStyle = this.colors.accentFill;
+ this.context.fill();
+ this.context.beginPath();
+ for (let index = 0; index < this.points.length; index++) {
+ const point = this.points[index];
+ if (index === 0) this.context.moveTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM));
+ else this.context.lineTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM));
+ }
+ this.context.strokeStyle = this.colors.accent;
+ this.context.lineWidth = 2.5;
+ this.context.stroke();
+ for (const crossing of this.profile.crossings || []) {
+ const x = this.xForDistance(crossing.distanceKm);
+ const label = `${crossing.name} ${Math.round(crossing.passElevationM)} m`;
+ const labelWidth = this.context.measureText(label).width;
+ const labelX = clamp(x, plot.left + labelWidth / 2 + 3, plot.right - labelWidth / 2 - 3);
+ const labelY = plot.top - 6;
+ this.context.fillStyle = this.colors.plotSurfaceOverlay;
+ this.context.fillRect(labelX - labelWidth / 2 - 3, labelY - 15, labelWidth + 6, 16);
+ this.context.fillStyle = this.colors.crossingLabel;
+ this.context.textAlign = "center";
+ this.context.textBaseline = "bottom";
+ this.context.fillText(label, labelX, labelY);
+ }
+
+ this.context.fillStyle = this.colors.subtle;
+ this.context.textAlign = "center";
+ this.context.textBaseline = "top";
+ for (let step = 0; step <= 4; step++) {
+ const distance = this.minDistance + (step / 4) * (this.maxDistance - this.minDistance);
+ this.context.fillText(formatDistance(distance), this.xForDistance(distance), plot.bottom + 10);
+ }
+
+ if (this.hoveredIndex >= 0) {
+ const point = this.points[this.hoveredIndex];
+ const x = this.xForDistance(point.distanceKm);
+ const y = this.yForElevation(point.elevationM);
+ this.context.strokeStyle = this.colors.hoverLine;
+ this.context.lineWidth = 1;
+ this.context.setLineDash([3, 3]);
+ this.context.beginPath();
+ this.context.moveTo(x, plot.top);
+ this.context.lineTo(x, plot.bottom);
+ this.context.stroke();
+ this.context.setLineDash([]);
+ this.context.fillStyle = this.colors.forest;
+ this.context.beginPath();
+ this.context.arc(x, y, 5, 0, 2 * Math.PI);
+ this.context.fill();
+ }
+ }
+
+ clearHover() {
+ this.hoveredIndex = -1;
+ this.onPointHover(-1);
+ this.draw();
+ }
+
+ showPoint(index) {
+ this.hoveredIndex = clamp(index, 0, this.points.length - 1);
+ this.onPointHover(this.hoveredIndex);
+ this.draw();
+ }
+
+ pointIndexAtX(x) {
+ const distance = this.minDistance + ((x - this.plot.left) / (this.plot.right - this.plot.left)) * (this.maxDistance - this.minDistance);
+ return nearestPointIndex(this.points, distance);
+ }
+
+ bindEvents() {
+ this.canvas.addEventListener("pointermove", (event) => {
+ if (!this.plot) return;
+ const rect = this.canvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ if (x < this.plot.left || x > this.plot.right) {
+ this.clearHover();
+ return;
+ }
+ this.showPoint(this.pointIndexAtX(x));
+ });
+ this.canvas.addEventListener("pointerleave", () => this.clearHover());
+ this.canvas.addEventListener("pointercancel", () => this.clearHover());
+ this.canvas.addEventListener("click", (event) => {
+ if (!this.canSelectPoint("profile") || !this.plot) return;
+ const rect = this.canvas.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ if (x < this.plot.left || x > this.plot.right) return;
+ this.onPointSelected(this.pointIndexAtX(x));
+ });
+ this.canvas.addEventListener("keydown", (event) => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
+ event.preventDefault();
+ const direction = event.key === "ArrowRight" ? 1 : -1;
+ const index = this.hoveredIndex < 0 ? (direction > 0 ? 0 : this.points.length - 1) : this.hoveredIndex + direction;
+ this.showPoint(index);
+ });
+ }
+}
diff --git a/web/static/ride-detail-logic.js b/web/static/ride-detail-logic.js
new file mode 100644
index 0000000..4ab408b
--- /dev/null
+++ b/web/static/ride-detail-logic.js
@@ -0,0 +1,82 @@
+export const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
+
+export const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
+
+export const formatElevation = (elevation) => `${Math.round(elevation)} m`;
+
+export const nearestPointIndex = (points, 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;
+};
+
+export 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";
+};
+
+export const elevationAtDistance = (points, 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);
+};
+
+export const cotacolForClimb = (points, 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(points, pointIndex, segmentStartM);
+ while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 < segmentEndM) pointIndex++;
+ const endElevation = elevationAtDistance(points, pointIndex, segmentEndM);
+ const slope = (endElevation - startElevation) / (segmentEndM - segmentStartM);
+ if (slope > 0) score += ((segmentEndM - segmentStartM) / 1000) * (slope * 100) ** 2;
+ }
+ return score;
+};
+
+export const climbMetrics = (points, bounds) => {
+ 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(points, bounds.startIndex, bounds.endIndex),
+ category: categoryForScore(score),
+ };
+};
diff --git a/web/static/ride-detail-map.js b/web/static/ride-detail-map.js
new file mode 100644
index 0000000..e8e7240
--- /dev/null
+++ b/web/static/ride-detail-map.js
@@ -0,0 +1,106 @@
+import { clamp } from "./ride-detail-logic.js";
+
+export class RideDetailMap {
+ constructor({ leaflet, element, route, points, climbs, colors }) {
+ this.leaflet = leaflet;
+ this.points = points;
+ this.colors = colors;
+ this.map = leaflet.map(element);
+ 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(this.map);
+ const routeLayer = leaflet
+ .geoJSON(route, {
+ style: { color: colors.accent, weight: 4, opacity: 0.9 },
+ })
+ .addTo(this.map);
+ const bounds = routeLayer.getBounds();
+ if (bounds.isValid()) this.map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 });
+ this.climbLayers = climbs.map((climb) => this.createClimbLayer(climb));
+ this.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(this.map);
+ }
+
+ createClimbLayer(climb) {
+ if (!this.isValidBounds(climb)) return null;
+ const coordinates = this.points.slice(climb.startIndex, climb.endIndex + 1).map((point) => [point.latitude, point.longitude]);
+ return this.leaflet
+ .polyline(coordinates, {
+ color: this.colors.climbRoute,
+ weight: 7,
+ opacity: 0.65,
+ lineCap: "round",
+ lineJoin: "round",
+ interactive: false,
+ })
+ .addTo(this.map);
+ }
+
+ isValidBounds(bounds) {
+ return (
+ bounds &&
+ Number.isInteger(bounds.startIndex) &&
+ Number.isInteger(bounds.endIndex) &&
+ bounds.startIndex >= 0 &&
+ bounds.endIndex < this.points.length &&
+ bounds.startIndex < bounds.endIndex
+ );
+ }
+
+ updateClimbLayer(index, bounds, active) {
+ const layer = this.climbLayers[index];
+ if (!layer) return;
+ if (!this.isValidBounds(bounds)) {
+ layer.setLatLngs([]);
+ return;
+ }
+ layer.setLatLngs(this.points.slice(bounds.startIndex, bounds.endIndex + 1).map((point) => [point.latitude, point.longitude]));
+ layer.setStyle({ weight: active ? 9 : 7, opacity: active ? 1 : 0.65 });
+ }
+
+ nearestPointIndex(latitude, longitude) {
+ let nearestIndex = 0;
+ let nearestDistance = Infinity;
+ for (let index = 0; index < this.points.length; index++) {
+ const point = this.points[index];
+ const distance = this.leaflet.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]);
+ if (distance < nearestDistance) {
+ nearestIndex = index;
+ nearestDistance = distance;
+ }
+ }
+ return nearestIndex;
+ }
+
+ showPoint(index) {
+ const point = this.points[clamp(index, 0, this.points.length - 1)];
+ this.routeCursor.setLatLng([point.latitude, point.longitude]);
+ this.routeCursor.setStyle({ opacity: 1, fillOpacity: 0.9 });
+ }
+
+ clearPoint() {
+ this.routeCursor.setStyle({ opacity: 0, fillOpacity: 0 });
+ }
+
+ zoomToClimb(bounds) {
+ if (!this.isValidBounds(bounds)) return;
+ const climbPoints = this.points.slice(bounds.startIndex, bounds.endIndex + 1);
+ const mapBounds = this.leaflet.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude]));
+ if (mapBounds.isValid()) this.map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 });
+ }
+
+ onClick(listener) {
+ this.map.on("click", listener);
+ }
+}
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: '&copy; <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();
diff --git a/web/static_test/ride-detail-logic.test.js b/web/static_test/ride-detail-logic.test.js
new file mode 100644
index 0000000..2d9a695
--- /dev/null
+++ b/web/static_test/ride-detail-logic.test.js
@@ -0,0 +1,45 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ categoryForScore,
+ climbMetrics,
+ cotacolForClimb,
+ elevationAtDistance,
+ formatDistance,
+ formatElevation,
+ nearestPointIndex,
+} from "../static/ride-detail-logic.js";
+
+const points = [
+ { distanceKm: 0, elevationM: 100 },
+ { distanceKm: 0.12, elevationM: 106 },
+ { distanceKm: 0.2, elevationM: 114 },
+ { distanceKm: 0.25, elevationM: 120 },
+];
+
+test("finds the nearest profile point by distance", () => {
+ assert.equal(nearestPointIndex(points, 0.01), 0);
+ assert.equal(nearestPointIndex(points, 0.15), 1);
+ assert.equal(nearestPointIndex(points, 0.24), 3);
+});
+
+test("interpolates elevation between route points", () => {
+ assert.equal(elevationAtDistance(points, 0, 100), 105);
+ assert.equal(elevationAtDistance(points, 2, 250), 120);
+});
+
+test("calculates climb metrics with the Cotacol score", () => {
+ const metrics = climbMetrics(points, { startIndex: 0, endIndex: 3 });
+
+ assert.equal(metrics.distanceKm, 0.25);
+ assert.equal(metrics.elevationGain, 20);
+ assert.equal(metrics.slope, 8);
+ assert.equal(metrics.cotacol, cotacolForClimb(points, 0, 3));
+ assert.equal(metrics.category, categoryForScore(metrics.score));
+});
+
+test("formats profile labels consistently", () => {
+ assert.equal(formatDistance(2.4), "2.4 km");
+ assert.equal(formatDistance(12.4), "12 km");
+ assert.equal(formatElevation(123.6), "124 m");
+});