"use client"; import React from "react"; import { motion, useTransform, type MotionValue } from "framer-motion"; import { COLORS, KPIS, Kpi, rgba } from "./constants"; type Props = { /** shared scroll progress (0 → 1) */ scroll: MotionValue; }; const COUNT_START = 0.7; const COUNT_END = 0.97; function Metric({ kpi, scroll, index, jitter }: { kpi: Kpi; scroll: MotionValue; index: number; jitter: number }) { // Single reactive string — updates without re-rendering React. const value = useTransform(scroll, (v) => { const t = Math.min(1, Math.max(0, (v - COUNT_START) / (COUNT_END - COUNT_START))); let n = kpi.before + (kpi.after - kpi.before) * t; // Apply high-frequency live operational fluctuations on settled state if (t > 0.95) { n = n * (1 + jitter); } const afterSide = t > 0.5; const prefix = afterSide ? kpi.prefixAfter ?? "" : kpi.prefixBefore ?? ""; if (kpi.key === "delayed" && kpi.after === 0 && t > 0.95) { return `0`; } return `${prefix}${Math.round(n)}${kpi.suffix ?? ""}`; }); const label = useTransform(scroll, (v) => v > (COUNT_START + COUNT_END) / 2 ? kpi.labelAfter : kpi.labelBefore, ); const fromColor = kpi.key === "orders" ? COLORS.cyan : COLORS.red; const toColor = COLORS.green; const accent = useTransform( scroll, [COUNT_START, COUNT_END], [rgba(fromColor, 1), rgba(toColor, 1)], ); const glow = useTransform( scroll, [COUNT_START, COUNT_END], [rgba(fromColor, 0.28), rgba(toColor, 0.32)], ); const boxShadow = useTransform(glow, (g) => `0 10px 40px -12px ${g}, inset 0 1px 0 rgba(255,255,255,0.06)`); const borderColor = useTransform( scroll, [COUNT_START, COUNT_END], [rgba(fromColor, 0.4), rgba(toColor, 0.45)], ); // Improvement arrow appears once optimized. const arrowOpacity = useTransform(scroll, [COUNT_END - 0.12, COUNT_END], [0, 1]); // Bar fills as the counter morphs from before → after. const fillScale = useTransform(scroll, [COUNT_START, COUNT_END], [0.12, 1]); const trendColor = kpi.goodWhenLower ? COLORS.green : (kpi.key === "orders" ? COLORS.cyan : COLORS.green); const sparklinePath = React.useMemo(() => { switch (kpi.key) { case "distance": case "vehicles": return "M 0,3 C 16,3 32,17 64,17"; // Downward trend case "orders": return "M 0,17 C 16,6 32,19 64,8"; // Stable high wavy trend case "delayed": case "cost": return "M 0,1 C 16,1 28,19 64,19"; // Sharp drop default: return "M 0,10 L 64,10"; } }, [kpi.key]); return (
{label} {kpi.goodWhenLower ? "▼" : "▲"}
{value} {/* Sparkline trend graphic */}
); } const MetricCard = React.memo(Metric); function MetricsPanel({ scroll }: Props) { // One shared interval drives the live "operational" micro-fluctuation for all // cards (was 5 independent timers). Paused under reduced-motion and when the // tab is hidden. const [jitters, setJitters] = React.useState(() => KPIS.map(() => 0)); React.useEffect(() => { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const id = setInterval(() => { if (document.hidden) return; setJitters(KPIS.map(() => (Math.random() - 0.5) * 0.008)); }, 1100); return () => clearInterval(id); }, []); return (
{KPIS.map((kpi, i) => ( ))}
); } export default React.memo(MetricsPanel);