Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m0s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
'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<HTMLCanvasElement>(null);
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
const animRef = useRef<number>(0);
|
|
const particlesRef = useRef<Particle[]>([]);
|
|
|
|
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 (
|
|
<div
|
|
ref={wrapRef}
|
|
className="w-full h-full relative overflow-visible"
|
|
onPointerMove={onMove}
|
|
onPointerEnter={onEnter}
|
|
onPointerLeave={onLeave}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<canvas ref={canvasRef} className="w-full h-full block" style={{ imageRendering: 'auto' }} />
|
|
</div>
|
|
);
|
|
}
|