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