Draw My Canvas / studio

Fourier Epicycles

A drawing machine with no memory and no plan. Forty-nine circles hang tip to tail, each turning a whole number of times per lap, and the last tip holds the pen. Nobody tells it where the shape is — the shape is what the radii and starting angles already are.

Plate 27fourier-series

Waiting for the animation above to load…

What lands in the file, and what the width does

One click writes whatever the animation is drawing at that moment to a PNG, with the drawmycanvas.com mark drawn into the picture rather than laid over it. Leave the width box empty and you get the stage exactly as your browser rasterised it — your window’s width times its device pixel ratio, which is about 1,600 px across from a 1280‑px window on a HiDPI laptop and about 353 px from a 390‑px phone.

Type a width instead, or take a preset, and the frame is redrawn into a canvas that wide: the height follows the stage’s own shape and the mark scales with it. The stage is 16:9 on a wide window and 4:3 below 560 px, so a width of 1200 saves 1200×675 on a laptop and 1200×900 on a phone. 1200 px is the width Open Graph and X link cards are cut from — the canonical card is 1200×630, and a card crops the extra height rather than letterboxing it.

Honest limits. Asking for more pixels than the stage was drawn at resamples pixels that were never drawn: a 1920‑px file exported from a 353‑px phone stage is bigger, not sharper. For a big file that is sharp, use Save as wallpaper: it draws the plate again from scratch at exactly 1179×2556 or 1290×2796 (phones), 1920×1080, 2560×1440 or 3840×2160 (4K), so every line is rasterised at that size and the shape is the screen’s, never stretched. Because the plate restarts, a wallpaper is a fresh run of it rather than the exact frame on screen; it is given as long as the stage has been running, up to 20 seconds, to develop. A very large width is a real memory allocation and a browser is allowed to refuse it; when that happens the line above says so plainly and nothing else on the page changes. Stills are PNG only — no JPEG, no WebP. On browsers that can record video, Record a clip saves 5, 10 or 20 seconds of the running animation as MP4 or WebM (whichever this browser can encode) at the same width, with the mark in every frame. A clip is not a seamless loop, it has no audio, and a width bigger than the stage is resampled rather than sharper — only the wallpaper is redrawn at its size. Where a browser cannot record video the button never appears and a PNG is the only export. And nothing is uploaded: the frame or clip is assembled in your own browser, so no frame of this plate ever reaches us.

Live on an HTML canvas · vanilla JavaScript · no dependencies Open fullscreen

About this piece

Forty-nine circles, hung tip to tail, each one turning at its own fixed rate. The last tip holds a pen. That is the entire machine, and the closed curve it draws — the twelve- or seven- or five-pointed coral star on the plate above — is not stored anywhere: there is no list of points to follow and nothing that knows the shape. The shape is a consequence of forty-nine radii and forty-nine starting angles, and nothing else.

The reason this works is one line of mathematics. Write a closed curve in the plane as a complex-valued function of a lap parameter u running 0 to 1, z(u). It is periodic, so it has a Fourier series: z(u) = Σ X[k]·e2πi·k·u. Every term in that sum is literally a circle — a point at fixed distance |X[k]| from wherever the previous term left off, going round exactly k whole times per lap, backwards when k is negative. Adding complex numbers is adding vectors, so adding the terms and stacking the circles are the same operation drawn two ways. The plate draws both at once, which is the only reason to make it: you can watch the identity rather than take it on trust.

The target is a star polygon with five to eleven points, jittered in angle and radius and chosen by an integer hash. Its waist runs deep — between 0.10 and 0.28 of the outer radius — and that is a composition decision with a measurement behind it, given below. Every shape on the plate is picked by hash rather than by Math.random(), so the small gallery tile and the full-screen embed run the same sequence and can be compared frame for frame.

Why the corners never arrive

The faint straight-edged star sitting behind the smooth coral one is the target, drawn exactly. The coral curve is what forty-nine circles can actually reach, and the two do not agree. At the size this page draws it — a figure of radius 324 px on a 1280×720 stage — the reconstruction sits a mean of 4.4 px from the target all the way round, but the worst disagreement averages 17.8 px and it is always in the same place: the points. The drawn star's tips fall a mean of 16.6 px short of the target's tips, about 5% of the reach.

That is not a bug and it is not a tuning problem. A corner is a discontinuity in direction, and every term of the series is smooth. Adding smooth things gives a smooth thing; the sum can approach the corner but no finite number of terms ever contains one. Near the discontinuity the partial sum also overshoots before it settles — the ripples you can see running along the straight flanks of each spike, next to the tips and nowhere else. That is the Gibbs phenomenon, and its overshoot does not shrink as you add terms: it narrows, crowding closer to the corner, while its height converges to a fixed fraction of the jump.

The returns measure like this, as a mean over twelve shapes at the same 324 px radius:

  • 5 circles — mean error 67.5 px. A wobbling ellipse. The star is not there at all.
  • 17 circles — 33.4 px. The right number of lobes, badly.
  • 25 circles — 9.0 px. Recognisably the shape.
  • 49 circles — 4.8 px, worst case 26.5 px. What this plate draws.
  • 97 circles — 1.5 px. Four times the arithmetic for an improvement most of it spends below the width of the line.

Forty-nine is chosen at the knee of that curve. Going further would cost a full transform's worth of work per shape to move the line by less than it is thick — and would bury the machine, because the extra circles are all smaller than a pixel and would draw as a smudge.

Why the chain is ordered by size, and why the star is so deep

The series is written down in frequency order, k = −24 … 24. This plate draws it in descending amplitude instead. Addition commutes, so the pen lands on exactly the same point either way and the curve is identical to the last bit — but drawn in frequency order the chain reads as a jitter of similar-sized links, and drawn largest-first it reads as one big wheel carrying steadily smaller ones, which is a machine you can follow with your eye. The non-turning k = 0 term is the centroid and is pegged to the middle of the frame; it is the only one that does not spin.

The deep waist is the other half of that decision, and it is the one with a number attached. Measured at 324 px, a shallow star — waist 0.34 to 0.64 of the outer radius — gives first three radii of 213 / 27 / 17 px: one enormous circle plus a rounding error, so the picture degenerates into a single rim with a knot of specks stuck to the pen. Driving the radius nearly to the centre moves real amplitude into the k = ±V harmonics of a V-pointed star, and the same measurement gives 172 / 56 / 31 px. Same algorithm, same forty-nine terms; the difference between a diagram and a blot.

The outline is resampled to 512 points spaced evenly by arc length before it is transformed, and that is not housekeeping either. The parameterisation is the input to the transform: sampling vertex by vertex would spend as many samples on a three-pixel edge as on a three-hundred-pixel one and put energy in the spectrum that the shape itself does not have.

Reading the plate: what to watch for

  • The pen is the last tip, and the coral line ends exactly at it. The trace and the pen are computed from the same sum at the same quantised lap fraction, so the line cannot lag the dot or run ahead of it.
  • The ripples live next to the points and nowhere else. Along a flat flank far from a corner the curve lies right on the target. That is Gibbs, localised to the discontinuity, and it is the most visible thing on the plate once you know to look for it.
  • The pen does not move at constant speed. The target was resampled by arc length, but a truncated series re-times it: the step between consecutive trace points varies by about 22% (one standard deviation), and the pen visibly slows into a point and hurries across a flank. Mean speed on this stage is about 485 px a second.
  • The big circle is the lap, and everything else is detail. The largest term is the once-per-lap component: strip everything else away and the pen runs round a plain circle. The star is what the other forty-eight put on top of it.
  • The faint curve behind is the previous shape. One finished star is kept when the next one starts, so the plate is a layered sheet rather than a single figure — and the layer is built up front rather than waited for, so the first frame you see already has it.

Colour, weight and how the picture is put together

One frame is six strokes, whatever the size of the frame: the ghost of the last shape, the target outline, every circle rim in one path, every spoke in one more, the curve drawn so far, and the pen. Forty-nine strokeStyle writes and forty-nine path submissions for a picture holding two inks would be the obvious way to do it and the wrong one. Circles narrower than 1.1 px are skipped rather than drawn as a grey smear; at this size that leaves a mean of 29 of the forty-nine actually visible.

Not one colour is named in the drawing code. The background, both ink triplets, all seven alphas and the line width are handed in by the caller from the shared palette — so the embed follows the reader's own light or dark setting, the catalogue tile follows the tile frame, and the two cannot drift into different pictures. The traced curve is the coral accent and every part of the machinery is cobalt, which is the site's own 3:1 weighting doing the composition.

The whole Fourier transform is done once per shape, not once per frame: 0.80 ms for 25,088 complex multiply-adds under Node, plus the same again to evaluate the finished curve at 512 points. A frame after that is 49 sines, 49 cosines and about 600 path commands. Building the plate — which also builds the ghost curve behind it, so two transforms in all — measures 4.0 ms, once, when the tile first paints.

The figure is sized to 0.45 of the shorter side of the frame, and the chain's furthest reach is about 1.05 of the figure radius, so the machine never touches an edge at any aspect ratio. A gallery tile is the same drawing at a smaller scale, not a different one.

Under prefers-reduced-motion no animation loop is scheduled. The machine is constructed sitting on the finished curve rather than on a blank frame, so a visitor who asked for stillness is served the whole artwork — the complete star, the complete chain, the ghost behind it — and not a half-drawn lap. It is also the first thing everyone else sees, before the pen sets off again.

Honest limits

You cannot read the transform off the picture. The chain shows you amplitudes as radii, but the frequencies and the phases — which circle is k = +7 and which is −7, where each one started — are invisible. Two quite different spectra can look like the same chain for most of a lap. This is a picture of a Fourier series, not a readout of one.

Forty-nine circles and most of them are dust. Ordering by amplitude is honest about this: after the first three or four, every remaining term is a few pixels across, and the visible "machine" is really a big wheel, a couple of followers and a knot of specks riding the pen. That knot is not clutter that could be cleaned up — it is what the high harmonics of a sharp shape actually are.

The shapes are a narrow family. Star polygons, five to eleven points, jittered. The transform would happily draw a signature, a coastline or a letter — those are the epicycle animations you have probably seen — but each of those needs an outline to be supplied, and this gallery generates rather than imports. Within one family the hash gives real variety of rhythm and depth; it does not give you variety of kind, and after a few laps you will have seen the range.

Nothing here is simulated. There is no physics, nothing is integrated, no state carries from one frame to the next except a frame counter. Every frame is evaluated from closed form at its own lap fraction, which is why the picture is exactly reproducible and why it costs almost nothing — and also why there is nothing to discover in it. A sandpile or a slime mould can surprise the person who wrote it. This cannot.

It is not the historical epicycle. Ptolemy's deferents and epicycles were an attempt to say what the planets are doing, with a handful of circles chosen to fit observations. This is the modern inversion of that idea: since any closed path can be fitted by enough circles, fitting one says nothing whatever about the mechanism behind it. The machine is a proof of expressive power, and expressive power is exactly what makes it worthless as an explanation.

The truncation is visible and permanent. The points are blunt, the flanks ripple, and adding terms narrows the fault without removing it. If you want the sharp star you have to draw the star; this plate is about what happens when you insist on drawing it with circles.

Draw your own

The machine is short enough to build yourself. The snippet below is this plate in 28 lines of HTML and plain JavaScript with no library, and with the plate’s own recipe: the outline resampled to 512 points spaced evenly by arc length, the discrete Fourier transform of those points, the same 49 circles (k = −24 … 24) in the same order (the centre first, then the largest), the same lap and hold, and the same inks. Its outline is the plate’s own first star, built with the plate’s own hash, and the test suite checks that its circles match the plate’s to the last bit. What it leaves out is everything that exists for the gallery rather than for the mathematics: the ghost of the previous star, a new star every lap, click-to-skip, and sharp drawing on high-density screens (it draws in CSS pixels). Unlike the plate, it will draw anything: replace SHAPE with any closed list of [x, y] points within radius 1 (a letter, a coastline, an outline you traced by hand) and the same code draws that instead.

  • State. SHAPE is star S = 0 of the plate: ten points, twenty corners, scaled so its furthest corner sits at radius 1. resample() turns it into N = 512 points spaced evenly round the outline, each read as a complex number x + iy. Every circle is one coefficient X[k]: its radius is |X[k]| and it turns k times per lap, backwards when k is negative.
  • Rule. dft() computes X[k] = (1/N) Σ z[m]·e−2πikm/N for k = −256 … 255, and chain() adds the terms back up with the opposite sign, X[k]·e+2πiku, one circle on the tip of the last. That is the definition in Wikipedia’s Discrete Fourier transform article, except that the article puts the 1/N on the inverse and this code puts it on the forward step, so that |X[k]| is the circle’s radius directly. Measured on this code: rebuilt from all 512 terms, the path comes back within 10−13 of every input point.
  • Speed. The transform is 512 × 512 = 262,144 complex multiply-adds, about 8.6 ms in Node on the machine that built this page, and it runs once. A frame after that is 49 sines and cosines for the chain plus the drawing. The pen goes round in DRAW = 420 frames and the finished curve is held for HOLD = 150, which is 7 s and 2.5 s on a 60 Hz screen. Like the plate it counts frames, not seconds, so a 120 Hz screen runs it twice as fast.
  • Colour. The ground, the cobalt machine and the coral curve are this site’s palette, light or dark to match your device. The opacities are the plate’s: target 0.34, rims 0.3, spokes 0.18, curve 0.95 and pen 1 on dark; 0.4, 0.36, 0.24, 0.95 and 1 on light. Like the plate, it opens on the finished curve, and reduced-motion visitors get only that one still frame: the whole curve with the machine at the end of its lap.

Measured on this code at the plate’s size, a 324 px figure radius in a 1280×720 window: the three biggest turning circles are 169, 50 and 32 px (k = 1, −9, 11), and 31 of the 48 turning circles have a radius of at least 1.1 px and get drawn. The 49 circles carry all but 0.17% of the outline’s energy, yet the curve still sits a mean of 6.2 px from the outline and 21.9 px at worst, at the tips. Set K = 48 (97 circles) and that falls to 2.0 px and 9.8 px. Keeping k = −24 … 24 is the plate’s choice, and not the most accurate one: the 49 largest of all 512 terms (only 37 of them in that band) cut the root-mean-square error from 7.4 px to 4.3 px. Adding a term can never make that root-mean-square error worse. By Parseval’s theorem, in the same article, the energy of the points equals the energy of their coefficients, so the error left is exactly the energy of the terms left out. The worst single point has no such promise, which is why the tips keep their ripple. Set S = 1 for the plate’s next star, a five-pointed one, and the 49 circles land a mean of 1.9 px from it. Flip the minus sign in dft() and the picture still looks right, but the pen runs the outline backwards. The test compares point by point, which is how it catches that.

<canvas id="epicycles" style="position:fixed; inset:0"></canvas>
<script>
const dark = matchMedia('(prefers-color-scheme: dark)').matches, still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const [ground, cool, warm, INK] = dark ? ['11,13,18', '150,180,255', '255,120,84', [0.34, 0.3, 0.18, 0.2, 0.11, 0.95, 1]]
                                       : ['231,226,213', '40,72,205', '190,68,28', [0.4, 0.36, 0.24, 0.25, 0.14, 0.95, 1]];  // INK: target, rims, spokes, (2 ghosts, unused), curve, pen
const N = 512, K = 24, DRAW = 420, HOLD = 150, FIT = 0.45, MINR = 1.1, TIP = 2.4;  // N path points; circles k = -K..K; frames per lap and per hold
const hash = (a, b) => { let h = Math.imul(a, 374761393) ^ Math.imul(b, 668265263); h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967296; };  // the plate's hash
const S = 0, V = 5 + Math.floor(hash(S, 1) * 7), inner = 0.10 + hash(S, 2) * 0.18, rot = hash(S, 3) * Math.PI * 2;  // the plate's star number S: V points, its waist and turn
const STAR = [...Array(2 * V)].map((_, i) => { const a = rot + i * Math.PI / V + (hash(S, 10 + i) - 0.5) * 0.18, r = (i % 2 ? inner : 1) * (0.86 + hash(S, 60 + i) * 0.28); return [Math.cos(a) * r, Math.sin(a) * r]; });
const SHAPE = STAR.map((p) => p.map((v) => v / Math.max(...STAR.map(([x, y]) => Math.sqrt(x * x + y * y)))));  // ANY closed list of [x, y] points works here; this is star S, scaled to radius 1
function resample(p) { const n = p.length, seg = p.map(([x, y], i) => { const [u, v] = p[(i + 1) % n], dx = u - x, dy = v - y; return Math.sqrt(dx * dx + dy * dy); }), cum = [0];
  seg.forEach((d) => cum.push(cum[cum.length - 1] + d)); let s = 0;  // N points evenly spaced by arc length round the closed polygon
  return [...Array(N)].map((_, m) => { const want = cum[n] * m / N; while (s < n - 1 && cum[s + 1] < want) s++; const t = seg[s] > 0 ? (want - cum[s]) / seg[s] : 0, [x, y] = p[s], [u, v] = p[(s + 1) % n]; return [x + (u - x) * t, y + (v - y) * t]; }); }
function dft(z) { const n = z.length; return z.map((_, j) => { const k = j < n / 2 ? j : j - n; let re = 0, im = 0;  // X[k] = (1/n) sum z[m] e^(-2 pi i k m / n), k = -n/2 .. n/2 - 1
  z.forEach(([x, y], m) => { const a = -Math.PI * 2 * k * m / n, c = Math.cos(a), s = Math.sin(a); re += x * c - y * s; im += x * s + y * c; });
  re /= n; im /= n; return { k, re, im, amp: Math.sqrt(re * re + im * im) }; }); }
function chain(terms, u) { let x = 0, y = 0;  // every joint after a fraction u of a lap: circle j turns k times per lap, radius |X[k]|
  return terms.map(({ k, re, im }) => { const a = Math.PI * 2 * k * u, c = Math.cos(a), s = Math.sin(a); x += re * c - im * s; y += re * s + im * c; return [x, y]; }); }
const terms = dft(resample(SHAPE)).filter((t) => Math.abs(t.k) <= K).sort((a, b) => (b.k === 0) - (a.k === 0) || b.amp - a.amp || a.k - b.k);  // k = 0 (the centre) first, then largest circle first
const trace = [...Array(N)].map((_, m) => chain(terms, m / N).pop()), cv = document.getElementById('epicycles'), ctx = cv.getContext('2d'); let f = DRAW;  // start on the finished curve
function draw() { const W = cv.width = innerWidth, H = cv.height = innerHeight, R = Math.min(W, H) * FIT, P = ([x, y]) => [W / 2 + x * R, H / 2 + y * R];
  const n = Math.max(1, Math.round(Math.min(1, f / DRAW) * N)), J = [[0, 0], ...chain(terms, n / N)], line = (pts, ink, a, w) => { ctx.lineWidth = w; ctx.strokeStyle = 'rgba(' + ink + ',' + a + ')'; ctx.beginPath(); pts.forEach((p) => ctx.lineTo(...P(p))); ctx.stroke(); };
  ctx.fillStyle = 'rgb(' + ground + ')'; ctx.fillRect(0, 0, W, H); ctx.lineCap = ctx.lineJoin = 'round'; line([...SHAPE, SHAPE[0]], cool, INK[0], 1);  // the target, drawn exactly
  ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(' + cool + ',' + INK[1] + ')'; ctx.beginPath(); terms.forEach((t, j) => { if (j > 0 && t.amp * R >= MINR) { const [x, y] = P(J[j]); ctx.moveTo(x + t.amp * R, y); ctx.arc(x, y, t.amp * R, 0, 2 * Math.PI); } }); ctx.stroke();
  line(J.slice(1), cool, INK[2], 1); line(trace.slice(0, n + 1).concat(n >= N ? [trace[0]] : []), warm, INK[5], 1.9);  // spokes, then the curve drawn so far
  ctx.fillStyle = 'rgba(' + warm + ',' + INK[6] + ')'; ctx.beginPath(); ctx.arc(...P(J[J.length - 1]), TIP, 0, 2 * Math.PI); ctx.fill(); }  // the pen
const frame = () => { if (++f > DRAW + HOLD) f = 0; draw(); requestAnimationFrame(frame); }; if (still) draw(); else requestAnimationFrame(frame);  // reduced motion: one still frame
</script>
Paste it into an empty .html file.

Curious how the loop and canvas fit together? Read how it works →

More from the gallery

All 32 animations in the gallery →