{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "red-thread",
  "type": "registry:component",
  "title": "Red Thread",
  "description": "The red thread of fate: a thread with Verlet physics that follows the cursor, pulls taut against its anchor and swings.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Interaction"
  ],
  "docs": "Installed in components/furin/red-thread/. Docs, live controls and props table: https://furin.dev/components/red-thread/",
  "files": [
    {
      "path": "registry/red-thread/RedThread.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport \"./RedThread.css\";\n\n/** Dónde se amarra el otro extremo del hilo. */\nexport type ThreadAnchor =\n  | \"none\"\n  | \"center\"\n  | \"top\"\n  /** Punto fijo dentro del contenedor, en fracciones de 0 a 1: { x: 0.5, y: 0 } es el centro del borde de arriba. */\n  | { x: number; y: number };\n\nexport interface RedThreadProps {\n  /** Contenido opcional del contenedor; el hilo se dibuja por encima sin bloquear los clics. */\n  children?: ReactNode;\n  /** Color del hilo. */\n  color?: string;\n  /** Cuántos tramos tiene el hilo: más tramos, curvas más suaves y más cálculo. */\n  segments?: number;\n  /** Grosor del hilo en px. */\n  thickness?: number;\n  /** Fuerza de la gravedad en px/s²: 0 flota, más alta cuelga más pesado. */\n  gravity?: number;\n  /** Qué tan inextensible es el hilo, de 0.05 (elástico) a 1 (no se estira). */\n  stiffness?: number;\n  /** Extremo fijo: \"none\" (el hilo solo sigue al cursor), \"center\", \"top\" o un punto { x, y } en fracciones de 0 a 1. */\n  anchor?: ThreadAnchor;\n  /** Largo total del hilo en px; 0 lo calcula según el tamaño del contenedor. */\n  length?: number;\n  /** Intensidad del brillo alrededor del hilo, de 0 a 1. */\n  glow?: number;\n  /** Clase CSS extra para el contenedor. */\n  className?: string;\n  /** Estilos en línea del contenedor. */\n  style?: CSSProperties;\n}\n\n/** Un punto del hilo en integración de Verlet: guarda dónde está y dónde estaba. */\ninterface Punto {\n  x: number;\n  y: number;\n  anteriorX: number;\n  anteriorY: number;\n}\n\n/** La física avanza en pasos fijos para que el hilo se comporte igual a 60 y a 144 Hz. */\nconst PASO = 1 / 120;\n/** Vueltas del solucionador de restricciones por paso: más vueltas, hilo más firme. */\nconst ITERACIONES = 14;\n/** Frenado del aire por segundo: sin él el hilo ondularía para siempre. */\nconst AMORTIGUACION = 2.2;\n/** Cuánto tarda la punta en alcanzar al cursor (s); con cursor ausente, la deriva es más lenta. */\nconst TIEMPO_SEGUIR = 0.05;\nconst TIEMPO_DERIVA = 0.35;\n/** Si un cuadro tarda más que esto (pestaña dormida), se recorta para no dar un salto. */\nconst DT_MAXIMO = 1 / 30;\n\nexport function RedThread({\n  children,\n  color = \"#E8452C\",\n  segments = 28,\n  thickness = 3,\n  gravity = 1400,\n  stiffness = 0.95,\n  anchor = \"top\",\n  length = 0,\n  glow = 0.7,\n  className = \"\",\n  style,\n}: RedThreadProps) {\n  const contenedor = useRef<HTMLDivElement>(null);\n  const lienzo = useRef<HTMLCanvasElement>(null);\n  // Se leen dentro del bucle, así cambiarlos en vivo no reinicia el hilo.\n  const ajustes = useRef({ color, thickness, gravity, stiffness, anchor, length, glow });\n\n  useEffect(() => {\n    ajustes.current = { color, thickness, gravity, stiffness, anchor, length, glow };\n  }, [color, thickness, gravity, stiffness, anchor, length, glow]);\n\n  useEffect(() => {\n    const caja = contenedor.current;\n    const canvas = lienzo.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!caja || !canvas || !ctx) return;\n\n    const reducido = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const total = Math.max(2, Math.round(segments));\n\n    let ancho = 0;\n    let alto = 0;\n    let cuadro = 0;\n    let ultimo = 0;\n    let acumulado = 0;\n    let visible = true;\n    /** Posición del cursor dentro del contenedor, o null si está fuera. */\n    let cursor: { x: number; y: number } | null = null;\n    const puntos: Punto[] = [];\n\n    /** Largo de cada tramo, según la prop `length` o el tamaño del contenedor. */\n    const largoTramo = () => {\n      const { length: largo } = ajustes.current;\n      const base = largo > 0 ? largo : Math.min(640, Math.max(100, Math.min(ancho, alto) * 0.75));\n      return base / total;\n    };\n\n    /** Punto donde está amarrado el extremo fijo, o null si el hilo va suelto. */\n    const posicionAncla = (): { x: number; y: number } | null => {\n      const a = ajustes.current.anchor;\n      if (a === \"none\") return null;\n      if (a === \"top\") return { x: ancho / 2, y: Math.min(alto * 0.05, 24) };\n      if (a === \"center\") return { x: ancho / 2, y: alto / 2 };\n      return { x: a.x * ancho, y: a.y * alto };\n    };\n\n    /** A dónde quiere ir la punta: al cursor, o, sin cursor, a una deriva lenta para que siempre esté viva. */\n    const objetivo = (ahora: number): { x: number; y: number; deriva: boolean } => {\n      if (cursor) return { ...cursor, deriva: false };\n      const ancla = posicionAncla();\n      const alcance = largoTramo() * total;\n      if (ancla) {\n        return {\n          x: ancla.x + alcance * 0.72 * Math.sin(ahora / 1500) + alcance * 0.12 * Math.sin(ahora / 530),\n          y: ancla.y + alcance * 0.42 * Math.sin(ahora / 2300 + 1.1) + alcance * 0.3,\n          deriva: true,\n        };\n      }\n      return {\n        x: ancho * 0.5 + ancho * 0.3 * Math.sin(ahora / 1700) + ancho * 0.06 * Math.sin(ahora / 620),\n        y: alto * 0.42 + alto * 0.22 * Math.sin(ahora / 2100 + 0.8),\n        deriva: true,\n      };\n    };\n\n    /**\n     * Coloca el hilo colgando recto desde (x, y). Con ancla, el extremo fijo queda arriba y\n     * la punta abajo; sin ancla, la punta queda arriba y la cola cuelga de ella.\n     */\n    const reiniciar = (x: number, y: number) => {\n      puntos.length = 0;\n      const tramo = largoTramo();\n      const conAncla = ajustes.current.anchor !== \"none\";\n      for (let i = 0; i <= total; i++) {\n        const py = y + (conAncla ? i : total - i) * tramo;\n        puntos.push({ x, y: py, anteriorX: x, anteriorY: py });\n      }\n    };\n\n    /** Avanza la física un paso fijo hacia el objetivo `deseo` de la punta. */\n    const simular = (h: number, deseo: { x: number; y: number; deriva: boolean }) => {\n      const { gravity: g, stiffness: dureza } = ajustes.current;\n      const tramo = largoTramo();\n      const anclaFija = posicionAncla();\n      const cabeza = puntos[total];\n      const cola = puntos[0];\n\n      // 1. La cabeza se mueve (cinemática) hacia el objetivo, sin pasarse del largo si hay ancla.\n      let destinoX = deseo.x;\n      let destinoY = deseo.y;\n      if (anclaFija) {\n        const dx = destinoX - anclaFija.x;\n        const dy = destinoY - anclaFija.y;\n        const distancia = Math.hypot(dx, dy);\n        const maximo = tramo * total;\n        if (distancia > maximo) {\n          destinoX = anclaFija.x + (dx / distancia) * maximo;\n          destinoY = anclaFija.y + (dy / distancia) * maximo;\n        }\n      }\n      const suavizado = 1 - Math.exp(-h / (deseo.deriva ? TIEMPO_DERIVA : TIEMPO_SEGUIR));\n      cabeza.anteriorX = cabeza.x;\n      cabeza.anteriorY = cabeza.y;\n      cabeza.x += (destinoX - cabeza.x) * suavizado;\n      cabeza.y += (destinoY - cabeza.y) * suavizado;\n\n      // 2. Los puntos libres se integran con Verlet: velocidad implícita + gravedad.\n      const freno = Math.exp(-AMORTIGUACION * h);\n      const inicio = anclaFija ? 1 : 0;\n      for (let i = inicio; i < total; i++) {\n        const p = puntos[i];\n        const vx = (p.x - p.anteriorX) * freno;\n        const vy = (p.y - p.anteriorY) * freno;\n        p.anteriorX = p.x;\n        p.anteriorY = p.y;\n        p.x += vx;\n        p.y += vy + g * h * h;\n      }\n      if (anclaFija) {\n        cola.x = cola.anteriorX = anclaFija.x;\n        cola.y = cola.anteriorY = anclaFija.y;\n      }\n\n      // 3. Restricciones de distancia: cada tramo intenta conservar su largo.\n      //    La cabeza y el ancla pesan \"infinito\" (no se mueven por la restricción).\n      for (let vuelta = 0; vuelta < ITERACIONES; vuelta++) {\n        for (let k = 0; k < total; k++) {\n          const i = vuelta % 2 === 0 ? k : total - 1 - k; // alternar el sentido evita sesgos\n          const a = puntos[i];\n          const b = puntos[i + 1];\n          const dx = b.x - a.x;\n          const dy = b.y - a.y;\n          const distancia = Math.hypot(dx, dy) || 1e-6;\n          const error = ((distancia - tramo) / distancia) * dureza;\n          const aFijo = i === 0 && anclaFija !== null;\n          const bFijo = i + 1 === total;\n          const peso = aFijo || bFijo ? 1 : 0.5;\n          if (!aFijo) {\n            a.x += dx * error * peso;\n            a.y += dy * error * peso;\n          }\n          if (!bFijo) {\n            b.x -= dx * error * peso;\n            b.y -= dy * error * peso;\n          }\n        }\n      }\n    };\n\n    /** Dibuja el hilo: halo, cuerpo con brillo y un nudo en la punta. */\n    const dibujar = () => {\n      const { color: tono, thickness: ancho_, glow: luz } = ajustes.current;\n      ctx.clearRect(0, 0, ancho, alto);\n      if (puntos.length < 2) return;\n\n      const trazar = () => {\n        ctx.beginPath();\n        ctx.moveTo(puntos[0].x, puntos[0].y);\n        // Curva suave: cada punto es el control de una cuadrática entre puntos medios.\n        for (let i = 1; i < puntos.length - 1; i++) {\n          const p = puntos[i];\n          const q = puntos[i + 1];\n          ctx.quadraticCurveTo(p.x, p.y, (p.x + q.x) / 2, (p.y + q.y) / 2);\n        }\n        const fin = puntos[puntos.length - 1];\n        ctx.lineTo(fin.x, fin.y);\n      };\n\n      ctx.lineCap = \"round\";\n      ctx.lineJoin = \"round\";\n\n      // Halo suave.\n      if (luz > 0) {\n        ctx.save();\n        ctx.globalAlpha = 0.22 * luz;\n        ctx.strokeStyle = tono;\n        ctx.lineWidth = ancho_ * 5;\n        ctx.shadowColor = tono;\n        ctx.shadowBlur = 16 * luz;\n        trazar();\n        ctx.stroke();\n        ctx.restore();\n      }\n\n      // Cuerpo del hilo.\n      ctx.strokeStyle = tono;\n      ctx.lineWidth = ancho_;\n      trazar();\n      ctx.stroke();\n\n      // Hebra clara: da volumen de hilo trenzado.\n      ctx.save();\n      ctx.globalAlpha = 0.4;\n      ctx.strokeStyle = \"#ffffff\";\n      ctx.lineWidth = Math.max(0.6, ancho_ * 0.32);\n      ctx.translate(-ancho_ * 0.14, -ancho_ * 0.14);\n      trazar();\n      ctx.stroke();\n      ctx.restore();\n\n      // Nudo en la punta que sigue al cursor.\n      const cabeza = puntos[puntos.length - 1];\n      ctx.save();\n      ctx.shadowColor = tono;\n      ctx.shadowBlur = 14 * luz;\n      ctx.fillStyle = tono;\n      ctx.beginPath();\n      ctx.arc(cabeza.x, cabeza.y, ancho_ * 1.9, 0, Math.PI * 2);\n      ctx.fill();\n      ctx.restore();\n      ctx.fillStyle = \"rgba(255,255,255,0.55)\";\n      ctx.beginPath();\n      ctx.arc(cabeza.x - ancho_ * 0.55, cabeza.y - ancho_ * 0.6, ancho_ * 0.55, 0, Math.PI * 2);\n      ctx.fill();\n\n      // Y un pequeño nudo donde está amarrado.\n      const fijo = posicionAncla();\n      if (fijo) {\n        ctx.fillStyle = tono;\n        ctx.beginPath();\n        ctx.arc(fijo.x, fijo.y, ancho_ * 1.5, 0, Math.PI * 2);\n        ctx.fill();\n        ctx.fillStyle = \"rgba(255,255,255,0.5)\";\n        ctx.beginPath();\n        ctx.arc(fijo.x - ancho_ * 0.4, fijo.y - ancho_ * 0.45, ancho_ * 0.45, 0, Math.PI * 2);\n        ctx.fill();\n      }\n    };\n\n    /** Ajusta el lienzo al contenedor y a la densidad de píxeles (tope 2). */\n    const medir = () => {\n      // clientWidth/Height no cambian si algún ancestro escala el contenedor con CSS (miniaturas).\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      ancho = Math.max(1, caja.clientWidth);\n      alto = Math.max(1, caja.clientHeight);\n      canvas.width = Math.round(ancho * dpr);\n      canvas.height = Math.round(alto * dpr);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      if (puntos.length === 0) {\n        const inicio = posicionAncla() ?? { x: ancho / 2, y: alto * 0.3 };\n        reiniciar(inicio.x, inicio.y);\n        if (reducido) asentar();\n      }\n      if (reducido) dibujar();\n    };\n\n    /** Versión quieta (movimiento reducido): el hilo cuelga en reposo y se dibuja una sola vez. */\n    function asentar() {\n      const ancla = posicionAncla();\n      const tramo = largoTramo();\n      const inicio = ancla ?? { x: ancho / 2, y: alto * 0.28 };\n      // Con ancla la cabeza queda a un lado, para que se vea la curva del hilo.\n      const deseo = ancla\n        ? { x: inicio.x + tramo * total * 0.55, y: inicio.y + tramo * total * 0.1, deriva: false }\n        : { x: inicio.x, y: inicio.y, deriva: false };\n      reiniciar(inicio.x, inicio.y);\n      for (let i = 0; i < 600; i++) simular(PASO, deseo);\n    }\n\n    const paso = (ahora: number) => {\n      const dt = Math.min((ahora - ultimo) / 1000, DT_MAXIMO);\n      ultimo = ahora;\n      acumulado += dt;\n      const deseo = objetivo(ahora);\n      while (acumulado >= PASO) {\n        simular(PASO, deseo);\n        acumulado -= PASO;\n      }\n      dibujar();\n      cuadro = visible ? requestAnimationFrame(paso) : 0;\n    };\n\n    const despertar = () => {\n      if (cuadro !== 0 || !visible || reducido) return;\n      ultimo = performance.now();\n      cuadro = requestAnimationFrame(paso);\n    };\n\n    // El canvas no recibe eventos (no bloquea clics): el cursor se lee en la ventana.\n    const alMover = (e: PointerEvent) => {\n      const area = caja.getBoundingClientRect();\n      const dentro = e.clientX >= area.left && e.clientX <= area.right && e.clientY >= area.top && e.clientY <= area.bottom;\n      // Si el contenedor está escalado por CSS, las coordenadas se pasan a las unidades del lienzo.\n      const escala = area.width > 0 ? ancho / area.width : 1;\n      cursor = dentro ? { x: (e.clientX - area.left) * escala, y: (e.clientY - area.top) * escala } : null;\n    };\n    const soltar = () => {\n      cursor = null;\n    };\n    // En pantallas táctiles no hay cursor flotando: al levantar el dedo el hilo se suelta.\n    const alLevantar = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\") cursor = null;\n    };\n\n    const observadorTamano = new ResizeObserver(medir);\n    observadorTamano.observe(caja);\n    const observadorVista = new IntersectionObserver(([entrada]) => {\n      visible = entrada.isIntersecting;\n      if (visible) despertar();\n    });\n    observadorVista.observe(caja);\n\n    medir();\n\n    if (!reducido) {\n      window.addEventListener(\"pointermove\", alMover, { passive: true });\n      window.addEventListener(\"pointerdown\", alMover, { passive: true });\n      window.addEventListener(\"pointerup\", alLevantar, { passive: true });\n      window.addEventListener(\"pointercancel\", soltar, { passive: true });\n      document.documentElement.addEventListener(\"pointerleave\", soltar, { passive: true });\n      despertar();\n    }\n\n    return () => {\n      cancelAnimationFrame(cuadro);\n      observadorTamano.disconnect();\n      observadorVista.disconnect();\n      window.removeEventListener(\"pointermove\", alMover);\n      window.removeEventListener(\"pointerdown\", alMover);\n      window.removeEventListener(\"pointerup\", alLevantar);\n      window.removeEventListener(\"pointercancel\", soltar);\n      document.documentElement.removeEventListener(\"pointerleave\", soltar);\n    };\n  }, [segments]);\n\n  return (\n    <div ref={contenedor} className={`furin-red-thread ${className}`.trim()} style={style}>\n      {children ? <div className=\"furin-red-thread__contenido\">{children}</div> : null}\n      <canvas ref={lienzo} className=\"furin-red-thread__lienzo\" aria-hidden=\"true\" />\n    </div>\n  );\n}\n\nexport default RedThread;\n",
      "type": "registry:component",
      "target": "components/furin/red-thread/RedThread.tsx"
    },
    {
      "path": "registry/red-thread/RedThread.css",
      "content": "/* El contenedor llena a su padre. No recibe eventos: los hijos sí (ver abajo). */\n.furin-red-thread {\n  position: absolute;\n  inset: 0;\n  display: grid;\n  place-items: center;\n  pointer-events: none;\n}\n\n/* Lo que se pase como children vive debajo del hilo y sigue siendo clicable. */\n.furin-red-thread__contenido {\n  position: relative;\n  z-index: 1;\n  max-width: 100%;\n  padding: 0 24px;\n  font-size: clamp(22px, 4.4vw, 38px);\n  font-weight: 600;\n  line-height: 1.15;\n  letter-spacing: -0.02em;\n  text-align: center;\n  pointer-events: auto;\n}\n\n/* El hilo es decoración: se dibuja encima y deja pasar todos los clics. */\n.furin-red-thread__lienzo {\n  position: absolute;\n  inset: 0;\n  z-index: 2;\n  width: 100%;\n  height: 100%;\n  pointer-events: none;\n}\n",
      "type": "registry:file",
      "target": "components/furin/red-thread/RedThread.css"
    }
  ]
}
