Skip to content

Radar

Categories are spread around the circle (angleField), values go along the radius (radiusField). radar-line draws an outline, radar-area an outline with a fill. The grid is polygonal (a "spider web"). On hover, the vertex marker smoothly grows and the other series are dimmed — the same animation as in polar charts.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Product assessment' },
    series: [
      { type: 'radar-area', angleField: 'skill', radiusField: 'target', name: 'Target' },
      {
        type: 'radar-line',
        angleField: 'skill',
        radiusField: 'current',
        name: 'Current',
        tooltip: {
          renderer: ({ label, value, seriesName, color }) => ({
            heading: label,
            rows: [{ label: seriesName, value: `${value} / 10`, color }],
          }),
        },
      },
    ],
  };
}
ts
export function getData() {
  return [
    { skill: 'Speed', current: 7, target: 9 },
    { skill: 'Quality', current: 8, target: 9 },
    { skill: 'Reliability', current: 6, target: 8 },
    { skill: 'Support', current: 5, target: 7 },
    { skill: 'Price', current: 8, target: 8 },
    { skill: 'Documentation', current: 4, target: 8 },
  ];
}

Radar-area

Filled profiles with transparency — handy for comparing two outlines:

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Radar-area: profile comparison' },
    series: [
      { type: 'radar-area', angleField: 'metric', radiusField: 'team', name: 'Us', fillOpacity: 0.3 },
      { type: 'radar-area', angleField: 'metric', radiusField: 'market', name: 'Market', fillOpacity: 0.3 },
    ],
  };
}
ts
export function getData() {
  return [
    { metric: 'Speed', team: 8, market: 6 },
    { metric: 'Quality', team: 7, market: 8 },
    { metric: 'Price', team: 5, market: 7 },
    { metric: 'Support', team: 9, market: 5 },
    { metric: 'Features', team: 6, market: 8 },
    { metric: 'UX', team: 8, market: 6 },
  ];
}

Long category names

The web is sized after the labels around it, not before: the radius shrinks — and the centre shifts, when only one side is crowded — until every category name is inside the chart area. Nothing is cut off at the edge, whatever the aspect ratio of the container.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Long category names' },
    subtitle: { text: 'the grid gives way to the labels instead of cutting them off' },
    series: [{ type: 'radar-area', angleField: 'metric', radiusField: 'score', name: 'Score', fillOpacity: 0.3 }],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { metric: 'Documentation', score: 8 },
    { metric: 'Integrations', score: 6 },
    { metric: 'Reliability', score: 9 },
    { metric: 'Onboarding', score: 5 },
    { metric: 'Performance', score: 7 },
    { metric: 'Support hours', score: 6 },
  ];
}

Many categories

Once the spokes crowd together, labels start colliding — so the ones that would run into their neighbour are dropped. Density around a circle is uneven: labels stack up at the sides, where the spokes are closest in y, and spread out at the top and bottom, where the text runs sideways. Rather than thin by a fixed step that would have to obey the worst spot, each label is kept if it has room — so the sides stay dense and the top thins out.

The spokes and the rings are all drawn either way: the grid stays whole, only the labels give way. Values on the rings yield to the category names, so the two never overlap at twelve o'clock.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Signups by week' },
    subtitle: { text: 'every spoke is drawn, only the labels with room to spare' },
    series: [{ type: 'radar-area', angleField: 'week', radiusField: 'signups', name: 'Signups', fillOpacity: 0.25 }],
    legend: { enabled: false },
  };
}
ts
/** Weekly signups over a year: a seasonal swell with a summer dip. */
export function getData() {
  return Array.from({ length: 52 }, (_, index) => {
    const week = index + 1;
    const seasonal = 60 + 28 * Math.sin((index / 52) * Math.PI * 2 - 0.6);
    const ripple = 6 * Math.sin(index / 3);
    return { week: `W${String(week).padStart(2, '0')}`, signups: Math.round(seasonal + ripple) };
  });
}

The web and its outlines

The lines around the web and the lines inside it do different jobs, so they take different settings. The rim (axes.angle.line) and the vertical the values are read along (axes.radius.line) are outlines: solid, in the axis colour, each with its own stroke, width and dash. The spokes (axes.angle.gridLine) and the rings (axes.radius.gridLine) are chrome: dashed and faint, as a cartesian grid is, and following the same theme token. Where a ring would land on the rim, the rim keeps it — one circle, one stroke.

The value scale is labelled from the centre out, the centre included: that is where the scale starts, and a web whose middle carries no number is a web with nothing to read the first ring against.

js
axes: {
  angle: { line: { stroke: '#64748b', width: 1.5 }, gridLine: { opacity: 0.45 } },
  radius: { max: 10, ringCount: 5, line: { stroke: '#94a3b8', width: 1.5 } },
},
ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Cupping profile' },
    subtitle: { text: 'the rim is an outline, the web behind it is chrome' },
    series: [
      { type: 'radar-area', angleField: 'trait', radiusField: 'roast', name: 'Espresso roast', fillOpacity: 0.2 },
      { type: 'radar-line', angleField: 'trait', radiusField: 'filter', name: 'Filter roast' },
    ],
    axes: {
      angle: {
        // the outline of the web: its own stroke, its own width, solid
        line: { stroke: '#64748b', width: 1.5 },
        // the spokes are grid: dashed by the theme, and here a shade fainter
        gridLine: { opacity: 0.45 },
      },
      radius: {
        max: 10,
        ringCount: 5,
        // the vertical the ring values are read along — an outline of its own
        line: { stroke: '#94a3b8', width: 1.5 },
        label: { fontWeight: 'bold' },
      },
    },
  };
}
ts
export function getData() {
  return [
    { trait: 'Aroma', roast: 8, filter: 6 },
    { trait: 'Acidity', roast: 4, filter: 9 },
    { trait: 'Body', roast: 9, filter: 5 },
    { trait: 'Sweetness', roast: 7, filter: 8 },
    { trait: 'Bitterness', roast: 8, filter: 3 },
    { trait: 'Aftertaste', roast: 6, filter: 7 },
  ];
}

Options

Options common to all series (name, showInLegend, tooltip.renderer, …) are covered in Common series options.

OptionTypeDefaultDescription
angleFieldstringdata keys
radiusFieldstringdata keys
namestringradiusFieldseries name
strokeColorValuepaletteoutline
strokeWidthPixels2outline
fillOpacity (radar-area)Fraction0.25fill opacity
tooltip.renderer({ datum, label, value, seriesName, color }) => …custom tooltip content
marker.enabledbooleantruevertex markers
marker.shapeMarkerShapecircleshape
marker.sizePixels6size

Value labels

label.enabled prints the value on each vertex. placement defaults to outward, which pushes the label away from the centre along its own spoke — where the web is empty whichever way the vertex leans; top, bottom, left, right and inside behave as they do on a line.

ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Value labels on the vertices' },
    series: [
      { type: 'radar-area', angleField: 'skill', radiusField: 'target', name: 'Target' },
      {
        type: 'radar-line',
        angleField: 'skill',
        radiusField: 'current',
        name: 'Current',
        label: { enabled: true, fontWeight: 'bold' },
      },
    ],
  };
}
ts
export function getData() {
  return [
    { skill: 'Speed', current: 7, target: 9 },
    { skill: 'Quality', current: 8, target: 9 },
    { skill: 'Reliability', current: 6, target: 8 },
    { skill: 'Support', current: 5, target: 7 },
    { skill: 'Price', current: 8, target: 8 },
    { skill: 'Documentation', current: 4, target: 8 },
  ];
}
OptionTypeDefaultDescription
label.enabledbooleanfalseshow value labels
label.placement'outward' | 'top' | 'bottom' | 'left' | 'right' | 'inside''outward'where the label sits
label.formatter({ value, datum }) => stringthe valuelabel content
label.fontSizePixels11font size
label.fontWeightstring | numbernormalfont weight
label.fontFamilystringtheme fonttypeface
label.colorColorValueforeground; inside — auto-contrasttext colour

The rings, the spokes and the numbers beside them belong to the axes rather than to the series — see Polar axes.

A radar usually carries a measure per series, so tooltip.mode: 'shared' reads the same here as on a line chart: the spoke under the cursor answers for every series at once, each one picked out on its vertex and none of them dimmed. See Tooltip.