Skip to content

Commit 5fdad3e

Browse files
authored
Feat bar labels (#240)
* implementation of _add_bar_labels * update docstring * adjust logic to only update if the xlim or ylim is too small * update padding * update padding * only allow labels added for barcontainers * added unittest * simplify df logic * update docstring with kw * default to False
1 parent 1f3a21c commit 5fdad3e

File tree

3 files changed

+84
-0
lines changed

3 files changed

+84
-0
lines changed

ultraplot/axes/plot.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,10 @@
753753
stack, stacked : bool, default: False
754754
Whether to "stack" bars from successive columns of {y}
755755
data or plot bars side-by-side in groups.
756+
bar_labels : bool, default rc["bar.bar_labels"]
757+
Whether to show the height values for vertical bars or width values for horizontal bars.
758+
bar_labels_kw : dict, default None
759+
Keywords to format the bar_labels, see :func:`~matplotlib.pyplot.bar_label`.
756760
%(plot.args_1d_shared)s
757761
758762
Other parameters
@@ -4165,6 +4169,8 @@ def _apply_bar(
41654169
# Parse args
41664170
kw = kwargs.copy()
41674171
kw, extents = self._inbounds_extent(**kw)
4172+
bar_labels = kw.pop("bar_labels", rc["bar.bar_labels"])
4173+
bar_labels_kw = kw.pop("bar_labels_kw", {})
41684174
name = "barh" if orientation == "horizontal" else "bar"
41694175
stack = _not_none(stack=stack, stacked=stacked)
41704176
xs, hs, kw = self._parse_1d_args(xs, hs, orientation=orientation, **kw)
@@ -4212,6 +4218,10 @@ def _apply_bar(
42124218
obj = self._call_negpos(name, x, h, w, b, use_zero=True, **kw)
42134219
else:
42144220
obj = self._call_native(name, x, h, w, b, **kw)
4221+
if bar_labels:
4222+
if isinstance(obj, mcontainer.BarContainer):
4223+
self._add_bar_labels(obj, orientation=orientation, **bar_labels_kw)
4224+
42154225
self._fix_patch_edges(obj, **edgefix_kw, **kw)
42164226
for y in (b, b + h):
42174227
self._inbounds_xylim(extents, x, y, orientation=orientation)
@@ -4224,6 +4234,59 @@ def _apply_bar(
42244234
self._update_guide(objs, **guide_kw)
42254235
return objs[0] if len(objs) == 1 else cbook.silent_list("BarContainer", objs)
42264236

4237+
def _add_bar_labels(
4238+
self,
4239+
container,
4240+
*,
4241+
orientation="horizontal",
4242+
**kwargs,
4243+
):
4244+
"""
4245+
Automatically add bar labels and rescale the
4246+
limits to produce a striking visual image.
4247+
"""
4248+
# Drawing the labels does not rescale the limits to account
4249+
# for the labels. We therefore first draw them and then
4250+
# adjust the range for x or y depending on the orientation of the bar
4251+
bar_labels = self._call_native("bar_label", container, **kwargs)
4252+
4253+
which = "x" if orientation == "horizontal" else "y"
4254+
other_which = "y" if orientation == "horizontal" else "x"
4255+
4256+
# Get current limits
4257+
current_lim = getattr(self, f"get_{which}lim")()
4258+
other_lim = getattr(self, f"get_{other_which}lim")()
4259+
4260+
# Find the maximum extent of text + bar position
4261+
max_extent = current_lim[1] # Start with current upper limit
4262+
4263+
for label, bar in zip(bar_labels, container):
4264+
# Get text bounding box
4265+
bbox = label.get_window_extent(renderer=self.figure.canvas.get_renderer())
4266+
bbox_data = bbox.transformed(self.transData.inverted())
4267+
4268+
if orientation == "horizontal":
4269+
# For horizontal bars, check if text extends beyond right edge
4270+
bar_end = bar.get_width() + bar.get_x()
4271+
text_end = bar_end + bbox_data.width
4272+
max_extent = max(max_extent, text_end)
4273+
else:
4274+
# For vertical bars, check if text extends beyond top edge
4275+
bar_end = bar.get_height() + bar.get_y()
4276+
text_end = bar_end + bbox_data.height
4277+
max_extent = max(max_extent, text_end)
4278+
4279+
# Only adjust limits if text extends beyond current range
4280+
if max_extent > current_lim[1]:
4281+
padding = (max_extent - current_lim[1]) * 1.25 # Add a bit of padding
4282+
new_lim = (current_lim[0], max_extent + padding)
4283+
getattr(self, f"set_{which}lim")(new_lim)
4284+
4285+
# Keep the other axis unchanged
4286+
getattr(self, f"set_{other_which}lim")(other_lim)
4287+
4288+
return bar_labels
4289+
42274290
@inputs._preprocess_or_redirect("x", "height", "width", "bottom")
42284291
@docstring._concatenate_inherited
42294292
@docstring._snippet_manager

ultraplot/internals/rcsetup.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,11 @@ def copy(self):
946946
_validate_float,
947947
"The fractional *x* and *y* axis margins when limits are unset.",
948948
),
949+
"bar.bar_labels": (
950+
False,
951+
_validate_bool,
952+
"Add value of the bars to the bar labels",
953+
),
949954
# Country borders
950955
"borders": (False, _validate_bool, "Toggles country border lines on and off."),
951956
"borders.alpha": (

ultraplot/tests/test_1dplots.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,3 +647,19 @@ def test_lollipop_graph():
647647
y = [0, 2, 3]
648648
ax[2].lollipop(x, y)
649649
return fig
650+
651+
652+
@pytest.mark.mpl_image_compare
653+
def test_bar_labels():
654+
"""
655+
Simple bar test that rescales the limits if
656+
bar labels are added
657+
"""
658+
categories = ["Apple", "Banana", "Orange", "Grape"]
659+
percentages = [25.3, 42.1, 18.7, 65.2]
660+
fig, ax = uplt.subplots(ncols=2, nrows=1, share=0)
661+
ax.format(abc=True, abcloc="ul")
662+
df = pd.DataFrame({"Percentages": percentages}, index=categories)
663+
ax[0].barh(y="Percentages", data=df, bar_labels=True)
664+
ax[1].bar(x="Percentages", data=df, bar_labels=True)
665+
return fig

0 commit comments

Comments
 (0)