aboutsummaryrefslogtreecommitdiff
path: root/web/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'web/frontend')
-rw-r--r--web/frontend/analytics-logic.ts8
-rw-r--r--web/frontend/analytics.ts93
-rw-r--r--web/frontend/types.d.ts1
3 files changed, 76 insertions, 26 deletions
diff --git a/web/frontend/analytics-logic.ts b/web/frontend/analytics-logic.ts
index 7a53944..e8a1559 100644
--- a/web/frontend/analytics-logic.ts
+++ b/web/frontend/analytics-logic.ts
@@ -33,3 +33,11 @@ const roundStep = (value: number): number => {
export const formatSpeed = (speed: number): string => `${speed.toFixed(1)}`;
export const formatCotacol = (cotacol: number): string => (cotacol >= 100 ? `${Math.round(cotacol)}` : `${cotacol.toFixed(1)}`);
+
+export const formatHours = (hours: number): string => {
+ if (hours < 1) return `${Math.round(hours * 60)}m`;
+ const h = Math.floor(hours);
+ const m = Math.round((hours - h) * 60);
+ if (m === 0) return `${h}h`;
+ return `${h}h${String(m).padStart(2, "0")}`;
+};
diff --git a/web/frontend/analytics.ts b/web/frontend/analytics.ts
index 5dfad52..ef2479d 100644
--- a/web/frontend/analytics.ts
+++ b/web/frontend/analytics.ts
@@ -1,4 +1,4 @@
-import { formatCotacol, formatSpeed, ticks } from "./analytics-logic.js";
+import { formatCotacol, formatHours, formatSpeed, ticks } from "./analytics-logic.js";
import type { AnalyticsPoint } from "./types.js";
interface Plot {
@@ -20,38 +20,52 @@ interface AnalyticsColors {
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 minSpeed: number;
- private readonly maxSpeed: number;
+ private readonly minX: number;
+ private readonly maxX: number;
private readonly minCotacol: number;
private readonly maxCotacol: number;
- constructor(canvas: HTMLCanvasElement, points: AnalyticsPoint[], colors: AnalyticsColors) {
+ constructor(canvas: HTMLCanvasElement, points: AnalyticsPoint[], colors: AnalyticsColors, options: ScatterOptions) {
this.canvas = canvas;
this.points = points;
this.colors = colors;
- this.output = document.getElementById("analytics-hover");
+ 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 maxSpeed = 0;
+ let maxX = 0;
let maxCotacol = 0;
for (const point of points) {
- maxSpeed = Math.max(maxSpeed, point.speedKmh);
+ maxX = Math.max(maxX, this.xAccessor(point));
maxCotacol = Math.max(maxCotacol, point.cotacolPer100Km);
}
- this.minSpeed = 0;
+ this.minX = 0;
this.minCotacol = 0;
- this.maxSpeed = maxSpeed * 1.08 || 1;
+ this.maxX = maxX * 1.08 || 1;
this.maxCotacol = maxCotacol * 1.08 || 1;
this.bindEvents();
}
@@ -79,9 +93,9 @@ export class AnalyticsScatter {
private drawGrid(plot: Plot): void {
this.context.font = "11px system-ui, sans-serif";
- const xTicks = ticks(this.minSpeed, this.maxSpeed);
+ const xTicks = ticks(this.minX, this.maxX);
for (const tick of xTicks) {
- const x = this.xForSpeed(tick, plot);
+ const x = this.xForValue(tick, plot);
this.context.strokeStyle = this.colors.grid;
this.context.lineWidth = 1;
this.context.beginPath();
@@ -91,11 +105,11 @@ export class AnalyticsScatter {
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.fillText(this.xFormatter(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);
+ 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);
@@ -117,14 +131,20 @@ export class AnalyticsScatter {
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.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.xForSpeed(point.speedKmh, plot);
+ 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;
@@ -133,9 +153,9 @@ export class AnalyticsScatter {
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 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 {
@@ -149,7 +169,7 @@ export class AnalyticsScatter {
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 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) {
@@ -174,7 +194,7 @@ export class AnalyticsScatter {
this.canvas.addEventListener("pointercancel", () => this.setHovered(-1));
this.canvas.addEventListener("click", () => {
if (this.hoveredIndex < 0) return;
- window.location.href = this.points[this.hoveredIndex].url;
+ window.open(this.points[this.hoveredIndex].url, "_blank", "noopener");
});
}
@@ -186,7 +206,8 @@ export class AnalyticsScatter {
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`;
+ 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();
@@ -194,9 +215,8 @@ export class AnalyticsScatter {
}
export const mountAnalytics = (): void => {
- const canvas = document.getElementById("analytics-chart");
const pointsScript = document.getElementById("analytics-points");
- if (!canvas || !pointsScript) return;
+ if (!pointsScript) return;
const data = JSON.parse(pointsScript.textContent ?? "") as AnalyticsPoint[];
if (data.length === 0) return;
const colorProbe = document.createElement("span");
@@ -215,9 +235,30 @@ export const mountAnalytics = (): void => {
mutedText: resolveColor("--color-muted"),
};
colorProbe.remove();
- const scatter = new AnalyticsScatter(canvas as HTMLCanvasElement, data, colors);
- window.addEventListener("resize", () => scatter.redraw());
- scatter.redraw();
+
+ 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();
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
index c7fb152..3c2a918 100644
--- a/web/frontend/types.d.ts
+++ b/web/frontend/types.d.ts
@@ -121,6 +121,7 @@ export interface AnalyticsPoint {
url: string;
speedKmh: number;
cotacolPer100Km: number;
+ movingHours: number;
}
export interface SyncProgress {