Home

25. Martin · 1986 · Retro techniques

Hopalong attractor

Demos are animating. Following your system setting.
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.

Reference

Barry Martin's hopalong map, as presented by A. K. Dewdney in Scientific American, September 1986

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. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

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;
  let raf = 0;
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.matches;
  canvas.addEventListener("pointermove", function (ev) {
    const r = canvas.getBoundingClientRect();
    look = (ev.clientX - r.left) / r.width - 0.5;
    wake();
  });

  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) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();