diff --git a/CHANGELOG.md b/CHANGELOG.md index c92c3ea6d6..ef2079656f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix Plotly Express mutating lists passed to the `x` or `y` arguments in wide mode [[#5727](https://github.com/plotly/plotly.py/pull/5727)], with thanks to @cpruijsen 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 [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution! - Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution! +- Fix Plotly Express scrambling the order of the categorical x-axis when the data is split into several traces (e.g. by `color`); the x-axis now follows the order of first appearance in the underlying data [[#3198](https://github.com/plotly/plotly.py/issues/3198), [#3291](https://github.com/plotly/plotly.py/issues/3291)] + +## [7.1.0] - 2026-09-15 ## [7.1.0] - 2026-09-15 diff --git a/plotly/express/_core.py b/plotly/express/_core.py index 9994720b09..5800ede4ac 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -2530,6 +2530,41 @@ def get_groups_and_orders(args, grouper): return groups, orders +def _default_category_orders_for_axes(args, groups, orders): + """ + Add data-appearance order for the categorical x column. + + When the data is split into several traces (e.g. by `color`), plotly.js + orders categories by first appearance across traces, which can differ from + the order in the underlying data. Recording the data-appearance order here + lets `set_cartesian_axis_opts` emit an explicit `categoryarray`, matching + the documented behavior that Plotly Express lays out categorical data in + the order in which it appears in the data. See #3198. + + Only the x axis is handled: y categoricals are intentionally left alone + because `set_cartesian_axis_opts` reverses y orders for top-down display + and figures such as `px.timeline` / horizontal `px.bar` pair that with + `autorange="reversed"` (see `doc/python/gantt.md`), so an explicit y + `categoryarray` would double-reverse and flip the documented order. + """ + if len(groups) <= 1: + return orders + df = args["data_frame"] + col = args.get("x") + if not isinstance(col, str) or col in orders or col not in df.columns: + return orders + uniques = df.get_column(col).unique(maintain_order=True).to_list() + categories = [ + value + for value in uniques + if value is not None + and not (isinstance(value, float) and math.isnan(value)) + ] + if categories and all(isinstance(value, str) for value in categories): + orders[col] = categories + return orders + + def make_figure(args, constructor, trace_patch=None, layout_patch=None): trace_patch = trace_patch or {} layout_patch = layout_patch or {} @@ -2556,6 +2591,7 @@ def make_figure(args, constructor, trace_patch=None, layout_patch=None): ) grouper = [x.grouper or one_group for x in grouped_mappings] or [one_group] groups, orders = get_groups_and_orders(args, grouper) + orders = _default_category_orders_for_axes(args, groups, orders) col_labels = [] row_labels = [] diff --git a/tests/test_optional/test_px/test_px.py b/tests/test_optional/test_px/test_px.py index d4773de66f..104ac44749 100644 --- a/tests/test_optional/test_px/test_px.py +++ b/tests/test_optional/test_px/test_px.py @@ -361,6 +361,57 @@ def test_category_order_with_category_as_x(backend): assert set(trace["x"]) == {"Thur", "Fri", "Sat", "Sun"} +def test_category_order_defaults_to_data_order_with_color(): + # https://github.com/plotly/plotly.py/issues/3198 + # Splitting into one trace per color must not change the axis order: + # it should follow first appearance in the underlying data. + fig = px.line( + x=["A", "B", None, "B", "C", None, "C", "D", None, "D", "E", None], + y=[1, 1, None, 2, 2, None, 3, 3, None, 4, 4, None], + color=[1, 1, 1, 2, 2, 2, 2, 2, 2, 1, 1, 1], + ) + assert fig["layout"]["xaxis"]["categoryorder"] == "array" + assert fig["layout"]["xaxis"]["categoryarray"] == ("A", "B", "C", "D", "E") + + +def test_category_order_defaults_to_data_order_with_color_bar(): + # https://github.com/plotly/plotly.py/issues/3291 + df = pd.DataFrame( + {"x": ["a", "b", "c", "d"], "y": [1, 2, 3, 4], "z": ["p", "q", "p", "q"]} + ) + fig = px.bar(df, x="x", y="y", color="z") + assert fig["layout"]["xaxis"]["categoryorder"] == "array" + assert fig["layout"]["xaxis"]["categoryarray"] == ("a", "b", "c", "d") + + +def test_no_default_category_array_for_single_trace_or_numeric(): + # A single trace already follows data order, and numeric axes + # must never get a category array. + fig = px.line(x=["A", "B", "C"], y=[1, 2, 3]) + assert fig["layout"]["xaxis"]["categoryorder"] is None + fig = px.line(x=[1, 2, 3], y=[1, 2, 3], color=["a", "b", "a"]) + assert fig["layout"]["xaxis"]["categoryorder"] is None + + +def test_no_default_category_array_for_y_with_color(): + # https://github.com/plotly/plotly.py/pull/5776#pullrequestreview-5478271667 + # y categoricals must stay untouched so the `autorange="reversed"` + # pattern from doc/python/gantt.md keeps listing tasks top-down. + df = pd.DataFrame( + [ + dict(Task="Job A", Start="2009-01-01", Finish="2009-02-28", Resource="Alex"), + dict(Task="Job B", Start="2009-03-05", Finish="2009-04-15", Resource="Alex"), + dict(Task="Job C", Start="2009-02-20", Finish="2009-05-30", Resource="Max"), + ] + ) + fig = px.timeline(df, x_start="Start", x_end="Finish", y="Task", color="Resource") + assert fig["layout"]["yaxis"]["categoryorder"] is None + assert fig["layout"]["yaxis"]["categoryarray"] is None + fig = px.bar(x=[1, 2, 3, 4], y=list("abcd"), color=list("ppqq"), orientation="h") + assert fig["layout"]["yaxis"]["categoryorder"] is None + assert fig["layout"]["yaxis"]["categoryarray"] is None + + def test_permissive_defaults(): msg = "'PxDefaults' object has no attribute 'should_not_work'" with pytest.raises(AttributeError, match=msg):