d3js
June 19, 2023

Как сделать Sankey график на D3.js

Санкей (sankey) — это график, показывающий поток данных. Чаще всего его можно встретить в визуализациях веб траффика, финансовых транзакций или потребления энергии. Это граф, который отлично отображает, как связаны между собой элементы системы и какие роли они играют.

Скорее всего вы хорошо знаете первый знаменитый санкей график — это визуализация Чарльза Минарда наступления армии Наполеона на Россию в войне 1812 года.

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

В этой статье я собираюсь рассказать, как сделать санкей график с помощью D3.js и объяснить, как именно он работает. Чтобы пропустить все объяснения и сразу посмотреть код, кликайте на вот эти ссылки:

  1. Интерактивный Codepen
  2. Код на Github

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

В предыдущих туториалах я генерировала случайные данные. В этот раз я решила найти что-то не очень объёмное, но вполне реальное. После небольших поисков в открытых источниках я наткнулась на визуализацию производства и потребления электроэнергии в США в 2021 году.

Скопировав значения, я собрала такой файл. В нём данные уже приведены к нужному D3 формату. Санкей показывает связи между элементами системы, поэтому в данных есть две части: nodes — сами элементы и links — связи этих элементов. У каждого элемента есть его номер (id) и название (name). У каждой связи есть сила (value) и ссылки на соответствующие ей элементы (target и source указывают на id).

{
  "nodes": [
    { "id": 0, "name": "coal" },
    { "id": 1, "name": "natural gas" },
    ...
    { "id": 8, "name": "fossil fuels" },
    { "id": 9, "name": "energy consumed to generate electricity" },
    ... 
  ],
  "links": [
    { "source": 0, "target": 8, "value": 9.48 },
    { "source": 1, "target": 8, "value": 11.94 },
    ...
    { "source": 8, "target": 9, "value": 21.69 },
    ...
  ]
}

Подготовка графика

Я не буду описывать, как я подготавливаю страницу и SVG элементы, потому что я всегда делаю это одинаково и это можно найти в старой статье «Как сделать LineChart».

Единственное отличие от всех предыдущих туториалов — дополнительный скрипт с кодом для создания санкей графика, который я как и основной скрипт D3 вставляю в тег head.

Перейду сразу к основной части:

const sankey = d3.sankey()
  .nodeWidth(20)
  .nodePadding(20)
  .size([width, height]);

Функция d3.sankey создаёт и возвращает мне сложный метод, который будет отвечать за построение графа. Ему заранее, ещё до всех вычислений, нужно передать параметры. Здесь это ширина каждого элемента, расстояние между соседями и размер графика. Это не все возможные настройки, например, можно изменить функцию сортировки связей и вершин или тип выравнивания элементов.

Чтобы вычислить граф и положение его вершин и связей, я передаю в этот метод данные, прочитанные из json файла:

d3.json(filename).then(data => {
  const graph = sankey(data);
  ...
});

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

Так выглядят вычисленные связи
А так — вычисленные вершины

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

Теперь у меня есть все значения, и я могу рисовать элементы и их связи. Начну со связей:

const link = svg.append('g')
  .selectAll('.link')
  .data(graph.links)
  .enter()
  .append('g')
  .attr('class', 'link');

Можно было бы просто покрасить их в серый или в цвет, соответствующий одной из вершин, но я видела красивый пример с градиентами и решила сделать так же. Градиенты в SVG делаются непросто — в каждой группе нужно создать свой linearGradient:

  link.append('linearGradient')
    .attr('id', d => `link-${d.index}`)
    .attr('gradientUnits', 'userSpaceOnUse')
    .attr('x1', d => d.source.x1)
    .attr('x2', d => d.target.x0)
    .call(gradient => gradient.append('stop')
      .attr('offset', '0%')
      .attr('stop-color', ({ source }) => colorScale(source.name)))
    .call(gradient => gradient.append('stop')
      .attr('offset', '100%')
      .attr('stop-color', ({ target }) => colorScale(target.name)));

Не буду подробно рассказывать, что тут что, про градиенты в SVG можно подробно прочитать, например, тут. Мне нужны линейные градиенты с двумя цветами, соответствующими вершинам. Для вычисления этих цветов я заранее создала самую простую цветовую шкалу:

const colorScale = d3.scaleOrdinal(d3.schemeTableau10);

Завершаю рисование связей добавлением элементов path:

link.append('path')
  .attr('d', d3.sankeyLinkHorizontal())
  .attr('stroke', ({ index: i }) => `url(#link-${i})`)
  .attr('stroke-width', ({ width }) => Math.max(1, width));

Их цвет — созданный ранее градиент, размер — не меньше 1 пикселя, а геометрию я получаю из метода d3.sankeyLinkHorizontal всё того же скрипта d3-sankey.js.

Получаю вот такие разноцветные линии с дырками между ними

Рисование вершин и подписей

Осталось нарисовать и подписать вершины графа. Как и со связями добавляю группы и привязываю данные:

const node = svg.append('g')
  .selectAll('.node')
  .data(graph.nodes)
  .enter()
  .append('g')
  .attr('class', 'node');

В каждой группе рисую прямоугольник:

node.append('rect')
  .attr('x', d => d.x0)
  .attr('y', d => d.y0)
  .attr('height', d => d.y1 - d.y0)
  .attr('width', d => d.x1 - d.x0)
  .style('fill', d => colorScale(d.name));

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

Почти всё, осталось только подписать вершины. Я сделаю это с помощью элементов text (отступы и параметры шрифта я выбрала на глаз). Подписи слева от центра выровнены по правому краю соответствующего элемента, а подписи справа — по левому:

const text = node.append('text')
  .attr('x', d => d.x0 - 3)
  .attr('y', d => (d.y1 + d.y0) / 2)
  .attr('dy', '0.35em')
  .attr('text-anchor', 'end')
  .text(d => d.name)
  .filter(d => d.x0 < width / 2)
  .attr('x', d => d.x1 + 3)
  .attr('text-anchor', 'start');

И это всё, получился вот такой график:

Ссылки

  1. Пример на Codepen
  2. Код на Github
  3. Сам получившийся график