diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index a4388d3d3..7aacaaa27 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -6191,6 +6191,32 @@ def _parse_box_violin(fillcolor, fillalpha, edgecolor, **kw): edgecolor = edgecolor[0] return fillcolor, fillalpha, edgecolor, kw + def _boxplot_has_shared_tick_axis(self, axis_name: str) -> bool: + """ + Return whether the boxplot tick axis is shared with sibling axes. + """ + shared = ( + self.get_shared_x_axes() if axis_name == "x" else self.get_shared_y_axes() + ) + return len(shared.get_siblings(self)) > 1 + + def _apply_boxplot_tick_manager( + self, + axis_name: str, + positions: Iterable[Any], + tick_labels: Optional[Iterable[Any]] = None, + ) -> None: + """ + Apply fixed tick locations/labels without appending duplicates on shared axes. + """ + axis = self._axis_map[axis_name] + locator_positions = np.asarray(axis.convert_units(positions)) + label_values = positions if tick_labels is None else tick_labels + axis.set_major_locator(mticker.FixedLocator(locator_positions)) + axis.set_major_formatter( + mticker.FixedFormatter([str(label) for label in label_values]) + ) + def _apply_boxplot( self, x, @@ -6255,6 +6281,27 @@ def _apply_boxplot( # Plot boxes kw.setdefault("positions", x) + tick_labels = kw.get("tick_labels", kw.get("labels")) + manage_ticks = kw.pop("manage_ticks", True) + axis_name = "x" if vert else "y" + # Matplotlib's boxplot tick manager appends onto an existing + # FixedLocator/FixedFormatter pair. UltraPlot's stronger sharex/sharey + # modes currently share ticker state across sibling axes, so repeated + # boxplot calls in one shared group can duplicate tick labels. + # + # For now, avoid the native tick-manager path only for shared axes and + # install the intended fixed ticks ourselves. This keeps the fix narrow + # to the reported regression instead of changing global axis-sharing + # behavior in a bugfix PR. + # + # TODO: Revisit the shared ticker design more broadly. A deeper fix may + # be to stop aliasing ticker containers across shared axes, or to make + # the statistical-plot tick management path deduplicate shared fixed + # locators/formatters after the native call. + native_manage_ticks = manage_ticks and not self._boxplot_has_shared_tick_axis( + axis_name + ) + kw["manage_ticks"] = native_manage_ticks if means: kw["showmeans"] = kw["meanline"] = True y = inputs._dist_clean(y) @@ -6279,6 +6326,13 @@ def _apply_boxplot( # Use vert parameter artists = self._call_native("boxplot", y, vert=vert, **kw) + if manage_ticks and not native_manage_ticks: + self._apply_boxplot_tick_manager( + axis_name, + kw["positions"], + tick_labels=tick_labels, + ) + artists = artists or {} # necessary? artists = { key: cbook.silent_list(type(objs[0]).__name__, objs) if objs else objs diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 38e32b60e..8aabb865d 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -303,6 +303,27 @@ def test_boxplot_mpl_versions( assert "orientation" not in kwargs +def test_boxplot_shared_x_axes_do_not_duplicate_tick_labels(): + data = [np.random.random(size=j * 50) for j in range(1, 11)] + fig, axs = uplt.subplots( + nrows=2, + ncols=2, + sharex=2, + sharey=False, + xrotation=45, + xminorlocator="null", + grid=False, + ) + for ax in axs: + ax.boxplot(data, showfliers=False, lw=0.5) + + expected = [str(i) for i in range(len(data))] + for ax in axs: + labels = [tick.get_text() for tick in ax.xaxis.get_ticklabels()] + assert labels == expected + uplt.close(fig) + + def test_quiver_discrete_colors(rng): """ Edge case where colors are discrete for quiver plots