/** * Reveal animation: eases 0→1 once the chart scrolls into view. Respects * `prefers-reduced-motion` (snaps to 1) and cleans up after itself. */ import { onMounted, onUnmounted, ref, type Ref } from 'vue' export interface AnimOptions { el: Ref enabled?: boolean duration?: number delay?: number } function easeOutCubic(t: number): number { return 1 - (1 - t) ** 3 } function prefersReduced(): boolean { return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches } export function useChartAnim(opts: AnimOptions) { const progress = ref(opts.enabled === false || prefersReduced() ? 1 : 0) let observer: IntersectionObserver | null = null let raf = 0 let started = false function run() { if (started) return started = true const duration = opts.duration ?? 700 const start = performance.now() + (opts.delay ?? 0) const tick = (now: number) => { const elapsed = now - start if (elapsed < 0) { raf = requestAnimationFrame(tick); return } const t = Math.min(elapsed / duration, 1) progress.value = easeOutCubic(t) if (t < 1) raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) } onMounted(() => { if (progress.value === 1) return if (typeof IntersectionObserver === 'undefined') { run(); return } observer = new IntersectionObserver((entries) => { for (const e of entries) if (e.isIntersecting) { run(); observer?.disconnect() } }, { threshold: 0.25 }) if (opts.el.value) observer.observe(opts.el.value) else run() }) onUnmounted(() => { observer?.disconnect() cancelAnimationFrame(raf) }) return { progress } }