summaryrefslogtreecommitdiff
path: root/web/static/ride-detail-canvas.js
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 14:23:32 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-16 14:23:32 +0200
commit53ab332e6d2afe9818914e0026310e4cadcc4202 (patch)
tree198d708fa05d4247bdcc0cf2900fdf551d61b7d6 /web/static/ride-detail-canvas.js
parent2d18d177f48285eb123e7da8680fc00e7a6fe19c (diff)
refactor(web): migrate browser code to TypeScript
Diffstat (limited to 'web/static/ride-detail-canvas.js')
-rw-r--r--web/static/ride-detail-canvas.js211
1 files changed, 0 insertions, 211 deletions
diff --git a/web/static/ride-detail-canvas.js b/web/static/ride-detail-canvas.js
deleted file mode 100644
index 99b09c1..0000000
--- a/web/static/ride-detail-canvas.js
+++ /dev/null
@@ -1,211 +0,0 @@
-import { clamp, climbMetrics, formatDistance, formatElevation, nearestPointIndex } from "./ride-detail-logic.js";
-
-export class RideProfileCanvas {
- constructor({ canvas, points, profile, colors, getClimbBounds, canSelectPoint, onPointSelected, onPointHover }) {
- this.canvas = canvas;
- this.points = points;
- this.profile = profile;
- this.colors = colors;
- this.getClimbBounds = getClimbBounds;
- this.canSelectPoint = canSelectPoint;
- this.onPointSelected = onPointSelected;
- this.onPointHover = onPointHover;
- this.context = canvas.getContext("2d");
- if (!this.context) throw new Error("Ride profile canvas is unavailable");
- this.hoveredIndex = -1;
- this.focusedClimbIndex = 0;
- this.plot = null;
- this.minDistance = points[0].distanceKm;
- this.maxDistance = points[points.length - 1].distanceKm;
- let minElevation = points[0].elevationM;
- let maxElevation = points[0].elevationM;
- for (const point of points) {
- minElevation = Math.min(minElevation, point.elevationM);
- maxElevation = Math.max(maxElevation, point.elevationM);
- }
- const elevationPadding = Math.max((maxElevation - minElevation) * 0.1, 20);
- this.minElevation = minElevation - elevationPadding;
- this.maxElevation = maxElevation + elevationPadding;
- this.bindEvents();
- }
-
- setFocusedClimbIndex(index) {
- this.focusedClimbIndex = index;
- this.draw();
- }
-
- redraw() {
- this.draw();
- }
-
- xForDistance(distance) {
- const span = Math.max(this.maxDistance - this.minDistance, 1);
- return this.plot.left + ((distance - this.minDistance) / span) * (this.plot.right - this.plot.left);
- }
-
- yForElevation(elevation) {
- const span = Math.max(this.maxElevation - this.minElevation, 1);
- return this.plot.bottom - ((elevation - this.minElevation) / span) * (this.plot.bottom - this.plot.top);
- }
-
- draw() {
- const rect = this.canvas.getBoundingClientRect();
- if (rect.width === 0 || rect.height === 0) return;
- const ratio = window.devicePixelRatio || 1;
- this.canvas.width = Math.floor(rect.width * ratio);
- this.canvas.height = Math.floor(rect.height * ratio);
- this.context.setTransform(ratio, 0, 0, ratio, 0, 0);
- this.plot = { left: 52, right: rect.width - 20, top: 24, bottom: rect.height - 34 };
- const plot = this.plot;
- this.context.clearRect(0, 0, rect.width, rect.height);
- this.context.fillStyle = this.colors.plotSurface;
- this.context.fillRect(0, 0, rect.width, rect.height);
-
- this.context.font = "12px system-ui, sans-serif";
- this.context.textBaseline = "middle";
- for (let step = 0; step <= 4; step++) {
- const fraction = step / 4;
- const y = plot.top + fraction * (plot.bottom - plot.top);
- const elevation = this.maxElevation - fraction * (this.maxElevation - this.minElevation);
- this.context.strokeStyle = this.colors.grid;
- this.context.lineWidth = 1;
- this.context.beginPath();
- this.context.moveTo(plot.left, y);
- this.context.lineTo(plot.right, y);
- this.context.stroke();
- this.context.fillStyle = this.colors.subtle;
- this.context.textAlign = "right";
- this.context.fillText(formatElevation(elevation), plot.left - 8, y);
- }
-
- const climbBounds = this.getClimbBounds();
- for (let climbIndex = 0; climbIndex < (this.profile.climbs || []).length; climbIndex++) {
- const climb = this.profile.climbs[climbIndex];
- const metrics = climbMetrics(this.points, climbBounds[climbIndex]);
- if (!metrics) continue;
- const startX = clamp(this.xForDistance(metrics.start.distanceKm), plot.left, plot.right);
- const endX = clamp(this.xForDistance(metrics.end.distanceKm), plot.left, plot.right);
- this.context.fillStyle = climbIndex === this.focusedClimbIndex ? this.colors.climbFocusFill : this.colors.accentFill;
- this.context.fillRect(startX, plot.top, Math.max(endX - startX, 1), plot.bottom - plot.top);
- const label = climb.name || `${metrics.category} ${Math.round(metrics.score)}`;
- this.context.fillStyle = this.colors.climbLabel;
- this.context.textAlign = "center";
- this.context.textBaseline = "top";
- this.context.fillText(label, clamp((startX + endX) / 2, plot.left + 24, plot.right - 24), plot.top + 4);
- }
-
- for (const crossing of this.profile.crossings || []) {
- const x = this.xForDistance(crossing.distanceKm);
- this.context.strokeStyle = this.colors.crossing;
- this.context.lineWidth = 1;
- this.context.setLineDash([4, 3]);
- this.context.beginPath();
- this.context.moveTo(x, plot.top);
- this.context.lineTo(x, plot.bottom);
- this.context.stroke();
- this.context.setLineDash([]);
- }
-
- this.context.beginPath();
- this.context.moveTo(this.xForDistance(this.points[0].distanceKm), plot.bottom);
- for (const point of this.points) this.context.lineTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM));
- this.context.lineTo(this.xForDistance(this.points[this.points.length - 1].distanceKm), plot.bottom);
- this.context.closePath();
- this.context.fillStyle = this.colors.accentFill;
- this.context.fill();
- this.context.beginPath();
- for (let index = 0; index < this.points.length; index++) {
- const point = this.points[index];
- if (index === 0) this.context.moveTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM));
- else this.context.lineTo(this.xForDistance(point.distanceKm), this.yForElevation(point.elevationM));
- }
- this.context.strokeStyle = this.colors.accent;
- this.context.lineWidth = 2.5;
- this.context.stroke();
- for (const crossing of this.profile.crossings || []) {
- const x = this.xForDistance(crossing.distanceKm);
- const label = `${crossing.name} ${Math.round(crossing.passElevationM)} m`;
- const labelWidth = this.context.measureText(label).width;
- const labelX = clamp(x, plot.left + labelWidth / 2 + 3, plot.right - labelWidth / 2 - 3);
- const labelY = plot.top - 6;
- this.context.fillStyle = this.colors.plotSurfaceOverlay;
- this.context.fillRect(labelX - labelWidth / 2 - 3, labelY - 15, labelWidth + 6, 16);
- this.context.fillStyle = this.colors.crossingLabel;
- this.context.textAlign = "center";
- this.context.textBaseline = "bottom";
- this.context.fillText(label, labelX, labelY);
- }
-
- this.context.fillStyle = this.colors.subtle;
- this.context.textAlign = "center";
- this.context.textBaseline = "top";
- for (let step = 0; step <= 4; step++) {
- const distance = this.minDistance + (step / 4) * (this.maxDistance - this.minDistance);
- this.context.fillText(formatDistance(distance), this.xForDistance(distance), plot.bottom + 10);
- }
-
- if (this.hoveredIndex >= 0) {
- const point = this.points[this.hoveredIndex];
- const x = this.xForDistance(point.distanceKm);
- const y = this.yForElevation(point.elevationM);
- this.context.strokeStyle = this.colors.hoverLine;
- this.context.lineWidth = 1;
- this.context.setLineDash([3, 3]);
- this.context.beginPath();
- this.context.moveTo(x, plot.top);
- this.context.lineTo(x, plot.bottom);
- this.context.stroke();
- this.context.setLineDash([]);
- this.context.fillStyle = this.colors.forest;
- this.context.beginPath();
- this.context.arc(x, y, 5, 0, 2 * Math.PI);
- this.context.fill();
- }
- }
-
- clearHover() {
- this.hoveredIndex = -1;
- this.onPointHover(-1);
- this.draw();
- }
-
- showPoint(index) {
- this.hoveredIndex = clamp(index, 0, this.points.length - 1);
- this.onPointHover(this.hoveredIndex);
- this.draw();
- }
-
- pointIndexAtX(x) {
- const distance = this.minDistance + ((x - this.plot.left) / (this.plot.right - this.plot.left)) * (this.maxDistance - this.minDistance);
- return nearestPointIndex(this.points, distance);
- }
-
- bindEvents() {
- this.canvas.addEventListener("pointermove", (event) => {
- if (!this.plot) return;
- const rect = this.canvas.getBoundingClientRect();
- const x = event.clientX - rect.left;
- if (x < this.plot.left || x > this.plot.right) {
- this.clearHover();
- return;
- }
- this.showPoint(this.pointIndexAtX(x));
- });
- this.canvas.addEventListener("pointerleave", () => this.clearHover());
- this.canvas.addEventListener("pointercancel", () => this.clearHover());
- this.canvas.addEventListener("click", (event) => {
- if (!this.canSelectPoint("profile") || !this.plot) return;
- const rect = this.canvas.getBoundingClientRect();
- const x = event.clientX - rect.left;
- if (x < this.plot.left || x > this.plot.right) return;
- this.onPointSelected(this.pointIndexAtX(x));
- });
- this.canvas.addEventListener("keydown", (event) => {
- if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
- event.preventDefault();
- const direction = event.key === "ArrowRight" ? 1 : -1;
- const index = this.hoveredIndex < 0 ? (direction > 0 ? 0 : this.points.length - 1) : this.hoveredIndex + direction;
- this.showPoint(index);
- });
- }
-}