220 lines
6.7 KiB
TypeScript
220 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import { motion } from "framer-motion";
|
|
import { useEffect, useState } from "react";
|
|
|
|
interface RadarChartProps {
|
|
data: {
|
|
label: string;
|
|
score: number;
|
|
maxScore?: number;
|
|
}[];
|
|
size?: number;
|
|
}
|
|
|
|
export default function RadarChart({ data, size = 300 }: RadarChartProps) {
|
|
const [isAnimated, setIsAnimated] = useState(false);
|
|
const center = size / 2;
|
|
const radius = (size / 2) - 60; // Increased padding for labels
|
|
const angleStep = (Math.PI * 2) / data.length;
|
|
|
|
useEffect(() => {
|
|
setIsAnimated(true);
|
|
}, []);
|
|
|
|
// Calculate points for the polygon
|
|
const getPoint = (value: number, index: number) => {
|
|
const angle = index * angleStep - Math.PI / 2;
|
|
const r = (value / 100) * radius;
|
|
return {
|
|
x: center + r * Math.cos(angle),
|
|
y: center + r * Math.sin(angle)
|
|
};
|
|
};
|
|
|
|
// Create polygon points string
|
|
const polygonPoints = data
|
|
.map((item, i) => {
|
|
const point = getPoint(isAnimated ? item.score : 0, i);
|
|
return `${point.x},${point.y}`;
|
|
})
|
|
.join(' ');
|
|
|
|
// Grid levels
|
|
const gridLevels = [20, 40, 60, 80, 100];
|
|
|
|
return (
|
|
<div className="relative">
|
|
<svg width={size} height={size} className="overflow-visible">
|
|
<defs>
|
|
<linearGradient id="radar-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" stopColor="#FF4A00" stopOpacity="0.8" />
|
|
<stop offset="100%" stopColor="#FF8533" stopOpacity="0.3" />
|
|
</linearGradient>
|
|
<filter id="radar-glow">
|
|
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
|
<feMerge>
|
|
<feMergeNode in="coloredBlur"/>
|
|
<feMergeNode in="SourceGraphic"/>
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
{/* Grid circles */}
|
|
{gridLevels.map((level) => (
|
|
<circle
|
|
key={level}
|
|
cx={center}
|
|
cy={center}
|
|
r={(level / 100) * radius}
|
|
fill="none"
|
|
stroke="rgba(0,0,0,0.05)"
|
|
strokeWidth="1"
|
|
/>
|
|
))}
|
|
|
|
{/* Axis lines */}
|
|
{data.map((_, i) => {
|
|
const angle = i * angleStep - Math.PI / 2;
|
|
const x2 = center + radius * Math.cos(angle);
|
|
const y2 = center + radius * Math.sin(angle);
|
|
return (
|
|
<line
|
|
key={i}
|
|
x1={center}
|
|
y1={center}
|
|
x2={x2}
|
|
y2={y2}
|
|
stroke="rgba(0,0,0,0.05)"
|
|
strokeWidth="1"
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Data polygon */}
|
|
<motion.polygon
|
|
points={polygonPoints}
|
|
fill="url(#radar-gradient)"
|
|
stroke="#FF4A00"
|
|
strokeWidth="2"
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ duration: 0.8, ease: "easeOut" }}
|
|
filter="url(#radar-glow)"
|
|
/>
|
|
|
|
{/* Data points */}
|
|
{data.map((item, i) => {
|
|
const point = getPoint(item.score, i);
|
|
return (
|
|
<motion.circle
|
|
key={i}
|
|
cx={point.x}
|
|
cy={point.y}
|
|
r="4"
|
|
fill="#FF4A00"
|
|
stroke="white"
|
|
strokeWidth="2"
|
|
initial={{ scale: 0 }}
|
|
animate={{ scale: isAnimated ? 1 : 0 }}
|
|
transition={{ delay: 0.8 + i * 0.1, duration: 0.3 }}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Labels */}
|
|
{data.map((item, i) => {
|
|
const angle = i * angleStep - Math.PI / 2;
|
|
const labelRadius = radius + 40; // Increased label distance
|
|
const x = center + labelRadius * Math.cos(angle);
|
|
const y = center + labelRadius * Math.sin(angle);
|
|
|
|
// Better text anchor logic based on quadrant
|
|
let textAnchor = "middle";
|
|
let dy = 0;
|
|
|
|
// Left side
|
|
if (x < center - 20) {
|
|
textAnchor = "end";
|
|
}
|
|
// Right side
|
|
else if (x > center + 20) {
|
|
textAnchor = "start";
|
|
}
|
|
|
|
// Top
|
|
if (y < center - 20) {
|
|
dy = -5;
|
|
}
|
|
// Bottom
|
|
else if (y > center + 20) {
|
|
dy = 5;
|
|
}
|
|
|
|
return (
|
|
<motion.g key={i}>
|
|
{/* Background for better readability */}
|
|
<motion.rect
|
|
x={x - (textAnchor === "middle" ? 30 : textAnchor === "end" ? 60 : 0)}
|
|
y={y - 10}
|
|
width={60}
|
|
height={20}
|
|
fill="white"
|
|
fillOpacity={0.9}
|
|
rx={4}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.9 + i * 0.05 }}
|
|
/>
|
|
<motion.text
|
|
x={x}
|
|
y={y + dy}
|
|
textAnchor={textAnchor as any}
|
|
dominantBaseline="middle"
|
|
className="text-xs fill-black-alpha-80 font-medium"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 1 + i * 0.05 }}
|
|
style={{ pointerEvents: 'none' }}
|
|
>
|
|
{item.label}
|
|
</motion.text>
|
|
{/* Score value */}
|
|
<motion.text
|
|
x={x}
|
|
y={y + dy + 12}
|
|
textAnchor={textAnchor as any}
|
|
dominantBaseline="middle"
|
|
className="text-[10px] fill-heat-100 font-bold"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 1.1 + i * 0.05 }}
|
|
style={{ pointerEvents: 'none' }}
|
|
>
|
|
{item.score}%
|
|
</motion.text>
|
|
</motion.g>
|
|
);
|
|
})}
|
|
</svg>
|
|
|
|
{/* Legend */}
|
|
<div className="mt-16 flex justify-center">
|
|
<div className="inline-flex flex-row gap-16 text-xs text-black-alpha-48 bg-white px-16 py-8 rounded-6 shadow-sm">
|
|
<div className="flex items-center gap-8">
|
|
<div className="w-12 h-12 rounded-full bg-heat-200" />
|
|
<span className="whitespace-nowrap">80-100%</span>
|
|
</div>
|
|
<div className="flex items-center gap-8">
|
|
<div className="w-12 h-12 rounded-full bg-heat-100" />
|
|
<span className="whitespace-nowrap">60-79%</span>
|
|
</div>
|
|
<div className="flex items-center gap-8">
|
|
<div className="w-12 h-12 rounded-full bg-heat-50" />
|
|
<span className="whitespace-nowrap"><60%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |