aboutsummaryrefslogtreecommitdiff
path: root/web/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'web/frontend')
-rw-r--r--web/frontend/analytics-logic.test.ts62
-rw-r--r--web/frontend/analytics-logic.ts112
-rw-r--r--web/frontend/analytics.ts69
-rw-r--r--web/frontend/data-quality.ts9
-rw-r--r--web/frontend/types.d.ts3
5 files changed, 246 insertions, 9 deletions
diff --git a/web/frontend/analytics-logic.test.ts b/web/frontend/analytics-logic.test.ts
index 74fdddd..0d1f789 100644
--- a/web/frontend/analytics-logic.test.ts
+++ b/web/frontend/analytics-logic.test.ts
@@ -1,6 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { formatCotacol, formatSpeed, niceStep, ticks } from "./analytics-logic.ts";
+import { formatCotacol, formatSpeed, groupRidePoints, groupedPoints, niceStep, ticks, type ColorBy } from "./analytics-logic.ts";
+import type { AnalyticsPoint } from "./types.js";
+
+const makePoint = (overrides: Partial<Pick<AnalyticsPoint, "year" | "rideType" | "bikeType">>): AnalyticsPoint => ({
+ id: 1,
+ name: "ride",
+ url: "/rides/1",
+ speedKmh: 20,
+ cotacolPer100Km: 50,
+ movingHours: 2,
+ year: 2020,
+ rideType: "",
+ bikeType: "",
+ ...overrides,
+});
test("computes a pleasant axis step within a 1-2-5-10 series", () => {
assert.equal(niceStep(25, 5), 5);
@@ -24,3 +38,49 @@ test("formats speed and cotacol per 100 km for axis labels", () => {
assert.equal(formatCotacol(300), "300");
assert.equal(formatCotacol(50), "50.0");
});
+
+test("grouping by none produces no legend groups", () => {
+ assert.deepEqual(groupRidePoints([makePoint({})], "none"), []);
+});
+
+test("groups by year ascending with compact color indices", () => {
+ const points = [makePoint({ year: 2021 }), makePoint({ year: 2020 }), makePoint({ year: 2021 })];
+ assert.deepEqual(groupRidePoints(points, "year"), [
+ { label: "2020", count: 1, colorIndex: 0 },
+ { label: "2021", count: 2, colorIndex: 1 },
+ ]);
+ assert.deepEqual(
+ groupedPoints(points, "year").map(({ point, colorIndex }) => [point.year, colorIndex]),
+ [
+ [2021, 1],
+ [2020, 0],
+ [2021, 1],
+ ],
+ );
+});
+
+test("groups by ride type in stable order with stable color indices", () => {
+ const points = [makePoint({ rideType: "race" }), makePoint({ rideType: "training" }), makePoint({ rideType: "race" })];
+ assert.deepEqual(groupRidePoints(points, "rideType"), [
+ { label: "Training", count: 1, colorIndex: 0 },
+ { label: "Race", count: 2, colorIndex: 2 },
+ ]);
+});
+
+test("empty ride and bike types land in an Unknown group", () => {
+ const points = [makePoint({ rideType: "" }), makePoint({ rideType: "race" })];
+ assert.deepEqual(groupRidePoints(points, "rideType"), [
+ { label: "Race", count: 1, colorIndex: 2 },
+ { label: "Unknown", count: 1, colorIndex: 4 },
+ ]);
+ const bikes = [makePoint({ bikeType: "" }), makePoint({ bikeType: "road" })];
+ assert.deepEqual(groupRidePoints(bikes, "bikeType"), [
+ { label: "Road", count: 1, colorIndex: 0 },
+ { label: "Unknown", count: 1, colorIndex: 2 },
+ ]);
+});
+
+test("color by mode is limited to the supported dimensions", () => {
+ const modes: ColorBy[] = ["none", "year", "rideType", "bikeType"];
+ assert.deepEqual(modes, ["none", "year", "rideType", "bikeType"]);
+});
diff --git a/web/frontend/analytics-logic.ts b/web/frontend/analytics-logic.ts
index e8a1559..5da8b80 100644
--- a/web/frontend/analytics-logic.ts
+++ b/web/frontend/analytics-logic.ts
@@ -1,3 +1,5 @@
+import type { AnalyticsPoint } from "./types.js";
+
export const niceStep = (range: number, target: number): number => {
if (range <= 0) return 1;
const ideal = range / target;
@@ -41,3 +43,113 @@ export const formatHours = (hours: number): string => {
if (m === 0) return `${h}h`;
return `${h}h${String(m).padStart(2, "0")}`;
};
+
+export type ColorBy = "none" | "year" | "rideType" | "bikeType";
+
+export interface RideGroup {
+ label: string;
+ count: number;
+ colorIndex: number;
+}
+
+export interface GroupedPoint {
+ point: AnalyticsPoint;
+ colorIndex: number;
+}
+
+const UNKNOWN_LABEL = "Unknown";
+
+const rideTypeLabels: Record<string, string> = {
+ "": UNKNOWN_LABEL,
+ training: "Training",
+ indoor: "Indoor",
+ race: "Race",
+ transport: "Transport",
+};
+
+const bikeTypeLabels: Record<string, string> = {
+ "": UNKNOWN_LABEL,
+ road: "Road",
+ mtb: "MTB",
+};
+
+const rideTypeOrder = ["training", "indoor", "race", "transport"];
+const bikeTypeOrder = ["road", "mtb"];
+
+export const analyticsRideTypeLabel = (value: string): string => rideTypeLabels[value] ?? value;
+export const analyticsBikeTypeLabel = (value: string): string => bikeTypeLabels[value] ?? value;
+
+const rideTypeColorIndex = (value: string): number => {
+ const index = rideTypeOrder.indexOf(value);
+ return index < 0 ? rideTypeOrder.length : index;
+};
+
+const bikeTypeColorIndex = (value: string): number => {
+ const index = bikeTypeOrder.indexOf(value);
+ return index < 0 ? bikeTypeOrder.length : index;
+};
+
+const yearColorIndex = (value: number, years: number[]): number => years.indexOf(value);
+
+export const groupRidePoints = (points: AnalyticsPoint[], colorBy: ColorBy): RideGroup[] => {
+ if (colorBy === "none") return [];
+ const counts = new Map<string, number>();
+ for (const point of points) {
+ const key = groupKey(colorBy, point);
+ counts.set(key, (counts.get(key) ?? 0) + 1);
+ }
+ return groupKeys(colorBy, counts).map((key, index) => ({
+ label: groupLabel(colorBy, key),
+ count: counts.get(key) ?? 0,
+ colorIndex: colorIndexForGroupKey(colorBy, key, index),
+ }));
+};
+
+const colorIndexForGroupKey = (colorBy: ColorBy, key: string, compactIndex: number): number => {
+ if (colorBy === "rideType") return rideTypeColorIndex(key);
+ if (colorBy === "bikeType") return bikeTypeColorIndex(key);
+ return compactIndex;
+};
+
+const groupKey = (colorBy: ColorBy, point: AnalyticsPoint): string => {
+ if (colorBy === "year") return String(point.year);
+ if (colorBy === "rideType") return point.rideType;
+ if (colorBy === "bikeType") return point.bikeType;
+ return "";
+};
+
+const groupLabel = (colorBy: ColorBy, key: string): string => {
+ if (colorBy === "year") return key;
+ if (colorBy === "rideType") return analyticsRideTypeLabel(key);
+ if (colorBy === "bikeType") return analyticsBikeTypeLabel(key);
+ return key;
+};
+
+const groupKeys = (colorBy: ColorBy, counts: Map<string, number>): string[] => {
+ const keys = [...counts.keys()];
+ if (colorBy === "rideType") {
+ return [...rideTypeOrder.filter((key) => counts.has(key)), ...(counts.has("") ? [""] : [])];
+ }
+ if (colorBy === "bikeType") {
+ return [...bikeTypeOrder.filter((key) => counts.has(key)), ...(counts.has("") ? [""] : [])];
+ }
+ if (colorBy === "year") {
+ return keys.sort((a, b) => Number(a) - Number(b));
+ }
+ return keys;
+};
+
+export const colorIndexForPoint = (point: AnalyticsPoint, colorBy: ColorBy): number => {
+ if (colorBy === "year") return 0;
+ if (colorBy === "rideType") return rideTypeColorIndex(point.rideType);
+ if (colorBy === "bikeType") return bikeTypeColorIndex(point.bikeType);
+ return 0;
+};
+
+export const groupedPoints = (points: AnalyticsPoint[], colorBy: ColorBy): GroupedPoint[] => {
+ if (colorBy === "year") {
+ const years = [...new Set(points.map((point) => point.year))].sort((a, b) => a - b);
+ return points.map((point) => ({ point, colorIndex: yearColorIndex(point.year, years) }));
+ }
+ return points.map((point) => ({ point, colorIndex: colorIndexForPoint(point, colorBy) }));
+};
diff --git a/web/frontend/analytics.ts b/web/frontend/analytics.ts
index ef2479d..8c7435f 100644
--- a/web/frontend/analytics.ts
+++ b/web/frontend/analytics.ts
@@ -1,4 +1,4 @@
-import { formatCotacol, formatHours, formatSpeed, ticks } from "./analytics-logic.js";
+import { formatCotacol, formatHours, formatSpeed, groupRidePoints, groupedPoints, ticks, type ColorBy } from "./analytics-logic.js";
import type { AnalyticsPoint } from "./types.js";
interface Plot {
@@ -15,11 +15,13 @@ interface AnalyticsColors {
forest: string;
accent: string;
mutedText: string;
+ categories: string[];
}
const POINT_RADIUS = 4;
const HOVER_RADIUS = 7;
const HIT_RADIUS = 12;
+const CATEGORY_COLOR_COUNT = 8;
interface ScatterOptions {
outputId: string;
@@ -39,6 +41,8 @@ export class AnalyticsScatter {
private readonly xUnit: string;
private plot: Plot | null;
private hoveredIndex: number;
+ private colorBy: ColorBy;
+ private pointColorIndices: number[];
private readonly minX: number;
private readonly maxX: number;
private readonly minCotacol: number;
@@ -57,6 +61,8 @@ export class AnalyticsScatter {
this.context = context;
this.plot = null;
this.hoveredIndex = -1;
+ this.colorBy = "none";
+ this.pointColorIndices = points.map(() => 0);
let maxX = 0;
let maxCotacol = 0;
for (const point of points) {
@@ -74,6 +80,12 @@ export class AnalyticsScatter {
this.draw();
}
+ setColorBy(colorBy: ColorBy): void {
+ this.colorBy = colorBy;
+ this.pointColorIndices = groupedPoints(this.points, colorBy).map(({ colorIndex }) => colorIndex);
+ this.draw();
+ }
+
private draw(): void {
const rect = this.canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
@@ -129,7 +141,7 @@ export class AnalyticsScatter {
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.fillStyle = this.pointColor(index);
this.context.beginPath();
this.context.arc(
this.xForValue(this.xAccessor(point), plot),
@@ -142,6 +154,12 @@ export class AnalyticsScatter {
}
}
+ private pointColor(index: number): string {
+ if (this.colorBy === "none" || this.colors.categories.length === 0) return this.colors.accent;
+ const colorIndex = this.pointColorIndices[index];
+ return this.colors.categories[colorIndex % this.colors.categories.length];
+ }
+
private drawHover(plot: Plot): void {
const point = this.points[this.hoveredIndex];
const x = this.xForValue(this.xAccessor(point), plot);
@@ -214,6 +232,28 @@ export class AnalyticsScatter {
}
}
+const renderLegend = (points: AnalyticsPoint[], colorBy: ColorBy, categories: string[]): void => {
+ const legend = document.getElementById("analytics-legend");
+ if (!legend) return;
+ legend.replaceChildren();
+ if (colorBy === "none") {
+ legend.hidden = true;
+ return;
+ }
+ for (const group of groupRidePoints(points, colorBy)) {
+ const item = document.createElement("li");
+ item.className = "analytics-legend-item";
+ const swatch = document.createElement("span");
+ swatch.className = "analytics-legend-swatch";
+ swatch.style.backgroundColor = categories.length > 0 ? categories[group.colorIndex % categories.length] : "currentColor";
+ const label = document.createElement("span");
+ label.textContent = `${group.label} ยท ${group.count}`;
+ item.append(swatch, label);
+ legend.append(item);
+ }
+ legend.hidden = false;
+};
+
export const mountAnalytics = (): void => {
const pointsScript = document.getElementById("analytics-points");
if (!pointsScript) return;
@@ -226,6 +266,11 @@ export const mountAnalytics = (): void => {
colorProbe.style.color = `var(${name})`;
return getComputedStyle(colorProbe).color;
};
+ const categories: string[] = [];
+ for (let index = 1; index <= CATEGORY_COLOR_COUNT; index++) {
+ const value = resolveColor(`--color-category-${index}`);
+ if (value) categories.push(value);
+ }
const colors: AnalyticsColors = {
surface: resolveColor("--color-plot-surface"),
grid: resolveColor("--color-plot-grid"),
@@ -233,9 +278,12 @@ export const mountAnalytics = (): void => {
forest: resolveColor("--color-forest"),
accent: resolveColor("--color-accent"),
mutedText: resolveColor("--color-muted"),
+ categories,
};
colorProbe.remove();
+ const scatters: AnalyticsScatter[] = [];
+
const speedCanvas = document.getElementById("analytics-chart") as HTMLCanvasElement | null;
if (speedCanvas) {
const scatter = new AnalyticsScatter(speedCanvas, data, colors, {
@@ -245,7 +293,7 @@ export const mountAnalytics = (): void => {
xUnit: "km/h",
});
window.addEventListener("resize", () => scatter.redraw());
- scatter.redraw();
+ scatters.push(scatter);
}
const durationCanvas = document.getElementById("analytics-duration-chart") as HTMLCanvasElement | null;
@@ -257,7 +305,20 @@ export const mountAnalytics = (): void => {
xUnit: "",
});
window.addEventListener("resize", () => scatter.redraw());
- scatter.redraw();
+ scatters.push(scatter);
+ }
+
+ for (const scatter of scatters) scatter.redraw();
+
+ const colorSelect = document.getElementById("analytics-color-by") as HTMLSelectElement | null;
+ if (colorSelect) {
+ const applyColorBy = (): void => {
+ const colorBy = colorSelect.value as ColorBy;
+ for (const scatter of scatters) scatter.setColorBy(colorBy);
+ renderLegend(data, colorBy, colors.categories);
+ };
+ colorSelect.addEventListener("change", applyColorBy);
+ applyColorBy();
}
};
diff --git a/web/frontend/data-quality.ts b/web/frontend/data-quality.ts
index 32c3563..20112c0 100644
--- a/web/frontend/data-quality.ts
+++ b/web/frontend/data-quality.ts
@@ -3,6 +3,7 @@ function initDataQuality(): void {
const selectAll = document.getElementById("data-quality-select-all") as HTMLInputElement | null;
const counter = document.getElementById("data-quality-selected");
if (!form || !selectAll) return;
+ const selectAllInput: HTMLInputElement = selectAll;
const checkboxes = Array.from(form.querySelectorAll<HTMLInputElement>("input.data-quality-checkbox"));
function updateCount(): void {
@@ -10,12 +11,12 @@ function initDataQuality(): void {
if (counter) counter.textContent = `${selected} selected`;
const allChecked = selected === checkboxes.length && checkboxes.length > 0;
const noneChecked = selected === 0;
- selectAll!.checked = allChecked;
- selectAll!.indeterminate = !allChecked && !noneChecked;
+ selectAllInput.checked = allChecked;
+ selectAllInput.indeterminate = !allChecked && !noneChecked;
}
- selectAll!.addEventListener("change", () => {
- for (const cb of checkboxes) cb.checked = selectAll!.checked;
+ selectAllInput.addEventListener("change", () => {
+ for (const cb of checkboxes) cb.checked = selectAllInput.checked;
updateCount();
});
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
index 6ff3dcf..83b3921 100644
--- a/web/frontend/types.d.ts
+++ b/web/frontend/types.d.ts
@@ -122,6 +122,9 @@ export interface AnalyticsPoint {
speedKmh: number;
cotacolPer100Km: number;
movingHours: number;
+ rideType: string;
+ bikeType: string;
+ year: number;
}
export interface RateLimit {