Skip to content

Value Labels

A value label is the number printed beside a mark. Every series that draws marks with a value can print it: line, area, scatter, bubble, bar, histogram, range bar, waterfall, heatmap, funnel and pyramid, treemap, sunburst, sankey and chord, pie and donut, radar.

Labels are off by default (funnel, pyramid and treemap are the exceptions — they are unreadable without them) and turn on per series:

js
series: [{ type: 'bar', xField: 'month', yField: 'revenue', label: { enabled: true } }],
ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Value Labels on Bars' },
    subtitle: { text: "outer ('top') and inner ('inner-top') placements" },
    series: [
      {
        type: 'bar',
        xField: 'team',
        yField: 'done',
        name: 'Done',
        label: { enabled: true, placement: 'inner-top', fontWeight: 'bold' },
      },
      {
        type: 'bar',
        xField: 'team',
        yField: 'planned',
        name: 'Plan',
        label: { enabled: true, placement: 'top' },
      },
    ],
  };
}
ts
export function getData() {
  return [
    { team: 'Alpha', done: 34, planned: 42 },
    { team: 'Beta', done: 27, planned: 30 },
    { team: 'Gamma', done: 41, planned: 38 },
    { team: 'Delta', done: 22, planned: 35 },
  ];
}

Options

OptionTypeDefaultDescription
enabledbooleanfalse (funnel/pyramid/treemap — true)show the labels
placementdepends on the seriestop (heatmap/treemap — center)where the label sits relative to its mark
formatter(params) => stringthe raw valuelabel text; params depend on the series
avoidOverlapbooleanfalsedrop a label that collides with one already drawn (below)
minShareFraction0parts-of-a-whole series only: share of the total a part needs to be labelled
layout'inline' | 'stacked''inline' (pie 'stacked')name-and-value labels: the value behind a separator or on its own line
fontSizePixels11 (funnel/pyramid — 12)font size
fontWeightstring | numbernormalfont weight
fontFamilystringtheme fonttypeface
colorColorValueforeground; inside a mark — auto-contrasttext colour

Placement

Rectangular marks — bar, histogram, range bar, waterfall, heatmap cells — take the full set: the four outer sides top, bottom, left, right, the outer corners top-left, top-right, bottom-left, bottom-right, center, and the inner variants of the same eight spots prefixed with inner- (inner-top, inner-bottom-right, …).

Point marks — line, area, scatter — take top, bottom, left, right; bubble adds inside, which centres the text on the marker. Funnel and pyramid take inside and outside, where the outside label is joined to its segment by a callout line.

A radar vertex takes the same five, plus outward — its default. Which way is "away from the data" on a web depends on where on the circle the vertex sits, so outward pushes the label along its own spoke and lets the polygon keep the inside.

A label placed inside its mark gets a halo in the mark's colour and its text colour flips to whatever reads on that fill, so it stays legible over a dark bar as well as a light one.

Sector labels

A pie or a donut labels a sector rather than a mark, so its label is put together differently: the name and the value are one label of two parts, each with its own font and colour, and label.placement moves the whole of it. 'inside' centres it in the sector over a halo of the sector colour:

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 },
  ];
}

'outside' — the default — puts it beside the pie on a two-segment callout line. Callout labels crowd near the top and the bottom of the circle, where neighbouring sectors point almost the same way, so the pie spreads them down its sides and gives up radius until the longest of them fits the chart area.

Which sectors get a label at all is label.minShare: below that share of the total a sector is drawn but left unlabelled, which is what keeps a long tail of slivers from burying the numbers that matter.

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 },
  ];
}

The Pie and Donut page has the full set: layout for putting the two parts in one row, separator, positionRatio, per-part fonts and the callout line options.

Drawing order

Labels are drawn above all the marks of the chart, not just the ones of their own series. A bar drawn later never covers the number of the bar before it, and a second series never covers the labels of the first.

Room for a label hanging over the edge of the plot is reserved during layout: the plot shrinks so that the text of an outer label fits the chart area instead of being cut off at the canvas edge.

Avoiding overlap

Labels crowd each other long before a chart runs out of room: marks at similar heights put their numbers in the same row and the text collides. With avoidOverlap: true a label whose box runs into one already drawn is left out:

js
label: { enabled: true, avoidOverlap: true },
ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Labels That Fight for Room' },
    subtitle: { text: 'avoidOverlap keeps the first label of every crowded spot' },
    series: [
      {
        type: 'line',
        xField: 'day',
        yField: 'visits',
        name: 'Visits',
        label: {
          enabled: true,
          avoidOverlap: true,
          formatter: ({ value }) => `${value} visits`,
        },
      },
    ],
  };
}
ts
export function getData() {
  const visits = [
    118, 126, 131, 129, 140, 152, 149, 143, 138, 146, 158, 167, 171, 169, 160, 152, 147, 151, 163, 176, 181, 174, 168, 172,
  ];
  return visits.map((value, index) => ({ day: `${index + 1}`, visits: value }));
}

The rules are worth knowing before you turn it on:

  • The first label on a spot keeps it. Series are asked in the order they are declared and each series goes through its data in order, so an earlier datum wins over a later one. Put the series whose numbers matter most first. Series whose marks read as parts of a whole — pie/donut, funnel/cone-funnel, pyramid, bubble — ask in size order instead: the largest part gets the spot, and the slivers are what a crowded chart drops.
  • Labels of the whole chart share one guard. Two series avoid each other's labels, not only their own — but only the series that ask for it take part; a series without avoidOverlap draws its labels regardless and does not reserve anything.
  • Only labels count. A label is dropped when it collides with another label, never when it merely sits over a bar or a line. Move such a label inside the mark (placement: 'inner-top') or shorten it with a formatter.
  • Boxes get a 2px gap. Two labels count as touching while less than that separates them.
  • Cartesian series, funnel/pyramid and pie/donut take the option. A pie spreads crowded callout labels along its side of the circle whether or not the option is on; what the option adds is dropping the ones the spreading could not find room for, narrowest sector first. label.minShare is the other half of the answer, and pie/donut, funnel/cone-funnel, pyramid and scatter/bubble all take it: it leaves the long tail of slivers, thin stages, thin layers and specks unlabelled outright. Treemap prints a label only where the tile has room for it; sunburst, sankey and chord draw every label they are given.

On a pie the option works on the callout labels the spreading could not separate — with no threshold to pick, the sides of the circle hold as many labels as they have rows for:

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 },
  ];
}

Shortening the text is often the better fix — formatter gives you full control:

js
label: {
  enabled: true,
  avoidOverlap: true,
  formatter: ({ value }) => `${(value / 1_000_000).toFixed(1)}M`,
},