From 6faf63c2cd8f5db4a3680c6b7234362dd4e02704 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Mon, 18 May 2026 18:57:55 +0200 Subject: [PATCH] ActivityCharts: add slope coloring tab to elevation profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- site/src/components/ActivityCharts.svelte | 144 ++++++++++++++++++---- 1 file changed, 121 insertions(+), 23 deletions(-) diff --git a/site/src/components/ActivityCharts.svelte b/site/src/components/ActivityCharts.svelte index 2f09c31..f6ff018 100644 --- a/site/src/components/ActivityCharts.svelte +++ b/site/src/components/ActivityCharts.svelte @@ -11,7 +11,7 @@ const HR_ZONE_COLORS = ['#60a5fa', '#4ade80', '#facc15', '#fb923c', '#f87171']; 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 ChartType = 'line' | 'histogram'; type SmoothMode = 'raw' | '10s' | '20s'; @@ -59,9 +59,11 @@ $: hasSpeed = timeseries.speed_kmh.some(v => v != null); $: hasPower = timeseries.power_w.some(v => v != null); $: hasDistance = dist_km !== null; + $: hasSlope = hasElevation && hasDistance; const tabLabels: Record = { elevation: 'Elevation', + slope: 'Slope', speed: 'Speed', hr: 'Heart Rate', cadence: 'Cadence', @@ -70,6 +72,7 @@ const tabMeta: Record = { 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' }, hr: { color: '#f87171', yLabel: 'Heart Rate (bpm)', yKey: 'hr' }, cadence: { color: '#a78bfa', yLabel: 'Cadence (rpm)', yKey: 'cadence' }, @@ -138,6 +141,63 @@ // Reset when switching away from a zone-capable metric or leaving histogram $: 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(``); + } + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + defs.innerHTML = `${stops.join('')}`; + 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 ────────────────────────────────────────────────── function getThemeColors() { const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; @@ -200,17 +260,18 @@ const tabEnabled = activeTab === 'elevation' ? hasElevation : + activeTab === 'slope' ? hasSlope : activeTab === 'speed' ? hasSpeed : activeTab === 'hr' ? hasHR : activeTab === 'cadence' ? hasCadence : hasPower; if (!tabEnabled) return; - chart = chartType === 'histogram' + chart = (chartType === 'histogram' && activeTab !== 'slope') ? renderHistogram(w, h, yKey, yLabel, color) : renderLine(w, h, yKey, yLabel, color); - if (chartType === 'line') { + if (chartType === 'line' || activeTab === 'slope') { chart.addEventListener('input', () => { const pt = (chart as any)?.value; 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) { - const x = xMode === 'distance' ? 'dist_km' : 't'; + const isSlope = activeTab === 'slope'; + const x = (xMode === 'distance' || isSlope) ? 'dist_km' : 't'; const tc = getThemeColors(); const marks: any[] = []; @@ -229,22 +291,25 @@ // 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'; + 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 lineData = halfWin > 0 - ? (() => { - const s = rollingMean(data.map(d => (d as any)[yKey] as number | null), halfWin); - return data.map((d, i) => ({ ...d, [yKey]: s[i] })); - })() - : data; + const lineData = isSlope && slopeData + ? slopeData + : (halfWin > 0 + ? (() => { + const s = rollingMean(data.map(d => (d as any)[yKey] as number | null), halfWin); + return data.map((d, i) => ({ ...d, [yKey]: s[i] })); + })() + : data); if (activeTab === 'cadence') { marks.push(Plot.lineY(lineData, { x, y: yKey, stroke: color, strokeWidth: 1.5, curve })); } else { 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 }), ); } @@ -270,17 +335,44 @@ marks.push( 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.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', + Plot.dot(lineData, Plot.pointerX({ + x, y: yKey, r: 4, + fill: isSlope ? (d: any) => slopeColor(d.slope ?? 0) : color, + stroke: tc.tooltipBg, strokeWidth: 1.5, })), ); - 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` : (t: number) => { const h = Math.floor(t / 3600); @@ -288,13 +380,16 @@ 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, style: { background: 'transparent', color: tc.axis, fontSize: '11px' }, x: { label: null, tickFormat: xTickFormat, grid: false, ticks: 6 }, y: { label: yLabel, grid: true, tickCount: 4, domain: [lineDomainMin, lineDomainMax] }, marks, }); + + if (isSlope && slopeData) injectSlopeGradient(svg, slopeData, w); + return svg; } function renderHistogram(w: number, h: number, yKey: string, yLabel: string, color: string) { @@ -393,6 +488,7 @@ {#each Object.entries(tabLabels) as [tab, label]} {@const enabled = tab === 'elevation' ? hasElevation : + tab === 'slope' ? hasSlope : tab === 'speed' ? hasSpeed : tab === 'hr' ? hasHR : tab === 'cadence' ? hasCadence : @@ -415,7 +511,7 @@
- {#if hasDistance && chartType === 'line'} + {#if hasDistance && chartType === 'line' && activeTab !== 'slope'}
X: {#each (['time', 'distance'] as XMode[]) as mode} @@ -445,6 +541,7 @@
{/if} + {#if activeTab !== 'slope'}
{#each (['line', 'histogram'] as ChartType[]) as type} {/each}
+ {/if}