aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-08-23 11:18:06 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-08-23 11:18:06 +0200
commitf7264b8f594859c1430bd13e765db03002842d38 (patch)
treeb4cb9e008b8ba8567ead0a6263b69deb8a8b2f8d
parent53615a05708ff852af42905e2b5ced66ba70c43d (diff)
Add normalized difficulty versus moving duration to analytics
Extend analytics with a second scatter plotting Cotacol per 100 km against moving hours, keep speed versus Cotacol as first chart, render both canvases from shared points, open ride detail in a new tab on dot click, and keep all website copy in English.
-rw-r--r--README.md2
-rw-r--r--web/frontend/analytics-logic.ts8
-rw-r--r--web/frontend/analytics.ts93
-rw-r--r--web/frontend/types.d.ts1
-rw-r--r--web/templates.templ20
-rw-r--r--web/views.go2
6 files changed, 98 insertions, 28 deletions
diff --git a/README.md b/README.md
index b108792..be72c1b 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ A Go toolkit for analyzing cycling rides from GPX exports: parse rides, detect c
- **Computed moving time** — computes moving time from the GPX track with a speed-threshold algorithm, persists it as the canonical value (keeping Strava's as `external_moving_time_s`), and benchmarks against that reference via `-backtest`
- **Ride detail maps** — opens a stored ride with its recorded route on an interactive OpenStreetMap map
- **Strava import progress** — streams live import progress with an aggregate progress bar and prevents overlapping syncs
+- **Analytics second chart** — plots normalized difficulty (Cotacol per 100 km) against moving duration alongside speed vs. Cotacol
## Getting started
@@ -109,7 +110,6 @@ TypeScript checks can also be run directly with `npm run check`, covering format
- http://localhost:8080/rides/368: does not contain Pont Rout
- http://localhost:8080/rides/6: Cat 4 inside Chemin des Lauriers (embrassé)
- Compute estimated power, speed
-- Plot de difficultés normalisées contre moving duration
- Categorised rides like to-dos
- Compact binary or vectorized storage for root
- For a ride, scatter, one point per 100m segment. Slope vs speed
diff --git a/web/frontend/analytics-logic.ts b/web/frontend/analytics-logic.ts
index 7a53944..e8a1559 100644
--- a/web/frontend/analytics-logic.ts
+++ b/web/frontend/analytics-logic.ts
@@ -33,3 +33,11 @@ const roundStep = (value: number): number => {
export const formatSpeed = (speed: number): string => `${speed.toFixed(1)}`;
export const formatCotacol = (cotacol: number): string => (cotacol >= 100 ? `${Math.round(cotacol)}` : `${cotacol.toFixed(1)}`);
+
+export const formatHours = (hours: number): string => {
+ if (hours < 1) return `${Math.round(hours * 60)}m`;
+ const h = Math.floor(hours);
+ const m = Math.round((hours - h) * 60);
+ if (m === 0) return `${h}h`;
+ return `${h}h${String(m).padStart(2, "0")}`;
+};
diff --git a/web/frontend/analytics.ts b/web/frontend/analytics.ts
index 5dfad52..ef2479d 100644
--- a/web/frontend/analytics.ts
+++ b/web/frontend/analytics.ts
@@ -1,4 +1,4 @@
-import { formatCotacol, formatSpeed, ticks } from "./analytics-logic.js";
+import { formatCotacol, formatHours, formatSpeed, ticks } from "./analytics-logic.js";
import type { AnalyticsPoint } from "./types.js";
interface Plot {
@@ -20,38 +20,52 @@ interface AnalyticsColors {
const POINT_RADIUS = 4;
const HOVER_RADIUS = 7;
const HIT_RADIUS = 12;
+
+interface ScatterOptions {
+ outputId: string;
+ xAccessor: (point: AnalyticsPoint) => number;
+ xFormatter: (value: number) => string;
+ xUnit: string;
+}
+
export class AnalyticsScatter {
private readonly canvas: HTMLCanvasElement;
private readonly points: AnalyticsPoint[];
private readonly colors: AnalyticsColors;
private readonly output: HTMLElement | null;
private readonly context: CanvasRenderingContext2D;
+ private readonly xAccessor: (point: AnalyticsPoint) => number;
+ private readonly xFormatter: (value: number) => string;
+ private readonly xUnit: string;
private plot: Plot | null;
private hoveredIndex: number;
- private readonly minSpeed: number;
- private readonly maxSpeed: number;
+ private readonly minX: number;
+ private readonly maxX: number;
private readonly minCotacol: number;
private readonly maxCotacol: number;
- constructor(canvas: HTMLCanvasElement, points: AnalyticsPoint[], colors: AnalyticsColors) {
+ constructor(canvas: HTMLCanvasElement, points: AnalyticsPoint[], colors: AnalyticsColors, options: ScatterOptions) {
this.canvas = canvas;
this.points = points;
this.colors = colors;
- this.output = document.getElementById("analytics-hover");
+ this.output = document.getElementById(options.outputId);
+ this.xAccessor = options.xAccessor;
+ this.xFormatter = options.xFormatter;
+ this.xUnit = options.xUnit;
const context = canvas.getContext("2d");
if (!context) throw new Error("Analytics canvas is unavailable");
this.context = context;
this.plot = null;
this.hoveredIndex = -1;
- let maxSpeed = 0;
+ let maxX = 0;
let maxCotacol = 0;
for (const point of points) {
- maxSpeed = Math.max(maxSpeed, point.speedKmh);
+ maxX = Math.max(maxX, this.xAccessor(point));
maxCotacol = Math.max(maxCotacol, point.cotacolPer100Km);
}
- this.minSpeed = 0;
+ this.minX = 0;
this.minCotacol = 0;
- this.maxSpeed = maxSpeed * 1.08 || 1;
+ this.maxX = maxX * 1.08 || 1;
this.maxCotacol = maxCotacol * 1.08 || 1;
this.bindEvents();
}
@@ -79,9 +93,9 @@ export class AnalyticsScatter {
private drawGrid(plot: Plot): void {
this.context.font = "11px system-ui, sans-serif";
- const xTicks = ticks(this.minSpeed, this.maxSpeed);
+ const xTicks = ticks(this.minX, this.maxX);
for (const tick of xTicks) {
- const x = this.xForSpeed(tick, plot);
+ const x = this.xForValue(tick, plot);
this.context.strokeStyle = this.colors.grid;
this.context.lineWidth = 1;
this.context.beginPath();
@@ -91,11 +105,11 @@ export class AnalyticsScatter {
this.context.fillStyle = this.colors.subtle;
this.context.textAlign = "center";
this.context.textBaseline = "top";
- this.context.fillText(formatSpeed(tick), x, plot.bottom + 8);
+ this.context.fillText(this.xFormatter(tick), x, plot.bottom + 8);
}
this.context.fillStyle = this.colors.mutedText;
this.context.textAlign = "right";
- this.context.fillText("km/h", plot.right, plot.bottom + 8);
+ this.context.fillText(this.xUnit, plot.right, plot.bottom + 8);
const yTicks = ticks(this.minCotacol, this.maxCotacol);
for (const tick of yTicks) {
const y = this.yForCotacol(tick, plot);
@@ -117,14 +131,20 @@ export class AnalyticsScatter {
const point = this.points[index];
this.context.fillStyle = this.colors.accent;
this.context.beginPath();
- this.context.arc(this.xForSpeed(point.speedKmh, plot), this.yForCotacol(point.cotacolPer100Km, plot), POINT_RADIUS, 0, 2 * Math.PI);
+ this.context.arc(
+ this.xForValue(this.xAccessor(point), plot),
+ this.yForCotacol(point.cotacolPer100Km, plot),
+ POINT_RADIUS,
+ 0,
+ 2 * Math.PI,
+ );
this.context.fill();
}
}
private drawHover(plot: Plot): void {
const point = this.points[this.hoveredIndex];
- const x = this.xForSpeed(point.speedKmh, plot);
+ const x = this.xForValue(this.xAccessor(point), plot);
const y = this.yForCotacol(point.cotacolPer100Km, plot);
this.context.strokeStyle = this.colors.forest;
this.context.lineWidth = 2;
@@ -133,9 +153,9 @@ export class AnalyticsScatter {
this.context.stroke();
}
- private xForSpeed(speed: number, plot: Plot): number {
- const span = Math.max(this.maxSpeed - this.minSpeed, 1);
- return plot.left + ((speed - this.minSpeed) / span) * (plot.right - plot.left);
+ private xForValue(value: number, plot: Plot): number {
+ const span = Math.max(this.maxX - this.minX, 1);
+ return plot.left + ((value - this.minX) / span) * (plot.right - plot.left);
}
private yForCotacol(cotacol: number, plot: Plot): number {
@@ -149,7 +169,7 @@ export class AnalyticsScatter {
let best = Infinity;
for (let index = 0; index < this.points.length; index++) {
const point = this.points[index];
- const dx = this.xForSpeed(point.speedKmh, this.plot) - x;
+ const dx = this.xForValue(this.xAccessor(point), this.plot) - x;
const dy = this.yForCotacol(point.cotacolPer100Km, this.plot) - y;
const distance = Math.hypot(dx, dy);
if (distance < best) {
@@ -174,7 +194,7 @@ export class AnalyticsScatter {
this.canvas.addEventListener("pointercancel", () => this.setHovered(-1));
this.canvas.addEventListener("click", () => {
if (this.hoveredIndex < 0) return;
- window.location.href = this.points[this.hoveredIndex].url;
+ window.open(this.points[this.hoveredIndex].url, "_blank", "noopener");
});
}
@@ -186,7 +206,8 @@ export class AnalyticsScatter {
this.output.textContent = "Hover a point to inspect a ride.";
} else {
const point = this.points[index];
- this.output.textContent = `${point.name} · ${formatSpeed(point.speedKmh)} km/h · ${formatCotacol(point.cotacolPer100Km)} Cotacol/100km · click for details`;
+ const xLabel = this.xUnit ? `${this.xFormatter(this.xAccessor(point))} ${this.xUnit}` : this.xFormatter(this.xAccessor(point));
+ this.output.textContent = `${point.name} · ${xLabel} · ${formatCotacol(point.cotacolPer100Km)} Cotacol/100km · click for details`;
}
}
this.draw();
@@ -194,9 +215,8 @@ export class AnalyticsScatter {
}
export const mountAnalytics = (): void => {
- const canvas = document.getElementById("analytics-chart");
const pointsScript = document.getElementById("analytics-points");
- if (!canvas || !pointsScript) return;
+ if (!pointsScript) return;
const data = JSON.parse(pointsScript.textContent ?? "") as AnalyticsPoint[];
if (data.length === 0) return;
const colorProbe = document.createElement("span");
@@ -215,9 +235,30 @@ export const mountAnalytics = (): void => {
mutedText: resolveColor("--color-muted"),
};
colorProbe.remove();
- const scatter = new AnalyticsScatter(canvas as HTMLCanvasElement, data, colors);
- window.addEventListener("resize", () => scatter.redraw());
- scatter.redraw();
+
+ const speedCanvas = document.getElementById("analytics-chart") as HTMLCanvasElement | null;
+ if (speedCanvas) {
+ const scatter = new AnalyticsScatter(speedCanvas, data, colors, {
+ outputId: "analytics-hover",
+ xAccessor: (point) => point.speedKmh,
+ xFormatter: formatSpeed,
+ xUnit: "km/h",
+ });
+ window.addEventListener("resize", () => scatter.redraw());
+ scatter.redraw();
+ }
+
+ const durationCanvas = document.getElementById("analytics-duration-chart") as HTMLCanvasElement | null;
+ if (durationCanvas) {
+ const scatter = new AnalyticsScatter(durationCanvas, data, colors, {
+ outputId: "analytics-duration-hover",
+ xAccessor: (point) => point.movingHours,
+ xFormatter: formatHours,
+ xUnit: "",
+ });
+ window.addEventListener("resize", () => scatter.redraw());
+ scatter.redraw();
+ }
};
mountAnalytics();
diff --git a/web/frontend/types.d.ts b/web/frontend/types.d.ts
index c7fb152..3c2a918 100644
--- a/web/frontend/types.d.ts
+++ b/web/frontend/types.d.ts
@@ -121,6 +121,7 @@ export interface AnalyticsPoint {
url: string;
speedKmh: number;
cotacolPer100Km: number;
+ movingHours: number;
}
export interface SyncProgress {
diff --git a/web/templates.templ b/web/templates.templ
index 0660d07..9be2daf 100644
--- a/web/templates.templ
+++ b/web/templates.templ
@@ -100,7 +100,7 @@ templ AnalyticsPage(data AnalyticsPageData) {
templ AnalyticsContent(data AnalyticsPageData) {
<p class="eyebrow">Analytics</p>
<h1>Ride analytics</h1>
- <p class="lead">One point per ride: average speed on the x axis, Cotacol per 100 km on the y axis.</p>
+ <p class="lead">One point per ride — first chart: average speed vs. Cotacol per 100 km; second chart: normalized difficulty (Cotacol per 100 km) vs. moving duration.</p>
<section class="panel analytics-filters-panel">
<form id="analytics-filters" class="ride-filters" method="get" action="/analytics" aria-label="Analytics filters">
<div class="section-heading">
@@ -130,6 +130,24 @@ templ AnalyticsContent(data AnalyticsPageData) {
<output id="analytics-hover" class="analytics-hover" aria-live="polite">Hover a point to inspect a ride.</output>
}
</section>
+ <section class="panel analytics-panel">
+ <div class="section-heading">
+ <div><p class="eyebrow">Chart</p><h2>Normalized difficulty vs moving duration</h2></div>
+ if len(data.Points) > 0 {
+ <span class="section-count" data-analytics-duration-count>{ formatRideCount(len(data.Points)) }</span>
+ }
+ </div>
+ if len(data.Points) == 0 {
+ if data.Filter.Active {
+ <div class="empty">No rides match the selected filters. <a href={ templ.URL(data.Filter.ResetURL) }>Clear filters.</a></div>
+ } else {
+ <div class="empty">No eligible rides yet. <a href="/sync">Import rides to see the chart.</a></div>
+ }
+ } else {
+ <div class="analytics-chart"><canvas id="analytics-duration-chart" tabindex="0" role="img" aria-label="Scatter plot of normalized difficulty against moving duration"></canvas></div>
+ <output id="analytics-duration-hover" class="analytics-hover" aria-live="polite">Hover a point to inspect a ride.</output>
+ }
+ </section>
@templ.JSONScript("analytics-points", data.Points)
<script type="module" src="/static/ride-filters.js"></script>
<script type="module" src="/static/analytics.js"></script>
diff --git a/web/views.go b/web/views.go
index 64afb82..6fd5adf 100644
--- a/web/views.go
+++ b/web/views.go
@@ -36,6 +36,7 @@ type AnalyticsPoint struct {
URL string `json:"url"`
SpeedKmh float64 `json:"speedKmh"`
CotacolPer100Km float64 `json:"cotacolPer100Km"`
+ MovingHours float64 `json:"movingHours"`
}
func buildAnalyticsPoints(items []ride.StoredRide) []AnalyticsPoint {
@@ -51,6 +52,7 @@ func buildAnalyticsPoints(items []ride.StoredRide) []AnalyticsPoint {
URL: rideDetailURL(item.ID),
SpeedKmh: item.AverageSpeedMps * 3.6,
CotacolPer100Km: cotacol * 100 / (item.DistanceM / 1000),
+ MovingHours: float64(item.EffectiveMovingTimeS()) / 3600,
})
}
return points