diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 14:23:32 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-16 14:23:32 +0200 |
| commit | 53ab332e6d2afe9818914e0026310e4cadcc4202 (patch) | |
| tree | 198d708fa05d4247bdcc0cf2900fdf551d61b7d6 | |
| parent | 2d18d177f48285eb123e7da8680fc00e7a6fe19c (diff) | |
refactor(web): migrate browser code to TypeScript
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | .mise.toml | 18 | ||||
| -rw-r--r-- | package.json | 11 | ||||
| -rw-r--r-- | tsconfig.build.json | 13 | ||||
| -rw-r--r-- | tsconfig.json | 7 | ||||
| -rw-r--r-- | web/frontend/dom.ts | 4 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile-logic.test.ts (renamed from web/static_test/official-climb-profile-logic.test.js) | 2 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile-logic.ts (renamed from web/static/official-climb-profile-logic.js) | 25 | ||||
| -rw-r--r-- | web/frontend/official-climb-profile.ts (renamed from web/static/official-climb-profile.js) | 33 | ||||
| -rw-r--r-- | web/frontend/ride-detail-boundaries.ts | 123 | ||||
| -rw-r--r-- | web/frontend/ride-detail-canvas.ts (renamed from web/static/ride-detail-canvas.js) | 92 | ||||
| -rw-r--r-- | web/frontend/ride-detail-logic.test.ts (renamed from web/static_test/ride-detail-logic.test.js) | 3 | ||||
| -rw-r--r-- | web/frontend/ride-detail-logic.ts (renamed from web/static/ride-detail-logic.js) | 18 | ||||
| -rw-r--r-- | web/frontend/ride-detail-map.ts (renamed from web/static/ride-detail-map.js) | 62 | ||||
| -rw-r--r-- | web/frontend/ride-detail.ts (renamed from web/static/ride-detail.js) | 52 | ||||
| -rw-r--r-- | web/frontend/sync.ts (renamed from web/static/sync.js) | 52 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 116 | ||||
| -rw-r--r-- | web/static/ride-detail-boundaries.js | 84 |
18 files changed, 495 insertions, 221 deletions
@@ -6,3 +6,4 @@ debug_department_*.csv france-latest.osm.pbf biking_home node_modules/ +web/static/*.js @@ -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 = <T extends Element>(element: T | null, description: string): T => { + if (!element) throw new Error(`Missing ${description} element`); + return element; +}; diff --git a/web/static_test/official-climb-profile-logic.test.js b/web/frontend/official-climb-profile-logic.test.ts index 9976c66..31391b2 100644 --- a/web/static_test/official-climb-profile-logic.test.js +++ b/web/frontend/official-climb-profile-logic.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { displayStepForLength, profileBandForSlope, officialProfileSections } from "../static/official-climb-profile-logic.js"; +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); diff --git a/web/static/official-climb-profile-logic.js b/web/frontend/official-climb-profile-logic.ts index 87dcef8..1b2bc83 100644 --- a/web/static/official-climb-profile-logic.js +++ b/web/frontend/official-climb-profile-logic.ts @@ -1,8 +1,19 @@ -const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); -const profileStepSizesM = [100, 200, 500, 1000]; -const displayStepForLength = (lengthM) => +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) => { +const profileBandForSlope = (slopePercent: number): ProfileBand => { if (slopePercent < 0) return "downhill"; if (slopePercent < 3) return "0-3"; if (slopePercent < 6) return "3-6"; @@ -10,12 +21,12 @@ const profileBandForSlope = (slopePercent) => { if (slopePercent < 12) return "9-12"; return "12-plus"; }; -const officialProfileSections = (points, startIndex, endIndex, stepM) => { +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 = []; + const sections: OfficialProfileSection[] = []; let pointIndex = startIndex; - const elevationAtDistance = (distanceM) => { + 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]; diff --git a/web/static/official-climb-profile.js b/web/frontend/official-climb-profile.ts index 3f0a229..82ecc01 100644 --- a/web/static/official-climb-profile.js +++ b/web/frontend/official-climb-profile.ts @@ -1,28 +1,32 @@ import { displayStepForLength, officialProfileSections } from "./official-climb-profile-logic.js"; +import type { OfficialProfileColors, RideProfilePoint } from "./types.js"; export class OfficialClimbProfileController { - constructor(points) { + 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) => /** @type {HTMLDetailsElement} */ (card)); + 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; } - /** @type {HTMLCanvasElement | null} */ - const profileCanvas = card.querySelector("[data-official-profile]"); + const profileCanvas = card.querySelector<HTMLCanvasElement>("[data-official-profile]"); if (profileCanvas) this.drawOfficialProfile(profileCanvas); }); } } - resolveColors() { + resolveColors(): OfficialProfileColors { const colorProbe = document.createElement("span"); colorProbe.hidden = true; document.body.append(colorProbe); - const resolveColor = (name) => { + const resolveColor = (name: string): string => { colorProbe.style.color = `var(${name})`; return getComputedStyle(colorProbe).color; }; @@ -42,10 +46,10 @@ export class OfficialClimbProfileController { return colors; } - drawOfficialProfile(profileCanvas) { + drawOfficialProfile(profileCanvas: HTMLCanvasElement): void { const { points, colors } = this; - const startIndex = Number.parseInt(profileCanvas.dataset.profileStart, 10); - const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd, 10); + const startIndex = Number.parseInt(profileCanvas.dataset.profileStart ?? "", 10); + const endIndex = Number.parseInt(profileCanvas.dataset.profileEnd ?? "", 10); if ( !Number.isInteger(startIndex) || !Number.isInteger(endIndex) || @@ -79,8 +83,8 @@ export class OfficialClimbProfileController { 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); + 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); @@ -151,15 +155,14 @@ export class OfficialClimbProfileController { context.fillText(this.formatDistance(maxDistance - minDistance), plot.right, rect.height - 16); } - formatDistance(distance) { + formatDistance(distance: number): string { return `${distance.toFixed(distance < 10 ? 1 : 0)} km`; } redrawOpen() { - /** @type {NodeListOf<HTMLCanvasElement>} */ - const profileCanvases = document.querySelectorAll("[data-official-profile]"); + const profileCanvases = document.querySelectorAll<HTMLCanvasElement>("[data-official-profile]"); for (const profileCanvas of profileCanvases) { - const card = /** @type {HTMLDetailsElement | null} */ (profileCanvas.closest("[data-official-climb-card]")); + const card = profileCanvas.closest<HTMLDetailsElement>("[data-official-climb-card]"); if (card?.open) this.drawOfficialProfile(profileCanvas); } } diff --git a/web/frontend/ride-detail-boundaries.ts b/web/frontend/ride-detail-boundaries.ts new file mode 100644 index 0000000..22a1255 --- /dev/null +++ b/web/frontend/ride-detail-boundaries.ts @@ -0,0 +1,123 @@ +import { climbMetrics, formatDistance, formatElevation } from "./ride-detail-logic.js"; +import { requireElement } from "./dom.js"; +import type { BoundarySource, BoundaryTarget, ClimbBounds, RideProfilePoint } from "./types.js"; + +interface ActiveBoundary { + form: HTMLFormElement; + target: BoundaryTarget; + source: BoundarySource; +} + +interface RideBoundaryControllerOptions { + forms: HTMLFormElement[]; + points: RideProfilePoint[]; + climbBounds: ClimbBounds[]; + onBoundaryChanged: (index: number) => void; + onPointSelected: (index: number) => void; + setMessage: (message: string) => void; +} + +export class RideBoundaryController { + private readonly forms: HTMLFormElement[]; + private readonly points: RideProfilePoint[]; + private readonly climbBounds: ClimbBounds[]; + private readonly onBoundaryChanged: (index: number) => void; + private readonly onPointSelected: (index: number) => void; + private readonly setMessage: (message: string) => void; + private activeBoundary: ActiveBoundary | null; + + constructor({ forms, points, climbBounds, onBoundaryChanged, onPointSelected, setMessage }: RideBoundaryControllerOptions) { + this.forms = forms; + this.points = points; + this.climbBounds = climbBounds; + this.onBoundaryChanged = onBoundaryChanged; + this.onPointSelected = onPointSelected; + this.setMessage = setMessage; + this.activeBoundary = null; + this.initialize(); + } + + initialize(): void { + for (const form of this.forms) { + const item = form.closest<HTMLElement>("[data-climb-item]"); + if (!item) continue; + const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10); + this.climbBounds[climbIndex] = { + startIndex: Number.parseInt( + requireElement(form.querySelector<HTMLInputElement>('[data-boundary-input="start"]'), "start boundary input").value, + 10, + ), + endIndex: Number.parseInt( + requireElement(form.querySelector<HTMLInputElement>('[data-boundary-input="end"]'), "end boundary input").value, + 10, + ), + }; + this.updatePreview(form); + const buttons = [...form.querySelectorAll<HTMLElement>("[data-boundary-button]")]; + for (const button of buttons) { + button.addEventListener("click", () => { + this.clearSelection(); + this.activeBoundary = { + form, + target: button.dataset.boundaryButton as BoundaryTarget, + source: button.dataset.boundarySource as BoundarySource, + }; + button.classList.add("active"); + const surface = this.activeBoundary.source === "map" ? "map" : "profile"; + this.setMessage(`Click the ${surface} to select the ${this.activeBoundary.target} point.`); + }); + } + } + } + + isSelecting(source: BoundarySource): boolean { + return this.activeBoundary?.source === source; + } + + clearSelection(): void { + this.activeBoundary = null; + for (const form of this.forms) { + for (const button of form.querySelectorAll("[data-boundary-button]")) button.classList.remove("active"); + } + } + + choosePoint(index: number): void { + if (!this.activeBoundary) return; + const { form, target } = this.activeBoundary; + const item = form.closest<HTMLElement>("[data-climb-item]"); + if (!item) return; + const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10); + const input = requireElement(form.querySelector<HTMLInputElement>(`[data-boundary-input="${target}"]`), `${target} boundary input`); + const output = requireElement(form.querySelector<HTMLOutputElement>(`[data-boundary-output="${target}"]`), `${target} boundary output`); + input.value = String(index); + output.textContent = this.boundaryLabel(index); + if (target === "start") this.climbBounds[climbIndex].startIndex = index; + else this.climbBounds[climbIndex].endIndex = index; + this.updatePreview(form); + this.onBoundaryChanged(climbIndex); + this.clearSelection(); + this.onPointSelected(index); + } + + updatePreview(form: HTMLFormElement): void { + const preview = requireElement(form.querySelector<HTMLElement>("[data-boundary-preview]"), "boundary preview"); + const item = form.closest<HTMLElement>("[data-climb-item]"); + if (!item) return; + const climbIndex = Number.parseInt(item.dataset.climbIndex ?? "", 10); + const metrics = climbMetrics(this.points, this.climbBounds[climbIndex]); + if (!metrics) { + preview.textContent = "Choose an end point after the start point."; + return; + } + const summary = requireElement(item.querySelector<HTMLElement>("[data-climb-summary]"), "climb summary"); + const metricsOutput = requireElement(item.querySelector<HTMLElement>("[data-climb-metrics]"), "climb metrics"); + summary.textContent = `${formatDistance(metrics.start.distanceKm)}–${formatDistance(metrics.end.distanceKm)}`; + metricsOutput.textContent = `${metrics.category} · ${formatDistance(metrics.distanceKm)} at ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`; + preview.textContent = `Preview: ${formatDistance(metrics.distanceKm)} · ${metrics.elevationGain >= 0 ? "+" : ""}${formatElevation(metrics.elevationGain)} · ${metrics.slope.toFixed(1)}% · Cotacol ${metrics.cotacol.toFixed(1)}`; + } + + boundaryLabel(index: number): string { + const point = this.points[index]; + return `${formatDistance(point.distanceKm)} · ${formatElevation(point.elevationM)}`; + } +} diff --git a/web/static/ride-detail-canvas.js b/web/frontend/ride-detail-canvas.ts index 99b09c1..1453013 100644 --- a/web/static/ride-detail-canvas.js +++ b/web/frontend/ride-detail-canvas.ts @@ -1,7 +1,52 @@ 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 { - constructor({ canvas, points, profile, colors, getClimbBounds, canSelectPoint, onPointSelected, onPointHover }) { + 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; @@ -10,8 +55,9 @@ export class RideProfileCanvas { 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"); + 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; @@ -29,34 +75,38 @@ export class RideProfileCanvas { this.bindEvents(); } - setFocusedClimbIndex(index) { + setFocusedClimbIndex(index: number): void { this.focusedClimbIndex = index; this.draw(); } - redraw() { + redraw(): void { this.draw(); } - xForDistance(distance) { + 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 this.plot.left + ((distance - this.minDistance) / span) * (this.plot.right - this.plot.left); + return plot.left + ((distance - this.minDistance) / span) * (plot.right - plot.left); } - yForElevation(elevation) { + 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 this.plot.bottom - ((elevation - this.minElevation) / span) * (this.plot.bottom - this.plot.top); + return plot.bottom - ((elevation - this.minElevation) / span) * (plot.bottom - plot.top); } - draw() { + 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); - this.plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 }; - const plot = this.plot; + 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); @@ -163,25 +213,27 @@ export class RideProfileCanvas { } } - clearHover() { + clearHover(): void { this.hoveredIndex = -1; this.onPointHover(-1); this.draw(); } - showPoint(index) { + showPoint(index: number): void { 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); + 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() { - this.canvas.addEventListener("pointermove", (event) => { + bindEvents(): void { + this.canvas.addEventListener("pointermove", (event: PointerEvent) => { if (!this.plot) return; const rect = this.canvas.getBoundingClientRect(); const x = event.clientX - rect.left; @@ -193,14 +245,14 @@ export class RideProfileCanvas { }); this.canvas.addEventListener("pointerleave", () => this.clearHover()); this.canvas.addEventListener("pointercancel", () => this.clearHover()); - this.canvas.addEventListener("click", (event) => { + 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) => { + this.canvas.addEventListener("keydown", (event: KeyboardEvent) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; event.preventDefault(); const direction = event.key === "ArrowRight" ? 1 : -1; diff --git a/web/static_test/ride-detail-logic.test.js b/web/frontend/ride-detail-logic.test.ts index 2d9a695..024f018 100644 --- a/web/static_test/ride-detail-logic.test.js +++ b/web/frontend/ride-detail-logic.test.ts @@ -8,7 +8,7 @@ import { formatDistance, formatElevation, nearestPointIndex, -} from "../static/ride-detail-logic.js"; +} from "./ride-detail-logic.ts"; const points = [ { distanceKm: 0, elevationM: 100 }, @@ -30,6 +30,7 @@ test("interpolates elevation between route points", () => { 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); diff --git a/web/static/ride-detail-logic.js b/web/frontend/ride-detail-logic.ts index 4ab408b..825bb2c 100644 --- a/web/static/ride-detail-logic.js +++ b/web/frontend/ride-detail-logic.ts @@ -1,10 +1,12 @@ -export const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +import type { ClimbBounds, ClimbMetrics, ProfilePoint } from "./types.js"; -export const formatDistance = (distance) => `${distance.toFixed(distance < 10 ? 1 : 0)} km`; +export const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); -export const formatElevation = (elevation) => `${Math.round(elevation)} m`; +export const formatDistance = (distance: number): string => `${distance.toFixed(distance < 10 ? 1 : 0)} km`; -export const nearestPointIndex = (points, distance) => { +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) { @@ -17,7 +19,7 @@ export const nearestPointIndex = (points, distance) => { return distance - previous.distanceKm < points[low].distanceKm - distance ? low - 1 : low; }; -export const categoryForScore = (score) => { +export const categoryForScore = (score: number): string => { if (score < 35) return "NO"; if (score < 80) return "Cat 4"; if (score < 180) return "Cat 3"; @@ -26,7 +28,7 @@ export const categoryForScore = (score) => { return "HC"; }; -export const elevationAtDistance = (points, index, distanceM) => { +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; @@ -34,7 +36,7 @@ export const elevationAtDistance = (points, index, distanceM) => { return points[index].elevationM + fraction * (points[index + 1].elevationM - points[index].elevationM); }; -export const cotacolForClimb = (points, startIndex, endIndex) => { +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; @@ -52,7 +54,7 @@ export const cotacolForClimb = (points, startIndex, endIndex) => { return score; }; -export const climbMetrics = (points, bounds) => { +export const climbMetrics = (points: ProfilePoint[], bounds: ClimbBounds | undefined): ClimbMetrics | null => { if ( !bounds || !Number.isInteger(bounds.startIndex) || diff --git a/web/static/ride-detail-map.js b/web/frontend/ride-detail-map.ts index e8e7240..bd19412 100644 --- a/web/static/ride-detail-map.js +++ b/web/frontend/ride-detail-map.ts @@ -1,7 +1,27 @@ 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 { - constructor({ leaflet, element, route, points, climbs, colors }) { + 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; @@ -32,9 +52,11 @@ export class RideDetailMap { .addTo(this.map); } - createClimbLayer(climb) { + createClimbLayer(climb: ClimbBounds): Polyline | null { if (!this.isValidBounds(climb)) return null; - const coordinates = this.points.slice(climb.startIndex, climb.endIndex + 1).map((point) => [point.latitude, point.longitude]); + 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, @@ -47,29 +69,31 @@ export class RideDetailMap { .addTo(this.map); } - isValidBounds(bounds) { - return ( + 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 + Number.isInteger(bounds.startIndex) && + Number.isInteger(bounds.endIndex) && + bounds.startIndex >= 0 && + bounds.endIndex < this.points.length && + bounds.startIndex < bounds.endIndex, ); } - updateClimbLayer(index, bounds, active) { + updateClimbLayer(index: number, bounds: ClimbBounds | undefined, active: boolean): void { const layer = this.climbLayers[index]; if (!layer) return; - if (!this.isValidBounds(bounds)) { + if (!bounds || !this.isValidBounds(bounds)) { layer.setLatLngs([]); return; } - layer.setLatLngs(this.points.slice(bounds.startIndex, bounds.endIndex + 1).map((point) => [point.latitude, point.longitude])); + 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, longitude) { + nearestPointIndex(latitude: number, longitude: number): number { let nearestIndex = 0; let nearestDistance = Infinity; for (let index = 0; index < this.points.length; index++) { @@ -83,7 +107,7 @@ export class RideDetailMap { return nearestIndex; } - showPoint(index) { + 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 }); @@ -93,14 +117,14 @@ export class RideDetailMap { this.routeCursor.setStyle({ opacity: 0, fillOpacity: 0 }); } - zoomToClimb(bounds) { - if (!this.isValidBounds(bounds)) return; + 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])); + 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) { + onClick(listener: (event: LeafletMouseEvent) => void): void { this.map.on("click", listener); } } diff --git a/web/static/ride-detail.js b/web/frontend/ride-detail.ts index 67c87ae..fb75c19 100644 --- a/web/static/ride-detail.js +++ b/web/frontend/ride-detail.ts @@ -3,26 +3,29 @@ 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 = () => { +export const mountRideDetail = (): void => { 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 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) => { + const resolveColor = (name: string): string => { colorProbe.style.color = `var(${name})`; return getComputedStyle(colorProbe).color; }; - const colors = { + const colors: RideDetailColors = { accent: resolveColor("--color-accent"), forest: resolveColor("--color-forest"), subtle: resolveColor("--color-subtle"), @@ -39,20 +42,17 @@ export const mountRideDetail = () => { }; colorProbe.remove(); - const route = JSON.parse(routeElement.textContent); - const profile = JSON.parse(profileElement.textContent); - const points = profile.points || []; + 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]")].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 climbItems = [...document.querySelectorAll<HTMLElement>("[data-climb-item]")]; + const climbItemIndices = climbItems.map((item) => Number.parseInt(item.dataset.climbIndex ?? "", 10)); + const previousClimbButton = document.querySelector<HTMLButtonElement>("[data-climb-previous]"); + const nextClimbButton = document.querySelector<HTMLButtonElement>("[data-climb-next]"); + const climbPosition = document.querySelector<HTMLElement>("[data-climb-position]"); + const climbBounds: ClimbBounds[] = profile.climbs.map((climb) => ({ startIndex: climb.startIndex, endIndex: climb.endIndex })); const state = { focusedClimbItemIndex: 0, focusedClimbIndex: climbItemIndices[0] ?? 0, @@ -67,9 +67,9 @@ export const mountRideDetail = () => { colors, }); const officialProfileController = new OfficialClimbProfileController(points); - let boundaryController; + let boundaryController: RideBoundaryController | undefined; const profileCanvas = new RideProfileCanvas({ - canvas, + canvas: profileCanvasElement, points, profile, colors, @@ -88,11 +88,11 @@ export const mountRideDetail = () => { }, }); - const updateClimbLayer = (index) => { + const updateClimbLayer = (index: number): void => { mapController.updateClimbLayer(index, climbBounds[index], index === state.focusedClimbIndex); }; boundaryController = new RideBoundaryController({ - forms: [...document.querySelectorAll("[data-official-climb-form]")].map((form) => /** @type {HTMLFormElement} */ (form)), + forms: [...document.querySelectorAll<HTMLFormElement>("[data-official-climb-form]")], points, climbBounds, onBoundaryChanged: (index) => { @@ -106,11 +106,11 @@ export const mountRideDetail = () => { }); mapController.onClick((event) => { - if (!boundaryController.isSelecting("map")) return; + if (!boundaryController?.isSelecting("map")) return; boundaryController.choosePoint(mapController.nearestPointIndex(event.latlng.lat, event.latlng.lng)); }); - const updateClimbFocus = (index, zoom) => { + 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]; @@ -124,7 +124,7 @@ export const mountRideDetail = () => { 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(); + boundaryController?.clearSelection(); profileCanvas.setFocusedClimbIndex(state.focusedClimbIndex); if (zoom) mapController.zoomToClimb(climbBounds[state.focusedClimbIndex]); }; diff --git a/web/static/sync.js b/web/frontend/sync.ts index 3cf6cca..000dcfc 100644 --- a/web/static/sync.js +++ b/web/frontend/sync.ts @@ -1,67 +1,69 @@ -export const mountSync = () => { - const form = /** @type {HTMLFormElement | null} */ (document.getElementById("sync-form")); +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; - /** @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 button = requireElement(form.querySelector<HTMLButtonElement>("button[type=submit]"), "sync submit button"); + const progressPanel = requireElement(document.getElementById("sync-progress"), "sync progress"); + const progressBar = requireElement(document.getElementById("sync-progress-bar") as HTMLProgressElement | null, "sync progress bar"); + const progressCount = requireElement(document.getElementById("sync-progress-count"), "sync progress count"); + const progressDetails = requireElement(document.getElementById("sync-progress-details"), "sync progress details"); + const notice = requireElement(document.getElementById("sync-notice"), "sync notice"); + const error = requireElement(document.getElementById("sync-error"), "sync error"); - const showError = (message) => { + const showError = (message: string): void => { error.textContent = message; error.hidden = false; }; - const updateProgress = (progress) => { + 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, data) => { + 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 (data.progress) updateProgress(data.progress); - throw new Error(data.message || "Strava sync failed"); + 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) => { + const consumeEvents = async (response: Response): Promise<void> => { + if (!response.body) throw new Error("The browser does not support streaming responses"); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let completed = false; - const consumeBlock = (block) => { + 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)) || completed; + 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(); + buffer = blocks.pop() ?? ""; for (const block of blocks) consumeBlock(block); if (done) break; } @@ -69,7 +71,7 @@ export const mountSync = () => { if (!completed) throw new Error("Strava sync ended before completion"); }; - form.addEventListener("submit", async (event) => { + form.addEventListener("submit", async (event: SubmitEvent) => { event.preventDefault(); button.disabled = true; button.textContent = "Syncing..."; diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts new file mode 100644 index 0000000..66f1ad2 --- /dev/null +++ b/web/frontend/types.d.ts @@ -0,0 +1,116 @@ +export type ProfileBand = "downhill" | "0-3" | "3-6" | "6-9" | "9-12" | "12-plus"; +export type BoundaryTarget = "start" | "end"; +export type BoundarySource = "profile" | "map"; + +export interface ProfilePoint { + distanceKm: number; + elevationM: number; +} + +export interface RideProfilePoint extends ProfilePoint { + latitude: number; + longitude: number; +} + +export interface RideProfileClimb { + startKm: number; + endKm: number; + topKm: number; + topElevationM: number; + name: string; + score: number; + category: string; + distanceKm: number; + slopePercent: number; + cotacol: number; + officialClimbId?: number; + officialName?: string; + startIndex: number; + endIndex: number; +} + +export interface RideProfileCrossing { + distanceKm: number; + passElevationM: number; + rideElevationM: number; + distanceToM: number; + elevationDiffM: number; + name: string; +} + +export interface RideProfile { + points: RideProfilePoint[]; + climbs: RideProfileClimb[]; + crossings: RideProfileCrossing[]; +} + +export interface RideRoute { + type: "FeatureCollection"; + features: RideRouteFeature[]; +} + +export interface RideRouteFeature { + type: "Feature"; + geometry: { + type: "LineString"; + coordinates: [number, number][]; + }; + properties?: Record<string, unknown>; +} + +export interface ClimbBounds { + startIndex: number; + endIndex: number; +} + +export interface ClimbMetrics { + start: ProfilePoint; + end: ProfilePoint; + distanceKm: number; + elevationGain: number; + slope: number; + score: number; + cotacol: number; + category: string; +} + +export interface OfficialProfileColors { + downhill: string; + "0-3": string; + "3-6": string; + "6-9": string; + "9-12": string; + "12-plus": string; + plotSurface: string; + grid: string; + subtle: string; + accent: string; +} + +export interface RideDetailColors { + accent: string; + subtle: string; + plotSurface: string; + grid: string; + forest: string; + plotSurfaceOverlay: string; + accentFill: string; + climbLabel: string; + crossing: string; + crossingLabel: string; + hoverLine: string; + climbRoute: string; + climbFocusFill: string; +} + +export interface SyncProgress { + total: number; + completed: number; + imported: number; + skipped: number; +} + +export interface SyncErrorEvent { + message: string; + progress?: SyncProgress; +} 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)}`; - } -} |