From 104328bc50f449c82fbbc91a723164a2bc9f8459 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Mon, 20 Apr 2026 17:06:56 +0200 Subject: [PATCH] fix: stable y-axis range and sane dist_km in activity charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in the time↔distance x-axis toggle: 1. GPS speed glitches (e.g. a 1-second spike at 222 km/h) were accumulated into dist_km, pushing all subsequent points ~60 m too far right on the distance axis and compressing the rest of the chart. Cap speed at 150 km/h during dist_km integration; values above that are treated as 0 movement. 2. Observable Plot auto-infers the y domain from plottable points only. When x-mode changes, which points are "plottable" changes too, so the y axis range silently shifted between time and distance views. Fix by computing lineDomainMin/Max once from the full dataset and passing an explicit domain to Plot. 3. monotone-x curve requires strictly increasing x. In distance mode, stopped segments produce consecutive points with identical dist_km, causing NaN Bézier control points and visual artifacts. Use linear curve for distance mode (data is dense enough that it looks smooth). --- site/src/components/ActivityCharts.svelte | 34 ++++++++++++++++++----- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/site/src/components/ActivityCharts.svelte b/site/src/components/ActivityCharts.svelte index c23eef4..d7541ac 100644 --- a/site/src/components/ActivityCharts.svelte +++ b/site/src/components/ActivityCharts.svelte @@ -21,15 +21,20 @@ let chartEl: HTMLDivElement; let chart: SVGElement | null = null; - // Cumulative distance in km, integrated from speed_kmh + // Cumulative distance in km, integrated from speed_kmh. + // Speeds > 150 km/h are treated as 0 (GPS glitch guard) — otherwise a single + // 1-second spike at 220 km/h pushes all subsequent points ~60 m too far right + // on the distance axis and stretches the chart out of proportion. $: dist_km = (() => { if (!timeseries.speed_kmh.some(v => v != null)) return null; - const d: (number | null)[] = [0]; + const d: number[] = [0]; for (let i = 1; i < timeseries.t.length; i++) { const v = timeseries.speed_kmh[i]; const dt = timeseries.t[i] - timeseries.t[i - 1]; const prev = d[i - 1]; - d.push(v != null && prev != null ? prev + v * dt / 3600 : prev); + // Clamp to 150 km/h; treat null or out-of-range as 0 movement + const vSafe = (v != null && v > 0 && v <= 150) ? v : 0; + d.push(prev + vSafe * dt / 3600); } return d; })(); @@ -79,6 +84,15 @@ $: dataMin = metricValues.length ? Math.min(...metricValues) : 0; $: dataMax = metricValues.length ? Math.max(...metricValues) : 100; + // Explicit y domain for the line chart. + // We compute this once from all data and pass it explicitly to Plot so that + // switching x-axis mode (time ↔ distance) never changes the y range — Observable + // Plot auto-infers different domains when the x-channel changes because it only + // considers plottable points, but we want the scale to stay anchored to the + // full dataset. areaY extends down to 0, so include 0 in the minimum. + $: lineDomainMin = Math.min(0, dataMin); + $: lineDomainMax = dataMax; + // Range handles — reset whenever the metric or chart type changes let trimMin = 0; let trimMax = 100; @@ -183,12 +197,18 @@ const tc = getThemeColors(); const marks: any[] = []; + // monotone-x requires strictly increasing x. In time mode t is always + // strictly increasing. In distance mode, stopped segments produce many + // consecutive points with identical dist_km, which causes NaN Bézier + // control points and visual artifacts — use linear instead. + const curve = xMode === 'distance' ? 'linear' : 'monotone-x'; + if (activeTab === 'cadence') { - marks.push(Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotone-x' })); + marks.push(Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve })); } else { marks.push( - Plot.areaY(data, { x, y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotone-x' }), - Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotone-x' }), + Plot.areaY(data, { x, y: yKey, fill: color, fillOpacity: 0.15, curve }), + Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve }), ); } @@ -216,7 +236,7 @@ width: w, height: h, marginLeft: 48, marginBottom: 32, style: { background: 'transparent', color: tc.axis, fontSize: '11px' }, x: { label: null, tickFormat: xTickFormat, grid: false, ticks: 6 }, - y: { label: yLabel, grid: true, tickCount: 4 }, + y: { label: yLabel, grid: true, tickCount: 4, domain: [lineDomainMin, lineDomainMax] }, marks, }); }