ActivityCharts: add slope coloring tab to elevation profile

New "Slope" tab colours the filled area and stroke line with an SVG
linearGradient driven by per-point slope data (green→yellow→orange→red→
purple scale). Slope is computed from smoothed elevation + cumulative
distance, reusing the existing raw/10s/20s smoothing controls. The
hover tooltip shows slope % (in slope colour) and elevation. Tab is
enabled only when both elevation and distance data are present; the
X-mode and histogram toggles are hidden for this tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Davide Scaini
2026-05-18 18:57:55 +02:00
parent c0f6c4da6d
commit 6faf63c2cd
+121 -23
View File
@@ -11,7 +11,7 @@
const HR_ZONE_COLORS = ['#60a5fa', '#4ade80', '#facc15', '#fb923c', '#f87171']; const HR_ZONE_COLORS = ['#60a5fa', '#4ade80', '#facc15', '#fb923c', '#f87171'];
const PWR_ZONE_COLORS = ['#60a5fa', '#34d399', '#facc15', '#fb923c', '#f87171', '#c084fc', '#f43f5e']; const PWR_ZONE_COLORS = ['#60a5fa', '#34d399', '#facc15', '#fb923c', '#f87171', '#c084fc', '#f43f5e'];
type Tab = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power'; type Tab = 'elevation' | 'slope' | 'speed' | 'hr' | 'cadence' | 'power';
type XMode = 'time' | 'distance'; type XMode = 'time' | 'distance';
type ChartType = 'line' | 'histogram'; type ChartType = 'line' | 'histogram';
type SmoothMode = 'raw' | '10s' | '20s'; type SmoothMode = 'raw' | '10s' | '20s';
@@ -59,9 +59,11 @@
$: hasSpeed = timeseries.speed_kmh.some(v => v != null); $: hasSpeed = timeseries.speed_kmh.some(v => v != null);
$: hasPower = timeseries.power_w.some(v => v != null); $: hasPower = timeseries.power_w.some(v => v != null);
$: hasDistance = dist_km !== null; $: hasDistance = dist_km !== null;
$: hasSlope = hasElevation && hasDistance;
const tabLabels: Record<Tab, string> = { const tabLabels: Record<Tab, string> = {
elevation: 'Elevation', elevation: 'Elevation',
slope: 'Slope',
speed: 'Speed', speed: 'Speed',
hr: 'Heart Rate', hr: 'Heart Rate',
cadence: 'Cadence', cadence: 'Cadence',
@@ -70,6 +72,7 @@
const tabMeta: Record<Tab, { color: string; yLabel: string; yKey: string }> = { const tabMeta: Record<Tab, { color: string; yLabel: string; yKey: string }> = {
elevation: { color: '#00c8ff', yLabel: 'Elevation (m)', yKey: 'elevation' }, elevation: { color: '#00c8ff', yLabel: 'Elevation (m)', yKey: 'elevation' },
slope: { color: '#e4e4e7', yLabel: 'Elevation (m)', yKey: 'elevation' },
speed: { color: '#ff6b35', yLabel: 'Speed (km/h)', yKey: 'speed' }, speed: { color: '#ff6b35', yLabel: 'Speed (km/h)', yKey: 'speed' },
hr: { color: '#f87171', yLabel: 'Heart Rate (bpm)', yKey: 'hr' }, hr: { color: '#f87171', yLabel: 'Heart Rate (bpm)', yKey: 'hr' },
cadence: { color: '#a78bfa', yLabel: 'Cadence (rpm)', yKey: 'cadence' }, cadence: { color: '#a78bfa', yLabel: 'Cadence (rpm)', yKey: 'cadence' },
@@ -138,6 +141,63 @@
// Reset when switching away from a zone-capable metric or leaving histogram // Reset when switching away from a zone-capable metric or leaving histogram
$: if (!canAlignZones) alignZones = false; $: if (!canAlignZones) alignZones = false;
// ── Slope coloring ───────────────────────────────────────────────────────
function slopeColor(pct: number): string {
if (pct < -1) return '#60a5fa'; // descent
if (pct < 2) return '#4ade80'; // flat
if (pct < 5) return '#facc15'; // easy
if (pct < 8) return '#f97316'; // moderate
if (pct < 12) return '#ef4444'; // steep
return '#a855f7'; // wall
}
$: 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 };
});
})();
function injectSlopeGradient(svg: SVGElement, pts: { dist_km: number | null; slope: number }[], w: number) {
const totalKm = pts[pts.length - 1].dist_km ?? 0;
if (totalKm === 0) return;
const N = Math.min(pts.length - 1, 80);
const stops: string[] = [];
for (let s = 0; s <= N; s++) {
const target = (s / N) * totalKm;
let slope = 0;
for (let i = 0; i < pts.length - 1; i++) {
const d0 = pts[i].dist_km ?? 0;
const d1 = pts[i + 1].dist_km ?? 0;
if (target >= d0 && target <= d1 + 1e-9) {
const span = d1 - d0;
slope = span > 1e-9
? pts[i].slope + ((target - d0) / span) * (pts[i + 1].slope - pts[i].slope)
: pts[i].slope;
break;
}
}
stops.push(`<stop offset="${((s / N) * 100).toFixed(2)}%" stop-color="${slopeColor(slope)}" />`);
}
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
defs.innerHTML = `<linearGradient id="slope-grad" gradientUnits="userSpaceOnUse" x1="48" y1="0" x2="${w - 20}" y2="0">${stops.join('')}</linearGradient>`;
svg.insertBefore(defs, svg.firstChild);
const areaG = svg.querySelector('[aria-label="area"]') as SVGElement | null;
if (areaG) { areaG.setAttribute('fill', 'url(#slope-grad)'); areaG.setAttribute('fill-opacity', '0.45'); }
const lineG = svg.querySelector('[aria-label="line"]') as SVGElement | null;
if (lineG) lineG.setAttribute('stroke', 'url(#slope-grad)');
}
// ── Theme-aware colours ────────────────────────────────────────────────── // ── Theme-aware colours ──────────────────────────────────────────────────
function getThemeColors() { function getThemeColors() {
const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; const isDark = document.documentElement.getAttribute('data-theme') !== 'light';
@@ -200,17 +260,18 @@
const tabEnabled = const tabEnabled =
activeTab === 'elevation' ? hasElevation : activeTab === 'elevation' ? hasElevation :
activeTab === 'slope' ? hasSlope :
activeTab === 'speed' ? hasSpeed : activeTab === 'speed' ? hasSpeed :
activeTab === 'hr' ? hasHR : activeTab === 'hr' ? hasHR :
activeTab === 'cadence' ? hasCadence : activeTab === 'cadence' ? hasCadence :
hasPower; hasPower;
if (!tabEnabled) return; if (!tabEnabled) return;
chart = chartType === 'histogram' chart = (chartType === 'histogram' && activeTab !== 'slope')
? renderHistogram(w, h, yKey, yLabel, color) ? renderHistogram(w, h, yKey, yLabel, color)
: renderLine(w, h, yKey, yLabel, color); : renderLine(w, h, yKey, yLabel, color);
if (chartType === 'line') { if (chartType === 'line' || activeTab === 'slope') {
chart.addEventListener('input', () => { chart.addEventListener('input', () => {
const pt = (chart as any)?.value; const pt = (chart as any)?.value;
hoveredIdx = pt ? timeseries.t.findIndex(t => t === pt.t) : null; hoveredIdx = pt ? timeseries.t.findIndex(t => t === pt.t) : null;
@@ -221,7 +282,8 @@
} }
function renderLine(w: number, h: number, yKey: string, yLabel: string, color: string) { function renderLine(w: number, h: number, yKey: string, yLabel: string, color: string) {
const x = xMode === 'distance' ? 'dist_km' : 't'; const isSlope = activeTab === 'slope';
const x = (xMode === 'distance' || isSlope) ? 'dist_km' : 't';
const tc = getThemeColors(); const tc = getThemeColors();
const marks: any[] = []; const marks: any[] = [];
@@ -229,22 +291,25 @@
// strictly increasing. In distance mode, stopped segments produce many // strictly increasing. In distance mode, stopped segments produce many
// consecutive points with identical dist_km, which causes NaN Bézier // consecutive points with identical dist_km, which causes NaN Bézier
// control points and visual artifacts — use linear instead. // control points and visual artifacts — use linear instead.
const curve = xMode === 'distance' ? 'linear' : 'monotone-x'; const curve = (xMode === 'distance' || isSlope) ? 'linear' : 'monotone-x';
// Apply smoothing for visual rendering only — raw data still used for stats/histogram. // Slope tab uses pre-computed slopeData (elevation already smoothed, slope attached).
// Other tabs apply rolling mean here for visual rendering only.
const halfWin = SMOOTH_HALF[smoothMode]; const halfWin = SMOOTH_HALF[smoothMode];
const lineData = halfWin > 0 const lineData = isSlope && slopeData
? (() => { ? slopeData
const s = rollingMean(data.map(d => (d as any)[yKey] as number | null), halfWin); : (halfWin > 0
return data.map((d, i) => ({ ...d, [yKey]: s[i] })); ? (() => {
})() const s = rollingMean(data.map(d => (d as any)[yKey] as number | null), halfWin);
: data; return data.map((d, i) => ({ ...d, [yKey]: s[i] }));
})()
: data);
if (activeTab === 'cadence') { if (activeTab === 'cadence') {
marks.push(Plot.lineY(lineData, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve })); marks.push(Plot.lineY(lineData, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve }));
} else { } else {
marks.push( marks.push(
Plot.areaY(lineData, { x, y: yKey, fill: color, fillOpacity: 0.15, curve }), Plot.areaY(lineData, { x, y: yKey, fill: color, fillOpacity: isSlope ? 0.45 : 0.15, curve }),
Plot.lineY(lineData, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve }), Plot.lineY(lineData, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve }),
); );
} }
@@ -270,17 +335,44 @@
marks.push( marks.push(
Plot.ruleX(lineData, Plot.pointerX({ x, stroke: tc.rule, strokeWidth: 1, strokeDasharray: '4,4' })), Plot.ruleX(lineData, Plot.pointerX({ x, stroke: tc.rule, strokeWidth: 1, strokeDasharray: '4,4' })),
Plot.dot(lineData, Plot.pointerX({ x, y: yKey, r: 4, fill: color, stroke: tc.tooltipBg, strokeWidth: 1.5 })), Plot.dot(lineData, Plot.pointerX({
Plot.text(lineData, Plot.pointerX({ x, y: yKey, r: 4,
x, y: yKey, fill: isSlope ? (d: any) => slopeColor(d.slope ?? 0) : color,
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '', stroke: tc.tooltipBg, strokeWidth: 1.5,
dy: -12,
fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3,
fontSize: 11, fontWeight: '600',
})), })),
); );
const xTickFormat = xMode === 'distance' if (isSlope) {
marks.push(
Plot.text(lineData, Plot.pointerX({
x, y: yKey,
text: (d: any) => d.slope != null ? `${d.slope > 0.05 ? '+' : ''}${(+d.slope).toFixed(1)}%` : '',
dy: -22,
fill: (d: any) => slopeColor(d.slope ?? 0),
stroke: tc.tooltipBg, strokeWidth: 3,
fontSize: 11, fontWeight: '700',
})),
Plot.text(lineData, Plot.pointerX({
x, y: yKey,
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}m` : '',
dy: -10,
fill: tc.axis, stroke: tc.tooltipBg, strokeWidth: 3,
fontSize: 10,
})),
);
} else {
marks.push(
Plot.text(lineData, Plot.pointerX({
x, y: yKey,
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '',
dy: -12,
fill: tc.tooltipFg, stroke: tc.tooltipBg, strokeWidth: 3,
fontSize: 11, fontWeight: '600',
})),
);
}
const xTickFormat = (xMode === 'distance' || isSlope)
? (v: number) => `${v.toFixed(1)} km` ? (v: number) => `${v.toFixed(1)} km`
: (t: number) => { : (t: number) => {
const h = Math.floor(t / 3600); const h = Math.floor(t / 3600);
@@ -288,13 +380,16 @@
return h > 0 ? `${h}h${m.toString().padStart(2, '0')}` : `${m}m`; return h > 0 ? `${h}h${m.toString().padStart(2, '0')}` : `${m}m`;
}; };
return Plot.plot({ const svg = Plot.plot({
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, domain: [lineDomainMin, lineDomainMax] }, y: { label: yLabel, grid: true, tickCount: 4, domain: [lineDomainMin, lineDomainMax] },
marks, marks,
}); });
if (isSlope && slopeData) injectSlopeGradient(svg, slopeData, w);
return svg;
} }
function renderHistogram(w: number, h: number, yKey: string, yLabel: string, color: string) { function renderHistogram(w: number, h: number, yKey: string, yLabel: string, color: string) {
@@ -393,6 +488,7 @@
{#each Object.entries(tabLabels) as [tab, label]} {#each Object.entries(tabLabels) as [tab, label]}
{@const enabled = {@const enabled =
tab === 'elevation' ? hasElevation : tab === 'elevation' ? hasElevation :
tab === 'slope' ? hasSlope :
tab === 'speed' ? hasSpeed : tab === 'speed' ? hasSpeed :
tab === 'hr' ? hasHR : tab === 'hr' ? hasHR :
tab === 'cadence' ? hasCadence : tab === 'cadence' ? hasCadence :
@@ -415,7 +511,7 @@
<div class="flex-1"></div> <div class="flex-1"></div>
<div class="flex items-center gap-3 text-xs text-zinc-500"> <div class="flex items-center gap-3 text-xs text-zinc-500">
{#if hasDistance && chartType === 'line'} {#if hasDistance && chartType === 'line' && activeTab !== 'slope'}
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="mr-0.5">X:</span> <span class="mr-0.5">X:</span>
{#each (['time', 'distance'] as XMode[]) as mode} {#each (['time', 'distance'] as XMode[]) as mode}
@@ -445,6 +541,7 @@
</div> </div>
{/if} {/if}
{#if activeTab !== 'slope'}
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{#each (['line', 'histogram'] as ChartType[]) as type} {#each (['line', 'histogram'] as ChartType[]) as type}
<button <button
@@ -457,6 +554,7 @@
>{type === 'line' ? '↗ Line' : '▭ Hist'}</button> >{type === 'line' ? '↗ Line' : '▭ Hist'}</button>
{/each} {/each}
</div> </div>
{/if}
</div> </div>
</div> </div>