{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"ripple-tank","type":"registry:ui","title":"Ripple Tank","description":"A shallow-water surface solved on a grid: drips fall on a clock, their rings interfere, and the wake reflects off the walls of the tank instead of fading out. Click to drop your own.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/ripple-tank.tsx","target":"components/ui/ripple-tank.tsx","content":"\"use client\"\n\nimport { useCanvasScene, useReducedMotion } from \"../hooks/use-canvas-scene\"\n\n/**\n * Ripple tank — two-source wave interference.\n *\n * A scalar height field is summed over two point sources at a fixed timestep:\n *\n *     h(p, t) = Σ_s  A/√(r_s + r0) · sin(k·r_s − ω·t)     k = 2π/λ, ω = 2π·f\n *\n * The per-cell distances r_s are fixed, so `setup` precomputes each source's\n * spatial phase (k·r_s) and amplitude envelope once; every frame then costs one\n * sin per source per cell. Time advances by a constant dt (no keyframes, no\n * easing) — the motion is the equation, integrated.\n *\n * The field is evaluated on a coarse buffer and bilinearly upscaled to the\n * stage, so a 60fps paint stays cheap regardless of canvas size. Under\n * prefers-reduced-motion a single frame is drawn: the analytic superposition\n * amplitude |A₁e^{ikr₁} + A₂e^{ikr₂}|, i.e. the standing hyperbolic fringes.\n */\n\nexport type RippleTankProps = { compact?: boolean; className?: string }\n\ntype Field = {\n  cols: number\n  rows: number\n  buffer: HTMLCanvasElement\n  bctx: CanvasRenderingContext2D\n  image: ImageData\n  /** k·r_s per cell, one array per source. */\n  phase: Float32Array[]\n  /** A/√(r_s + r0) per cell, one array per source. */\n  amp: Float32Array[]\n  /** Soft additive source glow per cell (summed over sources). */\n  glow: Float32Array\n  /** Static superposition amplitude per cell — the reduced-motion frame. */\n  still: Float32Array\n  /** ω·dt: the fixed phase advance per frame. */\n  step: number\n  /** 1 / peak instantaneous |h|, for the animated frame. */\n  invNorm: number\n  /** 1 / peak superposition amplitude, for the still frame. */\n  invStill: number\n}\n\n/**\n * Precompute everything that does not move: the source geometry, and for every\n * cell of the coarse buffer the spatial phase k·r and amplitude A/√(r+r0) of\n * each source, plus the analytic standing-wave amplitude used for the reduced\n * frame. Re-run by the hook on every resize, so the grid is always sized to the\n * stage and never resized in place.\n */\nfunction buildField(width: number, height: number, compact: boolean): Field {\n  // Coarse cells: a wave is ~44px across, so ~6px cells resolve every crest\n  // with margin while keeping the buffer small enough to sum at 60fps. Cap the\n  // total cell count so a large stage scales the cell up instead of the cost.\n  const target = compact ? 7 : 6\n  const maxCells = compact ? 9000 : 22000\n  let cell = target\n  let cols = Math.max(2, Math.ceil(width / cell))\n  let rows = Math.max(2, Math.ceil(height / cell))\n  while (cols * rows > maxCells) {\n    cell += 1\n    cols = Math.max(2, Math.ceil(width / cell))\n    rows = Math.max(2, Math.ceil(height / cell))\n  }\n\n  // Physical constants, in CSS pixels. λ sets fringe spacing; the source\n  // separation is a few wavelengths so several hyperbolic nodes are in frame.\n  const minSide = Math.min(width, height)\n  const wavelength = Math.max(26, Math.min(52, minSide * 0.11))\n  const k = (2 * Math.PI) / wavelength\n  const r0 = wavelength * 0.5 // softening: caps amplitude at the singularity\n  const envelope = Math.max(width, height) * 0.62 // gentle radial fade\n\n  const sep = Math.min(width * 0.42, wavelength * 3.6)\n  const cx = width / 2\n  const cy = height / 2\n  const sources = [\n    { x: cx - sep / 2, y: cy },\n    { x: cx + sep / 2, y: cy },\n  ]\n  const A = compact ? 0.86 : 1\n\n  const count = cols * rows\n  const phase = sources.map(() => new Float32Array(count))\n  const amp = sources.map(() => new Float32Array(count))\n  const glow = new Float32Array(count)\n  const still = new Float32Array(count)\n\n  let peakAmp = 0 // Σ|amp| — the largest instantaneous |h| any cell can reach\n  let peakStill = 0\n\n  for (let gy = 0; gy < rows; gy++) {\n    // Sample at cell centres, mapped back into stage pixels.\n    const py = ((gy + 0.5) / rows) * height\n    for (let gx = 0; gx < cols; gx++) {\n      const px = ((gx + 0.5) / cols) * width\n      const i = gy * cols + gx\n\n      let ampSum = 0\n      let reCos = 0\n      let reSin = 0\n      let glowSum = 0\n      for (let s = 0; s < sources.length; s++) {\n        const dx = px - sources[s].x\n        const dy = py - sources[s].y\n        const r = Math.sqrt(dx * dx + dy * dy)\n        const a = (A / Math.sqrt(r + r0)) * Math.exp(-r / envelope)\n        const ph = k * r\n        phase[s][i] = ph\n        amp[s][i] = a\n        ampSum += a\n        // Phasor sum → standing amplitude |Σ a·e^{ikr}| for the still frame.\n        reCos += a * Math.cos(ph)\n        reSin += a * Math.sin(ph)\n        glowSum += Math.exp(-r / (wavelength * 1.5))\n      }\n      glow[i] = glowSum\n      const st = Math.sqrt(reCos * reCos + reSin * reSin)\n      still[i] = st\n      if (ampSum > peakAmp) peakAmp = ampSum\n      if (st > peakStill) peakStill = st\n    }\n  }\n\n  const buffer = document.createElement(\"canvas\")\n  buffer.width = cols\n  buffer.height = rows\n  const bctx = buffer.getContext(\"2d\")\n  if (!bctx) throw new Error(\"ripple-tank: 2D context unavailable\")\n  const image = bctx.createImageData(cols, rows)\n\n  // ω·dt as one number. A period of ~52 frames reads as calm, deliberate water.\n  const step = (2 * Math.PI) / 52\n\n  return {\n    cols,\n    rows,\n    buffer,\n    bctx,\n    image,\n    phase,\n    amp,\n    glow,\n    still,\n    step,\n    invNorm: peakAmp > 0 ? 1 / peakAmp : 1,\n    invStill: peakStill > 0 ? 1 / peakStill : 1,\n  }\n}\n\nexport function RippleTank({ compact = false, className }: RippleTankProps) {\n  const reduced = useReducedMotion()\n\n  const { stageRef, canvasRef } = useCanvasScene<Field>({\n    setup: ({ width, height }) => buildField(width, height, compact),\n    draw: ({ context, width, height, state, frame }) =>\n      drawField(context, width, height, state, frame, reduced, compact),\n  })\n\n  return (\n    <div\n      ref={stageRef}\n      className={`relative block size-full overflow-hidden ${className ?? \"\"}`.trim()}\n      style={{ touchAction: \"pan-y\", background: \"rgb(8, 11, 28)\" }}\n      role=\"img\"\n      aria-label=\"Two-source wave interference on a ripple tank, cyan crests fanning into hyperbolic fringes\"\n    >\n      <canvas ref={canvasRef} className=\"block size-full\" />\n    </div>\n  )\n}\n\nfunction smoothstep(edge0: number, edge1: number, x: number): number {\n  const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)))\n  return t * t * (3 - 2 * t)\n}\n\n/**\n * Paint one frame. `frame·step` is the integrated time t; the field is the sum\n * of each source's precomputed envelope times sin(phase − t). The value is\n * mapped through a deep-indigo → cyan → white ramp, written to the coarse\n * buffer, then upscaled with bilinear smoothing to fill the stage.\n */\nfunction drawField(\n  context: CanvasRenderingContext2D,\n  width: number,\n  height: number,\n  field: Field,\n  frame: number,\n  reduced: boolean,\n  compact: boolean,\n) {\n  const { cols, rows, phase, amp, glow, still, image, step, invNorm, invStill } = field\n  const data = image.data\n  const t = frame * step\n\n  // Rest indigo, and the deltas a crest/trough push it by. Kept subtle so the\n  // tank reads as lit water rather than neon.\n  const baseR = 11\n  const baseG = 17\n  const baseB = 40\n  const crestR = 122\n  const crestG = 226\n  const crestB = 255\n  const contrast = compact ? 1.08 : 1.22\n  const glowGain = compact ? 0.5 : 0.85\n\n  const src0Phase = phase[0]\n  const src1Phase = phase[1]\n  const src0Amp = amp[0]\n  const src1Amp = amp[1]\n\n  for (let i = 0; i < cols * rows; i++) {\n    let up: number\n    let down: number\n\n    if (reduced) {\n      // Standing superposition amplitude: the time-invariant fringe pattern.\n      up = Math.min(1, still[i] * invStill * contrast)\n      down = 0\n    } else {\n      const h =\n        (src0Amp[i] * Math.sin(src0Phase[i] - t) + src1Amp[i] * Math.sin(src1Phase[i] - t)) *\n        invNorm *\n        contrast\n      up = h > 0 ? Math.min(1, h) : 0\n      down = h < 0 ? Math.min(1, -h) : 0\n    }\n\n    // Crest side: lerp indigo → cyan, then lift the peaks toward white.\n    const c = Math.pow(up, 0.82)\n    let r = baseR + c * (crestR - baseR)\n    let g = baseG + c * (crestG - baseG)\n    let b = baseB + c * (crestB - baseB)\n    const hl = smoothstep(0.62, 1, c)\n    r += hl * (255 - r) * 0.85\n    g += hl * (255 - g) * 0.8\n    b += hl * (255 - b) * 0.6\n\n    // Trough side: sink toward near-black indigo.\n    r -= down * 7\n    g -= down * 12\n    b -= down * 26\n\n    // Faint cyan bloom radiating from each source.\n    const bloom = glow[i] * glowGain\n    r += bloom * 10\n    g += bloom * 30\n    b += bloom * 44\n\n    const o = i * 4\n    data[o] = r // Uint8ClampedArray clamps out-of-range for us\n    data[o + 1] = g\n    data[o + 2] = b\n    data[o + 3] = 255\n  }\n\n  field.bctx.putImageData(image, 0, 0)\n  context.imageSmoothingEnabled = true\n  context.imageSmoothingQuality = \"high\"\n  context.clearRect(0, 0, width, height)\n  context.drawImage(field.buffer, 0, 0, cols, rows, 0, 0, width, height)\n}\n","type":"registry:ui"},{"path":"hooks/use-canvas-scene.ts","target":"hooks/use-canvas-scene.ts","content":"\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\n/**\n * The canvas preamble every 2D scene needs, in one place: a DPR-scaled backing\n * store, a rebuild on resize, a loop that stops when the stage scrolls out of\n * view, pointer tracking with per-frame deltas, and teardown.\n *\n * A scene supplies two functions. `setup` builds whatever mutable state the\n * animation owns and is re-run whenever the stage changes size, so the state can\n * be sized to the stage without ever being resized in place. `draw` paints one\n * frame from that state — it is called with the transform already scaled to\n * device pixels, so every coordinate in it is a CSS pixel.\n */\n\nexport type ScenePointer = {\n  x: number\n  y: number\n  /** Position at the previous painted frame, so `x - lastX` is a frame delta. */\n  lastX: number\n  lastY: number\n  down: boolean\n  inside: boolean\n}\n\nexport type SceneSetupContext = {\n  context: CanvasRenderingContext2D\n  width: number\n  height: number\n  dpr: number\n}\n\nexport type SceneDrawContext<State> = SceneSetupContext & {\n  state: State\n  pointer: ScenePointer\n  /** Painted frames since the last rebuild. Useful for every-Nth-frame work. */\n  frame: number\n}\n\nexport type CanvasSceneOptions<State> = {\n  setup: (context: SceneSetupContext) => State\n  draw: (context: SceneDrawContext<State>) => void\n}\n\nexport type CanvasScene = {\n  /** The sizing element. Owns the pointer listeners and is what is observed. */\n  stageRef: (node: HTMLDivElement | null) => void\n  canvasRef: (node: HTMLCanvasElement | null) => void\n  /** Paint one frame now. The escape hatch for a paused or reduced-motion loop. */\n  requestRender: () => void\n}\n\n/** Live `prefers-reduced-motion`. False during SSR and the first paint. */\nexport function useReducedMotion() {\n  const [reduced, setReduced] = useState(false)\n\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    setReduced(query.matches)\n    const onChange = () => setReduced(query.matches)\n    query.addEventListener(\"change\", onChange)\n    return () => query.removeEventListener(\"change\", onChange)\n  }, [])\n\n  return reduced\n}\n\nexport function useCanvasScene<State>(options: CanvasSceneOptions<State>): CanvasScene {\n  const reduced = useReducedMotion()\n\n  /*\n   * `draw` is usually an inline closure, so it is a new function on every\n   * render. Reading it through a ref keeps the loop from being torn down and\n   * the scene from being rebuilt each time the component re-renders.\n   */\n  const optionsRef = useRef(options)\n  optionsRef.current = options\n\n  const stage = useRef<HTMLDivElement | null>(null)\n  const canvas = useRef<HTMLCanvasElement | null>(null)\n\n  /*\n   * Plain ref assignment, with no state behind it. React attaches refs during\n   * the commit phase, before passive effects run, so the effect below already\n   * sees both nodes on the first mount — which is why these used to bump a\n   * `mounted` counter for nothing: the two `setMounted` calls batched into one\n   * re-render, the counter went 0 → 2, and the effect's dependency on it tore\n   * the live scene down and rebuilt it. Every scene was constructed, measured\n   * and warmed twice on every mount, four times under StrictMode in dev.\n   *\n   * The requirement this trades for that: a consumer must render the stage and\n   * the canvas unconditionally, in the same commit as the component itself. All\n   * thirteen do. Gating the canvas behind a flag would leave the effect bailing\n   * on the null guard with nothing to re-run it.\n   */\n  const stageRef = useCallback((node: HTMLDivElement | null) => {\n    stage.current = node\n  }, [])\n  const canvasRef = useCallback((node: HTMLCanvasElement | null) => {\n    canvas.current = node\n  }, [])\n\n  /** Set once the scene is live, so `requestRender` before that is a no-op. */\n  const render = useRef<(() => void) | null>(null)\n  const requestRender = useCallback(() => render.current?.(), [])\n\n  useEffect(() => {\n    const stageNode = stage.current\n    const canvasNode = canvas.current\n    if (!stageNode || !canvasNode) return\n\n    const context = canvasNode.getContext(\"2d\")\n    if (!context) return\n\n    const pointer: ScenePointer = {\n      x: 0,\n      y: 0,\n      lastX: 0,\n      lastY: 0,\n      down: false,\n      inside: false,\n    }\n\n    let state: State | null = null\n    let width = 0\n    let height = 0\n    let dpr = 1\n    let frame = 0\n    let loop = 0\n    let pending = 0\n    let visible = true\n\n    /** Rebuild the backing store and the scene state for the current size. */\n    const measure = () => {\n      // `offsetWidth`/`offsetHeight`, not `getBoundingClientRect()`: the rect is\n      // post-transform, so a scene sitting inside a scaled ancestor measured its\n      // own frame at the scaled size, sized the backing store to that, and then\n      // had CSS scale the result a second time — the scene ran at a fraction of\n      // the box it was drawn into. The catalogue's scaled-poster branch is the\n      // one place that happens, and it is reachable again the moment an\n      // animation is registered without a card composition. These two properties\n      // are the untransformed layout box; both are integers, which is what the\n      // rounding below already reduced the rect to.\n      const nextWidth = Math.max(1, stageNode.offsetWidth)\n      const nextHeight = Math.max(1, stageNode.offsetHeight)\n      const nextDpr = Math.min(2, window.devicePixelRatio || 1)\n      if (nextWidth === width && nextHeight === height && nextDpr === dpr && state) return\n\n      width = nextWidth\n      height = nextHeight\n      dpr = nextDpr\n      canvasNode.width = Math.round(width * dpr)\n      canvasNode.height = Math.round(height * dpr)\n      canvasNode.style.width = `${width}px`\n      canvasNode.style.height = `${height}px`\n      frame = 0\n      state = optionsRef.current.setup({ context, width, height, dpr })\n    }\n\n    const paint = () => {\n      if (!state) return\n      // Re-applied every frame: a scene is free to install its own transform\n      // for a cell or a sprite, and most do.\n      context.setTransform(dpr, 0, 0, dpr, 0, 0)\n      optionsRef.current.draw({ context, width, height, dpr, state, pointer, frame })\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n      frame += 1\n    }\n\n    /** One frame on the next tick, coalescing however many were asked for. */\n    const paintOnce = () => {\n      if (pending) return\n      pending = requestAnimationFrame(() => {\n        pending = 0\n        measure()\n        paint()\n      })\n    }\n    render.current = paintOnce\n\n    const tick = () => {\n      loop = requestAnimationFrame(tick)\n      if (visible) paint()\n    }\n\n    const start = () => {\n      if (loop || reduced) return\n      loop = requestAnimationFrame(tick)\n    }\n    const stop = () => {\n      if (!loop) return\n      cancelAnimationFrame(loop)\n      loop = 0\n    }\n\n    const at = (event: PointerEvent) => {\n      const rect = stageNode.getBoundingClientRect()\n      // The rect is the right thing to subtract here — `clientX` is viewport\n      // space and so is the rect — but the difference comes back in *rendered*\n      // pixels, and a scene reads `pointer` in the scene pixels `measure()` set\n      // up from the untransformed box. Under a CSS scale those two disagree, so\n      // divide the transform back out. `rect.width / offsetWidth` is the scale\n      // actually in force, whatever produced it, and it is exactly 1 when there\n      // is none.\n      const scale = stageNode.offsetWidth > 0 ? rect.width / stageNode.offsetWidth : 1\n      pointer.x = (event.clientX - rect.left) / (scale || 1)\n      pointer.y = (event.clientY - rect.top) / (scale || 1)\n      // A frozen loop still owes the user feedback for a drag.\n      if (reduced) paintOnce()\n    }\n\n    const onEnter = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n      pointer.lastX = pointer.x\n      pointer.lastY = pointer.y\n    }\n    const onMove = (event: PointerEvent) => {\n      pointer.inside = true\n      at(event)\n    }\n    const onDown = (event: PointerEvent) => {\n      pointer.down = true\n      at(event)\n      // Capture keeps a drag alive past the edge of the stage, which is where\n      // a hard throw naturally ends up.\n      stageNode.setPointerCapture(event.pointerId)\n    }\n    const onUp = (event: PointerEvent) => {\n      pointer.down = false\n      at(event)\n      if (stageNode.hasPointerCapture(event.pointerId)) {\n        stageNode.releasePointerCapture(event.pointerId)\n      }\n    }\n    const onLeave = () => {\n      pointer.inside = false\n      pointer.down = false\n      if (reduced) paintOnce()\n    }\n\n    stageNode.addEventListener(\"pointerenter\", onEnter)\n    stageNode.addEventListener(\"pointermove\", onMove)\n    stageNode.addEventListener(\"pointerdown\", onDown)\n    stageNode.addEventListener(\"pointerup\", onUp)\n    stageNode.addEventListener(\"pointercancel\", onUp)\n    stageNode.addEventListener(\"pointerleave\", onLeave)\n\n    const resizes = new ResizeObserver(() => paintOnce())\n    resizes.observe(stageNode)\n\n    /*\n     * An animation nobody can see is heat. The observer both pauses the loop\n     * and, on the way back in, repaints immediately rather than waiting a frame.\n     */\n    const views = new IntersectionObserver(\n      (entries) => {\n        visible = entries.some((entry) => entry.isIntersecting)\n        if (visible) {\n          start()\n          paintOnce()\n        } else {\n          stop()\n        }\n      },\n      { rootMargin: \"120px\" },\n    )\n    views.observe(stageNode)\n\n    measure()\n    paint()\n    start()\n\n    return () => {\n      render.current = null\n      stop()\n      if (pending) cancelAnimationFrame(pending)\n      resizes.disconnect()\n      views.disconnect()\n      stageNode.removeEventListener(\"pointerenter\", onEnter)\n      stageNode.removeEventListener(\"pointermove\", onMove)\n      stageNode.removeEventListener(\"pointerdown\", onDown)\n      stageNode.removeEventListener(\"pointerup\", onUp)\n      stageNode.removeEventListener(\"pointercancel\", onUp)\n      stageNode.removeEventListener(\"pointerleave\", onLeave)\n    }\n  }, [reduced])\n\n  return { stageRef, canvasRef, requestRender }\n}\n","type":"registry:hook"}],"meta":{"kind":"animations","categories":["backgrounds","particles"],"docs":"https://ui.artbloom.tech/artbloom/animations/ripple-tank"}}