{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "interactive-dot-grid",
  "title": "Interactive Dot Grid",
  "author": "WebberUI",
  "description": "互動點陣背景：滿版規則點陣，游標附近的點放大並被斥力推開，離開後平滑回位。",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/webber/interactive-dot-grid.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface InteractiveDotGridProps {\n  /** 點與點的間距（px），同時決定點的密度 */\n  gap?: number;\n  /** 靜止時每個點的半徑（px） */\n  dotRadius?: number;\n  /** 游標影響半徑（px）：此範圍內的點才會放大並被推開 */\n  influence?: number;\n  /** 點的顏色；未指定時淺色用 neutral-400、深色用 neutral-600，並隨主題切換 */\n  color?: string;\n  className?: string;\n}\n\n/** 游標跟隨的緩動係數：每幀朝目標插值靠近，數值越小越滑順 */\nconst EASE = 0.14;\n/** 最大推開距離相對 influence 的比例（越近的點位移越大） */\nconst PUSH_RATIO = 0.35;\n/** 最貼近游標時點半徑的額外放大倍率 */\nconst GROWTH = 1.8;\n\nexport function InteractiveDotGrid({\n  gap = 28,\n  dotRadius = 1.5,\n  influence = 120,\n  color,\n  className,\n}: InteractiveDotGridProps) {\n  const reducedMotion = useReducedMotion();\n  const reduced = Boolean(reducedMotion);\n\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const rafRef = React.useRef<number | null>(null);\n  // 游標目標狀態：座標相對容器，active 表示滑鼠是否在容器內\n  const pointerRef = React.useRef({ x: 0, y: 0, active: false });\n  // 每幀實際採用的插值狀態：座標與影響強度都平滑追隨目標，離開時衰減回 0\n  const stateRef = React.useRef({ x: 0, y: 0, strength: 0 });\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    let width = 0;\n    let height = 0;\n    // 顯式顏色直接採用，否則讀 currentColor（由 text-neutral-* class 依主題解析）\n    let resolvedColor = color ?? getComputedStyle(canvas).color;\n\n    const draw = () => {\n      ctx.clearRect(0, 0, width, height);\n      ctx.fillStyle = resolvedColor;\n\n      const st = stateRef.current;\n      const strength = reduced ? 0 : st.strength;\n      const maxPush = influence * PUSH_RATIO;\n\n      for (let gx = gap / 2; gx < width; gx += gap) {\n        for (let gy = gap / 2; gy < height; gy += gap) {\n          let x = gx;\n          let y = gy;\n          let r = dotRadius;\n\n          if (strength > 0.001) {\n            const dx = gx - st.x;\n            const dy = gy - st.y;\n            const dist = Math.hypot(dx, dy) || 0.0001;\n            if (dist < influence) {\n              // 越近越強（0~1），再乘上整體影響強度\n              const f = (1 - dist / influence) * strength;\n              const push = f * maxPush;\n              x = gx + (dx / dist) * push;\n              y = gy + (dy / dist) * push;\n              r = dotRadius * (1 + f * GROWTH);\n            }\n          }\n\n          ctx.beginPath();\n          ctx.arc(x, y, r, 0, Math.PI * 2);\n          ctx.fill();\n        }\n      }\n    };\n\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const rect = canvas.getBoundingClientRect();\n      width = rect.width;\n      height = rect.height;\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      // 以 CSS 像素為單位繪製，交由 transform 放大到裝置像素\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      draw();\n    };\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    resize();\n\n    // 未指定 color 時，跟隨 <html> 的 class 變化（深淺主題切換）重讀顏色\n    let themeObserver: MutationObserver | undefined;\n    if (!color) {\n      themeObserver = new MutationObserver(() => {\n        resolvedColor = getComputedStyle(canvas).color;\n        draw();\n      });\n      themeObserver.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: [\"class\"],\n      });\n    }\n\n    if (reduced) {\n      // 減少動態效果：畫靜態點陣、不啟動 rAF、不做互動\n      draw();\n      return () => {\n        ro.disconnect();\n        themeObserver?.disconnect();\n      };\n    }\n\n    const st = stateRef.current;\n    const p = pointerRef.current;\n    let settled = false;\n\n    const loop = () => {\n      st.x += (p.x - st.x) * EASE;\n      st.y += (p.y - st.y) * EASE;\n      const target = p.active ? 1 : 0;\n      st.strength += (target - st.strength) * EASE;\n\n      const animating =\n        p.active ||\n        st.strength > 0.002 ||\n        Math.abs(p.x - st.x) > 0.5 ||\n        Math.abs(p.y - st.y) > 0.5;\n\n      if (animating) {\n        settled = false;\n        draw();\n      } else if (!settled) {\n        // 靜止後補畫一幀回到基準點陣，之後略過繪製只保留輕量迴圈\n        st.strength = 0;\n        draw();\n        settled = true;\n      }\n\n      rafRef.current = requestAnimationFrame(loop);\n    };\n    rafRef.current = requestAnimationFrame(loop);\n\n    return () => {\n      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);\n      ro.disconnect();\n      themeObserver?.disconnect();\n    };\n  }, [gap, dotRadius, influence, color, reduced]);\n\n  function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {\n    if (reduced || e.pointerType !== \"mouse\") return;\n    const rect = e.currentTarget.getBoundingClientRect();\n    const p = pointerRef.current;\n    p.x = e.clientX - rect.left;\n    p.y = e.clientY - rect.top;\n    p.active = true;\n  }\n\n  function handlePointerLeave() {\n    pointerRef.current.active = false;\n  }\n\n  return (\n    <div\n      aria-hidden\n      onPointerMove={reduced ? undefined : handlePointerMove}\n      onPointerLeave={reduced ? undefined : handlePointerLeave}\n      className={cn(\"relative h-full w-full overflow-hidden\", className)}\n    >\n      <canvas\n        ref={canvasRef}\n        className=\"absolute inset-0 h-full w-full text-neutral-400 dark:text-neutral-600\"\n      />\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "background"
  ],
  "type": "registry:ui"
}