<canvas>
body {
  margin: 0;
  padding: 0;
  overflow: hidden;
}

canvas {
  display: block;
}
const NB_PARTICLES = 20
const ATTR_RANGE = 80
const ATTR_STRENGTH = 20
const REP_RANGE = 20
const REP_STRENGTH = 50
const INV_FRICTION = 0.95

const SETTINGS = {
  attractionRange: 60,
  attractionStrength: 20,
  repulsionRange: 20,
  repulsionStrength: 50,
  invFriction: 0.95
}

// GUI
const gui = new dat.GUI()
gui.add(SETTINGS, "attractionRange", 0, 150)
gui.add(SETTINGS, "attractionStrength", 0, 100)
gui.add(SETTINGS, "repulsionRange", 0, 150)
gui.add(SETTINGS, "repulsionStrength", 0, 100)

let W = 300, H = 200
const cvs = document.querySelector("canvas")
const ctx = cvs.getContext("2d")
cvs.width = W
cvs.height = H

const distanceField = new Uint8ClampedArray(W*H*4)

const particles = []

// initialize particles
let { width, height } = cvs
for (let i = 0; i < NB_PARTICLES; i++) {
  particles[i] = {
    x: Math.random() * W,
    y: Math.random() * H,
    vx: 0,
    vy: 0,
    ax: 0,
    ay: 0
  }
}

const computeDistanceField = () => {
  let idx, D
  for (let x = 0; x < W; x++) {
    for (let y = 0; y < H; y++) {
      idx = (x + y*W) * 4
      let closest = Infinity
      for (const P of particles) {
        D = Math.sqrt((P.x-x)**2 + (P.y-y)**2)
        if (D < closest) {
          closest = D
        }
      }
      closest = 255 - (Math.min(closest*3, 255) | 0)
      distanceField[idx] = closest
      distanceField[idx+1] = closest
      distanceField[idx+2] = closest
      distanceField[idx+3] = 255
    }
  }
}

// update function
const update = () => {
  // compute the acceleration of each particle
  for (let p of particles) {
    // force reset the acceleration of the particle 
    p.ax = 0
    p.ay = 0
    
    // go through all the other particles
    for (let p2 of particles) {
      if (p !== p2) {
        // the vector between the 2 particles
        let M = {
          x: p2.x - p.x,
          y: p2.y - p.y
        }
        // the distance between the 2 particles
        let D = Math.sqrt(M.x**2 + M.y**2)
        
        if (D > 1) { // prevents extreme values because of division by D^2
          // normalized p->p2 vector
          M.x/= D
          M.y/= D
          // add attraction force
          if (D < SETTINGS.attractionRange) {
            p.ax+= M.x / D**2 * SETTINGS.attractionStrength
            p.ay+= M.y / D**2 * SETTINGS.attractionStrength
          }
          // add rep force
          if (D < SETTINGS.repulsionRange) {
            p.ax+= -M.x / D**2 * SETTINGS.repulsionStrength
            p.ay+= -M.y / D**2 * SETTINGS.repulsionStrength
          }
        }
      }
    }
  }
  
  // update the particles position based on their acceleration and velocity
  for (const p of particles) {
    p.vx+= p.ax
    p.vy+= p.ay
    p.vx*= INV_FRICTION
    p.vy*= INV_FRICTION
    p.x+= p.vx
    p.y+= p.vy
  }
}

// draw the particles
const draw = () => {  
  ctx.fillStyle = "black"
  ctx.fillRect(0, 0, W, H)
  
  // update and draw the distance field
  computeDistanceField()
  const imageData = new ImageData(distanceField, W, H)
  ctx.putImageData(imageData, 0, 0)
  
  ctx.fillStyle = "red"
  for (const p of particles) {
    ctx.beginPath()
    ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI)
    ctx.fill()
  }
}

const loop = () => {
  requestAnimationFrame(loop)
  update()
  draw()
}

loop()

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.7/dat.gui.min.js