Files
doormile_react/src/components/optimization/MetricsPanel.tsx

156 lines
5.2 KiB
TypeScript

"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<number>;
};
const COUNT_START = 0.7;
const COUNT_END = 0.97;
function Metric({ kpi, scroll, index, jitter }: { kpi: Kpi; scroll: MotionValue<number>; 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 (
<motion.div
className="dm-opt-metric"
style={{ boxShadow, borderColor, animationDelay: `${0.06 * index}s` }}
>
<div className="dm-opt-metric__top">
<motion.span className="dm-opt-metric__label">{label}</motion.span>
<motion.span className="dm-opt-metric__arrow" style={{ opacity: arrowOpacity, color: COLORS.green }}>
{kpi.goodWhenLower ? "▼" : "▲"}
</motion.span>
</div>
<motion.div className="dm-opt-metric__value" style={{ color: accent }}>
{value}
</motion.div>
{/* Sparkline trend graphic */}
<div className="dm-opt-metric__sparkline" style={{ position: "absolute", bottom: 8, right: 12, opacity: 0.32, width: 64, height: 20, pointerEvents: "none" }}>
<svg width="100%" height="100%" viewBox="0 0 64 20" fill="none">
<defs>
<linearGradient id={`grad-${kpi.key}`} x1="0" y1="0" x2="0" y2="20" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor={trendColor} stopOpacity="0.22" />
<stop offset="100%" stopColor={trendColor} stopOpacity="0.0" />
</linearGradient>
</defs>
<path
d={sparklinePath}
stroke={trendColor}
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d={`${sparklinePath} L 64,20 L 0,20 Z`}
fill={`url(#grad-${kpi.key})`}
/>
</svg>
</div>
<div className="dm-opt-metric__bar">
<motion.div
className="dm-opt-metric__fill"
style={{ background: accent, scaleX: fillScale }}
/>
</div>
</motion.div>
);
}
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<number[]>(() => 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 (
<div className="dm-opt-metrics" role="group" aria-label="Optimization results">
{KPIS.map((kpi, i) => (
<MetricCard key={kpi.key} kpi={kpi} scroll={scroll} index={i} jitter={jitters[i]} />
))}
</div>
);
}
export default React.memo(MetricsPanel);