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/frontend | |
| parent | 2d18d177f48285eb123e7da8680fc00e7a6fe19c (diff) | |
refactor(web): migrate browser code to TypeScript
Diffstat (limited to 'web/frontend')
| -rw-r--r-- | web/frontend/dom.ts | 4 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile-logic.test.ts | 37 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile-logic.ts | 56 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile.ts | 169 | ||||
| -rw-r--r-- | web/frontend/ride-detail-boundaries.ts | 123 | ||||
| -rw-r--r-- | web/frontend/ride-detail-canvas.ts | 263 | ||||
| -rw-r--r-- | web/frontend/ride-detail-logic.test.ts | 46 | ||||
| -rw-r--r-- | web/frontend/ride-detail-logic.ts | 84 | ||||
| -rw-r--r-- | web/frontend/ride-detail-map.ts | 130 | ||||
| -rw-r--r-- | web/frontend/ride-detail.ts | 142 | ||||
| -rw-r--r-- | web/frontend/sync.ts | 105 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 116 |
12 files changed, 1275 insertions, 0 deletions
diff --git a/web/frontend/dom.ts b/web/frontend/dom.ts new file mode 100644 index 0000000..a3a1765 --- /dev/null +++ b/web/frontend/dom.ts @@ -0,0 +1,4 @@ +export const requireElement = <T extends Element>(element: T | null, description: string): T => { + if (!element) throw new Error(`Missing ${description} element`); + return element; +}; diff --git a/web/frontend/official-climb-profile-logic.test.ts b/web/frontend/official-climb-profile-logic.test.ts new file mode 100644 index 0000000..31391b2 --- /dev/null +++ b/web/frontend/official-climb-profile-logic.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { displayStepForLength, profileBandForSlope, officialProfileSections } from "./official-climb-profile-logic.ts"; + +test("selects a display step that keeps long profiles readable", () => { + assert.equal(displayStepForLength(2_400), 100); + assert.equal(displayStepForLength(4_500), 200); + assert.equal(displayStepForLength(7_000), 500); + assert.equal(displayStepForLength(20_000), 1000); +}); + +test("assigns each slope to its color band", () => { + assert.equal(profileBandForSlope(-0.1), "downhill"); + assert.equal(profileBandForSlope(2.9), "0-3"); + assert.equal(profileBandForSlope(3), "3-6"); + assert.equal(profileBandForSlope(6), "6-9"); + assert.equal(profileBandForSlope(9), "9-12"); + assert.equal(profileBandForSlope(12), "12-plus"); +}); + +test("interpolates profile sections at the selected step", () => { + const points = [ + { distanceKm: 0, elevationM: 100 }, + { distanceKm: 0.12, elevationM: 106 }, + { distanceKm: 0.2, elevationM: 114 }, + { distanceKm: 0.25, elevationM: 120 }, + ]; + const sections = officialProfileSections(points, 0, 3, 100); + + assert.equal(sections.length, 3); + assert.equal(sections[0].startDistanceKm, 0); + assert.equal(sections[0].endDistanceKm, 0.1); + assert.equal(sections[0].startElevation, 100); + assert.equal(sections[0].endElevation, 105); + assert.equal(sections[2].endDistanceKm, 0.25); + assert.equal(sections[2].endElevation, 120); +}); diff --git a/web/frontend/official-climb-profile-logic.ts b/web/frontend/official-climb-profile-logic.ts new file mode 100644 index 0000000..1b2bc83 --- /dev/null +++ b/web/frontend/official-climb-profile-logic.ts @@ -0,0 +1,56 @@ +import type { ProfileBand, ProfilePoint } from "./types.js"; + +export interface OfficialProfileSection { + startDistanceKm: number; + endDistanceKm: number; + startElevation: number; + endElevation: number; + slopePercent: number; + band: ProfileBand; +} + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)); +const profileStepSizesM: number[] = [100, 200, 500, 1000]; +const displayStepForLength = (lengthM: number): number => + profileStepSizesM.find((stepM) => Math.ceil(lengthM / stepM) <= 30) || profileStepSizesM[profileStepSizesM.length - 1]; +const profileBandForSlope = (slopePercent: number): ProfileBand => { + 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: ProfilePoint[], startIndex: number, endIndex: number, stepM: number): OfficialProfileSection[] => { + const startDistanceM = points[startIndex].distanceKm * 1000; + const endDistanceM = points[endIndex].distanceKm * 1000; + const sections: OfficialProfileSection[] = []; + let pointIndex = startIndex; + const elevationAtDistance = (distanceM: number) => { + 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/frontend/official-climb-profile.ts b/web/frontend/official-climb-profile.ts new file mode 100644 index 0000000..82ecc01 --- /dev/null +++ b/web/frontend/official-climb-profile.ts @@ -0,0 +1,169 @@ +import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js"; +import type { OfficialProfileColors, RideProfilePoint } from "./types.js"; + +export class OfficialClimbProfileController { + readonly points: RideProfilePoint[]; + readonly colors: OfficialProfileColors; + readonly cards: HTMLDetailsElement[]; + + constructor(points: RideProfilePoint[]) { + this.points = points; + this.colors = this.resolveColors(); + this.cards = [...document.querySelectorAll("[data-official-climb-card]")].map((card) => card as HTMLDetailsElement); + 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; + } + const profileCanvas = card.querySelector<HTMLCanvasElement>("[data-official-profile]"); + if (profileCanvas) this.drawOfficialProfile(profileCanvas); + }); + } + } + + resolveColors(): OfficialProfileColors { + const colorProbe = document.createElement("span"); + colorProbe.hidden = true; + document.body.append(colorProbe); + const resolveColor = (name: string): string => { + 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: HTMLCanvasElement): void { + 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: number) => plot.left + ((distanceKm - minDistance) / distanceSpan) * (plot.right - plot.left); + const yForElevation = (elevationM: number) => 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: number): string { + return `${distance.toFixed(distance < 10 ? 1 : 0)} km`; + } + + redrawOpen() { + const profileCanvases = document.querySelectorAll<HTMLCanvasElement>("[data-official-profile]"); + for (const profileCanvas of profileCanvases) { + const card = profileCanvas.closest<HTMLDetailsElement>("[data-official-climb-card]"); + if (card?.open) this.drawOfficialProfile(profileCanvas); + } + } +} diff --git a/web/frontend/ride-detail-boundaries.ts b/web/frontend/ride-detail-boundaries.ts new file mode 100644 index 0000000..22a1255 --- /dev/null +++ b/web/frontend/ride-detail-boundaries.ts @@ -0,0 +1,123 @@ +import { climbMetrics, formatDistance, formatElevation } from "./ride-detail-logic.js"; +import { requireElement } from "./dom.js"; +import type { BoundarySource, BoundaryTarget, ClimbBounds, RideProfilePoint } from "./types.js"; + +interface ActiveBoundary { + form: HTMLFormElement; + target: BoundaryTarget; + source: BoundarySource; +} + +interface RideBoundaryControllerOptions { + forms: HTMLFormElement[]; + points: RideProfilePoint[]; + climbBounds: ClimbBounds[]; + onBoundaryChanged: (index: number) => void; + onPointSelected: (index: number) => void; + setMessage: (message: string) => void; +} + +export class RideBoundaryController { + private readonly forms: HTMLFormElement[]; + private readonly points: RideProfilePoint[]; + private readonly climbBounds: ClimbBounds[]; + private readonly onBoundaryChanged: (index: number) => void; + private readonly onPointSelected: (index: number) => void; + private readonly setMessage: (message: string) => void; + private activeBoundary: ActiveBoundary | null; + + constructor({ forms, points, climbBounds, onBoundaryChanged, onPointSelected, setMessage }: RideBoundaryControllerOptions) { + this.forms = forms; + this.points = points; + this.climbBounds = climbBounds; + this.onBoundaryChanged = onBoundaryChanged; + this.onPointSelected = onPointSelected; + this.setMessage = setMessage; + this.activeBoundary = null; + this.initialize(); + } + + initialize(): void { + for (const form of this.forms) { + const item = form.closest<HTMLElement>("[data-climb-item]"); + if (!item) continue; + const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10); + this.climbBounds[climbIndex] = { + startIndex: Number.parseInt( + requireElement(form.querySelector<HTMLInputElement>('[data-boundary-input="start"]'), "start boundary input").value, + 10, + ), + endIndex: Number.parseInt( + requireElement(form.querySelector<HTMLInputElement>('[data-boundary-input="end"]'), "end boundary input").value, + 10, + ), + }; + this.updatePreview(form); + const buttons = [...form.querySelectorAll<HTMLElement>("[data-boundary-button]")]; + for (const button of buttons) { + button.addEventListener("click", () => { + this.clearSelection(); + this.activeBoundary = { + form, + target: button.dataset.boundaryButton as BoundaryTarget, + source: button.dataset.boundarySource as 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: BoundarySource): boolean { + return this.activeBoundary?.source === source; + } + + clearSelection(): void { + this.activeBoundary = null; + for (const form of this.forms) { + for (const button of form.querySelectorAll("[data-boundary-button]")) button.classList.remove("active"); + } + } + + choosePoint(index: number): void { + if (!this.activeBoundary) return; + const { form, target } = this.activeBoundary; + const item = form.closest<HTMLElement>("[data-climb-item]"); + if (!item) return; + const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10); + const input = requireElement(form.querySelector<HTMLInputElement>(`[data-boundary-input="${target}"]`), `${target} boundary input`); + const output = requireElement(form.querySelector<HTMLOutputElement>(`[data-boundary-output="${target}"]`), `${target} boundary output`); + input.value = String(index); + output.textContent = this.boundaryLabel(index); + if (target === "start") this.climbBounds[climbIndex].startIndex = index; + else this.climbBounds[climbIndex].endIndex = index; + this.updatePreview(form); + this.onBoundaryChanged(climbIndex); + this.clearSelection(); + this.onPointSelected(index); + } + + updatePreview(form: HTMLFormElement): void { + const preview = requireElement(form.querySelector<HTMLElement>("[data-boundary-preview]"), "boundary preview"); + const item = form.closest<HTMLElement>("[data-climb-item]"); + if (!item) return; + 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 = requireElement(item.querySelector<HTMLElement>("[data-climb-summary]"), "climb summary"); + const metricsOutput = requireElement(item.querySelector<HTMLElement>("[data-climb-metrics]"), "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: number): string { + const point = this.points[index]; + return `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`; + } +} diff --git a/web/frontend/ride-detail-canvas.ts b/web/frontend/ride-detail-canvas.ts new file mode 100644 index 0000000..1453013 --- /dev/null +++ b/web/frontend/ride-detail-canvas.ts @@ -0,0 +1,263 @@ +import { clamp, climbMetrics, formatDistance, formatElevation, nearestPointIndex } from "./ride-detail-logic.js"; +import type { BoundarySource, ClimbBounds, RideDetailColors, RideProfile, RideProfilePoint } from "./types.js"; + +interface Plot { + left: number; + right: number; + top: number; + bottom: number; +} + +interface RideProfileCanvasOptions { + canvas: HTMLCanvasElement; + points: RideProfilePoint[]; + profile: RideProfile; + colors: RideDetailColors; + getClimbBounds: () => ClimbBounds[]; + canSelectPoint: (source: BoundarySource) => boolean; + onPointSelected: (index: number) => void; + onPointHover: (index: number) => void; +} + +export class RideProfileCanvas { + private readonly canvas: HTMLCanvasElement; + private readonly points: RideProfilePoint[]; + private readonly profile: RideProfile; + private readonly colors: RideDetailColors; + private readonly getClimbBounds: () => ClimbBounds[]; + private readonly canSelectPoint: (source: BoundarySource) => boolean; + private readonly onPointSelected: (index: number) => void; + private readonly onPointHover: (index: number) => void; + private readonly context: CanvasRenderingContext2D; + private hoveredIndex: number; + private focusedClimbIndex: number; + private plot: Plot | null; + private readonly minDistance: number; + private readonly maxDistance: number; + private readonly minElevation: number; + private readonly maxElevation: number; + + constructor({ + canvas, + points, + profile, + colors, + getClimbBounds, + canSelectPoint, + onPointSelected, + onPointHover, + }: RideProfileCanvasOptions) { + this.canvas = canvas; + this.points = points; + this.profile = profile; + this.colors = colors; + this.getClimbBounds = getClimbBounds; + this.canSelectPoint = canSelectPoint; + this.onPointSelected = onPointSelected; + this.onPointHover = onPointHover; + const context = canvas.getContext("2d"); + if (!context) throw new Error("Ride profile canvas is unavailable"); + this.context = context; + 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: number): void { + this.focusedClimbIndex = index; + this.draw(); + } + + redraw(): void { + this.draw(); + } + + xForDistance(distance: number): number { + const plot = this.plot; + if (!plot) throw new Error("Ride profile canvas has not been drawn"); + const span = Math.max(this.maxDistance - this.minDistance, 1); + return plot.left + ((distance - this.minDistance) / span) * (plot.right - plot.left); + } + + yForElevation(elevation: number): number { + const plot = this.plot; + if (!plot) throw new Error("Ride profile canvas has not been drawn"); + const span = Math.max(this.maxElevation - this.minElevation, 1); + return plot.bottom - ((elevation - this.minElevation) / span) * (plot.bottom - plot.top); + } + + draw(): void { + 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); + const plot: Plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 }; + this.plot = 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(): void { + this.hoveredIndex = -1; + this.onPointHover(-1); + this.draw(); + } + + showPoint(index: number): void { + this.hoveredIndex = clamp(index, 0, this.points.length - 1); + this.onPointHover(this.hoveredIndex); + this.draw(); + } + + pointIndexAtX(x: number): number { + const plot = this.plot; + if (!plot) throw new Error("Ride profile canvas has not been drawn"); + const distance = this.minDistance + ((x - plot.left) / (plot.right - plot.left)) * (this.maxDistance - this.minDistance); + return nearestPointIndex(this.points, distance); + } + + bindEvents(): void { + this.canvas.addEventListener("pointermove", (event: PointerEvent) => { + 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: MouseEvent) => { + 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: KeyboardEvent) => { + 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/frontend/ride-detail-logic.test.ts b/web/frontend/ride-detail-logic.test.ts new file mode 100644 index 0000000..024f018 --- /dev/null +++ b/web/frontend/ride-detail-logic.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + categoryForScore, + climbMetrics, + cotacolForClimb, + elevationAtDistance, + formatDistance, + formatElevation, + nearestPointIndex, +} from "./ride-detail-logic.ts"; + +const points = [ + { distanceKm: 0, elevationM: 100 }, + { distanceKm: 0.12, elevationM: 106 }, + { distanceKm: 0.2, elevationM: 114 }, + { distanceKm: 0.25, elevationM: 120 }, +]; + +test("finds the nearest profile point by distance", () => { + assert.equal(nearestPointIndex(points, 0.01), 0); + assert.equal(nearestPointIndex(points, 0.15), 1); + assert.equal(nearestPointIndex(points, 0.24), 3); +}); + +test("interpolates elevation between route points", () => { + assert.equal(elevationAtDistance(points, 0, 100), 105); + assert.equal(elevationAtDistance(points, 2, 250), 120); +}); + +test("calculates climb metrics with the Cotacol score", () => { + const metrics = climbMetrics(points, { startIndex: 0, endIndex: 3 }); + assert.ok(metrics); + + assert.equal(metrics.distanceKm, 0.25); + assert.equal(metrics.elevationGain, 20); + assert.equal(metrics.slope, 8); + assert.equal(metrics.cotacol, cotacolForClimb(points, 0, 3)); + assert.equal(metrics.category, categoryForScore(metrics.score)); +}); + +test("formats profile labels consistently", () => { + assert.equal(formatDistance(2.4), "2.4 km"); + assert.equal(formatDistance(12.4), "12 km"); + assert.equal(formatElevation(123.6), "124 m"); +}); diff --git a/web/frontend/ride-detail-logic.ts b/web/frontend/ride-detail-logic.ts new file mode 100644 index 0000000..825bb2c --- /dev/null +++ b/web/frontend/ride-detail-logic.ts @@ -0,0 +1,84 @@ +import type { ClimbBounds, ClimbMetrics, ProfilePoint } from "./types.js"; + +export const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); + +export const formatDistance = (distance: number): string => `${distance.toFixed(distance < 10 ? 1 : 0)} km`; + +export const formatElevation = (elevation: number): string => `${Math.round(elevation)} m`; + +export const nearestPointIndex = (points: ProfilePoint[], distance: number): number => { + 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: number): string => { + 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: ProfilePoint[], index: number, distanceM: number): number => { + 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: ProfilePoint[], startIndex: number, endIndex: number): number => { + 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: ProfilePoint[], bounds: ClimbBounds | undefined): ClimbMetrics | 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(points, bounds.startIndex, bounds.endIndex), + category: categoryForScore(score), + }; +}; diff --git a/web/frontend/ride-detail-map.ts b/web/frontend/ride-detail-map.ts new file mode 100644 index 0000000..bd19412 --- /dev/null +++ b/web/frontend/ride-detail-map.ts @@ -0,0 +1,130 @@ +import { clamp } from "./ride-detail-logic.js"; +import type { ClimbBounds, RideDetailColors, RideProfilePoint, RideRoute } from "./types.js"; +import type { CircleMarker, LeafletMouseEvent, Map as LeafletMap, Polyline } from "leaflet"; + +type LeafletApi = typeof import("leaflet"); + +interface RideDetailMapOptions { + leaflet: LeafletApi; + element: HTMLElement; + route: RideRoute; + points: RideProfilePoint[]; + climbs: ClimbBounds[]; + colors: RideDetailColors; +} + +export class RideDetailMap { + private readonly leaflet: LeafletApi; + private readonly points: RideProfilePoint[]; + private readonly colors: RideDetailColors; + private readonly map: LeafletMap; + private readonly climbLayers: (Polyline | null)[]; + private readonly routeCursor: CircleMarker; + + constructor({ leaflet, element, route, points, climbs, colors }: RideDetailMapOptions) { + 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: ClimbBounds): Polyline | null { + if (!this.isValidBounds(climb)) return null; + const coordinates: [number, number][] = 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: ClimbBounds | undefined): boolean { + return Boolean( + bounds && + Number.isInteger(bounds.startIndex) && + Number.isInteger(bounds.endIndex) && + bounds.startIndex >= 0 && + bounds.endIndex < this.points.length && + bounds.startIndex < bounds.endIndex, + ); + } + + updateClimbLayer(index: number, bounds: ClimbBounds | undefined, active: boolean): void { + const layer = this.climbLayers[index]; + if (!layer) return; + if (!bounds || !this.isValidBounds(bounds)) { + layer.setLatLngs([]); + return; + } + layer.setLatLngs( + this.points.slice(bounds.startIndex, bounds.endIndex + 1).map((point) => [point.latitude, point.longitude] as [number, number]), + ); + layer.setStyle({ weight: active ? 9 : 7, opacity: active ? 1 : 0.65 }); + } + + nearestPointIndex(latitude: number, longitude: number): number { + 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: number): void { + 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: ClimbBounds | undefined): void { + if (!bounds || !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] as [number, number])); + if (mapBounds.isValid()) this.map.fitBounds(mapBounds, { padding: [32, 32], maxZoom: 15 }); + } + + onClick(listener: (event: LeafletMouseEvent) => void): void { + this.map.on("click", listener); + } +} diff --git a/web/frontend/ride-detail.ts b/web/frontend/ride-detail.ts new file mode 100644 index 0000000..fb75c19 --- /dev/null +++ b/web/frontend/ride-detail.ts @@ -0,0 +1,142 @@ +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"; +import type { ClimbBounds, RideDetailColors, RideProfile, RideProfilePoint, RideRoute } from "./types.js"; + +export const mountRideDetail = (): void => { + 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 hoverOutput = document.getElementById("ride-profile-hover"); + if (!mapElement || !routeElement || !profileElement || !canvas || !hoverOutput) return; + const routeScript = routeElement as HTMLScriptElement; + const profileScript = profileElement as HTMLScriptElement; + const profileCanvasElement = canvas as HTMLCanvasElement; + const leaflet = window.L; + if (!leaflet) return; + + const colorProbe = document.createElement("span"); + colorProbe.hidden = true; + document.body.append(colorProbe); + const resolveColor = (name: string): string => { + colorProbe.style.color = `var(${name})`; + return getComputedStyle(colorProbe).color; + }; + const colors: RideDetailColors = { + 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(routeScript.textContent ?? "") as RideRoute; + const profile = JSON.parse(profileScript.textContent ?? "") as RideProfile; + const points: RideProfilePoint[] = profile.points; + if (points.length === 0) return; + + const climbItems = [...document.querySelectorAll<HTMLElement>("[data-climb-item]")]; + const climbItemIndices = climbItems.map((item) => Number.parseInt(item.dataset.climbIndex ?? "", 10)); + const previousClimbButton = document.querySelector<HTMLButtonElement>("[data-climb-previous]"); + const nextClimbButton = document.querySelector<HTMLButtonElement>("[data-climb-next]"); + const climbPosition = document.querySelector<HTMLElement>("[data-climb-position]"); + const climbBounds: 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: RideBoundaryController | undefined; + const profileCanvas = new RideProfileCanvas({ + canvas: profileCanvasElement, + 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: number): void => { + mapController.updateClimbLayer(index, climbBounds[index], index === state.focusedClimbIndex); + }; + boundaryController = new RideBoundaryController({ + forms: [...document.querySelectorAll<HTMLFormElement>("[data-official-climb-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: number, zoom: boolean): void => { + 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/frontend/sync.ts b/web/frontend/sync.ts new file mode 100644 index 0000000..000dcfc --- /dev/null +++ b/web/frontend/sync.ts @@ -0,0 +1,105 @@ +import type { SyncErrorEvent, SyncProgress } from "./types.js"; +import { requireElement } from "./dom.js"; + +const isSyncProgress = (data: SyncProgress | SyncErrorEvent): data is SyncProgress => "total" in data; + +export const mountSync = (): void => { + const form = document.getElementById("sync-form") as HTMLFormElement | null; + if (!form) return; + const button = requireElement(form.querySelector<HTMLButtonElement>("button[type=submit]"), "sync submit button"); + const progressPanel = requireElement(document.getElementById("sync-progress"), "sync progress"); + const progressBar = requireElement(document.getElementById("sync-progress-bar") as HTMLProgressElement | null, "sync progress bar"); + const progressCount = requireElement(document.getElementById("sync-progress-count"), "sync progress count"); + const progressDetails = requireElement(document.getElementById("sync-progress-details"), "sync progress details"); + const notice = requireElement(document.getElementById("sync-notice"), "sync notice"); + const error = requireElement(document.getElementById("sync-error"), "sync error"); + + const showError = (message: string): void => { + error.textContent = message; + error.hidden = false; + }; + const updateProgress = (progress: SyncProgress): void => { + 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: string, data: SyncProgress | SyncErrorEvent): boolean => { + if (eventName === "progress") { + if (!isSyncProgress(data)) throw new Error("Invalid Strava sync progress event"); + updateProgress(data); + return false; + } + if (eventName === "complete") { + if (!isSyncProgress(data)) throw new Error("Invalid Strava sync completion event"); + updateProgress(data); + notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`; + notice.hidden = false; + return true; + } + if (eventName === "error") { + if ("progress" in data && data.progress) updateProgress(data.progress); + throw new Error("message" in data ? data.message || "Strava sync failed" : "Strava sync failed"); + } + return false; + }; + const consumeEvents = async (response: Response): Promise<void> => { + if (!response.body) throw new Error("The browser does not support streaming responses"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let completed = false; + const consumeBlock = (block: string): void => { + 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) as SyncProgress | SyncErrorEvent) || 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: SubmitEvent) => { + 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(); diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts new file mode 100644 index 0000000..66f1ad2 --- /dev/null +++ b/web/frontend/types.d.ts @@ -0,0 +1,116 @@ +export type ProfileBand = "downhill" | "0-3" | "3-6" | "6-9" | "9-12" | "12-plus"; +export type BoundaryTarget = "start" | "end"; +export type BoundarySource = "profile" | "map"; + +export interface ProfilePoint { + distanceKm: number; + elevationM: number; +} + +export interface RideProfilePoint extends ProfilePoint { + latitude: number; + longitude: number; +} + +export interface RideProfileClimb { + startKm: number; + endKm: number; + topKm: number; + topElevationM: number; + name: string; + score: number; + category: string; + distanceKm: number; + slopePercent: number; + cotacol: number; + officialClimbId?: number; + officialName?: string; + startIndex: number; + endIndex: number; +} + +export interface RideProfileCrossing { + distanceKm: number; + passElevationM: number; + rideElevationM: number; + distanceToM: number; + elevationDiffM: number; + name: string; +} + +export interface RideProfile { + points: RideProfilePoint[]; + climbs: RideProfileClimb[]; + crossings: RideProfileCrossing[]; +} + +export interface RideRoute { + type: "FeatureCollection"; + features: RideRouteFeature[]; +} + +export interface RideRouteFeature { + type: "Feature"; + geometry: { + type: "LineString"; + coordinates: [number, number][]; + }; + properties?: Record<string, unknown>; +} + +export interface ClimbBounds { + startIndex: number; + endIndex: number; +} + +export interface ClimbMetrics { + start: ProfilePoint; + end: ProfilePoint; + distanceKm: number; + elevationGain: number; + slope: number; + score: number; + cotacol: number; + category: string; +} + +export interface OfficialProfileColors { + downhill: string; + "0-3": string; + "3-6": string; + "6-9": string; + "9-12": string; + "12-plus": string; + plotSurface: string; + grid: string; + subtle: string; + accent: string; +} + +export interface RideDetailColors { + accent: string; + subtle: string; + plotSurface: string; + grid: string; + forest: string; + plotSurfaceOverlay: string; + accentFill: string; + climbLabel: string; + crossing: string; + crossingLabel: string; + hoverLine: string; + climbRoute: string; + climbFocusFill: string; +} + +export interface SyncProgress { + total: number; + completed: number; + imported: number; + skipped: number; +} + +export interface SyncErrorEvent { + message: string; + progress?: SyncProgress; +} |