12

Magnetic Pendulum

click to drop a pendulum, watch the basin emerge

A pendulum bob in 2D, viewed from above, swinging over three colored magnets. The bob obeys — a Hookean spring back to the origin, viscous drag, and a softened inverse-square pull from each magnet. We integrate with velocity Verlet at and several substeps per frame. When the bob settles, its *starting* position is painted with the color of the magnet it found. Click anywhere to drop a new pendulum; auto-mode (toggle with **a**) scans a coarse-to-fine grid so the basin reveals itself progressively. Despite the system being completely deterministic and continuous, the boundaries between the three basins are famously *fractal* — arbitrarily close starting points can land on different magnets, and the boundary set has non-integer Hausdorff dimension. Press **c** to clear and start over.

idle
251 lines · vanilla
view source
// Magnetic Pendulum — top-down basin reveal.
//
// A bob in 2D is pulled toward the origin by a Hookean spring, dragged by a
// viscous force, and attracted to N point magnets via a softened inverse-square
// law. We integrate with velocity Verlet (uses position+accel from the previous
// step, so no recompute of forces per stage — cheap and stable here) at several
// substeps per frame. When the bob's speed stays small for a while, we snap to
// the nearest magnet and paint the *starting* position on a hidden basin canvas
// with that magnet's color. After many drops the basin canvas builds up the
// famously fractal basin-boundary map.

// ---- physics constants ----
const K_SPRING = 0.5;       // restoring force toward origin (in sim units)
const C_DRAG = 0.18;        // viscous drag coefficient
const G_MAG = 1.2;          // magnet attraction strength
const EPS2 = 0.06;          // softening squared, keeps force finite near magnets
const DT = 1 / 240;         // physics dt — small for stability
const SUBSTEPS = 6;         // physics substeps per render frame
const V_SETTLE = 0.08;      // speed threshold below which we start counting
const SETTLE_FRAMES = 90;   // ~1.5s worth of substeps' below-threshold time
const SNAP_DIST = 0.5;      // also snap if within this of a magnet and slow
const MAX_LIFE_FRAMES = 60 * 60 * 8; // hard-cap: 8s @ 60fps before forced snap

// ---- magnet layout — equilateral triangle, distinct primaries ----
const MAGNETS = [
  { x:  0.0, y: -0.9, color: [255,  80,  80] },   // red    (top)
  { x: -0.78, y: 0.45, color: [ 90, 220, 110] },   // green  (bottom-left)
  { x:  0.78, y: 0.45, color: [110, 150, 255] },   // blue   (bottom-right)
];

// ---- runtime state ----
let W, H, cx, cy, scale;
let basinCanvas, basinCtx;       // offscreen — accumulates basin dots
let pendulums;                   // array of live pendulums
let autoMode;
let autoTimer;
let autoGrid;                    // queued grid positions for auto-mode
let autoGridIdx;
let lastWidth, lastHeight;

function worldToScreen(x, y) {
  return { sx: cx + x * scale, sy: cy + y * scale };
}
function screenToWorld(sx, sy) {
  return { x: (sx - cx) / scale, y: (sy - cy) / scale };
}

function makeAutoGrid() {
  // Coarse-to-fine scan across the playable area so the basin reveals
  // progressively rather than filling one corner.
  const grid = [];
  const R = 1.6;
  const passes = [9, 17, 33]; // each pass doubles resolution
  for (const n of passes) {
    for (let i = 0; i < n; i++) {
      for (let j = 0; j < n; j++) {
        const x = -R + (2 * R) * (i + 0.5) / n;
        const y = -R + (2 * R) * (j + 0.5) / n;
        grid.push({ x, y });
      }
    }
  }
  // Shuffle so the reveal looks fractal-y rather than scan-line-y.
  for (let i = grid.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    const tmp = grid[i]; grid[i] = grid[j]; grid[j] = tmp;
  }
  return grid;
}

function setupBasin(width, height) {
  basinCanvas = new OffscreenCanvas(width, height);
  basinCtx = basinCanvas.getContext('2d');
  basinCtx.fillStyle = 'rgba(0,0,0,0)';
  basinCtx.clearRect(0, 0, width, height);
}

function resize(width, height) {
  W = width; H = height;
  cx = width / 2; cy = height / 2;
  // World extends roughly ±1.8 in both axes; pick scale to fit.
  scale = Math.min(width, height) / 4.0;
  setupBasin(width, height);
  pendulums = [];
  autoGrid = makeAutoGrid();
  autoGridIdx = 0;
  lastWidth = width; lastHeight = height;
}

function init({ width, height }) {
  autoMode = true;
  autoTimer = 0;
  resize(width, height);
}

function computeAccel(x, y, vx, vy) {
  // spring (centering) + drag.
  let ax = -K_SPRING * x - C_DRAG * vx;
  let ay = -K_SPRING * y - C_DRAG * vy;
  // magnet attraction — inverse-square-like, softened by EPS2.
  for (let i = 0; i < MAGNETS.length; i++) {
    const m = MAGNETS[i];
    const dx = m.x - x;
    const dy = m.y - y;
    const r2 = dx*dx + dy*dy + EPS2;
    const inv = G_MAG / (r2 * Math.sqrt(r2));
    ax += dx * inv;
    ay += dy * inv;
  }
  return [ax, ay];
}

function spawnPendulum(wx, wy) {
  const [ax, ay] = computeAccel(wx, wy, 0, 0);
  pendulums.push({
    x0: wx, y0: wy,    // starting position — used to paint basin pixel
    x: wx, y: wy,
    vx: 0, vy: 0,
    ax, ay,
    trail: [],         // recent (sx, sy) positions for live render
    settleCount: 0,
    age: 0,
    settled: false,
    basinIndex: -1,
  });
}

function step(p) {
  // Velocity Verlet:
  //   x_{n+1} = x_n + v_n dt + 0.5 a_n dt²
  //   compute a_{n+1} at the new x_{n+1}
  //   v_{n+1} = v_n + 0.5 (a_n + a_{n+1}) dt
  const dt = DT;
  const halfDt2 = 0.5 * dt * dt;
  const nx = p.x + p.vx * dt + p.ax * halfDt2;
  const ny = p.y + p.vy * dt + p.ay * halfDt2;
  const [nax, nay] = computeAccel(nx, ny, p.vx, p.vy);
  // For drag (velocity-dependent) we use the previous-step velocity inside
  // computeAccel — semi-implicit but good enough; drag is small.
  p.vx += 0.5 * (p.ax + nax) * dt;
  p.vy += 0.5 * (p.ay + nay) * dt;
  p.x = nx; p.y = ny;
  p.ax = nax; p.ay = nay;
}

function nearestMagnetIndex(x, y) {
  let best = 0, bestD = Infinity;
  for (let i = 0; i < MAGNETS.length; i++) {
    const dx = MAGNETS[i].x - x;
    const dy = MAGNETS[i].y - y;
    const d = dx*dx + dy*dy;
    if (d < bestD) { bestD = d; best = i; }
  }
  return best;
}

function paintBasinDot(p) {
  const idx = nearestMagnetIndex(p.x, p.y);
  p.basinIndex = idx;
  const [r, g, b] = MAGNETS[idx].color;
  const { sx, sy } = worldToScreen(p.x0, p.y0);
  // Soft dot to suggest a continuous map without raw pixels.
  const radius = 4.5;
  const grad = basinCtx.createRadialGradient(sx, sy, 0, sx, sy, radius);
  grad.addColorStop(0, `rgba(${r},${g},${b},0.95)`);
  grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
  basinCtx.fillStyle = grad;
  basinCtx.fillRect(sx - radius, sy - radius, radius * 2, radius * 2);
}

function tick({ ctx, dt, width, height, input }) {
  if (width !== lastWidth || height !== lastHeight) {
    resize(width, height);
  }

  // Handle input — toggle auto on 'a', clear basin on 'c', click = drop.
  if (input.justPressed && input.justPressed('a')) autoMode = !autoMode;
  if (input.justPressed && input.justPressed('A')) autoMode = !autoMode;
  if (input.justPressed && input.justPressed('c')) setupBasin(W, H);
  if (input.justPressed && input.justPressed('C')) setupBasin(W, H);

  const clicks = input.consumeClicks();
  for (const c of clicks) {
    const { x, y } = screenToWorld(c.x, c.y);
    if (Math.abs(x) < 2.2 && Math.abs(y) < 2.2) spawnPendulum(x, y);
  }

  // Auto-mode: drip new pendulums from the queued grid.
  if (autoMode) {
    autoTimer += dt;
    // Keep at most ~12 live pendulums so frame budget stays bounded.
    while (autoTimer > 0.05 && pendulums.filter(p => !p.settled).length < 12) {
      autoTimer -= 0.05;
      if (autoGridIdx >= autoGrid.length) {
        autoGrid = makeAutoGrid();
        autoGridIdx = 0;
      }
      const g = autoGrid[autoGridIdx++];
      spawnPendulum(g.x, g.y);
    }
    if (autoTimer > 0.5) autoTimer = 0; // clamp drift while paused
  }

  // Physics — substep each pendulum.
  for (let s = 0; s < SUBSTEPS; s++) {
    for (let i = 0; i < pendulums.length; i++) {
      const p = pendulums[i];
      if (p.settled) continue;
      step(p);
      p.age++;
      const sp2 = p.vx*p.vx + p.vy*p.vy;
      // Distance to nearest magnet (use the closest squared so we don't sqrt
      // three times per substep).
      let nearD2 = Infinity;
      for (let mi = 0; mi < MAGNETS.length; mi++) {
        const dx = MAGNETS[mi].x - p.x;
        const dy = MAGNETS[mi].y - p.y;
        const d2 = dx*dx + dy*dy;
        if (d2 < nearD2) nearD2 = d2;
      }
      if (sp2 < V_SETTLE * V_SETTLE && nearD2 < SNAP_DIST * SNAP_DIST) {
        p.settleCount++;
      } else {
        p.settleCount = 0;
      }
      if (p.settleCount > SETTLE_FRAMES || p.age > MAX_LIFE_FRAMES) {
        p.settled = true;
        p.settledAt = p.age;
        paintBasinDot(p);
      }
    }
  }

  // Build a screen-space trail point once per frame (not per substep — keeps
  // the trail cheap to render).
  for (let i = 0; i < pendulums.length; i++) {
    const p = pendulums[i];
    if (p.settled) continue;
    const { sx, sy } = worldToScreen(p.x, p.y);
    p.trail.push({ sx, sy });
    if (p.trail.length > 60) p.trail.shift();
  }

  // Drop settled pendulums entirely once their dot is painted — keeping them
  // around just bloats the array and slows the inner loop. The basin canvas
  // already records the outcome.
  pendulums = pendulums.filter(p => !p.settled);

  // ---- render ----
  // Background.
  ctx.fillStyle = '#08080d';
  ctx.fillRect(0, 0, W, H);

  // Basin layer.
  ctx.globalAlpha = 0.85;
  ctx.drawImage(basinCanvas, 0, 0);
  ctx.globalAlpha = 1;

  // Light grid for visual anchor (subtle).
  ctx.strokeStyle = 'rgba(255,255,255,0.04)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  for (let g = -2; g <= 2; g++) {
    const { sx } = worldToScreen(g, 0);
    ctx.moveTo(sx, 0); ctx.lineTo(sx, H);
    const { sy } = worldToScreen(0, g);
    ctx.moveTo(0, sy); ctx.lineTo(W, sy);
  }
  ctx.stroke();

  // Magnets — colored discs with a faint halo.
  for (let i = 0; i < MAGNETS.length; i++) {
    const m = MAGNETS[i];
    const { sx, sy } = worldToScreen(m.x, m.y);
    const [r, g, b] = m.color;
    const halo = ctx.createRadialGradient(sx, sy, 0, sx, sy, 28);
    halo.addColorStop(0, `rgba(${r},${g},${b},0.55)`);
    halo.addColorStop(1, `rgba(${r},${g},${b},0)`);
    ctx.fillStyle = halo;
    ctx.fillRect(sx - 30, sy - 30, 60, 60);
    ctx.fillStyle = `rgb(${r},${g},${b})`;
    ctx.beginPath();
    ctx.arc(sx, sy, 7, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = 'rgba(255,255,255,0.8)';
    ctx.lineWidth = 1.2;
    ctx.stroke();
  }

  // Pendulums — trail and head.
  for (let i = 0; i < pendulums.length; i++) {
    const p = pendulums[i];
    const trail = p.trail;
    if (trail.length > 1) {
      ctx.lineCap = 'round';
      for (let k = 1; k < trail.length; k++) {
        const t = k / trail.length;
        ctx.strokeStyle = `rgba(240, 240, 255, ${t * 0.6})`;
        ctx.lineWidth = 0.6 + t * 1.6;
        ctx.beginPath();
        ctx.moveTo(trail[k - 1].sx, trail[k - 1].sy);
        ctx.lineTo(trail[k].sx, trail[k].sy);
        ctx.stroke();
      }
    }
    if (!p.settled) {
      const { sx, sy } = worldToScreen(p.x, p.y);
      ctx.fillStyle = 'rgba(255,255,255,0.95)';
      ctx.beginPath();
      ctx.arc(sx, sy, 2.6, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  // HUD.
  ctx.fillStyle = 'rgba(200, 210, 240, 0.55)';
  ctx.font = '12px system-ui, sans-serif';
  ctx.fillText('click: drop pendulum   a: auto ' + (autoMode ? 'on' : 'off') + '   c: clear', 12, H - 14);
  const liveCount = pendulums.filter(p => !p.settled).length;
  ctx.fillText('live: ' + liveCount, 12, 18);
}

Comments (0)

Log in to comment.