Click to place a gravity well; nearby dots accelerate toward it with distance-based strength and orbit/spiral naturally.


Code for Above

<div style="max-width:1500px;margin:0 auto;">
<iframe
  title="POC — Gravity Well (Attractor)"
  scrolling="no"
  style="width:1500px;height:1500px;border:0;display:block;background:#000;border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.5);"
  sandbox="allow-scripts allow-same-origin"
  srcdoc='
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>POC — Gravity Well (Attractor)</title>
<style>
  html, body { margin:0; width:100%; height:100%; overflow:hidden; background:#000; }
  canvas { display:block; outline:none; cursor:crosshair; }
  .hint { position:absolute; left:12px; bottom:12px; color:#aaa; font:14px system-ui; user-select:none; }
</style>
</head>
<body>
<canvas id="c" width="1500" height="1500"></canvas>
<div class="hint">Click: place gravity well • Right click: clear • Dots orbit/spiral</div>

<script>
const c = document.getElementById("c");
const x = c.getContext("2d");
const W = 1500, H = 1500;

c.addEventListener("contextmenu", e => e.preventDefault());

function pos(e){
  const r = c.getBoundingClientRect();
  return {
    x: (e.clientX - r.left) * (c.width / r.width),
    y: (e.clientY - r.top)  * (c.height / r.height)
  };
}

// Dots
const dots = Array.from({length: 140}, () => ({
  x: Math.random()*W,
  y: Math.random()*H,
  vx: (Math.random()*2-1)*0.8,
  vy: (Math.random()*2-1)*0.8,
  r: 3 + Math.random()*8
}));

// Gravity well (optional)
let well = null;

// Tunables
const G = 2200;       // gravity strength (bigger = stronger)
const SOFT = 80;      // softening to prevent infinite force near center
const DAMP = 0.992;   // velocity damping
const MAXV = 18;      // speed clamp

function clamp(v,a,b){ return Math.max(a, Math.min(b, v)); }

function limit(vx, vy, m){
  const d = Math.hypot(vx,vy);
  if (d <= m) return { vx, vy, d };
  return { vx:(vx/d)*m, vy:(vy/d)*m, d };
}

c.addEventListener("click", (e) => {
  const p = pos(e);
  well = { x:p.x, y:p.y };
});

c.addEventListener("mousedown", (e) => {
  if (e.button === 2) well = null;
});

function update(){
  for (const d of dots){
    if (well){
      const dx = well.x - d.x;
      const dy = well.y - d.y;
      const dist = Math.hypot(dx,dy) || 0.0001;

      // inverse-square-ish with softening
      const force = G / ((dist*dist) + (SOFT*SOFT));
      const ax = (dx / dist) * force;
      const ay = (dy / dist) * force;

      d.vx += ax;
      d.vy += ay;
    }

    // damping + clamp
    d.vx *= DAMP;
    d.vy *= DAMP;
    const lim = limit(d.vx, d.vy, MAXV);
    d.vx = lim.vx; d.vy = lim.vy;

    // integrate
    d.x += d.vx;
    d.y += d.vy;

    // wrap (so it doesn’t all pile up on edges)
    if (d.x < -50) d.x = W+50;
    if (d.x > W+50) d.x = -50;
    if (d.y < -50) d.y = H+50;
    if (d.y > H+50) d.y = -50;
  }
}

function draw(){
  x.clearRect(0,0,W,H);

  // grid
  x.strokeStyle = "rgba(255,255,255,0.05)";
  for (let i=0;i<=W;i+=150){ x.beginPath(); x.moveTo(i,0); x.lineTo(i,H); x.stroke(); }
  for (let j=0;j<=H;j+=150){ x.beginPath(); x.moveTo(0,j); x.lineTo(W,j); x.stroke(); }

  // well
  if (well){
    x.strokeStyle = "rgba(255,255,255,0.22)";
    x.lineWidth = 2;
    x.beginPath(); x.arc(well.x, well.y, 24, 0, Math.PI*2); x.stroke();
    x.beginPath(); x.arc(well.x, well.y, 90, 0, Math.PI*2); x.stroke();
    x.fillStyle = "rgba(255,255,255,0.85)";
    x.beginPath(); x.arc(well.x, well.y, 5, 0, Math.PI*2); x.fill();
  }

  // dots
  x.fillStyle = "rgba(255,255,255,0.7)";
  for (const d of dots){
    x.beginPath();
    x.arc(d.x, d.y, d.r, 0, Math.PI*2);
    x.fill();
  }

  // HUD
  x.fillStyle = "rgba(255,255,255,0.6)";
  x.font = "14px system-ui";
  x.fillText(well ? `well=ON  G=${G}` : "well=OFF (click to place)", 12, 24);

  requestAnimationFrame(loop);
}

function loop(){
  update();
  draw();
}
loop();
</script>
</body>
</html>
'></iframe>
</div>