Smooth a Svg path with cubic bezier curves

And a bit of trigonometry

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

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.

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

--

--

Freelance developer / designer → http://francoisromain.com

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store