summaryrefslogtreecommitdiff
path: root/web/static
diff options
context:
space:
mode:
Diffstat (limited to 'web/static')
-rw-r--r--web/static/official-climb-profile-logic.js45
-rw-r--r--web/static/official-climb-profile.js166
-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.js142
-rw-r--r--web/static/sync.js103
8 files changed, 0 insertions, 939 deletions
diff --git a/web/static/official-climb-profile-logic.js b/web/static/official-climb-profile-logic.js
deleted file mode 100644
index 87dcef8..0000000
--- a/web/static/official-climb-profile-logic.js
+++ /dev/null
@@ -1,45 +0,0 @@
-const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
-const profileStepSizesM = [100, 200, 500, 1000];
-const displayStepForLength = (lengthM) =>
- profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1];
-const profileBandForSlope = (slopePercent) => {
- if (slopePercent < 0) return "downhill";
- if (slopePercent < 3) return "0-3";
- if (slopePercent < 6) return "3-6";
- if (slopePercent < 9) return "6-9";
- if (slopePercent < 12) return "9-12";
- return "12-plus";
-};
-const officialProfileSections = (points, startIndex, endIndex, stepM) => {
- const startDistanceM = points[startIndex].distanceKm * 1000;
- const endDistanceM = points[endIndex].distanceKm * 1000;
- const sections = [];
- let pointIndex = startIndex;
- const elevationAtDistance = (distanceM) => {
- while (pointIndex < endIndex - 1 && points[pointIndex + 1].distanceKm * 1000 < distanceM) pointIndex++;
- const nextIndex = Math.min(pointIndex + 1, endIndex);
- const first = points[pointIndex];
- const next = points[nextIndex];
- const distanceSpan = next.distanceKm * 1000 - first.distanceKm * 1000;
- if (distanceSpan <= 0) return first.elevationM;
- const fraction = (distanceM - first.distanceKm * 1000) / distanceSpan;
- return first.elevationM + clamp(fraction, 0, 1) * (next.elevationM - first.elevationM);
- };
- for (let sectionStartM = startDistanceM; sectionStartM < endDistanceM; sectionStartM += stepM) {
- const sectionEndM = Math.min(sectionStartM + stepM, endDistanceM);
- const startElevation = elevationAtDistance(sectionStartM);
- const endElevation = elevationAtDistance(sectionEndM);
- const slopePercent = ((endElevation - startElevation) / (sectionEndM - sectionStartM)) * 100;
- sections.push({
- startDistanceKm: sectionStartM / 1000,
- endDistanceKm: sectionEndM / 1000,
- startElevation,
- endElevation,
- slopePercent,
- band: profileBandForSlope(slopePercent),
- });
- }
- return sections;
-};
-
-export { displayStepForLength, profileBandForSlope, officialProfileSections };
diff --git a/web/static/official-climb-profile.js b/web/static/official-climb-profile.js
deleted file mode 100644
index 3f0a229..0000000
--- a/web/static/official-climb-profile.js
+++ /dev/null
@@ -1,166 +0,0 @@
-import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js";
-
-export class OfficialClimbProfileController {
- constructor(points) {
- this.points = points;
- this.colors = this.resolveColors();
- this.cards = [...document.querySelectorAll("[data-official-climb-card]")].map((card) => /** @type {HTMLDetailsElement} */ (card));
- for (const card of this.cards) {
- card.addEventListener("toggle", () => {
- if (!card.open) return;
- for (const other of this.cards) {
- if (other !== card) other.open = false;
- }
- /** @type {HTMLCanvasElement | null} */
- const profileCanvas = card.querySelector("[data-official-profile]");
- if (profileCanvas) this.drawOfficialProfile(profileCanvas);
- });
- }
- }
-
- resolveColors() {
- const colorProbe = document.createElement("span");
- colorProbe.hidden = true;
- document.body.append(colorProbe);
- const resolveColor = (name) => {
- colorProbe.style.color = `var(${name})`;
- return getComputedStyle(colorProbe).color;
- };
- const colors = {
- downhill: resolveColor("--color-profile-downhill"),
- "0-3": resolveColor("--color-profile-0-3"),
- "3-6": resolveColor("--color-profile-3-6"),
- "6-9": resolveColor("--color-profile-6-9"),
- "9-12": resolveColor("--color-profile-9-12"),
- "12-plus": resolveColor("--color-profile-12-plus"),
- plotSurface: resolveColor("--color-plot-surface"),
- grid: resolveColor("--color-plot-grid"),
- subtle: resolveColor("--color-subtle"),
- accent: resolveColor("--color-accent"),
- };
- colorProbe.remove();
- return colors;
- }
-
- drawOfficialProfile(profileCanvas) {
- const { points, colors } = this;
- const startIndex = Number.parseInt(profileCanvas.dataset.profileStart, 10);
- const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd, 10);
- if (
- !Number.isInteger(startIndex) ||
- !Number.isInteger(endIndex) ||
- startIndex < 0 ||
- endIndex >= points.length ||
- startIndex >= endIndex
- )
- return;
- const rect = profileCanvas.getBoundingClientRect();
- if (rect.width === 0 || rect.height === 0) return;
- const context = profileCanvas.getContext("2d");
- if (!context) return;
- const ratio = window.devicePixelRatio || 1;
- profileCanvas.width = Math.floor(rect.width * ratio);
- profileCanvas.height = Math.floor(rect.height * ratio);
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
- const plot = { left: 10, right: rect.width - 10, top: 16, bottom: rect.height - 24 };
- const climbLengthM = (points[endIndex].distanceKm - points[startIndex].distanceKm) * 1000;
- const sections = officialProfileSections(points, startIndex, endIndex, displayStepForLength(climbLengthM));
- if (sections.length === 0) return;
- let minElevation = sections[0].startElevation;
- let maxElevation = minElevation;
- for (const section of sections) {
- minElevation = Math.min(minElevation, section.startElevation, section.endElevation);
- maxElevation = Math.max(maxElevation, section.startElevation, section.endElevation);
- }
- const elevationPadding = Math.max((maxElevation - minElevation) * 0.12, 8);
- minElevation -= elevationPadding;
- maxElevation += elevationPadding;
- const minDistance = sections[0].startDistanceKm;
- const maxDistance = sections[sections.length - 1].endDistanceKm;
- const distanceSpan = Math.max(maxDistance - minDistance, 0.1);
- const elevationSpan = Math.max(maxElevation - minElevation, 1);
- const xForDistance = (distanceKm) => plot.left + ((distanceKm - minDistance) / distanceSpan) * (plot.right - plot.left);
- const yForElevation = (elevationM) => plot.bottom - ((elevationM - minElevation) / elevationSpan) * (plot.bottom - plot.top);
- context.clearRect(0, 0, rect.width, rect.height);
- context.fillStyle = colors.plotSurface;
- context.fillRect(0, 0, rect.width, rect.height);
- context.strokeStyle = colors.grid;
- context.lineWidth = 1;
- context.beginPath();
- context.moveTo(plot.left, plot.bottom);
- context.lineTo(plot.right, plot.bottom);
- context.stroke();
- for (const section of sections) {
- const startX = xForDistance(section.startDistanceKm);
- const endX = xForDistance(section.endDistanceKm);
- context.beginPath();
- context.moveTo(startX, plot.bottom);
- context.lineTo(startX, yForElevation(section.startElevation));
- context.lineTo(endX, yForElevation(section.endElevation));
- context.lineTo(endX, plot.bottom);
- context.closePath();
- context.globalAlpha = 0.25;
- context.fillStyle = colors[section.band];
- context.fill();
- context.globalAlpha = 1;
- context.beginPath();
- context.moveTo(startX, yForElevation(section.startElevation));
- context.lineTo(endX, yForElevation(section.endElevation));
- context.strokeStyle = colors[section.band];
- context.lineWidth = 2.5;
- context.stroke();
- }
- context.font = "9px system-ui, sans-serif";
- context.fillStyle = colors.subtle;
- context.textBaseline = "bottom";
- context.textAlign = "center";
- context.save();
- context.beginPath();
- context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top);
- context.clip();
- for (const section of sections) {
- const startX = xForDistance(section.startDistanceKm);
- const endX = xForDistance(section.endDistanceKm);
- const label = `${section.slopePercent.toFixed(1)}%`;
- const labelY = Math.max(plot.top + 10, Math.min(yForElevation(section.startElevation), yForElevation(section.endElevation)) - 4);
- context.fillText(label, (startX + endX) / 2, labelY);
- }
- context.restore();
- let topElevation = sections[0].startElevation;
- let topDistance = sections[0].startDistanceKm;
- for (const section of sections) {
- if (section.startElevation > topElevation) {
- topElevation = section.startElevation;
- topDistance = section.startDistanceKm;
- }
- if (section.endElevation > topElevation) {
- topElevation = section.endElevation;
- topDistance = section.endDistanceKm;
- }
- }
- context.fillStyle = colors.accent;
- context.beginPath();
- context.arc(xForDistance(topDistance), yForElevation(topElevation), 3.5, 0, 2 * Math.PI);
- context.fill();
- context.font = "11px system-ui, sans-serif";
- context.fillStyle = colors.subtle;
- context.textBaseline = "top";
- context.textAlign = "left";
- context.fillText(this.formatDistance(0), plot.left, rect.height - 16);
- context.textAlign = "right";
- context.fillText(this.formatDistance(maxDistance - minDistance), plot.right, rect.height - 16);
- }
-
- formatDistance(distance) {
- return `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
- }
-
- redrawOpen() {
- /** @type {NodeListOf<HTMLCanvasElement>} */
- const profileCanvases = document.querySelectorAll("[data-official-profile]");
- for (const profileCanvas of profileCanvases) {
- const card = /** @type {HTMLDetailsElement | null} */ (profileCanvas.closest("[data-official-climb-card]"));
- if (card?.open) this.drawOfficialProfile(profileCanvas);
- }
- }
-}
diff --git a/web/static/ride-detail-boundaries.js b/web/static/ride-detail-boundaries.js
deleted file mode 100644
index 98e0990..0000000
--- a/web/static/ride-detail-boundaries.js
+++ /dev/null
@@ -1,84 +0,0 @@
-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
deleted file mode 100644
index 99b09c1..0000000
--- a/web/static/ride-detail-canvas.js
+++ /dev/null
@@ -1,211 +0,0 @@
-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
deleted file mode 100644
index 4ab408b..0000000
--- a/web/static/ride-detail-logic.js
+++ /dev/null
@@ -1,82 +0,0 @@
-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
deleted file mode 100644
index e8e7240..0000000
--- a/web/static/ride-detail-map.js
+++ /dev/null
@@ -1,106 +0,0 @@
-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
deleted file mode 100644
index 67c87ae..0000000
--- a/web/static/ride-detail.js
+++ /dev/null
@@ -1,142 +0,0 @@
-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");
- 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);
- const resolveColor = (name) => {
- colorProbe.style.color = `var(${name})`;
- return getComputedStyle(colorProbe).color;
- };
- const colors = {
- accent: resolveColor("--color-accent"),
- forest: resolveColor("--color-forest"),
- subtle: resolveColor("--color-subtle"),
- plotSurface: resolveColor("--color-plot-surface"),
- plotSurfaceOverlay: resolveColor("--color-plot-surface-overlay"),
- grid: resolveColor("--color-plot-grid"),
- accentFill: resolveColor("--color-accent-fill"),
- climbLabel: resolveColor("--color-climb-label"),
- 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);
- const profile = JSON.parse(profileElement.textContent);
- 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} */
- 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]");
- const climbBounds = (profile.climbs || []).map((climb) => ({ startIndex: climb.startIndex, endIndex: climb.endIndex }));
- const state = {
- focusedClimbItemIndex: 0,
- focusedClimbIndex: climbItemIndices[0] ?? 0,
- };
-
- const mapController = new RideDetailMap({
- leaflet,
- element: mapElement,
- route,
- points,
- climbs: climbBounds,
- colors,
- });
- const officialProfileController = new OfficialClimbProfileController(points);
- 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;
- }
- const point = points[index];
- mapController.showPoint(index);
- hoverOutput.textContent = `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`;
- },
- });
-
- 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;
- },
- });
-
- mapController.onClick((event) => {
- if (!boundaryController.isSelecting("map")) return;
- boundaryController.choosePoint(mapController.nearestPointIndex(event.latlng.lat, event.latlng.lng));
- });
-
- const updateClimbFocus = (index, zoom) => {
- if (climbItems.length === 0) return;
- 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);
- 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;
- 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));
- window.addEventListener("resize", () => {
- profileCanvas.redraw();
- officialProfileController.redrawOpen();
- });
- if (climbItems.length > 0) updateClimbFocus(0, false);
- else profileCanvas.redraw();
-};
-
-mountRideDetail();
diff --git a/web/static/sync.js b/web/static/sync.js
deleted file mode 100644
index 3cf6cca..0000000
--- a/web/static/sync.js
+++ /dev/null
@@ -1,103 +0,0 @@
-export const mountSync = () => {
- const form = /** @type {HTMLFormElement | null} */ (document.getElementById("sync-form"));
- if (!form) return;
- /** @type {HTMLButtonElement} */
- const button = form.querySelector("button[type=submit]");
- /** @type {HTMLElement} */
- const progressPanel = document.getElementById("sync-progress");
- const progressBar = /** @type {HTMLProgressElement} */ (document.getElementById("sync-progress-bar"));
- /** @type {HTMLElement} */
- const progressCount = document.getElementById("sync-progress-count");
- /** @type {HTMLElement} */
- const progressDetails = document.getElementById("sync-progress-details");
- /** @type {HTMLElement} */
- const notice = document.getElementById("sync-notice");
- /** @type {HTMLElement} */
- const error = document.getElementById("sync-error");
-
- const showError = (message) => {
- error.textContent = message;
- error.hidden = false;
- };
- const updateProgress = (progress) => {
- progressPanel.hidden = false;
- progressBar.max = Math.max(progress.total, 1);
- progressBar.value = progress.completed;
- progressCount.textContent = `${progress.completed} / ${progress.total}`;
- progressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;
- };
- const handleEvent = (eventName, data) => {
- if (eventName === "progress") {
- updateProgress(data);
- return false;
- }
- if (eventName === "complete") {
- updateProgress(data);
- notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`;
- notice.hidden = false;
- return true;
- }
- if (eventName === "error") {
- if (data.progress) updateProgress(data.progress);
- throw new Error(data.message || "Strava sync failed");
- }
- return false;
- };
- const consumeEvents = async (response) => {
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- let completed = false;
- const consumeBlock = (block) => {
- let eventName = "message";
- let data = "";
- for (const line of block.split(/\r?\n/)) {
- if (line.startsWith("event: ")) eventName = line.slice(7);
- if (line.startsWith("data: ")) data += line.slice(6);
- }
- if (data) completed = handleEvent(eventName, JSON.parse(data)) || completed;
- };
- while (true) {
- const { value, done } = await reader.read();
- buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
- const blocks = buffer.split(/\r?\n\r?\n/);
- buffer = blocks.pop();
- for (const block of blocks) consumeBlock(block);
- if (done) break;
- }
- if (buffer.trim()) consumeBlock(buffer);
- if (!completed) throw new Error("Strava sync ended before completion");
- };
-
- form.addEventListener("submit", async (event) => {
- event.preventDefault();
- button.disabled = true;
- button.textContent = "Syncing...";
- notice.hidden = true;
- error.hidden = true;
- progressPanel.hidden = false;
- progressCount.textContent = "0 / 0";
- progressDetails.textContent = "Preparing import...";
- try {
- const response = await fetch(form.action, {
- method: "POST",
- body: new FormData(form),
- headers: { Accept: "text/event-stream" },
- });
- if (response.redirected) {
- window.location.assign(response.url);
- return;
- }
- if (!response.ok) throw new Error((await response.text()) || `Sync failed (HTTP ${response.status})`);
- if (!response.body) throw new Error("The browser does not support streaming responses");
- await consumeEvents(response);
- } catch (caught) {
- showError(caught instanceof Error ? caught.message : "Strava sync failed");
- } finally {
- button.disabled = false;
- button.textContent = "Sync rides";
- }
- });
-};
-
-mountSync();