{"slug":"spiralstretch","number":"30","title":"Spiral stretch","era":"Illustration · 2026","section":"modern","provenance":"interpretation","reference":null,"summary":"A hopalong cousin: radius shrinks, z grows. Pointer detunes the shrink.","description":"No fluid solver. Each iterate is r ← k r, θ ← θ + ω, z ← z + c/r. Plot (r cos θ, r sin θ + z) and you get the cartoon of inward spiral plus axial stretch — the 2026 Navier–Stokes construction as a CRT orbit. Teal is early, orange is stretched. Pointer nudges k. When a walker's radius collapses it reseeds on the ring.","apis":["Canvas 2D","ImageData","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["attractor","crt"],"hasGlsl":false,"interaction":"Move the pointer left or right to change how fast the radius shrinks.","url":"https://3d-retro.com/experiments/spiralstretch","example_url":"https://3d-retro.com/examples/spiralstretch.html","api_url":"https://3d-retro.com/api/v1/experiments/spiralstretch","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=\"Inward spiral, axial stretch. A hopalong cousin with no fluid solver.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/spiralstretch\" />\n  <title>Spiral stretch · 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: \"#ff6a2a\",\n    accent: \"#3ce0c8\",\n    shrink: 0.992\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [255, 106, 42];\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  d.set(bctx.getImageData(0, 0, BW, BH).data);\n\n  const N = 64;\n  const pts = [];\n  function seed() {\n    pts.length = 0;\n    for (let i = 0; i < N; i++) {\n      const th = (i / N) * Math.PI * 2;\n      const r = 1.05 + (i % 7) * 0.04;\n      pts.push({ r: r, th: th, z: (i % 5) * 0.02 });\n    }\n  }\n  seed();\n\n  let look = 0;\n  let raf = 0;\n  let warmed = false;\n  const motionQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  let reduced = motionQuery.matches;\n  canvas.addEventListener(\"pointermove\", function (ev) {\n    const rec = canvas.getBoundingClientRect();\n    look = (ev.clientX - rec.left) / rec.width - 0.5;\n    wake();\n  });\n\n  function fade() {\n    for (let i = 0; i < d.length; i += 4) {\n      d[i] *= 0.965; d[i + 1] *= 0.965; d[i + 2] *= 0.965;\n    }\n  }\n\n  function plot(px, py, k, hot, cool) {\n    const x = px | 0, y = py | 0;\n    if (x < 0 || y < 0 || x >= BW || y >= BH) return;\n    const o = (y * BW + x) * 4;\n    const r = cool[0] + (hot[0] - cool[0]) * k;\n    const g = cool[1] + (hot[1] - cool[1]) * k;\n    const b = cool[2] + (hot[2] - cool[2]) * k;\n    d[o] = Math.min(255, d[o] + r * 0.22);\n    d[o + 1] = Math.min(255, d[o + 1] + g * 0.22);\n    d[o + 2] = Math.min(255, d[o + 2] + b * 0.22);\n    d[o + 3] = 255;\n  }\n\n  function step() {\n    fade();\n    const hot = hexToRgb(PARAMS.color);\n    const cool = hexToRgb(PARAMS.accent);\n    const shrink = Math.min(0.999, Math.max(0.96, PARAMS.shrink + look * 0.012));\n    const cx = BW * 0.5, cy = BH * 0.62;\n    const s = Math.min(BW, BH) * 0.28;\n    const inner = 4;\n    for (let k = 0; k < inner; k++) {\n      for (let i = 0; i < pts.length; i++) {\n        const p = pts[i];\n        p.th += 0.19;\n        p.r *= shrink;\n        p.z += 0.085 / (p.r + 0.04);\n        if (p.r < 0.03 || p.z > 14) {\n          p.r = 1.08 + (i % 7) * 0.05;\n          p.th = (i / N) * Math.PI * 2 + p.z;\n          p.z = 0;\n        }\n        const x = p.r * Math.cos(p.th);\n        const y = p.r * Math.sin(p.th);\n        const px = cx + x * s;\n        const py = cy - (y * 0.32 + p.z * 0.085) * s;\n        const heat = Math.min(1, p.z / 8);\n        plot(px, py, heat, hot, cool);\n        plot(px + 1, py, heat * 0.5, hot, cool);\n      }\n    }\n    bctx.putImageData(img, 0, 0);\n    ctx.imageSmoothingEnabled = false;\n    ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);\n    ctx.font = Math.max(10, (canvas.height * 0.03) | 0) + \"px ui-monospace, 'IBM Plex Mono', monospace\";\n    ctx.fillStyle = \"rgba(7,6,10,0.5)\";\n    ctx.fillRect(6, 6, canvas.width * 0.46, canvas.height * 0.1);\n    ctx.fillStyle = \"#c8d2dc\";\n    ctx.textBaseline = \"top\";\n    ctx.fillText(\"r shrinks   z grows\", 12, 10);\n    ctx.fillText(\"inward spiral + axial stretch\", 12, 10 + canvas.height * 0.04);\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    if (reduced) {\n      if (!warmed) { for (let i = 0; i < 80; i++) step(); warmed = true; }\n      resize();\n      ctx.drawImage(buf, 0, 0, canvas.width, canvas.height);\n    } else {\n      resize();\n      step();\n    }\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  // Motion. The system's reduced-motion preference decides by default. A\n  // standalone copy animates anyway with \"#motion=play\" in its URL (or holds a\n  // frame with \"#motion=still\"); an embedding page can post {motion: \"play\"}.\n  let motionMode = \"auto\";\n  function applyMotion() {\n    const next = motionMode === \"still\" || (motionMode === \"auto\" && motionQuery.matches);\n    if (next === reduced) return;\n    reduced = next;\n    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }\n    else if (!raf) raf = requestAnimationFrame(frame);\n  }\n  function setMotion(mode) {\n    if (mode === \"play\" || mode === \"still\" || mode === \"auto\") { motionMode = mode; applyMotion(); }\n  }\n  function readMotionHash() {\n    const m = /motion=(play|still|auto)/.exec(location.hash);\n    let mode = m ? m[1] : \"auto\";\n    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.\n    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === \"string\") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }\n    setMotion(mode);\n  }\n  function wake() { if (reduced) frame(performance.now()); }\n  if (motionQuery.addEventListener) motionQuery.addEventListener(\"change\", applyMotion);\n  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);\n  window.addEventListener(\"hashchange\", readMotionHash);\n  window.addEventListener(\"message\", function (e) { if (e.data && typeof e.data.motion === \"string\") setMotion(e.data.motion); });\n  window.addEventListener(\"resize\", wake);\n\n  if (reduced) frame(performance.now());\n  else raf = requestAnimationFrame(frame);\n  readMotionHash();\n})();\n</script>\n</body>\n</html>\n"}