← 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>
  )
}