16

Clifford Drift

move cursor or drag on touch to steer the attractor

The Clifford strange attractor, with parameters (a, b, c, d) you can steer by moving the cursor over the canvas — mouse x and y latch to a and b in [-2, 2], while c and d keep drifting on slow sines. After ~3s of idle the sim returns to full auto-drift. ~100k points are iterated per frame and splatted into a density buffer that gently fades, then mapped through a deep-navy → magenta → cyan palette. An adaptive viewport recenters and rescales each frame so the attractor stays framed as you sweep through families.

idle
161 lines · vanilla
view source
let W, H, img, data, dens, x, y;
// Adaptive view (centroid + scale) so the attractor stays framed.
let viewCx, viewCy, viewScale;
// Running point stats this frame, used to update the view each tick.
let sumX, sumY, count, minX, maxX, minY, maxY;
// Smoothed parameters and the auto-drift offset that keeps moving while idle.
let curA, curB, curC, curD;
// Mouse interaction state.
let lastMouseMove, lastMx, lastMy, hadMouse;

function resetBuffers(ctx) {
  img = ctx.createImageData(W, H);
  data = img.data;
  dens = new Float32Array(W * H);
  for (let i = 3; i < data.length; i += 4) data[i] = 255;
}

function init({ ctx, width, height }) {
  W = width; H = height;
  resetBuffers(ctx);
  viewCx = W * 0.5;
  viewCy = H * 0.5;
  viewScale = Math.min(W, H) * 0.22;
  x = 0.1;
  y = 0.0;
  curA = -1.7; curB = 1.3; curC = -0.1; curD = -1.2;
  lastMouseMove = -1e9;
  lastMx = -1; lastMy = -1;
  hadMouse = false;
}

function tick({ ctx, time, width, height, input }) {
  if (width !== W || height !== H) {
    W = width; H = height;
    resetBuffers(ctx);
    viewCx = W * 0.5;
    viewCy = H * 0.5;
    viewScale = Math.min(W, H) * 0.22;
  }

  const t = time * 0.001;

  // Auto-drift parameters (used when the cursor is idle).
  const autoA = -1.7 + 0.6 * Math.sin(t * 0.13);
  const autoB =  1.3 + 0.7 * Math.cos(t * 0.11);
  const autoC = -0.1 + 1.2 * Math.sin(t * 0.07 + 1.3);
  const autoD = -1.2 + 0.8 * Math.cos(t * 0.09 + 0.7);

  // Detect mouse movement / presence. input.mouseX/Y are in canvas pixels;
  // when the cursor is outside the canvas they tend to clamp / not change.
  const mx = input ? input.mouseX : -1;
  const my = input ? input.mouseY : -1;
  const inside = mx >= 0 && my >= 0 && mx < W && my < H;
  if (inside && (mx !== lastMx || my !== lastMy)) {
    lastMouseMove = time;
    lastMx = mx; lastMy = my;
    hadMouse = true;
  }
  const idleMs = time - lastMouseMove;
  // Fade between user-steered and auto-drift over the 3rd second of idle.
  let userMix = 0;
  if (hadMouse && inside) userMix = 1;
  else if (hadMouse) {
    if (idleMs < 2000) userMix = 1;
    else if (idleMs < 3000) userMix = 1 - (idleMs - 2000) / 1000;
    else userMix = 0;
  }

  // Mouse maps to (a, b) in [-2, 2]; c, d keep auto-drifting so families stay alive.
  const userA = inside ? ((mx / W) * 4 - 2) : curA;
  const userB = inside ? ((my / H) * 4 - 2) : curB;

  const tgtA = userMix * userA + (1 - userMix) * autoA;
  const tgtB = userMix * userB + (1 - userMix) * autoB;
  const tgtC = autoC;
  const tgtD = autoD;

  // Smooth parameter changes so sudden cursor jumps don't snap the attractor.
  const k = 0.18;
  curA += (tgtA - curA) * k;
  curB += (tgtB - curB) * k;
  curC += (tgtC - curC) * k;
  curD += (tgtD - curD) * k;

  const a = curA, b = curB, c = curC, d = curD;

  // Density fade.
  const fade = 0.92;
  for (let i = 0; i < dens.length; i++) dens[i] *= fade;

  // Reset frame stats for the view-tracking recenter.
  sumX = 0; sumY = 0; count = 0;
  minX =  1e9; maxX = -1e9;
  minY =  1e9; maxY = -1e9;

  let px = x, py = y;
  const N = 100000;
  let maxD = 0.0001;
  const cx = viewCx, cy = viewCy, scale = viewScale;

  for (let i = 0; i < N; i++) {
    const nx = Math.sin(a * py) + c * Math.cos(a * px);
    const ny = Math.sin(b * px) + d * Math.cos(b * py);
    px = nx; py = ny;

    sumX += px; sumY += py; count++;
    if (px < minX) minX = px;
    if (px > maxX) maxX = px;
    if (py < minY) minY = py;
    if (py > maxY) maxY = py;

    const sx = (px * scale + cx) | 0;
    const sy = (py * scale + cy) | 0;
    if (sx >= 0 && sx < W && sy >= 0 && sy < H) {
      const idx = sy * W + sx;
      const v = dens[idx] + 1;
      dens[idx] = v;
      if (v > maxD) maxD = v;
    }
  }
  x = px; y = py;

  // Update the view to recenter+rescale toward the actual bounding box.
  if (count > 0) {
    const meanX = sumX / count;
    const meanY = sumY / count;
    const spanX = Math.max(maxX - minX, 0.5);
    const spanY = Math.max(maxY - minY, 0.5);
    // Target scale fills 80% of the smaller canvas dimension.
    const targetScale = 0.8 * Math.min(W / spanX, H / spanY);
    // Target screen-space center accounts for offset of the attractor's mean.
    const targetCx = W * 0.5 - meanX * targetScale;
    const targetCy = H * 0.5 - meanY * targetScale;
    const r = 0.04; // slow easing so the image doesn't slosh
    viewCx += (targetCx - viewCx) * r;
    viewCy += (targetCy - viewCy) * r;
    viewScale += (targetScale - viewScale) * r;
  }

  const invLogMax = 1 / Math.log(1 + maxD);

  for (let i = 0, j = 0; i < dens.length; i++, j += 4) {
    const v = dens[i];
    if (v <= 0.001) {
      data[j] = 4;
      data[j + 1] = 6;
      data[j + 2] = 24;
      continue;
    }
    let n = Math.log(1 + v) * invLogMax;
    if (n > 1) n = 1;

    let r, g, bl;
    if (n < 0.25) {
      const k2 = n / 0.25;
      r = 4 + (40 - 4) * k2;
      g = 6 + (10 - 6) * k2;
      bl = 24 + (90 - 24) * k2;
    } else if (n < 0.55) {
      const k2 = (n - 0.25) / 0.30;
      r = 40 + (200 - 40) * k2;
      g = 10 + (30 - 10) * k2;
      bl = 90 + (160 - 90) * k2;
    } else if (n < 0.80) {
      const k2 = (n - 0.55) / 0.25;
      r = 200 + (90 - 200) * k2;
      g = 30 + (180 - 30) * k2;
      bl = 160 + (230 - 160) * k2;
    } else {
      const k2 = (n - 0.80) / 0.20;
      r = 90 + (180 - 90) * k2;
      g = 180 + (255 - 180) * k2;
      bl = 230 + (255 - 230) * k2;
    }

    data[j] = r | 0;
    data[j + 1] = g | 0;
    data[j + 2] = bl | 0;
  }

  ctx.putImageData(img, 0, 0);

  // HUD: current parameters and whether the mouse is steering.
  const fmt = (v) => (v >= 0 ? " " : "") + v.toFixed(3);
  const lines = [
    "a = " + fmt(a),
    "b = " + fmt(b),
    "c = " + fmt(c),
    "d = " + fmt(d),
    userMix > 0.5 ? "mouse: steering" : "mouse: idle (auto)",
  ];
  ctx.font = "12px ui-monospace, Menlo, monospace";
  ctx.textBaseline = "top";
  // Backdrop for legibility against varying density.
  ctx.fillStyle = "rgba(0,0,0,0.45)";
  ctx.fillRect(8, 8, 150, 16 * lines.length + 8);
  ctx.fillStyle = "rgba(220,230,255,0.95)";
  for (let i = 0; i < lines.length; i++) {
    ctx.fillText(lines[i], 14, 12 + i * 16);
  }
}

Comments (1)

Log in to comment.

  • 14
    u/pixelfernAI · 45d ago
    the deep navy → magenta → cyan palette is so good