Skip to content

Commit 3180fd7

Browse files
authored
Improve visual appearance of Bar mark (#2889)
* Improve visual appearance of Bar mark * Matplotlib < 3.4 compat * Improve test coverage
1 parent d251dea commit 3180fd7

File tree

2 files changed

+60
-6
lines changed

2 files changed

+60
-6
lines changed

seaborn/_marks/bars.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22
from dataclasses import dataclass
33

4+
import numpy as np
45
import matplotlib as mpl
56

67
from seaborn._marks.base import (
@@ -13,6 +14,7 @@
1314
resolve_properties,
1415
resolve_color,
1516
)
17+
from seaborn.external.version import Version
1618

1719
from typing import TYPE_CHECKING
1820
if TYPE_CHECKING:
@@ -67,31 +69,69 @@ def coords_to_geometry(x, y, w, b):
6769
xy = b, y - h / 2
6870
return xy, w, h
6971

72+
val_idx = ["y", "x"].index(orient)
73+
7074
for _, data, ax in split_gen():
7175

7276
xys = data[["x", "y"]].to_numpy()
7377
data = self._resolve_properties(data, scales)
7478

75-
bars = []
79+
bars, vals = [], []
7680
for i, (x, y) in enumerate(xys):
7781

7882
baseline = data["baseline"][i]
7983
width = data["width"][i]
8084
xy, w, h = coords_to_geometry(x, y, width, baseline)
8185

86+
# Skip bars with no value. It's possible we'll want to make this
87+
# an option (i.e so you have an artist for animating or annotating),
88+
# but let's keep things simple for now.
89+
if not np.nan_to_num(h):
90+
continue
91+
92+
# TODO Because we are clipping the artist (see below), the edges end up
93+
# looking half as wide as they actually are. I don't love this clumsy
94+
# workaround, which is going to cause surprises if you work with the
95+
# artists directly. We may need to revisit after feedback.
96+
linewidth = data["edgewidth"][i] * 2
97+
linestyle = data["edgestyle"][i]
98+
if linestyle[1]:
99+
linestyle = (linestyle[0], tuple(x / 2 for x in linestyle[1]))
100+
82101
bar = mpl.patches.Rectangle(
83102
xy=xy,
84103
width=w,
85104
height=h,
86105
facecolor=data["facecolor"][i],
87106
edgecolor=data["edgecolor"][i],
88-
linewidth=data["edgewidth"][i],
89-
linestyle=data["edgestyle"][i],
107+
linestyle=linestyle,
108+
linewidth=linewidth,
109+
**self.artist_kws,
90110
)
111+
112+
# This is a bit of a hack to handle the fact that the edge lines are
113+
# centered on the actual extents of the bar, and overlap when bars are
114+
# stacked or dodged. We may discover that this causes problems and needs
115+
# to be revisited at some point. Also it should be faster to clip with
116+
# a bbox than a path, but I cant't work out how to get the intersection
117+
# with the axes bbox.
118+
bar.set_clip_path(bar.get_path(), bar.get_transform() + ax.transData)
119+
if self.artist_kws.get("clip_on", True):
120+
# It seems the above hack undoes the default axes clipping
121+
bar.set_clip_box(ax.bbox)
122+
bar.sticky_edges[val_idx][:] = (0, np.inf)
91123
ax.add_patch(bar)
92124
bars.append(bar)
125+
vals.append(h)
93126

94-
# TODO add container object to ax, line ax.bar does
127+
# Add a container which is useful for, e.g. Axes.bar_label
128+
if Version(mpl.__version__) >= Version("3.4.0"):
129+
orientation = {"x": "vertical", "y": "horizontal"}[orient]
130+
container_kws = dict(datavalues=vals, orientation=orientation)
131+
else:
132+
container_kws = {}
133+
container = mpl.container.BarContainer(bars, **container_kws)
134+
ax.add_container(container)
95135

96136
def _legend_artist(
97137
self, variables: list[str], value: Any, scales: dict[str, Scale],

tests/_marks/test_bars.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,10 @@ def test_direct_properties(self):
7676
for bar in ax.patches:
7777
assert bar.get_facecolor() == to_rgba(mark.color, mark.alpha)
7878
assert bar.get_edgecolor() == to_rgba(mark.edgecolor, mark.edgealpha)
79-
assert bar.get_linewidth() == mark.edgewidth
80-
assert bar.get_linestyle() == (0, mark.edgestyle)
79+
# See comments in plotting method for why we need these adjustments
80+
assert bar.get_linewidth() == mark.edgewidth * 2
81+
expected_dashes = (mark.edgestyle[0] / 2, mark.edgestyle[1] / 2)
82+
assert bar.get_linestyle() == (0, expected_dashes)
8183

8284
def test_mapped_properties(self):
8385

@@ -90,3 +92,15 @@ def test_mapped_properties(self):
9092
assert bar.get_facecolor() == to_rgba(f"C{i}", mark.alpha)
9193
assert bar.get_edgecolor() == to_rgba(f"C{i}", 1)
9294
assert ax.patches[0].get_linewidth() < ax.patches[1].get_linewidth()
95+
96+
def test_zero_height_skipped(self):
97+
98+
p = Plot(["a", "b", "c"], [1, 0, 2]).add(Bar()).plot()
99+
ax = p._figure.axes[0]
100+
assert len(ax.patches) == 2
101+
102+
def test_artist_kws_clip(self):
103+
104+
p = Plot(["a", "b"], [1, 2]).add(Bar({"clip_on": False})).plot()
105+
patch = p._figure.axes[0].patches[0]
106+
assert patch.clipbox is None

0 commit comments

Comments
 (0)