# RadarChart

Source: https://www.pitchkitjs.com/docs/charts/radar-chart

> A football radar — one axis per metric, each on its own range, with translucent shapes for one to three players and axis labels that open your own detail view.

```tsx
import { RadarChart } from "@pitchkit/react";

// Per 90 minutes. Each axis runs from the 5th to the 95th percentile of the
// 168 outfield players with 270+ minutes at the tournament, so the rim is
// "top 5%" — StatsBomb's own radar convention. Derived once from StatsBomb
// open data; the chart computes none of it.
const metrics = [
  { id: "npxg", label: "npxG", min: 0, max: 0.34 },
  { id: "shots", label: "Shots", min: 0, max: 3 },
  { id: "xa", label: "xA", min: 0, max: 0.24 },
  { id: "kp", label: "Key passes", min: 0, max: 2.34 },
  { id: "drib", label: "Dribbles", min: 0, max: 2.25 },
  { id: "to", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true },
  { id: "press", label: "Pressures", min: 4.82, max: 22.2 },
  { id: "ti", label: "Tackles + Int", min: 0.31, max: 2.77 },
];

// Lamine Yamal at Euro 2024, per 90.
const yamal = {
  npxg: 0.28,
  shots: 3.13,
  xa: 0.37,
  kp: 3.13,
  drib: 2.09,
  to: 4.18,
  press: 19.49,
  ti: 1.57,
};

/**
 * One player against the tournament. Turnovers are flipped, so outward is
 * always better; xA is past the 95th percentile, so it is pinned to
 * the rim rather than off the chart.
 */
export function RadarChartBasic() {
  return (
    <RadarChart
      metrics={metrics}
      series={[{ id: "yamal", label: "Lamine Yamal", values: yamal }]}
    />
  );
}
```

`<RadarChart>` is the player radar analysts use to profile a player: one axis per metric, each with
its own range, and a shape joining the player's values. Lower-is-better metrics are flipped so that
outward is always better.

It is a root, not a layer, like the other [charts](/docs/charts): no `<Pitch>`, no `type` prop.

## Usage

```tsx
const metrics = [
  { id: "npxg", label: "npxG", min: 0, max: 0.34 },
  { id: "shots", label: "Shots", min: 0, max: 3 },
  { id: "turnovers", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true },
  // ...at least three
];

<RadarChart metrics={metrics} series={[{ id: "yamal", label: "Lamine Yamal", values: yamal }]} />;
```

* **`metrics`** are the axes, drawn clockwise from the top. Each has an `id`, an optional `label`,
  and a `min` and `max` (0–100 if you leave them out).
* **`series`** are the shapes. `values` is keyed by metric id, so a row from your own data usually
  drops straight in.

### The chart computes nothing

`<RadarChart>` draws the values you give it, on the ranges you give it. Per-90s, percentiles and
the ranges themselves are yours to compute. The only arithmetic is placing each value between its
metric's `min` and `max`.

A common choice for the range is the **5th to 95th percentile** of the comparison population, which
is StatsBomb's convention: a value at the rim is top 5%, and one at the centre is bottom 5%. That is
what the examples here use, derived from every outfield player with 270 or more minutes at Euro 2024.

## Lower is better

```tsx
{ id: "turnovers", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true }
```

The axis runs from `max` at the centre to `min` at the rim, so a careful player's shape still
reaches outward. The label carries a ↓, the ring values count down, and the readout says "lower is
better".

## Out of range and missing values

A value beyond its axis is **pinned to the edge**, so the shape never claims more than the range
shows. The readout still gives the real number, marked "off scale". In the example above, Yamal's
xA is past the 95th percentile.

A missing or non-finite value takes the outline to the centre on that axis, and the readout says
"No data". There are no markers at the vertices: the outline bends at each value, and the readout
gives the numbers.

## Comparing players

```tsx
import { RadarChart } from "@pitchkit/react";

// Per 90 minutes. Each axis runs from the 5th to the 95th percentile of the
// 168 outfield players with 270+ minutes at the tournament, so the rim is
// "top 5%" — StatsBomb's own radar convention. Derived once from StatsBomb
// open data; the chart computes none of it.
const metrics = [
  { id: "npxg", label: "npxG", min: 0, max: 0.34 },
  { id: "shots", label: "Shots", min: 0, max: 3 },
  { id: "xa", label: "xA", min: 0, max: 0.24 },
  { id: "kp", label: "Key passes", min: 0, max: 2.34 },
  { id: "drib", label: "Dribbles", min: 0, max: 2.25 },
  { id: "to", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true },
  { id: "press", label: "Pressures", min: 4.82, max: 22.2 },
  { id: "ti", label: "Tackles + Int", min: 0.31, max: 2.77 },
];

// Euro 2024, per 90.
const players = [
  {
    id: "yamal",
    label: "Yamal",
    values: {
      npxg: 0.28,
      shots: 3.13,
      xa: 0.37,
      kp: 3.13,
      drib: 2.09,
      to: 4.18,
      press: 19.49,
      ti: 1.57,
    },
  },
  {
    id: "williams",
    label: "N. Williams",
    values: {
      npxg: 0.17,
      shots: 2.14,
      xa: 0.22,
      kp: 2.14,
      drib: 2.68,
      to: 3.75,
      press: 18.95,
      ti: 0.36,
    },
  },
  {
    id: "saka",
    label: "Saka",
    values: {
      npxg: 0.07,
      shots: 1.12,
      xa: 0.05,
      kp: 1.12,
      drib: 1.54,
      to: 2.1,
      press: 9.38,
      ti: 0.56,
    },
  },
];

/**
 * Three wingers on one radar. Each takes the next series colour by position,
 * so removing one never repaints the others. Three is the most that stay
 * readable overlaid; past that, draw small multiples.
 */
export function RadarChartCompareBasic() {
  return <RadarChart metrics={metrics} series={players} />;
}
```

Pass more than one series and each gets a light wash and an outline in its own colour, with a
legend. &#x2A;*Three is the most that stay readable.** Every shape overlaps every other, and past three
the colours can't be told apart reliably, especially with colour-blindness. A fourth series still
draws, with a development warning; small multiples are the better answer.

A single series is drawn in two tones of its colour where it crosses the rings, mplsoccer's look.
Turn that off with `appearance={{ bands: false }}`.

## Label rotation

```tsx
import { useState } from "react";
import { RadarChart } from "@pitchkit/react";
import type { LabelRotation } from "@pitchkit/react";

// Per 90 minutes. Each axis runs from the 5th to the 95th percentile of the
// 168 outfield players with 270+ minutes at the tournament, so the rim is
// "top 5%" — StatsBomb's own radar convention. Derived once from StatsBomb
// open data; the chart computes none of it.
const metrics = [
  { id: "npxg", label: "npxG", min: 0, max: 0.34 },
  { id: "shots", label: "Shots", min: 0, max: 3 },
  { id: "xa", label: "xA", min: 0, max: 0.24 },
  { id: "kp", label: "Key passes", min: 0, max: 2.34 },
  { id: "drib", label: "Dribbles", min: 0, max: 2.25 },
  { id: "to", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true },
  { id: "press", label: "Pressures", min: 4.82, max: 22.2 },
  { id: "ti", label: "Tackles + Int", min: 0.31, max: 2.77 },
];

const yamal = {
  npxg: 0.28,
  shots: 3.13,
  xa: 0.37,
  kp: 3.13,
  drib: 2.09,
  to: 4.18,
  press: 19.49,
  ti: 1.57,
};

const rotations: LabelRotation[] = ["tangent", "radial", "horizontal"];

/** The same radar with each label rotation. Tangent is the default. */
export function RadarChartRotationBasic() {
  const [rotation, setRotation] = useState<LabelRotation>("tangent");

  return (
    <div>
      <div className="mb-3 flex gap-2" role="group" aria-label="Label rotation">
        {rotations.map((r) => (
          <button
            key={r}
            type="button"
            aria-pressed={rotation === r}
            onClick={() => setRotation(r)}
            className="rounded-md border border-fd-border px-2.5 py-1 font-mono text-xs aria-pressed:bg-fd-primary/10 aria-pressed:text-fd-primary"
          >
            {r}
          </button>
        ))}
      </div>
      <RadarChart
        metrics={metrics}
        series={[{ id: "yamal", label: "Lamine Yamal", values: yamal }]}
        labelRotation={rotation}
      />
    </div>
  );
}
```

`labelRotation` decides how labels sit round the rim:

| Value                 | Labels                                                            |
| --------------------- | ----------------------------------------------------------------- |
| `"tangent"` (default) | Run along the rim, at right angles to the axis, as mplsoccer does |
| `"radial"`            | Run outward along the axis line; suits long names                 |
| `"horizontal"`        | Stay upright, anchored by side                                    |

Labels that would read upside down are turned 180°, and long labels wrap onto two lines. The ring
values follow the same rotation.

## Click a label to open a detail view

```tsx
import { cropForHalf, getPitchDimensions } from "@pitchkit/core";
import { RadarChart, Scatter, VerticalPitch } from "@pitchkit/react";
import type { RadarMetric } from "@pitchkit/react";

// Per 90 minutes. Each axis runs from the 5th to the 95th percentile of the
// 168 outfield players with 270+ minutes at the tournament, so the rim is
// "top 5%" — StatsBomb's own radar convention. Derived once from StatsBomb
// open data; the chart computes none of it.
const metrics = [
  { id: "npxg", label: "npxG", min: 0, max: 0.34 },
  { id: "shots", label: "Shots", min: 0, max: 3 },
  { id: "xa", label: "xA", min: 0, max: 0.24 },
  { id: "kp", label: "Key passes", min: 0, max: 2.34 },
  { id: "drib", label: "Dribbles", min: 0, max: 2.25 },
  { id: "to", label: "Turnovers", min: 0.04, max: 4.36, lowerIsBetter: true },
  { id: "press", label: "Pressures", min: 4.82, max: 22.2 },
  { id: "ti", label: "Tackles + Int", min: 0.31, max: 2.77 },
];

const yamal = {
  npxg: 0.28,
  shots: 3.13,
  xa: 0.37,
  kp: 3.13,
  drib: 2.09,
  to: 4.18,
  press: 19.49,
  ti: 1.57,
};

// His non-penalty shots (StatsBomb coordinates, 120 x 80).
const shots = [
  { x: 106.5, y: 49.9, xg: 0.048, goal: false },
  { x: 108.6, y: 48.3, xg: 0.282, goal: false },
  { x: 91.3, y: 45.4, xg: 0.042, goal: false },
  { x: 103.5, y: 49.7, xg: 0.162, goal: false },
  { x: 107.2, y: 50.2, xg: 0.164, goal: false },
  { x: 93.2, y: 47.1, xg: 0.027, goal: true },
  { x: 97.7, y: 49.9, xg: 0.024, goal: false },
  { x: 100.4, y: 51.7, xg: 0.054, goal: false },
  { x: 98.9, y: 48.6, xg: 0.084, goal: false },
  { x: 89.1, y: 49.3, xg: 0.027, goal: false },
  { x: 94.3, y: 48.5, xg: 0.031, goal: false },
  { x: 92.7, y: 49.6, xg: 0.016, goal: false },
  { x: 99.6, y: 43.0, xg: 0.084, goal: false },
  { x: 101.0, y: 47.7, xg: 0.076, goal: false },
  { x: 104.9, y: 41.5, xg: 0.076, goal: false },
  { x: 114.8, y: 47.3, xg: 0.286, goal: false },
  { x: 107.6, y: 49.0, xg: 0.082, goal: false },
  { x: 97.3, y: 55.7, xg: 0.022, goal: false },
];

// Raw counts per match, for every other metric's breakdown.
const games = [
  {
    opponent: "Croatia",
    npxg: 0.372,
    shots: 3,
    xa: 0.565,
    kp: 3,
    drib: 2,
    to: 2,
    press: 26,
    ti: 1,
  },
  {
    opponent: "England",
    npxg: 0.326,
    shots: 2,
    xa: 0.382,
    kp: 3,
    drib: 0,
    to: 4,
    press: 19,
    ti: 1,
  },
  {
    opponent: "France",
    npxg: 0.105,
    shots: 3,
    xa: 0.242,
    kp: 2,
    drib: 0,
    to: 4,
    press: 12,
    ti: 1,
  },
  {
    opponent: "Germany",
    npxg: 0.112,
    shots: 2,
    xa: 0.389,
    kp: 3,
    drib: 2,
    to: 7,
    press: 13,
    ti: 3,
  },
  {
    opponent: "Georgia",
    npxg: 0.65,
    shots: 7,
    xa: 0.542,
    kp: 6,
    drib: 3,
    to: 2,
    press: 16,
    ti: 1,
  },
  {
    opponent: "Albania",
    npxg: 0,
    shots: 0,
    xa: 0.029,
    kp: 1,
    drib: 1,
    to: 1,
    press: 9,
    ti: 0,
  },
  {
    opponent: "Italy",
    npxg: 0.022,
    shots: 1,
    xa: 0,
    kp: 0,
    drib: 4,
    to: 4,
    press: 17,
    ti: 2,
  },
];

const half = cropForHalf(getPitchDimensions("statsbomb"));

/** What opens in place of the chart. Anything can go here. */
function Detail({ metric }: { metric: RadarMetric }) {
  if (metric.id === "npxg" || metric.id === "shots") {
    return (
      <VerticalPitch type="statsbomb" crop={half} className="mx-auto max-w-xs">
        <Scatter
          data={shots}
          x={(s) => s.x}
          y={(s) => s.y}
          r={(s) => 3 + s.xg * 12}
          fill={(s) => (s.goal ? "var(--pitch-marker-goal)" : "var(--pitch-marker-primary)")}
          stroke="white"
          tooltip={(s) => `xG ${s.xg.toFixed(2)}${s.goal ? " · goal" : ""}`}
        />
      </VerticalPitch>
    );
  }

  const key = metric.id as keyof (typeof games)[number];
  const most = Math.max(1, ...games.map((g) => Number(g[key])));
  return (
    <ul className="space-y-1.5 text-sm">
      {games.map((g) => (
        <li key={g.opponent} className="grid grid-cols-[6rem_1fr_3rem] items-center gap-2">
          <span className="truncate text-fd-muted-foreground">v {g.opponent}</span>
          <span
            className="h-2 rounded-full bg-fd-primary/70"
            style={{ width: `${(Number(g[key]) / most) * 100}%` }}
          />
          <span className="text-right tabular-nums">
            {Number(g[key]).toFixed(metric.id === "xa" ? 2 : 0)}
          </span>
        </li>
      ))}
    </ul>
  );
}

/** Click an axis label (or focus it and press Enter) to open its breakdown. */
export function RadarChartDetailBasic() {
  return (
    <RadarChart
      metrics={metrics}
      series={[{ id: "yamal", label: "Lamine Yamal", values: yamal }]}
      renderDetail={({ metric }) => <Detail metric={metric} />}
    />
  );
}
```

Pass `renderDetail` and every axis label becomes a button. Activating one replaces the chart with
whatever you return, in the same box, under a header with a Back button:

```tsx
<RadarChart
  metrics={metrics}
  series={series}
  renderDetail={({ metric, values, close }) => <MetricBreakdown metric={metric} values={values} />}
/>
```

`renderDetail` receives the `metric`, every series' value for it in `values` (keyed by series id),
and `close()`. Back, Escape and `close()` all return to the chart, and focus goes back to the label
you opened. Labels are reachable with Tab and open with Enter or Space.

Without `renderDetail`, labels are plain text: nothing looks clickable that isn't.

To keep the selection in the URL, control it:

```tsx
const [selected, setSelected] = useState<RadarSelection | null>(null);

<RadarChart ... renderDetail={...} selected={selected} onSelectedChange={setSelected} />;
```

## Reading values

Hover anywhere on the chart, or focus a label, and the readout shows every series' value on the
nearest axis. On a touch screen a tap does the same and stays up until you tap outside the chart.

## Annotations

Children are drawn over the shapes and positioned with `useRadarChart()`. `pointAt(metricId,
value)` uses the same range and flip as the shapes, so a benchmark lines up exactly:

```tsx
function LeagueAverage({ values }: { values: Record<string, number> }) {
  const { pointAt } = useRadarChart();
  const points = Object.entries(values).flatMap(([id, v]) => pointAt(id, v) ?? []);
  return (
    <polygon points={points.join(" ")} fill="none" stroke="currentColor" strokeDasharray="3 3" />
  );
}
```

It also returns `cx`, `cy`, the `inner` and `outer` radii, and `angleOf(metricId)`.

## Sizing

Responsive by default and square. Passing `width` **and** `height` is the opt-out, and
`aspectRatio` changes the box (the chart stays centred). Below 420px wide the labels shrink a step
and the ring values are hidden, since they collide there; turn them back on with
`appearance={{ rangeLabels: true }}`.

## Theming

CSS variables only.

| Variable                    | What it colours                     |
| --------------------------- | ----------------------------------- |
| `--pitch-series-1` … `-3`   | The shapes, by position in `series` |
| `--pitch-grid`              | The range bands                     |
| `--pitch-axis`              | The spokes                          |
| `--pitch-chart-text`        | Labels and the legend               |
| `--pitch-chart-muted`       | Ring values                         |
| `--pitch-chart-accent`      | The detail view's Back button       |
| `--pitch-chart-accent-text` | The Back button's label             |

Every part of a series paints with `currentColor`, so one class recolours all of it:

```tsx
series={[{ id: "yamal", values: yamal, className: "text-rose-500" }]}
```

A series with a `className` and no `color` drops its themed default, the same rule as every mark
layer. `appearance` is structural only: `rangeLabels`, `legend` and `bands`.

Every element carries a `data-pitchkit-part` for Tailwind arbitrary variants: `radar-band`,
`radar-spoke`, `radar-shape`, `radar-band-tone`, `radar-tick`, `radar-label`,
`radar-legend`, `radar-detail`, `radar-back`. Series groups carry `data-pitchkit-series`, labels
`data-pitchkit-metric`, and the root `data-pitchkit-layer="radar"`.
