Skip to content

Commit a911810

Browse files
committed
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.
1 parent 4c5cde0 commit a911810

3 files changed

Lines changed: 200 additions & 56 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
2323
- 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!
2424
- 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!
2525
- 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!
26-
- 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)]
26+
- 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)]
2727

2828
## [7.1.0] - 2026-09-15
2929

‎plotly/express/_core.py‎

Lines changed: 80 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2530,58 +2530,64 @@ def get_groups_and_orders(args, grouper):
25302530
return groups, orders
25312531

25322532

2533-
def _default_category_orders_for_axes(args, groups, orders, constructor):
2533+
def _default_x_category_order(args, groups, orders, constructor):
25342534
"""
2535-
Add data-appearance order for the categorical x axis.
2535+
Return the data-appearance order of the categorical x column, or None.
25362536
25372537
When the data is split into several traces (e.g. by `color`), plotly.js
25382538
orders categories by first appearance across traces, which can differ from
25392539
the order in the underlying data. Recording the data-appearance order here
2540-
lets `set_cartesian_axis_opts` emit an explicit `categoryarray`, matching
2541-
the documented behavior that Plotly Express lays out categorical data in
2542-
the order in which it appears in the data. See #3198.
2543-
2544-
The default is only emitted for figures with a single subplot, so the one
2545-
x axis it touches spans the whole data-frame:
2546-
2547-
- With facets each subplot only carries its own categories and the
2548-
documented independent-axes technique (disabling `matches`, see
2549-
`doc/python/facet-plots.md`) must keep listing each axis only its own
2550-
categories, which an explicit data-wide `categoryarray` would break. See
2551-
https://github.com/plotly/plotly.py/pull/5776#pullrequestreview-5701715902
2552-
2553-
- With marginals the extra subplots span the y dimension, so an explicit
2554-
x `categoryarray` on their axes would be wrong.
2555-
2556-
Figures with subplots could get per-axis data orders in a follow up.
2540+
lets the axis configurators emit an explicit `categoryarray`, matching the
2541+
documented behavior that Plotly Express lays out categorical data in the
2542+
order in which it appears in the data. See #3198.
2543+
2544+
The default only applies to string categoricals the user did not order
2545+
explicitly, to figures split into more than one trace, and to the x axis:
2546+
2547+
- Cartesian figures with facets or marginals apply the default per axis
2548+
in `make_figure`: each x axis gets the whole-data-frame order
2549+
restricted to the categories carried by the traces on its subplot,
2550+
exactly the order its single-subplot counterpart would use with the
2551+
categories absent from the subplot dropped. Shared categories keep the
2552+
same relative positions on matched facet axes, and the documented
2553+
independent-axes technique (disabling `matches`, see
2554+
`doc/python/facet-plots.md`) still lists each axis only its own
2555+
categories. The right-hand marginal of `marginal_y` spans the y
2556+
dimension and carries the binned y values, so it never receives the x
2557+
order.
2558+
2559+
- `px.ecdf` sorts the x values inside each trace, so its categorical
2560+
axis must keep the trace-derived order for the step function to read
2561+
in sequence.
2562+
2563+
- `px.scatter_3d` has a single scene, so the whole-data-frame order is
2564+
applied through the per-column mechanism in `configure_3d_axes`.
25572565
25582566
Only the x axis is handled: y categoricals are intentionally left alone
25592567
because `set_cartesian_axis_opts` reverses y orders for top-down display
25602568
and figures such as `px.timeline` / horizontal `px.bar` pair that with
25612569
`autorange="reversed"` (see `doc/python/gantt.md`), so an explicit y
25622570
`categoryarray` would double-reverse and flip the documented order.
25632571
"""
2564-
if constructor not in cartesians:
2565-
return orders
2572+
if constructor not in cartesians and constructor is not go.Scatter3d:
2573+
return None
25662574
if len(groups) <= 1:
2567-
return orders
2568-
if args.get("facet_row") or args.get("facet_col"):
2569-
return orders
2570-
if args.get("marginal_x") or args.get("marginal_y"):
2571-
return orders
2575+
return None
2576+
if args.get("ecdfmode") is not None:
2577+
return None
25722578
df = args["data_frame"]
25732579
col = args.get("x")
25742580
if not isinstance(col, str) or col in orders or col not in df.columns:
2575-
return orders
2581+
return None
25762582
uniques = df.get_column(col).unique(maintain_order=True).to_list()
25772583
categories = [
25782584
value
25792585
for value in uniques
25802586
if value is not None and not (isinstance(value, float) and math.isnan(value))
25812587
]
25822588
if categories and all(isinstance(value, str) for value in categories):
2583-
orders[col] = categories
2584-
return orders
2589+
return categories
2590+
return None
25852591

25862592

25872593
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):
26102616
)
26112617
grouper = [x.grouper or one_group for x in grouped_mappings] or [one_group]
26122618
groups, orders = get_groups_and_orders(args, grouper)
2613-
orders = _default_category_orders_for_axes(args, groups, orders, constructor)
2614-
2619+
default_x_categories = _default_x_category_order(args, groups, orders, constructor)
2620+
per_axis_order = (
2621+
default_x_categories is not None
2622+
and constructor in cartesians
2623+
and (
2624+
args.get("facet_row") is not None
2625+
or args.get("facet_col") is not None
2626+
or args.get("marginal_x") is not None
2627+
or args.get("marginal_y") is not None
2628+
)
2629+
)
2630+
if default_x_categories is not None and not per_axis_order:
2631+
# The figure has a single x axis spanning the whole data frame (a
2632+
# plain cartesian subplot or a 3D scene), so the per-column
2633+
# mechanism in `set_cartesian_axis_opts` / `configure_3d_axes`
2634+
# applies the data order directly.
2635+
orders[args["x"]] = default_x_categories
2636+
2637+
trace_axes_rows = []
26152638
col_labels = []
26162639
row_labels = []
26172640
nrows = ncols = 1
@@ -2660,6 +2683,11 @@ def make_figure(args, constructor, trace_patch=None, layout_patch=None):
26602683
for trace_spec in trace_specs:
26612684
# Create the trace
26622685
trace = trace_spec.constructor(name=trace_name)
2686+
if per_axis_order:
2687+
# Remember the rows feeding this trace so the data order can
2688+
# be computed per x axis once the traces are placed on their
2689+
# subplots.
2690+
trace_axes_rows.append((trace, group, trace_spec.marginal))
26632691
if trace_spec.constructor not in [
26642692
go.Parcats,
26652693
go.Parcoords,
@@ -2908,6 +2936,27 @@ def make_figure(args, constructor, trace_patch=None, layout_patch=None):
29082936
fig._px_trendlines = []
29092937

29102938
configure_axes(args, constructor, fig, orders)
2939+
if per_axis_order:
2940+
# Each x axis gets the whole-data-frame order restricted to the
2941+
# categories carried by the traces placed on it: the same order its
2942+
# single-subplot counterpart would use, with the categories absent
2943+
# from the subplot dropped, so matched facet axes agree on the
2944+
# relative positions of shared categories. Marginal axes spanning
2945+
# the y dimension do not carry the x column and get no default.
2946+
axis_x_values = {}
2947+
for trace, group, marginal in trace_axes_rows:
2948+
xaxis_id = getattr(trace, "xaxis", None)
2949+
if xaxis_id is None or marginal == "y":
2950+
continue
2951+
axis_x_values.setdefault(xaxis_id, set()).update(
2952+
group.get_column(args["x"]).to_list()
2953+
)
2954+
for xaxis_id, values in axis_x_values.items():
2955+
categories = [value for value in default_x_categories if value in values]
2956+
if categories:
2957+
axis = fig.layout["xaxis" + xaxis_id[1:]]
2958+
axis["categoryorder"] = "array"
2959+
axis["categoryarray"] = categories
29112960
configure_animation_controls(args, constructor, fig)
29122961
return fig
29132962

‎tests/test_optional/test_px/test_px.py‎

Lines changed: 119 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -431,35 +431,130 @@ def test_unlinked_facet_axes_only_list_their_own_categories():
431431
assert axis.categoryarray is None or set(axis.categoryarray) <= set(trace.x)
432432

433433

434-
def test_no_default_category_array_with_subplots():
435-
# https://github.com/plotly/plotly.py/pull/5776#pullrequestreview-5701715902
436-
# Facet axes list their own categories (the `matches=None` technique in
437-
# doc/python/facet-plots.md), and marginal subplots span the y dimension,
438-
# so no axis of a faceted or marginal figure may get a data-wide default.
434+
def test_default_category_order_applies_to_facets():
435+
# https://github.com/plotly/plotly.py/pull/5776#discussion_r4429048272
436+
# A facet must not change the x order relative to the same chart without
437+
# facets: every facet axis follows the data order of its own rows, and
438+
# the y axes stay untouched.
439439
df = pd.DataFrame(
440440
{
441-
"panel": ["left", "right", "left", "right", "left"],
442-
"fruit": ["apple", "banana", "cherry", "date", "elderberry"],
443-
"sold": [5, 3, 4, 6, 2],
441+
"month": ["Jan", "Feb", "Mar", "Apr", "Jan", "Feb", "Mar", "Apr"],
442+
"product": [
443+
"Basic",
444+
"Basic",
445+
"Pro",
446+
"Basic",
447+
"Basic",
448+
"Basic",
449+
"Pro",
450+
"Basic",
451+
],
452+
"region": ["East", "East", "East", "East", "West", "West", "West", "West"],
453+
"units": [10, 12, 8, 15, 11, 13, 9, 16],
444454
}
445455
)
446-
fig = px.bar(df, x="fruit", y="sold", facet_col="panel")
447-
for axname in fig.layout:
448-
if axname.startswith("xaxis"):
449-
assert fig.layout[axname].categoryorder is None
450-
assert fig.layout[axname].categoryarray is None
451-
fig = px.scatter(
452-
df,
453-
x="fruit",
454-
y="sold",
455-
color="panel",
456-
marginal_y="rug",
457-
marginal_x="rug",
456+
fig = px.bar(df, x="month", y="units", color="product")
457+
assert fig["layout"]["xaxis"]["categoryorder"] == "array"
458+
assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
459+
fig = px.bar(df, x="month", y="units", color="product", facet_col="region")
460+
assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
461+
assert fig["layout"]["xaxis2"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
462+
fig = px.bar(df, x="month", y="units", color="product", facet_row="region")
463+
assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
464+
assert fig["layout"]["xaxis2"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
465+
assert fig["layout"]["yaxis"]["categoryorder"] is None
466+
assert fig["layout"]["yaxis2"]["categoryorder"] is None
467+
# numeric axes never get a category array, faceted or not
468+
fig = px.bar(df, x="units", y="units", color="product", facet_col="region")
469+
assert fig["layout"]["xaxis"]["categoryorder"] is None
470+
assert fig["layout"]["xaxis2"]["categoryorder"] is None
471+
472+
473+
def test_facet_axes_list_their_own_categories_in_data_order():
474+
df = pd.DataFrame(
475+
{
476+
"region": ["E", "E", "W", "E", "W"],
477+
"cat": ["a", "b", "a", "c", "b"],
478+
"kind": ["p", "q", "q", "q", "p"],
479+
"val": [1, 2, 3, 4, 5],
480+
}
458481
)
459-
for axname in fig.layout:
460-
if axname.startswith("xaxis"):
461-
assert fig.layout[axname].categoryorder is None
462-
assert fig.layout[axname].categoryarray is None
482+
fig = px.bar(df, x="cat", y="val", color="kind", facet_col="region")
483+
# each facet lists only its own categories, in the order of the whole
484+
# data frame: the trace-derived order scrambles the second facet because
485+
# its "kind=p" trace carries "b" and is created before its "kind=q" trace
486+
assert fig["layout"]["xaxis"]["categoryarray"] == ("a", "b", "c")
487+
assert fig["layout"]["xaxis2"]["categoryarray"] == ("a", "b")
488+
489+
490+
def test_default_category_order_with_marginals():
491+
# The top marginal of `marginal_x` shares the x dimension with the main
492+
# subplot and gets the same data order. The right marginal of
493+
# `marginal_y` spans the y dimension and carries the binned y values, so
494+
# it must not receive the x order.
495+
df = pd.DataFrame(
496+
{
497+
"month": ["Jan", "Feb", "Mar", "Apr", "Jan", "Feb", "Mar", "Apr"],
498+
"product": [
499+
"Basic",
500+
"Basic",
501+
"Pro",
502+
"Basic",
503+
"Basic",
504+
"Basic",
505+
"Pro",
506+
"Basic",
507+
],
508+
"units": [10, 12, 8, 15, 11, 13, 9, 16],
509+
}
510+
)
511+
fig = px.scatter(df, x="month", y="units", color="product", marginal_x="rug")
512+
assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
513+
assert fig["layout"]["xaxis2"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
514+
fig = px.scatter(df, x="month", y="units", color="product", marginal_y="rug")
515+
assert fig["layout"]["xaxis"]["categoryarray"] == ("Jan", "Feb", "Mar", "Apr")
516+
assert fig["layout"]["xaxis2"]["categoryarray"] is None
517+
518+
519+
def test_default_category_order_for_3d():
520+
df = pd.DataFrame(
521+
{
522+
"month": ["Jan", "Feb", "Mar", "Apr", "Jan", "Feb", "Mar", "Apr"],
523+
"product": [
524+
"Basic",
525+
"Basic",
526+
"Pro",
527+
"Basic",
528+
"Basic",
529+
"Basic",
530+
"Pro",
531+
"Basic",
532+
],
533+
"units": [10, 12, 8, 15, 11, 13, 9, 16],
534+
}
535+
)
536+
fig = px.scatter_3d(df, x="month", y="units", z="units", color="product")
537+
assert fig["layout"]["scene"]["xaxis"]["categoryorder"] == "array"
538+
assert fig["layout"]["scene"]["xaxis"]["categoryarray"] == (
539+
"Jan",
540+
"Feb",
541+
"Mar",
542+
"Apr",
543+
)
544+
assert fig["layout"]["scene"]["yaxis"]["categoryorder"] is None
545+
assert fig["layout"]["scene"]["zaxis"]["categoryorder"] is None
546+
547+
548+
def test_no_default_category_array_for_ecdf():
549+
# px.ecdf sorts the x values inside each trace, so its categorical axis
550+
# must keep the trace-derived order for the step function to read in
551+
# sequence.
552+
df = pd.DataFrame(
553+
{"x": ["b", "a", "c", "a", "b", "c"], "g": ["p", "p", "p", "q", "q", "q"]}
554+
)
555+
fig = px.ecdf(df, x="x", color="g")
556+
assert fig["layout"]["xaxis"]["categoryorder"] is None
557+
assert fig["layout"]["xaxis"]["categoryarray"] is None
463558

464559

465560
def test_permissive_defaults():

0 commit comments

Comments
 (0)