Skip to content

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.

Highest value 90Line reaches 97+7

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.

Catmull-Rom
Reaches 112% and -8%
Monotone
Stays within 0-100%

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

app.ts
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

Search concepts

Search for a command to run...