/* Connie's energy orb — multi-layered, audio-reactive */ const { useEffect, useRef, useState } = React; function Orb({ state }) { // state: 'ready' | 'listening' | 'thinking' | 'speaking' const canvasRef = useRef(null); const rafRef = useRef(null); // Audio-reactive waveform driven by procedural amplitude useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); const dpr = window.devicePixelRatio || 1; function resize() { const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } resize(); const ro = new ResizeObserver(resize); ro.observe(canvas); let t = 0; let particles = Array.from({ length: 30 }, (_, i) => ({ angle: (i / 30) * Math.PI * 2, radius: 90 + Math.random() * 20, speed: 0.0006 + Math.random() * 0.0008, size: 0.6 + Math.random() * 1.4, hue: Math.random() < 0.5 ? "lime" : "teal", phase: Math.random() * Math.PI * 2, })); function draw(now) { t = now; const w = canvas.width / dpr; const h = canvas.height / dpr; ctx.clearRect(0, 0, w, h); const cx = w / 2; const cy = h / 2; const baseR = Math.min(w, h) * 0.18; // State-driven intensity let intensity = 0.4; if (state === "listening") intensity = 0.7; else if (state === "thinking") intensity = 0.55; else if (state === "speaking") intensity = 1.0; // ---- Outer ripples (listening) ---- if (state === "listening") { for (let i = 0; i < 3; i++) { const phase = ((t / 1400) + i / 3) % 1; const r = baseR + phase * baseR * 2.4; const alpha = (1 - phase) * 0.5; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.strokeStyle = `rgba(225,245,40,${alpha * 0.8})`; ctx.lineWidth = 1.5; ctx.stroke(); } } // ---- Particles ---- const particleBoost = state === "thinking" ? 1.6 : state === "speaking" ? 1.3 : 1; particles.forEach((p) => { const a = p.angle + t * p.speed * particleBoost; const rWobble = Math.sin(t * 0.001 + p.phase) * 6; const r = p.radius + rWobble + baseR * 0.15; const x = cx + Math.cos(a) * r; const y = cy + Math.sin(a) * r; const flicker = 0.6 + Math.sin(t * 0.003 + p.phase) * 0.4; ctx.beginPath(); ctx.arc(x, y, p.size * (1 + intensity * 0.4), 0, Math.PI * 2); const c = p.hue === "lime" ? `rgba(225,245,40,${0.6 * flicker})` : `rgba(5,230,165,${0.6 * flicker})`; ctx.fillStyle = c; ctx.shadowColor = c; ctx.shadowBlur = 6; ctx.fill(); }); ctx.shadowBlur = 0; // ---- Speaking waveform around orb ---- if (state === "speaking") { ctx.save(); ctx.translate(cx, cy); const points = 96; ctx.beginPath(); for (let i = 0; i <= points; i++) { const ang = (i / points) * Math.PI * 2; const wob = Math.sin(t * 0.006 + i * 0.4) * 6 + Math.sin(t * 0.011 + i * 0.7) * 4 + Math.sin(t * 0.018 + i * 1.3) * 3; const r = baseR * 1.55 + wob; const x = Math.cos(ang) * r; const y = Math.sin(ang) * r; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); const grad = ctx.createLinearGradient(-baseR, -baseR, baseR, baseR); grad.addColorStop(0, "rgba(225,245,40,0.85)"); grad.addColorStop(1, "rgba(5,230,165,0.85)"); ctx.strokeStyle = grad; ctx.lineWidth = 1.4; ctx.stroke(); ctx.restore(); } rafRef.current = requestAnimationFrame(draw); } const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; if (!reduced) rafRef.current = requestAnimationFrame(draw); else draw(0); return () => { cancelAnimationFrame(rafRef.current); ro.disconnect(); }; }, [state]); return (
); } window.Orb = Orb;