From 06102aa21e624781a653f50cf8f38d965fbfe3e0 Mon Sep 17 00:00:00 2001 From: vinayak Date: Sun, 27 Sep 2026 16:18:06 +0530 Subject: [PATCH 1/5] Fix px scrambling categorical axis order when data is split into traces --- CHANGELOG.md | 1 + plotly/express/_core.py | 31 +++++++++++++++++++++++++ tests/test_optional/test_px/test_px.py | 32 ++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c92c3ea6d64..ef14740f37d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ 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 categorical axes when the data is split into several traces (e.g. by `color`); the 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 diff --git a/plotly/express/_core.py b/plotly/express/_core.py index 9994720b094..d51ab6b1cb0 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -2530,6 +2530,36 @@ def get_groups_and_orders(args, grouper): return groups, orders +def _default_category_orders_for_axes(args, groups, orders): + """ + Add data-appearance orders for categorical x/y columns. + + 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. + """ + if len(groups) <= 1: + return orders + df = args["data_frame"] + for letter in ("x", "y"): + col = args.get(letter) + if not isinstance(col, str) or col in orders or col not in df.columns: + continue + 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 +2586,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 d4773de66f8..4b9f8163c00 100644 --- a/tests/test_optional/test_px/test_px.py +++ b/tests/test_optional/test_px/test_px.py @@ -361,6 +361,38 @@ 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_permissive_defaults(): msg = "'PxDefaults' object has no attribute 'should_not_work'" with pytest.raises(AttributeError, match=msg): From 16ab376c917c63ebdcd8a6416fcc74ade6ccb276 Mon Sep 17 00:00:00 2001 From: vinayak Date: Sat, 10 Oct 2026 17:48:48 +0530 Subject: [PATCH 2/5] Scope default category order to x-axis only Address review on #5776: y categoricals stay untouched so the autorange=reversed pattern from doc/python/gantt.md keeps listing tasks top-down instead of double-reversing. Adds guard test that px.timeline and horizontal px.bar with color emit no y categoryarray, while x data-order fix for #3198/#3291 is kept. --- CHANGELOG.md | 4 +++- plotly/express/_core.py | 33 +++++++++++++++----------- tests/test_optional/test_px/test_px.py | 19 +++++++++++++++ 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef14740f37d..ef2079656f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +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 categorical axes when the data is split into several traces (e.g. by `color`); the 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)] +- 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 d51ab6b1cb0..5800ede4ac2 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -2532,7 +2532,7 @@ def get_groups_and_orders(args, grouper): def _default_category_orders_for_axes(args, groups, orders): """ - Add data-appearance orders for categorical x/y columns. + 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 @@ -2540,23 +2540,28 @@ def _default_category_orders_for_axes(args, groups, orders): 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"] - for letter in ("x", "y"): - col = args.get(letter) - if not isinstance(col, str) or col in orders or col not in df.columns: - continue - 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 + 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 diff --git a/tests/test_optional/test_px/test_px.py b/tests/test_optional/test_px/test_px.py index 4b9f8163c00..104ac447495 100644 --- a/tests/test_optional/test_px/test_px.py +++ b/tests/test_optional/test_px/test_px.py @@ -393,6 +393,25 @@ def test_no_default_category_array_for_single_trace_or_numeric(): 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): From 55d5c75efb23557eaa6eb61619706c589727648d Mon Sep 17 00:00:00 2001 From: vinayak Date: Mon, 12 Oct 2026 16:47:32 +0530 Subject: [PATCH 3/5] Remove duplicated 7.1.0 heading in CHANGELOG --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2079656f8..7e3ecbc81a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,6 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [7.1.0] - 2026-09-15 -## [7.1.0] - 2026-09-15 - ### Added - Support `marginal_x`/`marginal_y="heatmap"` in `density_heatmap`, drawing a single-row/column heatmap strip in the margin colored by the same `z`/`histfunc` aggregate as the main plot and sharing its color scale [[#5706](https://github.com/plotly/plotly.py/issues/5706)], with thanks to @lucasjamar for the contribution! - Add support for custom tick values in `mpl_to_plotly` when the matplotlib tick positions don't follow an arithmetic progression, including custom tick labels and tick values on date axes [[#5262](https://github.com/plotly/plotly.py/pull/5262)], with thanks to @robertoffmoura for the contribution! From 5a693515e59c8368d5bac5b9ea3e6e21003a2663 Mon Sep 17 00:00:00 2001 From: vineoy <113031024+vineoy@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:21:34 +0530 Subject: [PATCH 4/5] Restrict the default x category order to single-subplot figures Address review on plotly#5776: facet axes must keep listing their own categories (the matches=None technique in doc/python/facet-plots.md) and marginal axes span the y dimension, so the data-wide default must not reach any axis of a faceted or marginal figure. Adds the unlinked facet axes test plus a subplot guard test, limits the changelog entry, and applies ruff format to the touched files. --- CHANGELOG.md | 2 +- plotly/express/_core.py | 29 +++++++++++--- tests/test_optional/test_px/test_px.py | 54 +++++++++++++++++++++++++- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e3ecbc81a3..18fcd0137d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ 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)] +- Fix Plotly Express scrambling the order of the categorical x-axis in single-subplot figures 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, while faceted and marginal figures are unchanged [[#3198](https://github.com/plotly/plotly.py/issues/3198), [#3291](https://github.com/plotly/plotly.py/issues/3291)] ## [7.1.0] - 2026-09-15 diff --git a/plotly/express/_core.py b/plotly/express/_core.py index 5800ede4ac2..79aca7ee774 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -2530,9 +2530,9 @@ def get_groups_and_orders(args, grouper): return groups, orders -def _default_category_orders_for_axes(args, groups, orders): +def _default_category_orders_for_axes(args, groups, orders, constructor): """ - Add data-appearance order for the categorical x column. + Add data-appearance order for the categorical x axis. 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 @@ -2541,14 +2541,34 @@ def _default_category_orders_for_axes(args, groups, orders): the documented behavior that Plotly Express lays out categorical data in the order in which it appears in the data. See #3198. + The default is only emitted for figures with a single subplot, so the one + x axis it touches spans the whole data-frame: + + - With facets each subplot only carries its own categories and the + documented independent-axes technique (disabling `matches`, see + `doc/python/facet-plots.md`) must keep listing each axis only its own + categories, which an explicit data-wide `categoryarray` would break. See + https://github.com/plotly/plotly.py/pull/5776#pullrequestreview-5701715902 + + - With marginals the extra subplots span the y dimension, so an explicit + x `categoryarray` on their axes would be wrong. + + Figures with subplots could get per-axis data orders in a follow up. + 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 constructor not in cartesians: + return orders if len(groups) <= 1: return orders + if args.get("facet_row") or args.get("facet_col"): + return orders + if args.get("marginal_x") or args.get("marginal_y"): + 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: @@ -2557,8 +2577,7 @@ def _default_category_orders_for_axes(args, groups, orders): categories = [ value for value in uniques - if value is not None - and not (isinstance(value, float) and math.isnan(value)) + 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 @@ -2591,7 +2610,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) + orders = _default_category_orders_for_axes(args, groups, orders, constructor) 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 104ac447495..3f112e08f47 100644 --- a/tests/test_optional/test_px/test_px.py +++ b/tests/test_optional/test_px/test_px.py @@ -399,8 +399,12 @@ def test_no_default_category_array_for_y_with_color(): # 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 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"), ] ) @@ -412,6 +416,52 @@ def test_no_default_category_array_for_y_with_color(): assert fig["layout"]["yaxis"]["categoryarray"] is None +def test_unlinked_facet_axes_only_list_their_own_categories(): + df = pd.DataFrame( + { + "panel": ["left", "right", "left", "right", "left"], + "fruit": ["apple", "banana", "cherry", "date", "elderberry"], + "sold": [5, 3, 4, 6, 2], + } + ) + fig = px.bar(df, x="fruit", y="sold", facet_col="panel") + fig.update_xaxes(matches=None) + for trace in fig.data: + axis = fig.layout["xaxis" + trace.xaxis[1:]] + assert axis.categoryarray is None or set(axis.categoryarray) <= set(trace.x) + + +def test_no_default_category_array_with_subplots(): + # https://github.com/plotly/plotly.py/pull/5776#pullrequestreview-5701715902 + # Facet axes list their own categories (the `matches=None` technique in + # doc/python/facet-plots.md), and marginal subplots span the y dimension, + # so no axis of a faceted or marginal figure may get a data-wide default. + df = pd.DataFrame( + { + "panel": ["left", "right", "left", "right", "left"], + "fruit": ["apple", "banana", "cherry", "date", "elderberry"], + "sold": [5, 3, 4, 6, 2], + } + ) + fig = px.bar(df, x="fruit", y="sold", facet_col="panel") + for axname in fig.layout: + if axname.startswith("xaxis"): + assert fig.layout[axname].categoryorder is None + assert fig.layout[axname].categoryarray is None + fig = px.scatter( + df, + x="fruit", + y="sold", + color="panel", + marginal_y="rug", + marginal_x="rug", + ) + for axname in fig.layout: + if axname.startswith("xaxis"): + assert fig.layout[axname].categoryorder is None + assert fig.layout[axname].categoryarray is None + + def test_permissive_defaults(): msg = "'PxDefaults' object has no attribute 'should_not_work'" with pytest.raises(AttributeError, match=msg): From a911810a3dc95b31cc0d68b41548c7ae7717d64b Mon Sep 17 00:00:00 2001 From: vinayak Date: Sun, 1 Nov 2026 01:03:47 +0530 Subject: [PATCH 5/5] Apply the default x category order per axis to facets, marginals and 3D Address review on #5776: rather than excluding faceted and marginal figures, each x axis now gets the whole-data-frame order restricted to the categories carried by the traces placed on it, so every facet gets the same order as its single-subplot counterpart and matched facet axes agree on the relative positions of shared categories. The right-hand marginal of marginal_y spans the y dimension and keeps no x order, and px.ecdf is excluded because it sorts the x values inside each trace. 3D figures get the data order on the scene x axis. Adds tests for the facet repro from review, per-panel categories, marginals, 3D and the ecdf guard, and updates the changelog entry. --- CHANGELOG.md | 2 +- plotly/express/_core.py | 111 +++++++++++++------ tests/test_optional/test_px/test_px.py | 143 ++++++++++++++++++++----- 3 files changed, 200 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2947744183b..eabb71d16f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - 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 serialization of box/lasso select data for figures with multiple subplots [[#5710](https://github.com/plotly/plotly.py/pull/5710)], with thanks to @flpi-sun for the contribution! - Fix `plotly.io.write_images` ignoring `layout.width`/`layout.height` (and the template's) and exporting every figure at the default 700x500 size; it now uses the same width/height priority as `write_image` and `to_image` [[#5784](https://github.com/plotly/plotly.py/pull/5784)], with thanks to @breken-ai for the contribution! -- Fix Plotly Express scrambling the order of the categorical x-axis in single-subplot figures 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, while faceted and marginal figures are unchanged [[#3198](https://github.com/plotly/plotly.py/issues/3198), [#3291](https://github.com/plotly/plotly.py/issues/3291)] +- 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, on single-subplot figures, on each facet of faceted figures and in 3D figures, while marginal axes spanning the y dimension are left alone [[#3198](https://github.com/plotly/plotly.py/issues/3198), [#3291](https://github.com/plotly/plotly.py/issues/3291)] ## [7.1.0] - 2026-09-15 diff --git a/plotly/express/_core.py b/plotly/express/_core.py index 79aca7ee774..2a4b665b168 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -2530,30 +2530,38 @@ def get_groups_and_orders(args, grouper): return groups, orders -def _default_category_orders_for_axes(args, groups, orders, constructor): +def _default_x_category_order(args, groups, orders, constructor): """ - Add data-appearance order for the categorical x axis. + Return the data-appearance order of the categorical x column, or None. 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. - - The default is only emitted for figures with a single subplot, so the one - x axis it touches spans the whole data-frame: - - - With facets each subplot only carries its own categories and the - documented independent-axes technique (disabling `matches`, see - `doc/python/facet-plots.md`) must keep listing each axis only its own - categories, which an explicit data-wide `categoryarray` would break. See - https://github.com/plotly/plotly.py/pull/5776#pullrequestreview-5701715902 - - - With marginals the extra subplots span the y dimension, so an explicit - x `categoryarray` on their axes would be wrong. - - Figures with subplots could get per-axis data orders in a follow up. + lets the axis configurators 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. + + The default only applies to string categoricals the user did not order + explicitly, to figures split into more than one trace, and to the x axis: + + - Cartesian figures with facets or marginals apply the default per axis + in `make_figure`: each x axis gets the whole-data-frame order + restricted to the categories carried by the traces on its subplot, + exactly the order its single-subplot counterpart would use with the + categories absent from the subplot dropped. Shared categories keep the + same relative positions on matched facet axes, and the documented + independent-axes technique (disabling `matches`, see + `doc/python/facet-plots.md`) still lists each axis only its own + categories. The right-hand marginal of `marginal_y` spans the y + dimension and carries the binned y values, so it never receives the x + order. + + - `px.ecdf` sorts the x values inside each trace, so its categorical + axis must keep the trace-derived order for the step function to read + in sequence. + + - `px.scatter_3d` has a single scene, so the whole-data-frame order is + applied through the per-column mechanism in `configure_3d_axes`. Only the x axis is handled: y categoricals are intentionally left alone because `set_cartesian_axis_opts` reverses y orders for top-down display @@ -2561,18 +2569,16 @@ def _default_category_orders_for_axes(args, groups, orders, constructor): `autorange="reversed"` (see `doc/python/gantt.md`), so an explicit y `categoryarray` would double-reverse and flip the documented order. """ - if constructor not in cartesians: - return orders + if constructor not in cartesians and constructor is not go.Scatter3d: + return None if len(groups) <= 1: - return orders - if args.get("facet_row") or args.get("facet_col"): - return orders - if args.get("marginal_x") or args.get("marginal_y"): - return orders + return None + if args.get("ecdfmode") is not None: + return None 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 + return None uniques = df.get_column(col).unique(maintain_order=True).to_list() categories = [ value @@ -2580,8 +2586,8 @@ def _default_category_orders_for_axes(args, groups, orders, constructor): 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 + return categories + return None def make_figure(args, constructor, trace_patch=None, layout_patch=None): @@ -2610,8 +2616,25 @@ 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, constructor) - + default_x_categories = _default_x_category_order(args, groups, orders, constructor) + per_axis_order = ( + default_x_categories is not None + and constructor in cartesians + and ( + args.get("facet_row") is not None + or args.get("facet_col") is not None + or args.get("marginal_x") is not None + or args.get("marginal_y") is not None + ) + ) + if default_x_categories is not None and not per_axis_order: + # The figure has a single x axis spanning the whole data frame (a + # plain cartesian subplot or a 3D scene), so the per-column + # mechanism in `set_cartesian_axis_opts` / `configure_3d_axes` + # applies the data order directly. + orders[args["x"]] = default_x_categories + + trace_axes_rows = [] col_labels = [] row_labels = [] nrows = ncols = 1 @@ -2660,6 +2683,11 @@ def make_figure(args, constructor, trace_patch=None, layout_patch=None): for trace_spec in trace_specs: # Create the trace trace = trace_spec.constructor(name=trace_name) + if per_axis_order: + # Remember the rows feeding this trace so the data order can + # be computed per x axis once the traces are placed on their + # subplots. + trace_axes_rows.append((trace, group, trace_spec.marginal)) if trace_spec.constructor not in [ go.Parcats, go.Parcoords, @@ -2908,6 +2936,27 @@ def make_figure(args, constructor, trace_patch=None, layout_patch=None): fig._px_trendlines = [] configure_axes(args, constructor, fig, orders) + if per_axis_order: + # Each x axis gets the whole-data-frame order restricted to the + # categories carried by the traces placed on it: the same order its + # single-subplot counterpart would use, with the categories absent + # from the subplot dropped, so matched facet axes agree on the + # relative positions of shared categories. Marginal axes spanning + # the y dimension do not carry the x column and get no default. + axis_x_values = {} + for trace, group, marginal in trace_axes_rows: + xaxis_id = getattr(trace, "xaxis", None) + if xaxis_id is None or marginal == "y": + continue + axis_x_values.setdefault(xaxis_id, set()).update( + group.get_column(args["x"]).to_list() + ) + for xaxis_id, values in axis_x_values.items(): + categories = [value for value in default_x_categories if value in values] + if categories: + axis = fig.layout["xaxis" + xaxis_id[1:]] + axis["categoryorder"] = "array" + axis["categoryarray"] = categories configure_animation_controls(args, constructor, fig) return fig diff --git a/tests/test_optional/test_px/test_px.py b/tests/test_optional/test_px/test_px.py index 3f112e08f47..5882a34f023 100644 --- a/tests/test_optional/test_px/test_px.py +++ b/tests/test_optional/test_px/test_px.py @@ -431,35 +431,130 @@ def test_unlinked_facet_axes_only_list_their_own_categories(): assert axis.categoryarray is None or set(axis.categoryarray) <= set(trace.x) -def test_no_default_category_array_with_subplots(): - # https://github.com/plotly/plotly.py/pull/5776#pullrequestreview-5701715902 - # Facet axes list their own categories (the `matches=None` technique in - # doc/python/facet-plots.md), and marginal subplots span the y dimension, - # so no axis of a faceted or marginal figure may get a data-wide default. +def test_default_category_order_applies_to_facets(): + # https://github.com/plotly/plotly.py/pull/5776#discussion_r4429048272 + # A facet must not change the x order relative to the same chart without + # facets: every facet axis follows the data order of its own rows, and + # the y axes stay untouched. df = pd.DataFrame( { - "panel": ["left", "right", "left", "right", "left"], - "fruit": ["apple", "banana", "cherry", "date", "elderberry"], - "sold": [5, 3, 4, 6, 2], + "month": ["Jan", "Feb", "Mar", "Apr", "Jan", "Feb", "Mar", "Apr"], + "product": [ + "Basic", + "Basic", + "Pro", + "Basic", + "Basic", + "Basic", + "Pro", + "Basic", + ], + "region": ["East", "East", "East", "East", "West", "West", "West", "West"], + "units": [10, 12, 8, 15, 11, 13, 9, 16], } ) - fig = px.bar(df, x="fruit", y="sold", facet_col="panel") - for axname in fig.layout: - if axname.startswith("xaxis"): - assert fig.layout[axname].categoryorder is None - assert fig.layout[axname].categoryarray is None - fig = px.scatter( - df, - x="fruit", - y="sold", - color="panel", - marginal_y="rug", - marginal_x="rug", + fig = px.bar(df, x="month", y="units", color="product") + assert fig["layout"]["xaxis"]["categoryorder"] == "array" + assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr") + fig = px.bar(df, x="month", y="units", color="product", facet_col="region") + assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr") + assert fig["layout"]["xaxis2"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr") + fig = px.bar(df, x="month", y="units", color="product", facet_row="region") + assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr") + assert fig["layout"]["xaxis2"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr") + assert fig["layout"]["yaxis"]["categoryorder"] is None + assert fig["layout"]["yaxis2"]["categoryorder"] is None + # numeric axes never get a category array, faceted or not + fig = px.bar(df, x="units", y="units", color="product", facet_col="region") + assert fig["layout"]["xaxis"]["categoryorder"] is None + assert fig["layout"]["xaxis2"]["categoryorder"] is None + + +def test_facet_axes_list_their_own_categories_in_data_order(): + df = pd.DataFrame( + { + "region": ["E", "E", "W", "E", "W"], + "cat": ["a", "b", "a", "c", "b"], + "kind": ["p", "q", "q", "q", "p"], + "val": [1, 2, 3, 4, 5], + } ) - for axname in fig.layout: - if axname.startswith("xaxis"): - assert fig.layout[axname].categoryorder is None - assert fig.layout[axname].categoryarray is None + fig = px.bar(df, x="cat", y="val", color="kind", facet_col="region") + # each facet lists only its own categories, in the order of the whole + # data frame: the trace-derived order scrambles the second facet because + # its "kind=p" trace carries "b" and is created before its "kind=q" trace + assert fig["layout"]["xaxis"]["categoryarray"] == ("a", "b", "c") + assert fig["layout"]["xaxis2"]["categoryarray"] == ("a", "b") + + +def test_default_category_order_with_marginals(): + # The top marginal of `marginal_x` shares the x dimension with the main + # subplot and gets the same data order. The right marginal of + # `marginal_y` spans the y dimension and carries the binned y values, so + # it must not receive the x order. + df = pd.DataFrame( + { + "month": ["Jan", "Feb", "Mar", "Apr", "Jan", "Feb", "Mar", "Apr"], + "product": [ + "Basic", + "Basic", + "Pro", + "Basic", + "Basic", + "Basic", + "Pro", + "Basic", + ], + "units": [10, 12, 8, 15, 11, 13, 9, 16], + } + ) + fig = px.scatter(df, x="month", y="units", color="product", marginal_x="rug") + assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr") + assert fig["layout"]["xaxis2"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr") + fig = px.scatter(df, x="month", y="units", color="product", marginal_y="rug") + assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr") + assert fig["layout"]["xaxis2"]["categoryarray"] is None + + +def test_default_category_order_for_3d(): + df = pd.DataFrame( + { + "month": ["Jan", "Feb", "Mar", "Apr", "Jan", "Feb", "Mar", "Apr"], + "product": [ + "Basic", + "Basic", + "Pro", + "Basic", + "Basic", + "Basic", + "Pro", + "Basic", + ], + "units": [10, 12, 8, 15, 11, 13, 9, 16], + } + ) + fig = px.scatter_3d(df, x="month", y="units", z="units", color="product") + assert fig["layout"]["scene"]["xaxis"]["categoryorder"] == "array" + assert fig["layout"]["scene"]["xaxis"]["categoryarray"] == ( + "Jan", + "Feb", + "Mar", + "Apr", + ) + assert fig["layout"]["scene"]["yaxis"]["categoryorder"] is None + assert fig["layout"]["scene"]["zaxis"]["categoryorder"] is None + + +def test_no_default_category_array_for_ecdf(): + # px.ecdf sorts the x values inside each trace, so its categorical axis + # must keep the trace-derived order for the step function to read in + # sequence. + df = pd.DataFrame( + {"x": ["b", "a", "c", "a", "b", "c"], "g": ["p", "p", "p", "q", "q", "q"]} + ) + fig = px.ecdf(df, x="x", color="g") + assert fig["layout"]["xaxis"]["categoryorder"] is None + assert fig["layout"]["xaxis"]["categoryarray"] is None def test_permissive_defaults():