Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!doctype html>` 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
Expand Down
22 changes: 18 additions & 4 deletions plotly/express/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down
48 changes: 48 additions & 0 deletions tests/test_optional/test_px/test_marginals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down