Histogram
Распределение числового поля по корзинам. xField — числовое поле (а с календарным binWidth — поле дат); без yField считается количество записей.
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 }));
}Количество корзин
binCount управляет детализацией — это цель, а не обещание: шаг округляется до 1/2/5×10ⁿ, чтобы границы читались как числа, которые выбрал бы человек, и из-за этого корзин может оказаться на одну-две больше или меньше. nice: false даёт ровно binCount корзин от минимума до максимума данных.
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);
}Без binCount количество выводится из самих данных. Правила названы так же, как в статистике: 'auto' (по умолчанию — Фридман–Диаконис, но не меньше Стёрджеса), 'sturges', 'fd', 'scott', 'rice':
series: [{ type: 'histogram', xField: 'response', binCount: 'fd' }];Ширина корзины
binWidth — вторая формулировка того же: шаг задан, количество следует из него; именно так о биннинге спрашивают BI-системы. binOrigin задаёт точку привязки сетки (по умолчанию 0 — границы попадают на кратные ширине):
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);
}// недели с понедельника, а не с первого значения
series: [{ type: 'histogram', xField: 'day', binWidth: 7, binOrigin: 1 }];Явные bins сильнее обоих: [[0, 18], [18, 65], [65, 120]] — три корзины разной ширины. Значение на границе уходит в правую корзину ([x0, x1)), последняя корзина закрыта с обеих сторон, так что максимум не теряется; binInclusive: 'right' — зеркальное правило.
Календарные корзины
binWidth принимает и календарную единицу — 'second', 'minute', 'hour', 'day', 'week', 'month', 'quarter', 'year'; тогда значения читаются как даты: Date, timestamp или ISO-строка. Строки приходят как есть, а схлопывает их график — так BI-система спрашивает у хранилища гранулярность времени:
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' }];Месяцы и кварталы шагают по календарю, а не фиксированным числом миллисекунд, поэтому февральский столбец ровно настолько же короче. Сетка выровнена по UTC — там же, где деления временной оси, — и столбец заканчивается точно на делении; неделя начинается с понедельника. Без явных axes серия сама просит ось time, а подсказка называет период — February 2025, Q1 2025 — вместо двух timestamp-ов.
Единица, много мельче размаха (секунды за десятилетие), потребовала бы миллионов столбцов: шаг растёт целыми единицами, пока сетка не уложится в тысячу корзин.
Диапазон и выбросы
domain строит корзины по фиксированному диапазону, а не по размаху данных: длинный хвост больше не прижимает к нулю столбцы, ради которых всё и рисовалось. Значения вне диапазона отбрасываются — либо собираются в крайние корзины через 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 }];
}Что означает высота столбца
normalize меняет прочтение столбцов, не трогая корзины: то же распределение отвечает на другой вопрос:
normalize | Столбец читается как |
|---|---|
'none' (по умолчанию) | само агрегированное значение |
'percent' | доля от общего, 0–100 |
'frequency' | та же доля в шкале 0–1 |
'density' | доля ÷ ширина корзины — площадь столбцов равна 1 |
'cumulative' | накопленный итог слева направо |
'cumulative-percent' | накопленная доля, последняя корзина — 100; эмпирическая функция распределения |
series: [{ type: 'histogram', xField: 'response', normalize: 'percent' }];'density' нужен там, где корзины разной ширины (явные bins) или где сравниваются два распределения с разным числом наблюдений — количества в этих случаях врут. Накопленные столбцы отвечают на вопрос «какая доля укладывается в это значение»:
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);
}В подсказке нормализованного столбца исходное значение остаётся в скобках — 33.3% (2). Форматтеру подписи доступно и то и другое: raw — значение до нормализации, count — число записей в корзине.
Разрез по полю
groupField превращает одно распределение в несколько на общей сетке корзин: сетка строится по всем данным, поэтому столбцы разных групп совпадают по границам и их можно читать друг относительно друга. Каждая группа получает цвет из палитры темы (или из fills) и собственный элемент легенды; выключенная в легенде группа исчезает и из итогов:
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 определяет, как группы делят корзину:
groupMode | Группы одной корзины |
|---|---|
'stacked' (по умолчанию) | складываются друг на друга — виден итог корзины |
'grouped' | стоят рядом, на расстоянии groupGap |
'overlay' | начинаются от нуля и рисуются одна поверх другой |
'normalized' | складываются и масштабируются к 100 — состав каждой корзины |
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 доля считается от самой группы, а во всех остальных режимах от всего графика. normalizeWithin: 'total' | 'group' переопределяет это в любую сторону:
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' отвечает на другой вопрос — из чего состоит каждый диапазон:
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);
}Биннинг за пределами графика
Биннинг графика экспортируется наружу: обработчик клика может отфильтровать строки по тем же границам, которые были нарисованы, а не пересчитывать их — иначе правила nice-шага разойдутся со столбцами:
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 отвечает, сколько корзин выбрало бы правило ('auto', 'fd', …) для выборки.
Подсказка пишется про корзину, поэтому tooltip.renderer получает корзину, а не строку: её границы, высоту, которую рисует столбец, агрегат за ней, число записей и группу:
tooltip: {
renderer: ({ x0, x1, count, seriesName }) => `${seriesName}: ${count} между ${x0} и ${x1} мс`,
}Подписи корзин
label — позиции как у 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;
}Опции
Общие опции всех серий (name, showInLegend, tooltip.renderer, …) — в разделе Общие опции серий.
| Опция | Тип | По умолчанию | Описание |
|---|---|---|---|
xField | string | — | поле для корзин: числа, а с календарным binWidth — даты |
yField | string | — | поле агрегации (опционально) |
aggregation | 'count' | 'sum' | 'mean' | count / sum | способ агрегации (с yField — sum) |
binCount | number | BinRule | 'auto' | число корзин или правило выбора |
binWidth | number | TimeBinUnit | — | ширина корзины или календарная единица; сильнее binCount |
binOrigin | number | 0 | точка привязки сетки корзин |
nice | boolean | true | округлять шаг до 1/2/5×10ⁿ |
binInclusive | 'left' | 'right' | 'left' | чья граница — левой или правой корзины |
bins | [number, number][] | — | явные границы корзин; сильнее всех |
domain | [number, number] | размах данных | диапазон биннинга |
outliers | 'exclude' | 'clamp' | 'exclude' | значения вне domain |
normalize | HistogramNormalize | 'none' | что означает высота столбца |
normalizeWithin | 'total' | 'group' | при overlay 'group', иначе 'total' | от чьего итога считается доля |
groupField | string | — | поле, разбивающее данные на группы |
groupMode | HistogramGroupMode | 'stacked' | как группы делят корзину |
fills | ColorValue[] | палитра темы | цвета групп |
groupGap | Fraction | 0 | зазор между соседними столбцами корзины |
fill | стили | палитра | оформление столбцов |
stroke | стили | палитра | оформление столбцов |
fillOpacity | стили | палитра | оформление столбцов |
strokeWidth | стили | 1 | обводка корзин |
label.enabled | boolean | false | показать подписи значений |
label.placement | внешние/center/inner-* (17 позиций) | 'top' | позиция подписи |
label.formatter | ({ value, x0, x1, raw, count, group }) => string | значение | содержимое подписи |
tooltip.renderer | (params: HistogramTooltipRendererParams) => … | — | подсказка про корзину |
label.fontSize | Pixels | 11 | размер шрифта подписи |
label.fontWeight | string | number | normal | насыщенность |
label.fontFamily | string | шрифт темы | гарнитура |
label.color | ColorValue | foreground; внутри — автоконтраст | цвет текста |