Как сделать BarChart с несколькими сериями данных
В этом туториале я разберу создание столбчатой диаграммы (bar chart) сначала для одной, а потом и для нескольких серий данных. Ещё немного расскажу про цветовые шкалы.
В прошлый раз мне несколько человек написали, что было бы круто, если бы в примеры можно было потыкать. Поэтому теперь я буду не только выкладывать код в гит репозиторий, но и делать небольшие интерактивные codepen'ы.
Ссылки к этому туториалу:
Подготовка данных
Подготовку проекта и создание 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. Я передаю ей номер столбика и получаю его цвет.
И вот итоговый результат: