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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user