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.

Wrong
Right

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.

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

Search concepts

Search for a command to run...