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 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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user