diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 14:23:32 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 14:23:32 +0200 |
| commit | 53ab332e6d2afe9818914e0026310e4cadcc4202 (patch) | |
| tree | 198d708fa05d4247bdcc0cf2900fdf551d61b7d6 /web/static | |
| parent | 2d18d177f48285eb123e7da8680fc00e7a6fe19c (diff) | |
refactor(web): migrate browser code to TypeScript
Diffstat (limited to 'web/static')
| -rw-r--r-- | web/static/official-climb-profile-logic.js | 45 | ||||
| -rw-r--r-- | web/static/official-climb-profile.js | 166 | ||||
| -rw-r--r-- | web/static/ride-detail-boundaries.js | 84 | ||||
| -rw-r--r-- | web/static/ride-detail-canvas.js | 211 | ||||
| -rw-r--r-- | web/static/ride-detail-logic.js | 82 | ||||
| -rw-r--r-- | web/static/ride-detail-map.js | 106 | ||||
| -rw-r--r-- | web/static/ride-detail.js | 142 | ||||
| -rw-r--r-- | web/static/sync.js | 103 |
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: '© <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(); |