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:
@@ -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<Tab, string> = {
|
||||
elevation: 'Elevation',
|
||||
slope: 'Slope',
|
||||
speed: 'Speed',
|
||||
hr: 'Heart Rate',
|
||||
cadence: 'Cadence',
|
||||
@@ -70,6 +72,7 @@
|
||||
|
||||
const tabMeta: Record<Tab, { color: string; yLabel: string; yKey: string }> = {
|
||||
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(`<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 ──────────────────────────────────────────────────
|
||||
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 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;
|
||||
: 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,7 +335,33 @@
|
||||
|
||||
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.dot(lineData, Plot.pointerX({
|
||||
x, y: yKey, r: 4,
|
||||
fill: isSlope ? (d: any) => slopeColor(d.slope ?? 0) : color,
|
||||
stroke: tc.tooltipBg, strokeWidth: 1.5,
|
||||
})),
|
||||
);
|
||||
|
||||
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])}` : '',
|
||||
@@ -279,8 +370,9 @@
|
||||
fontSize: 11, fontWeight: '600',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const xTickFormat = xMode === 'distance'
|
||||
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 @@
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<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">
|
||||
<span class="mr-0.5">X:</span>
|
||||
{#each (['time', 'distance'] as XMode[]) as mode}
|
||||
@@ -445,6 +541,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeTab !== 'slope'}
|
||||
<div class="flex items-center gap-1">
|
||||
{#each (['line', 'histogram'] as ChartType[]) as type}
|
||||
<button
|
||||
@@ -457,6 +554,7 @@
|
||||
>{type === 'line' ? '↗ Line' : '▭ Hist'}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user