import { useEffect, useId, useRef } from 'react';
import { createParticleLoop } from './particleLoop';
import '../companion.css';
export type CompanionState = 'idle' | 'running' | 'paused' | 'completed' | 'error';
type Particle = { x: number; y: number; tx: number; ty: number; seed: number; size: number; alpha: number };
// The brand is a small, rounded spark. Both the SVG and particle mask share
// these paths so the idle companion stays recognizable at every size.
const SPARK_BODY = 'M109 58C121 46 137 40 148 44C161 47 160 63 158 76C156 89 172 86 184 101C203 123 204 155 193 179C181 205 156 218 122 219C84 220 53 204 41 179C27 155 29 129 44 102C54 78 70 61 81 57C89 53 96 58 101 66C104 70 106 64 109 58Z';
const SPARK_FACE = 'M75 129C85 116 101 111 120 112C142 112 162 127 164 145C166 165 146 179 122 179C98 179 77 173 70 159C65 149 68 138 75 129Z';
const SPARK_DROP = 'M169 9C179 14 186 25 183 34C181 43 170 47 162 41C153 35 157 22 169 9Z';
const SPARK_SMILE = 'M108 152C116 160 129 160 137 151';
export function SparkMark({ small = false }: { small?: boolean }) {
const gradient = `spark-${useId().replace(/:/g, '')}`;
return ;
}
function seeded(index: number): number {
const value = Math.sin(index * 127.1 + 17.23) * 43758.5453;
return value - Math.floor(value);
}
function sampleSpark(): Array<{ x: number; y: number }> {
const mask = document.createElement('canvas');
mask.width = 180;
mask.height = 170;
const context = mask.getContext('2d', { willReadFrequently: true });
if (!context) return [];
context.translate(23, 6);
context.scale(.6, .6);
context.fillStyle = '#fff';
context.fill(new Path2D(SPARK_BODY));
context.fill(new Path2D(SPARK_DROP));
context.globalCompositeOperation = 'destination-out';
context.fill(new Path2D(SPARK_FACE));
context.globalCompositeOperation = 'source-over';
for (const x of [102, 139]) {
context.beginPath();
context.ellipse(x, 140, 3.7, 4.4, 0, 0, Math.PI * 2);
context.fill();
}
context.strokeStyle = '#fff';
context.lineWidth = 3.2;
context.lineCap = 'round';
context.stroke(new Path2D(SPARK_SMILE));
const image = context.getImageData(0, 0, mask.width, mask.height).data;
const points: Array<{ x: number; y: number }> = [];
for (let y = 10; y < 153; y += 1.35) {
for (let x = 36; x < 154; x += 1.35) {
const index = (Math.floor(y) * mask.width + Math.floor(x)) * 4;
if (image[index + 3] > 150) points.push({ x: x - 92, y: y - 80 });
}
}
// Even subsampling keeps the detached spark and the soft face cutout.
const count = Math.min(1500, points.length);
return Array.from({ length: count }, (_, index) => points[Math.floor(index * points.length / count)]);
}
export function ParticleCompanion({ state, reducedMotion, active = true }: { state: CompanionState; reducedMotion: boolean; active?: boolean }) {
const wrapper = useRef(null);
const canvas = useRef(null);
const stateRef = useRef(state);
const reducedRef = useRef(reducedMotion);
const activeRef = useRef(active);
const wake = useRef<() => void>(() => undefined);
useEffect(() => { stateRef.current = state; wake.current(); }, [state]);
useEffect(() => { reducedRef.current = reducedMotion; wake.current(); }, [reducedMotion]);
useEffect(() => { activeRef.current = active; wake.current(); }, [active]);
useEffect(() => {
const host = wrapper.current;
const surface = canvas.current;
if (!host || !surface) return;
const context = surface.getContext('2d');
if (!context) return;
const points = sampleSpark();
const particles: Particle[] = points.map((point, index) => ({
x: point.x + (seeded(index + 19) - .5) * 30,
y: point.y + (seeded(index + 51) - .5) * 30,
tx: point.x, ty: point.y, seed: seeded(index + 7) * Math.PI * 2,
size: .48 + seeded(index + 111) * .6,
alpha: .12 + seeded(index + 270) * .31,
}));
let width = 0;
let height = 0;
let inViewport = true;
function draw(time: number, settle = false): void {
if (!context || !surface || !width || !height) return;
const mood = stateRef.current;
const still = reducedRef.current || settle;
const running = mood === 'running';
const quiet = mood === 'paused' || mood === 'error';
const scale = Math.min(width / 235, height / 175, 1.43);
const centerX = width * .5;
const centerY = height * .5;
const breath = still ? 1 : 1 + Math.sin(time * .00052) * .018;
context.clearRect(0, 0, width, height);
const alphaScale = mood === 'completed' ? .95 : quiet ? .7 : .8;
for (let index = 0; index < particles.length; index += 1) {
const particle = particles[index];
const drift = still ? 0 : quiet ? .65 : running ? 2.25 : 1.15;
const flowX = Math.sin(time * .00048 + particle.seed + particle.ty * .026) * drift;
const flowY = Math.cos(time * .00039 + particle.seed + particle.tx * .018) * drift;
const lift = running && !still ? Math.sin(time * .0011 + particle.seed) * 1.4 * Math.max(0, -particle.ty / 72) : 0;
const targetX = particle.tx * breath + flowX;
const targetY = particle.ty * breath + flowY - lift;
if (still) {
particle.x = particle.tx;
particle.y = particle.ty;
} else {
particle.x += (targetX - particle.x) * .048;
particle.y += (targetY - particle.y) * .048;
}
const pulse = still ? 1 : .86 + Math.sin(time * .00072 + particle.seed) * .14;
context.fillStyle = `rgba(91, 127, 162, ${particle.alpha * pulse * alphaScale})`;
context.beginPath();
context.arc(centerX + particle.x * scale, centerY + particle.y * scale, particle.size * scale, 0, Math.PI * 2);
context.fill();
}
}
const loop = createParticleLoop({
draw,
canRender: () => !document.hidden && inViewport && width > 0 && height > 0,
canAnimate: () => activeRef.current && !reducedRef.current,
});
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();
}
wake.current = loop.sync;
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(host);
const visibilityObserver = new IntersectionObserver(entries => {
inViewport = entries.some(entry => entry.isIntersecting);
loop.sync();
});
visibilityObserver.observe(host);
document.addEventListener('visibilitychange', loop.sync);
resize();
return () => {
wake.current = () => undefined;
loop.dispose();
resizeObserver.disconnect();
visibilityObserver.disconnect();
document.removeEventListener('visibilitychange', loop.sync);
};
}, []);
return
;
}