2

Hénon Map: Folded Attractor

drag Y to scrub a

The Hénon map iterates , — a discrete dynamical system whose orbits trace a folded strange attractor at , . Tens of thousands of iterates per frame accumulate into a density buffer that fades slowly, revealing the attractor's Cantor-set cross-section. Drag vertically to scrub across and watch fixed points bifurcate into chaos.

idle
145 lines · vanilla
view source
// Hénon map: x_{n+1} = 1 - a x_n^2 + y_n,  y_{n+1} = b x_n
// Accumulates ~60k iterates per frame into a fixed-size density buffer,
// fades it slowly, then drawImage-scales onto the visible canvas.
// Mouse Y latches a in [1.0, 1.4]; auto-sweeps when idle.

const B = 0.3;
const ITERS_PER_FRAME = 60000;
const FADE = 0.965;
const BUF_W = 720;
const BUF_H = 720;
const IDLE_MS = 1200;

let W = 0, H = 0;
let bufCanvas, bufCtx, bufImg, bufData;
let density;
let palette;

let x = 0.0, y = 0.0;
let a = 1.4;
let aDisplay = 1.4;

let lastMouseX = -1, lastMouseY = -1;
let lastMoveTime = -10000;
let sweepPhase = 0;

function buildPalette() {
  // dark -> deep purple -> magenta -> cyan -> near-white
  palette = new Uint8Array(1024 * 4);
  const stops = [
    [0.00,   3,   4,  16],
    [0.10,  16,   6,  44],
    [0.28,  78,  10, 110],
    [0.48, 188,  30, 168],
    [0.66, 232,  78, 200],
    [0.82,  90, 210, 232],
    [1.00, 230, 250, 255],
  ];
  for (let i = 0; i < 1024; i++) {
    const t = i / 1023;
    let A = stops[0], C = stops[stops.length - 1];
    for (let k = 0; k < stops.length - 1; k++) {
      if (t >= stops[k][0] && t <= stops[k + 1][0]) {
        A = stops[k]; C = stops[k + 1]; break;
      }
    }
    const u = (t - A[0]) / (C[0] - A[0] || 1);
    palette[i * 4]     = (A[1] + (C[1] - A[1]) * u) | 0;
    palette[i * 4 + 1] = (A[2] + (C[2] - A[2]) * u) | 0;
    palette[i * 4 + 2] = (A[3] + (C[3] - A[3]) * u) | 0;
    palette[i * 4 + 3] = 255;
  }
}

function init({ canvas, ctx, width, height }) {
  W = width; H = height;
  buildPalette();

  bufCanvas = new OffscreenCanvas(BUF_W, BUF_H);
  bufCtx = bufCanvas.getContext('2d');
  bufImg = bufCtx.createImageData(BUF_W, BUF_H);
  bufData = bufImg.data;
  density = new Float32Array(BUF_W * BUF_H);

  // alpha = 255 throughout
  for (let i = 3; i < bufData.length; i += 4) bufData[i] = 255;

  x = 0.1; y = 0.0;
  // warm up off-attractor transient
  for (let i = 0; i < 200; i++) {
    const nx = 1 - 1.4 * x * x + y;
    y = B * x;
    x = nx;
  }

  ctx.fillStyle = '#03040e';
  ctx.fillRect(0, 0, W, H);
}

function updateA({ input, time }) {
  // Detect mouse motion to decide latched vs auto-sweep.
  const mx = input.mouseX, my = input.mouseY;
  if (mx !== lastMouseX || my !== lastMouseY) {
    lastMouseX = mx; lastMouseY = my;
    lastMoveTime = time;
  }
  const idle = (time - lastMoveTime) > IDLE_MS;
  let target;
  if (!idle && my >= 0 && my <= H) {
    // map Y to a in [1.0, 1.4]
    target = 1.0 + (my / H) * 0.4;
  } else {
    // auto-sweep across the same range
    sweepPhase += 0.0035;
    target = 1.2 + 0.2 * Math.sin(sweepPhase);
  }
  // smooth toward target so changes don't flash
  aDisplay += (target - aDisplay) * 0.08;
  a = aDisplay;
}

function tick({ ctx, time, width, height, input }) {
  if (width !== W || height !== H) {
    W = width; H = height;
  }

  updateA({ input, time });

  // Fade density.
  for (let i = 0; i < density.length; i++) density[i] *= FADE;

  // Iterate and accumulate.
  // Center the canonical attractor (roughly x in [-1.5,1.5], y in [-0.45,0.45])
  // into the square buffer with a margin.
  const cx = BUF_W * 0.5;
  const cy = BUF_H * 0.5;
  const sx = BUF_W * 0.30;       // x scale
  const sy = BUF_H * 1.05;       // y scale (y range is much smaller)

  let lx = x, ly = y;
  for (let i = 0; i < ITERS_PER_FRAME; i++) {
    const nx = 1 - a * lx * lx + ly;
    ly = B * lx;
    lx = nx;
    // bail if the orbit escaped (large a or numerical fluke)
    if (lx < -10 || lx > 10) { lx = 0.1; ly = 0.0; continue; }
    const px = (cx + lx * sx) | 0;
    const py = (cy - ly * sy) | 0;
    if (px >= 0 && px < BUF_W && py >= 0 && py < BUF_H) {
      density[py * BUF_W + px] += 1.0;
    }
  }
  x = lx; y = ly;

  // Find max (strided sample).
  let max = 0;
  const stride = 17;
  for (let i = 0; i < density.length; i += stride) {
    const v = density[i];
    if (v > max) max = v;
  }
  if (max < 1) max = 1;
  const invLog = 1 / Math.log1p(max);

  // Density -> palette.
  for (let i = 0; i < density.length; i++) {
    const v = density[i];
    const o = i * 4;
    if (v <= 0.01) {
      bufData[o]     = 3;
      bufData[o + 1] = 4;
      bufData[o + 2] = 16;
      continue;
    }
    let t = Math.log1p(v) * invLog;
    if (t > 1) t = 1;
    t = Math.pow(t, 0.62);
    const p = ((t * 1023) | 0) * 4;
    bufData[o]     = palette[p];
    bufData[o + 1] = palette[p + 1];
    bufData[o + 2] = palette[p + 2];
  }
  bufCtx.putImageData(bufImg, 0, 0);

  // Scale buffer to the visible canvas.
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = 'high';
  ctx.drawImage(bufCanvas, 0, 0, BUF_W, BUF_H, 0, 0, W, H);

  // HUD: live a value, top-left.
  ctx.font = 'bold 14px ui-monospace, Menlo, monospace';
  ctx.textBaseline = 'top';
  const label = 'a = ' + a.toFixed(4) + '   b = ' + B.toFixed(2);
  const pad = 8;
  const tw = ctx.measureText(label).width;
  ctx.fillStyle = 'rgba(0,0,0,0.45)';
  ctx.fillRect(8, 8, tw + pad * 2, 24);
  ctx.fillStyle = '#e8f6ff';
  ctx.fillText(label, 8 + pad, 12);

  // Hint when idle.
  const idle = (time - lastMoveTime) > IDLE_MS;
  if (idle) {
    ctx.font = '12px ui-sans-serif, system-ui, sans-serif';
    ctx.fillStyle = 'rgba(232,246,255,0.55)';
    ctx.fillText('drag vertically to scrub a', 8 + pad, 36);
  }
}

Comments (0)

Log in to comment.