aboutsummaryrefslogtreecommitdiff
path: root/web/frontend/analytics.ts
diff options
context:
space:
mode:
Diffstat (limited to 'web/frontend/analytics.ts')
-rw-r--r--web/frontend/analytics.ts223
1 files changed, 223 insertions, 0 deletions
diff --git a/web/frontend/analytics.ts b/web/frontend/analytics.ts
new file mode 100644
index 0000000..5dfad52
--- /dev/null
+++ b/web/frontend/analytics.ts
@@ -0,0 +1,223 @@
+import { formatCotacol, 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;
+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 plot: Plot | null;
+ private hoveredIndex: number;
+ private readonly minSpeed: number;
+ private readonly maxSpeed: number;
+ private readonly minCotacol: number;
+ private readonly maxCotacol: number;
+
+ constructor(canvas: HTMLCanvasElement, points: AnalyticsPoint[], colors: AnalyticsColors) {
+ this.canvas = canvas;
+ this.points = points;
+ this.colors = colors;
+ this.output = document.getElementById("analytics-hover");
+ const context = canvas.getContext("2d");
+ if (!context) throw new Error("Analytics canvas is unavailable");
+ this.context = context;
+ this.plot = null;
+ this.hoveredIndex = -1;
+ let maxSpeed = 0;
+ let maxCotacol = 0;
+ for (const point of points) {
+ maxSpeed = Math.max(maxSpeed, point.speedKmh);
+ maxCotacol = Math.max(maxCotacol, point.cotacolPer100Km);
+ }
+ this.minSpeed = 0;
+ this.minCotacol = 0;
+ this.maxSpeed = maxSpeed * 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.minSpeed, this.maxSpeed);
+ for (const tick of xTicks) {
+ const x = this.xForSpeed(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(formatSpeed(tick), x, plot.bottom + 8);
+ }
+ this.context.fillStyle = this.colors.mutedText;
+ this.context.textAlign = "right";
+ this.context.fillText("km/h", 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.xForSpeed(point.speedKmh, 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.xForSpeed(point.speedKmh, 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 xForSpeed(speed: number, plot: Plot): number {
+ const span = Math.max(this.maxSpeed - this.minSpeed, 1);
+ return plot.left + ((speed - this.minSpeed) / 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.xForSpeed(point.speedKmh, 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.location.href = this.points[this.hoveredIndex].url;
+ });
+ }
+
+ 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];
+ this.output.textContent = `${point.name} · ${formatSpeed(point.speedKmh)} km/h · ${formatCotacol(point.cotacolPer100Km)} Cotacol/100km · click for details`;
+ }
+ }
+ this.draw();
+ }
+}
+
+export const mountAnalytics = (): void => {
+ const canvas = document.getElementById("analytics-chart");
+ const pointsScript = document.getElementById("analytics-points");
+ if (!canvas || !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 scatter = new AnalyticsScatter(canvas as HTMLCanvasElement, data, colors);
+ window.addEventListener("resize", () => scatter.redraw());
+ scatter.redraw();
+};
+
+mountAnalytics();