/** * useCountUp — RAF-tween анимация числа (Quiet Luxury KPI cards). * * - easeOutQuint easing * - respects prefers-reduced-motion (instant value) * - re-animates when target ref changes * * Spec: docs/superpowers/plans/2026-05-12-portal-redesign-quiet-luxury-plan.md (Task 6). */ import { ref, watch, type Ref } from 'vue'; export interface CountUpOptions { duration?: number; // ms precision?: number; // знаков после запятой } export interface CountUpHandle { display: Ref; start: () => void; } const easeOutQuint = (t: number): number => 1 - Math.pow(1 - t, 5); function prefersReducedMotion(): boolean { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } export function useCountUp(target: Ref, opts: CountUpOptions = {}): CountUpHandle { const duration = opts.duration ?? 600; const precision = opts.precision ?? 0; const display = ref(0); let raf: number | null = null; let startTime = 0; let fromValue = 0; function tick(now: number): void { const elapsed = now - startTime; const t = Math.min(elapsed / duration, 1); const eased = easeOutQuint(t); const value = fromValue + (target.value - fromValue) * eased; display.value = precision === 0 ? Math.round(value) : parseFloat(value.toFixed(precision)); if (t < 1) { raf = requestAnimationFrame(tick); } else { display.value = target.value; raf = null; } } function start(): void { if (prefersReducedMotion()) { display.value = target.value; return; } if (raf !== null) cancelAnimationFrame(raf); fromValue = display.value; startTime = performance.now(); raf = requestAnimationFrame(tick); } watch(target, () => { if (display.value !== target.value) start(); }); return { display, start }; }