From f7264b8f594859c1430bd13e765db03002842d38 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 23 Aug 2026 11:18:06 +0200 Subject: 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. --- README.md | 2 +- web/frontend/analytics-logic.ts | 8 ++++ web/frontend/analytics.ts | 93 +++++++++++++++++++++++++++++------------ web/frontend/types.d.ts | 1 + web/templates.templ | 20 ++++++++- web/views.go | 2 + 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) {

Analytics

Ride analytics

-

One point per ride: average speed on the x axis, Cotacol per 100 km on the y axis.

+

One point per ride — first chart: average speed vs. Cotacol per 100 km; second chart: normalized difficulty (Cotacol per 100 km) vs. moving duration.

@@ -130,6 +130,24 @@ templ AnalyticsContent(data AnalyticsPageData) { Hover a point to inspect a ride. }
+
+
+

Chart

Normalized difficulty vs moving duration

+ if len(data.Points) > 0 { + { formatRideCount(len(data.Points)) } + } +
+ if len(data.Points) == 0 { + if data.Filter.Active { +
No rides match the selected filters. Clear filters.
+ } else { +
No eligible rides yet. Import rides to see the chart.
+ } + } else { +
+ Hover a point to inspect a ride. + } +
@templ.JSONScript("analytics-points", data.Points) 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 -- cgit v1.3