156 lines
5.2 KiB
TypeScript
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);
|