18

Galaxy Rotation: Visible Mass vs Dark Matter

Two face-on spiral galaxies side by side, each with ~125 stars distributed in a bright central bulge and an exponential disk. **Left:** the Newtonian prediction — every star orbits at using only the visible mass (bulge + disk). Beyond the bulge, enclosed mass barely grows, so falls off like and the outer stars visibly lag. **Right:** the observed behavior — stars orbit at across the entire outer disk, the famous *flat rotation curve*. Below, both profiles are plotted: the Newtonian curve drops, the observed curve plateaus, and the shaded gap between them is the missing-mass evidence. To reproduce the flat curve gravitationally, the galaxy needs an extra halo with , so that stays constant out into the disk. Vera Rubin and Kent Ford measured exactly this discrepancy across dozens of spiral galaxies in the 1970s, and it remains the cleanest dynamical evidence that ordinary baryonic matter alone cannot account for what galaxies are doing — the inference being a dominant, non-luminous *dark matter* halo enclosing every spiral.

idle
234 lines · vanilla
view source
// Galaxy rotation curves: Newtonian (visible-mass) prediction vs observed flat curve.
// Two face-on spiral galaxies side-by-side, each with ~125 stars in concentric rings.
// Left galaxy spins each star at v_newt(r); right galaxy at v_obs(r). Below them,
// the two v(r) curves are plotted continuously so the disagreement is visible.

const N_STARS = 250;          // total across both galaxies
const N_PER = N_STARS / 2;
const N_RINGS = 14;
const R_MAX = 1.0;            // sim units, scaled to pixel radius per panel
const R_BULGE = 0.18;         // bulge scale radius
const R_DISK = 0.32;          // exponential disk scale length

// Visible-mass profile M_vis(r): bulge (Hernquist-ish) + exponential disk.
// Normalized so M_vis(R_MAX) ~ 1.
function mVis(r) {
  const mBulge = (r * r) / Math.pow(r + R_BULGE, 2);            // 0..1, saturates fast
  // Exponential disk enclosed mass: 1 - (1 + r/Rd) * exp(-r/Rd).
  const u = r / R_DISK;
  const mDisk = 1 - (1 + u) * Math.exp(-u);
  return 0.35 * mBulge + 0.65 * mDisk;
}

// Newtonian orbital speed from visible mass: v_n(r) = sqrt(M_vis(r) / r).
// Add a small softening to avoid divide-by-zero at r=0.
function vNewt(r) {
  const rs = Math.max(r, 0.02);
  return Math.sqrt(mVis(rs) / rs);
}

// Observed flat rotation: rises through the bulge, then flat plateau.
// v_obs(r) = V_FLAT * (r / sqrt(r^2 + R_CORE^2)).
const V_FLAT = 1.05;
const R_CORE = 0.12;
function vObs(r) {
  return V_FLAT * r / Math.sqrt(r * r + R_CORE * R_CORE);
}

let W, H;
let stars;            // Float32Array: [r, theta, ring, brightness, size] per star
let vNewtMax, vObsMax;
let vCurveCache;      // precomputed v(r) samples for the plot
const CURVE_SAMPLES = 96;

function init({ canvas, ctx, width, height }) {
  W = width;
  H = height;

  stars = new Float32Array(N_STARS * 5);

  // Distribute stars: ~30% in the bulge (small r, high density), ~70% in the disk
  // with exponential falloff. Same distribution mirrored across both panels.
  for (let g = 0; g < 2; g++) {
    for (let i = 0; i < N_PER; i++) {
      const idx = g * N_PER + i;
      let r;
      if (i < N_PER * 0.30) {
        // Bulge: r ~ uniform(0, R_BULGE * 1.5) but pulled inward
        r = Math.pow(Math.random(), 1.4) * R_BULGE * 1.8 + 0.02;
      } else {
        // Disk: exponential. Sample u = -ln(1-rand) * Rd, clipped.
        r = -Math.log(Math.max(1e-3, Math.random())) * R_DISK * 0.55 + R_BULGE * 0.6;
        if (r > R_MAX) r = R_MAX * (0.9 + Math.random() * 0.1);
      }
      const theta = Math.random() * Math.PI * 2;
      const ring = Math.floor(r * N_RINGS / R_MAX);
      const bright = 0.55 + Math.random() * 0.45;
      const size = r < R_BULGE ? 1.4 + Math.random() * 0.8 : 0.9 + Math.random() * 0.7;
      stars[idx * 5 + 0] = r;
      stars[idx * 5 + 1] = theta;
      stars[idx * 5 + 2] = ring;
      stars[idx * 5 + 3] = bright;
      stars[idx * 5 + 4] = size;
    }
  }

  // Precompute velocity curves and their maxima for plot normalization.
  vCurveCache = new Float32Array(CURVE_SAMPLES * 2);
  vNewtMax = 0;
  vObsMax = 0;
  for (let i = 0; i < CURVE_SAMPLES; i++) {
    const r = (i + 1) / CURVE_SAMPLES * R_MAX;
    const vn = vNewt(r);
    const vo = vObs(r);
    vCurveCache[i * 2 + 0] = vn;
    vCurveCache[i * 2 + 1] = vo;
    if (vn > vNewtMax) vNewtMax = vn;
    if (vo > vObsMax) vObsMax = vo;
  }

  ctx.fillStyle = '#02030a';
  ctx.fillRect(0, 0, W, H);
}

function drawGalaxy(ctx, cx, cy, panelR, panelW, panelH, glowHue) {
  // Soft disk glow.
  const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, panelR * 1.15);
  grad.addColorStop(0, `hsla(${glowHue}, 80%, 78%, 0.30)`);
  grad.addColorStop(0.18, `hsla(${glowHue}, 70%, 60%, 0.18)`);
  grad.addColorStop(0.55, `hsla(${glowHue + 20}, 60%, 40%, 0.07)`);
  grad.addColorStop(1, 'hsla(0, 0%, 0%, 0)');
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(cx, cy, panelR * 1.15, 0, Math.PI * 2);
  ctx.fill();

  // Central bulge core.
  const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, panelR * 0.22);
  core.addColorStop(0, `hsla(${glowHue + 5}, 100%, 92%, 0.95)`);
  core.addColorStop(0.4, `hsla(${glowHue + 10}, 90%, 75%, 0.55)`);
  core.addColorStop(1, 'hsla(0, 0%, 0%, 0)');
  ctx.fillStyle = core;
  ctx.beginPath();
  ctx.arc(cx, cy, panelR * 0.22, 0, Math.PI * 2);
  ctx.fill();
}

function tick({ ctx, dt, time, width, height }) {
  if (width !== W || height !== H) {
    W = width;
    H = height;
  }

  // Slight trail / fade for motion blur on stars.
  ctx.globalCompositeOperation = 'source-over';
  ctx.fillStyle = 'rgba(2, 3, 10, 0.32)';
  ctx.fillRect(0, 0, W, H);

  // Layout: two galaxy panels in the top 60%, v(r) plot in the bottom 40%.
  const topH = H * 0.62;
  const botH = H - topH;
  const panelW = W / 2;
  const cyTop = topH * 0.50;
  const panelR = Math.min(panelW, topH) * 0.40;
  const cxL = panelW * 0.5;
  const cxR = panelW * 1.5;

  // Background star field (a few sparse stars per panel for context).
  ctx.fillStyle = 'rgba(180, 200, 255, 0.45)';
  // Don't redraw bg stars every frame — they'd flicker against the trail fade.
  // Instead skip; the panel glow gives enough atmosphere.

  drawGalaxy(ctx, cxL, cyTop, panelR, panelW, topH, 45);   // warm amber
  drawGalaxy(ctx, cxR, cyTop, panelR, panelW, topH, 200);  // cool blue

  // Advance star angles and draw.
  // We pick an angular-speed scale so that a star at r = 0.5 in the OBSERVED
  // panel completes a full revolution in ~12 seconds. omega = v/r.
  const TIME_SCALE = (2 * Math.PI / 12) * 0.5 / (vObs(0.5) / 0.5);

  ctx.globalCompositeOperation = 'lighter';

  for (let g = 0; g < 2; g++) {
    const cx = g === 0 ? cxL : cxR;
    const useObs = g === 1;
    for (let i = 0; i < N_PER; i++) {
      const idx = (g * N_PER + i) * 5;
      const r = stars[idx + 0];
      let theta = stars[idx + 1];
      const bright = stars[idx + 3];
      const size = stars[idx + 4];

      const v = useObs ? vObs(r) : vNewt(r);
      const omega = (v / Math.max(r, 0.02)) * TIME_SCALE;
      theta += omega * dt;
      if (theta > Math.PI * 2) theta -= Math.PI * 2;
      stars[idx + 1] = theta;

      const px = cx + Math.cos(theta) * r * panelR;
      const py = cyTop + Math.sin(theta) * r * panelR;

      // Bulge stars warmer-white, disk stars bluish-white.
      const hue = r < R_BULGE ? 45 + Math.random() * 10 : 215 + Math.random() * 20;
      const lum = r < R_BULGE ? 85 : 80;
      ctx.fillStyle = `hsla(${hue}, 60%, ${lum}%, ${(bright * 0.85).toFixed(3)})`;
      ctx.beginPath();
      ctx.arc(px, py, size, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  ctx.globalCompositeOperation = 'source-over';

  // --- Labels above the panels ---
  ctx.fillStyle = 'rgba(255, 220, 160, 0.92)';
  ctx.font = 'bold 13px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('Newtonian prediction (visible mass only)', cxL, 18);
  ctx.fillStyle = 'rgba(160, 210, 255, 0.92)';
  ctx.fillText('Observed (real galaxies)', cxR, 18);

  ctx.fillStyle = 'rgba(255, 220, 160, 0.65)';
  ctx.font = '11px sans-serif';
  ctx.fillText('outer stars lag — v ∝ 1/√r', cxL, topH - 8);
  ctx.fillStyle = 'rgba(160, 210, 255, 0.65)';
  ctx.fillText('outer stars keep pace — v ≈ const', cxR, topH - 8);

  // --- Bottom panel: v(r) plot ---
  // Clear the plot area cleanly so the curves don't ghost.
  ctx.fillStyle = '#02030a';
  ctx.fillRect(0, topH, W, botH);

  const plotPadL = 56;
  const plotPadR = 24;
  const plotPadT = 16;
  const plotPadB = 28;
  const plotX0 = plotPadL;
  const plotY0 = topH + plotPadT;
  const plotW = W - plotPadL - plotPadR;
  const plotH = botH - plotPadT - plotPadB;

  // Plot frame.
  ctx.strokeStyle = 'rgba(120, 130, 160, 0.55)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(plotX0, plotY0);
  ctx.lineTo(plotX0, plotY0 + plotH);
  ctx.lineTo(plotX0 + plotW, plotY0 + plotH);
  ctx.stroke();

  // Axis labels.
  ctx.fillStyle = 'rgba(190, 200, 220, 0.85)';
  ctx.font = '11px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('radius r  (galactocentric distance)', plotX0 + plotW / 2, plotY0 + plotH + 18);
  ctx.save();
  ctx.translate(14, plotY0 + plotH / 2);
  ctx.rotate(-Math.PI / 2);
  ctx.textAlign = 'center';
  ctx.fillText('orbital speed v(r)', 0, 0);
  ctx.restore();

  // Normalize plot to the larger of the two maxima.
  const vMax = Math.max(vNewtMax, vObsMax) * 1.08;

  const xOf = (i) => plotX0 + (i / (CURVE_SAMPLES - 1)) * plotW;
  const yOf = (v) => plotY0 + plotH - (v / vMax) * plotH;

  // Newtonian curve.
  ctx.strokeStyle = 'rgba(255, 200, 110, 0.95)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  for (let i = 0; i < CURVE_SAMPLES; i++) {
    const vn = vCurveCache[i * 2 + 0];
    const x = xOf(i);
    const y = yOf(vn);
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.stroke();

  // Observed curve.
  ctx.strokeStyle = 'rgba(140, 200, 255, 0.95)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  for (let i = 0; i < CURVE_SAMPLES; i++) {
    const vo = vCurveCache[i * 2 + 1];
    const x = xOf(i);
    const y = yOf(vo);
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.stroke();

  // Shaded gap = inferred dark-matter contribution.
  ctx.fillStyle = 'rgba(180, 140, 255, 0.18)';
  ctx.beginPath();
  // Top edge = observed curve
  for (let i = 0; i < CURVE_SAMPLES; i++) {
    const vo = vCurveCache[i * 2 + 1];
    const x = xOf(i);
    const y = yOf(vo);
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  // Back along Newtonian curve.
  for (let i = CURVE_SAMPLES - 1; i >= 0; i--) {
    const vn = vCurveCache[i * 2 + 0];
    const x = xOf(i);
    const y = yOf(vn);
    ctx.lineTo(x, y);
  }
  ctx.closePath();
  ctx.fill();

  // Legend.
  const legX = plotX0 + plotW - 200;
  const legY = plotY0 + 6;
  ctx.font = '11px sans-serif';
  ctx.textAlign = 'left';

  ctx.strokeStyle = 'rgba(255, 200, 110, 0.95)';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(legX, legY + 6);
  ctx.lineTo(legX + 22, legY + 6);
  ctx.stroke();
  ctx.fillStyle = 'rgba(255, 220, 160, 0.95)';
  ctx.fillText('Newtonian (visible mass)', legX + 28, legY + 9);

  ctx.strokeStyle = 'rgba(140, 200, 255, 0.95)';
  ctx.beginPath();
  ctx.moveTo(legX, legY + 22);
  ctx.lineTo(legX + 22, legY + 22);
  ctx.stroke();
  ctx.fillStyle = 'rgba(160, 210, 255, 0.95)';
  ctx.fillText('Observed (flat rotation)', legX + 28, legY + 25);

  ctx.fillStyle = 'rgba(200, 170, 255, 0.95)';
  ctx.fillRect(legX, legY + 34, 22, 8);
  ctx.fillStyle = 'rgba(210, 180, 255, 0.95)';
  ctx.fillText('inferred dark matter', legX + 28, legY + 41);
}

Comments (0)

Log in to comment.