'use client'; import React, { useRef, useEffect, useCallback } from 'react'; interface AIOrbProps { isThinking: boolean; hasError?: boolean; } function lerp(a: number, b: number, t: number) { return a + (b - a) * t; } // Simple noise function for organic movement function noise(x: number, y: number, t: number): number { return ( Math.sin(x * 1.3 + t * 0.7) * Math.cos(y * 0.9 + t * 0.5) * 0.5 + Math.sin(x * 2.7 + y * 1.1 + t * 1.3) * 0.25 + Math.cos(x * 0.8 - y * 2.3 + t * 0.9) * 0.25 ); } // ── Particle ─────────────────────────────────────────────────── interface Particle { // Sphere position (target shape) theta: number; phi: number; // Current position x: number; y: number; z: number; // Velocity vx: number; vy: number; vz: number; // Properties size: number; baseSize: number; hue: number; // 0=blue, 1=green brightness: number; phase: number; orbitSpeed: number; noiseScale: number; } function createParticles(count: number): Particle[] { const particles: Particle[] = []; for (let i = 0; i < count; i++) { // Fibonacci sphere distribution for even spacing const golden = Math.PI * (3 - Math.sqrt(5)); const y = 1 - (i / (count - 1)) * 2; const radiusAtY = Math.sqrt(1 - y * y); const theta = golden * i; const phi = Math.acos(y); particles.push({ theta, phi, x: Math.cos(theta) * radiusAtY, y, z: Math.sin(theta) * radiusAtY, vx: 0, vy: 0, vz: 0, size: 0.4 + Math.random() * 0.8, baseSize: 0.4 + Math.random() * 0.8, hue: Math.random() > 0.45 ? 0 : 1, brightness: 0.5 + Math.random() * 0.5, phase: Math.random() * Math.PI * 2, orbitSpeed: (0.1 + Math.random() * 0.4) * (Math.random() > 0.5 ? 1 : -1), noiseScale: 0.5 + Math.random() * 1.5, }); } return particles; } export default function AIOrb({ isThinking = false, hasError = false }: AIOrbProps) { const canvasRef = useRef(null); const wrapRef = useRef(null); const animRef = useRef(0); const particlesRef = useRef([]); const mouse = useRef({ x: 0.5, y: 0.5, hover: false }); const state = useRef({ pulse: 0, hover: 0, error: 0, mouseX: 0.5, mouseY: 0.5, rotY: 0, rotX: 0, breathe: 0, scatter: 0, shake: 0, }); const onMove = useCallback((e: React.PointerEvent) => { const r = wrapRef.current?.getBoundingClientRect(); if (!r) return; mouse.current.x = (e.clientX - r.left) / r.width; mouse.current.y = (e.clientY - r.top) / r.height; }, []); const onEnter = useCallback(() => { mouse.current.hover = true; }, []); const onLeave = useCallback(() => { mouse.current.hover = false; mouse.current.x = 0.5; mouse.current.y = 0.5; }, []); const draw = useCallback( function drawStep() { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); const w = rect.width * dpr; const h = rect.height * dpr; if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } const cx = w / 2; const cy = h / 2; const minDim = Math.min(w, h); // Reduced further to give maximum breathing room for glow + movement const sphereR = minDim * 0.16; const time = performance.now() / 1000; const s = state.current; const m = mouse.current; // ── Interpolate state ── s.pulse = lerp(s.pulse, isThinking ? 1 : 0, 0.03); s.hover = lerp(s.hover, m.hover ? 1 : 0, 0.12); s.error = lerp(s.error, hasError ? 1 : 0, 0.05); s.mouseX = lerp(s.mouseX, m.x, 0.12); s.mouseY = lerp(s.mouseY, m.y, 0.12); s.scatter = lerp(s.scatter, m.hover ? 0.8 : hasError ? 0.5 : 0, 0.06); s.shake += 0.15 * s.error; // Global rotation — ALWAYS rotating + ALWAYS facing cursor s.rotY += lerp(0.008, 0.04, Math.max(s.pulse, s.hover)); const mouseRotY = (s.mouseX - 0.5) * 1.2; // always face cursor const mouseRotX = (s.mouseY - 0.5) * 0.8; s.breathe += lerp(1.2, 3.0, s.pulse) / 60; const breathe = Math.sin(s.breathe) * 0.5 + 0.5; // ── Clear ── ctx.clearRect(0, 0, w, h); // ── Subtle core glow ── const shakeX = Math.sin(s.shake * 17) * s.error * minDim * 0.02; const glowCX = cx + shakeX; const glowCY = cy; // Clamp glow radius so it never exceeds ~48% of canvas (leaves padding for movement) const glowR = Math.min( sphereR * lerp(2.2, 4.0, Math.max(s.pulse, s.hover * 0.8)), minDim * 0.48, ); const glowA = lerp(0.1, 0.4, Math.max(s.pulse, s.hover * 0.7, s.error * 0.8)); const glow = ctx.createRadialGradient(glowCX, glowCY, 0, glowCX, glowCY, glowR); // Glow color: blue normally, red on error const glowR1 = Math.round(lerp(20, 255, s.error)); const glowG1 = Math.round(lerp(60, 40, s.error)); const glowB1 = Math.round(lerp(255, 40, s.error)); glow.addColorStop(0, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 2})`); glow.addColorStop( 0.25, `rgba(${Math.round(lerp(80, 200, s.error))}, ${Math.round(lerp(140, 50, s.error))}, ${Math.round(lerp(255, 50, s.error))}, ${glowA * 1.2})`, ); glow.addColorStop(0.6, `rgba(${glowR1}, ${glowG1}, ${glowB1}, ${glowA * 0.4})`); glow.addColorStop(1, `rgba(${glowR1}, ${glowG1}, ${glowB1}, 0)`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(glowCX, glowCY, glowR, 0, Math.PI * 2); ctx.fill(); // ── Create particles if empty ── if (particlesRef.current.length === 0) { particlesRef.current = createParticles(350); } // ── Update & draw particles ── const cosRY = Math.cos(s.rotY + mouseRotY); const sinRY = Math.sin(s.rotY + mouseRotY); const cosRX = Math.cos(mouseRotX); const sinRX = Math.sin(mouseRotX); // Sort by z for correct layering type ParticleWithScreen = { p: Particle; sx: number; sy: number; sz: number; depth: number }; const projected: ParticleWithScreen[] = []; for (const p of particlesRef.current) { // Target position: sphere surface + noise displacement const n = noise(p.theta * p.noiseScale, p.phi * p.noiseScale, time * 0.5 + p.phase); const displacement = 1 + n * lerp(0.12, 0.3, s.pulse); // Orbit: rotate theta — always moving, faster idle const activeTheta = p.theta + time * p.orbitSpeed * lerp(0.35, 0.8, s.pulse); // Sphere coordinates to cartesian const sinPhi = Math.sin(p.phi); const tgtX = Math.cos(activeTheta) * sinPhi * displacement; // Excitement from hover + pulse + error const targetExcite = Math.max(s.hover * 0.9, s.pulse, s.error * 0.8); const tgtY = Math.cos(p.phi) * displacement; const tgtZ = Math.sin(activeTheta) * sinPhi * displacement; // Scatter on hover: push particles outward const scatterMul = 1 + s.scatter * (0.5 + n * 0.5); // Spring physics toward target const tx = tgtX * scatterMul; const ty = tgtY * scatterMul; const tz = tgtZ * scatterMul; p.vx += (tx - p.x) * 0.08; p.vy += (ty - p.y) * 0.08; p.vz += (tz - p.z) * 0.08; p.vx *= 0.88; p.vy *= 0.88; p.vz *= 0.88; p.x += p.vx; p.y += p.vy; p.z += p.vz; // 3D rotation (Y then X) const rx = p.x * cosRY - p.z * sinRY; const rz = p.x * sinRY + p.z * cosRY; const ry = p.y * cosRX - rz * sinRX; const finalZ = p.y * sinRX + rz * cosRX; // Project to screen const perspective = 3; const scale = perspective / (perspective + finalZ); const sx = cx + rx * sphereR * scale; const sy = cy + ry * sphereR * scale; projected.push({ p, sx, sy, sz: finalZ, depth: scale }); } // Sort back-to-front projected.sort((a, b) => a.sz - b.sz); for (const { p, sx, sy, sz, depth } of projected) { // Depth-based alpha and size const depthAlpha = 0.25 + (sz + 1) * 0.375; // 0.25 (back) → 1.0 (front) const twinkle = 0.75 + 0.25 * Math.sin(time * 3.5 + p.phase); const alpha = depthAlpha * twinkle * p.brightness * lerp(0.8, 1.3, Math.max(s.pulse, s.hover * 0.8)); const drawSize = p.baseSize * depth * dpr * lerp(1.0, 2.0, Math.max(s.pulse, s.hover * 0.7)); // Color — shift to red on error let r: number, g: number, b: number; if (s.error > 0.1) { // Error: red family if (p.hue === 0) { r = Math.round(lerp(40 + sz * 30, 255, s.error)); g = Math.round(lerp(80 + sz * 40, 40 + sz * 20, s.error)); b = Math.round(lerp(255, 40, s.error)); } else { r = Math.round(lerp(100 + sz * 30, 230, s.error)); g = Math.round(lerp(220 + sz * 17, 60, s.error)); b = Math.round(lerp(20, 20, s.error)); } } else if (p.hue === 0) { r = 60 + Math.round(sz * 40); g = 100 + Math.round(sz * 50); b = 255; } else { r = 120 + Math.round(sz * 30); g = 237 + Math.round(sz * 10); b = 30; } // Thinking: shift toward brighter, more saturated if (s.pulse > 0.1) { r = Math.round(lerp(r, p.hue === 0 ? 100 : 130, s.pulse * 0.3)); g = Math.round(lerp(g, p.hue === 0 ? 140 : 237, s.pulse * 0.3)); b = Math.round(lerp(b, p.hue === 0 ? 255 : 32, s.pulse * 0.3)); } // Micro glow — always visible, stronger on front if (depthAlpha > 0.25) { const gSize = drawSize * lerp(4, 7, s.hover); const pg = ctx.createRadialGradient(sx, sy, 0, sx, sy, gSize); pg.addColorStop(0, `rgba(${r},${g},${b},${alpha * 0.5})`); pg.addColorStop(1, `rgba(${r},${g},${b},0)`); ctx.fillStyle = pg; ctx.beginPath(); ctx.arc(sx, sy, gSize, 0, Math.PI * 2); ctx.fill(); } // Core dot — bright ctx.fillStyle = `rgba(${Math.min(r + 40, 255)},${Math.min(g + 30, 255)},${b},${Math.min(alpha * 1.6, 1)})`; ctx.beginPath(); ctx.arc(sx, sy, Math.max(drawSize * 0.5, 0.3 * dpr), 0, Math.PI * 2); ctx.fill(); } // ── Loading rings (thinking) ── if (s.pulse > 0.02) { ctx.save(); ctx.translate(cx, cy); // Spinning arc const spinAngle = time * 2; const arcLen = Math.PI * lerp(0.3, 1.0, (Math.sin(time * 1.5) + 1) / 2); ctx.rotate(spinAngle); ctx.strokeStyle = `rgba(130, 237, 32, ${s.pulse * 0.4})`; ctx.lineWidth = 1.2 * dpr; ctx.lineCap = 'round'; ctx.beginPath(); ctx.arc(0, 0, sphereR * 1.25, 0, arcLen); ctx.stroke(); // Counter-spinning arc ctx.rotate(-spinAngle * 2); ctx.strokeStyle = `rgba(1, 29, 255, ${s.pulse * 0.3})`; ctx.lineWidth = 0.8 * dpr; ctx.beginPath(); ctx.arc(0, 0, sphereR * 1.35, 0, arcLen * 0.6); ctx.stroke(); ctx.restore(); // Expanding pulse const pulsePhase = (time * 0.8) % 1; const pulseR = sphereR * (1 + pulsePhase * 1.5); const pulseA = s.pulse * (1 - pulsePhase) * 0.15; ctx.strokeStyle = `rgba(130, 237, 32, ${pulseA})`; ctx.lineWidth = 1 * dpr; ctx.beginPath(); ctx.arc(cx, cy, pulseR, 0, Math.PI * 2); ctx.stroke(); } animRef.current = requestAnimationFrame(drawStep); }, [isThinking, hasError], ); useEffect(() => { animRef.current = requestAnimationFrame(draw); return () => cancelAnimationFrame(animRef.current); }, [draw]); return (
); }