assistant/renderer/components/ConversationParticles.tsx

103 lines
4.2 KiB
TypeScript

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<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(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 <div ref={wrapper} className={`conversation-particles${compact ? ' is-compact' : ''}`}
data-testid="conversation-particles" data-active={active} data-compact={compact}
data-reduced-motion={reducedMotion} aria-hidden="true">
<canvas ref={canvas} />
</div>;
}