From d5de3a6fefbc57bccfb1fe688f9c190ccf87d58d Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 25 Aug 2026 13:25:35 +0200 Subject: analytics grouping --- web/frontend/analytics.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 4 deletions(-) (limited to 'web/frontend/analytics.ts') 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(); } }; -- cgit v1.3