summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--.mise.toml18
-rw-r--r--package.json11
-rw-r--r--tsconfig.build.json13
-rw-r--r--tsconfig.json7
-rw-r--r--web/frontend/dom.ts4
-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.ts123
-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.ts116
-rw-r--r--web/static/ride-detail-boundaries.js84
18 files changed, 495 insertions, 221 deletions
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 = <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)}`;
- }
-}