{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "galaxy",
  "type": "registry:component",
  "title": "Galaxy",
  "description": "A spiral galaxy of tens of thousands of particles with a glowing core that rotates slowly; the cursor tilts the view.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Backgrounds"
  ],
  "docs": "Installed in components/furin/galaxy/. Docs, live controls and props table: https://furin.dev/components/galaxy/",
  "files": [
    {
      "path": "registry/galaxy/Galaxy.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport \"./Galaxy.css\";\n\nexport interface GalaxyProps {\n  /** Contenido que va encima de la galaxia. */\n  children?: ReactNode;\n  /** Color del núcleo y del bulbo central. */\n  coreColor?: string;\n  /** Color de los brazos espirales. */\n  armColor?: string;\n  /** Color del fondo del espacio. */\n  background?: string;\n  /** Cantidad de partículas (1 = normal). */\n  density?: number;\n  /** Cuántos brazos tiene la espiral. */\n  arms?: number;\n  /** Qué tan rápido gira. */\n  speed?: number;\n  /** Inclinación de la galaxia respecto a la vista, en grados (90 = de frente). */\n  tilt?: number;\n  /** Qué tanto inclina la vista el cursor. */\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\nin vec4 a_p;   // radio, ángulo inicial, altura, tamaño\nin vec4 a_c;   // mezcla de color, fase de parpadeo, tipo (0 galaxia, 1 estrella de fondo), brillo\nuniform float u_t;\nuniform float u_spin;\nuniform float u_radius;     // radio de la galaxia en píxeles reales\nuniform float u_dpr;\nuniform vec2 u_res;\nuniform vec2 u_rot;         // cabeceo y giro\nuniform vec3 u_core;\nuniform vec3 u_arm;\nout vec3 v_col;\nout float v_a;\nvoid main() {\n  if (a_c.z > 0.5) {\n    vec2 pos = a_p.xy + vec2(-u_rot.y, u_rot.x - 0.9) * 0.012 * a_p.w;\n    gl_Position = vec4(pos, 0.0, 1.0);\n    gl_PointSize = max(1.0, a_p.w * u_dpr);\n    v_col = vec3(0.78, 0.84, 1.0);\n    v_a = a_c.w * (0.65 + 0.35 * sin(u_t * 1.1 + a_c.y * 6.2831));\n    return;\n  }\n  float r = a_p.x;\n  float th = a_p.y + u_spin;\n  vec3 P = vec3(r * cos(th), a_p.z, r * sin(th));\n  float cy = cos(u_rot.y), sy = sin(u_rot.y);\n  P = vec3(cy * P.x + sy * P.z, P.y, -sy * P.x + cy * P.z);\n  float cp = cos(u_rot.x), sp = sin(u_rot.x);\n  P = vec3(P.x, cp * P.y - sp * P.z, sp * P.y + cp * P.z);\n  float D = 3.2;\n  float k = D / (D - P.z);\n  vec2 px = P.xy * k * u_radius;\n  gl_Position = vec4(px / (0.5 * u_res), 0.0, 1.0);\n  gl_PointSize = max(1.0, a_p.w * k * u_dpr * 1.2);\n  float mixc = smoothstep(0.0, 0.62, r + (a_c.x - 0.5) * 0.4);\n  v_col = mix(u_core, u_arm, mixc);\n  // Unas pocas estrellas rosadas dan polvo y calor a los brazos.\n  v_col = mix(v_col, vec3(1.0, 0.62, 0.72), step(0.93, a_c.x) * 0.45 * mixc);\n  float tw = 0.82 + 0.18 * sin(u_t * 1.7 + a_c.y * 6.2831);\n  v_a = a_c.w * tw * clamp(k, 0.6, 1.5) * (1.0 - 0.5 * smoothstep(0.7, 1.0, r));\n}`;\n\nconst FRAG = `#version 300 es\nprecision highp float;\nin vec3 v_col;\nin float v_a;\nout vec4 o;\nvoid main() {\n  float d = length(gl_PointCoord - 0.5) * 2.0;\n  float a = exp(-d * d * 3.6) * (1.0 - smoothstep(0.82, 1.0, d));\n  o = vec4(v_col * a * v_a, 1.0);\n}`;\n\n// Halo del núcleo: una pasada de pantalla completa, aditiva, bajo las partículas.\nconst GLOW_FRAG = `#version 300 es\nprecision highp float;\nuniform vec2 u_res;\nuniform float u_radius;\nuniform float u_sinp;\nuniform vec3 u_core;\nuniform vec3 u_arm;\nuniform float u_t;\nout vec4 o;\nvoid main() {\n  vec2 q = (gl_FragCoord.xy - 0.5 * u_res) / u_radius;\n  float tight = exp(-dot(q, q) * 55.0);\n  float bulge = exp(-dot(q, q) * 9.0);\n  vec2 qd = vec2(q.x, q.y / max(u_sinp, 0.3));\n  float disc = exp(-dot(qd, qd) * 2.4);\n  vec3 c = u_core * (0.6 * tight + 0.26 * bulge) + mix(u_core, u_arm, 0.7) * 0.07 * disc;\n  c *= 0.94 + 0.06 * sin(u_t * 0.6);\n  o = vec4(c, 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\nconst gauss = () => { let s = 0; for (let i = 0; i < 4; i++) s += Math.random(); return (s - 2) * 1.7; };\nconst GLOW_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\n/** Fondo de galaxia espiral: decenas de miles de partículas con un núcleo brillante que gira; el cursor inclina la vista. */\nexport function Galaxy({\n  children,\n  coreColor = \"#ffd9a6\",\n  armColor = \"#7f9eea\",\n  background = \"#04050a\",\n  density = 1,\n  arms = 3,\n  speed = 1,\n  tilt = 50,\n  reactivity = 1,\n  className = \"\",\n  style,\n}: GalaxyProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const props = useRef({ coreColor, armColor, density, arms, speed, tilt, reactivity });\n  props.current = { coreColor, armColor, density, arms, speed, tilt, reactivity };\n  const wakeRef = useRef<(() => void) | null>(null);\n  const claveGeo = `${density}|${arms}`;\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const canvas = document.createElement(\"canvas\");\n    canvas.className = \"furin-galaxy__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 fondo oscuro\n    root.insertBefore(canvas, root.firstChild);\n\n    const reducido = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let progP: WebGLProgram | null = null, progG: WebGLProgram | null = null;\n    let U: Record<string, WebGLUniformLocation | null> = {};\n    let UG: Record<string, WebGLUniformLocation | null> = {};\n    let vao: WebGLVertexArrayObject | null = null, bufP: WebGLBuffer | null = null, bufC: WebGLBuffer | null = null, vaoG: WebGLVertexArrayObject | null = null;\n    let nGal = 0, nTot = 0;\n    let visible = true, muerto = false, raf = 0, w = 0, h = 0, dpr = 1, t = 0, ult = 0, spin = reducido ? 1.3 : 0.4;\n    const ptr = { x: 0, y: 0, tx: 0, ty: 0 };\n    let geoClave = \"\";\n\n    const generar = () => {\n      const p = props.current;\n      const nG = Math.round(34000 * Math.max(0.2, Math.min(2.5, p.density)));\n      const nB = 650;\n      const A = new Float32Array((nG + nB) * 4), C = new Float32Array((nG + nB) * 4);\n      const brazos = Math.max(2, Math.min(6, Math.round(p.arms)));\n      for (let i = 0; i < nG; i++) {\n        const r = Math.pow(Math.random(), 1.7);\n        const bulbo = Math.exp(-r * 7);\n        let th: number, y: number;\n        if (Math.random() < bulbo * 0.9) {\n          th = Math.random() * 6.2832;\n          y = gauss() * 0.075 * (0.4 + bulbo);\n        } else {\n          const brazo = Math.floor(Math.random() * brazos);\n          // Los brazos se enrollan con el radio y se ensanchan hacia afuera.\n          th = (brazo * 6.2832) / brazos + r * 4.6 + gauss() * (0.1 + 0.34 * r);\n          y = gauss() * 0.035 * (1 - 0.6 * r);\n        }\n        const q = Math.random();\n        const tam = q < 0.025 ? 3.0 + Math.random() * 2.2 : 0.9 + Math.random() * 1.6;\n        A.set([r, th, y, tam], i * 4);\n        C.set([Math.random(), Math.random(), 0, (q < 0.025 ? 0.5 : 0.22) * (0.55 + 0.7 * Math.random())], i * 4);\n      }\n      for (let i = 0; i < nB; i++) {\n        const k = nG + i;\n        A.set([Math.random() * 2 - 1, Math.random() * 2 - 1, 0, 0.8 + Math.random() * 1.1], k * 4);\n        C.set([0, Math.random(), 1, 0.12 + Math.random() * 0.55], k * 4);\n      }\n      nGal = nG; nTot = nG + nB;\n      gl.bindVertexArray(vao);\n      gl.bindBuffer(gl.ARRAY_BUFFER, bufP); gl.bufferData(gl.ARRAY_BUFFER, A, gl.STATIC_DRAW);\n      gl.bindBuffer(gl.ARRAY_BUFFER, bufC); gl.bufferData(gl.ARRAY_BUFFER, C, gl.STATIC_DRAW);\n      geoClave = `${p.density}|${p.arms}`;\n    };\n\n    const iniciarGl = () => {\n      progP = compilar(gl, VERT, FRAG);\n      progG = compilar(gl, GLOW_VERT, GLOW_FRAG);\n      if (!progP || !progG) return;\n      for (const k of [\"u_t\", \"u_spin\", \"u_radius\", \"u_dpr\", \"u_res\", \"u_rot\", \"u_core\", \"u_arm\"]) U[k] = gl.getUniformLocation(progP, k);\n      for (const k of [\"u_res\", \"u_radius\", \"u_sinp\", \"u_core\", \"u_arm\", \"u_t\"]) UG[k] = gl.getUniformLocation(progG, k);\n      vao = gl.createVertexArray(); bufP = gl.createBuffer(); bufC = gl.createBuffer(); vaoG = gl.createVertexArray();\n      gl.bindVertexArray(vao);\n      const aP = gl.getAttribLocation(progP, \"a_p\"), aC = gl.getAttribLocation(progP, \"a_c\");\n      gl.bindBuffer(gl.ARRAY_BUFFER, bufP); gl.enableVertexAttribArray(aP); gl.vertexAttribPointer(aP, 4, gl.FLOAT, false, 0, 0);\n      gl.bindBuffer(gl.ARRAY_BUFFER, bufC); gl.enableVertexAttribArray(aC); gl.vertexAttribPointer(aC, 4, gl.FLOAT, false, 0, 0);\n      generar();\n    };\n\n    const pintar = () => {\n      if (!progP || !progG || gl.isContextLost() || !w) return;\n      const p = props.current;\n      const [cr, cg, cb] = rgb(p.coreColor, [1, 0.85, 0.65]);\n      const [ar, ag, ab] = rgb(p.armColor, [0.5, 0.62, 0.92]);\n      const pitch = (p.tilt * Math.PI) / 180 + ptr.y * 0.45 * p.reactivity;\n      const yaw = ptr.x * 0.55 * p.reactivity + Math.sin(t * 0.13) * 0.05;\n      const radius = 0.5 * (h * 0.78 + w * 0.22) * 1.08;\n      gl.viewport(0, 0, canvas.width, canvas.height);\n      gl.clearColor(0, 0, 0, 1);\n      gl.clear(gl.COLOR_BUFFER_BIT);\n      gl.enable(gl.BLEND);\n      gl.blendFunc(gl.ONE, gl.ONE);\n      // 1) halo del núcleo\n      gl.useProgram(progG);\n      gl.uniform2f(UG.u_res, canvas.width, canvas.height);\n      gl.uniform1f(UG.u_radius, radius);\n      gl.uniform1f(UG.u_sinp, Math.abs(Math.sin(pitch)));\n      gl.uniform3f(UG.u_core, cr, cg, cb);\n      gl.uniform3f(UG.u_arm, ar, ag, ab);\n      gl.uniform1f(UG.u_t, t);\n      gl.bindVertexArray(vaoG);\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n      // 2) partículas\n      gl.useProgram(progP);\n      gl.uniform1f(U.u_t, t);\n      gl.uniform1f(U.u_spin, spin);\n      gl.uniform1f(U.u_radius, radius);\n      gl.uniform1f(U.u_dpr, dpr);\n      gl.uniform2f(U.u_res, canvas.width, canvas.height);\n      gl.uniform2f(U.u_rot, pitch, yaw);\n      gl.uniform3f(U.u_core, cr, cg, cb);\n      gl.uniform3f(U.u_arm, ar, ag, ab);\n      gl.bindVertexArray(vao);\n      gl.drawArrays(gl.POINTS, 0, nTot);\n      void nGal;\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      t += dt;\n      spin += dt * 0.07 * props.current.speed;\n      const k = 1 - Math.exp(-dt * 3);\n      ptr.x += (ptr.tx - ptr.x) * k; ptr.y += (ptr.ty - ptr.y) * k;\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 = () => {\n      if (geoClave !== `${props.current.density}|${props.current.arms}` && vao) generar();\n      if (reducido) pintar(); else wake();\n    };\n\n    const medir = () => {\n      dpr = Math.min(window.devicePixelRatio || 1, 1.75);\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      // Las dimensiones que usa el shader van en píxeles reales.\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    // El cursor inclina la galaxia: arriba/abajo cabecea, izquierda/derecha gira la vista.\n    const onMove = (e: PointerEvent) => {\n      const r = root.getBoundingClientRect();\n      ptr.tx = ((e.clientX - r.left) / r.width) * 2 - 1;\n      ptr.ty = ((e.clientY - r.top) / r.height) * 2 - 1;\n    };\n    const onLeave = () => { ptr.tx = 0; ptr.ty = 0; };\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 = {}; UG = {}; 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      if (bufP) gl.deleteBuffer(bufP);\n      if (bufC) gl.deleteBuffer(bufC);\n      gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n      canvas.remove();\n    };\n  }, []);\n\n  useEffect(() => { wakeRef.current?.(); }, [claveGeo, coreColor, armColor, speed, tilt, reactivity]);\n\n  const vars = { \"--furin-galaxy-bg\": background } as CSSProperties;\n  return (\n    <div ref={rootRef} className={`furin-galaxy ${className}`} style={{ ...vars, ...style }}>\n      <span className=\"furin-galaxy__vineta\" aria-hidden=\"true\" />\n      {children != null ? <div className=\"furin-galaxy__contenido\">{children}</div> : null}\n    </div>\n  );\n}\n\nexport default Galaxy;\n",
      "type": "registry:component",
      "target": "components/furin/galaxy/Galaxy.tsx"
    },
    {
      "path": "registry/galaxy/Galaxy.css",
      "content": ".furin-galaxy {\n  position: absolute;\n  inset: 0;\n  display: grid;\n  place-items: center;\n  overflow: hidden;\n  isolation: isolate;\n  background: var(--furin-galaxy-bg, #04050a);\n  touch-action: pan-y;\n}\n.furin-galaxy__lienzo { position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: 0; }\n.furin-galaxy__vineta {\n  position: absolute;\n  inset: 0;\n  z-index: 1;\n  pointer-events: none;\n  background: radial-gradient(ellipse 92% 90% at 50% 50%, transparent 45%, rgba(0, 0, 0, 0.55) 115%);\n}\n.furin-galaxy__contenido { position: relative; z-index: 2; max-width: 100%; pointer-events: none; }\n.furin-galaxy__contenido > * { pointer-events: auto; }\n",
      "type": "registry:file",
      "target": "components/furin/galaxy/Galaxy.css"
    }
  ]
}
