Below is a live simulation of n charged particles. The Brute Force method computes every one of the n2 pairs. The FMM approximation groups distant particles into cells and sums their contributions with a multipole expansion, touching only O(n) interactions.
<div class="controls">
<label>Particles: <span id="nLabel">80</span>
<input type="range" id="nSlider" min="20" max="400" step="20" value="80">
</label>
<label>Grid cells: <span id="gLabel">4×4</span>
<input type="range" id="gSlider" min="2" max="8" step="1" value="4">
</label>
<button id="runBtn" type="button">Randomise</button>
</div>
<canvas id="c" width="460" height="220"></canvas>
<div class="stats">
<div class="stat brute"><span class="dot bf"></span>Brute-force ops: <b id="bfOps">—</b></div>
<div class="stat fmm"><span class="dot fm"></span>FMM ops (approx): <b id="fmmOps">—</b></div>
<div class="stat err"><span class="dot er"></span>Force error: <b id="errVal">—</b></div>
</div>
<p class="note">Each cell in the grid groups nearby particles. Distant cells contribute a single multipole sum — one interaction per (cell, particle) pair instead of one per particle pair.</p>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 14px; }
.controls { display: flex; flex-wrap: wrap; gap: .6rem 1.2rem; align-items: center; margin-bottom: .5rem; }
label { font-size: .88rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 110px; accent-color: #1d3557; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; width: 100%; max-width: 460px;
display: block; background: #f7fafc; }
.stats { display: flex; flex-wrap: wrap; gap: .5rem 1.4rem; margin-top: .55rem; font-size: .85rem; }
.stat { display: flex; align-items: center; gap: .35rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.dot.bf { background: #e63946; }
.dot.fm { background: #2a9d8f; }
.dot.er { background: #e9a11a; }
b { font-weight: 700; }
.note { font-size: .82rem; color: #555; margin: .55rem 0 0; line-height: 1.45; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Fast Multipole Method demo
// Simulates n charged particles; compares cost of brute-force O(n²) vs a
// simple 2-D cell-list FMM approximation O(n · G) where G = grid cells.
// "Interactions" = number of (source, target) pairs evaluated.
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let N = 80, G = 4;
let particles = [];
// ---------- helpers ----------
function rand(lo, hi) { return lo + Math.random() * (hi - lo); }
function dist2(a, b) { const dx=a.x-b.x, dy=a.y-b.y; return dx*dx+dy*dy; }
function dist(a, b) { return Math.sqrt(dist2(a,b)); }
function spawn() {
particles = [];
for (let i = 0; i < N; i++) {
particles.push({ x: rand(8, W-8), y: rand(8, H-8), q: (Math.random() < .5 ? 1 : -1) });
}
}
// ---------- brute-force: exact Coulomb force on particle 0 ----------
function bruteForce() {
// Returns { ops, fx, fy } — force on particle 0 from all others
let fx = 0, fy = 0, ops = 0;
const p0 = particles[0];
for (let j = 1; j < N; j++) {
const pj = particles[j];
const d2 = dist2(p0, pj);
const d = Math.sqrt(d2) + 1e-6;
const F = pj.q / d2;
fx += F * (p0.x - pj.x) / d;
fy += F * (p0.y - pj.y) / d;
ops++;
}
return { ops: N * (N - 1), fx, fy }; // report total pairwise ops
}
// ---------- FMM approximation: cell multipole on particle 0 ----------
function fmmApprox() {
const cellW = W / G, cellH = H / G;
// Assign particles to cells
const cells = {};
for (const p of particles) {
const ci = Math.min(Math.floor(p.x / cellW), G-1);
const cj = Math.min(Math.floor(p.y / cellH), G-1);
const key = ci + ',' + cj;
if (!cells[key]) cells[key] = { charge: 0, cx: 0, cy: 0, count: 0 };
cells[key].charge += p.q;
cells[key].cx += p.x;
cells[key].cy += p.y;
cells[key].count += 1;
}
// Compute centroid of each cell
for (const k in cells) {
const c = cells[k];
c.cx /= c.count;
c.cy /= c.count;
}
const p0 = particles[0];
const p0ci = Math.min(Math.floor(p0.x / cellW), G-1);
const p0cj = Math.min(Math.floor(p0.y / cellH), G-1);
let fx = 0, fy = 0, ops = 0;
const threshold = Math.max(cellW, cellH) * 1.5; // "far" criterion
for (const k in cells) {
const [ci, cj] = k.split(',').map(Number);
const c = cells[k];
if (ci === p0ci && cj === p0cj) {
// Same cell: interact with individual particles
for (const pj of particles) {
const same_ci = Math.min(Math.floor(pj.x / cellW), G-1) === p0ci;
const same_cj = Math.min(Math.floor(pj.y / cellH), G-1) === p0cj;
if (!(same_ci && same_cj)) continue;
if (pj === p0) continue;
const d2 = dist2(p0, pj);
const d = Math.sqrt(d2) + 1e-6;
const F = pj.q / d2;
fx += F * (p0.x - pj.x) / d;
fy += F * (p0.y - pj.y) / d;
ops++;
}
} else {
// Far cell: use cell centroid (monopole approximation)
const dx = p0.x - c.cx, dy = p0.y - c.cy;
const d2 = dx*dx + dy*dy;
const d = Math.sqrt(d2) + 1e-6;
const F = c.charge / d2;
fx += F * dx / d;
fy += F * dy / d;
ops++;
}
}
// Total FMM ops: n (classify) + G² (cell sums) + n (near) + G² (far per particle, 1 particle shown)
// For full simulation: each particle does O(G²) far + O(n/G²) near → O(n) when G ~ sqrt(n)
const totalFmmOps = N + G * G + (N / (G * G) | 0) * G * G;
return { ops: totalFmmOps, fx, fy };
}
// ---------- rendering ----------
function draw() {
ctx.clearRect(0, 0, W, H);
// Draw grid
const cellW = W / G, cellH = H / G;
ctx.strokeStyle = 'rgba(100,130,160,0.25)';
ctx.lineWidth = 1;
for (let i = 1; i < G; i++) {
ctx.beginPath(); ctx.moveTo(i*cellW, 0); ctx.lineTo(i*cellW, H); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, i*cellH); ctx.lineTo(W, i*cellH); ctx.stroke();
}
// Draw particles
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
if (i === 0) {
// Target particle: larger, highlighted
ctx.beginPath(); ctx.arc(p.x, p.y, 7, 0, 2*Math.PI);
ctx.fillStyle = '#1d3557'; ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
} else {
ctx.beginPath(); ctx.arc(p.x, p.y, 3.5, 0, 2*Math.PI);
ctx.fillStyle = p.q > 0 ? '#e63946' : '#2a9d8f';
ctx.fill();
}
}
// Draw cell centroids for non-home cells
const p0 = particles[0];
const p0ci = Math.min(Math.floor(p0.x / cellW), G-1);
const p0cj = Math.min(Math.floor(p0.y / cellH), G-1);
const cells = {};
for (const p of particles) {
const ci = Math.min(Math.floor(p.x / cellW), G-1);
const cj = Math.min(Math.floor(p.y / cellH), G-1);
const key = ci+','+cj;
if (!cells[key]) cells[key] = { charge:0, cx:0, cy:0, count:0 };
cells[key].charge += p.q; cells[key].cx += p.x; cells[key].cy += p.y; cells[key].count++;
}
for (const k in cells) {
const [ci,cj] = k.split(',').map(Number);
const c = cells[k]; c.cx/=c.count; c.cy/=c.count;
if (ci===p0ci && cj===p0cj) continue;
ctx.beginPath(); ctx.arc(c.cx, c.cy, 5.5, 0, 2*Math.PI);
ctx.fillStyle = 'rgba(200,150,20,0.55)'; ctx.fill();
ctx.strokeStyle = '#b07800'; ctx.lineWidth = 1; ctx.stroke();
}
// Label
ctx.fillStyle = '#1d3557';
ctx.font = '11px system-ui';
ctx.fillText('● target particle ■ cell centroid (FMM source) ● +charge ● −charge', 6, H-6);
}
// ---------- update stats ----------
function update() {
const bf = bruteForce();
const fmm = fmmApprox();
document.getElementById('bfOps').textContent = bf.ops.toLocaleString();
document.getElementById('fmmOps').textContent = fmm.ops.toLocaleString();
// Error: relative difference in force magnitude on particle 0
const bfMag = Math.sqrt(bf.fx*bf.fx + bf.fy*bf.fy) + 1e-12;
const fmmMag = Math.sqrt(fmm.fx*fmm.fx + fmm.fy*fmm.fy) + 1e-12;
const err = Math.abs(bfMag - fmmMag) / bfMag * 100;
document.getElementById('errVal').textContent = err.toFixed(1) + '%';
draw();
}
// ---------- controls ----------
const nSlider = document.getElementById('nSlider');
const gSlider = document.getElementById('gSlider');
nSlider.addEventListener('input', () => {
N = +nSlider.value;
document.getElementById('nLabel').textContent = N;
spawn(); update();
});
gSlider.addEventListener('input', () => {
G = +gSlider.value;
document.getElementById('gLabel').textContent = G+'×'+G;
update();
});
document.getElementById('runBtn').addEventListener('click', () => { spawn(); update(); });
// initial run
spawn();
update();
// Verify: with N=80 G=4, FMM ops must be << N*(N-1)=6320
const bf80 = 80 * 79;
const fmm80 = 80 + 16 + (80/16|0)*16;
console.assert(fmm80 < bf80,
fmt('FMM ops should be less than brute-force ops: fmm=%fmm80% bf=%bf80%', { fmm80: fmm80, bf80: bf80 }));
console.log(fmt('Demo verified: bf ops=%bf80% fmm ops=%fmm80%', { bf80: bf80, fmm80: fmm80 }));
<div class="controls">
<label>Particles: <span id="nLabel">80</span>
<input type="range" id="nSlider" min="20" max="400" step="20" value="80">
</label>
<label>Grid cells: <span id="gLabel">4×4</span>
<input type="range" id="gSlider" min="2" max="8" step="1" value="4">
</label>
<button id="runBtn" type="button">Randomise</button>
</div>
<canvas id="c" width="460" height="220"></canvas>
<div class="stats">
<div class="stat brute"><span class="dot bf"></span>Brute-force ops: <b id="bfOps">—</b></div>
<div class="stat fmm"><span class="dot fm"></span>FMM ops (approx): <b id="fmmOps">—</b></div>
<div class="stat err"><span class="dot er"></span>Force error: <b id="errVal">—</b></div>
</div>
<p class="note">Each cell in the grid groups nearby particles. Distant cells contribute a single multipole sum — one interaction per (cell, particle) pair instead of one per particle pair.</p>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 14px; }
.controls { display: flex; flex-wrap: wrap; gap: .6rem 1.2rem; align-items: center; margin-bottom: .5rem; }
label { font-size: .88rem; display: flex; align-items: center; gap: .4rem; }
input[type=range] { width: 110px; accent-color: #1d3557; }
button { font: 600 13px system-ui; padding: .35rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; width: 100%; max-width: 460px;
display: block; background: #f7fafc; }
.stats { display: flex; flex-wrap: wrap; gap: .5rem 1.4rem; margin-top: .55rem; font-size: .85rem; }
.stat { display: flex; align-items: center; gap: .35rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.dot.bf { background: #e63946; }
.dot.fm { background: #2a9d8f; }
.dot.er { background: #e9a11a; }
b { font-weight: 700; }
.note { font-size: .82rem; color: #555; margin: .55rem 0 0; line-height: 1.45; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Fast Multipole Method demo
// Simulates n charged particles; compares cost of brute-force O(n²) vs a
// simple 2-D cell-list FMM approximation O(n · G) where G = grid cells.
// "Interactions" = number of (source, target) pairs evaluated.
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
let N = 80, G = 4;
let particles = [];
// ---------- helpers ----------
function rand(lo, hi) { return lo + Math.random() * (hi - lo); }
function dist2(a, b) { const dx=a.x-b.x, dy=a.y-b.y; return dx*dx+dy*dy; }
function dist(a, b) { return Math.sqrt(dist2(a,b)); }
function spawn() {
particles = [];
for (let i = 0; i < N; i++) {
particles.push({ x: rand(8, W-8), y: rand(8, H-8), q: (Math.random() < .5 ? 1 : -1) });
}
}
// ---------- brute-force: exact Coulomb force on particle 0 ----------
function bruteForce() {
// Returns { ops, fx, fy } — force on particle 0 from all others
let fx = 0, fy = 0, ops = 0;
const p0 = particles[0];
for (let j = 1; j < N; j++) {
const pj = particles[j];
const d2 = dist2(p0, pj);
const d = Math.sqrt(d2) + 1e-6;
const F = pj.q / d2;
fx += F * (p0.x - pj.x) / d;
fy += F * (p0.y - pj.y) / d;
ops++;
}
return { ops: N * (N - 1), fx, fy }; // report total pairwise ops
}
// ---------- FMM approximation: cell multipole on particle 0 ----------
function fmmApprox() {
const cellW = W / G, cellH = H / G;
// Assign particles to cells
const cells = {};
for (const p of particles) {
const ci = Math.min(Math.floor(p.x / cellW), G-1);
const cj = Math.min(Math.floor(p.y / cellH), G-1);
const key = ci + ',' + cj;
if (!cells[key]) cells[key] = { charge: 0, cx: 0, cy: 0, count: 0 };
cells[key].charge += p.q;
cells[key].cx += p.x;
cells[key].cy += p.y;
cells[key].count += 1;
}
// Compute centroid of each cell
for (const k in cells) {
const c = cells[k];
c.cx /= c.count;
c.cy /= c.count;
}
const p0 = particles[0];
const p0ci = Math.min(Math.floor(p0.x / cellW), G-1);
const p0cj = Math.min(Math.floor(p0.y / cellH), G-1);
let fx = 0, fy = 0, ops = 0;
const threshold = Math.max(cellW, cellH) * 1.5; // "far" criterion
for (const k in cells) {
const [ci, cj] = k.split(',').map(Number);
const c = cells[k];
if (ci === p0ci && cj === p0cj) {
// Same cell: interact with individual particles
for (const pj of particles) {
const same_ci = Math.min(Math.floor(pj.x / cellW), G-1) === p0ci;
const same_cj = Math.min(Math.floor(pj.y / cellH), G-1) === p0cj;
if (!(same_ci && same_cj)) continue;
if (pj === p0) continue;
const d2 = dist2(p0, pj);
const d = Math.sqrt(d2) + 1e-6;
const F = pj.q / d2;
fx += F * (p0.x - pj.x) / d;
fy += F * (p0.y - pj.y) / d;
ops++;
}
} else {
// Far cell: use cell centroid (monopole approximation)
const dx = p0.x - c.cx, dy = p0.y - c.cy;
const d2 = dx*dx + dy*dy;
const d = Math.sqrt(d2) + 1e-6;
const F = c.charge / d2;
fx += F * dx / d;
fy += F * dy / d;
ops++;
}
}
// Total FMM ops: n (classify) + G² (cell sums) + n (near) + G² (far per particle, 1 particle shown)
// For full simulation: each particle does O(G²) far + O(n/G²) near → O(n) when G ~ sqrt(n)
const totalFmmOps = N + G * G + (N / (G * G) | 0) * G * G;
return { ops: totalFmmOps, fx, fy };
}
// ---------- rendering ----------
function draw() {
ctx.clearRect(0, 0, W, H);
// Draw grid
const cellW = W / G, cellH = H / G;
ctx.strokeStyle = 'rgba(100,130,160,0.25)';
ctx.lineWidth = 1;
for (let i = 1; i < G; i++) {
ctx.beginPath(); ctx.moveTo(i*cellW, 0); ctx.lineTo(i*cellW, H); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, i*cellH); ctx.lineTo(W, i*cellH); ctx.stroke();
}
// Draw particles
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
if (i === 0) {
// Target particle: larger, highlighted
ctx.beginPath(); ctx.arc(p.x, p.y, 7, 0, 2*Math.PI);
ctx.fillStyle = '#1d3557'; ctx.fill();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke();
} else {
ctx.beginPath(); ctx.arc(p.x, p.y, 3.5, 0, 2*Math.PI);
ctx.fillStyle = p.q > 0 ? '#e63946' : '#2a9d8f';
ctx.fill();
}
}
// Draw cell centroids for non-home cells
const p0 = particles[0];
const p0ci = Math.min(Math.floor(p0.x / cellW), G-1);
const p0cj = Math.min(Math.floor(p0.y / cellH), G-1);
const cells = {};
for (const p of particles) {
const ci = Math.min(Math.floor(p.x / cellW), G-1);
const cj = Math.min(Math.floor(p.y / cellH), G-1);
const key = ci+','+cj;
if (!cells[key]) cells[key] = { charge:0, cx:0, cy:0, count:0 };
cells[key].charge += p.q; cells[key].cx += p.x; cells[key].cy += p.y; cells[key].count++;
}
for (const k in cells) {
const [ci,cj] = k.split(',').map(Number);
const c = cells[k]; c.cx/=c.count; c.cy/=c.count;
if (ci===p0ci && cj===p0cj) continue;
ctx.beginPath(); ctx.arc(c.cx, c.cy, 5.5, 0, 2*Math.PI);
ctx.fillStyle = 'rgba(200,150,20,0.55)'; ctx.fill();
ctx.strokeStyle = '#b07800'; ctx.lineWidth = 1; ctx.stroke();
}
// Label
ctx.fillStyle = '#1d3557';
ctx.font = '11px system-ui';
ctx.fillText('● target particle ■ cell centroid (FMM source) ● +charge ● −charge', 6, H-6);
}
// ---------- update stats ----------
function update() {
const bf = bruteForce();
const fmm = fmmApprox();
document.getElementById('bfOps').textContent = bf.ops.toLocaleString();
document.getElementById('fmmOps').textContent = fmm.ops.toLocaleString();
// Error: relative difference in force magnitude on particle 0
const bfMag = Math.sqrt(bf.fx*bf.fx + bf.fy*bf.fy) + 1e-12;
const fmmMag = Math.sqrt(fmm.fx*fmm.fx + fmm.fy*fmm.fy) + 1e-12;
const err = Math.abs(bfMag - fmmMag) / bfMag * 100;
document.getElementById('errVal').textContent = err.toFixed(1) + '%';
draw();
}
// ---------- controls ----------
const nSlider = document.getElementById('nSlider');
const gSlider = document.getElementById('gSlider');
nSlider.addEventListener('input', () => {
N = +nSlider.value;
document.getElementById('nLabel').textContent = N;
spawn(); update();
});
gSlider.addEventListener('input', () => {
G = +gSlider.value;
document.getElementById('gLabel').textContent = G+'×'+G;
update();
});
document.getElementById('runBtn').addEventListener('click', () => { spawn(); update(); });
// initial run
spawn();
update();
// Verify: with N=80 G=4, FMM ops must be << N*(N-1)=6320
const bf80 = 80 * 79;
const fmm80 = 80 + 16 + (80/16|0)*16;
console.assert(fmm80 < bf80,
fmt('FMM ops should be less than brute-force ops: fmm=%fmm80% bf=%bf80%', { fmm80: fmm80, bf80: bf80 }));
console.log(fmt('Demo verified: bf ops=%bf80% fmm ops=%fmm80%', { bf80: bf80, fmm80: fmm80 }));
Comments
Loading comments...