import { formatCotacol, formatHours, formatSpeed, ticks } from "./analytics-logic.js"; import type { AnalyticsPoint } from "./types.js"; interface Plot { left: number; right: number; top: number; bottom: number; } interface AnalyticsColors { surface: string; grid: string; subtle: string; forest: string; accent: string; mutedText: string; } const POINT_RADIUS = 4; const HOVER_RADIUS = 7; const HIT_RADIUS = 12; interface ScatterOptions { outputId: string; xAccessor: (point: AnalyticsPoint) => number; xFormatter: (value: number) => string; xUnit: string; } export class AnalyticsScatter { private readonly canvas: HTMLCanvasElement; private readonly points: AnalyticsPoint[]; private readonly colors: AnalyticsColors; private readonly output: HTMLElement | null; private readonly context: CanvasRenderingContext2D; private readonly xAccessor: (point: AnalyticsPoint) => number; private readonly xFormatter: (value: number) => string; private readonly xUnit: string; private plot: Plot | null; private hoveredIndex: number; private readonly minX: number; private readonly maxX: number; private readonly minCotacol: number; private readonly maxCotacol: number; constructor(canvas: HTMLCanvasElement, points: AnalyticsPoint[], colors: AnalyticsColors, options: ScatterOptions) { this.canvas = canvas; this.points = points; this.colors = colors; this.output = document.getElementById(options.outputId); this.xAccessor = options.xAccessor; this.xFormatter = options.xFormatter; this.xUnit = options.xUnit; const context = canvas.getContext("2d"); if (!context) throw new Error("Analytics canvas is unavailable"); this.context = context; this.plot = null; this.hoveredIndex = -1; let maxX = 0; let maxCotacol = 0; for (const point of points) { maxX = Math.max(maxX, this.xAccessor(point)); maxCotacol = Math.max(maxCotacol, point.cotacolPer100Km); } this.minX = 0; this.minCotacol = 0; this.maxX = maxX * 1.08 || 1; this.maxCotacol = maxCotacol * 1.08 || 1; this.bindEvents(); } redraw(): void { this.draw(); } private 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: 44, right: rect.width - 16, top: 16, bottom: rect.height - 30 }; this.plot = plot; this.context.clearRect(0, 0, rect.width, rect.height); this.context.fillStyle = this.colors.surface; this.context.fillRect(0, 0, rect.width, rect.height); this.drawGrid(plot); this.drawPoints(plot); if (this.hoveredIndex >= 0) this.drawHover(plot); } private drawGrid(plot: Plot): void { this.context.font = "11px system-ui, sans-serif"; const xTicks = ticks(this.minX, this.maxX); for (const tick of xTicks) { const x = this.xForValue(tick, plot); this.context.strokeStyle = this.colors.grid; this.context.lineWidth = 1; this.context.beginPath(); this.context.moveTo(x, plot.top); this.context.lineTo(x, plot.bottom); this.context.stroke(); this.context.fillStyle = this.colors.subtle; this.context.textAlign = "center"; this.context.textBaseline = "top"; this.context.fillText(this.xFormatter(tick), x, plot.bottom + 8); } this.context.fillStyle = this.colors.mutedText; this.context.textAlign = "right"; this.context.fillText(this.xUnit, plot.right, plot.bottom + 8); const yTicks = ticks(this.minCotacol, this.maxCotacol); for (const tick of yTicks) { const y = this.yForCotacol(tick, plot); 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.textBaseline = "middle"; this.context.fillText(formatCotacol(tick), plot.left - 8, y); } } private drawPoints(plot: Plot): void { for (let index = 0; index < this.points.length; index++) { const point = this.points[index]; this.context.fillStyle = this.colors.accent; this.context.beginPath(); this.context.arc( this.xForValue(this.xAccessor(point), plot), this.yForCotacol(point.cotacolPer100Km, plot), POINT_RADIUS, 0, 2 * Math.PI, ); this.context.fill(); } } private drawHover(plot: Plot): void { const point = this.points[this.hoveredIndex]; const x = this.xForValue(this.xAccessor(point), plot); const y = this.yForCotacol(point.cotacolPer100Km, plot); this.context.strokeStyle = this.colors.forest; this.context.lineWidth = 2; this.context.beginPath(); this.context.arc(x, y, HOVER_RADIUS, 0, 2 * Math.PI); this.context.stroke(); } private xForValue(value: number, plot: Plot): number { const span = Math.max(this.maxX - this.minX, 1); return plot.left + ((value - this.minX) / span) * (plot.right - plot.left); } private yForCotacol(cotacol: number, plot: Plot): number { const span = Math.max(this.maxCotacol - this.minCotacol, 1); return plot.bottom - ((cotacol - this.minCotacol) / span) * (plot.bottom - plot.top); } private nearestPoint(x: number, y: number): number { if (!this.plot) return -1; let nearest = -1; let best = Infinity; for (let index = 0; index < this.points.length; index++) { const point = this.points[index]; const dx = this.xForValue(this.xAccessor(point), this.plot) - x; const dy = this.yForCotacol(point.cotacolPer100Km, this.plot) - y; const distance = Math.hypot(dx, dy); if (distance < best) { best = distance; nearest = index; } } return best <= HIT_RADIUS ? nearest : -1; } private bindEvents(): void { this.canvas.addEventListener("pointermove", (event: PointerEvent) => { const rect = this.canvas.getBoundingClientRect(); const index = this.nearestPoint(event.clientX - rect.left, event.clientY - rect.top); this.canvas.style.cursor = index >= 0 ? "pointer" : "default"; this.setHovered(index); }); this.canvas.addEventListener("pointerleave", () => { this.canvas.style.cursor = "default"; this.setHovered(-1); }); this.canvas.addEventListener("pointercancel", () => this.setHovered(-1)); this.canvas.addEventListener("click", () => { if (this.hoveredIndex < 0) return; window.open(this.points[this.hoveredIndex].url, "_blank", "noopener"); }); } private setHovered(index: number): void { if (index === this.hoveredIndex) return; this.hoveredIndex = index; if (this.output) { if (index < 0) { this.output.textContent = "Hover a point to inspect a ride."; } else { const point = this.points[index]; const xLabel = this.xUnit ? `${this.xFormatter(this.xAccessor(point))} ${this.xUnit}` : this.xFormatter(this.xAccessor(point)); this.output.textContent = `${point.name} · ${xLabel} · ${formatCotacol(point.cotacolPer100Km)} Cotacol/100km · click for details`; } } this.draw(); } } export const mountAnalytics = (): void => { const pointsScript = document.getElementById("analytics-points"); if (!pointsScript) return; const data = JSON.parse(pointsScript.textContent ?? "") as AnalyticsPoint[]; if (data.length === 0) return; const colorProbe = document.createElement("span"); colorProbe.hidden = true; document.body.append(colorProbe); const resolveColor = (name: string): string => { colorProbe.style.color = `var(${name})`; return getComputedStyle(colorProbe).color; }; const colors: AnalyticsColors = { surface: resolveColor("--color-plot-surface"), grid: resolveColor("--color-plot-grid"), subtle: resolveColor("--color-subtle"), forest: resolveColor("--color-forest"), accent: resolveColor("--color-accent"), mutedText: resolveColor("--color-muted"), }; colorProbe.remove(); const speedCanvas = document.getElementById("analytics-chart") as HTMLCanvasElement | null; if (speedCanvas) { const scatter = new AnalyticsScatter(speedCanvas, data, colors, { outputId: "analytics-hover", xAccessor: (point) => point.speedKmh, xFormatter: formatSpeed, xUnit: "km/h", }); window.addEventListener("resize", () => scatter.redraw()); scatter.redraw(); } const durationCanvas = document.getElementById("analytics-duration-chart") as HTMLCanvasElement | null; if (durationCanvas) { const scatter = new AnalyticsScatter(durationCanvas, data, colors, { outputId: "analytics-duration-hover", xAccessor: (point) => point.movingHours, xFormatter: formatHours, xUnit: "", }); window.addEventListener("resize", () => scatter.redraw()); scatter.redraw(); } }; mountAnalytics();