wui
图表

雷达图 Radar Chart

用于在极坐标系上展示多维度能力评估、多系列性能对比或平衡指标的图表。

基础示例 · Basic example

Loading…
pnpm dlx @wui-design/cli@latest add @wui/radar-chart
charts/radar-chart.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import {
  ChartHeader,
  ChartTooltip,
  chartColors,
  defaultValueFormatter,
  useChartSize,
  type ChartDatum,
  type ChartSeries,
} from "./chart-core"

export interface RadarChartProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "title"> {
  /** 图表标题。 */
  title: React.ReactNode
  /** 补充说明或副标题。 */
  description?: React.ReactNode
  /** 标题右侧操作区。 */
  actions?: React.ReactNode
  /** 图表数据列表,每项代表一个雷达维度。 */
  data: ChartDatum[]
  /** 维度名称字段。 */
  nameKey: string
  /** 需要绘制的数据系列列表。 */
  series: ChartSeries[]
  /** 雷达轴的最大刻度值;未传时根据数据自动推导。 */
  maxValue?: number
  /** 雷达网格形状(多边形或同心圆)。 @default "polygon" */
  gridShape?: "polygon" | "circle"
  /** 同心网格层数。 @default 4 */
  gridLevels?: number
  /** 绘图区高度(像素)。 @default 340 */
  height?: number
  /** 是否展示图例。 @default true */
  showLegend?: boolean
  /** 是否展示各维度标签。 @default true */
  showLabels?: boolean
  /** 数值格式化函数。 */
  valueFormatter?: (value: number, series: ChartSeries, datum: ChartDatum) => React.ReactNode
}

function polarToCartesian(cx: number, cy: number, radius: number, angle: number) {
  return {
    x: cx + radius * Math.cos(angle - Math.PI / 2),
    y: cy + radius * Math.sin(angle - Math.PI / 2),
  }
}

/** 多维能力与指标对比雷达图。 */
function RadarChart({
  title,
  description,
  actions,
  data,
  nameKey,
  series,
  maxValue: controlledMax,
  gridShape = "polygon",
  gridLevels = 4,
  height = 340,
  showLegend = true,
  showLabels = true,
  valueFormatter = defaultValueFormatter,
  className,
  ...props
}: RadarChartProps) {
  const { ref, width } = useChartSize()
  const [activeDatum, setActiveDatum] = React.useState<{
    index: number
    datum: ChartDatum
    x: number
    y: number
  } | null>(null)

  const dimensionsCount = data.length
  const angleStep = dimensionsCount > 0 ? (2 * Math.PI) / dimensionsCount : 0

  const resolvedMax = React.useMemo(() => {
    if (controlledMax !== undefined) return controlledMax
    let max = 0
    for (const item of data) {
      for (const s of series) {
        const val = Number(item[s.key]) || 0
        if (val > max) max = val
      }
    }
    return max > 0 ? Math.ceil(max * 1.1) : 100
  }, [controlledMax, data, series])

  const margin = 40
  const cx = width / 2
  const cy = height / 2
  const maxRadius = Math.max(20, Math.min(cx - margin, cy - margin))

  const seriesWithColors = React.useMemo(() => {
    return series.map((s, idx) => ({
      ...s,
      color: s.color ?? chartColors[idx % chartColors.length],
    }))
  }, [series])

  const id = React.useId()
  const titleId = `${id}-title`
  const descriptionId = `${id}-description`

  return (
    <div
      ref={ref}
      data-slot="radar-chart"
      className={cn("flex flex-col gap-3 rounded-xl border border-border bg-card p-4", className)}
      {...props}
    >
      <ChartHeader
        title={title}
        description={description}
        actions={actions}
        titleId={titleId}
        descriptionId={descriptionId}
      />

      <div className="relative flex justify-center" style={{ height }}>
        <svg width={width} height={height} className="overflow-visible select-none">
          {/* Grid Rings */}
          {Array.from({ length: gridLevels }).map((_, levelIdx) => {
            const levelRadius = (maxRadius / gridLevels) * (levelIdx + 1)
            if (gridShape === "circle") {
              return (
                <circle
                  key={levelIdx}
                  cx={cx}
                  cy={cy}
                  r={levelRadius}
                  fill="none"
                  stroke="var(--border)"
                  strokeDasharray="3 3"
                  strokeOpacity="0.7"
                />
              )
            }

            const points = Array.from({ length: dimensionsCount }).map((_, dIdx) => {
              const pt = polarToCartesian(cx, cy, levelRadius, dIdx * angleStep)
              return `${pt.x},${pt.y}`
            })

            return (
              <polygon
                key={levelIdx}
                points={points.join(" ")}
                fill="none"
                stroke="var(--border)"
                strokeOpacity="0.7"
              />
            )
          })}

          {/* Axes */}
          {Array.from({ length: dimensionsCount }).map((_, dIdx) => {
            const pt = polarToCartesian(cx, cy, maxRadius, dIdx * angleStep)
            const labelPt = polarToCartesian(cx, cy, maxRadius + 18, dIdx * angleStep)
            const datum = data[dIdx]

            return (
              <g key={dIdx}>
                <line
                  x1={cx}
                  y1={cy}
                  x2={pt.x}
                  y2={pt.y}
                  stroke="var(--border)"
                  strokeOpacity="0.6"
                />
                {showLabels && datum && (
                  <text
                    x={labelPt.x}
                    y={labelPt.y}
                    textAnchor="middle"
                    dominantBaseline="central"
                    className="fill-muted-foreground text-[11px] font-medium"
                  >
                    {String(datum[nameKey])}
                  </text>
                )}
              </g>
            )
          })}

          {/* Series Polygons */}
          {seriesWithColors.map((s) => {
            const polygonPoints = data.map((datum, dIdx) => {
              const val = Math.max(0, Number(datum[s.key]) || 0)
              const r = (val / resolvedMax) * maxRadius
              const pt = polarToCartesian(cx, cy, r, dIdx * angleStep)
              return `${pt.x},${pt.y}`
            })

            return (
              <g key={s.key} className="transition-opacity">
                <polygon
                  points={polygonPoints.join(" ")}
                  fill={s.color}
                  fillOpacity="0.25"
                  stroke={s.color}
                  strokeWidth="2"
                  className="transition-all duration-300"
                />
                {data.map((datum, dIdx) => {
                  const val = Math.max(0, Number(datum[s.key]) || 0)
                  const r = (val / resolvedMax) * maxRadius
                  const pt = polarToCartesian(cx, cy, r, dIdx * angleStep)
                  return (
                    <circle
                      key={dIdx}
                      cx={pt.x}
                      cy={pt.y}
                      r="4"
                      fill={s.color}
                      className="cursor-pointer transition-transform hover:scale-125"
                      onMouseEnter={() =>
                        setActiveDatum({
                          index: dIdx,
                          datum,
                          x: pt.x,
                          y: pt.y,
                        })
                      }
                      onMouseLeave={() => setActiveDatum(null)}
                    />
                  )
                })}
              </g>
            )
          })}
        </svg>

        <ChartTooltip
          active={activeDatum !== null}
          x={activeDatum?.x ?? 0}
          y={activeDatum?.y ?? 0}
          width={width}
          label={activeDatum ? String(activeDatum.datum[nameKey]) : ""}
          rows={
            activeDatum
              ? seriesWithColors.map((s) => ({
                  key: s.key,
                  label: s.label ?? s.key,
                  value: valueFormatter(
                    Number(activeDatum.datum[s.key]) || 0,
                    s,
                    activeDatum.datum
                  ),
                  color: s.color,
                }))
              : []
          }
        />
      </div>

      {showLegend && (
        <div className="flex flex-wrap items-center justify-center gap-4 pt-1">
          {seriesWithColors.map((s) => (
            <div key={s.key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
              <span
                className="size-2.5 rounded-full"
                style={{ backgroundColor: s.color }}
              />
              <span>{s.label ?? s.key}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

export { RadarChart }
export type { ChartDatum, ChartSeries }

组件作用 · What it's for

RadarChart 将多个维度的定量数据投射在从中心向外辐射的轴线上,形成多边形区域,便于一眼洞察多项指标的强弱与重叠分布。

适用场景

  • 模型基准测试 (Benchmark):对比不同大模型在代码、逻辑、数学、长文等维度的得分。
  • 员工或候选人技能画像:多维能力分布与岗位要求匹配度。
  • 产品综合指标对比:性能、安全性、稳定性、易用性等多维度量。

何时不建议使用

  • 如果只有一个维度或连续的时间序列变化,应使用柱状图或折线图。

组件属性 · Props

属性类型默认值说明
title *ReactNode—图表标题。
descriptionReactNode—补充说明或副标题。
actionsReactNode—标题右侧操作区。
data *ChartDatum[]—图表数据列表,每项代表一个雷达维度。
nameKey *string—维度名称字段。
series *ChartSeries[]—需要绘制的数据系列列表。
maxValuenumber—雷达轴的最大刻度值;未传时根据数据自动推导。
gridShape"circle" | "polygon"polygon雷达网格形状(多边形或同心圆)。
gridLevelsnumber4同心网格层数。
heightnumber340绘图区高度(像素)。
showLegendbooleantrue是否展示图例。
showLabelsbooleantrue是否展示各维度标签。
valueFormatter((value: number, series: ChartSeries, datum: ChartDatum) => ReactNode)—数值格式化函数。

属性名后的 * 表示必填。

事件 · Events

  • 鼠标悬停在各顶点时自动触发 ChartTooltip 显示各系列的准确数值。

拓展使用 · Extended usage

可通过 gridShape="circle" 切换为圆形同心网格,或通过 gridLevels 控制刻度圈数。