103 lines
4.2 KiB
TypeScript
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>;
|
|
}
|