aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md5
-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
-rw-r--r--web/static/style.css16
-rw-r--r--web/templates.templ14
-rw-r--r--web/views.go6
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<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 {
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) {
</div>
</form>
</section>
+ <section class="panel analytics-colorby-panel">
+ <div class="section-heading">
+ <div><p class="eyebrow">View</p><h2>Color points by</h2></div>
+ </div>
+ <label class="analytics-colorby" for="analytics-color-by">Split points by
+ <select id="analytics-color-by">
+ <option value="none" selected>None</option>
+ <option value="year">Year</option>
+ <option value="rideType">Ride type</option>
+ <option value="bikeType">Bike type</option>
+ </select>
+ </label>
+ <ul id="analytics-legend" class="analytics-legend" hidden></ul>
+ </section>
<section class="panel analytics-panel">
<div class="section-heading">
<div><p class="eyebrow">Chart</p><h2>Speed vs. Cotacol per 100 km</h2></div>
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