Harmonic analysis

Fourier series

A circle turning on the rim of another circle, on another, on another. Add enough of them at the right sizes and speeds and the tip traces a square wave: a shape with corners, drawn entirely by things with none.

PORNicolás MorenoCofounder · CTO
EXP-038

Move the cursor over the canvas — it is part of the simulation.

Drag your finger across the canvas — it is part of the simulation.

What to look for

The ripple near the corners never goes away. Adding more circles makes it narrower but not shorter: it overshoots by about 9% no matter how many terms you use. That is the Gibbs phenomenon, and it is a property of the series rather than a bug.

The maths

f(t) = Σ (4/nπ)·sin(nt), n odd

The square wave as a sum of sines. Only odd harmonics, each with amplitude falling as 1/n, which is why the series converges so slowly.

radius = 4/nπ

Each circle in the chain is one term of that sum. Its radius is the amplitude and its rotation rate is the frequency.

How it is built

Six circles, computed and drawn from scratch each frame: start at the centre, for each term add a rotating offset, and draw both the circle and the arm. The vertical position of the last tip is pushed into a buffer and plotted to the right, which is the waveform.

The code, step by step

Cut straight from lab.js. These are the real lines that run above, not a simplified version.

01

Time, or your cursor

const phase = M.in ? (M.x / w) * 12 : t * P.speed;
let x = cx, y = cy;

Normally the phase advances with time. With the pointer over the canvas it is taken from the cursor position instead, so you can scrub the wave by hand and stop it wherever you want.

02

One circle per harmonic

const n = i * 2 + 1, r = R * (4 / (n * Math.PI));
const px = x, py = y;
x += r * Math.cos(n * phase); y += r * Math.sin(n * phase);

n takes odd values only and the radius is 4/nπ: those are the Fourier coefficients of a square wave, straight from the series. The rotation rate is n, so higher harmonics spin faster.

03

Plot the tip over time

trace.unshift(y);
if (trace.length > Math.floor(w * 0.6)) trace.pop();
ctx.strokeStyle = `rgba(${acc},0.85)`; ctx.lineWidth = 1.4;
ctx.beginPath();
for (let i = 0; i < trace.length; i++) {
  const tx = w * 0.62 + i, ty = trace[i];
  i ? ctx.lineTo(tx, ty) : ctx.moveTo(tx, ty);
}
ctx.stroke();

Only the vertical position of the last tip is recorded. Pushing it to the front of a buffer and drawing that buffer to the right turns rotation into a waveform.

Why it matters

Fourier claimed in 1807 that any function could be written this way and was rejected by the leading mathematicians of his time. The claim was too strong as stated, but the corrected version underpins JPEG, MP3, MRI, and essentially every signal processing system built since.

The whole thing

Everything above, in one piece. This is the entire experiment as it lives in lab.js.

FULL

lab.js · fourier

{ id: 'fourier',
      name: L('Fourier series', 'Serie de Fourier'),
      note: L('Enough circles turning at once will draw any shape.',
              'Suficientes círculos girando a la vez dibujan cualquier forma.'),
      params: [
        { key: 'terms', label: L('Harmonics', 'Armónicos'), min: 1, max: 20, step: 1, def: 6 },
        { key: 'speed', label: L('Speed', 'Velocidad'),     min: 0, max: 2, step: 0.05, def: 0.6 }
      ],
      make() {
        let trace = [];
        return {
          reset() { trace = []; },
          step(ctx, w, h, t, acc, P, M) {
            const cx = w * 0.34, cy = h / 2, R = Math.min(w, h) * 0.17;
            // El mouse arrastra la fase: puedes recorrer la onda a mano
            const phase = M.in ? (M.x / w) * 12 : t * P.speed;
            let x = cx, y = cy;
            ctx.lineWidth = 1;
            for (let i = 0; i < (P.terms | 0); i++) {
              const n = i * 2 + 1, r = R * (4 / (n * Math.PI));
              const px = x, py = y;
              x += r * Math.cos(n * phase); y += r * Math.sin(n * phase);
              ctx.strokeStyle = `rgba(${acc},0.20)`;
              ctx.beginPath(); ctx.arc(px, py, r, 0, TAU); ctx.stroke();
              ctx.strokeStyle = `rgba(${acc},0.45)`;
              ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(x, y); ctx.stroke();
            }
            trace.unshift(y);
            if (trace.length > Math.floor(w * 0.6)) trace.pop();
            ctx.strokeStyle = `rgba(${acc},0.85)`; ctx.lineWidth = 1.4;
            ctx.beginPath();
            for (let i = 0; i < trace.length; i++) {
              const tx = w * 0.62 + i, ty = trace[i];
              i ? ctx.lineTo(tx, ty) : ctx.moveTo(tx, ty);
            }
            ctx.stroke();
            ctx.strokeStyle = `rgba(${acc},0.3)`; ctx.lineWidth = 1;
            ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(w * 0.62, trace[0]); ctx.stroke();
          }
        };
      }
    },
AUTHORNicolás Moreno

Cofounder and CTO at B&N Solutions.