diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-25 13:25:35 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-25 13:25:35 +0200 |
| commit | d5de3a6fefbc57bccfb1fe688f9c190ccf87d58d (patch) | |
| tree | 4a368e1331e04348353a9d65b94a07f4e94ee6bf /web/frontend | |
| parent | 87e29e1eb5a113fbb40d452a193219a40780c302 (diff) | |
analytics grouping
Diffstat (limited to 'web/frontend')
| -rw-r--r-- | web/frontend/analytics-logic.test.ts | 62 | ||||
| -rw-r--r-- | web/frontend/analytics-logic.ts | 112 | ||||
| -rw-r--r-- | web/frontend/analytics.ts | 69 | ||||
| -rw-r--r-- | web/frontend/data-quality.ts | 9 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 3 |
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 { |
