204 lines
7.0 KiB
TypeScript
204 lines
7.0 KiB
TypeScript
import { useEffect, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
|
|
|
import { drawLiquid, drawOrb, step, type WaveState } from './draw';
|
|
|
|
export interface WaveButtonProps {
|
|
variant?: 'orb' | 'liquid';
|
|
playing?: boolean;
|
|
readAudio?: () => { bass: number; mid: number; treble: number };
|
|
bpm?: number;
|
|
energy?: number;
|
|
colors?: [string, string, string];
|
|
theme?: 'dark' | 'light';
|
|
interactive?: boolean;
|
|
size?: number;
|
|
label: string;
|
|
onClick?: () => void;
|
|
children?: ReactNode;
|
|
}
|
|
|
|
const DEFAULT_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
|
|
|
function prefersReducedMotion(): boolean {
|
|
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
}
|
|
|
|
/**
|
|
* Кнопка «Моя волна» (docs/design.md#3): в покое «дышит», при игре пульсирует в такт BPM с силой от energy,
|
|
* тянется к курсору, при нажатии сжимается с отскоком. Enter / пробел — то же, что клик.
|
|
*/
|
|
export function WaveButton(props: WaveButtonProps) {
|
|
const size = props.size ?? 320;
|
|
const audioRef = useRef(props.readAudio);
|
|
audioRef.current = props.readAudio;
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const labelRef = useRef<HTMLDivElement>(null);
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
const st = useRef<WaveState>({
|
|
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
|
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: DEFAULT_COLORS,
|
|
dark: true, reduced: false, size,
|
|
});
|
|
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
|
|
const s = st.current;
|
|
s.variant = props.variant ?? 'orb';
|
|
s.playing = !!props.playing;
|
|
s.bpm = props.bpm ?? 100;
|
|
s.energy = props.energy ?? 0.6;
|
|
s.colors = props.colors ?? DEFAULT_COLORS;
|
|
s.dark = props.theme !== 'light';
|
|
s.size = size;
|
|
|
|
useEffect(() => {
|
|
const cv = canvasRef.current;
|
|
const ctx = cv?.getContext('2d');
|
|
if (!cv || !ctx) return;
|
|
const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
st.current.reduced = prefersReducedMotion();
|
|
const onMq = () => (st.current.reduced = prefersReducedMotion());
|
|
mq?.addEventListener?.('change', onMq);
|
|
let raf = 0;
|
|
let livePulse = 0;
|
|
let last = performance.now();
|
|
const loop = (now: number) => {
|
|
const dt = Math.min(0.05, (now - last) / 1000);
|
|
last = now;
|
|
const w = st.current;
|
|
const S = w.size * 1.6;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
if (cv.width !== Math.round(S * dpr)) {
|
|
cv.width = Math.round(S * dpr);
|
|
cv.height = Math.round(S * dpr);
|
|
}
|
|
step(w, dt);
|
|
const audio = !w.reduced && w.playing ? audioRef.current?.() : undefined;
|
|
if (audioRef.current && !w.reduced) {
|
|
const target = audio ? Math.min(1, audio.bass * 0.7 + audio.mid * 0.2) : 0;
|
|
livePulse += (target - livePulse) * (1 - Math.exp(-dt * (target > livePulse ? 35 : 9)));
|
|
w.pulse = livePulse;
|
|
w.amp = audio ? Math.min(1, audio.mid * 0.8 + audio.treble * 0.7) : 0;
|
|
}
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.clearRect(0, 0, S, S);
|
|
const sc = 1 - w.press * 0.07 + w.hover * 0.02;
|
|
const r = w.size * 0.5 * sc * (1 + w.pulse * 0.055);
|
|
const cx = S / 2 + w.mx * w.hover * 6;
|
|
const cy = S / 2 + w.my * w.hover * 6;
|
|
const ripple = audio ? w.amp * 0.025 * Math.sin(w.t * 13) : 0;
|
|
ctx.translate(cx, cy);
|
|
ctx.scale(1 + ripple, 1 - ripple);
|
|
ctx.translate(-cx, -cy);
|
|
if (w.variant === 'liquid') drawLiquid(ctx, cx, cy, r, w, w.t);
|
|
else drawOrb(ctx, cx, cy, r, w, w.t);
|
|
if (labelRef.current) labelRef.current.style.transform = `scale(${sc + w.pulse * 0.03})`;
|
|
raf = requestAnimationFrame(loop);
|
|
};
|
|
// Во фоновой вкладке анимация не нужна — останавливаемся до возвращения.
|
|
const onVisibility = () => {
|
|
cancelAnimationFrame(raf);
|
|
if (document.visibilityState === 'visible') {
|
|
last = performance.now();
|
|
raf = requestAnimationFrame(loop);
|
|
}
|
|
};
|
|
raf = requestAnimationFrame(loop);
|
|
document.addEventListener('visibilitychange', onVisibility);
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
document.removeEventListener('visibilitychange', onVisibility);
|
|
mq?.removeEventListener?.('change', onMq);
|
|
};
|
|
}, []);
|
|
|
|
const interactive = props.interactive !== false;
|
|
const onMove = (e: ReactMouseEvent) => {
|
|
const b = wrapRef.current!.getBoundingClientRect();
|
|
const x = ((e.clientX - b.left) / b.width) * 2 - 1;
|
|
const y = ((e.clientY - b.top) / b.height) * 2 - 1;
|
|
s.mx = x;
|
|
s.my = y;
|
|
s.ang = Math.atan2(y, x);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={wrapRef}
|
|
role={interactive ? 'button' : undefined}
|
|
tabIndex={interactive ? 0 : undefined}
|
|
aria-label={interactive ? props.label : undefined}
|
|
aria-pressed={interactive ? !!props.playing : undefined}
|
|
aria-hidden={interactive ? undefined : true}
|
|
onMouseMove={interactive ? onMove : undefined}
|
|
onMouseEnter={interactive ? () => (s.hoverT = 1) : undefined}
|
|
onMouseLeave={
|
|
interactive
|
|
? () => {
|
|
s.hoverT = 0;
|
|
s.pressT = 0;
|
|
}
|
|
: undefined
|
|
}
|
|
onMouseDown={interactive ? () => (s.pressT = 1) : undefined}
|
|
onMouseUp={
|
|
interactive
|
|
? () => {
|
|
s.pressT = 0;
|
|
s.pressV -= 0.12;
|
|
}
|
|
: undefined
|
|
}
|
|
onClick={interactive ? props.onClick : undefined}
|
|
onKeyDown={
|
|
interactive
|
|
? (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
e.stopPropagation(); // пробел здесь — кнопка волны, а не пауза плеера
|
|
props.onClick?.();
|
|
}
|
|
}
|
|
: undefined
|
|
}
|
|
style={{
|
|
position: 'relative',
|
|
width: size,
|
|
height: size,
|
|
maxWidth: '78vw',
|
|
maxHeight: '78vw',
|
|
borderRadius: '50%',
|
|
cursor: interactive ? 'pointer' : 'default',
|
|
flexShrink: 0,
|
|
pointerEvents: interactive ? 'auto' : 'none',
|
|
}}
|
|
>
|
|
<canvas
|
|
ref={canvasRef}
|
|
style={{
|
|
position: 'absolute',
|
|
left: '-30%',
|
|
top: '-30%',
|
|
width: '160%',
|
|
height: '160%',
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
<div
|
|
ref={labelRef}
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
pointerEvents: 'none',
|
|
color: '#fff',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{props.children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|