import { useEffect, useRef } from 'react'; import { createParticleLoop } from './particleLoop'; import '../companion.css'; /** A flowing field, deliberately separate from the idle brand silhouette. * It expresses a pending request, never a percentage or a successful outcome. */ export function ConversationParticles({ active, reducedMotion, compact = false }: { active: boolean; reducedMotion: boolean; compact?: boolean; }) { const wrapper = useRef(null); const canvas = useRef(null); const activeRef = useRef(active); const reducedRef = useRef(reducedMotion); const wake = useRef<() => void>(() => undefined); useEffect(() => { activeRef.current = active; wake.current(); }, [active]); useEffect(() => { reducedRef.current = reducedMotion; wake.current(); }, [reducedMotion]); useEffect(() => { const host = wrapper.current; const surface = canvas.current; const context = surface?.getContext('2d'); if (!host || !surface || !context) return; let width = 0; let height = 0; let inViewport = true; const count = compact ? 180 : 680; const particles = Array.from({ length: count }, (_, index) => { const noise = (salt: number) => { const value = Math.sin((index + 1) * 127.1 + salt * 311.7) * 43758.5453; return value - Math.floor(value); }; return { u: noise(1), layer: noise(2), phase: noise(3) * Math.PI * 2, size: .48 + noise(4) * .65, opacity: .14 + noise(5) * .38 }; }); function draw(elapsed: number, still: boolean): void { if (!context || !width || !height) return; const time = still ? 0 : elapsed * .00032; const scaleX = Math.min(width * .4, compact ? 22 : 145); const scaleY = Math.min(height * .36, compact ? 16 : 48); context.clearRect(0, 0, width, height); for (const particle of particles) { // Two soft streams gather and release without ever forming the bird. const angle = particle.u * Math.PI * 2 + time * (particle.layer < .5 ? .72 : -.52); const depth = .24 + particle.layer * .76; const gather = .8 + Math.sin(time * .7 + particle.phase * .22) * .12; const x = Math.cos(angle) * depth * gather; const y = Math.sin(angle * 2 + time * .55) * depth * .65 + Math.sin(angle + particle.phase) * .18; const luminosity = still ? 1 : .82 + Math.sin(time * 2 + particle.phase) * .18; context.fillStyle = `rgba(91, 127, 162, ${particle.opacity * luminosity})`; context.beginPath(); context.arc(width / 2 + x * scaleX, height / 2 + y * scaleY, particle.size * (compact ? .75 : 1), 0, Math.PI * 2); context.fill(); } } const loop = createParticleLoop({ draw, canRender: () => !document.hidden && inViewport && width > 0 && height > 0, canAnimate: () => activeRef.current && !reducedRef.current, }); wake.current = loop.sync; function resize(): void { if (!host || !surface || !context) return; const bounds = host.getBoundingClientRect(); width = Math.max(1, bounds.width); height = Math.max(1, bounds.height); const dpr = Math.min(2, window.devicePixelRatio || 1); surface.width = Math.round(width * dpr); surface.height = Math.round(height * dpr); context.setTransform(dpr, 0, 0, dpr, 0, 0); loop.sync(); } const resizeObserver = new ResizeObserver(resize); const visibilityObserver = new IntersectionObserver(entries => { inViewport = entries.some(entry => entry.isIntersecting); loop.sync(); }); resizeObserver.observe(host); visibilityObserver.observe(host); document.addEventListener('visibilitychange', loop.sync); resize(); return () => { wake.current = () => undefined; loop.dispose(); resizeObserver.disconnect(); visibilityObserver.disconnect(); document.removeEventListener('visibilitychange', loop.sync); }; }, [compact]); return ; }