fix: stable y-axis range and sane dist_km in activity charts

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).
This commit is contained in:
Davide Scaini
2026-04-20 17:06:56 +02:00
parent 631e814d64
commit 104328bc50
+27 -7
View File
@@ -21,15 +21,20 @@
let chartEl: HTMLDivElement; let chartEl: HTMLDivElement;
let chart: SVGElement | null = null; 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 = (() => { $: dist_km = (() => {
if (!timeseries.speed_kmh.some(v => v != null)) return null; 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++) { for (let i = 1; i < timeseries.t.length; i++) {
const v = timeseries.speed_kmh[i]; const v = timeseries.speed_kmh[i];
const dt = timeseries.t[i] - timeseries.t[i - 1]; const dt = timeseries.t[i] - timeseries.t[i - 1];
const prev = d[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; return d;
})(); })();
@@ -79,6 +84,15 @@
$: dataMin = metricValues.length ? Math.min(...metricValues) : 0; $: dataMin = metricValues.length ? Math.min(...metricValues) : 0;
$: dataMax = metricValues.length ? Math.max(...metricValues) : 100; $: 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 // Range handles — reset whenever the metric or chart type changes
let trimMin = 0; let trimMin = 0;
let trimMax = 100; let trimMax = 100;
@@ -183,12 +197,18 @@
const tc = getThemeColors(); const tc = getThemeColors();
const marks: any[] = []; 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') { 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 { } else {
marks.push( marks.push(
Plot.areaY(data, { x, y: yKey, fill: color, fillOpacity: 0.15, 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: 'monotone-x' }), Plot.lineY(data, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve }),
); );
} }
@@ -216,7 +236,7 @@
width: w, height: h, marginLeft: 48, marginBottom: 32, width: w, height: h, marginLeft: 48, marginBottom: 32,
style: { background: 'transparent', color: tc.axis, fontSize: '11px' }, style: { background: 'transparent', color: tc.axis, fontSize: '11px' },
x: { label: null, tickFormat: xTickFormat, grid: false, ticks: 6 }, 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, marks,
}); });
} }