diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index 0ea9a17f0..3ad6ccadd 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -458,6 +458,7 @@ quartodoc: - panel_spacing_y - plot_background - plot_caption + - plot_caption_position - plot_margin - plot_margin_bottom - plot_margin_left @@ -465,6 +466,7 @@ quartodoc: - plot_margin_top - plot_subtitle - plot_title + - plot_title_position - rect - strip_align - strip_align_x diff --git a/doc/changelog.qmd b/doc/changelog.qmd index fbaac6afe..680533a2e 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -1,6 +1,27 @@ --- title: Changelog --- +--- +## v0.15.0 +(not-yet-released) + +### New Features + +- Using + [](:class:`~plotnine.themes.themeables.plot_title_position`) and + [](:class:`~plotnine.themes.themeables.plot_caption_position`) e.g. + + ```python + theme( + plot_title_position="plot", + plot_caption_position="plot", + ) + ``` + + You can now position the `plot_title`, `plot_subtitle` and `plot_caption` + by alignment them with respect to the plot. ({{< issue 838 >}}) + + ## v0.14.1 (2024-11-05) diff --git a/plotnine/_mpl/layout_manager/_layout_items.py b/plotnine/_mpl/layout_manager/_layout_items.py index 23ef4a18e..ef8fad88a 100644 --- a/plotnine/_mpl/layout_manager/_layout_items.py +++ b/plotnine/_mpl/layout_manager/_layout_items.py @@ -450,31 +450,39 @@ def _adjust_positions(self, spaces: LayoutSpaces): Set the x,y position of the artists around the panels """ theme = self.plot.theme + plot_title_position = theme.getp("plot_title_position", "panel") + plot_caption_position = theme.getp("plot_caption_position", "panel") if self.plot_title: ha = theme.getp(("plot_title", "ha")) self.plot_title.set_y(spaces.t.edge("plot_title")) - horizontally_align_text_with_panels(self.plot_title, ha, spaces) + horizontally_align_text( + self.plot_title, ha, spaces, plot_title_position + ) if self.plot_subtitle: ha = theme.getp(("plot_subtitle", "ha")) self.plot_subtitle.set_y(spaces.t.edge("plot_subtitle")) - horizontally_align_text_with_panels(self.plot_subtitle, ha, spaces) + horizontally_align_text( + self.plot_subtitle, ha, spaces, plot_title_position + ) if self.plot_caption: ha = theme.getp(("plot_caption", "ha"), "right") self.plot_caption.set_y(spaces.b.edge("plot_caption")) - horizontally_align_text_with_panels(self.plot_caption, ha, spaces) + horizontally_align_text( + self.plot_caption, ha, spaces, plot_caption_position + ) if self.axis_title_x: ha = theme.getp(("axis_title_x", "ha"), "center") self.axis_title_x.set_y(spaces.b.edge("axis_title_x")) - horizontally_align_text_with_panels(self.axis_title_x, ha, spaces) + horizontally_align_text(self.axis_title_x, ha, spaces) if self.axis_title_y: va = theme.getp(("axis_title_y", "va"), "center") self.axis_title_y.set_x(spaces.l.edge("axis_title_y")) - vertically_align_text_with_panels(self.axis_title_y, va, spaces) + vertically_align_text(self.axis_title_y, va, spaces) if self.legends: set_legends_position(self.legends, spaces) @@ -487,13 +495,17 @@ def _text_is_visible(text: Text) -> bool: return text.get_visible() and text._text # type: ignore -def horizontally_align_text_with_panels( - text: Text, ha: str | float, spaces: LayoutSpaces +def horizontally_align_text( + text: Text, + ha: str | float, + spaces: LayoutSpaces, + how: Literal["panel", "plot"] = "panel", ): """ Horizontal justification - Reinterpret horizontal alignment to be justification about the panels. + Reinterpret horizontal alignment to be justification about the panels or + the plot (depending on the how parameter) """ if isinstance(ha, str): lookup = { @@ -505,20 +517,30 @@ def horizontally_align_text_with_panels( else: f = ha - params = spaces.gsparams + if how == "panel": + left = spaces.l.left + right = spaces.r.right + else: + left = spaces.l.plot_left + right = spaces.r.plot_right + width = spaces.items.calc.width(text) - x = params.left * (1 - f) + (params.right - width) * f + x = left * (1 - f) + (right - width) * f text.set_x(x) text.set_horizontalalignment("left") -def vertically_align_text_with_panels( - text: Text, va: str | float, spaces: LayoutSpaces +def vertically_align_text( + text: Text, + va: str | float, + spaces: LayoutSpaces, + how: Literal["panel", "plot"] = "panel", ): """ Vertical justification - Reinterpret vertical alignment to be justification about the panels. + Reinterpret vertical alignment to be justification about the panels or + the plot (depending on the how parameter). """ if isinstance(va, str): lookup = { @@ -532,9 +554,15 @@ def vertically_align_text_with_panels( else: f = va - params = spaces.gsparams + if how == "panel": + top = spaces.t.top + bottom = spaces.b.bottom + else: + top = spaces.t.plot_top + bottom = spaces.b.plot_bottom + height = spaces.items.calc.height(text) - y = params.bottom * (1 - f) + (params.top - height) * f + y = bottom * (1 - f) + (top - height) * f text.set_y(y) text.set_verticalalignment("bottom") diff --git a/plotnine/_mpl/layout_manager/_spaces.py b/plotnine/_mpl/layout_manager/_spaces.py index bed7a7d65..c9d18e773 100644 --- a/plotnine/_mpl/layout_manager/_spaces.py +++ b/plotnine/_mpl/layout_manager/_spaces.py @@ -173,6 +173,20 @@ def edge(self, item: str) -> float: """ return self.sum_upto(item) + @property + def left(self): + """ + Left of the panels in figure space + """ + return self.total + + @property + def plot_left(self): + """ + Distance in figure space from left edge upto where artists start + """ + return self.edge("legend") + @dataclass class right_spaces(_side_spaces): @@ -219,6 +233,20 @@ def edge(self, item: str) -> float: """ return 1 - self.sum_upto(item) + @property + def right(self): + """ + Right of the panels in figure space + """ + return 1 - self.total + + @property + def plot_right(self): + """ + Distance in figure space from right edge upto where artists start + """ + return self.edge("legend") + @dataclass class top_spaces(_side_spaces): @@ -284,6 +312,20 @@ def edge(self, item: str) -> float: """ return 1 - self.sum_upto(item) + @property + def top(self): + """ + Top of the panels in figure space + """ + return 1 - self.total + + @property + def plot_top(self): + """ + Distance in figure space from top edge upto where artists start + """ + return self.edge("legend") + @dataclass class bottom_spaces(_side_spaces): @@ -353,6 +395,20 @@ def edge(self, item: str) -> float: """ return self.sum_upto(item) + @property + def bottom(self): + """ + Bottom of the panels in figure space + """ + return self.total + + @property + def plot_bottom(self): + """ + Distance in figure space from bottom edge upto where artists start + """ + return self.edge("legend") + @dataclass class LayoutSpaces: @@ -434,34 +490,6 @@ def __post_init__(self): # Increase aspect ratio, wider panels self._reduce_height(ratio) - @property - def left(self): - """ - Left of the panels in figure space - """ - return self.l.total - - @property - def right(self): - """ - Right of the panels in figure space - """ - return 1 - self.r.total - - @property - def top(self): - """ - Top of the panels in figure space - """ - return 1 - self.t.total - - @property - def bottom(self): - """ - Bottom of the panels in figure space - """ - return self.b.total - def increase_horizontal_plot_margin(self, dw: float): """ Increase the plot_margin to the right & left of the panels @@ -494,7 +522,12 @@ def _calculate_panel_spacing(self) -> GridSpecParams: raise TypeError(f"Unknown type of facet: {type(self.plot.facet)}") return GridSpecParams( - self.left, self.right, self.top, self.bottom, wspace, hspace + self.l.left, + self.r.right, + self.t.top, + self.b.bottom, + wspace, + hspace, ) def _calculate_panel_spacing_facet_grid(self) -> tuple[float, float]: @@ -513,8 +546,8 @@ def _calculate_panel_spacing_facet_grid(self) -> tuple[float, float]: self.sh = theme.getp("panel_spacing_y") * self.W / self.H # width and height of axes as fraction of figure width & height - self.w = ((self.right - self.left) - self.sw * (ncol - 1)) / ncol - self.h = ((self.top - self.bottom) - self.sh * (nrow - 1)) / nrow + self.w = ((self.r.right - self.l.left) - self.sw * (ncol - 1)) / ncol + self.h = ((self.t.top - self.b.bottom) - self.sh * (nrow - 1)) / nrow # Spacing as fraction of axes width & height wspace = self.sw / self.w @@ -559,8 +592,8 @@ def _calculate_panel_spacing_facet_wrap(self) -> tuple[float, float]: ) + self.items.axis_ticks_y_max_width("all") # width and height of axes as fraction of figure width & height - self.w = ((self.right - self.left) - self.sw * (ncol - 1)) / ncol - self.h = ((self.top - self.bottom) - self.sh * (nrow - 1)) / nrow + self.w = ((self.r.right - self.l.left) - self.sw * (ncol - 1)) / ncol + self.h = ((self.t.top - self.b.bottom) - self.sh * (nrow - 1)) / nrow # Spacing as fraction of axes width & height wspace = self.sw / self.w @@ -571,8 +604,8 @@ def _calculate_panel_spacing_facet_null(self) -> tuple[float, float]: """ Calculate spacing parts for facet_null """ - self.w = self.right - self.left - self.h = self.top - self.bottom + self.w = self.r.right - self.l.left + self.h = self.t.top - self.b.bottom self.sw = 0 self.sh = 0 return 0, 0 diff --git a/plotnine/themes/theme.py b/plotnine/themes/theme.py index 2c8e9c095..39dd40388 100644 --- a/plotnine/themes/theme.py +++ b/plotnine/themes/theme.py @@ -122,6 +122,8 @@ def __init__( plot_title=None, plot_subtitle=None, plot_caption=None, + plot_title_position=None, + plot_caption_position=None, strip_text_x=None, strip_text_y=None, strip_text=None, diff --git a/plotnine/themes/theme_gray.py b/plotnine/themes/theme_gray.py index 74cdc9cfa..189d6e073 100644 --- a/plotnine/themes/theme_gray.py +++ b/plotnine/themes/theme_gray.py @@ -126,6 +126,8 @@ def __init__(self, base_size=11, base_family=None): ma="left", margin={"b": m, "units": "fig"}, ), + plot_title_position="panel", + plot_caption_position="panel", strip_align=0, strip_background=element_rect(color="none", fill="#D9D9D9"), strip_background_x=element_rect(width=1), diff --git a/plotnine/themes/theme_matplotlib.py b/plotnine/themes/theme_matplotlib.py index 476c89592..4a56d591f 100644 --- a/plotnine/themes/theme_matplotlib.py +++ b/plotnine/themes/theme_matplotlib.py @@ -104,6 +104,8 @@ def __init__(self, rc=None, fname=None, use_defaults=True): ma="left", margin={"b": m, "units": "fig"}, ), + plot_title_position="panel", + plot_caption_position="panel", strip_align=0, strip_background=element_rect( fill="#D9D9D9", color="black", size=linewidth diff --git a/plotnine/themes/theme_seaborn.py b/plotnine/themes/theme_seaborn.py index ef2dff00a..c1220096f 100644 --- a/plotnine/themes/theme_seaborn.py +++ b/plotnine/themes/theme_seaborn.py @@ -116,6 +116,8 @@ def __init__( ma="left", margin={"b": m, "units": "fig"}, ), + plot_title_position="panel", + plot_caption_position="panel", strip_align=0, strip_background=element_rect(color="none", fill="#D1CDDF"), strip_text=element_text( diff --git a/plotnine/themes/theme_void.py b/plotnine/themes/theme_void.py index 0c43eb487..28c3a9527 100644 --- a/plotnine/themes/theme_void.py +++ b/plotnine/themes/theme_void.py @@ -93,6 +93,8 @@ def __init__(self, base_size=11, base_family=None): ma="left", margin={"b": m, "units": "fig"}, ), + plot_title_position="panel", + plot_caption_position="panel", strip_align=0, strip_text=element_text( color="#1A1A1A", diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index a20a489cb..89a41fccc 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -742,6 +742,32 @@ def blank_figure(self, figure: Figure, targets: ThemeTargets): text.set_visible(False) +class plot_title_position(themeable): + """ + How to align the plot title and plot subtitle + + Parameters + ---------- + theme_element : Literal["panel", "plot"], default = "panel" + If "panel", the title / subtitle are aligned with respect + to the panels. If "plot", they are aligned with the plot, + excluding the margin space + """ + + +class plot_caption_position(themeable): + """ + How to align the plot caption + + Parameters + ---------- + theme_element : Literal["panel", "plot"], default = "panel" + If "panel", the caption is aligned with respect to the + panels. If "plot", it is aligned with the plot, excluding + the margin space. + """ + + class strip_text_y(MixinSequenceOfValues): """ Facet labels along the vertical axis diff --git a/tests/baseline_images/test_layout/plot_titles_and_caption_positioning.png b/tests/baseline_images/test_layout/plot_titles_and_caption_positioning.png new file mode 100644 index 000000000..115731389 Binary files /dev/null and b/tests/baseline_images/test_layout/plot_titles_and_caption_positioning.png differ diff --git a/tests/test_layout.py b/tests/test_layout.py index e875cc527..7e08d3548 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -154,6 +154,13 @@ def test_different_colorbar_themes(self): assert p == "different_colorbar_themes" + def test_plot_titles_and_caption_positioning(self): + p = self.g + theme( + plot_title_position="plot", + plot_caption_position="plot", + ) + assert p == "plot_titles_and_caption_positioning" + class TestLegendPositioning: g = ggplot(mtcars, aes(x="wt", y="mpg", color="gear")) + geom_point()