aboutsummaryrefslogtreecommitdiff
path: root/web/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'web/frontend')
-rw-r--r--web/frontend/analytics-logic.test.ts26
-rw-r--r--web/frontend/analytics-logic.ts35
-rw-r--r--web/frontend/analytics.ts223
-rw-r--r--web/frontend/types.d.ts8
4 files changed, 292 insertions, 0 deletions
diff --git a/web/frontend/analytics-logic.test.ts b/web/frontend/analytics-logic.test.ts
new file mode 100644
index 0000000..74fdddd
--- /dev/null
+++ b/web/frontend/analytics-logic.test.ts
@@ -0,0 +1,26 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { formatCotacol, formatSpeed, niceStep, ticks } from "./analytics-logic.ts";
+
+test("computes a pleasant axis step within a 1-2-5-10 series", () => {
+ assert.equal(niceStep(25, 5), 5);
+ assert.equal(niceStep(60, 5), 10);
+ assert.equal(niceStep(3.6, 5), 0.5);
+ assert.equal(niceStep(0, 5), 1);
+});
+
+test("produces ascending ticks that bracket the data range and include its max", () => {
+ assert.deepEqual(ticks(0, 27.9), [0, 5, 10, 15, 20, 25]);
+ assert.deepEqual(ticks(0, 315), [0, 50, 100, 150, 200, 250, 300]);
+ const values = ticks(0, 27.9);
+ for (let index = 1; index < values.length; index++) {
+ assert.ok(values[index] > values[index - 1]);
+ }
+ assert.ok(values[values.length - 1] <= 27.9 + 1e-9);
+});
+
+test("formats speed and cotacol per 100 km for axis labels", () => {
+ assert.equal(formatSpeed(25.2), "25.2");
+ assert.equal(formatCotacol(300), "300");
+ assert.equal(formatCotacol(50), "50.0");
+});
diff --git a/web/frontend/analytics-logic.ts b/web/frontend/analytics-logic.ts
new file mode 100644
index 0000000..7a53944
--- /dev/null
+++ b/web/frontend/analytics-logic.ts
@@ -0,0 +1,35 @@
+export const niceStep = (range: number, target: number): number => {
+ if (range <= 0) return 1;
+ const ideal = range / target;
+ const magnitude = 10 ** Math.floor(Math.log10(ideal));
+ let best = magnitude;
+ let bestDistance = Number.POSITIVE_INFINITY;
+ for (const factor of [1, 2, 5, 10]) {
+ const candidate = factor * magnitude;
+ const distance = Math.abs(candidate - ideal);
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ best = candidate;
+ }
+ }
+ return best;
+};
+
+export const ticks = (min: number, max: number, target = 5): number[] => {
+ const step = niceStep(max - min, target);
+ const values: number[] = [];
+ for (let value = Math.floor(min / step) * step; value <= max + 1e-9; value += step) {
+ if (value >= min - 1e-9) values.push(roundStep(value));
+ }
+ return values;
+};
+
+const roundStep = (value: number): number => {
+ if (!Number.isFinite(value) || value === 0) return 0;
+ const decimals = Math.max(0, -Math.floor(Math.log10(value) + 1e-9));
+ return Number(value.toFixed(decimals));
+};
+
+export const formatSpeed = (speed: number): string => `${speed.toFixed(1)}`;
+
+export const formatCotacol = (cotacol: number): string => (cotacol >= 100 ? `${Math.round(cotacol)}` : `${cotacol.toFixed(1)}`);
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();
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
index 07c8204..c7fb152 100644
--- a/web/frontend/types.d.ts
+++ b/web/frontend/types.d.ts
@@ -115,6 +115,14 @@ export interface RideMapColors {
climbRoute: string;
}
+export interface AnalyticsPoint {
+ id: number;
+ name: string;
+ url: string;
+ speedKmh: number;
+ cotacolPer100Km: number;
+}
+
export interface SyncProgress {
total: number;
completed: number;