X Tutup
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased

### Fixed
- Fix wrong x/y positions in Sankey diagrams with isolated nodes when using `arrangement='fixed'` [[#5381](https://github.com/plotly/plotly.py/issues/5381)]
- Update `numpy.percentile` syntax to stop using deprecated alias [[5483](https://github.com/plotly/plotly.py/pull/5483)], with thanks to @Mr-Neutr0n for the contribution!
- `numpy` with a version less than 1.22 is no longer supported.

Expand Down
91 changes: 91 additions & 0 deletions plotly/io/_kaleido.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,96 @@
from plotly.io._defaults import defaults

ENGINE_SUPPORT_TIMELINE = "September 2025"


def _fix_sankey_isolated_nodes(fig_dict: dict) -> dict:
"""
Work around a Plotly.js bug affecting Sankey diagrams with arrangement='fixed'.

Two related issues cause x/y positions to be misapplied:

1. *Isolated nodes*: Plotly.js excludes nodes with no valid links from its
internal ``b.nodes`` array, then applies ``i.node.x[p]`` to ``b.nodes[p]``
assuming a 1:1 index correspondence. Isolated nodes before connected nodes
shift this correspondence, misapplying positions to the wrong nodes.
2. *Phantom nodes*: out-of-bounds source/target indices cause Plotly.js to
compute ``_ = max(sources+targets) + 1``, inflating ``b.nodes`` with
unlabelled entries that further break the index alignment.

Parameters
----------
fig_dict : dict
Plotly figure dictionary. Modified in place.

Returns
-------
dict
The modified figure dictionary.
"""

for trace in fig_dict.get("data", []):
if trace.get("type") != "sankey":
continue
if trace.get("arrangement") != "fixed":
continue

node = trace.get("node", {})
x_positions = node.get("x")
y_positions = node.get("y")

if not x_positions or not y_positions:
continue

n_nodes = len(x_positions)
if len(y_positions) != n_nodes:
continue

link = trace.get("link", {})
sources = link.get("source", [])
targets = link.get("target", [])
values = link.get("value", [])

# Find connected nodes (appear in at least one valid link with value > 0).
# This mirrors the Plotly.js condition: C>0 && IWe(M,_) && IWe(g,_)
connected = set()
for s, t, v in zip(sources, targets, values):
try:
v_float = float(v)
except (TypeError, ValueError):
continue
if v_float > 0:
if isinstance(s, (int, float)) and 0 <= int(s) < n_nodes:
connected.add(int(s))
if isinstance(t, (int, float)) and 0 <= int(t) < n_nodes:
connected.add(int(t))

# If all nodes are connected (or no isolated nodes), nothing to fix
isolated = [i for i in range(n_nodes) if i not in connected]
if not isolated:
continue

# New order: connected nodes first (preserving original order), isolated last
connected_ordered = [i for i in range(n_nodes) if i in connected]
new_order = connected_ordered + isolated
old_to_new = {old: new for new, old in enumerate(new_order)}

# Reorder per-node arrays to match the new order
per_node_keys = [
"label", "x", "y", "color", "customdata",
"hovertemplate", "hovertemplatefallback", "hoverinfo",
]
for key in per_node_keys:
arr = node.get(key)
if isinstance(arr, list) and len(arr) == n_nodes:
node[key] = [arr[i] for i in new_order]

# Update source/target references to use new indices
link["source"] = [old_to_new.get(int(s), int(s)) for s in sources]
link["target"] = [old_to_new.get(int(t), int(t)) for t in targets]

return fig_dict


ENABLE_KALEIDO_V0_DEPRECATION_WARNINGS = True

PLOTLY_GET_CHROME_ERROR_MSG = """
Expand Down Expand Up @@ -353,6 +443,7 @@ def to_image(

# Convert figure to dict (and validate if requested)
fig_dict = validate_coerce_fig_to_dict(fig, validate)
fig_dict = _fix_sankey_isolated_nodes(fig_dict)

# Request image bytes
if kaleido_major() > 0:
Expand Down
101 changes: 101 additions & 0 deletions tests/test_optional/test_kaleido/test_kaleido.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,26 @@
import plotly.graph_objects as go
import plotly.io as pio
from plotly.io.kaleido import kaleido_available, kaleido_major
from plotly.io._kaleido import _fix_sankey_isolated_nodes
import pytest


fig = {"data": [], "layout": {"title": {"text": "figure title"}}}


def _make_sankey_fig(labels, x, y, sources, targets, values, arrangement="fixed"):
return {
"data": [
{
"type": "sankey",
"arrangement": arrangement,
"node": {"label": labels, "x": x, "y": y},
"link": {"source": sources, "target": targets, "value": values},
}
]
}


def create_figure(width=None, height=None):
"""Create a simple figure with optional layout dimensions."""
layout = {}
Expand Down Expand Up @@ -374,3 +388,90 @@ def test_width_height_priority():
assert height == pio.defaults.default_height, (
"Default height should be used when no layout or argument"
)


def test_fix_sankey_isolated_nodes_reorders_correctly():
"""Isolated node between connected nodes is moved to the end."""
fig = _make_sankey_fig(
labels=["left_a", "isolated", "right_a"],
x=[0.1, 0.1, 0.9],
y=[0.5, 0.8, 0.5],
sources=[0],
targets=[2],
values=[1.0],
)

result = _fix_sankey_isolated_nodes(fig)
node = result["data"][0]["node"]
link = result["data"][0]["link"]

# Isolated node moved to last position; connected nodes keep relative order
assert node["label"] == ["left_a", "right_a", "isolated"]
assert node["x"] == [0.1, 0.9, 0.1]
assert node["y"] == [0.5, 0.5, 0.8]

# Source/target indices updated to reflect the new order
assert link["source"] == [0] # left_a: 0 → 0 (unchanged)
assert link["target"] == [1] # right_a: 2 → 1


def test_fix_sankey_no_isolated_nodes_unchanged():
"""Figure with all nodes connected must not be modified."""
fig = _make_sankey_fig(
labels=["a", "b", "c"],
x=[0.1, 0.5, 0.9],
y=[0.5, 0.5, 0.5],
sources=[0, 1],
targets=[1, 2],
values=[1.0, 1.0],
)
original_labels = fig["data"][0]["node"]["label"][:]
original_x = fig["data"][0]["node"]["x"][:]

result = _fix_sankey_isolated_nodes(fig)
node = result["data"][0]["node"]

assert node["label"] == original_labels
assert node["x"] == original_x


def test_fix_sankey_out_of_bounds_targets_kept():
"""Out-of-bounds targets are preserved as-is and do not cause errors."""
fig = _make_sankey_fig(
labels=["isolated", "a", "b"],
x=[0.5, 0.1, 0.9],
y=[0.8, 0.5, 0.5],
sources=[1],
targets=[5], # out of bounds: n_nodes == 3
values=[1.0],
)

result = _fix_sankey_isolated_nodes(fig)
node = result["data"][0]["node"]
link = result["data"][0]["link"]

# node 1 (a) is the only connected node; isolated and b move to end
assert node["label"] == ["a", "isolated", "b"]
assert node["x"] == [0.1, 0.5, 0.9]
assert node["y"] == [0.5, 0.8, 0.5]

# Source index updated; out-of-bounds target kept unchanged
assert link["source"] == [0] # a: 1 → 0
assert link["target"] == [5] # out-of-bounds: preserved as-is


def test_fix_sankey_non_fixed_arrangement_unchanged():
"""Traces with arrangement != 'fixed' must not be modified."""
fig = _make_sankey_fig(
labels=["a", "isolated", "b"],
x=[0.1, 0.1, 0.9],
y=[0.5, 0.8, 0.5],
sources=[0],
targets=[2],
values=[1.0],
arrangement="snap",
)
original_label = fig["data"][0]["node"]["label"][:]

result = _fix_sankey_isolated_nodes(fig)
assert result["data"][0]["node"]["label"] == original_label
X Tutup