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 --- README.md | 5 +- web/frontend/analytics-logic.test.ts | 62 ++++++++++++++++++- web/frontend/analytics-logic.ts | 112 +++++++++++++++++++++++++++++++++++ web/frontend/analytics.ts | 69 +++++++++++++++++++-- web/frontend/data-quality.ts | 9 +-- web/frontend/types.d.ts | 3 + web/static/style.css | 16 +++++ web/templates.templ | 14 +++++ web/views.go | 6 ++ 9 files changed, 283 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6175e44..161a7f3 100644 --- a/README.md +++ b/README.md @@ -154,16 +154,13 @@ Add `github.com/grafana/pyroscope-go` and call `pyroscope.Start` with `ServerAdd ## TODO -- processing takes components as input, not entities +- use interval - screaming archi + hexagon -- testing: ride string builder - Compute estimated power, speed - For a ride, scatter, one point per 100m segment. Slope vs speed - Plot speed and slope per segment, colored by heart rate - http://localhost:8080/rides/378: Repentance partial climb - Auto detect new climbs from raw map -- Analytics - - color split (by year, type, etc) - Perf - improve GPX parsing and Analyze - Compact binary or vectorized storage for root 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>): 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 = { + "": UNKNOWN_LABEL, + training: "Training", + indoor: "Indoor", + race: "Race", + transport: "Transport", +}; + +const bikeTypeLabels: Record = { + "": 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(); + 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[] => { + 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("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 { diff --git a/web/static/style.css b/web/static/style.css index 5442ea8..3c9c05b 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -47,6 +47,14 @@ --color-profile-3-6: oklch(58% 0.17 255deg); --color-profile-6-9: oklch(58% 0.2 25deg); --color-profile-9-plus: oklch(0% 0 0deg); + --color-category-1: oklch(65% 0.16 15deg); + --color-category-2: oklch(65% 0.16 60deg); + --color-category-3: oklch(65% 0.16 105deg); + --color-category-4: oklch(65% 0.16 150deg); + --color-category-5: oklch(65% 0.16 195deg); + --color-category-6: oklch(65% 0.16 240deg); + --color-category-7: oklch(65% 0.16 285deg); + --color-category-8: oklch(65% 0.16 330deg); } * { box-sizing: border-box; } @@ -137,6 +145,14 @@ select { border: 1px solid var(--color-input-border); border-radius: .55rem; pad .analytics-chart canvas { display: block; width: 100%; height: 26rem; border-radius: .65rem; background: var(--color-plot-surface); outline: none; } .analytics-chart canvas:focus-visible { box-shadow: 0 0 0 .2rem var(--color-focus); } .analytics-hover { display: block; min-height: 1.4rem; margin: .7rem 0 0; color: var(--color-muted); font-size: .9rem; } +.analytics-colorby-panel { margin-top: 1.25rem; } +.analytics-colorby-panel .section-heading { flex-wrap: wrap; } +.analytics-colorby-label { display: flex; flex-direction: column; gap: .35rem; font-size: .9rem; font-weight: 650; color: var(--color-forest); } +.analytics-colorby-label select { margin-top: .25rem; font-weight: 500; color: var(--color-foreground); } +.analytics-legend { display: flex; flex-wrap: wrap; gap: .5rem 1rem; margin: 0; padding: 0; list-style: none; } +.analytics-legend[hidden] { display: none; } +.analytics-legend-item { display: flex; align-items: center; gap: .4rem; font-size: .9rem; color: var(--color-foreground); } +.analytics-legend-swatch { width: .75rem; height: .75rem; border-radius: .2rem; background: currentcolor; } .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } .section-heading .eyebrow { margin-bottom: .2rem; } .section-heading h2 { margin: 0; font-size: 1.2rem; } diff --git a/web/templates.templ b/web/templates.templ index c0e6e18..6bf0154 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -143,6 +143,20 @@ templ AnalyticsContent(data AnalyticsPageData) { +
+
+

View

Color points by

+
+ + +

Chart

Speed vs. Cotacol per 100 km

diff --git a/web/views.go b/web/views.go index 8df860c..b3cab8b 100644 --- a/web/views.go +++ b/web/views.go @@ -38,6 +38,9 @@ type AnalyticsPoint struct { SpeedKmh float64 `json:"speedKmh"` CotacolPer100Km float64 `json:"cotacolPer100Km"` MovingHours float64 `json:"movingHours"` + RideType string `json:"rideType"` + BikeType string `json:"bikeType"` + Year int `json:"year"` } func buildAnalyticsPoints(items []ride.StoredRide) []AnalyticsPoint { @@ -54,6 +57,9 @@ func buildAnalyticsPoints(items []ride.StoredRide) []AnalyticsPoint { SpeedKmh: item.AverageSpeedMps * 3.6, CotacolPer100Km: cotacol * 100 / (item.DistanceM / 1000), MovingHours: float64(item.EffectiveMovingTimeS()) / 3600, + RideType: string(item.RideType), + BikeType: string(item.BikeType), + Year: item.StartDate.Year(), }) } return points -- cgit v1.3