{"slug":"hopalong","number":"25","title":"Hopalong attractor","era":"Martin · 1986","summary":"A one-line iterator that fills a CRT. Pointer nudges a.","description":"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.","apis":["Canvas 2D","ImageData","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["attractor","plotter"],"hasGlsl":false,"interaction":"Move the pointer left or right to change the constant a.","url":"https://3d-retro.com/experiments/hopalong","example_url":"https://3d-retro.com/examples/hopalong.html","api_url":"https://3d-retro.com/api/v1/experiments/hopalong","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"Barry Martin's hopalong attractor. Pointer nudges the constant.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/hopalong\" />\n  <title>Hopalong attractor · 3d retro graphics</title>\n  <style>\n    html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #07060a; color: #e8e6e1; position: absolute; inset: 0; }\n    canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; }\n    .fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace; text-align: center; color: #e8e6e1; background: #07060a; }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#6ee7b7\",\n    a: 0.4,\n    scale: 1\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [110, 231, 183];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  const BW = 420, BH = 240;\n  const buf = document.createElement(\"canvas\");\n  buf.width = BW; buf.height = BH;\n  const bctx = buf.getContext(\"2d\");\n  const img = bctx.createImageData(BW, BH);\n  const d = img.data;\n  bctx.fillStyle = \"#07060a\";\n  bctx.fillRect(0, 0, BW, BH);\n  const src = bctx.getImageData(0, 0, BW, BH);\n  d.set(src.data);\n\n  let x = 0, y = 0, n = 0, look = 0;\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  canvas.addEventListener(\"pointermove\", function (ev) {\n    const r = canvas.getBoundingClientRect();\n    look = (ev.clientX - r.left) / r.width - 0.5;\n  });\n\n  function fade() {\n    for (let i = 0; i < d.length; i += 4) {\n      d[i] *= 0.96; d[i + 1] *= 0.96; d[i + 2] *= 0.96;\n    }\n  }\n\n  function step() {\n    fade();\n    const rgb = hexToRgb(PARAMS.color);\n    const a = PARAMS.a + look * 0.35;\n    const b = 1, c = 0;\n    const s = Math.min(BW, BH) * 0.1 * PARAMS.scale;\n    const cx = BW * 0.5, cy = BH * 0.5;\n    const iters = reduced ? 28000 : 18000;\n    for (let i = 0; i < iters; i++) {\n      const nx = y - Math.sign(x || 1) * Math.sqrt(Math.abs(b * x - c));\n      y = a - x;\n      x = nx;\n      const px = (cx + x * s) | 0;\n      const py = (cy + y * s) | 0;\n      if (px < 0 || py < 0 || px >= BW || py >= BH) continue;\n      const o = (py * BW + px) * 4;\n      const k = 0.4 + 0.6 * ((n + i) % 97) / 97;\n      d[o] = Math.min(255, d[o] + rgb[0] * 0.16 * k);\n      d[o + 1] = Math.min(255, d[o + 1] + rgb[1] * 0.16 * k);\n      d[o + 2] = Math.min(255, d[o + 2] + rgb[2] * 0.16 * k);\n      d[o + 3] = 255;\n    }\n    n += iters;\n    bctx.putImageData(img, 0, 0);\n    ctx.imageSmoothingEnabled = false;\n    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);\n  }\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }\n  }\n\n  function frame() {\n    resize();\n    step();\n    if (!reduced) requestAnimationFrame(frame);\n  }\n\n  if (reduced) { resize(); step(); }\n  else requestAnimationFrame(frame);\n})();\n</script>\n</body>\n</html>\n\n"}