ActivityCharts: switch slope tab to distance-weighted smoothing

Replace time-based rolling mean with a 400 m distance-weighted sliding-
window average (O(n), two advancing pointers) matching the spec in
SLOPE_COLORING.md. Slope values are now spatially consistent regardless
of riding speed. Smoothing buttons are hidden when the slope tab is
active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Davide Scaini
2026-05-18 19:03:56 +02:00
parent 6faf63c2cd
commit bbfab72138
+26 -14
View File
@@ -151,21 +151,33 @@
return '#a855f7'; // wall
}
// Distance-weighted sliding-window slope smoothing (400 m window).
// O(n) via two advancing pointers. Matches SLOPE_COLORING.md spec.
function computeSmoothedSlopes(pts: { d: number; e: number }[], windowM = 400): number[] {
if (pts.length < 2) return pts.map(() => 0);
const half = windowM / 2;
const segs: { d: number; slope: number; len: number }[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const len = pts[i + 1].d - pts[i].d;
segs.push({ d: (pts[i].d + pts[i + 1].d) / 2, slope: len > 0.5 ? (pts[i + 1].e - pts[i].e) / len * 100 : 0, len });
}
const slopes = new Array<number>(pts.length);
let lo = 0, hi = 0, sumW = 0, sumS = 0;
for (let i = 0; i < pts.length; i++) {
const center = pts[i].d;
while (hi < segs.length && segs[hi].d <= center + half) { sumW += segs[hi].len; sumS += segs[hi].slope * segs[hi].len; hi++; }
while (lo < hi && segs[lo].d < center - half) { sumW -= segs[lo].len; sumS -= segs[lo].slope * segs[lo].len; lo++; }
slopes[i] = sumW > 0.5 ? sumS / sumW : 0;
}
return slopes;
}
$: slopeData = (() => {
if (!dist_km) return null;
const d = dist_km;
const halfWin = SMOOTH_HALF[smoothMode];
const elev = halfWin > 0 ? rollingMean(timeseries.elevation_m, halfWin) : timeseries.elevation_m;
return data.map((pt, i) => {
const e0 = elev[i] ?? 0;
let slope = 0;
if (i < d.length - 1) {
const dDist = (d[i + 1] - d[i]) * 1000;
const e1 = elev[i + 1] ?? e0;
if (dDist > 0.5) slope = ((e1 - e0) / dDist) * 100;
}
return { ...pt, elevation: elev[i], slope };
});
const raw = timeseries.elevation_m;
const pts = dist_km.map((d, i) => ({ d: d * 1000, e: raw[i] ?? 0 }));
const slopes = computeSmoothedSlopes(pts);
return data.map((pt, i) => ({ ...pt, slope: slopes[i] }));
})();
function injectSlopeGradient(svg: SVGElement, pts: { dist_km: number | null; slope: number }[], w: number) {
@@ -526,7 +538,7 @@
</div>
{/if}
{#if chartType === 'line'}
{#if chartType === 'line' && activeTab !== 'slope'}
<div class="flex items-center gap-1">
{#each (['raw', '10s', '20s'] as SmoothMode[]) as sm}
<button