{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pixel-assemble",
  "type": "registry:component",
  "title": "Pixel Assemble",
  "description": "Text assembles itself from a cloud of spring-driven particles; the cursor scatters them and they snap back on their own.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Text"
  ],
  "docs": "Installed in components/furin/pixel-assemble/. Docs, live controls and props table: https://furin.dev/components/pixel-assemble/",
  "files": [
    {
      "path": "registry/pixel-assemble/PixelAssemble.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport \"./PixelAssemble.css\";\n\nexport interface PixelAssembleProps {\n  /** El texto que se arma con partículas (acepta saltos de línea). */\n  children?: string;\n  /** Tamaño máximo de la letra, en px (se reduce solo si no cabe). */\n  size?: number;\n  /** Alto del lienzo, en px. */\n  height?: number;\n  /** Separación entre partículas, en px: más chico, más fino (y más partículas). */\n  gap?: number;\n  /** Radio del cursor que dispersa las partículas, en px. */\n  radius?: number;\n  /** Fuerza con que el cursor empuja (0.3 a 2.5). */\n  repel?: number;\n  /** Cada cuántos segundos el texto se deshace y se vuelve a armar (0 = nunca). */\n  replay?: number;\n  /** Color de las partículas. */\n  color?: string;\n  /** Color que toman las partículas cuando se mueven. */\n  accent?: string;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\nconst SANS = `\"Geist\", \"Inter\", \"SF Pro Display\", system-ui, -apple-system, \"Helvetica Neue\", Arial, sans-serif`;\n\nfunction rgbDe(css: string, def: [number, number, number]): [number, number, number] {\n  try {\n    const c = document.createElement(\"canvas\");\n    c.width = c.height = 1;\n    const x = c.getContext(\"2d\", { willReadFrequently: true })!;\n    x.fillStyle = \"#000\";\n    x.fillStyle = css;\n    x.fillRect(0, 0, 1, 1);\n    const d = x.getImageData(0, 0, 1, 1).data;\n    return [d[0], d[1], d[2]];\n  } catch { return def; }\n}\n\nconst NIV = 8;\n\n/** El texto se arma desde una nube de partículas con resortes; el cursor las dispersa y vuelven solas a su sitio. */\nexport function PixelAssemble({\n  children = \"Pixels\",\n  size = 150,\n  height = 260,\n  gap = 3,\n  radius = 84,\n  repel = 1,\n  replay = 9,\n  color = \"#F4F5F7\",\n  accent = \"#7CF5C6\",\n  className = \"\",\n  style,\n}: PixelAssembleProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const texto = String(children ?? \"\");\n  const cfg = useRef({ size, gap, radius, repel, replay, color, accent });\n  cfg.current = { size, gap, radius, repel, replay, color, accent };\n  const vivo = useRef<{ build: () => void } | null>(null);\n\n  useEffect(() => {\n    const root = rootRef.current, canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    const reducido = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let W = 0, H = 0, dpr = 1, N = 0, px = 4;\n    let x = new Float32Array(0), y = new Float32Array(0), vx = new Float32Array(0), vy = new Float32Array(0);\n    let tx = new Float32Array(0), ty = new Float32Array(0), sx = new Float32Array(0), sy = new Float32Array(0);\n    let dl = new Float32Array(0), tam = new Float32Array(0), tinte = new Uint8Array(0), nivel = new Uint8Array(0);\n    let fase: \"arma\" | \"quieto\" | \"disperso\" = \"arma\";\n    let tFase = 0, tReplay = 0, visible = true, raf = 0, muerto = false, ultimo = 0, listo = false, sigReplay = 0;\n    const P = { x: -9999, y: -9999, activo: false, vx: 0, vy: 0 };\n    let c0: [number, number, number] = [244, 245, 247], c1: [number, number, number] = [124, 245, 198];\n    let paleta: string[] = [];\n\n    const colores = () => {\n      c0 = rgbDe(cfg.current.color, c0); c1 = rgbDe(cfg.current.accent, c1);\n      paleta = Array.from({ length: NIV }, (_, i) => {\n        const k = i / (NIV - 1);\n        return `rgb(${Math.round(c0[0] + (c1[0] - c0[0]) * k)},${Math.round(c0[1] + (c1[1] - c0[1]) * k)},${Math.round(c0[2] + (c1[2] - c0[2]) * k)})`;\n      });\n    };\n\n    const sembrar = (ahora: number) => {\n      // Reparte las partículas por el lienzo (con un hueco hacia abajo) y les da un empujón lateral para que lleguen en arco.\n      for (let i = 0; i < N; i++) {\n        x[i] = Math.random() * W; y[i] = Math.random() * H;\n        const a = Math.random() * 6.283, s = 20 + Math.random() * 110;\n        vx[i] = Math.cos(a) * s; vy[i] = Math.sin(a) * s;\n        dl[i] = (tx[i] / Math.max(1, W)) * 0.55 + Math.random() * 0.4;\n      }\n      fase = \"arma\"; tFase = ahora;\n    };\n\n    const construir = () => {\n      if (muerto) return;\n      W = root.clientWidth; H = root.clientHeight;\n      if (!W || !H) return;\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);\n      const c = cfg.current;\n      px = Math.max(2, c.gap);\n      colores();\n      // Texto → mapa de alfa → una partícula por celda cubierta.\n      const off = document.createElement(\"canvas\");\n      off.width = W; off.height = H;\n      const o = off.getContext(\"2d\", { willReadFrequently: true })!;\n      const lineas = texto.split(\"\\n\");\n      let fs = c.size;\n      o.font = `800 ${fs}px ${SANS}`;\n      const ancho = Math.max(...lineas.map((l) => o.measureText(l).width), 1);\n      fs = Math.min(fs, (fs * W * 0.88) / ancho, (H * 0.78) / (lineas.length * 0.98));\n      o.font = `800 ${fs}px ${SANS}`;\n      o.fillStyle = \"#fff\"; o.textAlign = \"center\"; o.textBaseline = \"alphabetic\";\n      const lh = fs * 0.98;\n      const m0 = o.measureText(lineas[0]), mN = o.measureText(lineas[lineas.length - 1]);\n      const mid = (-m0.actualBoundingBoxAscent + (lineas.length - 1) * lh + mN.actualBoundingBoxDescent) / 2;\n      lineas.forEach((ln, i) => o.fillText(ln, W / 2, H / 2 + i * lh - mid));\n      const img = o.getImageData(0, 0, W, H).data;\n      const pts: number[] = [];\n      for (let yy = px / 2; yy < H; yy += px) for (let xx = px / 2; xx < W; xx += px) {\n        if (img[((yy | 0) * W + (xx | 0)) * 4 + 3] > 128) pts.push(xx, yy);\n      }\n      N = pts.length / 2;\n      x = new Float32Array(N); y = new Float32Array(N); vx = new Float32Array(N); vy = new Float32Array(N);\n      tx = new Float32Array(N); ty = new Float32Array(N); sx = new Float32Array(N); sy = new Float32Array(N);\n      dl = new Float32Array(N); tam = new Float32Array(N); tinte = new Uint8Array(N); nivel = new Uint8Array(N);\n      for (let i = 0; i < N; i++) {\n        tx[i] = pts[i * 2]; ty[i] = pts[i * 2 + 1];\n        tam[i] = px * (0.72 + Math.random() * 0.2);\n        tinte[i] = Math.random() < 0.07 ? 1 : 0; // algunas partículas conservan el color de acento\n      }\n      listo = true;\n      const ahora = performance.now();\n      if (reducido) { x.set(tx); y.set(ty); dibujar(); return; }\n      sembrar(ahora);\n      sigReplay = c.replay > 0 ? ahora + c.replay * 1000 : 0;\n      wake();\n    };\n\n    const dibujar = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, W, H);\n      for (let l = 0; l < NIV; l++) {\n        ctx.fillStyle = paleta[l];\n        ctx.beginPath();\n        for (let i = 0; i < N; i++) {\n          const lv = tinte[i] ? Math.max(nivel[i], NIV - 3) : nivel[i];\n          if (lv !== l) continue;\n          const s = tam[i];\n          ctx.rect(x[i] - s / 2, y[i] - s / 2, s, s);\n        }\n        ctx.fill();\n      }\n    };\n\n    const paso = (now: number) => {\n      raf = 0;\n      if (muerto || !visible || !listo) return;\n      const dt = Math.min(0.033, (now - (ultimo || now)) / 1000 || 0.016);\n      ultimo = now;\n      const c = cfg.current;\n      const R = c.radius, R2 = R * R, F = 2600 * c.repel;\n      const tf = (now - tFase) / 1000;\n      // Ciclo: se arma → descansa → se deshace → se vuelve a armar.\n      if (fase === \"arma\" && tf > 2.4) { fase = \"quieto\"; tFase = now; }\n      if (c.replay > 0 && sigReplay && now > sigReplay && fase === \"quieto\") {\n        fase = \"disperso\"; tFase = now;\n        for (let i = 0; i < N; i++) {\n          const a = Math.random() * 6.283, r = 40 + Math.random() * 120;\n          sx[i] = Math.min(W, Math.max(0, tx[i] + Math.cos(a) * r)); sy[i] = Math.min(H, Math.max(0, ty[i] + Math.sin(a) * r * 0.7 - 20));\n          vx[i] += Math.cos(a) * 90; vy[i] += Math.sin(a) * 90 - 40;\n        }\n      }\n      if (fase === \"disperso\" && tf > 1.1) {\n        fase = \"arma\"; tFase = now; sigReplay = c.replay * 1000 + now + 2400;\n        for (let i = 0; i < N; i++) dl[i] = (tx[i] / Math.max(1, W)) * 0.45 + Math.random() * 0.35;\n      }\n      let energia = 0;\n      const sub = 2, h = dt / sub;\n      for (let s = 0; s < sub; s++) {\n        for (let i = 0; i < N; i++) {\n          let ax = 0, ay = 0;\n          if (fase === \"disperso\") {\n            ax = (sx[i] - x[i]) * 5 - vx[i] * 2.4; ay = (sy[i] - y[i]) * 5 - vy[i] * 2.4;\n          } else if (fase === \"arma\" && tf < dl[i]) {\n            // Todavía no le toca: flota despacio, con un poco de fricción.\n            ax = -vx[i] * 1.6; ay = -vy[i] * 1.6;\n          } else {\n            ax = (tx[i] - x[i]) * 46 - vx[i] * 9.5; ay = (ty[i] - y[i]) * 46 - vy[i] * 9.5;\n          }\n          if (P.activo) {\n            const dx = x[i] - P.x, dy = y[i] - P.y, d2 = dx * dx + dy * dy;\n            if (d2 < R2 && d2 > 0.01) {\n              const d = Math.sqrt(d2), f = 1 - d / R;\n              const k = (f * f * F) / d;\n              ax += dx * k; ay += dy * k;\n            }\n          }\n          vx[i] += ax * h; vy[i] += ay * h;\n          x[i] += vx[i] * h; y[i] += vy[i] * h;\n        }\n      }\n      for (let i = 0; i < N; i++) {\n        const sp = Math.abs(vx[i]) + Math.abs(vy[i]);\n        nivel[i] = Math.min(NIV - 1, Math.floor((sp / 260) * NIV));\n        energia += sp + Math.abs(tx[i] - x[i]) + Math.abs(ty[i] - y[i]);\n      }\n      dibujar();\n      const reposo = fase === \"quieto\" && !P.activo && energia / Math.max(1, N) < 0.06;\n      if (reposo) {\n        // Todo quieto: se asienta, se duerme y despierta solo cuando toque deshacerse otra vez.\n        x.set(tx); y.set(ty); vx.fill(0); vy.fill(0); nivel.fill(0); dibujar(); ultimo = 0;\n        if (sigReplay) { clearTimeout(tReplay); tReplay = window.setTimeout(wake, Math.max(30, sigReplay - performance.now())); }\n        return;\n      }\n      raf = requestAnimationFrame(paso);\n    };\n    function wake() { if (!raf && visible && listo && !muerto && !reducido) { ultimo = 0; raf = requestAnimationFrame(paso); } }\n\n    const onMove = (e: PointerEvent) => {\n      const r = canvas.getBoundingClientRect();\n      const m = cfg.current.radius;\n      P.x = e.clientX - r.left; P.y = e.clientY - r.top;\n      P.activo = e.clientX > r.left - m && e.clientX < r.right + m && e.clientY > r.top - m && e.clientY < r.bottom + m;\n      wake();\n    };\n    const onFuera = () => { P.activo = false; wake(); };\n    const onUp = (e: PointerEvent) => { if (e.pointerType !== \"mouse\") onFuera(); };\n    // Un clic o toque la deshace toda desde el punto donde se toca.\n    const onDown = (e: PointerEvent) => {\n      onMove(e);\n      const r = canvas.getBoundingClientRect();\n      if (!(e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) || !listo) return;\n      for (let i = 0; i < N; i++) {\n        const dx = x[i] - P.x, dy = y[i] - P.y, d = Math.hypot(dx, dy) || 1;\n        const k = 160 + Math.random() * 360;\n        vx[i] += (dx / d) * k + (Math.random() - 0.5) * 120; vy[i] += (dy / d) * k + (Math.random() - 0.5) * 120;\n      }\n      fase = \"arma\"; tFase = performance.now();\n      for (let i = 0; i < N; i++) dl[i] = 0.1 + Math.random() * 0.3;\n      wake();\n    };\n    if (!reducido) {\n      window.addEventListener(\"pointermove\", onMove, { passive: true });\n      window.addEventListener(\"pointerdown\", onDown, { passive: true });\n      window.addEventListener(\"pointerup\", onUp, { passive: true });\n      document.documentElement.addEventListener(\"pointerleave\", onFuera);\n    }\n    const io = new IntersectionObserver(([e]) => {\n      visible = e.isIntersecting;\n      if (visible) wake();\n    }, { rootMargin: \"80px\" });\n    io.observe(root);\n    let pend = 0;\n    const pedir = () => { cancelAnimationFrame(pend); pend = requestAnimationFrame(construir); };\n    const ro = new ResizeObserver(pedir);\n    ro.observe(root);\n    vivo.current = { build: pedir };\n    document.fonts?.load(`800 100px ${SANS}`).catch(() => {}).then(() => { if (!muerto) pedir(); });\n    pedir();\n    return () => {\n      muerto = true; vivo.current = null;\n      cancelAnimationFrame(raf); cancelAnimationFrame(pend); clearTimeout(tReplay);\n      ro.disconnect(); io.disconnect();\n      window.removeEventListener(\"pointermove\", onMove);\n      window.removeEventListener(\"pointerdown\", onDown);\n      window.removeEventListener(\"pointerup\", onUp);\n      document.documentElement.removeEventListener(\"pointerleave\", onFuera);\n    };\n  }, [texto]);\n\n  useEffect(() => { vivo.current?.build(); }, [size, height, gap, color, accent]);\n\n  return (\n    <div ref={rootRef} className={`furin-pixel-assemble ${className}`} style={{ height, ...style }} role=\"img\" aria-label={texto.replace(/\\n/g, \" \")}>\n      <canvas ref={canvasRef} className=\"furin-pixel-assemble__lienzo\" aria-hidden=\"true\" />\n      <span className=\"furin-pixel-assemble__sr\">{texto}</span>\n    </div>\n  );\n}\n\nexport default PixelAssemble;\n",
      "type": "registry:component",
      "target": "components/furin/pixel-assemble/PixelAssemble.tsx"
    },
    {
      "path": "registry/pixel-assemble/PixelAssemble.css",
      "content": ".furin-pixel-assemble {\n  position: relative;\n  display: block;\n  width: min(100%, 520px);\n  user-select: none;\n  -webkit-user-select: none;\n  touch-action: manipulation;\n}\n.furin-pixel-assemble__lienzo { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }\n.furin-pixel-assemble__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n",
      "type": "registry:file",
      "target": "components/furin/pixel-assemble/PixelAssemble.css"
    }
  ]
}
