"use client"; import { animate, useReducedMotion } from "motion/react"; import { useEffect, useRef, useState } from "react"; import type { ChartPhase } from "./chart-phase"; import { LINE_LOADING_PULSE_EASE } from "./line-loading-timing"; import { domainsEqual, isYDomainTweenPhase, resolveAnimatedYDestinationDomains, shouldTweenYDomain, type YDomain, } from "./y-domain-utils"; function lerpDomain(from: YDomain, to: YDomain, progress: number): YDomain { return [ from[0] + (to[0] - from[0]) * progress, from[1] + (to[1] - from[1]) * progress, ]; } function snapDomains( domains: Record, setAnimatedByAxis: (domains: Record) => void, animatedRef: { current: Record } ) { if (domainsEqual(animatedRef.current, domains)) { return; } setAnimatedByAxis(domains); animatedRef.current = domains; } function tweenDomains({ destination, durationMs, enabled, reducedMotion, animatedRef, setAnimatedByAxis, onSettled, }: { destination: Record; durationMs: number; enabled: boolean; reducedMotion: boolean | null; animatedRef: { current: Record }; setAnimatedByAxis: (domains: Record) => void; onSettled?: () => void; }) { if (domainsEqual(animatedRef.current, destination)) { onSettled?.(); return; } if (!enabled || reducedMotion) { snapDomains(destination, setAnimatedByAxis, animatedRef); onSettled?.(); return; } const axisIds = Object.keys(destination); const fromSnapshot = animatedRef.current; let needsTween = false; for (const axisId of axisIds) { const from = fromSnapshot[axisId] ?? destination[axisId] ?? ([0, 100] as YDomain); const to = destination[axisId] ?? from; if (shouldTweenYDomain(from, to)) { needsTween = true; break; } } if (!needsTween) { snapDomains(destination, setAnimatedByAxis, animatedRef); onSettled?.(); return; } const fromByAxis: Record = {}; for (const axisId of axisIds) { fromByAxis[axisId] = fromSnapshot[axisId] ?? destination[axisId] ?? [0, 100]; } const control = animate(0, 1, { duration: durationMs / 1000, ease: [...LINE_LOADING_PULSE_EASE], onUpdate: (progress) => { const next: Record = {}; for (const axisId of axisIds) { const from = fromByAxis[axisId] ?? destination[axisId] ?? ([0, 100] as YDomain); const to = destination[axisId] ?? from; next[axisId] = shouldTweenYDomain(from, to) ? lerpDomain(from, to, progress) : to; } animatedRef.current = next; setAnimatedByAxis(next); }, onComplete: () => { snapDomains(destination, setAnimatedByAxis, animatedRef); onSettled?.(); }, }); return control; } export interface UseAnimatedYDomainsOptions { enabled: boolean; durationMs: number; chartPhase: ChartPhase; skeletonByAxis: Record; targetByAxis: Record; onSettled?: () => void; /** When true, tweens y-domains on target changes while the chart is in the ready phase (e.g. brush zoom). */ tweenOnTargetChange?: boolean; } export function useAnimatedYDomains({ enabled, durationMs, chartPhase, skeletonByAxis, targetByAxis, onSettled, tweenOnTargetChange = false, }: UseAnimatedYDomainsOptions): Record { const reducedMotion = useReducedMotion(); const destinationByAxis = resolveAnimatedYDestinationDomains( chartPhase, skeletonByAxis, targetByAxis ); const destinationRef = useRef(destinationByAxis); destinationRef.current = destinationByAxis; const skeletonRef = useRef(skeletonByAxis); skeletonRef.current = skeletonByAxis; const targetRef = useRef(targetByAxis); targetRef.current = targetByAxis; const [animatedByAxis, setAnimatedByAxis] = useState(destinationByAxis); const animatedRef = useRef(animatedByAxis); const prevPhaseRef = useRef(chartPhase); const onSettledRef = useRef(onSettled); onSettledRef.current = onSettled; useEffect(() => { animatedRef.current = animatedByAxis; }, [animatedByAxis]); useEffect(() => { if (prevPhaseRef.current === chartPhase) { return; } prevPhaseRef.current = chartPhase; const settle = () => { onSettledRef.current?.(); }; // Keep grid spacing frozen while the series exits the viewport. if (chartPhase === "exiting") { snapDomains(skeletonRef.current, setAnimatedByAxis, animatedRef); return; } if (chartPhase === "exitingReady") { snapDomains(targetRef.current, setAnimatedByAxis, animatedRef); return; } if (chartPhase === "loading") { snapDomains(skeletonRef.current, setAnimatedByAxis, animatedRef); return; } if (chartPhase === "revealing" || chartPhase === "ready") { snapDomains(targetRef.current, setAnimatedByAxis, animatedRef); return; } if (!isYDomainTweenPhase(chartPhase)) { return; } const control = tweenDomains({ destination: destinationRef.current, durationMs, enabled, reducedMotion, animatedRef, setAnimatedByAxis, onSettled: settle, }); return () => control?.stop(); }, [chartPhase, durationMs, enabled, reducedMotion]); const targetSignature = JSON.stringify(targetByAxis); const prevTargetSignatureRef = useRef(targetSignature); useEffect(() => { const inLivePhase = chartPhase === "ready" || chartPhase === "revealing"; if (!inLivePhase) { prevTargetSignatureRef.current = targetSignature; return; } if (prevTargetSignatureRef.current === targetSignature) { return; } prevTargetSignatureRef.current = targetSignature; if (tweenOnTargetChange && chartPhase === "ready") { const control = tweenDomains({ destination: targetRef.current, durationMs, enabled, reducedMotion, animatedRef, setAnimatedByAxis, onSettled: () => onSettledRef.current?.(), }); return () => control?.stop(); } snapDomains(targetRef.current, setAnimatedByAxis, animatedRef); }, [ chartPhase, durationMs, enabled, reducedMotion, targetSignature, tweenOnTargetChange, ]); return animatedByAxis; }