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.
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 }],
}),
},
},
],
};
}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:
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 },
],
};
}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.
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 },
};
}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.
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 },
};
}/** 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.
axes: {
angle: { line: { stroke: '#64748b', width: 1.5 }, gridLine: { opacity: 0.45 } },
radius: { max: 10, ringCount: 5, line: { stroke: '#94a3b8', width: 1.5 } },
},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' },
},
},
};
}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.
| Option | Type | Default | Description |
|---|---|---|---|
angleField | string | — | data keys |
radiusField | string | — | data keys |
name | string | radiusField | series name |
stroke | ColorValue | palette | outline |
strokeWidth | Pixels | 2 | outline |
fillOpacity (radar-area) | Fraction | 0.25 | fill opacity |
tooltip.renderer | ({ datum, label, value, seriesName, color }) => … | — | custom tooltip content |
marker.enabled | boolean | true | vertex markers |
marker.shape | MarkerShape | circle | shape |
marker.size | Pixels | 6 | size |
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.
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' },
},
],
};
}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 },
];
}| Option | Type | Default | Description |
|---|---|---|---|
label.enabled | boolean | false | show value labels |
label.placement | 'outward' | 'top' | 'bottom' | 'left' | 'right' | 'inside' | 'outward' | where the label sits |
label.formatter | ({ value, datum }) => string | the value | label content |
label.fontSize | Pixels | 11 | font size |
label.fontWeight | string | number | normal | font weight |
label.fontFamily | string | theme font | typeface |
label.color | ColorValue | foreground; inside — auto-contrast | text 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.