Implementation
With the research done, we now have enough information to begin implementing the curve logic.
This largely falls into two buckets:
1. Creating an interesting pattern of (x, y) points
2. Turning these points into an SVG path definition
Creating an interesting pattern
For the pattern, we turn to a classic: stacked harmonic sine waves. Granted, this might only be a classic in certain fields like Digital Signals Processing (DSP), but I argue it is a classic nonetheless.
For this implementation we’ll be doing everything in Typescript. This is not a requirement, but personally I like types and while there is a transpilation step to get to Javascript it is the closest to “just running on the page”.
// The values of width and height are assumed
const pointCount = 200;
const frequency = width / 2 * Math.PI;
const xSteps: number[] = Array.from({length: pointCount}, (_, i) => i/pointCount);
const ySteps: number[] = xSteps.map((x) => height / 2 + height + Math.sin(x * frequency));
Here we’re defining 200 points. The first step is to
normalize the frequency for the width of the area. The next is to map
those normalized xSteps onto a series of
ySteps that is normalized to the height of the canvas.
Adding harmonics
A singular sine wave is a nice base, but we can create something with a little more visual interest. The classic way to do this is to layer multiple sine waves of varying frequency. These could theoretically be any frequency, but we will use harmonic frequencies here.
If we are starting with a frequency Z, then we will
create the harmonics with frequencies 2 * Z,
3 * Z, 4 * Z, etc. Fortunately the mechanism
for doing this in code is incredibly simple, instead of
Math.sin(x) we can change the method to
Math.sin(2 * x) and so on.
With a bit of cleaning up of the above code, we can create a point generator that uses harmonics and a baseFrequency:
const waveProps = {
...,
harmonics: [1.0, 0.5, 0.2],
baseFrequency: width / 2 * Math.PI
}
function getStep(waveProps: WaveProps, i: number): Point {
const x = i / pointCount;
const y = waveProps.harmonics.reduce(
(acc, h, i) => acc + h * Math.sin(x * waveProps.baseFrequency * (i + 1)),
0,
);
return {
x,
y,
};
}
With the getStep method wrapped up, all we need is to
create the glue code for putting it all together.
function getPoints(waveProps: WaveProps): Point[] {
const pointCount = waveProps.pointCount || DEFAULT_POINT_COUNT;
const rawPoints = [...Array(pointCount)].map((_, i) => getStep(waveProps, i));
const normalizedPoints = normalizePoints(waveProps, rawPoints);
return normalizedPoints;
}
The WaveProps object contains all of the various things
that are required to define these waves, from the height, pointCount,
harmonics and more. Admittedly, this can make the code snippets seem a
bit abstract. With this completed, we can mark the “step 1: create some
points” goal as done and move on to drawing this to the screen.
Turning the points into a curve: applying the Bézier curve learnings
When one is creating “art” rather than specifically doing engineering, certain liberties can be taken. You know, “artistic liberty”. In this case, the fall throughs and initial point definitions for the Bézier curves are not strictly necessary.
What is the lazy way to do this? Well, rather than attempting to special case the first three points, we can just triplicate the first and last points! Then, each of the points in the flow will be created with the same exact logic.
const bufferedPoints = [
points[0],
points[0],
...points,
points[points.length - 1],
points[points.length - 1],
];
And tada, just like that we have solved the problem we were running into with entirely less math and effort.
Now that we know what the curve definitions are going to be, it is pretty simple to turn them into actual SVG path commands.
const doBezier = (prev: Point, curr: Point, next: Point): string => {
const startControl: Point = {
x: (2 * prev.x + curr.x) / 3,
y: (2 * prev.y + curr.y) / 3,
};
const endControl: Point = {
x: (prev.x + 2 * curr.x) / 3,
y: (prev.y + 2 * curr.y) / 3,
};
const endAnchor: Point = {
x: (prev.x + 4 * curr.x + next.x) / 6,
y: (prev.y + 4 * curr.y + next.y) / 6,
};
// prints C x1 y1, x2 y2, x y
return `C ${startControl.x} ${startControl.y}, ${endControl.x} ${endControl.y}, ${endAnchor.x} ${endAnchor.y} `;
};
This is the exact logic from d3 but slightly re-worked for clarity.
The three provided points are used to construct the 2 control points as
well as the anchor point for the given segment. The C is
chosen since we are using non-parallel curves (that one can use the
S for) and also because we are using absolute positions
rather than deltas (which would be c).
To finish with the logic, we need only set up the start and end.
// To start
let pathString = `M ${points[0].x} ${points[0].y} `;
// Create each part of the path
for (let i = 2; i < bufferedPoints.length; i += 1) {
pathString += doBezier(bufferedPoints[i - 2], bufferedPoints[i - 1], bufferedPoints[i]);
}
// Add the bottom corners since we want to fill the entire area as a shape
// rather than just having the curve itself.
// Bottom right corner
pathString += `L ${1.0} ${waveProps.normalizeRange?.max} `;
// Bottom left corner
pathString += `L ${0.0} ${waveProps.normalizeRange?.max} `;
// Close the shape (back to the top-left where the curve started)
pathString += `Z`;
Constructing the path(s) into an SVG
To make use of our Bezier curve path definition, we need to attach it to an SVG. Compared to the above, this is much more mechanical and uninteresting.
`<svg width="${canvasProps.width}" height="${canvasProps.height}" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(${canvasProps.width}, 1)">
<path d="${pathString}" stroke="none" fill="${waveProps.color.color}" stroke-width="0.01" />
<rect x="0"
y="${maximumYPoint.y}"
width="1"
height="${canvasProps.height}"
fill="${waveProps.color.color}" stroke="none" />
</g>
</svg>`
An additional rect is used to fill the space from the
maximumY point value. The g transform scales the width
which is has been (0, 1) normalized throughout this
process.
Finalizing
At this point, we can see the curve! It is just a single curve but that alone can provide a lot of color, interest, and movement to a page. That said, we can get a bit more creative. From here, we will look at layering and applying other effects.
Previous: Initial Research