2025, Oct 20 00:19
Как выровнять узлы в диаграмме Санки Plotly: вычисляем Y из сумм потоков
Как выровнять узлы в диаграмме Санки Plotly при arrangement=fixed: считаем Y из суммарных потоков для точной геометрии. Пример и код на Python/pandas.
Чтобы диаграмма Санки выглядела «правильно», важнее не цвета и подписи, а геометрия. Если узлы в столбце не соотносятся с суммарным потоком, который они представляют, весь график выглядит неестественно. Подвох в том, что вертикальная позиция узла определяется его центром, а не верхней границей, поэтому ручной выбор координат y почти всегда приводит к смещению. Ниже — компактный разбор, как корректно выровнять узлы в двухколоночной диаграмме Санки, вычисляя Y из агрегированного потока.
Постановка задачи и минимальный воспроизводимый пример
В диаграмме фиксированы позиции узлов слева и справа. Категории упорядочены и продублированы по обеим сторонам, связи окрашены по источнику, а координаты x/y заданы вручную. Именно ручная расстановка y и даёт визуальное несоответствие.
import pandas as pd
import plotly.graph_objects as go
from io import StringIO
# Загрузка данных
csv_buf = StringIO("""
from_cat,to_cat,percent
rpf,bp,3.55314197051978
rpf,cc,6.19084561675718
rpf,es,1.21024049650892
rpf,ic,2.46702870442203
rpf,rpf,2.26532195500388
rpf,sc,6.54771140418929
bp,bp,0.977501939487975
bp,cc,0.403413498836307
bp,es,0.108611326609775
bp,ic,4.7944142746315
bp,rpf,0.387897595034911
bp,sc,1.81536074476338
ic,bp,0.124127230411171
ic,cc,0.21722265321955
ic,es,0.0155159038013964
ic,ic,0.170674941815361
ic,rpf,0.0155159038013964
ic,sc,0.294802172226532
cc,bp,1.25678820791311
cc,cc,7.50969743987587
cc,es,9.41815360744763
cc,ic,0.775795190069822
cc,rpf,1.05508145849496
cc,sc,20.8068269976726
cc,sr,0.0465477114041893
sc,bp,0.0155159038013964
sc,cc,0.325833979829325
sc,es,1.92397207137316
sc,rpf,0.0155159038013964
sc,sc,4.43754848719938
sr,bp,0.0620636152055857
sr,cc,1.55159038013964
sr,es,5.10473235065943
sr,ic,0.0155159038013964
sr,rpf,0.0155159038013964
sr,sc,9.71295577967417
sr,sr,0.0775795190069822
es,bp,0.108611326609775
es,cc,0.574088440651668
es,es,1.48952676493406
es,ic,0.0310318076027929
es,rpf,0.0620636152055857
es,sc,2.00155159038014
es,sr,0.0465477114041893
""")
frame = pd.read_csv(csv_buf, skipinitialspace=True)
# Порядок категорий
tier_order = ["es", "sr", "sc", "cc", "ic", "bp", "rpf"]
frame["from_cat"] = pd.Categorical(frame["from_cat"], categories=tier_order, ordered=True)
frame["to_cat"] = pd.Categorical(frame["to_cat"], categories=tier_order, ordered=True)
# Детерминированная сортировка
frame = frame.sort_values(["from_cat", "to_cat"]).reset_index(drop=True)
# Метки и индексы для левой/правой стороны
left_tiers = tier_order
right_tiers = tier_order
node_labels = [f"{c} (L)" for c in left_tiers] + [f"{c} (R)" for c in right_tiers]
label_to_id = {lbl: i for i, lbl in enumerate(node_labels)}
frame["src_id"] = frame["from_cat"].map(lambda c: label_to_id.get(f"{c} (L)", -1))
frame["dst_id"] = frame["to_cat"].map(lambda c: label_to_id.get(f"{c} (R)", -1))
frame["src_id"] = pd.to_numeric(frame["src_id"], downcast="integer", errors="coerce").fillna(-1).astype(int)
frame["dst_id"] = pd.to_numeric(frame["dst_id"], downcast="integer", errors="coerce").fillna(-1).astype(int)
# Цвета
COLOR_BY_GROUP = {
    "es": "#F6C57A",
    "sr": "#A6D8F0",
    "sc": "#7BDCB5",
    "cc": "#FFC20A",
    "ic": "#88BDE6",
    "bp": "#F4A582",
    "rpf": "#DDA0DD",
    "Unknown": "#D3D3D3"
}
node_fill = [COLOR_BY_GROUP[c] for c in left_tiers] + [COLOR_BY_GROUP[c] for c in right_tiers]
link_fill = [node_fill[s] for s in frame["src_id"].tolist()]
# Ручные позиции (проблемная расстановка y)
x_pos = [
    0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001,
    0.999, 0.999, 0.999, 0.999, 0.999, 0.999, 0.999
]
y_pos = [
    0.05, 0.18, 0.31, 0.44, 0.57, 0.70, 0.83,
    0.05, 0.18, 0.31, 0.44, 0.57, 0.70, 0.83
]
chart = go.Figure(go.Sankey(
    arrangement="fixed",
    node=dict(
        pad=40,
        thickness=25,
        line=dict(color="black", width=0.5),
        label=node_labels,
        x=x_pos,
        y=y_pos,
        color=node_fill
    ),
    link=dict(
        source=frame["src_id"].tolist(),
        target=frame["dst_id"].tolist(),
        value=frame["percent"].tolist(),
        color=link_fill,
        hovertemplate="%{source.label} → %{target.label}<br><b>%{value:.2f}%</b><extra></extra>"
    ),
    valueformat=".2f",
    valuesuffix="%"
))
chart.update_layout(
    title="Flow",
    font_size=12,
    paper_bgcolor="#f7f7f7",
    plot_bgcolor="#f7f7f7",
    margin=dict(l=30, r=30, t=60, b=30),
    width=1000,
    height=800
)
chart.show()
Что идёт не так и почему
В диаграмме Санки с arrangement, установленным в fixed, Y — это вертикальная координата центра узла. Равномерное распределение узлов игнорирует их реальную суммарную ширину, поэтому центр «толстого» узла смещается относительно потока, который он отображает. Отсюда — лёгкий перекос или скученность связей. Чтобы всё выровнять, Y нужно получать из накопленного распределения сумм в каждом столбце: сложить ширины всех предыдущих узлов и добавить половину ширины текущего, чтобы выйти на центр. В финале нормируйте эти значения к диапазону холста [0, 1].
Данные разбиты по категориям и явно упорядочены, а детерминированная сортировка удерживает визуальный порядок from_cat и to_cat стабильным между запусками. Эта устойчивость важна, когда вы сопоставляете вычисленные позиции с метками и цветами.
Исправление и выровненная версия
Подход прямолинейный: агрегируйте потоки по категориям для левого и правого столбцов, вычислите накопленные центры и используйте их как Y. Позиции X остаются на краях. Вспомогательная таблица упрощает вычисления.
import pandas as pd
import plotly.graph_objects as go
from io import StringIO
# Загрузка данных
csv_buf = StringIO("""
from_cat,to_cat,percent
rpf,bp,3.55314197051978
rpf,cc,6.19084561675718
rpf,es,1.21024049650892
rpf,ic,2.46702870442203
rpf,rpf,2.26532195500388
rpf,sc,6.54771140418929
bp,bp,0.977501939487975
bp,cc,0.403413498836307
bp,es,0.108611326609775
bp,ic,4.7944142746315
bp,rpf,0.387897595034911
bp,sc,1.81536074476338
ic,bp,0.124127230411171
ic,cc,0.21722265321955
ic,es,0.0155159038013964
ic,ic,0.170674941815361
ic,rpf,0.0155159038013964
ic,sc,0.294802172226532
cc,bp,1.25678820791311
cc,cc,7.50969743987587
cc,es,9.41815360744763
cc,ic,0.775795190069822
cc,rpf,1.05508145849496
cc,sc,20.8068269976726
cc,sr,0.0465477114041893
sc,bp,0.0155159038013964
sc,cc,0.325833979829325
sc,es,1.92397207137316
sc,rpf,0.0155159038013964
sc,sc,4.43754848719938
sr,bp,0.0620636152055857
sr,cc,1.55159038013964
sr,es,5.10473235065943
sr,ic,0.0155159038013964
sr,rpf,0.0155159038013964
sr,sc,9.71295577967417
sr,sr,0.0775795190069822
es,bp,0.108611326609775
es,cc,0.574088440651668
es,es,1.48952676493406
es,ic,0.0310318076027929
es,rpf,0.0620636152055857
es,sc,2.00155159038014
es,sr,0.0465477114041893
""")
frame = pd.read_csv(csv_buf, skipinitialspace=True)
# Порядок категорий
tier_order = ["es", "sr", "sc", "cc", "ic", "bp", "rpf"]
frame["from_cat"] = pd.Categorical(frame["from_cat"], categories=tier_order, ordered=True)
frame["to_cat"] = pd.Categorical(frame["to_cat"], categories=tier_order, ordered=True)
# Детерминированная сортировка
frame = frame.sort_values(["from_cat", "to_cat"]).reset_index(drop=True)
# Метки и индексы для левой/правой стороны
left_tiers = tier_order
right_tiers = tier_order
node_labels = [f"{c} (L)" for c in left_tiers] + [f"{c} (R)" for c in right_tiers]
label_to_id = {lbl: i for i, lbl in enumerate(node_labels)}
frame["src_id"] = frame["from_cat"].map(lambda c: label_to_id.get(f"{c} (L)", -1))
frame["dst_id"] = frame["to_cat"].map(lambda c: label_to_id.get(f"{c} (R)", -1))
frame["src_id"] = pd.to_numeric(frame["src_id"], downcast="integer", errors="coerce").fillna(-1).astype(int)
frame["dst_id"] = pd.to_numeric(frame["dst_id"], downcast="integer", errors="coerce").fillna(-1).astype(int)
# Цвета
COLOR_BY_GROUP = {
    "es": "#F6C57A",
    "sr": "#A6D8F0",
    "sc": "#7BDCB5",
    "cc": "#FFC20A",
    "ic": "#88BDE6",
    "bp": "#F4A582",
    "rpf": "#DDA0DD",
    "Unknown": "#D3D3D3"
}
node_fill = [COLOR_BY_GROUP[c] for c in left_tiers] + [COLOR_BY_GROUP[c] for c in right_tiers]
link_fill = [node_fill[s] for s in frame["src_id"].tolist()]
# Позиции X привязаны к краям, соответствуют уникальным категориям слева/справа
x_pos = [0.001 for _ in frame["from_cat"].unique()] + [0.999 for _ in frame["to_cat"].unique()]
# Вычисляем Y как накопленные центры по сторонам; Y — это центр каждого узла
pos_df = pd.DataFrame()
pos_df["left_total"] = frame.groupby("from_cat", observed=True)["percent"].sum()
pos_df["left_center"] = pos_df["left_total"].cumsum().sub(pos_df["left_total"]/2).div(100)
pos_df["right_total"] = frame.groupby("to_cat", observed=True)["percent"].sum()
pos_df["right_center"] = pos_df["right_total"].cumsum().sub(pos_df["right_total"]/2).div(100)
y_pos = pos_df["left_center"].tolist() + pos_df["right_center"].tolist()
chart = go.Figure(go.Sankey(
    arrangement="fixed",
    node=dict(
        pad=40,
        thickness=25,
        line=dict(color="black", width=0.5),
        label=node_labels,
        x=x_pos,
        y=y_pos,
        color=node_fill
    ),
    link=dict(
        source=frame["src_id"].tolist(),
        target=frame["dst_id"].tolist(),
        value=frame["percent"].tolist(),
        color=link_fill,
        hovertemplate="%{source.label} → %{target.label}<br><b>%{value:.2f}%</b><extra></extra>"
    ),
    valueformat=".2f",
    valuesuffix="%"
))
chart.update_layout(
    title="Flow",
    font_size=12,
    paper_bgcolor="#f7f7f7",
    plot_bgcolor="#f7f7f7",
    margin=dict(l=30, r=30, t=60, b=30),
    width=1000,
    height=800
)
chart.show()
Почему это важно
Точное выравнивание узлов делает диаграмму Санки читаемой с первого взгляда. Когда центры следуют реальной накопленной ширине, связи текут плавно, а визуальный вес соответствует данным. Это ещё и повышает согласованность: порядок категорий задаётся через Categorical с ordered=True и детерминированной сортировкой, благодаря чему левая и правая «стопки» остаются стабильными.
Выводы
Если вы управляете позициями узлов в Plotly Sankey при arrangement, установленном в fixed, вычисляйте Y из сумм по каждой категории. Считайте Y центром узла: сложите ширины всех предыдущих, прибавьте половину текущей и нормируйте к холсту. Держите порядок категорий явным, а сортировку — детерминированной, чтобы сохранить соответствие меток и цветов. С таким подходом диаграмма выровняется без ручной подгонки координат.