← Generative art

Fourier Epicycles

A chain of spinning circles, each riding on the rim of the one before, draws a heart, a star, a lightning bolt and an infinity sign in turn. The circles are the shape’s Fourier series: the big slow ones give the rough outline and the small fast ones add the detail. Fewer circles give a rounder, wobblier drawing.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-fourier-epicycles to a box with a height and a dark background.
  3. Pick shapes with data-shape, or give your own outline as an SVG path with data-path.

Options

Add any of these attributes to the same element.

data-shapeheart, star, bolt or infinity, or a comma-separated list to take turns. Default all four.
data-pathAn SVG path’s d to draw instead, in any units: it’s fitted to the box.
data-termsHow many circles. Default 50.
data-durationHow long each drawing takes. Default 9000 ms.
data-colorColour of the line it draws. Default #f472b6.
data-circles="false"Hide the circles and show only the pen.

The outline is sampled at 256 points and run through a discrete Fourier transform; each frequency becomes one circle, spinning at that frequency, with the biggest first. It’s paused when off screen, and with reduced motion it shows the finished drawing.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Fourier Epicycles: a chain of spinning circles, each one riding on the
    // rim of the one before, traces out a shape. The circles are the shape's
    // Fourier series: the biggest, slowest ones give the rough outline and
    // the small fast ones add the detail.
    // Usage: <div data-fourier-epicycles style="height:360px;background:#120a24"></div>
    // Optional: data-shape (heart, star, bolt or infinity, or a comma-separated
    // list to take turns; default "heart,star,bolt,infinity"), data-path (an SVG
    // path "d" string to draw instead, in any units), data-terms (how many
    // circles, default 50), data-duration (ms per drawing, default 9000),
    // data-color (the line it draws, default #f472b6), data-circles="false"
    // (hide the circles and show only the pen).
    var DEFAULTS = { shape: 'heart,star,bolt,infinity', terms: 50, duration: 9000, color: '#f472b6' };
    var N = 256;      // points sampled round the shape
    var M = 720;      // points along the redrawn curve

    var css =
      '[data-fourier-epicycles]{position:relative;isolation:isolate;overflow:hidden}' +
      '.fep-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // Built-in shapes, as closed lists of points (any scale; they're fitted later).
    function polygon(pts) { return pts; }
    var SHAPES = {
      heart: function () {
        var out = [];
        for (var i = 0; i < 400; i++) {
          var t = i / 400 * Math.PI * 2, s = Math.sin(t);
          out.push([16 * s * s * s, -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t))]);
        }
        return out;
      },
      star: function () {
        var out = [];
        for (var i = 0; i < 10; i++) {
          var a = -Math.PI / 2 + i * Math.PI / 5, r = i % 2 ? 0.42 : 1;
          out.push([Math.cos(a) * r, Math.sin(a) * r]);
        }
        return polygon(out);
      },
      bolt: function () {
        return polygon([[0.15, -1], [-0.55, 0.12], [-0.05, 0.12], [-0.25, 1], [0.55, -0.22], [0.05, -0.22], [0.35, -1]]);
      },
      infinity: function () {
        var out = [];
        for (var i = 0; i < 400; i++) {
          var t = i / 400 * Math.PI * 2, d = 1 + Math.sin(t) * Math.sin(t);
          out.push([Math.cos(t) / d, Math.sin(t) * Math.cos(t) / d]);
        }
        return out;
      }
    };

    // Walk round a closed outline and pick n points evenly spaced along it.
    function resample(pts, n) {
      var lens = [0], total = 0, i;
      for (i = 0; i < pts.length; i++) {
        var a = pts[i], b = pts[(i + 1) % pts.length];
        total += Math.hypot(b[0] - a[0], b[1] - a[1]);
        lens.push(total);
      }
      var out = [], j = 0;
      for (i = 0; i < n; i++) {
        var target = i / n * total;
        while (lens[j + 1] < target) j++;
        var p = pts[j], q = pts[(j + 1) % pts.length], seg = lens[j + 1] - lens[j] || 1;
        var f = (target - lens[j]) / seg;
        out.push([p[0] + (q[0] - p[0]) * f, p[1] + (q[1] - p[1]) * f]);
      }
      return out;
    }

    function fromPath(el, d) {
      var ns = 'http://www.w3.org/2000/svg';
      var svg = document.createElementNS(ns, 'svg');
      svg.setAttribute('aria-hidden', 'true');
      svg.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
      var path = document.createElementNS(ns, 'path');
      path.setAttribute('d', d);
      svg.appendChild(path);
      el.appendChild(svg);
      var len = path.getTotalLength(), out = [];
      for (var i = 0; i < N; i++) {
        var p = path.getPointAtLength(i / N * len);
        out.push([p.x, p.y]);
      }
      el.removeChild(svg);
      return out;
    }

    // Discrete Fourier transform: one spinning circle per frequency, biggest first.
    function dft(pts) {
      var terms = [];
      for (var k = -N / 2; k < N / 2; k++) {
        var re = 0, im = 0;
        for (var n = 0; n < N; n++) {
          var a = -2 * Math.PI * k * n / N;
          re += pts[n][0] * Math.cos(a) - pts[n][1] * Math.sin(a);
          im += pts[n][0] * Math.sin(a) + pts[n][1] * Math.cos(a);
        }
        re /= N; im /= N;
        terms.push({ k: k, amp: Math.hypot(re, im), phase: Math.atan2(im, re) });
      }
      terms.sort(function (a, b) { return b.amp - a.amp; });
      return terms;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'fep-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var nTerms = Math.max(2, Math.min(N, +d.terms || DEFAULTS.terms));
      var duration = +d.duration || DEFAULTS.duration;
      var color = d.color || DEFAULTS.color;
      var showCircles = d.circles !== 'false';
      var names = (d.shape || DEFAULTS.shape).split(',').map(function (s) { return s.trim(); }).filter(function (s) { return SHAPES[s]; });
      if (!names.length) names = ['heart'];

      // Each shape: unit-sized samples, centred, ready to be scaled to the box.
      var raw = d.path ? [fromPath(el, d.path)] : names.map(function (nm) { return resample(SHAPES[nm](), N); });
      var shapes = raw.map(function (pts) {
        var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
        pts.forEach(function (p) { minX = Math.min(minX, p[0]); maxX = Math.max(maxX, p[0]); minY = Math.min(minY, p[1]); maxY = Math.max(maxY, p[1]); });
        var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, sc = 1 / Math.max(maxX - minX, maxY - minY, 1e-6);
        var unit = pts.map(function (p) { return [(p[0] - cx) * sc, (p[1] - cy) * sc]; });
        return { terms: dft(unit).slice(0, nTerms), aspect: (maxX - minX) / Math.max(1e-6, maxY - minY) };
      });

      var w = 0, h = 0, dpr = 1, scale = 1, idx = 0, curve = null;

      // Where the pen is at time t (0..1), in unit coordinates, plus the circle centres on the way.
      function chain(terms, t, centres) {
        var x = 0, y = 0;
        for (var i = 0; i < terms.length; i++) {
          var c = terms[i], a = 2 * Math.PI * c.k * t + c.phase;
          if (centres) centres.push(x, y, c.amp);
          x += c.amp * Math.cos(a);
          y += c.amp * Math.sin(a);
        }
        return [x, y];
      }

      function prepare() {
        var s = shapes[idx];
        // Fit the shape's box to the canvas, leaving room round the edge.
        var bw = s.aspect >= 1 ? 1 : s.aspect, bh = s.aspect >= 1 ? 1 / s.aspect : 1;
        scale = Math.min(w * 0.78 / bw, h * 0.74 / bh);
        curve = [];
        for (var i = 0; i <= M; i++) curve.push(chain(s.terms, i / M));
      }

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return false;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        prepare();
        return true;
      }

      // t: how far round (0..1); fade: how visible the line is (for the change-over).
      function draw(t, fade, circleAlpha) {
        ctx.clearRect(0, 0, w, h);
        var ox = w / 2, oy = h / 2;
        var upto = Math.min(M, Math.floor(t * M));
        if (upto > 0) {
          ctx.save();
          ctx.globalAlpha = fade;
          ctx.lineJoin = ctx.lineCap = 'round';
          ctx.strokeStyle = color;
          ctx.shadowColor = color;
          ctx.shadowBlur = 10;
          ctx.lineWidth = Math.max(2, Math.min(w, h) / 140);
          ctx.beginPath();
          ctx.moveTo(ox + curve[0][0] * scale, oy + curve[0][1] * scale);
          for (var i = 1; i <= upto; i++) ctx.lineTo(ox + curve[i][0] * scale, oy + curve[i][1] * scale);
          ctx.stroke();
          ctx.restore();
        }
        var centres = [];
        var tip = chain(shapes[idx].terms, t % 1, centres);
        var px = ox + tip[0] * scale, py = oy + tip[1] * scale;
        if (showCircles && circleAlpha > 0) {
          ctx.lineWidth = 1;
          for (var j = 0; j < centres.length; j += 3) {
            var r = centres[j + 2] * scale;
            if (r < 0.6) break;
            var cx = ox + centres[j] * scale, cy = oy + centres[j + 1] * scale;
            var nx = j + 3 < centres.length ? ox + centres[j + 3] * scale : px;
            var ny = j + 3 < centres.length ? oy + centres[j + 4] * scale : py;
            var fadeBy = circleAlpha * (0.55 - 0.4 * j / centres.length);
            ctx.strokeStyle = 'rgba(216,200,255,' + (fadeBy * 0.55).toFixed(3) + ')';
            ctx.beginPath();
            ctx.arc(cx, cy, r, 0, Math.PI * 2);
            ctx.stroke();
            ctx.strokeStyle = 'rgba(255,255,255,' + fadeBy.toFixed(3) + ')';
            ctx.beginPath();
            ctx.moveTo(cx, cy);
            ctx.lineTo(nx, ny);
            ctx.stroke();
          }
        }
        ctx.fillStyle = '#fff';
        ctx.shadowColor = color;
        ctx.shadowBlur = 12;
        ctx.beginPath();
        ctx.arc(px, py, 3, 0, Math.PI * 2);
        ctx.fill();
        ctx.shadowBlur = 0;
      }

      resize();
      if (reduced()) {
        draw(1, 1, 1);
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) draw(1, 1, 1); }).observe(el);
        return;
      }

      var HOLD = 1400, FADE = 700;
      var clock = 0, last = 0;
      if (window.ResizeObserver) new ResizeObserver(function () { resize(); }).observe(el);

      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          clock += last ? Math.min(100, now - last) : 16;
          last = now;
          var many = shapes.length > 1;
          if (clock < duration) {
            draw(clock / duration, 1, 1);
          } else if (!many) {
            // One shape: keep the finished drawing and let the circles keep spinning over it.
            draw(1 + ((clock - duration) / duration) % 1, 1, 1);
          } else if (clock < duration + HOLD) {
            draw(1, 1, 1);
          } else if (clock < duration + HOLD + FADE) {
            var f = 1 - (clock - duration - HOLD) / FADE;
            draw(1, f, f);
          } else {
            idx = (idx + 1) % shapes.length;
            prepare();
            clock = 0;
            draw(0, 1, 1);
          }
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-fourier-epicycles]').forEach(function (el) {
        if (el._fep) return;
        el._fep = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-fourier-epicycles style="height: 360px; background: #120a24"></div>

<!-- your own outline, with fewer circles: -->
<div data-fourier-epicycles data-terms="20"
     data-path="M10 80 Q 52.5 10, 95 80 T 180 80 L 180 120 L 10 120 Z"
     style="height: 360px; background: #120a24"></div>