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.
Functional programming is the process of building software by composing pure functions, avoiding shared state, mutable data, and side-effects.
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> 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" />`
}
Eventually svgPath
is used like so:
svg.innerHTML = svgPath(points, bezierCommand)
bezierCommand
: returns a bezier curve commandC x1,y1 x2,y2 x,y
from the property of the currentpoint
and thepoints
array. It depends on acontrolPoint
function to findx1,y1
andx2,y2
.controlPoint
: returns the coordinatesx, y
of a control point from thecurrent
,previous
andnext
points. It depends on aline
function to find the property of the line joiningprevious
andnext
.line
: returns theangle
and thelength
of a line from the coordinates of two points. It does not depend on any external variables.
Pure functions
A pure function is a function which:
- Given the same inputs, always returns the same output.
- Has no side-effects.
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 theline
function and asmooth
variable.bezierCommand
references thecontrolPoint
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
tocontrolPoint
each time we want to use it, which is not necessary becauseline
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
A closure is a persistent local variable scope.
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]: coordinatesconst 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 commandconst 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
Functions composition is a mechanism to combine simple functions to build more complicated ones.
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)))