Smooth a Svg path with cubic bezier curves

And a bit of trigonometry


While it is straightforward to draw straight lines in a Svg element, it requires a bit of trigonometry to smooth these lines. Let’s see how.



We have an array of tuples representing the points coordinates of a line.

const points = [[5, 10], [10, 40], [40, 30], [60, 5], [90, 45], [120, 10], [150, 45], [200, 10]]

And a Svg element in an HTML page:

<svg viewBox="0 0 200 200" version="1.1" xmlns="http://www.w3.org/2000/svg" class="svg"></svg>

We want to make a <path> element from the points array.


Create a path from the points

The d attributes of <path> always starts with a move to command: M x,y, followed by several commands depending on the type of shape. The result is something like: <path d="M 10,20 L 15,25 L 20,35"> for a straight line.

First, let’s make a generic svgPath function which has two parameters: the points array and a command function.

// Render the svg <path> element 
// I: - points (array): points coordinates
// - command (
function)
// I: - point (array) [x,y]: current point coordinates
// - i (integer): index of 'point' in the array 'a'
// - a (array): complete array of points coordinates
// O: - (string) a svg path command
// O: - (string): a Svg <path> element
const svgPath = (points, command) => {
  // build the d attributes by looping over the points
const d = points.reduce((acc, point, i, a) => i === 0
    // if first point
? `M ${point[0]},${point[1]}`
    // else
: `${acc} ${command(point, i, a)}`
, '')
  return `<path d="${d}" fill="none" stroke="grey" />`
}

Now, let’s create two commands functions:

  • lineCommand: to draw straight lines.
  • bezierCommand: to draw a smooth line.

Drawing straight lines

Straight lines require the line to command, starting with the letter L followed by the coordinates of the end point x,y.

A basic lineCommand function to draw straight lines:

// Svg path line command
// I: - point (array) [x, y]: coordinates
// O: - (string) 'L x,y': svg line command
const lineCommand = point => `L ${point[0]} ${point[1]}`

Now we can use it to draw a line from the points array:

const svg = document.querySelector('.svg')
svg.innerHTML = svgPath(points, lineCommand)

This gives the following result (view on Codepen):


Drawing smooth lines

The cubic bezier command

The cubic bezier command starts with the letter C followed by three pairs of coordinates x1,y1 x2,y2 x,y:

  • x1,y1: coordinates of the start control point
  • x2,y2: coordinates of the end control point
  • x,y: coordinates of the end anchor point

(Interactive demo)

A few things to notice:

  • The start anchor point coordinates are given by the previous command.
  • The end anchor point coordinates come from the original points array.
  • Now we have to find the position of the two control points.

Find the position of the control points

We join the anchor points surrounding the start and the end anchor points with a line (let’s call these the opposed-lines):

For the line to be smooth, the position of each control point has to be relative to its opposed-line:

  • The control point is on a line parallel to the opposed-line, and tangent to the current anchor point.
  • On this tangent line, the distance from the anchor point to the control point depends on the length of the opposed-line and an arbitrary smoothing ratio.
  • The start control point goes in the same direction as the opposed-line, while the end control point goes backward.

Code

First, a function to find the properties of the opposed-line:

// Properties of a line 
// I: - pointA (array) [x,y]: coordinates
// - pointB (array) [x,y]: coordinates
// O: - (object) { length: l, angle: a }: properties of the line
const line = (pointA, pointB) => {
const lengthX = pointB[0] - pointA[0]
const lengthY =
pointB[1] - pointA[1]
  return {
length: Math.sqrt(Math.pow(lengthX, 2) + Math.pow(lengthY, 2)),
angle: Math.atan2(lengthY, lengthX)
}
}

Then, a function to find the position of a control point:

// Position of a control point 
// I:
- current (array) [x, y]: current point coordinates
// - previous (array) [x, y]: previous point coordinates
// - next (array) [x, y]: next point coordinates
// - reverse (boolean, optional): sets the direction
// O:
- (array) [x,y]: a tuple of coordinates
const controlPoint = (current, previous, next, reverse) => {
  // When 'current' is the first or last point of the array
// 'previous' or 'next' don't exist.
// Replace with 'current'
const p = previous || current
const n = next || current
  // The smoothing ratio
const smoothing = 0.2
  // Properties of the opposed-line
const o = line(p, n)
  // If is end-control-point, add PI to the angle to go backward
const angle = o.angle + (reverse ? Math.PI : 0)
const length = o.length * smoothing
  // The control point position is relative to the current point
const x = current[0] + Math.cos(angle) * length
const y = current[1] + Math.sin(angle) * length
  return [x, y]
}

A function to create the bezier curve C command:

// Create the bezier curve command 
// I: - point (array) [x,y]: current point coordinates
// - i (integer): index of 'point' in the array 'a'
// - a (array): complete array of points coordinates
// O: - (string) 'C x2,y2 x1,y1 x,y': SVG cubic bezier C command
const bezierCommand = (point, i, a) => {
  // start control point
const [cpsX, cpsY] = controlPoint(a[i - 1], a[i - 2], point)
  // end control point
const [cpeX, cpeY] = controlPoint(point, a[i - 1], a[i + 1], true)
  return `C ${cpsX},${cpsY} ${cpeX},${cpeY} ${point[0]},${point[1]}`
}

And finally we reuse the svgPath function to loop over the points of the array and build the <path> element. Then we append the <path> to the <svg> element.

const svg = document.querySelector('.svg')
svg.innerHTML = svgPath(points,
bezierCommand)

And the result (view on Codepen):



Interesting links