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> elementconst 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 commandconst 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 pointx2,y2
: coordinates of the end control pointx,y
: coordinates of the end anchor point
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-line
s):
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 arbitrarysmoothing
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 lineconst 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 coordinatesconst 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 commandconst 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
- Cubic Bezier Curves, Under the Hood: a great animated explanation on how computers actually render bezier curves by Peter Nowell.
- D3.js curves functions documentation.