Inspiration
One of my earliest memories of computer programming is of my dad showing me how the maths of sine waves works, then drawing them, pixel by pixel, to animate a snake wobbling across the TV screen.
I thought it’d be great to revisit that, using Vue.js – Almost All Digital’s current favourite Javascript framework – to produce an animated graphic device for the AAD website.
Full disclosure – I ended up not using actual sine waves. I looked into it, using a <canvas> tag, but the <svg> tag allowed smoother graphics, and simpler animation using the <animate> tag; and once I committed to using SVG, I found it was easier to approximate sine waves using Bezier curves. To my eye, the results look quite sine-y, but if you’re a Math purist, sorry to disappoint you!
Here’s the code in a Pen: https://codepen.io/almost-all-dave/pen/XWrgYNw – there’s lots of comments there, which I won’t duplicate here.
How it works
SVG tag in HTML
In the HTML, there’s a <div> tag containing the <svg>. The <div> tag’s id is #div_waves_wrapper, and that tag acts as the root DOM element for an instance of Vue.js.
<div id="div_waves_wrapper" class="waves">
<svg id="svg_waves" width="100%" :height="svgHeight" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<path v-for="(wave, i) in waves" :key="i" :id="wave.id" stroke="rgb(74,184,177)" stroke-width="1.5" :stroke-opacity="wave.opacity" fill="none" :d="wave.pathData"></path>
</defs>
<use v-for="(wave, i) in waves" :key="i" :xlink:href="wave.href" x="0" y="0">
<animate attributeName="x" from="0" :to="wave.animateToX" :dur="wave.animationDuration" repeatCount="indefinite"></animate>
</use>
</svg>
</div>
The <svg> tag has various attributes controlled by Vue:
- The SVG’s height attribute (in the Codepen example it’s 200px).
- The SVG contains a <path> tag which renders a line/shape for each wave. The parameters of each wave are defined as a Javascript object within Vue’s data.waves: stroke width and opacity, and the SVG path too.
- The SVG tag also contains a <use> tag for each wave. I can’t remember right now why it worked out better to split some parameters into the <use> tags than to keep them within the <path> tags – apologies for that. The <use> tag’s xlink:href attribute tells it which <path> it’s related to.
- Each <use> contains an <animate> tag, which tells the browser to animate the X base position of each path a certain number of pixels over a certain duration, and to repeat the animation forever.
Vue: data object
In the Code Pen example, I’ve tried to comment the Vue code to help you find your way around. But here’s some description of how the sine wave parameters are calculated.
First, here’s what Vue’s reactive data looks like.
data: function() {
return {
svgWidth: 0,// SVG width
svgHeight: 200,// SVG height
centreY: 0,// Y coord of vertical centre of SVG
waves: [],// Array of waves
wavesCount: 10// Number of waves in animation
};
},
Vue.created()
There’s a Vue created() function that triggers when, in the Vue component lifecycle, the component’s data is first made reactive:
created: function() {
this.compileWaves();
window.addEventListener("resize", this.compileWaves);
}
this.compileWaves(), described below, compiles the data that configures the sine-ish waves and their animation. Here, I’m also adding a listener so compileWaves() is called whenever the browser window is resized – so we won’t be left with sine waves that don’t fit the display.
Vue.methods.compileWaves()
compileWaves: function() {
this.svgWidth = document.documentElement.clientWidth;
this.centreY = Math.ceil(this.svgHeight / 2);
this.waves = [];
let frequency;
for (let index = 0; index < this.wavesCount; index++) {
frequency = this.partlyRandomFrequency(index);
this.waves.push({
id: `wave_${index}`,
href: `#wave_${index}`,
pathData: this.waveSVGPath(frequency),
opacity: this.opacity(frequency),
animateToX: this.animateToX(frequency),
animationDuration: this.randomDuration()
});
}
}
The function first sets the SVG tag’s width to full screen width (in px).
It then calculates half the height of the SVG in pixels. You can overwrite data.svgHeight if you want to change the height of the animation.
Next, the method sets this.waves to an empty array. Then, it executes a for loop, pushing onto this.waves an object whose properties are the parameters for an individual <path>/<use> within the SVG.
Partly random frequency
Each wave’s frequency is partly a function of the position of the wave object in the this.waves array. That’s because I wanted some randomness in the wine wave frequencies – so the graphic’s different on each page load – but I also wanted to guarantee that some waves would have low frequencies and some would have high frequencies.
So here’s how I calculated a partly-random frequency for each wave:
partlyRandomFrequency: function(index) {
const n = index + 1;
const min = 0.6 + (n * 0.4);
const max = 2 + (n * 0.4);
return this.rnd(min, max);
},
The frequency is a random number in a range… but the range is different for waves with a low index, than for waves with a high index.
If index == 0, the frequency will be a random number between 1.1 and 2.6; but if index == 9, the frequency will be between 4.5 and 6.
This isn’t perfect yet: The more sine waves I ask for, the bigger the biggest waves will be: I’ve juggled the parameters of the function to suit the number of waves I like. So one obvious improvement for the component is to make sure the partlyRandomFrequency(index) method always gives results scaled somehow according to this.sinesCount.
Compiling SVG path data
waveSVGPath: function (frequency) {
const cycleCount = Math.ceil(frequency) + 1;
const amplitude = this.amplitude(frequency);
let cyclesSVGPaths = [];
for (let cycleIndex = 0; cycleIndex < cycleCount; cycleIndex++) {
cyclesSVGPaths.push(this.cycleSVGPath(frequency, amplitude, cycleIndex));
}
return `M 0 ${this.centreY} ${cyclesSVGPaths.join(" ")}`;
},
Here, we’re going to compile some SVG path data so the browser can draw a single wave within the SVG. To define the path we’ll use a number of Bezier curves, chained together to look like a sine wave.
Depending on the frequency of the wave, we’ll need to draw a number of CYCLES of the wave (in each cycle, the wave rises and falls exactly once).
We’ll also need to draw 1 extra cycle of each wave off to the right of the visible area of the SVG, so that when the browser animates the path by shifting it to the left, there’s extra wave to walk onto the screen on the right-hand side.
So… frequency is the number of cycles across the screen (IE a wave with frequency == 4 would rise & fall 4 times across the screen); and cycleCount is the number of cycles we need to draw (IE if frequency == 4, cycleCount == 5).
The amplitude of each wave is important. The animation looks most natural to me when high-frequency waves have a smaller amplitude than low-frequency waves. Being a music/sound design geek, I bet this is because of musical Harmonic Series.
The way the path works is a bit like this:
A path begins with “M 0 100” (where 100 is this.centreY). That means “move the pen to a point where X == 0 and Y == the vertical centre-point of the SVG”. If our SVG’s 200px high, Y == 100.
I want the rest of the path to be formed of a number of cubic Bezier curves. Each curve’s specified like this:
C [handle1X] [handle1Y] [handle2X] [handle2Y] [endX] [endY]
A specific example might be:
C 0 0 300 200 300 100
This section of the SVG path means…
“Draw a curve [starting from the current pen position]. The first Bezier handle/control should be dragged to (0, 0). The second Bezier handle/control should be dragged to (300, 200). And the line itself should finish at (300, 100).”
I found it much easier to understand all this by looking at an interactive example at http://blogs.sitepointstatic.com/examples/tech/svg-curves/cubic-curve.html.
By playing with the Bezier handle positions, I was able to define curves which, although not generated using sine math, look pretty much like a sine wave to the naked eye.
Woah, sorry!
Hang on. I should’ve said “look pretty much like HALF of a sine wave”.
Because what we need, for each cycle of an individual wave, is to draw TWO Bezier curves: one for the “hill” of the cycle (up-then-down), and one for the “valley” (down-then-up).
So… here’s how we write a section of SVG path data, describing one cycle of a wave, with two Bezier curves two curves, in a function called cycleSVGPath.
Just to recap. We’re dealing with a wave, that has several cycles. And each cycle is drawn as two Bezier curves. This function’s job is to compile SVG path parameter data for one cycle‘s worth of curves – 2 curves.
cycleSVGPath: function (frequency, amplitude, cycleIndex) {
const centreY = this.centreY;
const minY = centreY - amplitude;
const maxY = centreY + amplitude;
const wavelength = 1 / frequency;
const wavelengthPixels = wavelength * this.svgWidth;
const cycleStartXPixels = wavelengthPixels * cycleIndex;
const cycleX = fraction => (fraction * wavelengthPixels) + cycleStartXPixels;
return `C ${cycleX(0.2)} ${minY} ${cycleX(0.3)} ${minY} ${cycleX(0.5)} ${centreY} C ${cycleX(0.7)} ${maxY} ${cycleX(0.8)} ${maxY} ${cycleX(1)} ${centreY}`;
},
The function takes cycleIndex as a parameter. This is because the function returns Bezier curve parameters for just one cycle of a wave with multiple cycles. And different cycles start at different positions. So the function needs to know what cycle it’s working on.
To start with, I defined a constant, centreY, that’s a copy of this.centreY – just to save space on subsequent lines of code.
Next, I calculated the minimum Y value – this depends on the wave’s amplitude, and because the SVG’s Y coordinates start from the top, it’ll define the highest point in the wave. Similarly, I calculated the maximum Y (lowest point).
Now, I need to work out the wavelength of the wave. That’s simple, it’s 1 / the frequency. A wave with frequency of 4 will have a wavelength of 0.25… meaning “each cycle of the wave takes up 0.25 x the width of the SVG”. So next, we calculate a wavelength in pixels… by multiplying the wavelength by the pixel width of the SVG.
The next step is to calculate the x coordinate where the cycle will start. That’s actually pretty simple math: it’s cycleIndex (whose lowest value is 0) multiplied by wavelengthPixels. So the 1st cycle will start at x = 0; the 2nd cycle will start at x = [1 x cycleWidth]… and so on.
Next, we define a function variable, cycleX. This function takes a fraction parameter – actually a decimal number between 0 and 1. The function uses wavelengthPixels and cycleStartXPixels to turn this fraction into an x coordinate within the current cycle of the wave. So the fraction “0.5” means “halfway through the width of the cycle”, “0” means “the starting x coordinate of the cycle” and “1” means “the end x coordinate of the cycle”.
Having done all of that, actually compiling the path data is simple – I’m using ES6 here, with template literals and expression interpolation just to make the whole thing more condensed…
return `C ${cycleX(0.2)} ${minY} ${cycleX(0.3)} ${minY} ${cycleX(0.5)} ${centreY} C ${cycleX(0.7)} ${maxY} ${cycleX(0.8)} ${maxY} ${cycleX(1)} ${centreY}`;
This means something like: “Draw a curve (C) with a Bezier handle at the x coordinate 0.2 of the way through this cycle’s width, at the top (minY); another handle 0.3 of the way through the cycle’s width, at the top; and the curve should finish exactly halfway through the cycle. Now draw another curve, with handles 0.7 and 0.8 of the way through the cycle’s width, at the bottom (maxY); the second curve should finish right at the end of the cycle.”
Putting the path together
Back up a level, function waveSVGPath(frequency), compiles an array cyclesSVGPaths with an element for each cycle of the wave. So it can use Javascript’s Array.join() method to concatenate the path sections for the cycles into one string; then prepend the “move the pen to the start position” instruction; then return the complete SVG path definition for a wave.
Opacity depends on frequency
A detail: I liked it when the opacity of each wave (how solid or faint it looks) depends on its frequency – higher-frequency waves look fainter. So there’s an opacity function for that.
Work in progress
The animation’s still a work-in-progress. Right now, every time the page refreshes, the waves start from the very beginning of the animation – that means they all seem to emanate initially from the same point at the left-hand side of the SVG, then gradually drift out of phase with respect to each other. I’d like them to start out of phase.
..And I don’t really like that widths are specified in pixels: It’d be more elegant if I could specify everything as percentages (or similar), and maybe drop the requirement for re-compiling the path data on window resize.
So if you get as far as fixing either of those issues – or if there’s anything else you want to say about the animation, or the tutorial – get in touch at dave@almostalldigital.co.uk.
I hope that was useful, and thanks for reading!
Cheers
Dave