Как сделать Sankey график на D3.js
Санкей (sankey) — это график, показывающий поток данных. Чаще всего его можно встретить в визуализациях веб траффика, финансовых транзакций или потребления энергии. Это граф, который отлично отображает, как связаны между собой элементы системы и какие роли они играют.
Скорее всего вы хорошо знаете первый знаменитый санкей график — это визуализация Чарльза Минарда наступления армии Наполеона на Россию в войне 1812 года.
Тогда у такого графика ещё не было своего названия. Оно появилось только в 1898 году в честь ирландского капитана Мэттью Санкея, который нарисовал схему работы парового двигателя.
В этой статье я собираюсь рассказать, как сделать санкей график с помощью D3.js и объяснить, как именно он работает. Чтобы пропустить все объяснения и сразу посмотреть код, кликайте на вот эти ссылки:
Подготовка данных
В предыдущих туториалах я генерировала случайные данные. В этот раз я решила найти что-то не очень объёмное, но вполне реальное. После небольших поисков в открытых источниках я наткнулась на визуализацию производства и потребления электроэнергии в США в 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');
И это всё, получился вот такой график: