Skip to content

Funnel and Pyramid

Stage-based series without axes: flat data with stageField/valueField.

Funnel

Stages run top to bottom, width is proportional to the value. funnel — rectangular stages, cone-funnel — trapezoids tapering to the next stage.

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Activation funnel' },
    series: [{ type: 'cone-funnel', stageField: 'stage', valueField: 'value', name: 'Users' }],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { stage: 'Visits', value: 12400 },
    { stage: 'Sign-ups', value: 5300 },
    { stage: 'Activations', value: 2900 },
    { stage: 'Subscriptions', value: 1150 },
    { stage: 'Renewals', value: 780 },
  ];
}

Spacing and outside labels

itemSpacing — the gap between segments; label.placement: 'outside' moves labels out to the right. The shape geometry does not depend on labels — the width is set by widthRatio:

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Funnel: spacing and outside labels' },
    series: [
      {
        type: 'funnel',
        stageField: 'stage',
        valueField: 'count',
        itemSpacing: 10,
        label: {
          placement: 'outside',
          formatter: ({ stage, value }) => `${stage} — ${value.toLocaleString('en-US')}`,
        },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { stage: 'Visits', count: 12500 },
    { stage: 'Sign-ups', count: 6400 },
    { stage: 'Activations', count: 3100 },
    { stage: 'Subscriptions', count: 1400 },
    { stage: 'Renewals', count: 900 },
  ];
}

Cone funnel with outside labels

Trapezoidal stages; the callout line starts at the slanted edge. Inside labels get an outline in the background color (readable on any segment):

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Cone funnel: outside labels' },
    series: [
      {
        type: 'cone-funnel',
        stageField: 'stage',
        valueField: 'count',
        itemSpacing: 2,
        label: {
          placement: 'outside',
          formatter: ({ stage, value }) => `${stage} — ${value.toLocaleString('en-US')}`,
        },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { stage: 'Leads', count: 8200 },
    { stage: 'Qualified', count: 4900 },
    { stage: 'Demo', count: 2300 },
    { stage: 'Contract', count: 1100 },
    { stage: 'Payment', count: 750 },
  ];
}

What the label is made of

A stage label is the name of the stage and its value, drawn as one block so the two always read together — the same label a pie sector gets. Each half carries its own font, layout decides whether the value follows the name ('inline', the default, behind separator) or sits on a line of its own ('stacked'), and value.type: 'percent' turns the number into the share of the whole funnel:

js
label: {
  placement: 'outside',
  layout: 'stacked',
  category: { fontWeight: 'bold' },
  value: { type: 'percent', fontSize: 11, color: '#8a8f98' },
},
ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Commerce funnel' },
    subtitle: { text: 'the name and the share of the total, each with its own font' },
    series: [
      {
        type: 'cone-funnel',
        stageField: 'stage',
        valueField: 'value',
        name: 'Users',
        label: {
          placement: 'outside',
          layout: 'stacked',
          category: { fontWeight: 'bold' },
          value: { type: 'percent', fontSize: 11, color: '#8a8f98' },
        },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { stage: 'Site visits', value: 62000 },
    { stage: 'Product views', value: 24500 },
    { stage: 'Add to cart', value: 8100 },
    { stage: 'Orders', value: 1420 },
  ];
}

The stage name comes out of a data field, and that field has a format like any other — a date with its granularity, a code with its wording. stageName says how that value becomes text, once for the whole series: the legend, the tooltip heading and the name half of the label all read the same. Both halves of the contract are accepted — format, a serializable string that survives a saved config, and formatter, for what a string cannot express:

js
stageName: { formatter: ({ datum, value }) => formatWeek(value) },

Where a label wants something shorter than the legend, label.category has a format of its own and overrides stageName — that is what it is for. It answers for the name exactly as value.format/value.formatter answer for the number, and its formatter receives the same { datum, stage, value, share }:

js
label: { category: { format: '%d.%m.%Y' }, value: { type: 'percent' } },

Either half can go on its own — category: { enabled: false } leaves the bare number, value: { enabled: false } the bare name. label.formatter still speaks for the whole label when one text is all you want; it wins over category/value.

A long tail of thin stages

Every stage is labelled whatever its size, and the crowded ones simply overlap. The same two options a pie has thin them out, and they answer different questions.

label.minShare decides which stages are worth a label at all: below that share of the total a stage is drawn but left unlabelled — what the funnel narrows down to keeps its callout, the tail stays in the shape and in the tooltip.

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Commerce funnel' },
    subtitle: { text: 'minShare leaves the tail of the funnel unlabelled' },
    series: [
      {
        type: 'cone-funnel',
        stageField: 'stage',
        valueField: 'value',
        name: 'Users',
        label: {
          placement: 'outside',
          minShare: 0.02,
          formatter: ({ stage, value }) => `${stage} — ${value.toLocaleString('en-US')}`,
        },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { stage: 'Ad impressions', value: 480000 },
    { stage: 'Site visits', value: 62000 },
    { stage: 'Product views', value: 24500 },
    { stage: 'Add to cart', value: 8100 },
    { stage: 'Checkout started', value: 3400 },
    { stage: 'Payment entered', value: 1900 },
    { stage: 'Orders', value: 1420 },
    { stage: 'Repeat orders', value: 460 },
  ];
}

label.avoidOverlap decides whether there is room for a label: the largest stages ask first, so a funnel squeezed for height loses the labels of its thinnest stages rather than of its last ones.

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

The tooltip

The default tooltip reads the stage value with its share of the whole funnel. The series tooltip.renderer receives the whole datum, so it can display any fields:

js
tooltip: {
  renderer: ({ datum, stage, value, color }) => ({
    heading: stage,
    rows: [{ label: 'Users', value: `${value} of ${datum.target}`, color }],
  }),
}

Pyramid

Layer height is proportional to the value; reverse flips the apex downward.

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Company structure' },
    series: [{ type: 'pyramid', stageField: 'level', valueField: 'count', name: 'People' }],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { level: 'C-level', count: 6 },
    { level: 'Managers', count: 28 },
    { level: 'Team leads', count: 90 },
    { level: 'Engineers', count: 420 },
    { level: 'Interns', count: 160 },
  ];
}

Spacing and inside labels

itemSpacing slices the pyramid into layers; label.placement: 'inside' — labels in the segments with an auto-contrast color:

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Pyramid: spacing and inside labels' },
    series: [
      {
        type: 'pyramid',
        stageField: 'level',
        valueField: 'people',
        itemSpacing: 6,
        label: { placement: 'inside', fontWeight: 'bold' },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { level: 'CEO', people: 2 },
    { level: 'Directors', people: 9 },
    { level: 'Managers', people: 34 },
    { level: 'Engineers', people: 120 },
    { level: 'Interns', people: 45 },
  ];
}

What the label is made of

A pyramid layer gets the same block label as a funnel stage: the name and the value, each with its own font, layout: 'stacked' putting the value on its own line and value: { type: 'percent' } reading it as the share of the total.

js
label: { placement: 'inside', layout: 'stacked', value: { type: 'percent' } },

Labels toward the apex

The layers thin out toward the apex, and their labels are the first to run out of room. label.minShare leaves the thinnest layers unlabelled; label.avoidOverlap hands out what is left to the thickest layers first:

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Org pyramid' },
    subtitle: { text: 'minShare and avoidOverlap thin out the labels toward the apex' },
    series: [
      {
        type: 'pyramid',
        stageField: 'level',
        valueField: 'people',
        itemSpacing: 2,
        label: { placement: 'inside', minShare: 0.1, avoidOverlap: true, fontWeight: 'bold' },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  return [
    { level: 'Board', people: 4 },
    { level: 'C-level', people: 9 },
    { level: 'VPs', people: 14 },
    { level: 'Directors', people: 26 },
    { level: 'Managers', people: 48 },
    { level: 'Leads', people: 74 },
    { level: 'Engineers', people: 210 },
    { level: 'Interns', people: 62 },
  ];
}

Selection

Stages and layers are picked out by clicks, the way pie sectors are: a selected segment is outlined and the rest fade back while the selection is active. listeners.nodeClick and listeners.selectionChange fire as they do elsewhere, and the selection is drivable from code (chart.setSelection, chart.clickNode) — see Selection.

js
selection: { enabled: true, mode: 'multiple' },
listeners: { selectionChange: ({ items }) => console.log(items) },

Options

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

OptionSeriesDefaultDescription
stageFieldallstage name and value
stageName.formatstringhow the stage-name field becomes text: legend, tooltip heading, label
stageName.formatter({ datum, value }) => stringthe same, when a format string cannot express it
valueFieldallstage name and value
fillsallpalettestage colors
itemSpacingallfunnel 4, pyramid 0gap between segments
widthRatioall0.62fraction of the area width given to the shape (independent of labels)
reversepyramidfalseapex at the bottom
label.enabledbooleantruestage labels
label.placement'inside' | 'outside'funnel 'inside'; pyramid 'outside'position (shared by all segments)
label.formatter({ datum, stage, value }) => stringthe whole label at once; wins over category/value
label.layout'inline' | 'stacked''inline'the value behind a separator or on its own line
label.separatorstring' · 'what separates the halves of an inline label
label.category.enabledbooleantruethe stage name as part of the label
label.category.formatstringformat string for the name field ('%d.%m.%Y', ',.0f')
label.category.formatter({ datum, stage, value, share }) => …text of the name half
label.category.formatstringformat string for the name field ('%d.%m.%Y', ',.0f')
label.category.formatter({ datum, stage, value, share }) => …text of the name half
label.category.*FontOptionsthe label fontfont of the name
label.value.enabledbooleantruethe value as part of the label
label.value.type'value' | 'percent''value'the value itself or its share of the total
label.value.formatstringformat string (',.0f', '.1%')
label.value.formatter({ datum, stage, value, share }) => …text of the value half
label.value.*FontOptionsthe label fontfont of the value
label.fontSizePixels12font
label.fontWeightstring | numbernormalfont weight
label.colorColorValueinside — auto-contrast; outside — foregroundcolor
label.minShareFraction0share of the total a stage needs before it is worth a label
label.avoidOverlapbooleanfalsedrop the labels there is no room for instead of letting them overlap
calloutLine.enabledbooleantrue when outsideline to the outside label
calloutLine.lengthPixels14line length
calloutLine.strokeColorValuesegment colorline color
calloutLine.strokeWidthPixels1line width
tooltip.renderer({ datum, stage, value, color }) => …custom tooltip