diff --git a/CHANGELOG.md b/CHANGELOG.md index 7107261c9f..0583fec45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#3441](https://github.com/plotly/plotly.py/issues/3441)], with thanks to @hb1915 for the contribution! +- Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5765](https://github.com/plotly/plotly.py/issues/5765)], with thanks to @Irahan2 for the contribution! ## [7.1.0] - 2026-09-15 diff --git a/plotly/express/_core.py b/plotly/express/_core.py index 027df9e5aa..b354ed6f8f 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -1958,7 +1958,10 @@ def process_dataframe_hierarchy(args): _check_dataframe_all_leaves(df[path[::-1]]) discrete_color = not _is_continuous(df, args["color"]) if args["color"] else False - df = df.lazy() + # Keep track of the original row order, so that the sectors can be sorted by + # first appearance after each group_by (Polars' group_by does not keep order). + row_index_colname = _generate_temporary_column_name(n_bytes=16, columns=df.columns) + df = df.with_row_index(row_index_colname).lazy() new_path = [col_name + "_path_copy" for col_name in path] df = df.with_columns( @@ -1997,6 +2000,7 @@ def process_dataframe_hierarchy(args): # Since count_colname is always in agg_f, it can be used later to normalize color # in the continuous case after some gymnastic agg_f[count_colname] = nw.sum(count_colname) + agg_f[row_index_colname] = nw.min(row_index_colname) discrete_aggs = [] continuous_aggs = [] @@ -2049,7 +2053,7 @@ def process_dataframe_hierarchy(args): agg_f[args["color"]] = nw.sum(args["color"]) # Other columns (for color, hover_data, custom_data etc.) - cols = list(set(df.collect_schema().names()).difference(path)) + cols = list(set(df.collect_schema().names()).difference([*path, row_index_colname])) df = df.with_columns(nw.col(c).cast(nw.String()) for c in cols if c not in agg_f) for col in cols: # for hover_data, custom_data etc. @@ -2092,6 +2096,7 @@ def post_agg(dframe: nw.LazyFrame, continuous_aggs, discrete_aggs) -> nw.LazyFra dfg = ( df.group_by(path[i:], drop_null_keys=True) .agg(**agg_f) + .sort(row_index_colname) .pipe(post_agg, continuous_aggs, discrete_aggs) ) diff --git a/tests/test_optional/test_px/test_px_functions.py b/tests/test_optional/test_px/test_px_functions.py index fed8183aca..0371248658 100644 --- a/tests/test_optional/test_px/test_px_functions.py +++ b/tests/test_optional/test_px/test_px_functions.py @@ -233,6 +233,32 @@ def test_sunburst_treemap_with_path(constructor): assert fig.data[0].values[-1] == 8 +@pytest.mark.parametrize("px_fn", [px.sunburst, px.treemap, px.icicle]) +def test_sunburst_treemap_with_path_order(constructor, px_fn): + # Sectors should follow the order of first appearance in the data, whatever + # the dataframe backend (Polars' group_by does not keep the row order). + df = constructor( + dict( + regions=["South", "North", "South", "West", "North", "West"], + sectors=["Tech", "Finance", "Finance", "Tech", "Tech", "Finance"], + values=[1, 2, 3, 4, 5, 6], + ) + ) + fig = px_fn(df, path=["regions", "sectors"], values="values") + assert list(fig.data[0].ids) == [ + "South/Tech", + "North/Finance", + "South/Finance", + "West/Tech", + "North/Tech", + "West/Finance", + "South", + "North", + "West", + ] + assert list(fig.data[0].values) == [1, 2, 3, 4, 5, 6, 4, 7, 10] + + def test_sunburst_treemap_with_path_and_hover(backend): df = px.data.tips(return_type=backend) fig = px.sunburst(