Skip to content

Pie and Donut

Polar share series. angleField sets the sector value, labelField the name (legend, sector labels, tooltip).

A sector label is one label made of two parts — the name and the value — each with its own font, colour and format. label.placement decides where the whole of it goes: 'outside' on a callout line, 'inside' the sector. Inside labels are haloed in the sector colour and take an automatic contrast colour.

The name comes out of labelField, and that field has a format like any other. labelName says how its value becomes text, once for the whole series — the legend, the tooltip heading and the name half of the label all read the same. It takes format (a serializable string) and formatter (a function, for what a string cannot express):

js
labelName: { format: '%d.%m.%Y' },

Where a label wants something shorter than the legend, label.category has a format of its own and overrides labelName; it answers for the name exactly as value.format/value.formatter answer for the number:

js
label: { category: { format: '%d.%m' }, value: { enabled: true } },
ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Browser share' },
    series: [
      {
        type: 'pie',
        angleField: 'share',
        angleName: 'Share, %',
        labelField: 'browser',
        // name and share read as one label, the share in its own smaller line
        label: { value: { enabled: true } },
      },
    ],
  };
}
ts
export function getData() {
  return [
    { browser: 'Chrome', share: 64 },
    { browser: 'Safari', share: 19 },
    { browser: 'Edge', share: 6 },
    { browser: 'Firefox', share: 5 },
    { browser: 'Other', share: 6 },
  ];
}

Spacing and corner rounding for pie

The same sectorSpacing and cornerRadius also work without a ring:

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Pie with spacing and rounded corners' },
    series: [
      {
        type: 'pie',
        angleField: 'share',
        angleName: 'Share, %',
        labelField: 'device',
        sectorSpacing: 3,
        cornerRadius: 7,
        // one-line label: the name, the separator and the value in a row
        label: { layout: 'inline', value: { enabled: true, type: 'value', format: '.0f' } },
      },
    ],
  };
}
ts
export function getData() {
  return [
    { device: 'Smartphones', share: 48 },
    { device: 'Laptops', share: 24 },
    { device: 'Tablets', share: 14 },
    { device: 'Desktops', share: 9 },
    { device: 'Other', share: 5 },
  ];
}

Donut

innerRadiusRatio creates a ring; innerLabels adds text in the center.

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Order statuses' },
    series: [
      {
        type: 'donut',
        angleField: 'count',
        angleName: 'Orders',
        labelField: 'status',
        innerRadiusRatio: 0.62,
        // callout lines: length and style of each segment are configured separately
        calloutLine: {
          radial: { length: 10, strokeWidth: 1 },
          horizontal: { length: 14, strokeWidth: 1 },
        },
        innerLabels: [
          { text: '2193', fontSize: 22, fontWeight: 'bold' },
          { text: 'orders', fontSize: 12 },
        ],
      },
    ],
    legend: { position: 'right' },
  };
}
ts
export function getData() {
  return [
    { status: 'Completed', count: 1840 },
    { status: 'Cancelled', count: 215 },
    { status: 'Refunded', count: 96 },
    { status: 'Failed', count: 42 },
  ];
}

Rotation, colors, labels inside the sectors

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Rotation and sector labels' },
    subtitle: { text: 'rotation: -90, labels inside the sectors' },
    series: [
      {
        type: 'pie',
        angleField: 'amount',
        angleName: 'Share, %',
        labelField: 'source',
        rotation: -90,
        fills: ['#1d4fd7', '#27c08d', '#f4a236', '#9a7bff'],
        // the whole label sits in the sector: the name above, the share under it
        label: {
          placement: 'inside',
          category: { fontWeight: 'bold' },
          value: { enabled: true },
        },
      },
    ],
    legend: { position: 'right' },
  };
}
ts
export function getData() {
  return [
    { source: 'Organization', amount: 46 },
    { source: 'Grants', amount: 28 },
    { source: 'Partners', amount: 16 },
    { source: 'Other', amount: 10 },
  ];
}

Custom tooltip

The series tooltip.renderer receives the whole datum, so the tooltip can display any fields:

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Custom sector tooltip' },
    series: [
      {
        type: 'donut',
        angleField: 'visits',
        labelField: 'channel',
        innerRadiusRatio: 0.55,
        tooltip: {
          renderer: ({ datum, color }) => ({
            heading: String(datum.channel),
            rows: [
              { label: 'Visits', value: String(datum.visits), color },
              { label: 'Conversion', value: `${datum.conversion}%` },
            ],
          }),
        },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { channel: 'Organic', visits: 18200, conversion: 4.1 },
    { channel: 'Ads', visits: 9400, conversion: 2.6 },
    { channel: 'Social', visits: 6100, conversion: 1.9 },
    { channel: 'Email', visits: 2800, conversion: 6.4 },
  ];
}

Spacing and corner rounding

sectorSpacing is the gap between sectors (px), cornerRadius rounds the corners:

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

const money = new Intl.NumberFormat('en-US');

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Sector spacing and corner radius' },
    series: [
      {
        type: 'donut',
        angleField: 'amount',
        labelField: 'fund',
        innerRadiusRatio: 0.55,
        sectorSpacing: 6,
        cornerRadius: 8,
        label: { placement: 'outside' },
        calloutLine: {
          radial: { length: 12, strokeWidth: 1 },
          horizontal: { length: 16, stroke: '#9aa1ad', strokeWidth: 1 },
        },
        tooltip: {
          renderer: ({ label, value, color }) => ({
            heading: label,
            rows: [{ label: 'Amount', value: money.format(Number(value)), color }],
          }),
        },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { fund: 'Stocks', amount: 32129675 },
    { fund: 'Bonds', amount: 9196461 },
    { fund: 'Funds', amount: 5248310 },
    { fund: 'Deposits', amount: 3933726 },
    { fund: 'Currency', amount: 3354246 },
  ];
}

A long tail of small sectors

A narrow sector is drawn however narrow it gets: the gap sectorSpacing asks for gives way rather than eating the sector it was meant to separate.

Every sector gets a label too, whatever its size — by default the crowded ones simply overlap. Two options thin them out, and they answer different questions.

label.minShare decides which sectors are worth a label at all: below that share of the total a sector is drawn but left unlabelled. This is what makes a long tail readable — the numbers that carry the chart get their callout, the slivers stay in the ring and in the tooltip.

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Labels for what carries the chart' },
    subtitle: { text: 'minShare leaves the tail of slivers unlabelled' },
    legend: { enabled: false },
    series: [
      {
        type: 'donut',
        angleField: 'revenue',
        angleName: 'Revenue',
        labelField: 'brand',
        sectorSpacing: 4,
        cornerRadius: 6,
        // under four percent of the total a sector is drawn but not labelled
        label: { minShare: 0.04, value: { enabled: true, type: 'value', format: ',.0f' } },
      },
    ],
  };
}
ts
export function getData() {
  return [
    { brand: 'JeansMar', revenue: 569566 },
    { brand: 'Cashmerehous', revenue: 274719 },
    { brand: 'Inside Out', revenue: 250483 },
    { brand: 'Cashmere & Silk', revenue: 216170 },
    { brand: 'Fifoshka', revenue: 214121 },
    { brand: 'Larne dress', revenue: 183819 },
    { brand: 'Fashion House', revenue: 159575 },
    { brand: 'Be Very', revenue: 149460 },
    { brand: 'Blues & Jeans', revenue: 121152 },
    { brand: 'Bat Norton', revenue: 113117 },
    { brand: 'Kurtkin Mir', revenue: 111099 },
    { brand: 'Thing!', revenue: 109086 },
    { brand: 'Stella', revenue: 105043 },
    { brand: 'Be Trandy', revenue: 94960 },
    { brand: 'Jeans club', revenue: 90908 },
    { brand: 'Velvet Season', revenue: 90904 },
    { brand: 'Egoista', revenue: 14139 },
    { brand: 'Mon Ami', revenue: 12820 },
    { brand: 'Pretty Wool', revenue: 11304 },
    { brand: 'Lace Age', revenue: 9877 },
    { brand: 'Silk Way', revenue: 8461 },
    { brand: 'Nordwind', revenue: 7215 },
    { brand: 'Bella Riva', revenue: 6088 },
    { brand: 'Trikotage', revenue: 4930 },
    { brand: 'Zima', revenue: 3742 },
    { brand: 'Mimi', revenue: 2611 },
  ];
}

label.avoidOverlap decides whether there is room for a label. The labels stack in rows down each side of the pie, and once a side runs out of rows the narrowest sectors on it are the ones that lose theirs — no threshold to pick, but which labels survive depends on the size of the chart.

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'A long tail of small sectors' },
    subtitle: { text: 'avoidOverlap hands out labels until the sides run out of rows' },
    legend: { enabled: false },
    series: [
      {
        type: 'donut',
        angleField: 'revenue',
        angleName: 'Revenue',
        labelField: 'brand',
        sectorSpacing: 4,
        cornerRadius: 6,
        // the sectors of the tail are slivers, and slivers still get drawn
        label: { avoidOverlap: true, value: { enabled: true, type: 'value', format: ',.0f' } },
      },
    ],
  };
}
ts
export function getData() {
  return [
    { brand: 'JeansMar', revenue: 569566 },
    { brand: 'Cashmerehous', revenue: 274719 },
    { brand: 'Inside Out', revenue: 250483 },
    { brand: 'Cashmere & Silk', revenue: 216170 },
    { brand: 'Fifoshka', revenue: 214121 },
    { brand: 'Larne dress', revenue: 183819 },
    { brand: 'Fashion House', revenue: 159575 },
    { brand: 'Be Very', revenue: 149460 },
    { brand: 'Blues & Jeans', revenue: 121152 },
    { brand: 'Bat Norton', revenue: 113117 },
    { brand: 'Kurtkin Mir', revenue: 111099 },
    { brand: 'Thing!', revenue: 109086 },
    { brand: 'Stella', revenue: 105043 },
    { brand: 'Be Trandy', revenue: 94960 },
    { brand: 'Jeans club', revenue: 90908 },
    { brand: 'Velvet Season', revenue: 90904 },
    { brand: 'Egoista', revenue: 14139 },
    { brand: 'Mon Ami', revenue: 12820 },
    { brand: 'Pretty Wool', revenue: 11304 },
    { brand: 'Lace Age', revenue: 9877 },
    { brand: 'Silk Way', revenue: 8461 },
    { brand: 'Nordwind', revenue: 7215 },
    { brand: 'Bella Riva', revenue: 6088 },
    { brand: 'Trikotage', revenue: 4930 },
    { brand: 'Zima', revenue: 3742 },
    { brand: 'Mimi', revenue: 2611 },
  ];
}

The two combine: minShare picks the sectors worth labelling, avoidOverlap guarantees that what is left never collides.

Values in the legend

legendValue shows the sector value to the right of the label; the tooltip here is anchored to the cursor (tooltip.position.anchorTo: 'pointer'):

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Values in the legend' },
    series: [
      {
        type: 'donut',
        angleField: 'amount',
        labelField: 'source',
        innerRadiusRatio: 0.62,
        sectorSpacing: 3,
        cornerRadius: 4,
        label: { enabled: false },
        innerLabels: [
          { text: 'Total', fontSize: 13 },
          { text: '$86K', fontSize: 24, fontWeight: 'bold' },
        ],
        legendValue: {
          enabled: true,
          formatter: ({ value }) => `$${value}K`,
        },
      },
    ],
    legend: { position: 'right' },
    tooltip: { position: { anchorTo: 'pointer', yOffset: -8 } },
  };
}
ts
export function getData() {
  return [
    { source: 'Equipment sales', amount: 35 },
    { source: 'Online training', amount: 12 },
    { source: 'Athlete contracts', amount: 5 },
    { source: 'Merchandise', amount: 21 },
    { source: 'Sponsored events', amount: 13 },
  ];
}

Donut progress

A thin ring + innerLabels makes a compact indicator:

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Year progress' },
    series: [
      {
        type: 'donut',
        angleField: 'value',
        labelField: 'state',
        innerRadiusRatio: 0.78,
        rotation: 0,
        fills: ['#21a06c', '#e8eaee'],
        label: { enabled: false },
        innerLabels: [
          { text: '68%', fontSize: 26, fontWeight: 'bold' },
          { text: 'of plan complete', fontSize: 12 },
        ],
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { state: 'Done', value: 68 },
    { state: 'Remaining', value: 32 },
  ];
}

Options

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

OptionTypeDefaultDescription
angleFieldstringsector value (required)
labelFieldstringsector name
fillsColorValue[]theme palettesector colors around the circle
strokesColorValue[]theme palettesector colors around the circle
rotationDegrees0start angle
outerRadiusRatioFraction0.85fraction of the available radius used by the chart (labels go in the remaining space)
innerRadiusRatio (donut)Fraction0.6inner radius
angleNamestringangleField namevalue label in the tooltip
labelName.formatstringhow the labelField value becomes text: legend, tooltip heading, label
labelName.formatter({ datum, value }) => stringthe same, when a format string cannot express it
sectorSpacingPixels0constant-width gap between sectors
cornerRadiusPixels0sector corner rounding
label.enabledbooleanon with labelFieldsector labels
label.placement'outside' | 'inside''outside'beside the pie on a callout line, or in the sector
label.layout'stacked' | 'inline''stacked'the value on its own line under the name, or in a row with it
label.separatorstring' · 'what separates the parts of an inline label
label.positionRatioFraction0.7position along the radius (inside placement)
label.minShareFraction0share of the total a sector needs before it is worth a label
label.avoidOverlapbooleanfalsedrop the labels there is no room for instead of letting them overlap
label.category.enabledbooleanon with labelFieldthe sector name as part of the label
label.category.formatstringformat string for the name field ('%d.%m.%Y', ',.0f')
label.category.formatter({ datum, label, value, share }) => …text of the name half
label.category.fontSizePixels11name font
label.category.fontFamilystringtheme fontfont family
label.category.fontWeightstring | numbernormalfont weight
label.category.colorColorValueforeground / auto contrasttext color
label.value.enabledbooleanfalsethe sector value as part of the label
label.value.type'percent' | 'value''percent'share of the total, or the angleField value
label.value.formatstringformat string (',.2f', '.1%')
label.value.formatter({ datum, label, value, share }) => stringfull control over the text
label.value.fontSizePixels11value font
label.value.colorColorValueforeground / auto contrasttext color
calloutLine.radial.lengthPixels20radial segment length
calloutLine.radial.strokeColorValuesector colorradial segment color
calloutLine.radial.strokeWidthPixels1width
calloutLine.horizontal.lengthPixels20length of the tail toward the label
calloutLine.horizontal.strokeColorValuesame as radialtail color
calloutLine.horizontal.strokeWidthPixelssame as radialtail width
legendValue.enabledbooleanfalsesector value in the legend
legendValue.formatter({ datum, label, value, color }) => stringvaluevalue format
tooltip.renderer({ datum, label, value, color }) => …custom tooltip
innerLabels[]{ text, fontSize?, fontWeight?, color? }lines in the donut center
innerCircle.fillColorValuedonut center fill
innerRadiusRatioFraction0.6donut inner radius

Clicking a legend item hides the sector (shares are recalculated); legend items that do not fit are paginated with arrows.