Astrophysics

Spiral galaxy

Give two thousand stars one law — the closer to the core, the faster the orbit — and a spiral appears. Keep watching and the arms wind up tighter and tighter, which is exactly the problem that forced astronomy to rethink what a spiral arm is.

EXP-039

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

Drag the rotation up and let it run: the inner stars overtake the outer ones and the arms stretch into ever-tighter coils. This is the winding problem — if arms were fixed sets of stars, every old galaxy would be a featureless circle by now. Real arms are density waves the stars pass through, like a jam that stays put while the cars drive through it. The cursor tilts the disc: face-on at the top, edge-on at the bottom.

The maths

ω(r) = ω₀ / √r

A Keplerian rotation curve: orbital speed set by the pull of the mass at the centre, falling with distance. Mercury laps the Sun four times while Earth finishes one orbit — same law, same reason.

θ = θ₀ + g·ln r + ω(r)·t

Each star sits on a logarithmic spiral — the curve that keeps the same pitch at every scale, the one nautilus shells and hurricanes share — and then drifts along it at its own angular speed.

y = cy + r·sin θ · cos i

The whole disc is projected by compressing the vertical axis by cos i, where i is the inclination. That single multiplication is what turns a flat spinning disc into a galaxy seen in perspective.

How it is built

Two thousand stars are seeded once, denser toward the core. Each stores only its radius, its arm, its scatter and a twinkle phase — the angle is recomputed every frame from the sliders, so changing the arm count re-folds the whole galaxy instantly. Everything is drawn twice with additive compositing, a wide faint halo under a small bright core: where stars pile up the canvas saturates toward white, which is how a long-exposure photograph of a real galaxy works.

The code, step by step

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

01

Seed the field

const u = Math.pow(Math.random(), 0.72);            // más densidad al núcleo
stars.push({
  u,
  arm: (Math.random() * 6) | 0,
  jit: (Math.random() - 0.5) * (0.14 + u * 0.6),    // los brazos se deshilachan afuera
  sz: 0.5 + Math.random() * 1.5,
  tw: Math.random() * Math.PI * 2
});

The radius comes from a power of a uniform random number: the 0.72 exponent piles stars toward the core. The arm is an integer folded later with mod, and the scatter grows with u so the arms fray at the rim exactly like the real thing.

02

Differential rotation on a log spiral

const r = 0.05 + s.u * 0.95;
// Rotación diferencial kepleriana: ω ∝ 1/√r
const om = P.vel / Math.sqrt(r);
// Espiral logarítmica más deriva temporal: el enrollamiento en vivo
const th = (s.arm % P.brazos) * (Math.PI * 2 / P.brazos)
         + P.giro * Math.log(r + 0.12) + s.jit + t * 0.05 * om;
const x = cx + Math.cos(th) * r * R;
const y = cy + Math.sin(th) * r * R * ci;

Three terms build the angle: which arm, where along the log spiral, and how far the star has drifted — inner stars drift faster because ω grows as r shrinks. The vertical squash by cos i is the entire 3D of this experiment.

03

Draw it like a long exposure

const b = (0.3 + 0.7 * (1 - s.u)) * (0.74 + 0.26 * Math.sin(t * 2.2 + s.tw));
ctx.fillStyle = `rgba(${acc},${(b * 0.3).toFixed(3)})`;
ctx.beginPath(); ctx.arc(x, y, s.sz * 2.6, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = `rgba(${acc},${b.toFixed(3)})`;
ctx.beginPath(); ctx.arc(x, y, s.sz * 0.75, 0, Math.PI * 2); ctx.fill();

Brightness falls with radius and twinkles with a per-star phase. Each star is a wide dim halo plus a small bright core, composited additively: overlap becomes brightness, and the crowded arms and nucleus bloom toward white on their own.

Why it matters

The winding problem is a rare case of a simulation falsifying a theory by just running. This exact model — material arms plus differential rotation — is the one that fails on screen, and its failure is what pushed Lin and Shu toward density-wave theory in 1964. Sometimes the most useful model is the one that breaks on schedule.

The whole thing

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

FULL

lab.js · galaxia

{ id: 'galaxia',
  name: L('Spiral galaxy', 'Galaxia espiral'),
  note: L('Two thousand stars, one law of rotation. The arms wind themselves up.',
          'Dos mil estrellas, una ley de rotación. Los brazos se enrollan solos.'),
  params: [
    { key: 'brazos', label: L('Arms', 'Brazos'),           min: 1, max: 6, step: 1,    def: 3 },
    { key: 'giro',   label: L('Pitch g', 'Paso g'),        min: 1, max: 9, step: 0.1,  def: 4.6 },
    { key: 'vel',    label: L('Rotation ω', 'Rotación ω'), min: 0, max: 3, step: 0.05, def: 1 }
  ],
  make() {
    // Cada estrella guarda radio, brazo, desvío y fase de parpadeo. El
    // ángulo se recalcula en cada frame desde los deslizadores: cambiar
    // el número de brazos repliega la galaxia sin resembrar el campo.
    let stars = [];
    const sow = () => {
      stars = [];
      for (let i = 0; i < 2000; i++) {
        const u = Math.pow(Math.random(), 0.72);            // más densidad al núcleo
        stars.push({
          u,
          arm: (Math.random() * 6) | 0,
          jit: (Math.random() - 0.5) * (0.14 + u * 0.6),    // los brazos se deshilachan afuera
          sz: 0.5 + Math.random() * 1.5,
          tw: Math.random() * Math.PI * 2
        });
      }
    };
    sow();
    return {
      reset() { sow(); },
      step(ctx, w, h, t, acc, P, M) {
        const cx = w / 2, cy = h / 2, R = Math.min(w, h) * 0.46;
        // El cursor inclina el disco: de frente arriba, de canto abajo
        const inc = M.in ? 0.1 + (M.y / h) * 1.25 : 0.44;
        const ci = Math.cos(inc);
        ctx.globalCompositeOperation = 'lighter';
        const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, R * 0.5);
        g.addColorStop(0, `rgba(${acc},0.5)`);
        g.addColorStop(0.3, `rgba(${acc},0.1)`);
        g.addColorStop(1, `rgba(${acc},0)`);
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.ellipse(cx, cy, R * 0.5, R * 0.5 * ci, 0, 0, Math.PI * 2);
        ctx.fill();
        for (const s of stars) {
          const r = 0.05 + s.u * 0.95;
          // Rotación diferencial kepleriana: ω ∝ 1/√r
          const om = P.vel / Math.sqrt(r);
          // Espiral logarítmica más deriva temporal: el enrollamiento en vivo
          const th = (s.arm % P.brazos) * (Math.PI * 2 / P.brazos)
                   + P.giro * Math.log(r + 0.12) + s.jit + t * 0.05 * om;
          const x = cx + Math.cos(th) * r * R;
          const y = cy + Math.sin(th) * r * R * ci;
          const b = (0.3 + 0.7 * (1 - s.u)) * (0.74 + 0.26 * Math.sin(t * 2.2 + s.tw));
          ctx.fillStyle = `rgba(${acc},${(b * 0.3).toFixed(3)})`;
          ctx.beginPath(); ctx.arc(x, y, s.sz * 2.6, 0, Math.PI * 2); ctx.fill();
          ctx.fillStyle = `rgba(${acc},${b.toFixed(3)})`;
          ctx.beginPath(); ctx.arc(x, y, s.sz * 0.75, 0, Math.PI * 2); ctx.fill();
        }
        ctx.globalCompositeOperation = 'source-over';
      }
    };
  }
},