На Хабре есть хороший материал по диаграмме Санки (Сэнки, Санкея, Sankey) в дополнение к нему хотелось бы рассказать, как подходить к созданию, и немного углубиться в вопросы некоторых особенностей построения связей. К сожалению, прочитал я её после того как решил свои задачи, поэтому процесс получился отличным, и некоторыми деталями этого процесса хотел поделиться.
Важное наблюдение: визуализация оказалось самым сложным этапом разработки. И ещё более интересное наблюдение: скажем, так, если бы этой визуализации не было, какие-то фатальные последствия для операционной деятельности вряд ли наступили бы.
Меня диаграмма Санки впечатлила силой визуализации, особенно хорошо это видно на сравнении с пирожковой круговой диаграммой. Получается намного более наглядное представление данных.

Но самое главное, для меня, это возможность сочетания разных характеристик данных в одном представлении. У меня это было структурой трафика: можно посмотреть не только вклад авторов в общий трафик, но и структуру вклада, выделяя потоки, например, эксклюзивы.
Правда надо понимать, диаграмма Санки хороша для отображения структуры, и совершенно не подходит для отображения динамики.
Итак, исходная задача и сложности: мне нужно было собрать диаграмму Санки, для ежедневного/еженедельного/квартального, одним словом регулярного представления данных о структуре трафика. Сложность в том, что не все параметры одинаковы каждый день. Т.е. количество узлов в диаграмме всё время меняется (бывают дни когда нет эксклюзивов, на выходных другой состав авторов и так далее).
Basic Sankey Diagram или основное что нужно знать
Документация по диаграмме тут.
На plotly построение состоит из двух основных блоков: узлы и связи. Связей обычно больше чему узлов, в чем собственно, вся прелесть этой визуализации и есть. Данные передаются в виде кортежей (списков), тут в общем-то главная сложность (для меня и была скрыта), списки друг с другом не связаны, и создать путаницу в буквальном смысле не сложно.
Скрытый текст
import plotly.graph_objects as go
fig = go.Figure(data=[go.Sankey(
node = dict(
pad = 15,
thickness = 20,
line = dict(color = "black", width = 0.5),
label = ["A1", "A2", "B1", "B2", "C1", "C2"],
color = "blue"
),
link = dict(
source = [0, 1, 0, 2, 3, 3], # indices correspond to labels, eg A1, A2, A1, B1, ...
target = [2, 3, 3, 4, 4, 5],
value = [8, 4, 2, 8, 4, 2]
))])
fig.update_layout(title_text="Basic Sankey Diagram", font_size=10)
fig.show()
Узлы (строка 4, словарь node) принимает значения:
название (label),
цвет (color),
координата по оси икс (x),
координата по оси y (y).
Плюс толщина линий, расстояние между потоками.
Связи (строка 12, словарь link) принимает значения:
Источник данных (source) – жестко привязан к индексу label в части узлов (т.е. индекс названия в списке label и есть source);
Цель или направление (target): это тоже индексы названий узлов (label), от какого (source) узла к какому (target) направлен поток. Если направления нет, выходящего потока из узла не отрисовывается;
Значение (value): размер потока от источника к другому узлу (source-target);
Название потока (label);
Цвет поток (color).
Длина передаваемых списков желательно должна быть одинаковой.

На рисунке каждый элемент списка label выделен цветом и стрелкой показаны индексы в значениях source и target справа приведены связи узлов по названиям и собственно сама диаграмма.
Другими словами - индексы значений в списке label для словаря node - отправная точка, от которой собирается вся диаграмма.
Диаграмма сама суммирует входящие на узлы потоки, если есть какие-то расхождения, это сразу довольно наглядно видно.
Задача довольно простая, нужно выстроить связи между узлами (node - label) и прописать значения для потоков. Небольшие сложности возникают, когда индексы начинают гулять туда-сюда, и вся диаграмма категорически не желает собираться.
Конструктор для динамической сборки диаграммы
Как появилась эта задача: сперва было интересно посмотреть просто вклад каждого (обычная круговая диаграмма, см картинку выше) но потом задача усложнилась, так как общий вклад сильно структурирован: бывают новости (которых пишут много), бывают статьи, которых пишут мало, ещё есть галереи, которые вообще не пишут), а новости и статьи бывают эксклюзивными.
На первом шаге я взял листок бумаги и просто нарисовал как примерно должен выглядеть результат (а до этого, нужно ещё понять, зачем вообще всё это делается, и какие задачи решает), в нашем случае, это оценка вклада, трудозатрат и эффективности.

На рисунке видно, довольно неочевидную классификацию: это объекты, для которых характерны одни и те же связи. Приведу сразу парами source-target:
автор -> эксклюзив
автор -> новость
автор -> статья
автор -> галерея
эксклюзив -> новость
эксклюзив -> статья
новость -> всего
статья -> всего
галерея -> всего
Решение получилось тоже простое: это адресная книга.
У каждого узла есть жёсткий список его адресатов, нужно всего лишь отвязать его от текущих индексов и сделать в виде отдельной карты. Далее, при сборке данных программа сразу понимает куда какой поток направлять.
Запишем в виде словаря:
destanation_map_dict = {
'author': ['exclusives', 'news', 'arts', 'gallery'],
'exclusives': ['news', 'arts'],
'news': ['total'],
'arts': ['total'],
'gallery': ['total'],
'total': [],
}
Теперь у каждого объекта есть список назначений, осталось только только всё собрать, рассчитать и распределить каждого по адресу.
Этап расчётов и подготовки данных пропущу (некоторые нюансы под спойлером), если в двух словах, то у меня это был список именованных кортежей, куда вносятся нужные значения для каждой тройки: источник-назначение-значение (source-target-value).
Скрытый текст
Например, от автора есть прямая связь с новостью и статьёй а есть через эксклюзив. В этом варианте не будет видно, от какого автора дальше на какой тип материала потянется нитка, т.е. узел эксклюзивов впитает в себя всех авторов, и дальше распределит по типам материалов. Тут при расчёте входящих и выходящих потоков это надо учитывать, т.е. общей трафик автора будет делиться на несколько частей исходя из последовательности шагов диаграммы:
Значение для эксклюзива (для новостей) - промежуточный узел
Значение новости (прямая связь) = общее значение на новости минус эксклюзив (для новостей)
И аналогично для для статей. Т.е. мы сперва считаем сколько трафика ушло на первый (промежуточный) узел (эксклюзивы), потом вычитаем это значение из второго узла (новости).
Т.е. у каждого автора есть значения для эксклюзивов, новостей, статей, галерей; эксклюзивы связаны с новостями и статьями. Новости, статьи и галереи связаны только с итоговым числом, итог ни с кем не связан, это финальный узел. Тут ещё есть хитрость с нулевыми значениями, если value = 0, библиотека просто не отображает этот кусок.
Правда именно на этом моменте я потерял больше всего времени, в основном на том, что пытался с разбегу прописать индексы. После того как присвоение финальных индексов на основе лейблов сделал самым последним шагом, всё встало на свои места.
В моём случае, я воспользовался следующей структурой данных:
class SankyLableChain(NamedTuple):
label: str = ''
source: str = ''
value: int = 0
link_color: str = ""
node_color: str = ""
x_pos: float = 0
y_pos: float = 0
target: str = ''
Тут весь секрет в том, что в значении source присваиваем имя объекта, а в значении target имя узла-назначения. И не торопимся присваивать индексы, пока работаем только с наименованиями.
На этом этапе главное верно присвоить значения из словаря-карты, авторам - авторов, остальным объектам их значения. Дальше проходимся по таблице, и сверяем какие данные для какого направления есть. Получаются примерно такие цепочки для всех значений:
SankyLableChain(
label='Эксклюзивы: 795 (1шт.)',
source='exclusives',
value=795,
link_color='rgba(219,41,35, 0.7)',
node_color='rgba(219,41,35, 0.9)',
x_pos=0.3,
y_pos=0.2,
target='news',
)
Т.е. вместо индексов для source и target присваиваем имена из словаря-карты в этом примере, это Эксклюзивы (1шт), имя 'exclusives' и target на 'news'. Значения из node повторяются, значения из link разные. После того, как все данные собрались в цепочки, для каждого лейбла, дела сразу пошли на лад.
Сводим всё в единую табличку, формируем словарь индексов и прописываем итоговые кортежи.
Скрытый текст
labels = []
source = []
indexes_dict_new = {}
index_count = 0
for part in collect_lables: # список всех цепочек данных srs-trg-val
if part.lable not in lables: # проверяем уникальность
labels.append(part.label) # добавляем значение лейбла
source.append(index_count)
indexes_dict_new[part.source] = index_count # part.source это имя (ключ) из словаря-карты destanation_map_dict
index_count += 1
plot_data = SankyPlotData(
lable=lables,
source=[indexes_dict_new.get(x.source) for x in collect_lables],
target=[indexes_dict_new.get(x.destination) for x in collect_lables],
values=[x.value for x in collect_lables],
link_color=[x.link_color for x in collect_lables],
node_color=node_color,
x_position=x_poses,
y_position=y_poses,
)
В первой части можно вместе со словарём индексов собрать и всю часть node (названия, цвета, координаты). Пробегаем по уникальным значениям labels и присваиваем индексы (индексы присваиваются не по значению подписи, а по названию объекта тут (author, exclusives, news, arts, gallery, total).
На выходе получаем рабочий механизм, который может справляться с переменным количеством параметров. У меня пока сбоев не давал.
Скрытый текст
fig = go.Figure(go.Sankey(
#arrangement='snap',
node = dict(
thickness = 10,
line = dict(color = "black", width = 0),
label = ['Автор-0 (38.25%)',
'Автор-1 (12.80%)',
'Автор-2 (18.74%)',
'Автор-3 (11.00%)',
'Автор-4 (19.22%)',
'Эксклюзивы (1шт.)',
'Новости 45.79%',
'Заметки 35.47%',
'Галереи 18.74%',
'Всего просмотров: 10.16 тыс'],
color = plot_data.node_color,
#x = [0, 0, 0, 0, 0, 0.3, 0.6, 0.6, 0.6, 0.9],
#y = [0, 0, 0, 0, 0, 0.2, 0.1, 0.1, 0.1, 0.55],
pad = 25,
),
link = dict(
source = [0, 0, 1, 2, 3, 3, 4, 5, 6, 7, 8, 9],
target = [6, 7, 6, 8, 5, 6, 6, 6, 9, 9, 9, None],
value = [0.28, 3.6, 1.3, 1.9, 0.7, 0.3, 1.9, 0.79, 4.6, 3.6, 1.9, 10.157],
color = plot_data.link_color,
)))
fig.show()

Отдельного разговора заслуживают цветовые решения и позиционирование потоков.