Smooth a Svg path with functional programming

How to use pure functions, closures and functions composition

In a previous article we went through the steps to Smooth a svg path with cubic bezier curves. Here, we are going to refactor this code with functional programming.

From What is functional programming? by Eric Elliott

Imperative code has a few problems:

  • Functions can rely on external states (variables or functions referenced from a higher scope) which can have side effects.
  • There is no explicit relations between functions.

With a large codebase, this is difficult to maintain and test. Functional programming aims to solving these problems.

So let’s refactor the imperative code from the previous article and use concepts related to functional programming: pure functions, closures and functions composition.

What are we trying to achieve?

Given an array of tuples representing the coordinates of a line:

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

We want to create a <path> element whose d attribute defines a smooth line with one moveto command M x,y followed by several bezier curve commands C x1,y1, x2,y2, x,y. The result is like:

<path d="M 5,10 C 6,16 3,36 10,40 C 17,44 30,37 40,30 C 50,23 50,2 60,5 C 70,8 78,44 90,45 C 102,46 108,10 120,10 C 132,10 134,45 150,45 C 166,45 190,17 200,10" fill="none" stroke="grey"></path>

And renders like that:

For a complete explanation about the trigonometry calculations, please check the previous article.

What we already have

From this article, we have a svgPath function to loop over the points array and return a <path>:

// 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" />`
}

Eventually svgPath is used like so:

svg.innerHTML = svgPath(points, bezierCommand)
  • bezierCommand: returns a bezier curve command C x1,y1 x2,y2 x,y from the property of the current point and the points array. It depends on a controlPoint function to find x1,y1 and x2,y2.
  • controlPoint: returns the coordinates x, y of a control point from the current, previous and next points. It depends on a line function to find the property of the line joining previous and next.
  • line: returns the angle and the length of a line from the coordinates of two points. It does not depend on any external variables.

Pure functions

The line function already respects this definition, so we reuse it as is:

// Properties of a line 
// I: - pointA (array) [x,y]: coordinates
// - pointB (array) [x,y]: coordinates
// O: - (object) { length: (integer), angle: (integer) }
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)
}
}

The two other functions controlPoint and bezierCommand are not pure because they reference variables from the global scope:

  • controlPoint references the line function and a smooth variable.
  • bezierCommand references the controlPoint function.

How to transform these in pure functions?

A first idea would be to pass the external variable as an additional parameter. For example, pass a line parameter to controlPoint like so: controlPoint(current, previous, next, reverse, line). The downsides of this solution are:

  • The need to pass line to controlPoint each time we want to use it, which is not necessary because line is the same for every points.
  • If controlPoint is nested inside other functions, line has to be passed through all the parent functions chain. In this case we have to modify each parent function definition, and thus making them less generic.

A better idea is to use closures.

Closures

The general mechanism is to return an existing function from a new function which takes variables that used to be external as parameters. Then, the former function becomes a closure and holds on these variables.

newFunction = usedToBeExternal => oldFunction

To apply that to our controlPoint function, we create a new function that takes two parameters, lineCalc and smooth, then returns our actual function as a closure which holds on these variables:

// Create a function to calculate the position of the control point
// I: - lineCalc (function)
// I: - pointA (array) [x, y]: coordinates
// - pointB (array) [x, y]: coordinates
// O: - (object) { length: (integer), angle: (integer) }
// - smooth (float)
// O: - (function) closure
// I: - current (array) [x, y]: coordinates
// - previous (array) [x, y]: coordinates
// - next (array) [x, y]: coordinates
// - reverse (boolean, optional): sets the direction
// O: - (array) [x,y]: coordinates
const controlPoint = (lineCalc, smooth) => (current, previous, next, reverse) => {

// when 'current' is the first or last point of the array
// 'previous' and 'next' are undefined
// replace with 'current'
const p = previous || current
const n = next || current
// properties of the line between previous and next
const l = lineCalc(p, n)
// If is end-control-point, add PI to the angle to go backward
const angle = l.angle + (reverse ? Math.PI : 0)
const length = l.length * smooth
// 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]
}

This is used like so:

// Create a closure function 
const controlPointCalc = controlPoint(line, smoothing)
// For each point of the array, find the position of a control point
controlPointCalc(current, previous, next, reverse)

Same technic with the bezierCommand function used to calculate the svg cubic bezier command on each point. This function takes controlPoint as a parameter and returns a closure function:

// Create a function to calculate a bezier curve command 
// I: - controlPointCalc (function)
// I: - current (array) [x, y]: current point coordinates
// - previous (array) [x, y]: previous point coordinates
// - next (array) [x, y]: next point coordinates
// - reverse (boolean) to set the direction
// O: - (array) [x, y]: coordinates of a control point
// O: - (function) closure
// 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': cubic bezier command
const bezierCommand = controlPointCalc => (point, i, a) => { // start control point
const [cpsX, cpsY] = controlPointCalc(a[i-1], a[i-2], point)
// end control point
const [cpeX, cpeY] = controlPointCalc(point, a[i-1], a[i+1], true)
return `C ${cpsX},${cpsY} ${cpeX},${cpeY} ${point[0]},${point[1]}`
}

Functions composition

Now all our pure functions are ready, we can finally compose them:

const smoothing = 0.2// Position of a control point
// I: - current (array) [x, y]: coordinates
// - previous (array) [x, y]: coordinates
// - next (array) [x, y]: coordinates
// - reverse (boolean, optional): sets the direction
// O: - (array) [x, y]: coordinates of a control point
const controlPointCalc = controlPoint(line, smoothing)
// 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': cubic bezier command
const bezierCommandCalc = bezierCommand(controlPointCalc)
svg.innerHTML = svgPath(points, bezierCommandCalc)

Which could be rewritten as a one-liner:

svg.innerHTML = svgPath(points, bezierCommand(controlPoint(line, smoothing)))

View the result (codepen).

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