diff options
Diffstat (limited to 'web/static')
| -rw-r--r-- | web/static/official-climb-profile-logic.js | 90 | ||||
| -rw-r--r-- | web/static/official-climb-profile.js | 261 | ||||
| -rw-r--r-- | web/static/ride-detail.js | 137 | ||||
| -rw-r--r-- | web/static/sync.js | 103 |
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: '© <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(); |