12
Magnetic Pendulum
click to drop a pendulum, watch the basin emerge
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.