Curve Smoothing
A line chart connects points. How you connect them is a design decision, and it changes what the chart appears to say. Straight segments are honest but jagged. A smoothed curve reads more calmly, but some smoothing methods add bumps that were never in the data.
The chart below draws the same fifteen values three ways. Switch between them and watch what happens between the dots.
Linear is the baseline. Monotone cubic keeps every rise and fall exactly where the data has it: the curve never goes above or below the two points it is joining. Catmull-Rom is the smoothest, and it gets there by swinging past the points on sharp turns.
Overshoot on bounded data
Overshoot is cosmetic on unbounded data and a real problem on bounded data. Percentages, counts, and anything that cannot be negative should never dip below zero on the chart, because readers will believe the chart before they believe the axis.
Below, an uptime series sits flat at zero and flat at a hundred for a few points in a row.
On the left the Catmull-Rom curve drops below the floor and rises above the ceiling to make its turns look smooth. On the right the monotone curve stays inside the bounds and still looks like a curve. If the flat stretches matter, and they usually do, monotone is the safe default.
Choosing
Use monotone for any series where the reader will infer values between points. Use Catmull-Rom only for decorative lines.
Sparklines, dashboards, and anything with a hover tooltip fall in the first group. A background flourish on a landing page falls in the second.
Usage
The monotone curve here is about forty lines with no dependencies. It computes a tangent per point using the Fritsch and Carlson rule, then emits one cubic segment per pair of points.
type Point = { x: number; y: number };
export function monotonePath(points: Point[]) {
const n = points.length;
const dx: number[] = [];
const slope: number[] = [];
for (let i = 0; i < n - 1; i++) {
dx[i] = points[i + 1].x - points[i].x;
slope[i] = (points[i + 1].y - points[i].y) / dx[i];
}
// One tangent per point. Zero where the slope changes sign,
// so the curve flattens instead of overshooting.
const tangent: number[] = [];
tangent[0] = slope[0];
tangent[n - 1] = slope[n - 2];
for (let i = 1; i < n - 1; i++) {
const a = slope[i - 1];
const b = slope[i];
if (a * b <= 0) {
tangent[i] = 0;
} else {
const w1 = 2 * dx[i] + dx[i - 1];
const w2 = dx[i] + 2 * dx[i - 1];
tangent[i] = (w1 + w2) / (w1 / a + w2 / b);
}
}
return points
.map((p, i) => {
if (i === 0) return `M${p.x},${p.y}`;
const a = points[i - 1];
const h = (p.x - a.x) / 3;
const c1x = a.x + h;
const c1y = a.y + h * tangent[i - 1];
const c2x = p.x - h;
const c2y = p.y - h * tangent[i];
return `C${c1x},${c1y} ${c2x},${c2y} ${p.x},${p.y}`;
})
.join(" ");
}
// <path d={monotonePath(points)} fill="none" stroke="currentColor" />If you already use d3, d3.line().curve(d3.curveMonotoneX) does the same thing. Either way, keep the data dots visible while you are developing the chart. Overshoot is easy to miss on a bare line and obvious once you can see where the real values are.
Resources
d3-shape curves - Every interpolation d3 ships, drawn on the same points. The fastest way to build intuition for them.
Monotone cubic interpolation - The Fritsch and Carlson method behind monotone curves, with the tangent rules spelled out.
Adaptive Polynomial Curve Fitting - Shu Ding on fitting curves to live data, and where naive smoothing goes wrong.
The SVG path d attribute - The cubic Bezier commands the demos here emit, and what each number means.