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.
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();
}
};
}
}
];
/* ── Motor ────────────────────────────────────────────────────────────── */
function accent() {
const v = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim();
const n = v.split(/\s+/).map(Number);
return (n.length === 3 && n.every(x => !isNaN(x))) ? n.join(',') : '255,179,71';
}
function mount(canvas, id, opts) {
opts = opts || {};
const def = EXPERIMENTS.find(e => e.id === id);
if (!canvas || !def) return null;
const ctx = canvas.getContext('2d');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
let w = 0, h = 0, inst = null, raf = 0, t = 0, acc = accent();
// Los experimentos escriben sus lecturas con una tipografía fija pensada
// para un lienzo ancho. En un teléfono el lienzo mide ~330px y esas líneas
// se salían cortadas por el borde derecho, en los 38. En vez de reescribir
// cada texto, el motor encoge la tipografía de esa llamada lo justo para
// que entre, y solo cuando hace falta.
const drawText = ctx.fillText.bind(ctx);
ctx.fillText = function (txt, x, y) {
const room = w - x - 8;
if (room <= 0) return;
const need = ctx.measureText(txt).width;
if (need <= room) return drawText(txt, x, y);
const face = /^(\d+(?:\.\d+)?)px\s+(.+)$/.exec(ctx.font);
const shrunk = face && parseFloat(face[1]) * (room / need);
if (shrunk && shrunk >= 7.5) {
const keep = ctx.font;
ctx.font = shrunk.toFixed(2) + 'px ' + face[2];
drawText(txt, x, y);
ctx.font = keep;
} else {
drawText(txt, x, y, room); // ya demasiado chica: que el canvas la comprima
}
};
const P = {};
(def.params || []).forEach(p => { P[p.key] = p.def; });
const M = { x: 0, y: 0, in: false };
canvas.addEventListener('pointermove', e => {
const r = canvas.getBoundingClientRect();
M.x = e.clientX - r.left; M.y = e.clientY - r.top; M.in = true;
}, { passive: true });
canvas.addEventListener('pointerleave', () => { M.in = false; }, { passive: true });
function size() {
const r = canvas.getBoundingClientRect();
if (!r.width || !r.height) return false;
const dpr = Math.min(devicePixelRatio || 1, 2);
w = r.width; h = r.height;
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (!inst) inst = def.make(w, h);
else if (inst.resize) inst.resize(w, h);
ctx.clearRect(0, 0, w, h);
return true;
}
function frame() {
raf = requestAnimationFrame(frame);
if (!w && !size()) return;
t += reduce ? 0 : 0.016;
if (inst.fade) { ctx.fillStyle = `rgba(0,0,0,${inst.fade})`; ctx.fillRect(0, 0, w, h); }
else if (!inst.persist) ctx.clearRect(0, 0, w, h);
inst.step(ctx, w, h, t, acc, P, M);
if (reduce) { cancelAnimationFrame(raf); raf = 0; }
}
addEventListener('resize', () => { w = 0; }, { passive: true });
addEventListener('themechange', () => { acc = accent(); });
new IntersectionObserver(es => es.forEach(en => {
if (en.isIntersecting) { if (!raf) { size(); frame(); } }
else if (raf) { cancelAnimationFrame(raf); raf = 0; }
}), { threshold: 0.01 }).observe(canvas);
const api = {
id, params: P,
set(k, v) { P[k] = v; },
reset() { if (inst && inst.reset) { inst.reset(); ctx.clearRect(0, 0, w, h); } },
stop() { if (raf) cancelAnimationFrame(raf); }
};
if (opts.controls) buildControls(opts.controls, def, api);
return api;
}
/* ── Controles ────────────────────────────────────────────────────────── */
function buildControls(host, def, api) {
if (!def.params || !def.params.length) return;
const es = () => document.documentElement.lang === 'es';
host.innerHTML = '';
def.params.forEach(p => {
const row = document.createElement('label');
row.className = 'ctl';
const val = document.createElement('output');
const fmt = v => (p.step < 1 ? Number(v).toFixed(String(p.step).split('.')[1].length) : v) + (p.unit || '');
row.innerHTML = `<span class="ctl-l" data-en="${p.label.en}" data-es="${p.label.es}">${es() ? p.label.es : p.label.en}</span>`;
const input = document.createElement('input');
input.type = 'range'; input.min = p.min; input.max = p.max; input.step = p.step; input.value = p.def;
val.className = 'ctl-v'; val.textContent = fmt(p.def);
input.addEventListener('input', () => { api.set(p.key, +input.value); val.textContent = fmt(input.value); });
row.appendChild(input); row.appendChild(val);
host.appendChild(row);
});
const btn = document.createElement('button');
btn.type = 'button'; btn.className = 'ctl-reset';
btn.dataset.en = 'Reset'; btn.dataset.es = 'Reiniciar';
btn.textContent = es() ? 'Reiniciar' : 'Reset';
btn.addEventListener('click', () => {
def.params.forEach((p, i) => {
const inp = host.querySelectorAll('input')[i];
inp.value = p.def; inp.dispatchEvent(new Event('input'));
});
api.reset();
});
host.appendChild(btn);
}
global.LAB = { experiments: EXPERIMENTS, mount, accent };
})(window);