← back to ClawCoder
src/components/ui/radar-chart.tsx
138 lines
'use client'
interface CriterionData {
name: string
score: number
maxScore: number
}
interface RadarChartProps {
criteria: CriterionData[]
}
export function RadarChart({ criteria }: RadarChartProps) {
const size = 300
const center = size / 2
const radius = size / 2 - 40
const n = criteria.length
if (n === 0) return null
// Calculate angle for each axis (starting from top, going clockwise)
const angleStep = (2 * Math.PI) / n
const startAngle = -Math.PI / 2
// Get point on the circle for a given axis index and normalized value (0-1)
function getPoint(index: number, value: number): { x: number; y: number } {
const angle = startAngle + index * angleStep
return {
x: center + radius * value * Math.cos(angle),
y: center + radius * value * Math.sin(angle),
}
}
// Build the polygon path for the data
const dataPoints = criteria.map((c, i) => {
const normalized = c.maxScore > 0 ? c.score / c.maxScore : 0
return getPoint(i, normalized)
})
const dataPath =
dataPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ') + ' Z'
// Grid rings at 25%, 50%, 75%, 100%
const rings = [0.25, 0.5, 0.75, 1.0]
// Build label description for screen readers
const ariaDescription = criteria
.map((c) => `${c.name}: ${c.score}/${c.maxScore}`)
.join(', ')
return (
<div className="flex items-center justify-center" role="img" aria-label={`Radar chart showing scores: ${ariaDescription}`}>
<svg
viewBox={`0 0 ${size} ${size}`}
className="h-auto w-full max-w-[300px]"
>
{/* Grid rings */}
{rings.map((r) => {
const ringPoints = Array.from({ length: n }, (_, i) => getPoint(i, r))
const ringPath =
ringPoints
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
.join(' ') + ' Z'
return (
<path
key={r}
d={ringPath}
fill="none"
stroke="#e2e8f0"
strokeWidth={1}
/>
)
})}
{/* Axis lines */}
{criteria.map((_, i) => {
const outerPoint = getPoint(i, 1)
return (
<line
key={`axis-${i}`}
x1={center}
y1={center}
x2={outerPoint.x}
y2={outerPoint.y}
stroke="#e2e8f0"
strokeWidth={1}
/>
)
})}
{/* Data polygon */}
<path
d={dataPath}
fill="rgba(37, 99, 235, 0.2)"
stroke="#2563eb"
strokeWidth={2}
/>
{/* Data points */}
{dataPoints.map((p, i) => (
<circle
key={`point-${i}`}
cx={p.x}
cy={p.y}
r={3}
fill="#2563eb"
/>
))}
{/* Labels */}
{criteria.map((c, i) => {
const labelPoint = getPoint(i, 1.25)
const angle = startAngle + i * angleStep
// Determine text-anchor based on position
let textAnchor: 'start' | 'middle' | 'end' = 'middle'
if (Math.cos(angle) > 0.1) textAnchor = 'start'
else if (Math.cos(angle) < -0.1) textAnchor = 'end'
// Truncate long labels
const label = c.name.length > 14 ? c.name.slice(0, 12) + '...' : c.name
return (
<text
key={`label-${i}`}
x={labelPoint.x}
y={labelPoint.y}
textAnchor={textAnchor}
dominantBaseline="central"
className="fill-slate-600 text-[9px]"
>
{label}
</text>
)
})}
</svg>
</div>
)
}