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