Number theory

Ulam spiral

Ulam was bored in a talk in 1963 and started numbering a square spiral on a napkin, circling the primes. They fell on diagonals. Sixty years later nobody has explained why.

EXP-029

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

Move the offset. The diagonals do not wash out, they reorganise, because shifting the start changes which quadratic sits on which line. Some offsets are visibly richer than others, and that is not an artefact: certain polynomials really are more prime-dense than others.

The maths

π(N) ~ N / ln N

The prime number theorem: primes thin out, but slowly. Near 40.000 roughly one in ten numbers is prime, so a spiral of that size should look like uniform static. It does not.

diagonal ⇒ 4n² + bn + c

Why diagonals are special: walking diagonally on the spiral means stepping by a full turn, and each turn adds a linearly growing amount. So a diagonal is exactly the set of values of a quadratic polynomial.

n² + n + 41

Euler found this in 1772 and it is prime for every n from 0 to 39, without exception. Turn on the marker and it lights up as a single unbroken line. Over the first 200 values it is prime 78% of the time, against a background density of 10.5%.

How it is built

A sieve of Eratosthenes for the whole range, then a single walk along the spiral writing pixels: right one, turn, up one, turn, left two, turn, down two, and so on. The arm length grows every second turn, which is the entire spiral in one line of logic. The image is cached by parameters, so panning the sliders is instant and only a change actually recomputes.

The code, step by step

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

01

Sieve of Eratosthenes

const sieve = n => {
  const p = new Uint8Array(n + 1).fill(1);
  p[0] = p[1] = 0;
  for (let i = 2; i * i <= n; i++) if (p[i]) for (let j = i * i; j <= n; j += i) p[j] = 0;
  return p;

Two thousand three hundred years old and still the right answer at this scale. Mark every multiple of every prime; whatever survives is prime. The inner loop starts at i² because everything smaller was already crossed off by a smaller factor, and the outer loop stops at √n for the same reason.

02

Walking the spiral

let x = N >> 1, y = N >> 1, dx = 1, dy = 0, run = 1, done = 0;
for (let v = 1; v <= total; v++) {
  if (x >= 0 && x < N && y >= 0 && y < N) {
    const num = v + start, o = (y * N + x) * 4;
    const isP = !!pr[num], isE = euler.has(num);
    const a = isE ? 1 : isP ? 0.85 : 0.04;
    d[o] = isE ? 255 : rgb[0] * a;
    d[o+1] = isE ? 255 : rgb[1] * a;
    d[o+2] = isE ? 255 : rgb[2] * a;
    d[o+3] = 255;
  }
  x += dx; y += dy;
  if (++done === run) {
    done = 0;
    const tmp = dx; dx = -dy; dy = tmp;      // gira
    if (dy === 0) run++;

The whole spiral is four lines: move, and when the current arm is done, rotate the direction ninety degrees. The arm length grows every second turn, which is why the condition is on dy hitting zero. Arms go 1, 1, 2, 2, 3, 3 — that pattern is the spiral.

03

One pixel per integer

const isP = !!pr[num], isE = euler.has(num);
const a = isE ? 1 : isP ? 0.85 : 0.04;
d[o] = isE ? 255 : rgb[0] * a;
d[o+1] = isE ? 255 : rgb[1] * a;
d[o+2] = isE ? 255 : rgb[2] * a;
d[o+3] = 255;

Writing straight into an ImageData buffer instead of drawing rectangles: 40.000 numbers would be 40.000 fill calls otherwise. Euler's polynomial gets pure white so it separates from the accent colour, and the whole image is cached by parameters because nothing about it changes between frames.

Why it matters

Because the pattern is real and unexplained. Hardy and Littlewood conjectured in 1923 a formula for how dense primes are along a quadratic, and it predicts the effect well, but the conjecture is still a conjecture. This picture is one of the few places where an open problem in number theory is visible to the naked eye.

The whole thing

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

FULL

lab.js · ulam

{ id: 'ulam',
      name: L('Ulam spiral', 'Espiral de Ulam'),
      note: L('Primes on a square spiral. Nobody knows why the diagonals are there.',
              'Primos en una espiral cuadrada. Nadie sabe por qué están las diagonales.'),
      params: [
        { key: 'size',  label: L('Grid', 'Rejilla'), min: 60, max: 400, step: 10, def: 200 },
        { key: 'start', label: L('Start at', 'Empieza en'), min: 0, max: 60, step: 1, def: 0 },
        { key: 'euler', label: L('Mark n²+n+41', 'Marcar n²+n+41'), min: 0, max: 1, step: 1, def: 0 }
      ],
      make() {
        let cache = null, key = '';
        const sieve = n => {
          const p = new Uint8Array(n + 1).fill(1);
          p[0] = p[1] = 0;
          for (let i = 2; i * i <= n; i++) if (p[i]) for (let j = i * i; j <= n; j += i) p[j] = 0;
          return p;
        };
        return {
          step(ctx, w, h, t, acc, P, M) {
            const N = P.size | 0, start = P.start | 0, eu = P.euler | 0;
            const k = N + ':' + start + ':' + eu + ':' + acc;
            if (key !== k) {
              key = k;
              const total = N * N, pr = sieve(total + start + 4);
              const euler = new Set();
              if (eu) for (let i = 0; i * i + i + 41 <= total + start; i++) euler.add(i * i + i + 41);
              const img = new ImageData(N, N), d = img.data;
              const rgb = acc.split(',').map(Number);
              // Camina la espiral: derecha 1, arriba 1, izquierda 2, abajo 2, …
              let x = N >> 1, y = N >> 1, dx = 1, dy = 0, run = 1, done = 0;
              for (let v = 1; v <= total; v++) {
                if (x >= 0 && x < N && y >= 0 && y < N) {
                  const num = v + start, o = (y * N + x) * 4;
                  const isP = !!pr[num], isE = euler.has(num);
                  const a = isE ? 1 : isP ? 0.85 : 0.04;
                  d[o] = isE ? 255 : rgb[0] * a;
                  d[o+1] = isE ? 255 : rgb[1] * a;
                  d[o+2] = isE ? 255 : rgb[2] * a;
                  d[o+3] = 255;
                }
                x += dx; y += dy;
                if (++done === run) {
                  done = 0;
                  const tmp = dx; dx = -dy; dy = tmp;      // gira
                  if (dy === 0) run++;
                }
              }
              cache = document.createElement('canvas');
              cache.width = cache.height = N;
              cache.getContext('2d').putImageData(img, 0, 0);
            }
            const S = Math.min(w, h) * 0.84, px = (w - S) / 2, py = (h - S) / 2;
            ctx.imageSmoothingEnabled = false;
            ctx.drawImage(cache, px, py, S, S);
            ctx.imageSmoothingEnabled = true;
            ctx.strokeStyle = `rgba(${acc},0.25)`;
            ctx.strokeRect(px, py, S, S);

            ctx.font = '11px monospace';
            ctx.fillStyle = `rgba(${acc},0.55)`;
            ctx.fillText('every diagonal line is a quadratic n² + bn + c that is prime unusually often', 12, h - 28);
            ctx.fillStyle = `rgba(${acc},0.95)`;
            ctx.fillText('1 to ' + (N * N + start).toLocaleString('en') + '   offset ' + start +
              (eu ? '   ·  white: n²+n+41, prime for n = 0…39' : ''), 12, h - 12);
          }
        };
      }
    },