summaryrefslogtreecommitdiff
path: root/web/static
diff options
context:
space:
mode:
Diffstat (limited to 'web/static')
-rw-r--r--web/static/official-climb-profile-logic.js90
-rw-r--r--web/static/official-climb-profile.js261
-rw-r--r--web/static/ride-detail.js137
-rw-r--r--web/static/sync.js103
4 files changed, 382 insertions, 209 deletions
diff --git a/web/static/official-climb-profile-logic.js b/web/static/official-climb-profile-logic.js
index c84960b..87dcef8 100644
--- a/web/static/official-climb-profile-logic.js
+++ b/web/static/official-climb-profile-logic.js
@@ -1,47 +1,45 @@
-(() => {
- 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 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);
};
- 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;
- };
- const api = { displayStepForLength, profileBandForSlope, officialProfileSections };
- if (typeof module !== "undefined" && module.exports) module.exports = api;
- else window.OfficialClimbProfileLogic = api;
-})();
+ 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
index 603edfe..3f0a229 100644
--- a/web/static/official-climb-profile.js
+++ b/web/static/official-climb-profile.js
@@ -1,6 +1,24 @@
-(() => {
- const createOfficialClimbProfileController = (points) => {
- const { displayStepForLength, officialProfileSections } = window.OfficialClimbProfileLogic;
+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);
@@ -21,127 +39,128 @@
accent: resolveColor("--color-accent"),
};
colorProbe.remove();
- const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
- const drawOfficialProfile = (profileCanvas) => {
- 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;
+ 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(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.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.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.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;
}
- 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;
- }
+ 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(formatDistance(0), plot.left, rect.height - 16);
- context.textAlign = "right";
- context.fillText(formatDistance(maxDistance - minDistance), plot.right, rect.height - 16);
- };
- const cards = [...document.querySelectorAll("[data-official-climb-card]")];
- for (const card of cards) {
- card.addEventListener("toggle", () => {
- if (!card.open) return;
- for (const other of cards) {
- if (other !== card) other.open = false;
- }
- const profileCanvas = card.querySelector("[data-official-profile]");
- if (profileCanvas) drawOfficialProfile(profileCanvas);
- });
}
- return {
- redrawOpen: () => {
- for (const profileCanvas of document.querySelectorAll("[data-official-profile]")) {
- const card = profileCanvas.closest("[data-official-climb-card]");
- if (card?.open) drawOfficialProfile(profileCanvas);
- }
- },
- };
- };
+ 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);
+ }
- window.createOfficialClimbProfileController = createOfficialClimbProfileController;
-})();
+ 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.js b/web/static/ride-detail.js
index 70878a1..75cb54a 100644
--- a/web/static/ride-detail.js
+++ b/web/static/ride-detail.js
@@ -1,10 +1,15 @@
-(() => {
+import { OfficialClimbProfileController } from "./official-climb-profile.js";
+
+export const mountRideDetail = () => {
const mapElement = document.getElementById("ride-map");
- const routeElement = document.getElementById("ride-route");
- const profileElement = document.getElementById("ride-profile");
- const canvas = document.getElementById("ride-profile-chart");
+ const routeElement = /** @type {HTMLScriptElement | null} */ (document.getElementById("ride-route"));
+ const profileElement = /** @type {HTMLScriptElement | null} */ (document.getElementById("ride-profile"));
+ const canvas = /** @type {HTMLCanvasElement | null} */ (document.getElementById("ride-profile-chart"));
+ /** @type {HTMLElement | null} */
const hoverOutput = document.getElementById("ride-profile-hover");
if (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return;
+ const leaflet = window.L;
+ if (!leaflet) return;
const colorProbe = document.createElement("span");
colorProbe.hidden = true;
document.body.append(colorProbe);
@@ -30,15 +35,17 @@
colorProbe.remove();
const route = JSON.parse(routeElement.textContent);
const profile = JSON.parse(profileElement.textContent);
- const map = L.map(mapElement);
- const tiles = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ const map = leaflet.map(mapElement);
+ const tiles = leaflet.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
});
tiles.addTo(map);
- const routeLayer = L.geoJSON(route, {
- style: { color: colors.accent, weight: 4, opacity: 0.9 },
- }).addTo(map);
+ const routeLayer = leaflet
+ .geoJSON(route, {
+ style: { color: colors.accent, weight: 4, opacity: 0.9 },
+ })
+ .addTo(map);
const bounds = routeLayer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 });
@@ -46,29 +53,52 @@
const points = profile.points || [];
if (points.length === 0) return;
- const climbItems = [...document.querySelectorAll("[data-climb-item]")];
+ const climbItems = [...document.querySelectorAll("[data-climb-item]")].map((item) => /** @type {HTMLElement} */ (item));
const climbItemIndices = climbItems.map((item) => Number.parseInt(item.dataset.climbIndex, 10));
+ /** @type {HTMLButtonElement | null} */
const previousClimbButton = document.querySelector("[data-climb-previous]");
+ /** @type {HTMLButtonElement | null} */
const nextClimbButton = document.querySelector("[data-climb-next]");
+ /** @type {HTMLElement | null} */
const climbPosition = document.querySelector("[data-climb-position]");
+ /** @type {(import("leaflet").Polyline | null)[]} */
const climbLayers = [];
for (const climb of profile.climbs || []) {
- if (!Number.isInteger(climb.startIndex) || !Number.isInteger(climb.endIndex) || climb.startIndex < 0 || climb.endIndex >= points.length || climb.startIndex >= climb.endIndex) {
+ if (
+ !Number.isInteger(climb.startIndex) ||
+ !Number.isInteger(climb.endIndex) ||
+ climb.startIndex < 0 ||
+ climb.endIndex >= points.length ||
+ climb.startIndex >= climb.endIndex
+ ) {
climbLayers.push(null);
continue;
}
const coordinates = points.slice(climb.startIndex, climb.endIndex + 1).map((point) => [point.latitude, point.longitude]);
- climbLayers.push(L.polyline(coordinates, { color: colors.climbRoute, weight: 7, opacity: 0.65, lineCap: "round", lineJoin: "round", interactive: false }).addTo(map));
+ climbLayers.push(
+ leaflet
+ .polyline(coordinates, {
+ color: colors.climbRoute,
+ weight: 7,
+ opacity: 0.65,
+ lineCap: "round",
+ lineJoin: "round",
+ interactive: false,
+ })
+ .addTo(map),
+ );
}
- const routeCursor = L.circleMarker([points[0].latitude, points[0].longitude], {
- color: colors.forest,
- fillColor: colors.accent,
- fillOpacity: 0,
- opacity: 0,
- radius: 7,
- weight: 3,
- interactive: false,
- }).addTo(map);
+ const routeCursor = leaflet
+ .circleMarker([points[0].latitude, points[0].longitude], {
+ color: colors.forest,
+ fillColor: colors.accent,
+ fillOpacity: 0,
+ opacity: 0,
+ radius: 7,
+ weight: 3,
+ interactive: false,
+ })
+ .addTo(map);
const context = canvas.getContext("2d");
if (!context) return;
@@ -96,14 +126,14 @@
const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`;
const formatElevation = (elevation) => `${Math.round(elevation)} m`;
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
- const officialProfileController = window.createOfficialClimbProfileController(points);
+ const officialProfileController = new OfficialClimbProfileController(points);
const xForDistance = (distance) => {
const span = Math.max(maxDistance - minDistance, 1);
- return state.plot.left + (distance - minDistance) / span * (state.plot.right - state.plot.left);
+ return state.plot.left + ((distance - minDistance) / span) * (state.plot.right - state.plot.left);
};
const yForElevation = (elevation) => {
const span = Math.max(maxElevation - minElevation, 1);
- return state.plot.bottom - (elevation - minElevation) / span * (state.plot.bottom - state.plot.top);
+ return state.plot.bottom - ((elevation - minElevation) / span) * (state.plot.bottom - state.plot.top);
};
const nearestPointIndex = (distance) => {
let low = 0;
@@ -122,7 +152,7 @@
let nearestDistance = Infinity;
for (let index = 0; index < points.length; index++) {
const point = points[index];
- const distance = L.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]);
+ const distance = leaflet.latLng(point.latitude, point.longitude).distanceTo([latitude, longitude]);
if (distance < nearestDistance) {
nearestIndex = index;
nearestDistance = distance;
@@ -130,7 +160,7 @@
}
return nearestIndex;
};
- const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")];
+ const boundaryForms = [...document.querySelectorAll("[data-official-climb-form]")].map((form) => /** @type {HTMLFormElement} */ (form));
let activeBoundary = null;
const boundaryLabel = (index) => {
const point = points[index];
@@ -164,20 +194,37 @@
while (pointIndex < endIndex && points[pointIndex + 1].distanceKm * 1000 < segmentEndM) pointIndex++;
const endElevation = elevationAtDistance(pointIndex, segmentEndM);
const slope = (endElevation - startElevation) / (segmentEndM - segmentStartM);
- if (slope > 0) score += (segmentEndM - segmentStartM) / 1000 * (slope * 100) ** 2;
+ if (slope > 0) score += ((segmentEndM - segmentStartM) / 1000) * (slope * 100) ** 2;
}
return score;
};
const climbMetrics = (index) => {
const bounds = state.climbBounds[index];
- if (!bounds || !Number.isInteger(bounds.startIndex) || !Number.isInteger(bounds.endIndex) || bounds.startIndex < 0 || bounds.endIndex >= points.length || bounds.startIndex >= bounds.endIndex) return null;
+ if (
+ !bounds ||
+ !Number.isInteger(bounds.startIndex) ||
+ !Number.isInteger(bounds.endIndex) ||
+ bounds.startIndex < 0 ||
+ bounds.endIndex >= points.length ||
+ bounds.startIndex >= bounds.endIndex
+ )
+ return null;
const start = points[bounds.startIndex];
const end = points[bounds.endIndex];
const distanceKm = end.distanceKm - start.distanceKm;
const elevationGain = end.elevationM - start.elevationM;
const slope = distanceKm > 0 ? elevationGain / (distanceKm * 10) : 0;
- const score = distanceKm > 0 ? Math.abs(elevationGain) * elevationGain / (distanceKm * 1000) * 10 : 0;
- return { start, end, distanceKm, elevationGain, slope, score, cotacol: cotacolForClimb(bounds.startIndex, bounds.endIndex), category: categoryForScore(score) };
+ const score = distanceKm > 0 ? ((Math.abs(elevationGain) * elevationGain) / (distanceKm * 1000)) * 10 : 0;
+ return {
+ start,
+ end,
+ distanceKm,
+ elevationGain,
+ slope,
+ score,
+ cotacol: cotacolForClimb(bounds.startIndex, bounds.endIndex),
+ category: categoryForScore(score),
+ };
};
const clearBoundarySelection = () => {
activeBoundary = null;
@@ -187,6 +234,7 @@
};
const updateBoundaryPreview = (form) => {
const preview = form.querySelector("[data-boundary-preview]");
+ /** @type {HTMLElement} */
const item = form.closest("[data-climb-item]");
const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
const metrics = climbMetrics(climbIndex);
@@ -216,10 +264,11 @@
const chooseBoundary = (index) => {
if (!activeBoundary) return;
const { form, target } = activeBoundary;
+ /** @type {HTMLElement} */
const item = form.closest("[data-climb-item]");
const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
- const input = form.querySelector(`[data-boundary-input="${target}"]`);
- const output = form.querySelector(`[data-boundary-output="${target}"]`);
+ const input = /** @type {HTMLInputElement} */ (form.querySelector(`[data-boundary-input="${target}"]`));
+ const output = /** @type {HTMLOutputElement} */ (form.querySelector(`[data-boundary-output="${target}"]`));
input.value = index;
output.textContent = boundaryLabel(index);
state.climbBounds[climbIndex][`${target}Index`] = index;
@@ -229,14 +278,16 @@
showPoint(index);
};
for (const form of boundaryForms) {
+ /** @type {HTMLElement} */
const item = form.closest("[data-climb-item]");
const climbIndex = Number.parseInt(item.dataset.climbIndex, 10);
state.climbBounds[climbIndex] = {
- startIndex: Number.parseInt(form.querySelector('[data-boundary-input="start"]').value, 10),
- endIndex: Number.parseInt(form.querySelector('[data-boundary-input="end"]').value, 10),
+ startIndex: Number.parseInt(/** @type {HTMLInputElement} */ (form.querySelector('[data-boundary-input="start"]')).value, 10),
+ endIndex: Number.parseInt(/** @type {HTMLInputElement} */ (form.querySelector('[data-boundary-input="end"]')).value, 10),
};
updateBoundaryPreview(form);
- for (const button of form.querySelectorAll("[data-boundary-button]")) {
+ const boundaryButtons = [...form.querySelectorAll("[data-boundary-button]")].map((button) => /** @type {HTMLElement} */ (button));
+ for (const button of boundaryButtons) {
button.addEventListener("click", () => {
clearBoundarySelection();
activeBoundary = { form, target: button.dataset.boundaryButton, source: button.dataset.boundarySource };
@@ -340,7 +391,7 @@
context.textAlign = "center";
context.textBaseline = "top";
for (let step = 0; step <= 4; step++) {
- const distance = minDistance + step / 4 * (maxDistance - minDistance);
+ const distance = minDistance + (step / 4) * (maxDistance - minDistance);
context.fillText(formatDistance(distance), xForDistance(distance), plot.bottom + 10);
}
@@ -381,7 +432,7 @@
const climbBounds = state.climbBounds[index];
if (!climbBounds || !Number.isInteger(climbBounds.startIndex) || !Number.isInteger(climbBounds.endIndex)) return;
const climbPoints = points.slice(climbBounds.startIndex, climbBounds.endIndex + 1);
- const mapBounds = L.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude]));
+ const mapBounds = leaflet.latLngBounds(climbPoints.map((point) => [point.latitude, point.longitude]));
if (mapBounds.isValid()) map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 });
};
const updateClimbFocus = (index, zoom) => {
@@ -415,21 +466,21 @@
clearHover();
return;
}
- const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance);
+ const distance = minDistance + ((x - state.plot.left) / (state.plot.right - state.plot.left)) * (maxDistance - minDistance);
showPoint(nearestPointIndex(distance));
});
canvas.addEventListener("pointerleave", clearHover);
canvas.addEventListener("pointercancel", clearHover);
canvas.addEventListener("click", (event) => {
- if (!activeBoundary || activeBoundary.source !== "profile" || !state.plot) return;
+ if (activeBoundary?.source !== "profile" || !state.plot) return;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
if (x < state.plot.left || x > state.plot.right) return;
- const distance = minDistance + (x - state.plot.left) / (state.plot.right - state.plot.left) * (maxDistance - minDistance);
+ const distance = minDistance + ((x - state.plot.left) / (state.plot.right - state.plot.left)) * (maxDistance - minDistance);
chooseBoundary(nearestPointIndex(distance));
});
map.on("click", (event) => {
- if (!activeBoundary || activeBoundary.source !== "map") return;
+ if (activeBoundary?.source !== "map") return;
chooseBoundary(nearestMapPointIndex(event.latlng.lat, event.latlng.lng));
});
canvas.addEventListener("keydown", (event) => {
@@ -445,4 +496,6 @@
});
if (climbItems.length > 0) updateClimbFocus(0, false);
else draw();
-})();
+};
+
+mountRideDetail();
diff --git a/web/static/sync.js b/web/static/sync.js
new file mode 100644
index 0000000..3cf6cca
--- /dev/null
+++ b/web/static/sync.js
@@ -0,0 +1,103 @@
+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();