ae92923c28
Animation primitives (6 new components): - AnimatedNumber: count-up with easeOutExpo, de-DE locale formatting - ShimmerSkeleton: diagonal gradient sweep replacing animate-pulse - FadeIn: framer-motion viewport-triggered fade + slide - StaggerList/StaggerItem: staggered children entrance - Sparkline: pure SVG inline trend chart with draw-in animation - ProgressRing: animated circular progress with CSS transitions Sidebar & page transitions: - Sliding nav indicator (framer-motion layoutId animation) - Icon frame hover glow (brand-color shadow) - Smooth section collapse/expand (AnimatePresence height animation) - PageTransition wrapper (fade-up on route change) - AnimatedModal component (scale + fade with custom bezier) - Notification badge bounce on count increase Dashboard animations: - StatCards: AnimatedNumber count-up + staggered FadeIn + budget color tinting - WidgetContainer: fade-slide-up on mount - Chargeability: animated percentages + inline utilization bars - ProjectTable/MyProjects: animated numbers + staggered row entrance Shimmer skeletons & table animations: - Replaced animate-pulse across 20+ loading states with shimmer gradient - Staggered row entrance (fadeSlideIn) on Resources, Projects, Allocations tables - hover-lift utility class for subtle card/row elevation on hover - Content-shaped skeletons (avatars, text bars, badges) Light mode surface depth: - Mesh gradient page background (subtle accent-tinted corners) - Enhanced card shadows (two-layer depth) - Sidebar glassmorphism upgrade (bg-white/60, backdrop-blur-2xl, saturate-150) - Toolbar sticky backdrop blur - Enhanced focus ring with brand-color glow Co-Authored-By: claude-flow <ruv@ruv.net>
163 lines
3.6 KiB
TypeScript
163 lines
3.6 KiB
TypeScript
"use client";
|
|
|
|
import { useId } from "react";
|
|
|
|
interface SparklineProps {
|
|
data: number[];
|
|
width?: number;
|
|
height?: number;
|
|
color?: string;
|
|
fillOpacity?: number;
|
|
strokeWidth?: number;
|
|
className?: string;
|
|
animated?: boolean;
|
|
}
|
|
|
|
function buildPoints(
|
|
data: number[],
|
|
width: number,
|
|
height: number,
|
|
strokeWidth: number,
|
|
): string {
|
|
if (data.length === 0) return "";
|
|
|
|
const padding = strokeWidth;
|
|
const drawWidth = width - padding * 2;
|
|
const drawHeight = height - padding * 2;
|
|
|
|
const min = Math.min(...data);
|
|
const max = Math.max(...data);
|
|
const range = max - min || 1; // avoid division by zero when all values equal
|
|
|
|
return data
|
|
.map((v, i) => {
|
|
const x =
|
|
data.length === 1
|
|
? width / 2
|
|
: padding + (i / (data.length - 1)) * drawWidth;
|
|
const y = padding + drawHeight - ((v - min) / range) * drawHeight;
|
|
return `${x},${y}`;
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
function buildAreaPoints(
|
|
linePoints: string,
|
|
width: number,
|
|
height: number,
|
|
strokeWidth: number,
|
|
dataLength: number,
|
|
): string {
|
|
if (!linePoints) return "";
|
|
|
|
const padding = strokeWidth;
|
|
const drawWidth = width - padding * 2;
|
|
const bottom = height - padding;
|
|
|
|
const lastX =
|
|
dataLength === 1 ? width / 2 : padding + drawWidth;
|
|
const firstX = dataLength === 1 ? width / 2 : padding;
|
|
|
|
return `${linePoints} ${lastX},${bottom} ${firstX},${bottom}`;
|
|
}
|
|
|
|
export function Sparkline({
|
|
data,
|
|
width = 60,
|
|
height = 20,
|
|
color = "currentColor",
|
|
fillOpacity = 0.1,
|
|
strokeWidth = 1.5,
|
|
className,
|
|
animated = true,
|
|
}: SparklineProps) {
|
|
const id = useId();
|
|
|
|
if (data.length === 0) {
|
|
return (
|
|
<svg
|
|
width={width}
|
|
height={height}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
className={className}
|
|
role="img"
|
|
aria-label="No data"
|
|
/>
|
|
);
|
|
}
|
|
|
|
const linePoints = buildPoints(data, width, height, strokeWidth);
|
|
const areaPoints = buildAreaPoints(
|
|
linePoints,
|
|
width,
|
|
height,
|
|
strokeWidth,
|
|
data.length,
|
|
);
|
|
|
|
// Approximate path length for dash animation
|
|
const approxLength = width * 2;
|
|
|
|
return (
|
|
<>
|
|
{animated && (
|
|
<style
|
|
dangerouslySetInnerHTML={{
|
|
__html: `
|
|
@keyframes sparkline-draw-${CSS.escape(id)} {
|
|
from { stroke-dashoffset: ${approxLength}; }
|
|
to { stroke-dashoffset: 0; }
|
|
}
|
|
`,
|
|
}}
|
|
/>
|
|
)}
|
|
<svg
|
|
width={width}
|
|
height={height}
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
className={className}
|
|
role="img"
|
|
aria-label={`Sparkline: ${data.join(", ")}`}
|
|
>
|
|
{/* Area fill */}
|
|
{data.length > 1 && (
|
|
<polygon
|
|
points={areaPoints}
|
|
fill={color}
|
|
opacity={fillOpacity}
|
|
/>
|
|
)}
|
|
|
|
{/* Line */}
|
|
{data.length === 1 ? (
|
|
<circle
|
|
cx={width / 2}
|
|
cy={height / 2}
|
|
r={strokeWidth}
|
|
fill={color}
|
|
/>
|
|
) : (
|
|
<polyline
|
|
points={linePoints}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={strokeWidth}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
{...(animated
|
|
? {
|
|
strokeDasharray: approxLength,
|
|
strokeDashoffset: approxLength,
|
|
style: {
|
|
animation: `sparkline-draw-${CSS.escape(id)} 600ms ease-out forwards`,
|
|
},
|
|
}
|
|
: {})}
|
|
/>
|
|
)}
|
|
</svg>
|
|
</>
|
|
);
|
|
}
|