1

Lagrange Points: Stability in the Rotating Frame

tap to drop a test probe

The restricted three-body problem in a corotating frame. A Sun and a planet with mass ratio (exaggerated from real Sun-Earth for visibility) sit fixed on the -axis at and . Working in units where the orbital separation is and , the rotation rate is and the effective potential is , where the final term is centrifugal. Five Lagrange points are equilibria of this potential, found from : L1, L2, L3 lie on the -axis (located here by bisection) and are saddles, while L4, L5 sit at the apexes of equilateral triangles and are LINEARLY STABLE for . Faint marching-squares contours of show the saddle topology โ€” notice how L1/L2/L3 sit at cols and L4/L5 sit at local maxima (yes, maxima โ€” they are stable only because of the Coriolis force , not because they are minima of ). Test probes are integrated with a semi-implicit velocity-Verlet that handles the Coriolis cross-term in closed form so the orbits stay stable for thousands of steps. The five seeded probes are perturbed by from each Lagrange point: the L1/L2/L3 probes drift off exponentially along the unstable manifold while L4/L5 settle into characteristic tadpole orbits curling around their points. Tap anywhere to drop a new probe at zero velocity in the rotating frame and watch it follow the equipotential contours; the HUD shows the Jacobi constant , the lone conserved quantity of the restricted three-body problem.

idle
499 lines ยท vanilla
view source
// Restricted three-body problem in the corotating frame.
// Units: G(m_s + m_p) = 1, separation = 1, omega = 1.
// Sun at (-mu, 0), planet at (1-mu, 0).
// U_eff(x,y) = -(1-mu)/r1 - mu/r2 - 0.5*(x^2 + y^2)
// Eqs of motion: x'' = 2 y' - dU/dx,  y'' = -2 x' - dU/dy
// Jacobi: C = -2 U_eff - (vx^2 + vy^2)

const MU = 0.01;          // mass ratio, exaggerated from real Sun-Earth (~3e-6) for visibility
const DT_SIM = 0.0025;    // sim seconds per integrator step
const SUBSTEPS = 4;       // base substeps per 1/60s frame
const TRAIL_LEN = 720;    // ring-buffer capacity per probe

let W, H, cx, cy, scale;
let sunX, planetX;        // x positions (in sim units) of primaries on the rotating x-axis
let L = [];               // {name, x, y, stable} for 5 Lagrange points
let probes = [];          // {x, y, vx, vy, trail (Float32Array x,y pairs), head, count, hue, dead}
let contourGrid;          // OffscreenCanvas with pre-rendered equipotential contours
let bgGrid;               // OffscreenCanvas with the dark-navy radial gradient + stars
let frameCount = 0;
let starfield;            // {x, y, r, b} background
let input = null;

// Muted gold for Lagrange markers; teal for contours; pale blue for planet.
const GOLD = '#d8b25c';
const GOLD_DIM = 'rgba(216, 178, 92, 0.55)';
const CONTOUR_STROKE = 'rgba(90, 180, 180, 0.11)';
const PLANET_BLUE = '#7ac8ff';

// ---------- math ----------

function r1r2(x, y) {
  const dx1 = x - (-MU);
  const dx2 = x - (1 - MU);
  const r1 = Math.hypot(dx1, y);
  const r2 = Math.hypot(dx2, y);
  return [r1, r2, dx1, dx2];
}

function ueff(x, y) {
  const [r1, r2] = r1r2(x, y);
  return -(1 - MU) / r1 - MU / r2 - 0.5 * (x * x + y * y);
}

// Gradient of U_eff (returns [dUdx, dUdy]); used both for finding L points and integration.
function gradU(x, y) {
  const [r1, r2, dx1, dx2] = r1r2(x, y);
  const r1c = r1 * r1 * r1;
  const r2c = r2 * r2 * r2;
  const dUdx = (1 - MU) * dx1 / r1c + MU * dx2 / r2c - x;
  const dUdy = (1 - MU) * y / r1c + MU * y / r2c - y;
  return [dUdx, dUdy];
}

// Find collinear Lagrange points by bisecting dU/dx along y=0 in three intervals.
function dUdx_on_axis(x) {
  const dx1 = x - (-MU);
  const dx2 = x - (1 - MU);
  const t1 = (1 - MU) * dx1 / (Math.abs(dx1) ** 3);
  const t2 = MU * dx2 / (Math.abs(dx2) ** 3);
  return t1 + t2 - x;
}

function bisect(lo, hi) {
  let flo = dUdx_on_axis(lo);
  let fhi = dUdx_on_axis(hi);
  for (let i = 0; i < 80; i++) {
    const mid = 0.5 * (lo + hi);
    const fm = dUdx_on_axis(mid);
    if (!isFinite(fm)) return mid;
    if (Math.sign(fm) === Math.sign(flo)) {
      lo = mid; flo = fm;
    } else {
      hi = mid; fhi = fm;
    }
    if (Math.abs(hi - lo) < 1e-12) break;
  }
  return 0.5 * (lo + hi);
}

function findLagrangePoints() {
  const eps = 1e-4;
  const sun = -MU;
  const planet = 1 - MU;
  const L1x = bisect(sun + eps, planet - eps);
  const L2x = bisect(planet + eps, planet + 1.5);
  const L3x = bisect(sun - 2.0, sun - eps);
  const L4x = 0.5 - MU, L4y =  Math.sqrt(3) / 2;
  const L5x = 0.5 - MU, L5y = -Math.sqrt(3) / 2;
  return [
    { name: 'L1', x: L1x, y: 0,   stable: false },
    { name: 'L2', x: L2x, y: 0,   stable: false },
    { name: 'L3', x: L3x, y: 0,   stable: false },
    { name: 'L4', x: L4x, y: L4y, stable: true  },
    { name: 'L5', x: L5x, y: L5y, stable: true  },
  ];
}

// Symplectic-ish integrator for the rotating-frame equations.
function step(p, h) {
  let [ax, ay] = accel(p.x, p.y, p.vx, p.vy);
  let vxh = p.vx + 0.5 * h * ax;
  let vyh = p.vy + 0.5 * h * ay;
  p.x += h * vxh;
  p.y += h * vyh;
  const [gx, gy] = gradU(p.x, p.y);
  const A = vxh - 0.5 * h * gx;
  const B = vyh - 0.5 * h * gy;
  const det = 1 + h * h;
  p.vx = (A + h * B) / det;
  p.vy = (B - h * A) / det;
}

function accel(x, y, vx, vy) {
  const [gx, gy] = gradU(x, y);
  return [-gx + 2 * vy, -gy - 2 * vx];
}

// ---------- world <-> screen ----------

function wx(x) { return cx + x * scale; }
function wy(y) { return cy - y * scale; }
function fromScreen(px, py) {
  return [(px - cx) / scale, -(py - cy) / scale];
}

// ---------- background pre-render ----------

function renderBackground() {
  const ow = Math.max(64, Math.floor(W));
  const oh = Math.max(64, Math.floor(H));
  bgGrid = new OffscreenCanvas(ow, oh);
  const ctx = bgGrid.getContext('2d');

  // Base near-black
  ctx.fillStyle = '#03050a';
  ctx.fillRect(0, 0, ow, oh);

  // Radial gradient centered on the Sun (in pixel coords).
  const cxL = ow * 0.5;
  const cyL = oh * 0.5;
  const scaleL = Math.min(ow, oh) * 0.32;
  const sxPx = cxL + (-MU) * scaleL;
  const syPx = cyL;
  const gRadius = Math.max(ow, oh) * 0.85;
  const g = ctx.createRadialGradient(sxPx, syPx, 0, sxPx, syPx, gRadius);
  g.addColorStop(0,    'rgba(28, 32, 60, 0.85)');
  g.addColorStop(0.35, 'rgba(14, 18, 38, 0.55)');
  g.addColorStop(1,    'rgba(3, 5, 10, 0)');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, ow, oh);

  // Stars baked in (so they don't redraw every frame).
  if (starfield) {
    for (const s of starfield) {
      ctx.fillStyle = `rgba(220,230,255,${(s.b * 0.55).toFixed(3)})`;
      ctx.beginPath();
      ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
      ctx.fill();
      // tiny extra-bright stars: faint cross-flare
      if (s.r > 1.0) {
        ctx.strokeStyle = `rgba(220,230,255,${(s.b * 0.18).toFixed(3)})`;
        ctx.lineWidth = 0.5;
        ctx.beginPath();
        ctx.moveTo(s.x - 3, s.y); ctx.lineTo(s.x + 3, s.y);
        ctx.moveTo(s.x, s.y - 3); ctx.lineTo(s.x, s.y + 3);
        ctx.stroke();
      }
    }
  }
}

// ---------- contour pre-render ----------

function renderContours() {
  const ow = Math.max(64, Math.floor(W));
  const oh = Math.max(64, Math.floor(H));
  contourGrid = new OffscreenCanvas(ow, oh);
  const ctx = contourGrid.getContext('2d');
  // Transparent backdrop; we composite on top of the bg layer.

  const GRID = 220;
  const xMin = -1.6, xMax = 1.6;
  const yMin = -1.4 * (oh / ow), yMax = 1.4 * (oh / ow);
  const dx = (xMax - xMin) / GRID;
  const dy = (yMax - yMin) / GRID;
  const u = new Float32Array((GRID + 1) * (GRID + 1));
  for (let j = 0; j <= GRID; j++) {
    const y = yMin + j * dy;
    for (let i = 0; i <= GRID; i++) {
      const x = xMin + i * dx;
      const [r1, r2] = r1r2(x, y);
      const r1s = Math.max(r1, 0.04);
      const r2s = Math.max(r2, 0.04);
      const v = -(1 - MU) / r1s - MU / r2s - 0.5 * (x * x + y * y);
      u[j * (GRID + 1) + i] = v;
    }
  }

  // Single muted teal palette for all level curves.
  const levels = [];
  for (let k = 0; k < 22; k++) {
    const v = -2.05 + k * 0.045;
    levels.push(v);
  }

  ctx.lineWidth = 1;
  ctx.strokeStyle = CONTOUR_STROKE;

  const cxL = ow * 0.5;
  const cyL = oh * 0.5;
  const scaleL = Math.min(ow, oh) * 0.32;
  const toPx = (sx, sy) => [cxL + sx * scaleL, cyL - sy * scaleL];

  for (let l = 0; l < levels.length; l++) {
    const lv = levels[l];
    ctx.beginPath();
    for (let j = 0; j < GRID; j++) {
      for (let i = 0; i < GRID; i++) {
        const a = u[j * (GRID + 1) + i];
        const b = u[j * (GRID + 1) + (i + 1)];
        const c = u[(j + 1) * (GRID + 1) + i];
        const d = u[(j + 1) * (GRID + 1) + (i + 1)];
        let idx = 0;
        if (a > lv) idx |= 1;
        if (b > lv) idx |= 2;
        if (d > lv) idx |= 4;
        if (c > lv) idx |= 8;
        if (idx === 0 || idx === 15) continue;
        const x0 = xMin + i * dx;
        const y0 = yMin + j * dy;
        const x1 = x0 + dx;
        const y1 = y0 + dy;
        const interp = (va, vb, xa, ya, xb, yb) => {
          const t = (lv - va) / (vb - va);
          return [xa + t * (xb - xa), ya + t * (yb - ya)];
        };
        const pts = [];
        const edges = [
          [1, 2, () => interp(a, b, x0, y0, x1, y0)],
          [2, 4, () => interp(b, d, x1, y0, x1, y1)],
          [4, 8, () => interp(d, c, x1, y1, x0, y1)],
          [8, 1, () => interp(c, a, x0, y1, x0, y0)],
        ];
        for (const [ma, mb, fn] of edges) {
          const inside = ((idx & ma) !== 0) !== ((idx & mb) !== 0);
          if (inside) pts.push(fn());
        }
        if (pts.length >= 2) {
          const [p1x, p1y] = toPx(pts[0][0], pts[0][1]);
          const [p2x, p2y] = toPx(pts[1][0], pts[1][1]);
          ctx.moveTo(p1x, p1y);
          ctx.lineTo(p2x, p2y);
          if (pts.length === 4) {
            const [p3x, p3y] = toPx(pts[2][0], pts[2][1]);
            const [p4x, p4y] = toPx(pts[3][0], pts[3][1]);
            ctx.moveTo(p3x, p3y);
            ctx.lineTo(p4x, p4y);
          }
        }
      }
    }
    ctx.stroke();
  }

  contourGrid._cx = cxL;
  contourGrid._cy = cyL;
  contourGrid._scale = scaleL;
}

// ---------- probe management ----------

function makeProbe(x, y, vx, vy, hue) {
  return {
    x, y, vx, vy,
    trail: new Float32Array(TRAIL_LEN * 2),
    head: 0,
    count: 0,
    hue,
    age: 0,
    dead: false,
  };
}

function pushTrail(p) {
  p.trail[p.head * 2] = p.x;
  p.trail[p.head * 2 + 1] = p.y;
  p.head = (p.head + 1) % TRAIL_LEN;
  if (p.count < TRAIL_LEN) p.count++;
}

function seedProbes() {
  probes = [];
  // Perturb each Lagrange-point probe slightly so the instability becomes
  // visible quickly without being a numerical spike.
  for (const lp of L) {
    const eps = 0.005;
    const dx = (Math.random() - 0.5) * eps;
    const dy = (Math.random() - 0.5) * eps;
    // Stable (L4/L5) -> soft mint; unstable (L1/L2/L3) -> warm amber gradient.
    let hue;
    if (lp.stable) hue = 155;
    else if (lp.name === 'L1') hue = 14;
    else if (lp.name === 'L2') hue = 28;
    else hue = 45;
    probes.push(makeProbe(lp.x + dx, lp.y + dy, 0, 0, hue));
  }
}

// ---------- init / tick ----------

function init({ canvas, ctx, width, height, input: inp }) {
  W = width; H = height;
  cx = W * 0.5;
  cy = H * 0.5;
  scale = Math.min(W, H) * 0.32;
  input = inp;

  sunX = -MU;
  planetX = 1 - MU;

  L = findLagrangePoints();
  seedProbes();

  // Background starfield, fixed in the rotating frame.
  starfield = [];
  for (let i = 0; i < 160; i++) {
    starfield.push({
      x: Math.random() * W,
      y: Math.random() * H,
      r: Math.random() * 1.2 + 0.2,
      b: Math.random() * 0.55 + 0.25,
    });
  }

  renderBackground();
  renderContours();

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

function drawBackground(ctx) {
  if (bgGrid) ctx.drawImage(bgGrid, 0, 0, W, H);
  else {
    ctx.fillStyle = '#03050a';
    ctx.fillRect(0, 0, W, H);
  }
}

function drawContours(ctx) {
  if (!contourGrid) return;
  ctx.drawImage(contourGrid, 0, 0, W, H);
}

function drawPrimaries(ctx) {
  // ---- Sun: layered translucent halo + bright core ----
  const sx = wx(sunX), sy = wy(0);
  const sunR = Math.max(8, scale * 0.05);

  // Outer corona โ€” very wide, very faint
  let gOuter = ctx.createRadialGradient(sx, sy, sunR * 0.8, sx, sy, sunR * 7);
  gOuter.addColorStop(0,   'rgba(255, 200, 110, 0.20)');
  gOuter.addColorStop(0.5, 'rgba(255, 140, 60, 0.06)');
  gOuter.addColorStop(1,   'rgba(255, 100, 30, 0)');
  ctx.fillStyle = gOuter;
  ctx.beginPath();
  ctx.arc(sx, sy, sunR * 7, 0, Math.PI * 2);
  ctx.fill();

  // Mid halo
  let gMid = ctx.createRadialGradient(sx, sy, sunR * 0.6, sx, sy, sunR * 3);
  gMid.addColorStop(0,   'rgba(255, 230, 140, 0.55)');
  gMid.addColorStop(0.5, 'rgba(255, 180, 70, 0.22)');
  gMid.addColorStop(1,   'rgba(255, 140, 40, 0)');
  ctx.fillStyle = gMid;
  ctx.beginPath();
  ctx.arc(sx, sy, sunR * 3, 0, Math.PI * 2);
  ctx.fill();

  // Inner halo
  let gIn = ctx.createRadialGradient(sx, sy, 0, sx, sy, sunR * 1.6);
  gIn.addColorStop(0,   'rgba(255, 245, 200, 0.95)');
  gIn.addColorStop(0.6, 'rgba(255, 220, 130, 0.5)');
  gIn.addColorStop(1,   'rgba(255, 180, 70, 0)');
  ctx.fillStyle = gIn;
  ctx.beginPath();
  ctx.arc(sx, sy, sunR * 1.6, 0, Math.PI * 2);
  ctx.fill();

  // Bright core
  ctx.fillStyle = '#fff6cc';
  ctx.beginPath();
  ctx.arc(sx, sy, sunR, 0, Math.PI * 2);
  ctx.fill();

  // Faint orbital circle of planet around barycenter (dashed)
  ctx.strokeStyle = 'rgba(120, 150, 200, 0.20)';
  ctx.lineWidth = 1;
  ctx.setLineDash([2, 6]);
  ctx.beginPath();
  ctx.arc(wx(0), wy(0), scale * 1.0, 0, Math.PI * 2);
  ctx.stroke();
  ctx.setLineDash([]);

  // ---- Planet: tinted disk with subtle light gradient ----
  const px = wx(planetX), py = wy(0);
  const plR = Math.max(4, scale * 0.025);
  // Soft atmosphere glow
  let gAtm = ctx.createRadialGradient(px, py, plR * 0.5, px, py, plR * 2.4);
  gAtm.addColorStop(0, 'rgba(122, 200, 255, 0.45)');
  gAtm.addColorStop(1, 'rgba(122, 200, 255, 0)');
  ctx.fillStyle = gAtm;
  ctx.beginPath();
  ctx.arc(px, py, plR * 2.4, 0, Math.PI * 2);
  ctx.fill();
  // Body: off-center radial for a lit-from-sun look
  let gBody = ctx.createRadialGradient(
    px - plR * 0.45, py - plR * 0.35, plR * 0.1,
    px, py, plR
  );
  gBody.addColorStop(0,   '#cfe9ff');
  gBody.addColorStop(0.5, '#7ac8ff');
  gBody.addColorStop(1,   '#345d8a');
  ctx.fillStyle = gBody;
  ctx.beginPath();
  ctx.arc(px, py, plR, 0, Math.PI * 2);
  ctx.fill();
}

function drawLagrange(ctx) {
  ctx.save();
  ctx.font = '10px monospace';
  for (const lp of L) {
    const px = wx(lp.x), py = wy(lp.y);
    const armLen = 7;
    // Faint dashed region circle: tadpole-region radius for stable, escape-region for unstable.
    const ringR = lp.stable ? scale * 0.10 : scale * 0.07;
    ctx.strokeStyle = lp.stable
      ? 'rgba(216, 178, 92, 0.18)'
      : 'rgba(216, 178, 92, 0.12)';
    ctx.lineWidth = 0.8;
    ctx.setLineDash(lp.stable ? [3, 4] : [1, 5]);
    ctx.beginPath();
    ctx.arc(px, py, ringR, 0, Math.PI * 2);
    ctx.stroke();
    ctx.setLineDash([]);

    // Thin cross-hair
    ctx.strokeStyle = GOLD;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(px - armLen, py); ctx.lineTo(px + armLen, py);
    ctx.moveTo(px, py - armLen); ctx.lineTo(px, py + armLen);
    ctx.stroke();

    // Tiny center dot
    ctx.fillStyle = GOLD;
    ctx.beginPath();
    ctx.arc(px, py, 1.2, 0, Math.PI * 2);
    ctx.fill();

    // Label, offset to avoid overlapping the planet/sun on L1/L2/L3.
    let lx = px + 9, ly = py - 7;
    if (lp.name === 'L5') ly = py + 14;
    if (lp.name === 'L3') lx = px - 22;
    ctx.fillStyle = GOLD_DIM;
    ctx.fillText(lp.name, lx, ly);
  }
  ctx.restore();
}

function drawProbes(ctx) {
  ctx.globalCompositeOperation = 'lighter';
  for (const p of probes) {
    if (p.count < 2) continue;
    const startIdx = (p.head - p.count + TRAIL_LEN) % TRAIL_LEN;
    let prevPx = wx(p.trail[startIdx * 2]);
    let prevPy = wy(p.trail[startIdx * 2 + 1]);
    ctx.lineWidth = 1.2;
    for (let i = 1; i < p.count; i++) {
      const idx = (startIdx + i) % TRAIL_LEN;
      const px = wx(p.trail[idx * 2]);
      const py = wy(p.trail[idx * 2 + 1]);
      // Linear decay from head (newest) to tail (oldest).
      const t = i / p.count; // 0 at oldest, 1 at newest
      const alpha = (0.02 + 0.6 * t) * (p.dead ? 0.3 : 1);
      ctx.strokeStyle = `hsla(${p.hue}, 88%, 66%, ${alpha.toFixed(3)})`;
      ctx.beginPath();
      ctx.moveTo(prevPx, prevPy);
      ctx.lineTo(px, py);
      ctx.stroke();
      prevPx = px; prevPy = py;
    }
    if (!p.dead) {
      // Small bright dot with a halo.
      const hx = wx(p.x), hy = wy(p.y);
      const gh = ctx.createRadialGradient(hx, hy, 0, hx, hy, 6);
      gh.addColorStop(0, `hsla(${p.hue}, 100%, 85%, 0.95)`);
      gh.addColorStop(1, `hsla(${p.hue}, 100%, 70%, 0)`);
      ctx.fillStyle = gh;
      ctx.beginPath();
      ctx.arc(hx, hy, 6, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = `hsla(${p.hue}, 100%, 88%, 1)`;
      ctx.beginPath();
      ctx.arc(hx, hy, 2.2, 0, Math.PI * 2);
      ctx.fill();
    }
  }
  ctx.globalCompositeOperation = 'source-over';
}

function drawHUD(ctx) {
  const last = probes[probes.length - 1];
  const liveCount = probes.reduce((n, p) => n + (p.dead ? 0 : 1), 0);

  // Top-left: minimal stats
  ctx.font = '10px monospace';
  ctx.fillStyle = 'rgba(170, 185, 210, 0.85)';
  ctx.fillText(`mu = ${MU.toFixed(3)}   omega = 1`, 12, 18);
  ctx.fillStyle = 'rgba(140, 155, 180, 0.8)';
  ctx.fillText(`probes ${liveCount}/${probes.length}`, 12, 32);
  if (last) {
    const C = -2 * ueff(last.x, last.y) - (last.vx * last.vx + last.vy * last.vy);
    ctx.fillStyle = `hsla(${last.hue}, 80%, 75%, 0.95)`;
    ctx.fillText(`C = ${isFinite(C) ? C.toFixed(3) : 'โ€”'}`, 12, 46);
  }

  // Bottom-right: tiny legend
  ctx.textAlign = 'right';
  ctx.fillStyle = 'rgba(216, 178, 92, 0.7)';
  ctx.fillText('L1 L2 L3  unstable', W - 12, H - 26);
  ctx.fillStyle = 'rgba(140, 220, 180, 0.8)';
  ctx.fillText('L4 L5  stable (tadpole)', W - 12, H - 12);
  ctx.textAlign = 'left';
}

function handleInput(ctx) {
  if (!input) return;
  const clicks = (typeof input.consumeClicks === 'function') ? input.consumeClicks() : 0;
  if (clicks > 0) {
    const mx = input.mouseX;
    const my = input.mouseY;
    if (mx != null && my != null && mx >= 0 && mx <= W && my >= 0 && my <= H) {
      const [sx, sy] = fromScreen(mx, my);
      const [r1, r2] = r1r2(sx, sy);
      if (r1 > 0.04 && r2 > 0.04) {
        if (probes.length > 60) probes.splice(5, 1); // keep canonical 5; trim oldest user probe
        const hue = (200 + probes.length * 37) % 360;
        probes.push(makeProbe(sx, sy, 0, 0, hue));
      }
    }
  }
}

function tick({ ctx, dt, width, height }) {
  if (width !== W || height !== H) {
    W = width; H = height;
    cx = W * 0.5;
    cy = H * 0.5;
    scale = Math.min(W, H) * 0.32;
    // re-seed starfield on resize so it covers the new viewport
    starfield = [];
    for (let i = 0; i < 160; i++) {
      starfield.push({
        x: Math.random() * W,
        y: Math.random() * H,
        r: Math.random() * 1.2 + 0.2,
        b: Math.random() * 0.55 + 0.25,
      });
    }
    renderBackground();
    renderContours();
  }

  handleInput(ctx);

  // Integrate probes.
  const steps = Math.max(1, Math.min(10, Math.round(SUBSTEPS * (dt / (1 / 60)))));
  for (const p of probes) {
    if (p.dead) continue;
    for (let s = 0; s < steps; s++) {
      step(p, DT_SIM);
      const [r1, r2] = r1r2(p.x, p.y);
      if (r1 < 0.025 || r2 < 0.025 || Math.abs(p.x) > 3 || Math.abs(p.y) > 3) {
        p.dead = true;
        break;
      }
    }
    if (!p.dead) {
      pushTrail(p);
      p.age++;
    }
  }

  // Draw.
  drawBackground(ctx);
  drawContours(ctx);
  drawPrimaries(ctx);
  drawLagrange(ctx);
  drawProbes(ctx);
  drawHUD(ctx);

  frameCount++;
}

Comments (0)

Log in to comment.