From 534c33fe36db97abbc9515afdc19d99c5409c44d Mon Sep 17 00:00:00 2001 From: Frederico Daniel Lebres Mendes Date: Mon, 23 Mar 2026 10:10:46 +0000 Subject: [PATCH] Fix plotly#5381: wrong x/y positions in Sankey with isolated nodes In Sankey diagrams with arrangement='fixed', Plotly.js builds an internal node array excluding isolated nodes (nodes not referenced by any valid link). The fixed-arrangement loop maps position array indices directly to this internal array, assuming a 1:1 correspondence. When isolated nodes appear before connected nodes in the original array, positions shift and are applied to the wrong nodes. Fix: in plotly/io/_kaleido.py, reorder per-node arrays before rendering so that isolated nodes appear last. This aligns the Plotly.js internal array with the x/y position arrays for all connected nodes. --- CHANGELOG.md | 1 + plotly/io/_kaleido.py | 91 ++++++++++++++++ .../test_kaleido/test_kaleido.py | 101 ++++++++++++++++++ 3 files changed, 193 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d339e3cc7b9..05fc34c3e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/plotly/io/_kaleido.py b/plotly/io/_kaleido.py index d0bb0eec438..bb841c83218 100644 --- a/plotly/io/_kaleido.py +++ b/plotly/io/_kaleido.py @@ -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 = """ @@ -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: diff --git a/tests/test_optional/test_kaleido/test_kaleido.py b/tests/test_optional/test_kaleido/test_kaleido.py index 91950782915..01a8cf06367 100644 --- a/tests/test_optional/test_kaleido/test_kaleido.py +++ b/tests/test_optional/test_kaleido/test_kaleido.py @@ -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 = {} @@ -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