Fourier Series: How to Create Mesmerizing Circular Drawings With Code
There is a mathematical idea so powerful it can turn any drawing — your signature, a portrait, a doodle of a cat — into a set of spinning circles. That idea is the Fourier series, and its computational cousin the Fourier transform. Originally developed by Joseph Fourier in the early 1800s to study heat flow, these tools have become foundational to signal processing, image compression, audio engineering, and — as we will explore here — mesmerizing generative art.
In this article, you will learn how the Fourier series decomposes complex signals into simple waves, how the Discrete Fourier Transform (DFT) converts any path into rotating circles called epicycles, and how to build increasingly complex visualizations with plain JavaScript and the Canvas API. Every code example is self-contained — paste it into an HTML file and open it in your browser.
What Is a Fourier Series?
At its core, a Fourier series says: any periodic function can be written as a sum of sines and cosines. A square wave, a sawtooth, a triangle wave — no matter how jagged or complex — can be built by adding simple sine waves of different frequencies, amplitudes, and phases.
Think of it like mixing paint. You start with a few primary colors (fundamental frequencies) and blend in more and more subtle shades (higher harmonics) until you match the target color (the target waveform) perfectly. The more harmonics you add, the closer the approximation.
Mathematically, a Fourier series for a periodic function f(x) with period 2L is:
f(x) = a0/2 + sum(an * cos(n*pi*x/L) + bn * sin(n*pi*x/L))
where the coefficients an and bn determine the amplitude of each harmonic. But you do not need to memorize the formula — the code examples below will make the concept intuitive.
The Math Behind the DFT
The Discrete Fourier Transform takes a finite sequence of N samples and converts them into N frequency components. Each component has an amplitude (how big the circle is), a frequency (how fast it spins), and a phase (its starting angle).
The DFT formula for the k-th frequency component is:
X[k] = sum(x[n] * e^(-i * 2 * pi * k * n / N)) for n = 0..N-1
Using Euler's formula, e^(i*theta) = cos(theta) + i*sin(theta), each term is just a rotation in the complex plane. The DFT finds the radius and starting angle of each spinning circle needed to reconstruct the original signal. Let's see this in action.
1. Square Wave Approximation
The classic first example: building a square wave from sine waves. A square wave's Fourier series contains only odd harmonics (1st, 3rd, 5th, 7th, ...) with amplitudes that decrease as 1/n. Watch as each harmonic is added and the sum gets closer to a perfect square wave.
<!DOCTYPE html>
<html><head><title>Square Wave Fourier</title></head>
<body style="margin:0;background:#111">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
c.width = 800; c.height = 400;
let numHarmonics = 1, maxH = 15, t = 0;
const trail = [];
function draw() {
ctx.fillStyle = 'rgba(17,17,17,0.15)';
ctx.fillRect(0, 0, c.width, c.height);
const cx = 200, cy = 200;
let x = cx, y = cy;
// Draw epicycles for odd harmonics
for (let i = 0; i < numHarmonics; i++) {
const n = 2 * i + 1;
const r = (4 / (n * Math.PI)) * 120;
const prevX = x, prevY = y;
x += r * Math.cos(n * t);
y += r * Math.sin(n * t);
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.beginPath(); ctx.arc(prevX, prevY, Math.abs(r), 0, Math.PI * 2); ctx.stroke();
ctx.strokeStyle = '#fff';
ctx.beginPath(); ctx.moveTo(prevX, prevY); ctx.lineTo(x, y); ctx.stroke();
}
// Dot at tip
ctx.fillStyle = '#0ff';
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
// Connect to waveform
trail.unshift(y);
if (trail.length > 400) trail.pop();
const waveX = 420;
ctx.strokeStyle = '#0ff';
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(waveX, y); ctx.stroke();
ctx.beginPath();
trail.forEach((v, i) => { i === 0 ? ctx.moveTo(waveX + i, v) : ctx.lineTo(waveX + i, v); });
ctx.stroke();
// Info
ctx.fillStyle = '#fff'; ctx.font = '14px monospace';
ctx.fillText(`Harmonics: ${numHarmonics} (odd: 1,3,5...)`, 10, 20);
t += 0.02;
if (t > Math.PI * 2) { t -= Math.PI * 2; numHarmonics = (numHarmonics % maxH) + 1; }
requestAnimationFrame(draw);
}
draw();
</script></body></html>
Notice how just 1 harmonic gives you a sine wave, 3 harmonics start to look square-ish, and by 7-9 harmonics the shape is clearly a square wave with slight ripples at the corners (the Gibbs phenomenon — a natural overshoot at discontinuities that never fully disappears, no matter how many terms you add).
2. Epicycle Drawing
Now let's see epicycles in their full glory. Five circles of decreasing radius rotate at different speeds. The tip of the last circle traces a pattern on the canvas. This is the fundamental principle behind Fourier drawing — stacking rotating circles creates complex paths.
<!DOCTYPE html>
<html><head><title>Epicycles</title></head>
<body style="margin:0;background:#111">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
c.width = 700; c.height = 700;
const epicycles = [
{ r: 100, speed: 1, phase: 0 },
{ r: 60, speed: -3, phase: 0.5 },
{ r: 35, speed: 5, phase: 1.2 },
{ r: 20, speed: -7, phase: 0.8 },
{ r: 12, speed: 11, phase: 2.1 }
];
let t = 0;
const path = [];
function draw() {
ctx.fillStyle = 'rgba(17,17,17,0.05)';
ctx.fillRect(0, 0, c.width, c.height);
let x = c.width / 2, y = c.height / 2;
for (const ep of epicycles) {
const px = x, py = y;
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.beginPath(); ctx.arc(px, py, ep.r, 0, Math.PI * 2); ctx.stroke();
x += ep.r * Math.cos(ep.speed * t + ep.phase);
y += ep.r * Math.sin(ep.speed * t + ep.phase);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(x, y); ctx.stroke();
}
ctx.fillStyle = '#ff0';
ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill();
path.push({ x, y });
if (path.length > 2000) path.shift();
ctx.strokeStyle = '#0ff';
ctx.lineWidth = 1.5;
ctx.beginPath();
path.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.stroke();
ctx.lineWidth = 1;
t += 0.015;
requestAnimationFrame(draw);
}
draw();
</script></body></html>
Try changing the radius, speed, and phase values to see how different combinations produce wildly different patterns. This is the artistic power of epicycles — small parameter changes create entirely new drawings. The speeds determine the frequency ratios (similar to Lissajous curves), while the phases control the starting orientation of each arm.
3. DFT on a Circle of Points
Let's apply the actual Discrete Fourier Transform to a set of points sampled from a circle. This demonstrates the fundamental concept: the DFT finds the epicycles needed to reconstruct a path. For a perfect circle, only 1 epicycle is needed — proving the DFT works correctly.
<!DOCTYPE html>
<html><head><title>DFT Circle</title></head>
<body style="margin:0;background:#111">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
c.width = 700; c.height = 700;
// Sample N points from a circle
const N = 64;
const points = [];
for (let i = 0; i < N; i++) {
const a = (2 * Math.PI * i) / N;
points.push({ x: Math.cos(a) * 120, y: Math.sin(a) * 120 });
}
// DFT
function dft(pts) {
const X = [];
const n = pts.length;
for (let k = 0; k < n; k++) {
let re = 0, im = 0;
for (let j = 0; j < n; j++) {
const angle = (2 * Math.PI * k * j) / n;
re += pts[j].x * Math.cos(angle) + pts[j].y * Math.sin(angle);
im += pts[j].y * Math.cos(angle) - pts[j].x * Math.sin(angle);
}
re /= n; im /= n;
X.push({ re, im, freq: k, amp: Math.sqrt(re * re + im * im), phase: Math.atan2(im, re) });
}
return X.sort((a, b) => b.amp - a.amp);
}
const spectrum = dft(points);
let t = 0;
const trail = [];
function draw() {
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, c.width, c.height);
let x = c.width / 2, y = c.height / 2;
// Draw only epicycles with significant amplitude
const active = spectrum.filter(s => s.amp > 0.5);
for (const s of active) {
const px = x, py = y;
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.beginPath(); ctx.arc(px, py, s.amp, 0, Math.PI * 2); ctx.stroke();
x += s.amp * Math.cos(s.freq * t + s.phase);
y += s.amp * Math.sin(s.freq * t + s.phase);
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(x, y); ctx.stroke();
}
trail.push({ x, y });
if (trail.length > N + 1) trail.shift();
ctx.strokeStyle = '#0f0';
ctx.lineWidth = 2;
ctx.beginPath();
trail.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.stroke();
ctx.lineWidth = 1;
// Info
ctx.fillStyle = '#fff'; ctx.font = '14px monospace';
ctx.fillText(`Active epicycles: ${active.length} (of ${N})`, 10, 25);
ctx.fillText('DFT of circle: only 1 epicycle has significant amplitude', 10, 45);
t += (2 * Math.PI) / (N * 4);
requestAnimationFrame(draw);
}
draw();
</script></body></html>
You should see that the DFT identifies essentially just one significant epicycle (with an amplitude of 120 — the circle's radius). All other components have near-zero amplitude. This is because a circle is a single rotation. The beauty of the DFT is that it finds this automatically.
4. DFT on a Square Path
A square is much more interesting than a circle. Its sharp corners require many harmonics to approximate. This example samples points along a square, computes the DFT, and lets you progressively add more epicycles to see the approximation improve.
<!DOCTYPE html>
<html><head><title>DFT Square</title></head>
<body style="margin:0;background:#111">
<canvas id="c"></canvas>
<div style="position:absolute;top:10px;left:10px;color:#fff;font:14px monospace">
Epicycles: <input type="range" id="sl" min="1" max="128" value="20"> <span id="sv">20</span>
</div>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
c.width = 700; c.height = 700;
const sl = document.getElementById('sl');
const sv = document.getElementById('sv');
// Square path
const N = 128, side = 120, pts = [];
for (let i = 0; i < N; i++) {
const frac = i / N;
let x, y;
if (frac < 0.25) { x = side; y = -side + 8 * side * frac; }
else if (frac < 0.5) { x = side - 8 * side * (frac - 0.25); y = side; }
else if (frac < 0.75) { x = -side; y = side - 8 * side * (frac - 0.5); }
else { x = -side + 8 * side * (frac - 0.75); y = -side; }
pts.push({ x, y });
}
function dft(pts) {
const X = [], n = pts.length;
for (let k = 0; k < n; k++) {
let re = 0, im = 0;
for (let j = 0; j < n; j++) {
const a = (2 * Math.PI * k * j) / n;
re += pts[j].x * Math.cos(a) + pts[j].y * Math.sin(a);
im += pts[j].y * Math.cos(a) - pts[j].x * Math.sin(a);
}
re /= n; im /= n;
X.push({ re, im, freq: k, amp: Math.sqrt(re * re + im * im), phase: Math.atan2(im, re) });
}
return X.sort((a, b) => b.amp - a.amp);
}
const spectrum = dft(pts);
let t = 0;
const trail = [];
function draw() {
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, c.width, c.height);
const numE = parseInt(sl.value);
sv.textContent = numE;
let x = c.width / 2, y = c.height / 2;
for (let i = 0; i < numE && i < spectrum.length; i++) {
const s = spectrum[i];
const px = x, py = y;
ctx.strokeStyle = 'rgba(100,200,255,0.15)';
ctx.beginPath(); ctx.arc(px, py, s.amp, 0, Math.PI * 2); ctx.stroke();
x += s.amp * Math.cos(s.freq * t + s.phase);
y += s.amp * Math.sin(s.freq * t + s.phase);
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(x, y); ctx.stroke();
}
trail.push({ x, y });
if (trail.length > N + 1) trail.shift();
ctx.strokeStyle = '#0ff';
ctx.lineWidth = 2;
ctx.beginPath();
trail.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.stroke();
ctx.lineWidth = 1;
// Draw target square faintly
ctx.strokeStyle = 'rgba(255,255,0,0.2)';
ctx.strokeRect(c.width / 2 - side, c.height / 2 - side, side * 2, side * 2);
t += (2 * Math.PI) / (N * 4);
requestAnimationFrame(draw);
}
draw();
</script></body></html>
Drag the slider to change how many epicycles are used. With just 5 epicycles the shape is a wobbly blob. At 20 it is recognizably square-ish. At 50+ it is nearly perfect. This beautifully demonstrates the Fourier series convergence — each added term captures finer detail. The corners are the hardest part, requiring high-frequency components to represent their sharp angles.
5. Fourier Series of a Sawtooth Wave
The sawtooth wave has a different Fourier series than the square wave: it uses all harmonics (not just odd ones), with amplitudes that alternate in sign and decrease as 1/n. This example shows the rotating circles on the left building the waveform that scrolls on the right.
<!DOCTYPE html>
<html><head><title>Sawtooth Fourier</title></head>
<body style="margin:0;background:#111">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
c.width = 900; c.height = 400;
const numH = 12;
let t = 0;
const wave = [];
function draw() {
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, c.width, c.height);
const cx = 180, cy = 200;
let x = cx, y = cy;
// Sawtooth Fourier: sum of (-1)^(n+1) * sin(n*t) / n * (2/pi)
for (let n = 1; n <= numH; n++) {
const r = (2 / (n * Math.PI)) * 120;
const sign = Math.pow(-1, n + 1);
const px = x, py = y;
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
ctx.beginPath(); ctx.arc(px, py, Math.abs(r), 0, Math.PI * 2); ctx.stroke();
x += r * sign * Math.cos(n * t) * (n === 1 ? 1 : 1);
y += r * sign * Math.sin(n * t);
ctx.strokeStyle = `hsla(${n * 30}, 80%, 60%, 0.6)`;
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(x, y); ctx.stroke();
}
ctx.fillStyle = '#ff0';
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
// Waveform panel
wave.unshift(y);
if (wave.length > 500) wave.pop();
const waveStart = 350;
ctx.strokeStyle = 'rgba(255,255,0,0.3)';
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(waveStart, y); ctx.stroke();
ctx.strokeStyle = '#0ff';
ctx.lineWidth = 1.5;
ctx.beginPath();
wave.forEach((v, i) => i === 0 ? ctx.moveTo(waveStart + i, v) : ctx.lineTo(waveStart + i, v));
ctx.stroke();
ctx.lineWidth = 1;
// Labels
ctx.fillStyle = '#fff'; ctx.font = '13px monospace';
ctx.fillText('Rotating circles', 100, 20);
ctx.fillText('Sawtooth waveform', waveStart + 100, 20);
ctx.fillText(`${numH} harmonics`, 10, 385);
// Divider
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.beginPath(); ctx.moveTo(waveStart - 15, 0); ctx.lineTo(waveStart - 15, c.height); ctx.stroke();
t += 0.025;
requestAnimationFrame(draw);
}
draw();
</script></body></html>
Compare this to the square wave from Example 1. The sawtooth uses every harmonic, so its circles are more numerous but individually smaller. The alternating sign means the circles spin in alternating directions, creating the characteristic ramp-and-drop shape of the sawtooth.
6. Interactive Drawing to Epicycles
This is the most satisfying example. Draw anything with your mouse or finger, and watch the Fourier transform decompose your drawing into a set of spinning circles that reconstruct your path. This is the same math behind those viral "Fourier series draws Homer Simpson" videos.
<!DOCTYPE html>
<html><head><title>Draw to Epicycles</title></head>
<body style="margin:0;background:#111">
<canvas id="c"></canvas>
<p style="position:absolute;top:5px;left:10px;color:#fff;font:14px monospace;margin:0">Draw a shape! (click & drag)</p>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
c.width = 800; c.height = 700;
let drawing = true, userPath = [], spectrum = [], trail = [], t = 0;
c.addEventListener('mousedown', () => { drawing = true; userPath = []; spectrum = []; trail = []; });
c.addEventListener('mousemove', e => {
if (!drawing || !e.buttons) return;
userPath.push({ x: e.offsetX - c.width / 2, y: e.offsetY - c.height / 2 });
});
c.addEventListener('mouseup', () => { drawing = false; if (userPath.length > 10) computeDFT(); });
c.addEventListener('touchstart', e => { e.preventDefault(); drawing = true; userPath = []; spectrum = []; trail = []; }, { passive: false });
c.addEventListener('touchmove', e => {
e.preventDefault();
const r = c.getBoundingClientRect();
const touch = e.touches[0];
userPath.push({ x: touch.clientX - r.left - c.width / 2, y: touch.clientY - r.top - c.height / 2 });
}, { passive: false });
c.addEventListener('touchend', () => { drawing = false; if (userPath.length > 10) computeDFT(); });
function resample(pts, n) {
const out = [];
for (let i = 0; i < n; i++) {
const idx = (i / n) * pts.length;
const lo = Math.floor(idx), hi = Math.min(lo + 1, pts.length - 1);
const frac = idx - lo;
out.push({ x: pts[lo].x * (1 - frac) + pts[hi].x * frac, y: pts[lo].y * (1 - frac) + pts[hi].y * frac });
}
return out;
}
function computeDFT() {
const pts = resample(userPath, 128);
const N = pts.length;
spectrum = [];
for (let k = 0; k < N; k++) {
let re = 0, im = 0;
for (let j = 0; j < N; j++) {
const a = (2 * Math.PI * k * j) / N;
re += pts[j].x * Math.cos(a) + pts[j].y * Math.sin(a);
im += pts[j].y * Math.cos(a) - pts[j].x * Math.sin(a);
}
re /= N; im /= N;
spectrum.push({ freq: k, amp: Math.sqrt(re * re + im * im), phase: Math.atan2(im, re) });
}
spectrum.sort((a, b) => b.amp - a.amp);
trail = []; t = 0;
}
function draw() {
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, c.width, c.height);
if (drawing && userPath.length > 1) {
ctx.strokeStyle = '#666';
ctx.beginPath();
userPath.forEach((p, i) => i === 0 ? ctx.moveTo(p.x + c.width / 2, p.y + c.height / 2) : ctx.lineTo(p.x + c.width / 2, p.y + c.height / 2));
ctx.stroke();
}
if (spectrum.length > 0) {
let x = c.width / 2, y = c.height / 2;
const useN = Math.min(spectrum.length, 80);
for (let i = 0; i < useN; i++) {
const s = spectrum[i];
if (s.amp < 0.5) continue;
const px = x, py = y;
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.beginPath(); ctx.arc(px, py, s.amp, 0, Math.PI * 2); ctx.stroke();
x += s.amp * Math.cos(s.freq * t + s.phase);
y += s.amp * Math.sin(s.freq * t + s.phase);
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(x, y); ctx.stroke();
}
trail.push({ x, y });
if (trail.length > 130) trail.shift();
ctx.strokeStyle = '#0ff';
ctx.lineWidth = 2;
ctx.beginPath();
trail.forEach((p, i) => i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y));
ctx.stroke();
ctx.lineWidth = 1;
t += (2 * Math.PI) / 128;
}
requestAnimationFrame(draw);
}
draw();
</script></body></html>
Draw a star, a letter, a heart — anything you like. The DFT will find the set of spinning circles that reconstruct it. Notice how smooth curves need fewer epicycles than shapes with sharp corners. Try drawing a circle — you will see that only one epicycle has significant amplitude, confirming what we learned in Example 3.
7. Frequency Spectrum Visualizer
To understand what the Fourier transform actually computes, it helps to visualize the frequency spectrum. This example shows a signal composed of adjustable frequency components alongside its amplitude spectrum as a bar chart. Drag the sliders to add or remove frequencies and watch how the signal and spectrum change together.
<!DOCTYPE html>
<html><head><title>Frequency Spectrum</title></head>
<body style="margin:0;background:#111">
<canvas id="c"></canvas>
<div style="position:absolute;top:10px;right:10px;color:#fff;font:12px monospace">
F1: <input type="range" id="f1" min="0" max="100" value="80"><br>
F2: <input type="range" id="f2" min="0" max="100" value="40"><br>
F3: <input type="range" id="f3" min="0" max="100" value="20"><br>
F5: <input type="range" id="f5" min="0" max="100" value="0"><br>
F8: <input type="range" id="f8" min="0" max="100" value="0">
</div>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
c.width = 800; c.height = 500;
const freqs = [1, 2, 3, 5, 8];
const sliders = ['f1','f2','f3','f5','f8'].map(id => document.getElementById(id));
let phase = 0;
function draw() {
ctx.fillStyle = '#111';
ctx.fillRect(0, 0, c.width, c.height);
const amps = sliders.map(s => parseInt(s.value) / 100);
// Time domain signal - top half
ctx.strokeStyle = '#fff'; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(0, 125); ctx.lineTo(520, 125); ctx.stroke();
ctx.strokeStyle = '#0ff'; ctx.lineWidth = 2;
ctx.beginPath();
for (let px = 0; px < 520; px++) {
const t = (px / 520) * Math.PI * 4 + phase;
let val = 0;
for (let i = 0; i < freqs.length; i++) val += amps[i] * Math.sin(freqs[i] * t);
const y = 125 - val * 80;
px === 0 ? ctx.moveTo(px, y) : ctx.lineTo(px, y);
}
ctx.stroke();
ctx.lineWidth = 1;
ctx.fillStyle = '#fff'; ctx.font = '13px monospace';
ctx.fillText('Signal (time domain)', 10, 20);
// Frequency domain - bottom half
ctx.fillText('Spectrum (frequency domain)', 10, 280);
const barW = 50, gap = 20, startX = 60;
for (let i = 0; i < freqs.length; i++) {
const h = amps[i] * 150;
const x = startX + i * (barW + gap);
const gradient = ctx.createLinearGradient(x, 450 - h, x, 450);
gradient.addColorStop(0, `hsl(${freqs[i] * 40}, 80%, 60%)`);
gradient.addColorStop(1, `hsl(${freqs[i] * 40}, 80%, 30%)`);
ctx.fillStyle = gradient;
ctx.fillRect(x, 450 - h, barW, h);
ctx.fillStyle = '#fff';
ctx.fillText(`f=${freqs[i]}`, x + 10, 470);
ctx.fillText(`${Math.round(amps[i] * 100)}%`, x + 8, 445 - h);
}
// Draw individual components faintly
for (let i = 0; i < freqs.length; i++) {
if (amps[i] < 0.05) continue;
ctx.strokeStyle = `hsla(${freqs[i] * 40}, 70%, 55%, 0.3)`;
ctx.beginPath();
for (let px = 0; px < 520; px++) {
const t = (px / 520) * Math.PI * 4 + phase;
const y = 125 - amps[i] * Math.sin(freqs[i] * t) * 80;
px === 0 ? ctx.moveTo(px, y) : ctx.lineTo(px, y);
}
ctx.stroke();
}
phase += 0.02;
requestAnimationFrame(draw);
}
draw();
</script></body></html>
This dual view — time domain on top, frequency domain on the bottom — is how engineers and scientists use the Fourier transform every day. Audio equalizers, image compression (JPEG), MRI scanners, and radio receivers all rely on exactly this decomposition. The spectrum tells you "which frequencies are present and how strong each one is."
8. Generative Fourier Art
Finally, let's create something purely artistic. This example uses multiple independent epicycle chains with HSL color trails, fade effects, and screen blend mode to create continuously evolving generative art. Each chain has different frequencies and phases, producing complex interference patterns.
<!DOCTYPE html>
<html><head><title>Fourier Art</title></head>
<body style="margin:0;background:#000;overflow:hidden">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
c.width = innerWidth; c.height = innerHeight;
addEventListener('resize', () => { c.width = innerWidth; c.height = innerHeight; });
const chains = [
{ epicycles: [{r:120,s:1},{r:60,s:-3},{r:30,s:7},{r:15,s:-11}], hueBase: 0 },
{ epicycles: [{r:100,s:2},{r:50,s:-5},{r:25,s:9},{r:12,s:-13}], hueBase: 120 },
{ epicycles: [{r:90,s:-1},{r:45,s:4},{r:22,s:-8},{r:11,s:15}], hueBase: 240 },
{ epicycles: [{r:70,s:3},{r:35,s:-7},{r:18,s:11},{r:9,s:-17}], hueBase: 60 },
];
let t = 0;
function draw() {
ctx.fillStyle = 'rgba(0,0,0,0.03)';
ctx.fillRect(0, 0, c.width, c.height);
ctx.globalCompositeOperation = 'screen';
for (const chain of chains) {
let x = c.width / 2, y = c.height / 2;
for (const ep of chain.epicycles) {
x += ep.r * Math.cos(ep.s * t);
y += ep.r * Math.sin(ep.s * t);
}
const hue = (chain.hueBase + t * 10) % 360;
ctx.fillStyle = `hsla(${hue}, 90%, 60%, 0.8)`;
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
// Connector lines between nearby chains for extra complexity
const x2 = c.width / 2 + chain.epicycles[0].r * Math.cos(chain.epicycles[0].s * t * 1.01);
const y2 = c.height / 2 + chain.epicycles[0].r * Math.sin(chain.epicycles[0].s * t * 1.01);
ctx.strokeStyle = `hsla(${hue}, 80%, 50%, 0.1)`;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x2, y2); ctx.stroke();
}
// Draw cross-chain connections
for (let i = 0; i < chains.length; i++) {
const a = chains[i], b = chains[(i + 1) % chains.length];
let ax = c.width / 2, ay = c.height / 2, bx = c.width / 2, by = c.height / 2;
for (const ep of a.epicycles) { ax += ep.r * Math.cos(ep.s * t); ay += ep.r * Math.sin(ep.s * t); }
for (const ep of b.epicycles) { bx += ep.r * Math.cos(ep.s * t); by += ep.r * Math.sin(ep.s * t); }
const dist = Math.hypot(ax - bx, ay - by);
if (dist < 200) {
ctx.strokeStyle = `hsla(${(a.hueBase + b.hueBase) / 2}, 70%, 50%, ${0.15 * (1 - dist / 200)})`;
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
}
}
ctx.globalCompositeOperation = 'source-over';
t += 0.008;
requestAnimationFrame(draw);
}
draw();
</script></body></html>
This creates an endlessly evolving, never-repeating artwork. The four epicycle chains produce four colored streams that weave around each other. The screen blend mode makes overlapping colors glow brighter, and the slow fade creates trailing light ribbons. Since the frequency ratios are irrational relative to each other, the pattern never exactly repeats — a hallmark of generative art inspired by spirograph and Lissajous curve aesthetics.
Key Concepts Summary
Let's recap what we've covered:
- Fourier series — any periodic function equals a sum of sines and cosines (harmonics)
- Epicycles — rotating circles stacked on top of each other, tracing a path
- DFT — the Discrete Fourier Transform converts sample points into frequency components (amplitude, frequency, phase)
- Convergence — more terms/epicycles = better approximation; sharp features need many harmonics
- Gibbs phenomenon — overshoot at discontinuities, even with infinite terms
- Frequency spectrum — a bar chart showing which frequencies are present in a signal
The Fourier transform is one of the most important algorithms in all of science and engineering. Understanding it through visual, interactive examples makes the math tangible in a way that textbooks rarely achieve.
Further Reading
If you enjoyed this deep dive into Fourier visualization, explore these related articles on Lumitree:
- Math Art: Beautiful Mathematical Patterns With Code — more mathematical curves and formulas turned into visual art
- Lissajous Curves: Mesmerizing Harmonic Patterns — another family of curves created by combining oscillations
- Spirograph: Spiral Patterns With Code — hypotrochoids and epitrochoids, the classic rotating-circle toy
- Bezier Curves — another mathematical approach to creating smooth curves with code
The Fourier transform turns the abstract world of frequencies into something you can see, draw, and play with. Whether you are a student trying to understand signal processing, an artist exploring mathematical beauty, or a creative coder looking for new techniques — epicycles are endlessly rewarding to experiment with.
Ready to explore more generative art? Visit Lumitree to discover unique algorithmic micro-worlds, or browse the full collection of creative coding tutorials to keep learning.