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.
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 },
};
}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.
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 },
};
}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':
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):
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 },
};
}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);
}// 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:
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' },
};
}/** 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;
}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':
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 },
};
}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:
normalize | A 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 |
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":
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 },
};
}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:
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 }],
};
}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:
groupMode | The 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 |
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,
},
],
};
}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:
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,
},
],
};
}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:
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)}%` : '') },
},
],
};
}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:
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:
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 }):
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 },
};
}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.
| Option | Type | Default | Description |
|---|---|---|---|
xField | string | — | field to bin: numbers, or dates with a calendar binWidth |
yField | string | — | aggregation field (optional) |
aggregation | 'count' | 'sum' | 'mean' | count / sum | aggregation method (sum with yField) |
binCount | number | BinRule | 'auto' | number of bins, or the rule that picks it |
binWidth | number | TimeBinUnit | — | bin width, or a calendar unit; wins over binCount |
binOrigin | number | 0 | value the bin grid is aligned to |
nice | boolean | true | round 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 extent | range to bin |
outliers | 'exclude' | 'clamp' | 'exclude' | values outside domain |
normalize | HistogramNormalize | 'none' | what a bar's height stands for |
normalizeWithin | 'total' | 'group' | 'group' under overlay, else 'total' | whose total a share measures against |
groupField | string | — | field that splits the data into groups |
groupMode | HistogramGroupMode | 'stacked' | how the groups share a bin |
fills | ColorValue[] | theme palette | colours of the groups |
groupGap | Fraction | 0 | gap between side-by-side bars of a bin |
fill | styles | palette | bar styling |
stroke | styles | palette | bar styling |
fillOpacity | styles | palette | bar styling |
strokeWidth | styles | 1 | bin stroke width |
label.enabled | boolean | false | show value labels |
label.placement | outer/center/inner-* (17 placements) | 'top' | label placement |
label.formatter | ({ value, x0, x1, raw, count, group }) => string | value | label content |
tooltip.renderer | (params: HistogramTooltipRendererParams) => … | — | tooltip written about the bin |
label.fontSize | Pixels | 11 | label font size |
label.fontWeight | string | number | normal | font weight |
label.fontFamily | string | theme font | font family |
label.color | ColorValue | foreground; inside — auto contrast | text color |