d3js
January 11, 2021

Как сделать BarChart с несколькими сериями данных

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

В прошлый раз мне несколько человек написали, что было бы круто, если бы в примеры можно было потыкать. Поэтому теперь я буду не только выкладывать код в гит репозиторий, но и делать небольшие интерактивные codepen'ы.

Ссылки к этому туториалу:

  1. Codepen с графиком
  2. Полный код графика на github

Подготовка данных

Подготовку проекта и создание svg элемента я уже описывала в предыдущем туториале, поэтому не буду повторяться. А вот данные я сгенерирую другие. Пусть у меня будут двенадцать месяцев и столбики значений от 0 до 1 (округлённые до 2 знаков после запятой) для каждого из них:

​const months = [
  'янв', 'фев', 'март', 'апр', 'май', 'июнь',
  'июль', 'авг', 'сент', 'окт', 'ноя', 'дек'
];
const data = [];

months.forEach(month => {
  data.push({ month, value: Math.round(Math.random() * 100) / 100 });
}​);

Подготовка шкал

В прошлом туториале я уже объясняла, что такое линейная шкала и как её сделать. Здесь такая тоже есть — это ось y, она будет определять высоту столбиков. Height тут как и раньше — высота всего графика.

const yDomain = [d3.min(data, d => d.value), d3.max(data, d => d.value)];
const yScale = d3.scaleLinear()
  .domain(yDomain)
  .range([height, 0]);

Ось x же не линейная и делается по-другому:

const xScale = d3.scaleBand()
  .domain(months)
  .range([0, width])
  .padding(0.1);

​Это ось групп или диапазонов (bands). В D3 для её создания есть специальный метод d3.scaleBand. Ему передаются диапазоны координат экрана (range, от 0 до ширины графика) и данных (domain, массив всех месяцев). Параметр padding необязательный, он отвечает за промежутки между столбиками.

В этой оси каждому значению из данных соответствует некоторый диапазон экранных координат.

Рисование столбиков

Рисование шкал я пропущу, потому что оно не зависит от типов этих шкал. Поэтому его можно просто скопировать из предыдущего туториала.

Рисование же самого графика похоже на scatter:

svg.selectAll('rect')
  .data(data)
  .enter()
  .append('rect')
  .attr('x', d => xScale(d.month))
  .attr('width', xScale.bandwidth())
  .attr('y', d => yScale(d.value))
  .attr('height', d => height - yScale(d.value))
  .attr('fill', 'orange');

Я создаю объект Selection, выделяя все rect элементы (изначально их нет), и передаю ему данные, используя методы data и enter. Затем, используя метод append, я для каждой записи данных добавляю свой rect элемент.

Координаты каждого элемента определяются созданными ранее шкалами xScale и yScale. Ширина находится с помощью специальной функции bandwidth — она вычисляет ширину каждого столбика, зная промежутки и ширину всего графика. Высота вычисляется с помощью шкалы yScale. Хак с height-yScale нужен, чтобы перевернуть стобики, в системе координат svg они по-умолчанию окажутся сверху вниз.

График получается такой:

Несколько серий данных

Допустим, для каждого месяца у меня не одно, а несколько значений, и я хочу показать их все на одном графике. Сначала я изменю исходные данные, чтобы вместо одного значения value у каждого месяца был бы массив из нескольких значений:

const count = 3;

months.forEach(month => {
  const values = [];
  
  for (let i = 0; i < count; i++) {
    values.push({
      key: i,
      value: Math.round(Math.random() * 100) / 100,
    });
  }
  data.push({ month, values });
});

Key – это название серии данных. Для простоты я использовала индексы.

Изменение в шкалах

Самые важные изменения происходят со шкалами. Раньше у меня по оси x была обычная band шкала, которая определяла, в каком месте и какой ширины рисовать конкретный столбик. Теперь у меня в этом же месте будут три столбика вместо одного. Поэтому появляется ещё одна дополнительная шкала:

const xInnerDomain = data[0].values.keys();
const xInnerScale = d3.scaleBand()
  .domain(xInnerDomain)
  .rangeRound([0, xScale.bandwidth()]);

​Если областью определения (domain) первой шкалы были месяцы, то во второй шкале — это названия серий данных. Так получается, потому что данные вложенные: для каждого месяца есть набор из трёх разных значений. Дополнительная шкала существует в рамках основной и зависит от неё, поэтому её координаты экрана (range) равны диапазону от 0 до ширины любого элемента основной шкалы.

Тут я использую метод rangeRound, чтобы при отображении пиксели автоматически округлялись и итоговая картинка получалась чёткой (результат вызова bandwidth может быть нецелым числом).

Немного меняется и определение области определения оси y (добавляется вложенность значений):

const yDomain = [
  d3.min(data, d => d3.min(d.values.map(v => v.value))),
  d3.max(data, d => d3.max(d.values.map(v => v.value)))
];
const yScale = d3.scaleLinear().domain(yDomain).range([height, 0]);

Цветовая шкала

Так как у меня появилось несколько столбиков для каждого месяца, я хочу их как-то различать. Самое простое — это цвет. Я, конечно, могла бы красить их вручную, через css и nth-child, но это очень неуниверсальное решение. Поэтому я хочу сделать шкалу для определения цвета:

​const colorScale = d3.scaleOrdinal(d3.schemeCategory10);

​Метод d3.scaleOrdinal создаёт дискретную шкалу. С её помощью можно определить соответствие одного набора элементов другому. Например, цвета и категории или категории и их последовательность. Отличие от линейной шкалы тут в том, что количество элементов в наборах фиксировано и исчислимо.

Шкалу можно определить, вызвав у неё методы range и domain (так я делала раньше), а можно, передав ей массив range в конструкторе. В этом случае domain будет пустым массивом, а его роль будут выполнять индексы массива range.

В D3 много вспомогательных методов и объектов, в том числе для создания шкал. Здесь d3.schemeCategory10 — это набор из десяти заготовленных цветов. Это цветовая схема, таких схем в D3 десять штук, вот тут их все можно посмотреть.

Изменения при рисовании

Я уже писала выше, что данные и шкалы тут можно назвать иерархически вложенными. Эта вложенность влияет и на элементы в html коде. Каждые три столбика для одного месяца я буду группировать с помощью элементов g с классом group:

const barGroup = svg.selectAll('.group')
  .data(data)
  .enter()
  .append('g')
  .attr('class', 'group')
  .attr('transform', d => `translate(${xScale(d.month)},0)`);

Каждый элемент сдвигается по оси x на своё место с помощью аттрибута transform.

Внутри каждой группы я буду рисовать по 3 столбика:

barGroup.selectAll('rect')
  .data(d => d.values)
  .enter()
  .append('rect')
  .attr('x', d => xInnerScale(d.key))
  .attr('width', xInnerScale.bandwidth())
  .attr('y', d => yScale(d.value))
  .attr('height', d => height - yScale(d.value))
  .style('fill', d => colorScale(d.key));

В методе data я передаю ​всей группе все данные, а столбикам — только данные, относящиеся к их группе. Каждый столбик использует дополнительную шкалу xInnerScale, чтобы определить свои положение и ширину. Цвет определяется из цветовой шкалы colorScale. Я передаю ей номер столбика и получаю его цвет.

И вот итоговый результат:

Ещё раз ссылки

  1. Codepen с графиком
  2. Полный код на github