{"$schema":"https://ui.shadcn.com/schema/registry.json","homepage":"https://uicapsule.com/ui/geometric-orb","name":"geometric-orb","type":"registry:block","author":"Kaiyu Hsu <uicapsule@kyh.io>","dependencies":["@react-three/drei","@react-three/fiber","three"],"devDependencies":["@types/three"],"registryDependencies":[],"files":[{"type":"registry:file","path":"/geometric-orb.tsx","content":"\"use client\";\n\nimport { useRef, useMemo, useEffect } from \"react\";\nimport { Canvas, useFrame, useThree, extend } from \"@react-three/fiber\";\nimport { OrbitControls } from \"@react-three/drei\";\nimport * as THREE from \"three\";\nimport { Line2 } from \"three/examples/jsm/lines/Line2.js\";\nimport { LineMaterial } from \"three/examples/jsm/lines/LineMaterial.js\";\nimport { LineGeometry } from \"three/examples/jsm/lines/LineGeometry.js\";\n\nextend({ Line2, LineMaterial, LineGeometry });\n\n/**\n * Configuration options for the geometric orb.\n * All fields are optional and fall back to sensible defaults.\n */\nexport type GeometricOrbConfig = {\n  /** Number of latitude lines rendered on the sphere. @default 20 */\n  numLines?: number;\n  /** Radius of the sphere in world units. @default 1.5 */\n  radius?: number;\n  /** Duration in seconds for a full pole-to-pole animation cycle. @default 20 */\n  speed?: number;\n  /** Thickness of each line in pixels. @default 2 */\n  lineWidth?: number;\n  /** CSS color string for the lines. @default \"#eeeeee\" */\n  color?: string;\n  /** CSS color string for the canvas background. @default \"#0a0a0a\" */\n  background?: string;\n  /** Intensity of the squiggle displacement on each line. @default 0.04 */\n  squiggleAmount?: number;\n  /** Wave frequency of the squiggle effect. Higher = more waves. @default 4 */\n  squiggleFrequency?: number;\n  /** Animation speed of the squiggle oscillation. @default 2 */\n  squiggleSpeed?: number;\n  /** Number of points around the full circle. Higher = smoother curves + finer depth-fade. @default 96 */\n  pointsPerLine?: number;\n  /** Whether scroll-to-zoom is enabled. @default true */\n  enableZoom?: boolean;\n  /** Whether click-and-drag panning is enabled. @default false */\n  enablePan?: boolean;\n  /** Minimum camera distance (closest zoom). @default 2 */\n  minDistance?: number;\n  /** Maximum camera distance (farthest zoom). @default 20 */\n  maxDistance?: number;\n};\n\nconst defaults: Required<GeometricOrbConfig> = {\n  numLines: 20,\n  radius: 1.5,\n  speed: 20,\n  lineWidth: 2,\n  color: \"#eeeeee\",\n  background: \"#0a0a0a\",\n  squiggleAmount: 0.04,\n  squiggleFrequency: 4,\n  squiggleSpeed: 2,\n  pointsPerLine: 96,\n  enableZoom: true,\n  enablePan: false,\n  minDistance: 2,\n  maxDistance: 20,\n};\n\n/**\n * All latitude lines rendered with a single useFrame callback.\n * Each line is one Line2 with per-vertex colors encoding depth-based opacity,\n * reducing draw calls from numLines×segmentGroups to just numLines.\n */\nfunction LatitudeLines({ config }: { config: Required<GeometricOrbConfig> }) {\n  const camDirRef = useRef(new THREE.Vector3());\n  const { size } = useThree();\n\n  const baseColor = useMemo(() => new THREE.Color(config.color), [config.color]);\n\n  const lineConstants = useMemo(\n    () =>\n      Array.from({ length: config.numLines }, (_, i) => ({\n        longitudeRotation: (i / config.numLines) * Math.PI,\n        timeOffset: (i / config.numLines) * config.speed,\n        cosR: Math.cos((i / config.numLines) * Math.PI),\n        sinR: Math.sin((i / config.numLines) * Math.PI),\n      })),\n    [config.numLines, config.speed],\n  );\n\n  // One material per line with vertexColors enabled\n  const materials = useMemo(\n    () =>\n      Array.from(\n        { length: config.numLines },\n        () =>\n          new LineMaterial({\n            color: baseColor.getHex(),\n            linewidth: config.lineWidth,\n            transparent: true,\n            opacity: 1,\n            vertexColors: true,\n          }),\n      ),\n    [baseColor, config.numLines, config.lineWidth],\n  );\n\n  // One geometry per line\n  const geometries = useMemo(\n    () => Array.from({ length: config.numLines }, () => new LineGeometry()),\n    [config.numLines],\n  );\n\n  useEffect(() => {\n    return () => {\n      for (const mat of materials) mat.dispose();\n      for (const geo of geometries) geo.dispose();\n    };\n  }, [materials, geometries]);\n\n  useEffect(() => {\n    for (const mat of materials) {\n      mat.resolution.set(size.width, size.height);\n    }\n  }, [materials, size.width, size.height]);\n\n  // Pre-allocate reusable buffers (+1 vertex to close the loop)\n  const vertexCount = config.pointsPerLine + 1;\n  const positionBuffer = useMemo(() => new Float32Array(vertexCount * 3), [vertexCount]);\n  const colorBuffer = useMemo(() => new Float32Array(vertexCount * 3), [vertexCount]);\n\n  useFrame((state) => {\n    const time = state.clock.elapsedTime;\n    const camDir = camDirRef.current.copy(state.camera.position).normalize();\n    const r = baseColor.r;\n    const g = baseColor.g;\n    const b = baseColor.b;\n\n    for (let lineIdx = 0; lineIdx < config.numLines; lineIdx++) {\n      const constants = lineConstants[lineIdx];\n      const geometry = geometries[lineIdx];\n      if (!constants || !geometry) continue;\n      const { timeOffset, cosR, sinR } = constants;\n      const progress = ((time + timeOffset) % config.speed) / config.speed;\n      const latitude = progress * Math.PI;\n      const circleRadius = Math.sin(latitude) * config.radius;\n      const yPosition = Math.cos(latitude) * config.radius;\n\n      for (let i = 0; i < config.pointsPerLine; i++) {\n        const angle = (i / config.pointsPerLine) * Math.PI * 2;\n        const squiggle =\n          Math.sin(angle * config.squiggleFrequency + time * config.squiggleSpeed + lineIdx * 0.5) *\n          config.squiggleAmount;\n        const radiusSquiggle =\n          Math.cos(angle * config.squiggleFrequency * 1.3 + time * config.squiggleSpeed * 0.8) *\n          config.squiggleAmount *\n          0.5;\n        const displacedRadius = circleRadius + (squiggle + radiusSquiggle) * circleRadius;\n        const ySquiggle =\n          Math.sin(angle * config.squiggleFrequency * 0.7 + time * config.squiggleSpeed * 1.2) *\n          config.squiggleAmount *\n          0.4;\n\n        const x = Math.cos(angle) * displacedRadius;\n        const y = yPosition + ySquiggle * circleRadius;\n        const z = Math.sin(angle) * displacedRadius;\n\n        const offset = i * 3;\n        positionBuffer[offset] = x;\n        positionBuffer[offset + 1] = y;\n        positionBuffer[offset + 2] = z;\n\n        // Smooth depth-fade via vertex color brightness\n        const worldX = x * cosR + z * sinR;\n        const worldZ = -x * sinR + z * cosR;\n        const dot = worldX * camDir.x + y * camDir.y + worldZ * camDir.z;\n        const depthFactor = (dot / config.radius + 1) / 2;\n        const opacity = depthFactor * 0.85 + 0.15;\n\n        colorBuffer[offset] = r * opacity;\n        colorBuffer[offset + 1] = g * opacity;\n        colorBuffer[offset + 2] = b * opacity;\n      }\n\n      // Close the loop: copy first vertex exactly to avoid floating-point gaps\n      const last = config.pointsPerLine * 3;\n      positionBuffer.copyWithin(last, 0, 3);\n      colorBuffer.copyWithin(last, 0, 3);\n\n      geometry.setPositions(positionBuffer);\n      geometry.setColors(colorBuffer);\n    }\n  });\n\n  return (\n    <>\n      {Array.from({ length: config.numLines }, (_, lineIdx) => {\n        const geometry = geometries[lineIdx];\n        const material = materials[lineIdx];\n        const constants = lineConstants[lineIdx];\n        if (!geometry || !material || !constants) return null;\n        return (\n          // Longitude rotation is constant per line, so it is declared once here\n          // rather than re-written from the frame loop.\n          <group key={lineIdx} rotation-y={constants.longitudeRotation}>\n            {/* @ts-expect-error line2 is an R3F extension registered via extend() */}\n            <line2>\n              <primitive object={geometry} attach=\"geometry\" />\n              <primitive object={material} attach=\"material\" />\n              {/* @ts-expect-error line2 is an R3F extension registered via extend() */}\n            </line2>\n          </group>\n        );\n      })}\n    </>\n  );\n}\n\n/**\n * 3D animated orb with flowing latitude lines and depth-based opacity.\n *\n * Lines travel pole-to-pole across a sphere surface with subtle squiggle\n * displacement. Per-vertex colors encode camera-facing depth for a\n * volumetric wireframe look.\n *\n * @example\n * ```tsx\n * <GeometricOrb />\n * <GeometricOrb config={{ color: \"#4af\", numLines: 30, speed: 10 }} />\n * ```\n */\nexport function GeometricOrb({\n  config: configOverrides,\n  className = \"\",\n}: {\n  config?: GeometricOrbConfig;\n  className?: string;\n}) {\n  // Derived during render: LatitudeLines only memoizes on individual config\n  // fields, so a fresh object identity each render costs nothing.\n  const config = { ...defaults, ...configOverrides };\n\n  return (\n    <div className={`w-full h-full ${className}`} style={{ background: config.background }}>\n      <Canvas camera={{ position: [0, 0, 8], fov: 45 }} gl={{ antialias: true, alpha: false }}>\n        <color attach=\"background\" args={[config.background]} />\n        <LatitudeLines config={config} />\n        <OrbitControls\n          enablePan={config.enablePan}\n          enableZoom={config.enableZoom}\n          minDistance={config.minDistance}\n          maxDistance={config.maxDistance}\n        />\n      </Canvas>\n    </div>\n  );\n}\n","target":"uicapsule/geometric-orb/geometric-orb.tsx"},{"type":"registry:file","path":"/package.json","content":"{\n  \"name\": \"@uicapsule/geometric-orb\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"exports\": {\n    \"./preview\": \"./preview.tsx\"\n  },\n  \"scripts\": {\n    \"clean\": \"git clean -xdf .cache .turbo dist node_modules\"\n  },\n  \"dependencies\": {\n    \"@react-three/drei\": \"^10.7.8\",\n    \"@react-three/fiber\": \"^9.7.0\",\n    \"react\": \"catalog:\",\n    \"react-dom\": \"catalog:\",\n    \"three\": \"^0.185.1\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"catalog:\",\n    \"@types/react-dom\": \"catalog:\",\n    \"@types/three\": \"^0.185.4\"\n  }\n}\n","target":"uicapsule/geometric-orb/package.json"},{"type":"registry:file","path":"/preview.tsx","content":"\"use client\";\n\nimport { GeometricOrb } from \"./geometric-orb\";\n\nconst Preview = () => {\n  return (\n    <div className=\"h-screen w-full\">\n      <GeometricOrb />\n    </div>\n  );\n};\n\nexport default Preview;\n","target":"uicapsule/geometric-orb/preview.tsx"}]}