diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4cc3ac0e..cfdc4e2c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fix `hex_to_rgb` parsing of 3-digit shorthand hexadecimal colors such as `#FFF` [[#5662](https://github.com/plotly/plotly.py/pull/5662)], with thanks to @genrichez for the contribution! - Add `` to the `to_html()` template to comply with modern web standards [[#5693](https://github.com/plotly/plotly.py/pull/5693)], with thanks to @mishrakushal for the contribution! +- Apply `histfunc`/`z` aggregation to `marginal_x`/`marginal_y="histogram"` subplots in `density_heatmap`/`density_contour`, instead of always showing raw bin counts [[#3521](https://github.com/plotly/plotly.py/issues/3521)] ## [6.9.0] - 2026-07-09 diff --git a/plotly/express/_core.py b/plotly/express/_core.py index 1b599cb43c..ab197c13b6 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -367,10 +367,10 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref): trace_patch["marker"]["sizeref"] = sizeref mapping_labels[attr_label] = "%{marker.size}" elif attr_name == "marginal_x": - if trace_spec.constructor == go.Histogram: + if trace_spec.constructor == go.Histogram and args.get("z") is None: mapping_labels["count"] = "%{y}" elif attr_name == "marginal_y": - if trace_spec.constructor == go.Histogram: + if trace_spec.constructor == go.Histogram and args.get("z") is None: mapping_labels["count"] = "%{x}" elif attr_name == "trendline": if ( @@ -580,6 +580,12 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref): mapping_labels[_label] = "%{label}" else: trace_patch[attr_name] = trace_data.get_column(attr_value) + elif attr_name == "z" and trace_spec.constructor == go.Histogram: + # marginal histogram aggregating z via histfunc: feed it onto + # the axis opposite the shared coordinate (trace_spec.marginal) + other_letter = "y" if trace_spec.marginal == "x" else "x" + trace_patch[other_letter] = trace_data.get_column(attr_value) + mapping_labels[attr_label] = "%%{%s}" % other_letter else: trace_patch[attr_name] = trace_data.get_column(attr_value) mapping_labels[attr_label] = "%%{%s}" % attr_name @@ -936,10 +942,18 @@ def make_trace_spec(args, constructor, attrs, trace_patch): yaxis="y1" if letter == "y" else "y2", ) if args["marginal_" + letter] == "histogram": + marginal_attrs = [letter, "marginal_" + letter] + marginal_trace_patch = dict(opacity=0.5, bingroup=letter, **axis_map) + if args.get("z") is not None: + marginal_attrs.append("z") + marginal_trace_patch["histfunc"] = args.get("histfunc") + marginal_trace_patch["orientation"] = ( + "v" if letter == "x" else "h" + ) trace_spec = TraceSpec( constructor=go.Histogram, - attrs=[letter, "marginal_" + letter], - trace_patch=dict(opacity=0.5, bingroup=letter, **axis_map), + attrs=marginal_attrs, + trace_patch=marginal_trace_patch, marginal=letter, ) elif args["marginal_" + letter] == "violin": diff --git a/tests/test_optional/test_px/test_marginals.py b/tests/test_optional/test_px/test_marginals.py index 40d3019f2d..ce48337330 100644 --- a/tests/test_optional/test_px/test_marginals.py +++ b/tests/test_optional/test_px/test_marginals.py @@ -26,6 +26,54 @@ def test_single_marginals(backend, px_fn, marginal, orientation): assert len(fig.data) == 1 + (marginal is not None) +@pytest.mark.parametrize("px_fn", [px.density_heatmap, px.density_contour]) +def test_marginal_histogram_uses_z_and_histfunc(backend, px_fn): # issue 3521 + df = px.data.tips(return_type=backend) + + fig = px_fn( + df, + x="total_bill", + y="tip", + z="size", + histfunc="sum", + marginal_x="histogram", + marginal_y="histogram", + ) + marginal_x_trace, marginal_y_trace = fig.data[1], fig.data[2] + + assert marginal_x_trace.histfunc == "sum" + assert marginal_x_trace.orientation == "v" + assert marginal_x_trace.y is not None + assert len(marginal_x_trace.y) == len(df) + assert "sum of size=%{y}" in marginal_x_trace.hovertemplate + + assert marginal_y_trace.histfunc == "sum" + assert marginal_y_trace.orientation == "h" + assert marginal_y_trace.x is not None + assert len(marginal_y_trace.x) == len(df) + assert "sum of size=%{x}" in marginal_y_trace.hovertemplate + + +@pytest.mark.parametrize("px_fn", [px.density_heatmap, px.density_contour]) +def test_marginal_histogram_without_z_is_unchanged(backend, px_fn): # issue 3521 + df = px.data.tips(return_type=backend) + + fig = px_fn( + df, x="total_bill", y="tip", marginal_x="histogram", marginal_y="histogram" + ) + marginal_x_trace, marginal_y_trace = fig.data[1], fig.data[2] + + assert marginal_x_trace.histfunc is None + assert marginal_x_trace.orientation is None + assert marginal_x_trace.y is None + assert "count=%{y}" in marginal_x_trace.hovertemplate + + assert marginal_y_trace.histfunc is None + assert marginal_y_trace.orientation is None + assert marginal_y_trace.x is None + assert "count=%{x}" in marginal_y_trace.hovertemplate + + def test_unsupported_marginal_raises_clear_error(): # issue 4654 # An unsupported marginal type used to fail deep inside make_figure with a # cryptic "'NoneType' object has no attribute 'constructor'". It should