d3js
December 17, 2020

Как сделать LineChart и ScatterChart и в чём разница между методами data() и datum()

Недавно для одного проекта я собирала простые визуализации на D3.js и решила написать небольшой туториал по теме. Я хочу рассказать, как сделать два самых распространённых типа графиков (line и scatter), и объяснить, как работают методы, которые при этом используются.

По ссылкам можно найти код графиков:

  1. Line chart
  2. Scatter chart

Настройка проекта

Начну с подготовки. Обычно я люблю разделять код, стили и вёрстку. Это удобно, когда проект большой и надо в нём ориентироваться. Но с небольшими одностраничными примерами я буду держать всё в одном файле вёрстки: index.html.

Его структура выглядит так:

<html>
  <head>
    <style></style>
    <script src="https://d3js.org/d3.v6.min.js"></script>
  </head>
  <body>
    <svg></svg>
    <script></script>
  </body>
</html>

Здесь тег style — место для всех стилей (их практически не будет), тег svg — контейнер для графика, а пустой тег script — место для моего JS кода.

Я помещаю свой скрипт в конце документа (после скрипта D3 и svg элемента) потому что мне нужно, чтобы он загрузился самым последним. Тогда я смогу использовать глобальную переменную d3, которая станет доступна в JS коде, и все элементы вёрстки. Обычно скрипты и элементы в вёрстке грузятся синхронно и последовательно.

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

Общая структура готова, но прежде чем начать рисовать график, мне нужны данные. Я хочу сделать простой пример, поэтому данные я сгенерирую случайным образом.

Внутри тега script я добавлю следующий код:

const N = 20;
const data = [];

for (let i = 0; i < N; i++) {
  data.push({ x: i, y: Math.round(Math.random() * N) });
}​

Здесь константа N определяет размер массива данных или число точек будущих графиков. Я люблю задавать это число параметром, а не писать его явно в коде, потому что так проще будет что-то изменить, если потребуется. Data — это массив, который мы заполняем точками с координатами x (просто порядковый номер точки) и y (случайное целое число от 0 до N).

Подготовка SVG

Когда данные есть, можно начинать рисовать график. Сначала я определю параметры графика:

const width = 600;
const height = 400;
​const margin = { top: 25, right: 25, bottom: 25, left: 25 };

​Размеры необязательно указывать в явном виде значениями, можно вычислить их из вёрстки или css. Но чтобы сделать проще, я задам их явно. Margin — это внутренние отступы графика, они будут нужны для осей.

После я подготовлю svg контейнер. Как видно в html вёрстке выше, он пустой и не стилизованный. Я хочу указать ему выбранные размеры и создать внутри тег g, в котором уже буду что-то рисовать. Созданный элемент g я сдвину на величину отступов.

const svg = d3.select('svg')
  .attr('width', width + margin.left + margin.right)
  .attr('height', height + margin.top + margin.bottom)
  .append('g')
  .attr('transform', `translate(${margin.left},${margin.top})`);

G используется для группировки элементов. Чаще всего он нужен для структурирования графики и для трансформаций — любая трансформация всей группы применяется ко всем её элементам.

Если вам захочется лучше разобраться в том, как устроена векторная графика, то у Мозиллы есть хороший и полный гайд.

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

Чтобы нарисовать оси и сам график, мне нужны шкалы. Они помогут в работе с масштабом и правильным преобразованием значений данных в точки на экране.

Чтобы лучше понимать задачу преобразования, можно представить, что у вас есть диапазон значений данных от 0 до 2 и диапазон экранных координат от 0 до 100. Если вы линейно проецируете один диапазон на другой, то нули будут соответствовать друг другу, 2 будет соответствовать 100, а 0.5 — 25.

Можно даже записать общую формулу перевода значения value из диапазона r1 в диапазон r2:

r2.min + ((value - r1.min) / (r1.max - r1.min)) * (r2.max - r2.min)

Для создания шкал я буду использовать метод d3.scaleLinear(). Ему нужно передать два диапазона (данных и координат экрана), а он будет потом выполнять все преобразования.

Но сначала я найду диапазоны значений из данных. Я использую методы d3.min и d3.max, чтобы найти минимальное и максимальное значение, но можно делать это любым другим способом.

const xDomain = [d3.min(data, d => d.x), d3.max(data, d => d.x)];
const yDomain = [d3.min(data, d => d.y), d3.max(data, d => d.y)];

Затем создам шкалы для осей x и y:

​const xScale = d3.scaleLinear().domain(xDomain).range([0, width]);
const yScale = d3.scaleLinear().domain(yDomain).range([height, 0]);​​

У получившихся шкал domain — диапазон значений данных, а range — диапазон координат экрана. Координаты экрана мне известны — это размены графика. Ось y перевёрнута, потому что начало координат в системе svg находится в левом верхнем углу.

Рисование осей

Теперь, когда шкалы готовы, можно добавить оси:

svg.append('g')
  .attr('transform', `translate(0,${height})`)
  .call(d3.axisBottom(xScale));

svg.append('g')
  .call(d3.axisLeft(yScale));

Ось x у меня будет снизу, а ось y слева. Горизонтальную ось мне нужно сдвинуть на высоту основной части графика, для этого я укажу ей атрибут transform.

Call обычно используется в JS, чтобы вызвать метод, подставив ему контекст выполнения. Здесь метод call — это метод объекта класса Selection. Он вызывает метод d3.axisBottom (или d3.axisLeft), передавая ему себя в качестве одного из параметров. А метод d3.axisBottom уже сам рисует внутри ось.

Рисование линии

Теперь всё готово для самой линии графика. Чтобы её нарисовать, мне нужна функция, которая будет конструировать строку команд для path элемента из значений данных, используя созданные ранее шкалы. Я создам её с помощью метода d3.line:

const line = d3.line()
  .x(d => xScale(d.x))
  .y(d => yScale(d.y));

И вот наконец можно добавить path элемент, который будет отображать сами данные:

svg.append('path')
  .datum(data)
  .attr('d', line);

Данные я передаю, вызвав метод datum (про него я ещё расскажу), а атрибуту d присвою созданную функцию line.

Теперь, казалось бы, всё готово. Но если открыть страницу, то графика на ней не окажется. Это потому что я не добавила стили. Оси меня устраивают как есть, а вот линия пусть будет чёрная и в 1 пиксель толщиной. Для этого внутри тега style я напишу:

​path {
  fill: none;
  stroke: black;
  stroke-width: 1px;
}

​И вот сейчас если открыть страницу, то можно увидеть вот такой график:

Рисование кружочков

Допустим, что я хочу сделать не LineChart, а ScatterChart. То есть не соединять точки линией, а показать их как скопление кружочков. Для этого я могу переиспользовать весь код до последней части с рисованием линии.

Мне уже не нужна никакая d3.line, вместо неё я возьму свой массив точек и для каждой нарисую кружочек.

svg.selectAll('circle')
  .data(data)
  .enter()
  .append('circle')
  .attr('cx', d => xScale(d.x))
  .attr('cy', d => yScale(d.y));

И опять же добавлю в стили:

​circle {
  r: 5;
  fill: black;
}

И получу такой график (я рисовала графики отдельно, поэтому данные сгенерировались другие):

Как передавать данные графикам

А теперь самое интересное — детали и нюансы реализации. Вы заметили, что когда я делала линию, я передавала данные с помощью метода datum, а когда делала кружочки — с помощью data и enter? При этом, если попробовать сделать по-другому, то либо ничего не нарисуется, либо возникнут ошибки.

Все эти три метода — методы класса Selection. Их код можно найти в репозитории d3-selection.

Вопрос «что такое Selection» — тема для отдельного рассказа. Если коротко, это специальная сущность, которая нужна D3 для работы с DOM элементами. Selection создаётся, когда мы делаем d3.select и модифицируется всеми остальными вызовами.

Метод datum очень простой и маленький. Он берёт выбранный через d3.select элемент и добавляет ему атрибут __data__ с переданными данными. Затем при добавлении любого следующего атрибута, если его значение — функция, она получит __data__ массив как один из своих параметров. Так и происходит с методом line: он берёт данные из этого атрибута, преобразовывает их в координаты и рисует по ним линию.

Метод data работает по-другому. Когда я передаю ему массив с данными, он анализирует их, определяет, сколько будет добавлено элементов и какая строка данных какому элементу должна соответствовать. Вызванный следом метод enter создаёт из этого новый объект Selection. Любой метод вызванный после этого уже будет работать с целым массивом элементов. Так работает указание атрибутов cx и cy — я пишу всего одну строчку, а вызывается она много раз, для каждого добавляемого кружочка на экране.

Ссылки

  1. Код line chart
  2. Результат line chart
  3. Код scatter chart
  4. Результат scatter chart