diff --git a/doc/changelog.qmd b/doc/changelog.qmd index 0128da6a4..4b68adaf3 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -112,6 +112,10 @@ title: Changelog text, so with free scales large margins no longer push the tick labels into the neighbouring panel. +- Plot titles in a composition now sit at the same height even when the + plots differ in what lies between the title and the panel — facet + strips, top legends or a top-positioned x axis. + - Subclass geoms now inherit their parent geom's default parameters, so parameters that a parent geom supports are no longer rejected. [](:class:`~plotnine.geom_step`) accepts `lineend`, `linejoin` and `arrow`, diff --git a/plotnine/_mpl/layout_manager/_layout_tree.py b/plotnine/_mpl/layout_manager/_layout_tree.py index c69d55d0a..af6fbf003 100644 --- a/plotnine/_mpl/layout_manager/_layout_tree.py +++ b/plotnine/_mpl/layout_manager/_layout_tree.py @@ -204,6 +204,7 @@ def arrange_layout(self): side-effects. """ self.align_axis_titles() + self.align_plot_titles() self.align() self.resize() @@ -563,6 +564,25 @@ def axis_title_clearance(s): for tree in self.sub_compositions: tree.align_axis_titles() + def align_plot_titles(self): + """ + Align the plot titles across each row of the composition + + The titles in a row line up when every plot has the same + distance between its title block and its panel. Equalising + the plot_title_clearance inserts the compensating space below + the title block, so a plot matches its neighbours' top + legends, facet strips or top axes without its title moving. + """ + + def plot_title_clearance(s): + return s.plot_title_clearance + + _align(self.top_spaces, plot_title_clearance, "plot_title_alignment") + + for tree in self.sub_compositions: + tree.align_plot_titles() + def resize_widths(self): """ Resize the widths of the plots & panels in the composition diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index daeeacb3c..081290251 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -454,16 +454,6 @@ def _calculate(self): if adjustment > 0: self.plot_margin += adjustment - @property - def axis_title_clearance(self) -> float: - """ - Axis-title-to-panel clearance, excluding any facet strip - """ - # The strip sits outside the axis title's alignment band, so it - # does not count toward the title-to-panel clearance used to - # align axis titles across a composition. - return super().axis_title_clearance - self.strip_text - @property def offset(self): """ @@ -544,6 +534,14 @@ class top_space(_plot_side_space): plot_subtitle_margin_top: float = 0 plot_subtitle: float = 0 plot_subtitle_margin_bottom: float = 0 + plot_title_alignment: float = 0 + """ + Space added to align the plot title with others in a composition + + This value is calculated during the layout process. The amount is + the difference between the largest and smallest plot_title_clearance + among the items in the composition. + """ legend: float = 0 legend_box_spacing: float = 0 axis_title_margin_top: float = 0 @@ -621,14 +619,16 @@ def _calculate(self): self.plot_margin += adjustment @property - def axis_title_clearance(self) -> float: + def plot_title_clearance(self) -> float: """ - Axis-title-to-panel clearance, excluding any facet strip + The distance between the plot title block and the panel + + Everything between the title & subtitle and the panel — legend, + axis title, axis text, ticks and facet strip — counts toward + this distance. When it is equal across the plots in a row of a + composition, their titles sit at the same height. """ - # The strip sits outside the axis title's alignment band, so it - # does not count toward the title-to-panel clearance used to - # align axis titles across a composition. - return super().axis_title_clearance - self.strip_text + return self.total - self.sum_upto("plot_title_alignment") @property def offset(self) -> float: diff --git a/tests/baseline_images/test_plot_composition/facets.png b/tests/baseline_images/test_plot_composition/facets.png index 2eb63976d..8ed395b89 100644 Binary files a/tests/baseline_images/test_plot_composition/facets.png and b/tests/baseline_images/test_plot_composition/facets.png differ diff --git a/tests/baseline_images/test_plot_composition/facets_legend_title_align.png b/tests/baseline_images/test_plot_composition/facets_legend_title_align.png new file mode 100644 index 000000000..c55415e1b Binary files /dev/null and b/tests/baseline_images/test_plot_composition/facets_legend_title_align.png differ diff --git a/tests/baseline_images/test_plot_composition/facets_title_align.png b/tests/baseline_images/test_plot_composition/facets_title_align.png new file mode 100644 index 000000000..2e824f917 Binary files /dev/null and b/tests/baseline_images/test_plot_composition/facets_title_align.png differ diff --git a/tests/test_plot_composition.py b/tests/test_plot_composition.py index c8fbedacd..6bba407bc 100644 --- a/tests/test_plot_composition.py +++ b/tests/test_plot_composition.py @@ -113,6 +113,24 @@ def test_facets(): assert p == "facets" +def test_facets_title_align(): + # The titles of a faceted plot (strip above the panel) and a plain + # plot should be at the same height. + p1 = plot.purple + g.points + facet_wrap("cat") + p2 = plot.brown + g.points + legend.bottom + p = p1 | p2 + assert p == "facets_title_align" + + +def test_facets_legend_title_align(): + # The titles of a faceted plot (strip above the panel) and a plain + # plot with a legend at the top should be at the same height. + p1 = plot.purple + g.points + facet_wrap("cat") + p2 = plot.brown + g.points + legend.top + p = p1 | p2 + assert p == "facets_legend_title_align" + + def test_complex_composition(): p1 = plot.red p2 = plot.green + g.points + rotate.plot_title + legend.bottom