From 3cb3739ff9ea9bc87bf67f30c7b6b798951d9610 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Thu, 15 Jan 2026 03:27:21 +0300 Subject: [PATCH] feat: individual positioning of texts of a colorbar --- doc/changelog.qmd | 3 + plotnine/guides/guide.py | 48 +++------- plotnine/guides/guide_colorbar.py | 83 ++++++++++-------- plotnine/guides/guide_legend.py | 69 ++++++++++----- plotnine/guides/guides.py | 4 +- plotnine/iapi.py | 18 +++- plotnine/themes/elements/element_line.py | 2 + plotnine/themes/themeable.py | 20 ++++- plotnine/typing.py | 7 +- .../guide_colorbar_text_sequence.png | Bin 0 -> 14326 bytes tests/test_theme.py | 30 +++++++ 11 files changed, 185 insertions(+), 99 deletions(-) create mode 100644 tests/baseline_images/test_theme/guide_colorbar_text_sequence.png diff --git a/doc/changelog.qmd b/doc/changelog.qmd index 9ae786c65a..f55fe87b6e 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -13,6 +13,9 @@ title: Changelog - You can now pass a sequence of horizontal and vertical alignment (ha & va) values in element_text for colorbars. +- You can now use a sequence to set the position of each text on colorbar, or have the + positions alternate left and right or bottom and top. + ### Bug Fixes - Fixed [](:class:`~plotnine.geom_smooth`) / [](:class:`~plotnine.stat_smooth`) when using a linear model via "lm" with weights for the model to do a weighted regression. This bug did not affect the formula API of the linear model. ({{< issue 1005 >}}) diff --git a/plotnine/guides/guide.py b/plotnine/guides/guide.py index c772dc638a..44134f3aa7 100644 --- a/plotnine/guides/guide.py +++ b/plotnine/guides/guide.py @@ -18,6 +18,7 @@ from typing_extensions import Self from plotnine import aes, guides + from plotnine.iapi import guide_text from plotnine.layer import Layers, layer from plotnine.scales.scale import scale from plotnine.typing import ( @@ -139,6 +140,13 @@ def _resolved_position_justification( just = cast("tuple[float, float]", just) return (pos, just) + @property + def num_breaks(self) -> int: + """ + Number of breaks + """ + return len(self.key) + def train( self, scale: scale, aesthetic: Optional[str] = None ) -> Self | None: @@ -177,7 +185,7 @@ class GuideElements: guide: guide @cached_property - def text(self): + def text(self) -> guide_text: raise NotImplementedError def __post_init__(self): @@ -214,16 +222,16 @@ def title(self): ) @cached_property - def text_position(self) -> Side: + def text_positions(self) -> Sequence[Side]: raise NotImplementedError @cached_property - def _text_margin(self) -> float: + def _text_margin(self) -> Sequence[float]: _margin = self.theme.getp( (f"legend_text_{self.guide_kind}", "margin") ).pt - _loc = get_opposite_side(self.text_position)[0] - return getattr(_margin, _loc) + locs = (get_opposite_side(p)[0] for p in self.text_positions) + return [getattr(_margin, loc) for loc in locs] @cached_property def title_position(self) -> Side: @@ -279,33 +287,3 @@ def is_horizontal(self) -> bool: Whether the guide is horizontal """ return self.direction == "horizontal" - - def has(self, n: int) -> Sequence[str]: - """ - Horizontal alignments per legend text - """ - ha = self.text.ha - if isinstance(ha, (list, tuple)): - if len(ha) != n: - raise ValueError( - "If `ha` is a sequence, its length should match the " - f"number of texts. ({len(ha)} != {n})" - ) - else: - ha = (ha,) * n - return ha - - def vas(self, n: int) -> Sequence[str]: - """ - Vertical alignments per legend texts - """ - va = self.text.va - if isinstance(va, (list, tuple)): - if len(va) != n: - raise ValueError( - "If `va` is a sequence, its length should match the " - f"number of texts. ({len(va)} != {n})" - ) - else: - va = (va,) * n - return va diff --git a/plotnine/guides/guide_colorbar.py b/plotnine/guides/guide_colorbar.py index d217615607..d3b1bd5af9 100644 --- a/plotnine/guides/guide_colorbar.py +++ b/plotnine/guides/guide_colorbar.py @@ -11,6 +11,8 @@ import pandas as pd from mizani.bounds import rescale +from plotnine.iapi import guide_text + from .._utils import get_opposite_side from ..exceptions import PlotnineError, PlotnineWarning from ..mapping.aes import rename_aesthetics @@ -417,26 +419,26 @@ def add_labels( """ from matplotlib.text import Text - n = len(labels) - sep = elements.text.margin + seps = elements.text.margins texts: list[Text] = [] - has = elements.has(n) - vas = elements.vas(n) + has = elements.text.has + vas = elements.text.vas + width = elements.key_width # The horizontal and vertical alignments are set in the theme # or dynamically calculates in GuideElements and added to the # themeable properties dict if elements.is_vertical: - if elements.text_position == "right": - xs = [elements.key_width + sep] * n - else: - xs = [-sep] * n + xs = [ + width + sep if side == "right" else -sep + for side, sep in zip(elements.text_positions, seps) + ] else: xs = ys - if elements.text_position == "bottom": - ys = [-sep] * n - else: - ys = [elements.key_width + sep] * n + ys = [ + -sep if side == "bottom" else width + sep + for side, sep in zip(elements.text_positions, seps) + ] for x, y, s, ha, va in zip(xs, ys, labels, has, vas): t = Text(x, y, s, ha=ha, va=va) @@ -475,43 +477,52 @@ def text(self): ha = self.theme.getp(("legend_text_colorbar", "ha")) va = self.theme.getp(("legend_text_colorbar", "va")) is_blank = self.theme.T.is_blank("legend_text_colorbar") + n = self.guide.num_breaks # Default text alignment depends on the direction of the # colorbar - _loc = get_opposite_side(self.text_position) + centers = ("center",) * n + has = (ha,) * n if isinstance(ha, str) else ha + vas = (va,) * n if isinstance(va, str) else va + opposite_sides = [get_opposite_side(s) for s in self.text_positions] if self.is_vertical: - ha = ha or _loc - va = va or "center" + has = has or opposite_sides + vas = vas or centers else: - va = va or _loc - ha = ha or "center" - - return NS( - margin=self._text_margin, - align=None, + vas = vas or opposite_sides + has = has or centers + return guide_text( + self._text_margin, + aligns=centers, fontsize=size, - ha=ha, - va=va, + has=has, # pyright: ignore[reportArgumentType] + vas=vas, # pyright: ignore[reportArgumentType] is_blank=is_blank, ) @cached_property - def text_position(self) -> Side: - if not (position := self.theme.getp("legend_text_position")): + def text_positions(self) -> Sequence[Side]: + if not (user_position := self.theme.getp("legend_text_position")): position = "right" if self.is_vertical else "bottom" + return (position,) * self.guide.num_breaks - if self.is_vertical and position not in ("right", "left"): - msg = ( - "The text position for a vertical legend must be " - "either left or right." - ) - raise PlotnineError(msg) - elif self.is_horizontal and position not in ("bottom", "top"): - msg = ( - "The text position for a horizonta legend must be " - "either top or bottom." + alternate = {"left-right", "right-left", "bottom-top", "top-bottom"} + if user_position in alternate: + tup = user_position.split("-") + return [tup[i % 2] for i in range(self.guide.num_breaks)] + + position = cast("Side | Sequence[Side]", user_position) + + if isinstance(position, str): + position = (position,) * self.guide.num_breaks + + valid = {"right", "left"} if self.is_vertical else {"bottom", "top"} + if any(p for p in position if p not in valid): + raise PlotnineError( + "The text position for a horizontal legend must be " + f"either one of {valid!r}. I got {user_position!r}." ) - raise PlotnineError(msg) + return position @cached_property diff --git a/plotnine/guides/guide_legend.py b/plotnine/guides/guide_legend.py index ce79c22f6a..cf524262b7 100644 --- a/plotnine/guides/guide_legend.py +++ b/plotnine/guides/guide_legend.py @@ -5,20 +5,21 @@ from dataclasses import dataclass, field from functools import cached_property from itertools import islice -from types import SimpleNamespace as NS from typing import TYPE_CHECKING, cast from warnings import warn import numpy as np import pandas as pd +from plotnine.iapi import guide_text + from .._utils import remove_missing from ..exceptions import PlotnineError, PlotnineWarning from ..mapping.aes import rename_aesthetics from .guide import GuideElements, guide if TYPE_CHECKING: - from typing import Any, Optional + from typing import Any, Optional, Sequence from matplotlib.artist import Artist from matplotlib.offsetbox import PackerBase @@ -209,7 +210,7 @@ def _calculate_rows_and_cols( self, elements: GuideElementsLegend ) -> tuple[int, int]: nrow, ncol = self.nrow, self.ncol - nbreak = len(self.key) + nbreak = self.num_breaks if nrow and ncol: if nrow * ncol < nbreak: @@ -248,7 +249,7 @@ def draw(self): obverse = slice(0, None) reverse = slice(None, None, -1) - nbreak = len(self.key) + nbreak = self.num_breaks targets = self.theme.targets keys_order = reverse if self.reverse else obverse elements = self.elements @@ -259,8 +260,12 @@ def draw(self): targets.legend_title = title_box._text # type: ignore # labels - props = {"ha": elements.text.ha, "va": elements.text.va} - labels = [TextArea(s, textprops=props) for s in self.key["label"]] + has = elements.text.has + vas = elements.text.vas + labels = [ + TextArea(s, textprops={"ha": ha, "va": va}) + for s, ha, va in zip(self.key["label"], has, vas) + ] _texts = [l._text for l in labels] # type: ignore targets.legend_text_legend = _texts @@ -287,18 +292,28 @@ def draw(self): "bottom": (VPacker, reverse), "top": (VPacker, obverse), } - packer, slc = lookup[elements.text_position] + if self.elements.text.is_blank: key_boxes = [d for d in drawings][keys_order] else: + packers, slices = [], [] + for side in elements.text_positions: + tup = lookup[side] + packers.append(tup[0]) + slices.append(tup[1]) + + seps = elements.text.margins + aligns = elements.text.aligns key_boxes = [ packer( children=[l, d][slc], - sep=elements.text.margin, - align=elements.text.align, + sep=sep, + align=align, pad=0, ) - for d, l in zip(drawings, labels) + for d, l, packer, slc, sep, align in zip( + drawings, labels, packers, slices, seps, aligns + ) ][keys_order] # Put the entries together in rows or columns @@ -326,7 +341,7 @@ def draw(self): break chunk_boxes: list[Artist] = [ - packer_dim1(children=chunk, align="left", sep=sep1, pad=0) + packer_dim1(children=chunk, align="right", sep=sep1, pad=0) for chunk in chunks ] @@ -364,26 +379,38 @@ def text(self): ha = self.theme.getp(("legend_text_legend", "ha"), "center") va = self.theme.getp(("legend_text_legend", "va"), "center") is_blank = self.theme.T.is_blank("legend_text_legend") + n = self.guide.num_breaks # The original ha & va values are used by the HPacker/VPacker # to align the TextArea with the DrawingArea. # We set ha & va to values that combine best with the aligning # for the text area. - align = va if self.text_position in {"left", "right"} else ha - return NS( - margin=self._text_margin, - align=align, + has = (ha,) * n if isinstance(ha, str) else ha + vas = (va,) * n if isinstance(va, str) else va + aligns = [ + va if side in ("right", "left") else ha + for side, ha, va in zip(self.text_positions, has, vas) + ] + return guide_text( + margins=self._text_margin, + aligns=aligns, # pyright: ignore[reportArgumentType] fontsize=size, - ha="center", - va="baseline", + has=("center",) * n, + vas=("baseline",) * n, is_blank=is_blank, ) @cached_property - def text_position(self) -> Side: - if not (pos := self.theme.getp("legend_text_position")): - pos = "right" - return pos + def text_positions(self) -> Sequence[Side]: + if not (position := self.theme.getp("legend_text_position")): + return ("right",) * self.guide.num_breaks + + position = cast("Side | Sequence[Side]", position) + + if isinstance(position, str): + position = (position,) * self.guide.num_breaks + + return position @cached_property def key_spacing_x(self) -> float: diff --git a/plotnine/guides/guides.py b/plotnine/guides/guides.py index 936026f522..433e5bcf58 100644 --- a/plotnine/guides/guides.py +++ b/plotnine/guides/guides.py @@ -31,12 +31,12 @@ from plotnine.scales.scale import scale from plotnine.scales.scales import Scales from plotnine.typing import ( + Justification, LegendPosition, NoGuide, Orientation, ScaledAestheticsName, Side, - TextJustification, ) LegendOrColorbar: TypeAlias = ( @@ -437,7 +437,7 @@ def _position_inside(self) -> LegendPosition: return ensure_xy_location(just) @cached_property - def box_just(self) -> TextJustification: + def box_just(self) -> Justification | Literal["baseline"]: if not (box_just := self.theme.getp("legend_box_just")): box_just = ( "left" if self.position in {"left", "right"} else "right" diff --git a/plotnine/iapi.py b/plotnine/iapi.py index 34ad5012b0..cb7a9eeaf5 100644 --- a/plotnine/iapi.py +++ b/plotnine/iapi.py @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Iterator, Optional, Sequence + from typing import Any, Iterator, Literal, Optional, Sequence from matplotlib.axes import Axes from matplotlib.figure import Figure @@ -26,8 +26,10 @@ FloatArrayLike, HorizontalJustification, ScaledAestheticsName, + Side, StripPosition, VerticalJustification, + VerticalTextJustification, ) from ._mpl.offsetbox import FlexibleAnchoredOffsetbox @@ -385,3 +387,17 @@ def boxes(self) -> list[FlexibleAnchoredOffsetbox]: ) inside = (l.box for l in self.inside) return list(itertools.chain([*lrtb, *inside])) + + +@dataclass +class guide_text: + """ + Processed guide text + """ + + margins: Sequence[float] + aligns: Sequence[Side | Literal["center"]] + fontsize: float + has: Sequence[HorizontalJustification] + vas: Sequence[VerticalTextJustification] + is_blank: bool diff --git a/plotnine/themes/elements/element_line.py b/plotnine/themes/elements/element_line.py index 746debe385..72fb681a3f 100644 --- a/plotnine/themes/elements/element_line.py +++ b/plotnine/themes/elements/element_line.py @@ -37,6 +37,7 @@ def __init__( *, color: ( str + | Sequence[str] | tuple[float, float, float] | tuple[float, float, float, float] | None @@ -46,6 +47,7 @@ def __init__( lineend: Literal["butt", "projecting", "round"] | None = None, colour: ( str + | Sequence[str] | tuple[float, float, float] | tuple[float, float, float, float] | None diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index 23356bad63..67ab1392b6 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -2368,9 +2368,23 @@ class legend_text_position(themeable): Parameters ---------- - theme_element : Literal["top", "bottom", "left", "right"] | None - Position of the legend key text. The default depends on the - position of the legend. + theme_element : Literal["top", "bottom", "left", "right"] | \ + Sequence[Literal["top", "bottom"]] | \ + Sequence[Literal["left", "right"]] | \ + Literal["top-bottom", "bottom-top"] | \ + Literal["left-right", "right-left"] | \ + None + Position of the legend key text. + It must be compatible with the position of the legend e.g. + when the legend is at the top or bottom, text can only be top + or bottom as well. + The default depends on the position of the legend. + Use a sequence to specify the position of each text, or + hyphenated values like `"left-right"` to alternate the position. + + Notes + ----- + Sequences and alternation only works well for colorbars. """ diff --git a/plotnine/typing.py b/plotnine/typing.py index b2319b0edb..2fd64b2f5b 100644 --- a/plotnine/typing.py +++ b/plotnine/typing.py @@ -122,8 +122,13 @@ def to_pandas(self) -> pd.DataFrame: NoGuide: TypeAlias = Literal["none", False] VerticalJustification: TypeAlias = Literal["bottom", "center", "top"] HorizontalJustification: TypeAlias = Literal["left", "center", "right"] +Justification: TypeAlias = HorizontalJustification | VerticalJustification +HorizontalTextJustification: TypeAlias = HorizontalJustification +VerticalTextJustification: TypeAlias = ( + VerticalJustification | Literal["baseline", "center_baseline"] +) TextJustification: TypeAlias = ( - VerticalJustification | HorizontalJustification | Literal["baseline"] + HorizontalTextJustification | VerticalTextJustification ) # Type Variables diff --git a/tests/baseline_images/test_theme/guide_colorbar_text_sequence.png b/tests/baseline_images/test_theme/guide_colorbar_text_sequence.png new file mode 100644 index 0000000000000000000000000000000000000000..5081ad423aeac9103807204bbc1ad5e0da9e9b0f GIT binary patch literal 14326 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 literal 0 HcmV?d00001 diff --git a/tests/test_theme.py b/tests/test_theme.py index 317e631fc2..948d5f8177 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -13,8 +13,11 @@ geom_blank, geom_point, ggplot, + guide_colorbar, + guides, labs, lims, + scale_color_cmap, theme, theme_538, theme_bw, @@ -190,6 +193,33 @@ def test_guide_colorbar_sequence_alignments(): assert p == "test_guide_colorbar_sequence_ha_va" +def test_guide_colorbar_text_sequence(): + p = ( + ggplot(mtcars) + + geom_point(aes("wt", "mpg", fill="wt", color="cyl")) + + scale_color_cmap("Greens") + + scale_color_cmap("Blues") + + guides( + fill=guide_colorbar( + theme=theme( + legend_title=element_text(ha="center"), + legend_text_position="left-right", + ) + ), + color=guide_colorbar( + theme=theme( + legend_position="bottom", + legend_text_position="bottom-top", + ) + ), + ) + + theme( + legend_ticks=element_line(color=["red", "none", "none", "red"] * 2) + ) + ) + assert p == "guide_colorbar_text_sequence" + + g = ( ggplot(mtcars, aes(x="wt", y="mpg", color="factor(gear)")) + geom_point()