{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "particle-field",
  "title": "Particle Field",
  "author": "WebberUI",
  "description": "互動粒子場（游標排斥/連線）",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/webber/particle-field.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ParticleFieldProps {\n  /** 粒子數量；數量越多連線越密，效能成本以 O(n²) 增長 */\n  quantity?: number;\n  /** 粒子與連線顏色；未指定時淺色用 neutral-400、深色用 neutral-600，並隨主題切換 */\n  color?: string;\n  /** 靜止時每顆粒子的半徑（px） */\n  particleSize?: number;\n  /** 漂移速度倍率；每幀位移約落在 ±speed（px） */\n  speed?: number;\n  /** 是否在鄰近粒子間繪製連線 */\n  links?: boolean;\n  /** 連線的最大距離（px）：距離越近線越實，超過此值不連 */\n  linkDistance?: number;\n  /** 游標影響半徑（px）：此範圍內的粒子才會被排斥或吸引 */\n  influence?: number;\n  /** 游標互動模式：`repel` 推開粒子、`attract` 聚攏粒子 */\n  mode?: \"repel\" | \"attract\";\n  /** 是否啟用游標互動（僅滑鼠指標生效，觸控不作用） */\n  interactive?: boolean;\n  className?: string;\n}\n\n/** 游標跟隨的緩動係數：每幀朝目標插值靠近，數值越小越滑順 */\nconst EASE = 0.12;\n/** 最大位移相對 influence 的比例（越貼近游標位移越大） */\nconst PUSH_RATIO = 0.5;\n\ninterface Particle {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n}\n\nexport function ParticleField({\n  quantity = 60,\n  color,\n  particleSize = 2,\n  speed = 0.5,\n  links = true,\n  linkDistance = 130,\n  influence = 120,\n  mode = \"repel\",\n  interactive = true,\n  className,\n}: ParticleFieldProps) {\n  const reducedMotion = useReducedMotion();\n  const reduced = Boolean(reducedMotion);\n  const enableInteraction = interactive && !reduced;\n\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const rafRef = React.useRef<number | null>(null);\n  const particlesRef = React.useRef<Particle[]>([]);\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    // 依當前尺寸播種粒子；已有粒子則只把越界的夾回範圍，保留漂移中的動態\n    const seed = () => {\n      const list = particlesRef.current;\n      if (list.length === quantity && width > 0 && height > 0) {\n        for (const p of list) {\n          p.x = Math.max(0, Math.min(width, p.x));\n          p.y = Math.max(0, Math.min(height, p.y));\n        }\n        return;\n      }\n      const next: Particle[] = [];\n      for (let i = 0; i < quantity; i++) {\n        next.push({\n          x: Math.random() * width,\n          y: Math.random() * height,\n          vx: (Math.random() - 0.5) * 2 * speed,\n          vy: (Math.random() - 0.5) * 2 * speed,\n        });\n      }\n      particlesRef.current = next;\n    };\n\n    const maxPush = influence * PUSH_RATIO;\n    const rx: number[] = [];\n    const ry: number[] = [];\n\n    const draw = () => {\n      ctx.clearRect(0, 0, width, height);\n\n      const st = stateRef.current;\n      const strength = enableInteraction ? st.strength : 0;\n      const particles = particlesRef.current;\n\n      // 先算出每顆粒子「本幀繪製位置」（含游標排斥／吸引位移），連線再據此計算\n      for (let i = 0; i < particles.length; i++) {\n        const p = particles[i];\n        let x = p.x;\n        let y = p.y;\n        if (strength > 0.001) {\n          const dx = p.x - st.x;\n          const dy = p.y - st.y;\n          const dist = Math.hypot(dx, dy) || 0.0001;\n          if (dist < influence) {\n            const f = (1 - dist / influence) * strength;\n            let push = f * maxPush;\n            if (mode === \"attract\") push = -Math.min(push, dist * 0.9);\n            x = p.x + (dx / dist) * push;\n            y = p.y + (dy / dist) * push;\n          }\n        }\n        rx[i] = x;\n        ry[i] = y;\n      }\n\n      // 連線畫在粒子底下，透明度隨距離淡出\n      if (links) {\n        ctx.strokeStyle = resolvedColor;\n        ctx.lineWidth = 1;\n        for (let i = 0; i < particles.length; i++) {\n          for (let j = i + 1; j < particles.length; j++) {\n            const dx = rx[i] - rx[j];\n            const dy = ry[i] - ry[j];\n            const dist = Math.hypot(dx, dy);\n            if (dist < linkDistance) {\n              ctx.globalAlpha = (1 - dist / linkDistance) * 0.55;\n              ctx.beginPath();\n              ctx.moveTo(rx[i], ry[i]);\n              ctx.lineTo(rx[j], ry[j]);\n              ctx.stroke();\n            }\n          }\n        }\n      }\n\n      // 粒子畫在最上層\n      ctx.globalAlpha = 1;\n      ctx.fillStyle = resolvedColor;\n      for (let i = 0; i < particles.length; i++) {\n        ctx.beginPath();\n        ctx.arc(rx[i], ry[i], particleSize, 0, Math.PI * 2);\n        ctx.fill();\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      seed();\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\n    const loop = () => {\n      // 更新每顆粒子的自由漂移，碰到邊界反彈\n      const particles = particlesRef.current;\n      for (const particle of particles) {\n        particle.x += particle.vx;\n        particle.y += particle.vy;\n        if (particle.x <= 0 || particle.x >= width) {\n          particle.vx *= -1;\n          particle.x = Math.max(0, Math.min(width, particle.x));\n        }\n        if (particle.y <= 0 || particle.y >= height) {\n          particle.vy *= -1;\n          particle.y = Math.max(0, Math.min(height, particle.y));\n        }\n      }\n\n      // 游標座標與影響強度都平滑插值，離開時 strength 衰減回 0\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      draw();\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  }, [\n    quantity,\n    color,\n    particleSize,\n    speed,\n    links,\n    linkDistance,\n    influence,\n    mode,\n    enableInteraction,\n    reduced,\n  ]);\n\n  function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {\n    if (!enableInteraction || 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={enableInteraction ? handlePointerMove : undefined}\n      onPointerLeave={enableInteraction ? handlePointerLeave : undefined}\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"
}