Home

25. Martin · 1986

Hopalong attractor

Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

Barry Martin's hopalong: x' = y − sign(x)·√|bx − c|, y' = a − x. Plot the orbit and a shape appears. Move the pointer to detune a. Color is additive phosphor.

Move the pointer left or right to change the constant a.

Browser APIs

  • Canvas 2D
  • ImageData
  • Pointer Events
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. prefers-reduced-motion freezes the first still.

Source

(function () {
  const PARAMS = {
    color: "#6ee7b7",
    a: 0.4,
    scale: 1
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [110, 231, 183];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const BW = 420, BH = 240;
  const buf = document.createElement("canvas");
  buf.width = BW; buf.height = BH;
  const bctx = buf.getContext("2d");
  const img = bctx.createImageData(BW, BH);
  const d = img.data;
  bctx.fillStyle = "#07060a";
  bctx.fillRect(0, 0, BW, BH);
  const src = bctx.getImageData(0, 0, BW, BH);
  d.set(src.data);

  let x = 0, y = 0, n = 0, look = 0;
  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  canvas.addEventListener("pointermove", function (ev) {
    const r = canvas.getBoundingClientRect();
    look = (ev.clientX - r.left) / r.width - 0.5;
  });

  function fade() {
    for (let i = 0; i < d.length; i += 4) {
      d[i] *= 0.96; d[i + 1] *= 0.96; d[i + 2] *= 0.96;
    }
  }

  function step() {
    fade();
    const rgb = hexToRgb(PARAMS.color);
    const a = PARAMS.a + look * 0.35;
    const b = 1, c = 0;
    const s = Math.min(BW, BH) * 0.1 * PARAMS.scale;
    const cx = BW * 0.5, cy = BH * 0.5;
    const iters = reduced ? 28000 : 18000;
    for (let i = 0; i < iters; i++) {
      const nx = y - Math.sign(x || 1) * Math.sqrt(Math.abs(b * x - c));
      y = a - x;
      x = nx;
      const px = (cx + x * s) | 0;
      const py = (cy + y * s) | 0;
      if (px < 0 || py < 0 || px >= BW || py >= BH) continue;
      const o = (py * BW + px) * 4;
      const k = 0.4 + 0.6 * ((n + i) % 97) / 97;
      d[o] = Math.min(255, d[o] + rgb[0] * 0.16 * k);
      d[o + 1] = Math.min(255, d[o + 1] + rgb[1] * 0.16 * k);
      d[o + 2] = Math.min(255, d[o + 2] + rgb[2] * 0.16 * k);
      d[o + 3] = 255;
    }
    n += iters;
    bctx.putImageData(img, 0, 0);
    ctx.imageSmoothingEnabled = false;
    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);
  }

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
  }

  function frame() {
    resize();
    step();
    if (!reduced) requestAnimationFrame(frame);
  }

  if (reduced) { resize(); step(); }
  else requestAnimationFrame(frame);
})();