{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"cobweb","type":"registry:ui","title":"Cobweb","description":"A spider web of silk threads swaying in a breeze; the cursor stretches them and they ripple as they spring back.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/cobweb.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nexport interface CobwebProps {\n  /** Number of radial threads. */\n  spokes?: number;\n  /** Number of rings between the hub and the edges. */\n  rings?: number;\n  /** Idle silk color. */\n  color?: string;\n  /** Color of threads while they stretch and vibrate. */\n  activeColor?: string;\n  /** Px radius the cursor pushes threads within. */\n  radius?: number;\n  /** Speed of the ambient breeze; 0 keeps the web still until touched. */\n  speed?: number;\n  className?: string;\n}\n\nfunction rgb(hex: string) {\n  let h = (hex || \"#000\").replace(\"#\", \"\");\n  if (h.length === 3) h = h.replace(/./g, \"$&$&\");\n  const n = parseInt(h.padEnd(6, \"0\").slice(0, 6), 16) || 0;\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\n// Deterministic jitter so the web has an organic, hand-spun look that stays stable.\nconst rand = (i: number, salt: number) => {\n  const x = Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453;\n  return x - Math.floor(x);\n};\n\nexport function Cobweb({\n  spokes = 18,\n  rings = 16,\n  color = \"#a1a1aa\",\n  activeColor = \"#67e8f9\",\n  radius = 140,\n  speed = 1,\n  className,\n}: CobwebProps) {\n  const ref = useRef<HTMLCanvasElement>(null);\n  const opts = useRef({ color, activeColor, radius, speed });\n  const redraw = useRef<(() => void) | null>(null);\n\n  useEffect(() => {\n    opts.current = { color, activeColor, radius, speed };\n    redraw.current?.();\n  }, [color, activeColor, radius, speed]);\n\n  useEffect(() => {\n    const canvas = ref.current!;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const S = Math.max(3, Math.round(spokes));\n    const R = Math.max(2, Math.round(rings));\n    const N = S * R;\n    // Rest positions (relative to the hub) and spring state per node.\n    const rx = new Float32Array(N);\n    const ry = new Float32Array(N);\n    const ox = new Float32Array(N);\n    const oy = new Float32Array(N);\n    const vx = new Float32Array(N);\n    const vy = new Float32Array(N);\n    const px = new Float32Array(N);\n    const py = new Float32Array(N);\n    const m = { x: -9999, y: -9999, px: -9999, py: -9999 };\n    let w = 0;\n    let h = 0;\n    let cx = 0;\n    let cy = 0;\n    let raf = 0;\n    let visible = true;\n    let time = 0;\n    let last = 0;\n\n    const layout = () => {\n      cx = w * 0.5;\n      cy = h * 0.5;\n      const rMax = Math.hypot(w, h) * 0.56;\n      for (let j = 0; j < R; j++) {\n        for (let i = 0; i < S; i++) {\n          const k = j * S + i;\n          const a = ((i + (rand(i, 1) - 0.5) * 0.35) / S) * Math.PI * 2;\n          const r = rMax * Math.pow((j + 1) / R, 1.3) * (1 + (rand(k, 2) - 0.5) * 0.08);\n          rx[k] = Math.cos(a) * r;\n          ry[k] = Math.sin(a) * r;\n        }\n      }\n    };\n\n    const physics = () => {\n      const o = opts.current;\n      const rad = Math.max(10, o.radius);\n      const mvx = m.px > -9000 ? m.x - m.px : 0;\n      const mvy = m.px > -9000 ? m.y - m.py : 0;\n      m.px = m.x;\n      m.py = m.y;\n      for (let j = 0; j < R; j++) {\n        for (let i = 0; i < S; i++) {\n          const k = j * S + i;\n          // Coupling to ring and spoke neighbours makes a pluck ripple across the web.\n          const l = j * S + ((i + S - 1) % S);\n          const r = j * S + ((i + 1) % S);\n          const inX = j > 0 ? ox[k - S] : 0;\n          const inY = j > 0 ? oy[k - S] : 0;\n          const outX = j < R - 1 ? ox[k + S] : 0;\n          const outY = j < R - 1 ? oy[k + S] : 0;\n          let fx = (ox[l] + ox[r] + inX + outX - 4 * ox[k]) * 0.07 - ox[k] * 0.01;\n          let fy = (oy[l] + oy[r] + inY + outY - 4 * oy[k]) * 0.07 - oy[k] * 0.01;\n          const dx = cx + rx[k] + ox[k] - m.x;\n          const dy = cy + ry[k] + oy[k] - m.y;\n          const d2 = dx * dx + dy * dy;\n          if (d2 < rad * rad) {\n            const d = Math.sqrt(d2) || 1;\n            const t = 1 - d / rad;\n            fx += (dx / d) * t * t * 2.2 + mvx * t * 0.08;\n            fy += (dy / d) * t * t * 2.2 + mvy * t * 0.08;\n          }\n          vx[k] = (vx[k] + fx) * 0.94;\n          vy[k] = (vy[k] + fy) * 0.94;\n        }\n      }\n      for (let k = 0; k < N; k++) {\n        ox[k] += vx[k];\n        oy[k] += vy[k];\n      }\n    };\n\n    const draw = () => {\n      const o = opts.current;\n      const base = rgb(o.color);\n      const hot = rgb(o.activeColor);\n      const breeze = reduced ? 0 : 1;\n      for (let k = 0; k < N; k++) {\n        const j = Math.floor(k / S);\n        const f = (j + 1) / R;\n        // A gentle breeze sways the web, more at the rim than at the hub.\n        const sx = Math.sin(time * 0.7 + j * 0.35 + rand(k, 3)) * 4 * f * breeze;\n        const sy = Math.cos(time * 0.55 + j * 0.3) * 3 * f * breeze;\n        px[k] = cx + rx[k] + ox[k] + sx;\n        py[k] = cy + ry[k] + oy[k] + sy;\n      }\n      ctx.clearRect(0, 0, w, h);\n      ctx.lineCap = \"round\";\n      // Segments are bucketed by stretch so the whole web draws in four strokes.\n      const paths = [new Path2D(), new Path2D(), new Path2D(), new Path2D()];\n      const energy = (a: number, b: number) =>\n        (Math.abs(ox[a]) + Math.abs(oy[a]) + Math.abs(ox[b]) + Math.abs(oy[b])) * 0.5;\n      const bucket = (e: number) => (e > 14 ? 3 : e > 6 ? 2 : e > 1.5 ? 1 : 0);\n      for (let i = 0; i < S; i++) {\n        let p = paths[bucket(energy(i, i) * 0.5)];\n        p.moveTo(cx, cy);\n        p.lineTo(px[i], py[i]);\n        for (let j = 1; j < R; j++) {\n          const a = (j - 1) * S + i;\n          const b = j * S + i;\n          p = paths[bucket(energy(a, b))];\n          p.moveTo(px[a], py[a]);\n          p.lineTo(px[b], py[b]);\n        }\n      }\n      for (let j = 0; j < R; j++) {\n        for (let i = 0; i < S; i++) {\n          const a = j * S + i;\n          const b = j * S + ((i + 1) % S);\n          // Silk sags slightly toward the hub between spokes.\n          const mx = (px[a] + px[b]) * 0.5;\n          const my = (py[a] + py[b]) * 0.5;\n          const p = paths[bucket(energy(a, b))];\n          p.moveTo(px[a], py[a]);\n          p.quadraticCurveTo(mx + (cx - mx) * 0.07, my + (cy - my) * 0.07, px[b], py[b]);\n        }\n      }\n      const styles = [\n        { c: base, a: 0.26, w: 0.8 },\n        { c: base.map((v, i) => Math.round(v + (hot[i] - v) * 0.5)), a: 0.5, w: 1 },\n        { c: hot, a: 0.75, w: 1.1 },\n        { c: hot, a: 1, w: 1.3 },\n      ];\n      styles.forEach((s, i) => {\n        ctx.strokeStyle = `rgba(${s.c},${s.a})`;\n        ctx.lineWidth = s.w;\n        ctx.stroke(paths[i]);\n      });\n      // Dew drops on a few junctions, brightening as they shake.\n      for (let k = 0; k < N; k++) {\n        if (rand(k, 4) < 0.84) continue;\n        const e = Math.min(1, (Math.abs(ox[k]) + Math.abs(oy[k])) / 10);\n        const c = base.map((v, i) => Math.round(v + (hot[i] - v) * e));\n        ctx.fillStyle = `rgba(${c},${0.35 + 0.6 * e})`;\n        ctx.beginPath();\n        ctx.arc(px[k], py[k], 1.2 + e, 0, Math.PI * 2);\n        ctx.fill();\n      }\n    };\n\n    const loop = (now: number) => {\n      time += (Math.min(now - last, 50) / 1000) * opts.current.speed;\n      last = now;\n      physics();\n      draw();\n      raf = requestAnimationFrame(loop);\n    };\n    const play = () => {\n      if (raf || !visible || reduced) return;\n      last = performance.now();\n      raf = requestAnimationFrame(loop);\n    };\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      w = canvas.offsetWidth;\n      h = canvas.offsetHeight;\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      layout();\n      draw();\n    };\n    const onMove = (e: PointerEvent) => {\n      const r = canvas.getBoundingClientRect();\n      m.x = e.clientX - r.left;\n      m.y = e.clientY - r.top;\n    };\n    const onLeave = () => {\n      m.x = m.y = m.px = m.py = -9999;\n    };\n\n    const target = canvas.parentElement ?? canvas;\n    target.addEventListener(\"pointermove\", onMove, { passive: true });\n    target.addEventListener(\"pointerleave\", onLeave);\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) play();\n      else {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      }\n    });\n    io.observe(canvas);\n    redraw.current = draw;\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      target.removeEventListener(\"pointermove\", onMove);\n      target.removeEventListener(\"pointerleave\", onLeave);\n      redraw.current = null;\n    };\n  }, [spokes, rings]);\n\n  return (\n    <canvas\n      ref={ref}\n      aria-hidden\n      className={className}\n      style={{ position: \"absolute\", inset: 0, width: \"100%\", height: \"100%\", pointerEvents: \"none\" }}\n    />\n  );\n}\n"}]}