1
Lagrange Points: Stability in the Rotating Frame
tap to drop a test probe
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.