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.
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();
}
};
}
},