diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-21 19:07:31 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-21 19:07:31 +0200 |
| commit | b3a83b2fdd5b406d020398e7c3cf6f46149a2523 (patch) | |
| tree | 7086ae3602e219f7478dcaeca66c0e54e337c1f3 /web/frontend | |
| parent | 1e63f5c91f0e5ff7dfc582b67d262d5939cc34bc (diff) | |
Add global analytics scatter chart
Diffstat (limited to 'web/frontend')
| -rw-r--r-- | web/frontend/analytics-logic.test.ts | 26 | ||||
| -rw-r--r-- | web/frontend/analytics-logic.ts | 35 | ||||
| -rw-r--r-- | web/frontend/analytics.ts | 223 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 8 |
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; |
