diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/frontend/analytics-logic.ts | 8 | ||||
| -rw-r--r-- | web/frontend/analytics.ts | 93 | ||||
| -rw-r--r-- | web/frontend/types.d.ts | 1 | ||||
| -rw-r--r-- | web/templates.templ | 20 | ||||
| -rw-r--r-- | web/views.go | 2 |
5 files changed, 97 insertions, 27 deletions
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 |
