Curve Smoothing
A line chart joins points, and the way it joins them says something about the values in between. Straight segments are honest but jagged. Smooth curves read more calmly, but some of them draw values that were never in the data.
Switch between four common curves. The dashed line marks the highest value in the data.
A smooth line should never go higher or lower than the points it connects.
Catmull-Rom passes through every point, but it swings past them on sharp turns. Here it peaks at 97 when nothing in the data is above 90. Basis does the opposite. It never touches the inner points, so it shaves the peak to 86 and flattens the spike at 62 down to 51. Monotone passes through every point and stays between each pair of neighbors, so it tops out at exactly 90.
Why monotone holds
The shape of each segment comes from the slope the line has as it leaves one point and arrives at the next. Catmull-Rom copies that slope from the neighbors, so a point next to a steep drop leaves at a steep angle and the curve bulges to make the turn.
A monotone curve limits those slopes. Where the data turns from rising to
falling, the slope at that point is zero, so the line flattens there
instead of sailing past. Everywhere else the slope is capped by the
segments on either side. Fritsch and Carlson described the method in 1980.
d3's curveMonotoneX uses a later variant by Steffen.
Bounded data
Overshoot does the most damage when the data has hard limits. A percentage cannot go above 100 or below zero, and readers trust the line before they check the axis.
Both charts plot the same twelve CPU readings. The Catmull-Rom line reaches 112% and dips to -8% around the flat stretches. The monotone line rests on the bounds and still reads as a curve.
Usage
import { curveMonotoneX, line } from "d3-shape";
const path = line<{ x: number; y: number }>()
.x((p) => p.x)
.y((p) => p.y)
.curve(curveMonotoneX);
// <path d={path(points)} fill="none" stroke="currentColor" />type Point = { x: number; y: number };
// Steffen's monotone cubic, the same curve as d3's curveMonotoneX.
export function monotonePath(p: Point[]) {
const n = p.length;
if (n < 3) return p.map((q, i) => `${i ? "L" : "M"}${q.x},${q.y}`).join("");
const h: number[] = [];
const s: number[] = [];
for (let i = 0; i < n - 1; i++) {
h[i] = p[i + 1].x - p[i].x;
s[i] = (p[i + 1].y - p[i].y) / h[i];
}
// Slope at each point: zero where the data turns, capped elsewhere.
const m: number[] = [];
for (let i = 1; i < n - 1; i++) {
const mid = (s[i - 1] * h[i] + s[i] * h[i - 1]) / (h[i - 1] + h[i]);
const limit = Math.min(2 * Math.abs(s[i - 1]), 2 * Math.abs(s[i]), Math.abs(mid));
m[i] = s[i - 1] * s[i] > 0 ? Math.sign(mid) * limit : 0;
}
m[0] = (3 * s[0] - m[1]) / 2;
m[n - 1] = (3 * s[n - 2] - m[n - 2]) / 2;
let d = `M${p[0].x},${p[0].y}`;
for (let i = 1; i < n; i++) {
const a = p[i - 1];
const b = p[i];
const t = (b.x - a.x) / 3;
d += `C${a.x + t},${a.y + t * m[i - 1]} ${b.x - t},${b.y - t * m[i]} ${b.x},${b.y}`;
}
return d;
}
// <path d={monotonePath(points)} fill="none" stroke="currentColor" />Monotone assumes x only ever increases, which holds for any time series. While you build the chart, keep the data dots visible. 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 curve d3 ships, drawn on the same points, including curveMonotoneX.
Monotone cubic interpolation - The Fritsch and Carlson method, with the tangent rules that stop a cubic from overshooting.
Adaptive Polynomial Curve Fitting - Shu Ding on smoothing Vercel Analytics charts by fitting a curve to the trend instead of through every point.
The SVG path d attribute - The cubic Bezier command each curve here is made of.