Skip to content

Histogram

Распределение числового поля по корзинам. xField — числовое поле (а с календарным binWidth — поле дат); без yField считается количество записей.

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 }));
}

Количество корзин

binCount управляет детализацией — это цель, а не обещание: шаг округляется до 1/2/5×10ⁿ, чтобы границы читались как числа, которые выбрал бы человек, и из-за этого корзин может оказаться на одну-две больше или меньше. nice: false даёт ровно binCount корзин от минимума до максимума данных.

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);
}

Без binCount количество выводится из самих данных. Правила названы так же, как в статистике: 'auto' (по умолчанию — Фридман–Диаконис, но не меньше Стёрджеса), 'sturges', 'fd', 'scott', 'rice':

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

Ширина корзины

binWidth — вторая формулировка того же: шаг задан, количество следует из него; именно так о биннинге спрашивают BI-системы. binOrigin задаёт точку привязки сетки (по умолчанию 0 — границы попадают на кратные ширине):

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
// недели с понедельника, а не с первого значения
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-система спрашивает у хранилища гранулярность времени:

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

Месяцы и кварталы шагают по календарю, а не фиксированным числом миллисекунд, поэтому февральский столбец ровно настолько же короче. Сетка выровнена по UTC — там же, где деления временной оси, — и столбец заканчивается точно на делении; неделя начинается с понедельника. Без явных axes серия сама просит ось time, а подсказка называет период — February 2025, Q1 2025 — вместо двух timestamp-ов.

Единица, много мельче размаха (секунды за десятилетие), потребовала бы миллионов столбцов: шаг растёт целыми единицами, пока сетка не уложится в тысячу корзин.

Диапазон и выбросы

domain строит корзины по фиксированному диапазону, а не по размаху данных: длинный хвост больше не прижимает к нулю столбцы, ради которых всё и рисовалось. Значения вне диапазона отбрасываются — либо собираются в крайние корзины через 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 }];
}

Что означает высота столбца

normalize меняет прочтение столбцов, не трогая корзины: то же распределение отвечает на другой вопрос:

normalizeСтолбец читается как
'none' (по умолчанию)само агрегированное значение
'percent'доля от общего, 0–100
'frequency'та же доля в шкале 0–1
'density'доля ÷ ширина корзины — площадь столбцов равна 1
'cumulative'накопленный итог слева направо
'cumulative-percent'накопленная доля, последняя корзина — 100; эмпирическая функция распределения
ts
series: [{ type: 'histogram', xField: 'response', normalize: 'percent' }];

'density' нужен там, где корзины разной ширины (явные bins) или где сравниваются два распределения с разным числом наблюдений — количества в этих случаях врут. Накопленные столбцы отвечают на вопрос «какая доля укладывается в это значение»:

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);
}

В подсказке нормализованного столбца исходное значение остаётся в скобках — 33.3% (2). Форматтеру подписи доступно и то и другое: raw — значение до нормализации, count — число записей в корзине.

Разрез по полю

groupField превращает одно распределение в несколько на общей сетке корзин: сетка строится по всем данным, поэтому столбцы разных групп совпадают по границам и их можно читать друг относительно друга. Каждая группа получает цвет из палитры темы (или из fills) и собственный элемент легенды; выключенная в легенде группа исчезает и из итогов:

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 определяет, как группы делят корзину:

groupModeГруппы одной корзины
'stacked' (по умолчанию)складываются друг на друга — виден итог корзины
'grouped'стоят рядом, на расстоянии groupGap
'overlay'начинаются от нуля и рисуются одна поверх другой
'normalized'складываются и масштабируются к 100 — состав каждой корзины
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 доля считается от самой группы, а во всех остальных режимах от всего графика. normalizeWithin: 'total' | 'group' переопределяет это в любую сторону:

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' отвечает на другой вопрос — из чего состоит каждый диапазон:

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);
}

Биннинг за пределами графика

Биннинг графика экспортируется наружу: обработчик клика может отфильтровать строки по тем же границам, которые были нарисованы, а не пересчитывать их — иначе правила nice-шага разойдутся со столбцами:

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 отвечает, сколько корзин выбрало бы правило ('auto', 'fd', …) для выборки.

Подсказка пишется про корзину, поэтому tooltip.renderer получает корзину, а не строку: её границы, высоту, которую рисует столбец, агрегат за ней, число записей и группу:

ts
tooltip: {
  renderer: ({ x0, x1, count, seriesName }) => `${seriesName}: ${count} между ${x0} и ${x1} мс`,
}

Подписи корзин

label — позиции как у 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;
}

Опции

Общие опции всех серий (name, showInLegend, tooltip.renderer, …) — в разделе Общие опции серий.

ОпцияТипПо умолчаниюОписание
xFieldstringполе для корзин: числа, а с календарным binWidth — даты
yFieldstringполе агрегации (опционально)
aggregation'count' | 'sum' | 'mean'count / sumспособ агрегации (с yField — sum)
binCountnumber | BinRule'auto'число корзин или правило выбора
binWidthnumber | TimeBinUnitширина корзины или календарная единица; сильнее binCount
binOriginnumber0точка привязки сетки корзин
nicebooleantrueокруглять шаг до 1/2/5×10ⁿ
binInclusive'left' | 'right''left'чья граница — левой или правой корзины
bins[number, number][]явные границы корзин; сильнее всех
domain[number, number]размах данныхдиапазон биннинга
outliers'exclude' | 'clamp''exclude'значения вне domain
normalizeHistogramNormalize'none'что означает высота столбца
normalizeWithin'total' | 'group'при overlay 'group', иначе 'total'от чьего итога считается доля
groupFieldstringполе, разбивающее данные на группы
groupModeHistogramGroupMode'stacked'как группы делят корзину
fillsColorValue[]палитра темыцвета групп
groupGapFraction0зазор между соседними столбцами корзины
fillстилипалитраоформление столбцов
strokeстилипалитраоформление столбцов
fillOpacityстилипалитраоформление столбцов
strokeWidthстили1обводка корзин
label.enabledbooleanfalseпоказать подписи значений
label.placementвнешние/center/inner-* (17 позиций)'top'позиция подписи
label.formatter({ value, x0, x1, raw, count, group }) => stringзначениесодержимое подписи
tooltip.renderer(params: HistogramTooltipRendererParams) => …подсказка про корзину
label.fontSizePixels11размер шрифта подписи
label.fontWeightstring | numbernormalнасыщенность
label.fontFamilystringшрифт темыгарнитура
label.colorColorValueforeground; внутри — автоконтрастцвет текста