93 lines
2.4 KiB
TypeScript
93 lines
2.4 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
||
import { motion } from 'framer-motion';
|
||
|
||
/**
|
||
* QDRing
|
||
* Animated SVG arc representing the Qualification Desire score (0–100).
|
||
* Color: green (≥70), amber (40–69), red (<40).
|
||
* Animates from 0 to score on mount; smooth spring on value change.
|
||
*/
|
||
interface QDRingProps {
|
||
score: number;
|
||
size?: number; // diameter in px (default 64)
|
||
strokeWidth?: number;
|
||
color?: string; // overrides semantic color if provided
|
||
showLabel?: boolean;
|
||
}
|
||
|
||
export function QDRing({
|
||
score,
|
||
size = 64,
|
||
strokeWidth = 4,
|
||
color,
|
||
showLabel = false,
|
||
}: QDRingProps) {
|
||
const clampedScore = Math.max(0, Math.min(100, score));
|
||
|
||
const r = (size - strokeWidth) / 2;
|
||
const cx = size / 2;
|
||
const cy = size / 2;
|
||
const circumference = 2 * Math.PI * r;
|
||
|
||
// Arc: 0% = full stroke-dashoffset (hidden), 100% = 0 offset (full)
|
||
const offset = circumference * (1 - clampedScore / 100);
|
||
|
||
// Semantic color
|
||
const arcColor = color ?? (
|
||
clampedScore >= 70 ? 'var(--color-green)' :
|
||
clampedScore >= 40 ? 'var(--color-amber)' :
|
||
'var(--color-red)'
|
||
);
|
||
|
||
return (
|
||
<svg
|
||
width={size}
|
||
height={size}
|
||
viewBox={`0 0 ${size} ${size}`}
|
||
role="img"
|
||
aria-label={`QD Score: ${clampedScore}`}
|
||
style={{ transform: 'rotate(-90deg)' }} // Start arc at top
|
||
>
|
||
{/* Track */}
|
||
<circle
|
||
cx={cx} cy={cy} r={r}
|
||
fill="none"
|
||
stroke="rgba(255,255,255,0.08)"
|
||
strokeWidth={strokeWidth}
|
||
/>
|
||
|
||
{/* Animated arc */}
|
||
<motion.circle
|
||
cx={cx} cy={cy} r={r}
|
||
fill="none"
|
||
stroke={arcColor}
|
||
strokeWidth={strokeWidth}
|
||
strokeLinecap="round"
|
||
strokeDasharray={circumference}
|
||
initial={{ strokeDashoffset: circumference }}
|
||
animate={{ strokeDashoffset: offset }}
|
||
transition={{ duration: 0.8, ease: [0.4, 0, 0.2, 1] }}
|
||
style={{
|
||
filter: `drop-shadow(0 0 4px ${arcColor})`,
|
||
}}
|
||
/>
|
||
|
||
{/* Optional center label (shown rotated back) */}
|
||
{showLabel && (
|
||
<text
|
||
x={cx} y={cy}
|
||
textAnchor="middle"
|
||
dominantBaseline="middle"
|
||
fill={arcColor}
|
||
fontSize={size * 0.22}
|
||
fontWeight="700"
|
||
fontFamily="var(--font-sans)"
|
||
style={{ transform: `rotate(90deg)`, transformOrigin: `${cx}px ${cy}px` }}
|
||
>
|
||
{clampedScore}
|
||
</text>
|
||
)}
|
||
</svg>
|
||
);
|
||
}
|