From 53ab332e6d2afe9818914e0026310e4cadcc4202 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 16 Aug 2026 14:23:32 +0200 Subject: refactor(web): migrate browser code to TypeScript --- .gitignore | 1 + .mise.toml | 18 +- package.json | 11 +- tsconfig.build.json | 13 + tsconfig.json | 7 +- web/frontend/dom.ts | 4 + web/frontend/official-climb-profile-logic.test.ts | 37 +++ web/frontend/official-climb-profile-logic.ts | 56 +++++ web/frontend/official-climb-profile.ts | 169 +++++++++++++ web/frontend/ride-detail-boundaries.ts | 123 ++++++++++ web/frontend/ride-detail-canvas.ts | 263 +++++++++++++++++++++ web/frontend/ride-detail-logic.test.ts | 46 ++++ web/frontend/ride-detail-logic.ts | 84 +++++++ web/frontend/ride-detail-map.ts | 130 ++++++++++ web/frontend/ride-detail.ts | 142 +++++++++++ web/frontend/sync.ts | 105 ++++++++ web/frontend/types.d.ts | 116 +++++++++ web/static/official-climb-profile-logic.js | 45 ---- web/static/official-climb-profile.js | 166 ------------- web/static/ride-detail-boundaries.js | 84 ------- web/static/ride-detail-canvas.js | 211 ----------------- web/static/ride-detail-logic.js | 82 ------- web/static/ride-detail-map.js | 106 --------- web/static/ride-detail.js | 142 ----------- web/static/sync.js | 103 -------- .../official-climb-profile-logic.test.js | 37 --- web/static_test/ride-detail-logic.test.js | 45 ---- 27 files changed, 1310 insertions(+), 1036 deletions(-) create mode 100644 tsconfig.build.json create mode 100644 web/frontend/dom.ts create mode 100644 web/frontend/official-climb-profile-logic.test.ts create mode 100644 web/frontend/official-climb-profile-logic.ts create mode 100644 web/frontend/official-climb-profile.ts create mode 100644 web/frontend/ride-detail-boundaries.ts create mode 100644 web/frontend/ride-detail-canvas.ts create mode 100644 web/frontend/ride-detail-logic.test.ts create mode 100644 web/frontend/ride-detail-logic.ts create mode 100644 web/frontend/ride-detail-map.ts create mode 100644 web/frontend/ride-detail.ts create mode 100644 web/frontend/sync.ts create mode 100644 web/frontend/types.d.ts delete mode 100644 web/static/official-climb-profile-logic.js delete mode 100644 web/static/official-climb-profile.js delete mode 100644 web/static/ride-detail-boundaries.js delete mode 100644 web/static/ride-detail-canvas.js delete mode 100644 web/static/ride-detail-logic.js delete mode 100644 web/static/ride-detail-map.js delete mode 100644 web/static/ride-detail.js delete mode 100644 web/static/sync.js delete mode 100644 web/static_test/official-climb-profile-logic.test.js delete mode 100644 web/static_test/ride-detail-logic.test.js diff --git a/.gitignore b/.gitignore index 41a41c6..4454516 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ debug_department_*.csv france-latest.osm.pbf biking_home node_modules/ +web/static/*.js diff --git a/.mise.toml b/.mise.toml index 1f21ba9..5ff5424 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,6 +1,7 @@ [tools] "github:boyter/dcd" = "1.1.0" go = "1.25.12" +node = "26.6.0" watchexec = "latest" [tasks.generate] @@ -11,15 +12,18 @@ run = "go tool templ generate" description = "Format Go sources" run = "go fmt ./..." +[tasks.frontend-build] +description = "Compile TypeScript browser assets" +run = "npm run build" + [tasks.check] -description = "Run formatting, JavaScript and Go gate checks" -depends = ["generate", "format"] +description = "Run formatting, TypeScript and Go gate checks" +depends = ["generate", "format", "frontend-build"] run = [ "npm run format:check", "npm run lint", "npm run typecheck", - "for file in web/static/*.js; do node --check \"$file\"; done", - "node --test web/static_test/*.test.js", + "npm test", "go vet ./...", "go build ./...", "go test ./...", @@ -27,6 +31,7 @@ run = [ [tasks.build] description = "Build the application" +depends = ["frontend-build"] run = "go build -o biking_home ." [tasks.code-analysis] @@ -39,8 +44,9 @@ run = "DATABASE_URL=sqlite:biking_home.db go tool dbmate up" [tasks.dev] description = "Run the web server" +depends = ["frontend-build"] run = "go run ." [tasks.dev-watch] -description = "Regenerate templates and run the web server" -run = ["mise run generate", "go run ."] +description = "Regenerate templates, compile TypeScript, and run the web server" +run = ["mise run generate", "mise run frontend-build", "go run ."] diff --git a/package.json b/package.json index 13f91b1..e63fa69 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,13 @@ "private": true, "type": "module", "scripts": { - "format": "biome format --write web/static/*.js web/static_test/*.js", - "format:check": "biome format web/static/*.js web/static_test/*.js", - "lint": "biome lint web/static/*.js web/static_test/*.js", + "format": "biome format --write web/frontend", + "format:check": "biome format web/frontend", + "lint": "biome lint web/frontend", "typecheck": "tsc --noEmit", - "test": "node --test web/static_test/*.test.js", - "check": "npm run format:check && npm run lint && npm run typecheck && npm test" + "build": "tsc -p tsconfig.build.json", + "test": "node --experimental-strip-types --test web/frontend/*.test.ts", + "check": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm test" }, "devDependencies": { "@biomejs/biome": "^2.5.8", diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..e8fd8a4 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "noEmit": false, + "noEmitOnError": true, + "outDir": "web/static", + "rootDir": "web/frontend", + "sourceMap": false + }, + "include": ["web/frontend/**/*.ts", "web/frontend/**/*.d.ts"], + "exclude": ["web/frontend/**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 6ad150d..5944a6c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,14 @@ { "compilerOptions": { - "allowJs": true, - "checkJs": true, + "allowImportingTsExtensions": true, "lib": ["DOM", "ES2022"], "module": "ESNext", "moduleResolution": "Bundler", "noEmit": true, - "strict": false, + "strict": true, "skipLibCheck": true, "target": "ES2022", "types": ["leaflet", "node"] }, - "include": ["web/static/**/*.js", "web/static_test/**/*.js", "web/types/**/*.d.ts"] + "include": ["web/frontend/**/*.ts", "web/frontend/**/*.d.ts", "web/types/**/*.d.ts"] } 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 = (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("[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("[data-official-profile]"); + for (const profileCanvas of profileCanvases) { + const card = profileCanvas.closest("[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("[data-climb-item]"); + if (!item) continue; + const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10); + this.climbBounds[climbIndex] = { + startIndex: Number.parseInt( + requireElement(form.querySelector('[data-boundary-input="start"]'), "start boundary input").value, + 10, + ), + endIndex: Number.parseInt( + requireElement(form.querySelector('[data-boundary-input="end"]'), "end boundary input").value, + 10, + ), + }; + this.updatePreview(form); + const buttons = [...form.querySelectorAll("[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("[data-climb-item]"); + if (!item) return; + const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10); + const input = requireElement(form.querySelector(`[data-boundary-input="${target}"]`), `${target} boundary input`); + const output = requireElement(form.querySelector(`[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("[data-boundary-preview]"), "boundary preview"); + const item = form.closest("[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("[data-climb-summary]"), "climb summary"); + const metricsOutput = requireElement(item.querySelector("[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: '© OpenStreetMap 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("[data-climb-item]")]; + const climbItemIndices = climbItems.map((item) => Number.parseInt(item.dataset.climbIndex ?? "", 10)); + const previousClimbButton = document.querySelector("[data-climb-previous]"); + const nextClimbButton = document.querySelector("[data-climb-next]"); + const climbPosition = document.querySelector("[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("[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("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 => { + 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; +} + +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; +} 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} */ - 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: '© OpenStreetMap 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(); diff --git a/web/static_test/official-climb-profile-logic.test.js b/web/static_test/official-climb-profile-logic.test.js deleted file mode 100644 index 9976c66..0000000 --- a/web/static_test/official-climb-profile-logic.test.js +++ /dev/null @@ -1,37 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { displayStepForLength, profileBandForSlope, officialProfileSections } from "../static/official-climb-profile-logic.js"; - -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/static_test/ride-detail-logic.test.js b/web/static_test/ride-detail-logic.test.js deleted file mode 100644 index 2d9a695..0000000 --- a/web/static_test/ride-detail-logic.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { - categoryForScore, - climbMetrics, - cotacolForClimb, - elevationAtDistance, - formatDistance, - formatElevation, - nearestPointIndex, -} from "../static/ride-detail-logic.js"; - -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.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"); -}); -- cgit v1.2.3