{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dither-wave",
  "type": "registry:component",
  "title": "Dither Wave",
  "description": "A relief of waves drawn with one-bit Bayer ordered dithering. The cursor leaves expanding rings.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Backgrounds"
  ],
  "docs": "Installed in components/furin/dither-wave/. Docs, live controls and props table: https://furin.dev/components/dither-wave/",
  "files": [
    {
      "path": "registry/dither-wave/DitherWave.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport \"./DitherWave.css\";\n\nexport interface DitherWaveProps {\n  /** Contenido que va encima de la onda. */\n  children?: ReactNode;\n  /** Color de los píxeles apagados (el fondo). */\n  dark?: string;\n  /** Color de los píxeles encendidos. */\n  light?: string;\n  /** Tamaño de cada píxel del tramado, en px. */\n  cell?: number;\n  /** Qué tan rápido avanza la onda. */\n  speed?: number;\n  /** Altura del relieve. */\n  amplitude?: number;\n  /** Qué tanto deforma el cursor la superficie. */\n  reactivity?: number;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\nconst VERT = `#version 300 es\nvoid main() {\n  vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));\n  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}`;\n\nconst FRAG = `#version 300 es\nprecision highp float;\nuniform vec2 u_res;\nuniform float u_t;\nuniform float u_cell;       // tamaño del píxel de tramado, en píxeles reales\nuniform float u_amp;\nuniform vec3 u_dark;\nuniform vec3 u_light;\nuniform vec3 u_ptr;         // x, y (espacio de la superficie) y fuerza\nuniform vec4 u_ond[6];      // ondas del cursor: x, y, nacimiento, fuerza\nuniform float u_now;\nout vec4 o;\n\n// Matriz de Bayer 8x8 como umbral de tramado ordenado.\nfloat bayer2(vec2 a) { a = floor(a); return fract(a.x / 2.0 + a.y * a.y * 0.75); }\nfloat bayer4(vec2 a) { return bayer2(0.5 * a) * 0.25 + bayer2(a); }\nfloat bayer8(vec2 a) { return bayer4(0.5 * a) * 0.25 + bayer2(a); }\n\n// Relieve: marejada de varias capas con el dominio doblado, para que no repita.\nfloat H(vec2 p) {\n  float t = u_t;\n  vec2 q = p;\n  q += 0.30 * vec2(sin(p.y * 1.5 + t * 0.40), cos(p.x * 1.3 - t * 0.33));\n  float a = 0.50 * sin(q.x * 3.4 + q.y * 1.5 + t * 0.7 + sin(q.y * 1.9 + t * 0.3) * 1.8);\n  a += 0.34 * sin(q.y * 3.1 - q.x * 1.9 - t * 0.55);\n  a += 0.22 * sin(length(q - vec2(0.55, -0.25)) * 6.0 - t * 0.9);\n  // Oleaje fino montado sobre la marejada grande.\n  a += 0.16 * sin((q.x * 0.8 + q.y * 1.5) * 9.0 - t * 1.3 + a * 2.0);\n  float h = a * u_amp;\n  vec2 d = p - u_ptr.xy;\n  h += u_ptr.z * 0.55 * exp(-dot(d, d) * 6.0);\n  for (int i = 0; i < 6; i++) {\n    vec4 g = u_ond[i];\n    if (g.w <= 0.0) continue;\n    float age = u_now - g.z;\n    float x = length(p - g.xy) - age * 0.55;\n    h += g.w * 0.3 * cos(x * 16.0) * exp(-x * x * 30.0) * exp(-age * 1.1);\n  }\n  return h;\n}\n\nvoid main() {\n  vec2 pc = floor(gl_FragCoord.xy / u_cell);\n  vec2 c = (pc + 0.5) * u_cell;\n  vec2 p = (c - 0.5 * u_res) / u_res.y * 2.0;\n  float e = u_cell / u_res.y * 2.0;\n  float h0 = H(p), hx = H(p + vec2(e, 0.0)), hy = H(p + vec2(0.0, e));\n  vec3 n = normalize(vec3(-(hx - h0) / e * 0.30, -(hy - h0) / e * 0.30, 1.0));\n  vec3 l = normalize(vec3(-0.5, 0.55, 0.67));\n  float dif = dot(n, l);\n  // Tono: altura (valles oscuros, crestas claras) + luz difusa que modela cada ola; el tramado convierte ese gradiente en puntos.\n  float L = 0.5 + 0.30 * h0 + 0.5 * (dif - 0.67);\n  L = smoothstep(0.08, 0.92, L);\n  // Hacia las esquinas la onda se apaga: el centro queda como escenario.\n  vec2 vv = gl_FragCoord.xy / u_res - 0.5;\n  L *= 1.0 - 0.9 * smoothstep(0.18, 0.62, dot(vv, vv) * 1.6);\n  float th = bayer8(pc);\n  vec3 col = L > th ? u_light : u_dark;\n  o = vec4(col, 1.0);\n}`;\n\nlet colorCtx: CanvasRenderingContext2D | null = null;\nconst RGB_CACHE = new Map<string, [number, number, number]>();\nfunction rgb(css: string, def: [number, number, number]): [number, number, number] {\n  const hit = RGB_CACHE.get(css);\n  if (hit) return hit;\n  try {\n    colorCtx ??= document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true })!;\n    colorCtx.clearRect(0, 0, 1, 1);\n    colorCtx.fillStyle = \"#010203\";\n    colorCtx.fillStyle = css;\n    colorCtx.fillRect(0, 0, 1, 1);\n    const d = colorCtx.getImageData(0, 0, 1, 1).data;\n    const out: [number, number, number] = [d[0] / 255, d[1] / 255, d[2] / 255];\n    if (RGB_CACHE.size > 64) RGB_CACHE.clear();\n    RGB_CACHE.set(css, out);\n    return out;\n  } catch {\n    return def;\n  }\n}\n\nfunction compilar(gl: WebGL2RenderingContext, vs: string, fs: string): WebGLProgram | null {\n  const mk = (tipo: number, src: string) => {\n    const s = gl.createShader(tipo)!;\n    gl.shaderSource(s, src);\n    gl.compileShader(s);\n    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(s)); return null; }\n    return s;\n  };\n  const a = mk(gl.VERTEX_SHADER, vs), b = mk(gl.FRAGMENT_SHADER, fs);\n  if (!a || !b) return null;\n  const p = gl.createProgram()!;\n  gl.attachShader(p, a); gl.attachShader(p, b);\n  gl.linkProgram(p);\n  gl.deleteShader(a); gl.deleteShader(b);\n  if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(p)); return null; }\n  return p;\n}\n\n/** Fondo de onda tramada: un relieve que fluye, dibujado solo con píxeles de dos colores (tramado Bayer de 1 bit). */\nexport function DitherWave({\n  children,\n  dark = \"#0a0b0f\",\n  light = \"#d9d3c3\",\n  cell = 3,\n  speed = 1,\n  amplitude = 1,\n  reactivity = 1,\n  className = \"\",\n  style,\n}: DitherWaveProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const props = useRef({ dark, light, cell, speed, amplitude, reactivity });\n  props.current = { dark, light, cell, speed, amplitude, reactivity };\n  const wakeRef = useRef<(() => void) | null>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const canvas = document.createElement(\"canvas\");\n    canvas.className = \"furin-dither-wave__lienzo\";\n    canvas.setAttribute(\"aria-hidden\", \"true\");\n    const gl = canvas.getContext(\"webgl2\", { alpha: false, antialias: false, powerPreference: \"high-performance\" });\n    if (!gl) return; // sin WebGL2 queda el color oscuro de fondo\n    root.insertBefore(canvas, root.firstChild);\n\n    const reducido = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let prog: WebGLProgram | null = null;\n    let U: Record<string, WebGLUniformLocation | null> = {};\n    let visible = true, muerto = false, raf = 0, w = 0, h = 0, dpr = 1, t = reducido ? 6.2 : 1.5, ult = 0, reloj = 0;\n    const ptr = { x: 0, y: 0, tx: 0, ty: 0, k: 0, kObj: 0, lx: 0, ly: 0, hay: false };\n    const ond = new Float32Array(6 * 4);\n    let sig = 0;\n\n    const iniciarGl = () => {\n      prog = compilar(gl, VERT, FRAG);\n      if (!prog) return;\n      for (const k of [\"u_res\", \"u_t\", \"u_cell\", \"u_amp\", \"u_dark\", \"u_light\", \"u_ptr\", \"u_ond\", \"u_now\"]) U[k] = gl.getUniformLocation(prog, k);\n      gl.useProgram(prog);\n    };\n\n    const pintar = () => {\n      if (!prog || gl.isContextLost() || !w) return;\n      const p = props.current;\n      gl.viewport(0, 0, canvas.width, canvas.height);\n      gl.useProgram(prog);\n      gl.uniform2f(U.u_res, canvas.width, canvas.height);\n      gl.uniform1f(U.u_t, t);\n      gl.uniform1f(U.u_cell, Math.max(1, Math.round(p.cell * dpr)));\n      gl.uniform1f(U.u_amp, p.amplitude);\n      gl.uniform3f(U.u_dark, ...rgb(p.dark, [0.04, 0.04, 0.06]));\n      gl.uniform3f(U.u_light, ...rgb(p.light, [0.85, 0.83, 0.76]));\n      gl.uniform3f(U.u_ptr, ptr.x, ptr.y, ptr.k * p.reactivity);\n      gl.uniform4fv(U.u_ond, ond);\n      gl.uniform1f(U.u_now, reloj);\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n    };\n\n    const paso = (ts: number) => {\n      raf = 0;\n      if (muerto || !visible) return;\n      const dt = Math.min(0.05, ult ? (ts - ult) / 1000 : 0.016);\n      ult = ts;\n      reloj += dt;\n      t += dt * 0.6 * props.current.speed;\n      const k = 1 - Math.exp(-dt * 8);\n      ptr.x += (ptr.tx - ptr.x) * k; ptr.y += (ptr.ty - ptr.y) * k;\n      ptr.k += (ptr.kObj - ptr.k) * (1 - Math.exp(-dt * 4));\n      for (let i = 0; i < 6; i++) if (ond[i * 4 + 3] > 0 && reloj - ond[i * 4 + 2] > 4) ond[i * 4 + 3] = 0;\n      pintar();\n      raf = requestAnimationFrame(paso);\n    };\n    const wake = () => { if (reducido) return; if (!raf && visible && !muerto) { ult = 0; raf = requestAnimationFrame(paso); } };\n    wakeRef.current = () => { if (reducido) pintar(); else wake(); };\n\n    const medir = () => {\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const r = root.getBoundingClientRect();\n      w = Math.max(1, Math.round(r.width * dpr)); h = Math.max(1, Math.round(r.height * dpr));\n      if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }\n      pintar();\n      wake();\n    };\n    const ro = new ResizeObserver(medir);\n    ro.observe(root);\n    const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) wake(); }, { rootMargin: \"80px\" });\n    io.observe(root);\n\n    const aSuperficie = (e: PointerEvent) => {\n      const r = root.getBoundingClientRect();\n      const s = 2 / Math.max(r.height, 1);\n      return [(e.clientX - r.left - r.width / 2) * s, -(e.clientY - r.top - r.height / 2) * s] as const;\n    };\n    const onMove = (e: PointerEvent) => {\n      const [x, y] = aSuperficie(e);\n      if (!ptr.hay) { ptr.x = x; ptr.y = y; ptr.hay = true; }\n      ptr.tx = x; ptr.ty = y; ptr.kObj = 1;\n      // Cada tramo de recorrido suelta un anillo que se expande.\n      if (Math.hypot(x - ptr.lx, y - ptr.ly) > 0.3) {\n        const i = sig++ % 6;\n        ond[i * 4] = x; ond[i * 4 + 1] = y; ond[i * 4 + 2] = reloj; ond[i * 4 + 3] = 0.7;\n        ptr.lx = x; ptr.ly = y;\n      }\n    };\n    const onLeave = () => { ptr.kObj = 0; ptr.hay = false; };\n    if (!reducido) {\n      root.addEventListener(\"pointermove\", onMove, { passive: true });\n      root.addEventListener(\"pointerdown\", onMove, { passive: true });\n      root.addEventListener(\"pointerleave\", onLeave);\n    }\n    canvas.addEventListener(\"webglcontextlost\", (e) => { e.preventDefault(); cancelAnimationFrame(raf); raf = 0; });\n    canvas.addEventListener(\"webglcontextrestored\", () => { U = {}; iniciarGl(); medir(); });\n\n    iniciarGl();\n    medir();\n\n    return () => {\n      muerto = true;\n      wakeRef.current = null;\n      cancelAnimationFrame(raf);\n      ro.disconnect(); io.disconnect();\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerdown\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n      gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n      canvas.remove();\n    };\n  }, []);\n\n  useEffect(() => { wakeRef.current?.(); }, [dark, light, cell, speed, amplitude, reactivity]);\n\n  const vars = { \"--furin-dither-wave-dark\": dark } as CSSProperties;\n  return (\n    <div ref={rootRef} className={`furin-dither-wave ${className}`} style={{ ...vars, ...style }}>\n      {children != null ? <div className=\"furin-dither-wave__contenido\">{children}</div> : null}\n    </div>\n  );\n}\n\nexport default DitherWave;\n",
      "type": "registry:component",
      "target": "components/furin/dither-wave/DitherWave.tsx"
    },
    {
      "path": "registry/dither-wave/DitherWave.css",
      "content": ".furin-dither-wave {\n  position: absolute;\n  inset: 0;\n  display: grid;\n  place-items: center;\n  overflow: hidden;\n  isolation: isolate;\n  background: var(--furin-dither-wave-dark, #0a0b0f);\n  touch-action: pan-y;\n}\n/* Los píxeles del tramado se ven nítidos, nunca suavizados. */\n.furin-dither-wave__lienzo { position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: 0; image-rendering: pixelated; }\n.furin-dither-wave__contenido { position: relative; z-index: 1; max-width: 100%; pointer-events: none; }\n.furin-dither-wave__contenido > * { pointer-events: auto; }\n",
      "type": "registry:file",
      "target": "components/furin/dither-wave/DitherWave.css"
    }
  ]
}
