Skip to content

Histogram

Distribution of a numeric field across bins. xField is a numeric field — a date field with a calendar binWidth; without yField, the number of records is counted.

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Session duration' },
    subtitle: { text: 'distribution, minutes' },
    series: [{ type: 'histogram', xField: 'duration', name: 'Sessions', binCount: 8 }],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  const durations = [
    12, 18, 22, 25, 28, 31, 33, 35, 38, 41, 42, 44, 47, 48, 51, 53, 54, 56, 58, 61, 63, 64, 67, 71, 74, 78, 82, 87, 93, 104, 36, 45, 52, 59,
    49, 39, 29, 57, 66, 73,
  ];
  return durations.map((duration) => ({ duration }));
}

Bin count

binCount controls the granularity — a target, not a promise: the step is rounded to 1/2/5×10ⁿ so the edges read as numbers a person would pick, which can shift the count by one or two. nice: false gives exactly binCount bins spanning the data.

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Bin count' },
    subtitle: { text: 'binCount: 24 vs default auto' },
    series: [{ type: 'histogram', xField: 'response', name: 'Response time, ms', binCount: 24, fillOpacity: 0.8 }],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  const values: Array<{ response: number }> = [];
  for (let i = 0; i < 400; i++) {
    const u = ((i * 9301 + 49297) % 233280) / 233280;
    const v = ((i * 7621 + 1) % 233280) / 233280;
    values.push({ response: Math.round(120 + 55 * (Math.sqrt(-2 * Math.log(u + 1e-6)) * Math.cos(2 * Math.PI * v))) });
  }
  return values.filter((d) => d.response > 0 && d.response < 320);
}

Without binCount the count comes from the data. The rules are the ones statistics gave them, under their usual names — 'auto' (the default: Freedman–Diaconis, never below Sturges), 'sturges', 'fd', 'scott', 'rice':

ts
series: [{ type: 'histogram', xField: 'response', binCount: 'fd' }];

Bin width

binWidth is the other way to ask: the step is fixed and the count follows from it — this is how BI tools phrase binning. binOrigin says what the grid is aligned to (the default 0, so edges land on multiples of the width):

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Bin width' },
    subtitle: { text: 'binWidth: 25 — bins start at multiples of 25' },
    series: [{ type: 'histogram', xField: 'response', name: 'Response time, ms', binWidth: 25, fillOpacity: 0.8 }],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  const values: Array<{ response: number }> = [];
  for (let i = 0; i < 400; i++) {
    const u = ((i * 9301 + 49297) % 233280) / 233280;
    const v = ((i * 7621 + 1) % 233280) / 233280;
    values.push({ response: Math.round(120 + 55 * (Math.sqrt(-2 * Math.log(u + 1e-6)) * Math.cos(2 * Math.PI * v))) });
  }
  return values.filter((d) => d.response > 0 && d.response < 320);
}
ts
// weeks starting on Monday rather than on the first value
series: [{ type: 'histogram', xField: 'day', binWidth: 7, binOrigin: 1 }];

Explicit bins win over both: [[0, 18], [18, 65], [65, 120]] builds three bins of unequal width. A value on an edge goes to the bin on the right ([x0, x1)), with the last bin closed on both ends so the maximum is never dropped; binInclusive: 'right' mirrors that.

Calendar bins

binWidth also takes a calendar unit — 'second', 'minute', 'hour', 'day', 'week', 'month', 'quarter', 'year' — and then the values are read as dates: a Date, a timestamp or an ISO string. The rows arrive as they were recorded and the chart does the collapsing, the way a BI tool asks its warehouse for a time grain:

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Revenue by month' },
    subtitle: { text: 'Rows of orders, collapsed into calendar months by the chart' },
    series: [
      {
        type: 'histogram',
        xField: 'placedAt',
        yField: 'amount',
        binWidth: 'month',
        aggregation: 'sum',
        groupField: 'channel',
        groupMode: 'stacked',
      },
    ],
    axes: [
      { type: 'time', position: 'bottom' },
      { type: 'number', position: 'left', title: { text: '₽' } },
    ],
    legend: { position: 'bottom' },
  };
}
ts
/** Orders as they came in — one row per order, no aggregation done for the chart. */
export function getData() {
  const rows: Array<{ placedAt: string; amount: number; channel: string }> = [];
  // a deterministic walk: enough orders per week to make the months differ
  let seed = 7;
  const next = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
  for (let day = 0; day < 180; day++) {
    const date = new Date(Date.UTC(2025, 0, 1 + day));
    const orders = 1 + Math.floor(next() * 4);
    for (let index = 0; index < orders; index++) {
      rows.push({
        placedAt: date.toISOString(),
        amount: Math.round(40 + next() * 160),
        channel: next() > 0.45 ? 'Web' : 'App',
      });
    }
  }
  return rows;
}
ts
series: [{ type: 'histogram', xField: 'placedAt', yField: 'amount', binWidth: 'month', aggregation: 'sum' }];

Months and quarters are stepped by the calendar rather than by a fixed number of milliseconds, so a February bar is as narrow as February is. The grid is aligned in UTC, where the ticks of the time axis are, and a bar therefore ends exactly on one; a week starts on Monday. Without explicit axes the series asks for a time axis by itself, and a tooltip names the period — February 2025, Q1 2025 — instead of printing two timestamps.

A grain far finer than the range (seconds across a decade) would ask for millions of bars: the step grows by whole units until the grid fits within a thousand of them.

Range and outliers

domain bins a fixed range instead of the data extent — a long tail no longer flattens the bars that matter. Values outside it are dropped, or piled into the edge bins with outliers: 'clamp':

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Order size' },
    subtitle: { text: 'domain: [0, 150], outliers piled into the last bin' },
    series: [
      {
        type: 'histogram',
        xField: 'amount',
        name: 'Orders',
        domain: [0, 150],
        binWidth: 15,
        outliers: 'clamp',
        label: { enabled: true },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  const values: Array<{ amount: number }> = [];
  for (let i = 0; i < 300; i++) {
    const u = ((i * 9301 + 49297) % 233280) / 233280;
    const v = ((i * 7621 + 1) % 233280) / 233280;
    values.push({ amount: Math.round(60 + 22 * (Math.sqrt(-2 * Math.log(u + 1e-6)) * Math.cos(2 * Math.PI * v))) });
  }
  // a handful of large orders that would otherwise stretch the axis to 900
  return [...values.filter((d) => d.amount > 0), { amount: 380 }, { amount: 520 }, { amount: 910 }];
}

What the height means

normalize restates the bars without touching the bins — the same distribution answering a different question:

normalizeA bar reads as
'none' (default)the aggregated value itself
'percent'its share of the total, 0–100
'frequency'the same share on a 0–1 scale
'density'share ÷ bin width — the bars enclose an area of 1
'cumulative'the running total from the left
'cumulative-percent'the running share, ending at 100 — the empirical CDF
ts
series: [{ type: 'histogram', xField: 'response', normalize: 'percent' }];

'density' is the one to reach for when bins differ in width (explicit bins) or when two distributions of different sample sizes are compared — counts would lie about both. Cumulative bars answer "what share is under this value":

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

// The share of requests served under a given time — the distribution read as a CDF.
export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Response time' },
    subtitle: { text: 'cumulative share of requests, %' },
    series: [
      {
        type: 'histogram',
        xField: 'response',
        name: 'Requests',
        binWidth: 25,
        normalize: 'cumulative-percent',
        label: { enabled: true, formatter: ({ value }) => (value < 99.5 ? `${Math.round(value)}%` : '') },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  const values: Array<{ response: number }> = [];
  for (let i = 0; i < 400; i++) {
    const u = ((i * 9301 + 49297) % 233280) / 233280;
    const v = ((i * 7621 + 1) % 233280) / 233280;
    values.push({ response: Math.round(120 + 55 * (Math.sqrt(-2 * Math.log(u + 1e-6)) * Math.cos(2 * Math.PI * v))) });
  }
  return values.filter((d) => d.response > 0 && d.response < 320);
}

The tooltip of a normalized bar keeps the original aggregate in brackets — 33.3% (2). The label formatter gets both as well: raw is the value before normalization, count the number of rows in the bin.

Splitting by a field

groupField turns one distribution into several sharing a bin grid — the grid is built from all the data, so the bars line up and can be read against each other. Each group gets a colour off the theme palette (or fills) and a legend item of its own; switching one off in the legend takes its rows out of the totals as well:

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

// One bin grid, two distributions on it: the default groupMode piles them up.
export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Session duration by plan' },
    subtitle: { text: 'groupField: plan — stacked' },
    series: [{ type: 'histogram', xField: 'duration', groupField: 'plan', binWidth: 10 }],
  };
}
ts
export function getData() {
  const rows: Array<{ duration: number; plan: string }> = [];
  for (let i = 0; i < 500; i++) {
    const u = ((i * 9301 + 49297) % 233280) / 233280;
    const v = ((i * 7621 + 1) % 233280) / 233280;
    const normal = Math.sqrt(-2 * Math.log(u + 1e-6)) * Math.cos(2 * Math.PI * v);
    // free sessions cluster low, paid ones run longer
    const free = i % 3 !== 0;
    rows.push({
      duration: Math.round((free ? 22 : 48) + (free ? 10 : 16) * normal),
      plan: free ? 'Free' : 'Pro',
    });
  }
  return rows.filter((row) => row.duration > 0 && row.duration < 100);
}

groupMode decides how the groups share a bin:

groupModeThe groups of a bin
'stacked' (default)pile up — the bin total stays readable
'grouped'stand side by side, groupGap apart
'overlay'all start at zero and are drawn over each other
'normalized'pile up and scale to 100 per bin — the mix of each bin
ts
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';

// The groups split the bin between them; groupGap keeps the bars apart.
export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Session duration by plan' },
    subtitle: { text: 'groupMode: grouped' },
    series: [
      {
        type: 'histogram',
        xField: 'duration',
        groupField: 'plan',
        groupMode: 'grouped',
        groupGap: 0.15,
        binWidth: 10,
      },
    ],
  };
}
ts
export function getData() {
  const rows: Array<{ duration: number; plan: string }> = [];
  for (let i = 0; i < 500; i++) {
    const u = ((i * 9301 + 49297) % 233280) / 233280;
    const v = ((i * 7621 + 1) % 233280) / 233280;
    const normal = Math.sqrt(-2 * Math.log(u + 1e-6)) * Math.cos(2 * Math.PI * v);
    // free sessions cluster low, paid ones run longer
    const free = i % 3 !== 0;
    rows.push({
      duration: Math.round((free ? 22 : 48) + (free ? 10 : 16) * normal),
      plan: free ? 'Free' : 'Pro',
    });
  }
  return rows.filter((row) => row.duration > 0 && row.duration < 100);
}

Overlay is for comparing shapes, and shapes of samples of different sizes are only comparable group by group — so under overlay a share is a share of its own group, while every other mode measures against the whole chart. normalizeWithin: 'total' | 'group' overrides that either way:

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

// Overlay compares the shapes, so each group is a percentage of itself —
// otherwise the smaller sample would read as the flatter distribution.
export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Session duration by plan' },
    subtitle: { text: 'groupMode: overlay, each group as % of itself' },
    series: [
      {
        type: 'histogram',
        xField: 'duration',
        groupField: 'plan',
        groupMode: 'overlay',
        normalize: 'percent',
        binWidth: 10,
      },
    ],
  };
}
ts
export function getData() {
  const rows: Array<{ duration: number; plan: string }> = [];
  for (let i = 0; i < 500; i++) {
    const u = ((i * 9301 + 49297) % 233280) / 233280;
    const v = ((i * 7621 + 1) % 233280) / 233280;
    const normal = Math.sqrt(-2 * Math.log(u + 1e-6)) * Math.cos(2 * Math.PI * v);
    // free sessions cluster low, paid ones run longer
    const free = i % 3 !== 0;
    rows.push({
      duration: Math.round((free ? 22 : 48) + (free ? 10 : 16) * normal),
      plan: free ? 'Free' : 'Pro',
    });
  }
  return rows.filter((row) => row.duration > 0 && row.duration < 100);
}

'normalized' answers the other question — what each duration band is made of:

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

// Every bin scaled to 100%: the composition of each duration band.
export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Plan mix by session duration' },
    subtitle: { text: 'groupMode: normalized' },
    series: [
      {
        type: 'histogram',
        xField: 'duration',
        groupField: 'plan',
        groupMode: 'normalized',
        binWidth: 10,
        label: { enabled: true, placement: 'center', formatter: ({ value }) => (value > 12 ? `${Math.round(value)}%` : '') },
      },
    ],
  };
}
ts
export function getData() {
  const rows: Array<{ duration: number; plan: string }> = [];
  for (let i = 0; i < 500; i++) {
    const u = ((i * 9301 + 49297) % 233280) / 233280;
    const v = ((i * 7621 + 1) % 233280) / 233280;
    const normal = Math.sqrt(-2 * Math.log(u + 1e-6)) * Math.cos(2 * Math.PI * v);
    // free sessions cluster low, paid ones run longer
    const free = i % 3 !== 0;
    rows.push({
      duration: Math.round((free ? 22 : 48) + (free ? 10 : 16) * normal),
      plan: free ? 'Free' : 'Pro',
    });
  }
  return rows.filter((row) => row.duration > 0 && row.duration < 100);
}

Binning outside the chart

The chart's own binning is exported, so a click handler can filter rows by the very edges that were drawn instead of recomputing them — the nice step rules would otherwise drift apart from the bars:

ts
import { binEdges, binIndexOf } from 'grafit-charts';

const options = { binWidth: 25, domain: [0, 300] } as const;
const edges = binEdges(
  rows.map((row) => row.response),
  options,
);
const inBin = rows.filter((row) => binIndexOf(row.response, edges, options) === clickedBin);

binCountFor answers what a rule ('auto', 'fd', …) would pick for a sample.

The tooltip is written about a bin, so tooltip.renderer gets the bin rather than a row — its bounds, the height the bar draws, the aggregate behind it, the row count and the group:

ts
tooltip: {
  renderer: ({ x0, x1, count, seriesName }) => `${seriesName}: ${count} between ${x0} and ${x1} ms`,
}

Bin labels

label — placements are the same as for bar (top, inner-top, center, …), formatter({ value, x0, x1, raw, count, group }):

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

export function createOptions(): ChartOptions {
  return {
    data: getData(),
    title: { text: 'Histogram with bin labels' },
    series: [
      {
        type: 'histogram',
        xField: 'score',
        name: 'Scores',
        binCount: 8,
        label: { enabled: true, placement: 'top', fontWeight: 'bold' },
      },
    ],
    legend: { enabled: false },
  };
}
ts
export function getData() {
  const values: Array<{ score: number }> = [];
  for (let i = 0; i < 120; i++) {
    const u = ((i * 9301 + 49297) % 233280) / 233280;
    values.push({ score: Math.round(35 + 50 * u + 15 * Math.sin(i) ** 2) });
  }
  return values;
}

Options

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

OptionTypeDefaultDescription
xFieldstringfield to bin: numbers, or dates with a calendar binWidth
yFieldstringaggregation field (optional)
aggregation'count' | 'sum' | 'mean'count / sumaggregation method (sum with yField)
binCountnumber | BinRule'auto'number of bins, or the rule that picks it
binWidthnumber | TimeBinUnitbin width, or a calendar unit; wins over binCount
binOriginnumber0value the bin grid is aligned to
nicebooleantrueround the computed step to 1/2/5×10ⁿ
binInclusive'left' | 'right''left'which side of a bin owns an edge value
bins[number, number][]explicit bin boundaries; wins over all
domain[number, number]data extentrange to bin
outliers'exclude' | 'clamp''exclude'values outside domain
normalizeHistogramNormalize'none'what a bar's height stands for
normalizeWithin'total' | 'group''group' under overlay, else 'total'whose total a share measures against
groupFieldstringfield that splits the data into groups
groupModeHistogramGroupMode'stacked'how the groups share a bin
fillsColorValue[]theme palettecolours of the groups
groupGapFraction0gap between side-by-side bars of a bin
fillstylespalettebar styling
strokestylespalettebar styling
fillOpacitystylespalettebar styling
strokeWidthstyles1bin stroke width
label.enabledbooleanfalseshow value labels
label.placementouter/center/inner-* (17 placements)'top'label placement
label.formatter({ value, x0, x1, raw, count, group }) => stringvaluelabel content
tooltip.renderer(params: HistogramTooltipRendererParams) => …tooltip written about the bin
label.fontSizePixels11label font size
label.fontWeightstring | numbernormalfont weight
label.fontFamilystringtheme fontfont family
label.colorColorValueforeground; inside — auto contrasttext color