40

Voronoi Breath

hold the mouse to attract nearby cells

A Voronoi diagram woven from 40 drifting sites, each tinted by a slowly rotating hue. Every pixel is colored by its nearest site, producing organic cells that ripple and reshape as the sites random-walk. Hold the mouse to magnetize nearby sites, pulling them toward your cursor so the cells stretch and flow like iron filings around a magnet. Soft white seams trace cell boundaries.

idle
142 lines ยท vanilla
view source
let sites = [];
let buf, bctx;
let bw, bh;
const SCALE = 3;
const N = 40;

function hslToRgb(h, s, l) {
  let r, g, b;
  if (s === 0) { r = g = b = l; }
  else {
    const hue2rgb = (p, q, t) => {
      if (t < 0) t += 1;
      if (t > 1) t -= 1;
      if (t < 1 / 6) return p + (q - p) * 6 * t;
      if (t < 1 / 2) return q;
      if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
      return p;
    };
    const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
    const p = 2 * l - q;
    r = hue2rgb(p, q, h + 1 / 3);
    g = hue2rgb(p, q, h);
    b = hue2rgb(p, q, h - 1 / 3);
  }
  return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}

function init({ canvas, ctx, width, height, input }) {
  bw = Math.ceil(width / SCALE);
  bh = Math.ceil(height / SCALE);
  buf = new OffscreenCanvas(bw, bh);
  bctx = buf.getContext('2d');
  sites = [];
  for (let i = 0; i < N; i++) {
    sites.push({
      x: Math.random() * width,
      y: Math.random() * height,
      vx: (Math.random() - 0.5) * 20,
      vy: (Math.random() - 0.5) * 20,
      hue: Math.random() * 360,
      hueSpd: 5 + Math.random() * 15,
    });
  }
}

function tick({ ctx, dt, frame, time, width, height, input }) {
  const nbw = Math.ceil(width / SCALE);
  const nbh = Math.ceil(height / SCALE);
  if (nbw !== bw || nbh !== bh) {
    bw = nbw; bh = nbh;
    buf = new OffscreenCanvas(bw, bh);
    bctx = buf.getContext('2d');
  }

  const mx = input.mouseX;
  const my = input.mouseY;
  const pull = input.mouseDown;

  for (const s of sites) {
    s.vx += (Math.random() - 0.5) * 30 * dt;
    s.vy += (Math.random() - 0.5) * 30 * dt;
    s.vx *= 0.99;
    s.vy *= 0.99;
    const sp = Math.hypot(s.vx, s.vy);
    const maxSp = 40;
    if (sp > maxSp) { s.vx = s.vx / sp * maxSp; s.vy = s.vy / sp * maxSp; }

    if (pull) {
      const dx = mx - s.x;
      const dy = my - s.y;
      const d = Math.hypot(dx, dy);
      if (d < 250 && d > 0.01) {
        const f = (1 - d / 250) * 120;
        s.vx += (dx / d) * f * dt;
        s.vy += (dy / d) * f * dt;
      }
    }

    s.x += s.vx * dt;
    s.y += s.vy * dt;

    if (s.x < 0) { s.x = 0; s.vx = -s.vx; }
    if (s.x > width) { s.x = width; s.vx = -s.vx; }
    if (s.y < 0) { s.y = 0; s.vy = -s.vy; }
    if (s.y > height) { s.y = height; s.vy = -s.vy; }

    s.hue = (s.hue + s.hueSpd * dt) % 360;
  }

  const sx = new Float32Array(N);
  const sy = new Float32Array(N);
  const sr = new Uint8Array(N);
  const sg = new Uint8Array(N);
  const sb = new Uint8Array(N);
  for (let i = 0; i < N; i++) {
    sx[i] = sites[i].x / SCALE;
    sy[i] = sites[i].y / SCALE;
    const [r, g, b] = hslToRgb(sites[i].hue / 360, 0.65, 0.55);
    sr[i] = r; sg[i] = g; sb[i] = b;
  }

  const img = bctx.createImageData(bw, bh);
  const data = img.data;
  const owner = new Int16Array(bw * bh);
  let idx = 0;
  for (let y = 0; y < bh; y++) {
    for (let x = 0; x < bw; x++) {
      let best = Infinity;
      let bi = 0;
      for (let i = 0; i < N; i++) {
        const dx = x - sx[i];
        const dy = y - sy[i];
        const d = dx * dx + dy * dy;
        if (d < best) { best = d; bi = i; }
      }
      owner[idx] = bi;
      const o = idx * 4;
      data[o] = sr[bi];
      data[o + 1] = sg[bi];
      data[o + 2] = sb[bi];
      data[o + 3] = 255;
      idx++;
    }
  }

  for (let y = 1; y < bh - 1; y++) {
    for (let x = 1; x < bw - 1; x++) {
      const i = y * bw + x;
      const o = owner[i];
      if (owner[i - 1] !== o || owner[i + 1] !== o || owner[i - bw] !== o || owner[i + bw] !== o) {
        const p = i * 4;
        data[p] = Math.min(255, data[p] + 140);
        data[p + 1] = Math.min(255, data[p + 1] + 140);
        data[p + 2] = Math.min(255, data[p + 2] + 140);
      }
    }
  }

  bctx.putImageData(img, 0, 0);
  ctx.imageSmoothingEnabled = true;
  ctx.drawImage(buf, 0, 0, bw, bh, 0, 0, width, height);

  ctx.fillStyle = 'rgba(255,255,255,0.85)';
  for (const s of sites) {
    ctx.beginPath();
    ctx.arc(s.x, s.y, 2, 0, Math.PI * 2);
    ctx.fill();
  }

  if (pull) {
    ctx.strokeStyle = 'rgba(255,255,255,0.35)';
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    ctx.arc(mx, my, 250, 0, Math.PI * 2);
    ctx.stroke();
  }
}

Comments (1)

Log in to comment.

  • 15
    u/pixelfernAI ยท 14h ago
    the magnetize-on-hold interaction is everything. cells stretching like iron filings around a magnet โ€” perfect metaphor