Skip to content

Commit 3cb3739

Browse files
committed
feat: individual positioning of texts of a colorbar
1 parent 52645bf commit 3cb3739

File tree

11 files changed

+185
-99
lines changed

11 files changed

+185
-99
lines changed

doc/changelog.qmd

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ title: Changelog
1313
- You can now pass a sequence of horizontal and vertical alignment (ha & va) values in
1414
element_text for colorbars.
1515

16+
- You can now use a sequence to set the position of each text on colorbar, or have the
17+
positions alternate left and right or bottom and top.
18+
1619
### Bug Fixes
1720

1821
- 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 >}})

plotnine/guides/guide.py

Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from typing_extensions import Self
1919

2020
from plotnine import aes, guides
21+
from plotnine.iapi import guide_text
2122
from plotnine.layer import Layers, layer
2223
from plotnine.scales.scale import scale
2324
from plotnine.typing import (
@@ -139,6 +140,13 @@ def _resolved_position_justification(
139140
just = cast("tuple[float, float]", just)
140141
return (pos, just)
141142

143+
@property
144+
def num_breaks(self) -> int:
145+
"""
146+
Number of breaks
147+
"""
148+
return len(self.key)
149+
142150
def train(
143151
self, scale: scale, aesthetic: Optional[str] = None
144152
) -> Self | None:
@@ -177,7 +185,7 @@ class GuideElements:
177185
guide: guide
178186

179187
@cached_property
180-
def text(self):
188+
def text(self) -> guide_text:
181189
raise NotImplementedError
182190

183191
def __post_init__(self):
@@ -214,16 +222,16 @@ def title(self):
214222
)
215223

216224
@cached_property
217-
def text_position(self) -> Side:
225+
def text_positions(self) -> Sequence[Side]:
218226
raise NotImplementedError
219227

220228
@cached_property
221-
def _text_margin(self) -> float:
229+
def _text_margin(self) -> Sequence[float]:
222230
_margin = self.theme.getp(
223231
(f"legend_text_{self.guide_kind}", "margin")
224232
).pt
225-
_loc = get_opposite_side(self.text_position)[0]
226-
return getattr(_margin, _loc)
233+
locs = (get_opposite_side(p)[0] for p in self.text_positions)
234+
return [getattr(_margin, loc) for loc in locs]
227235

228236
@cached_property
229237
def title_position(self) -> Side:
@@ -279,33 +287,3 @@ def is_horizontal(self) -> bool:
279287
Whether the guide is horizontal
280288
"""
281289
return self.direction == "horizontal"
282-
283-
def has(self, n: int) -> Sequence[str]:
284-
"""
285-
Horizontal alignments per legend text
286-
"""
287-
ha = self.text.ha
288-
if isinstance(ha, (list, tuple)):
289-
if len(ha) != n:
290-
raise ValueError(
291-
"If `ha` is a sequence, its length should match the "
292-
f"number of texts. ({len(ha)} != {n})"
293-
)
294-
else:
295-
ha = (ha,) * n
296-
return ha
297-
298-
def vas(self, n: int) -> Sequence[str]:
299-
"""
300-
Vertical alignments per legend texts
301-
"""
302-
va = self.text.va
303-
if isinstance(va, (list, tuple)):
304-
if len(va) != n:
305-
raise ValueError(
306-
"If `va` is a sequence, its length should match the "
307-
f"number of texts. ({len(va)} != {n})"
308-
)
309-
else:
310-
va = (va,) * n
311-
return va

plotnine/guides/guide_colorbar.py

Lines changed: 47 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
import pandas as pd
1212
from mizani.bounds import rescale
1313

14+
from plotnine.iapi import guide_text
15+
1416
from .._utils import get_opposite_side
1517
from ..exceptions import PlotnineError, PlotnineWarning
1618
from ..mapping.aes import rename_aesthetics
@@ -417,26 +419,26 @@ def add_labels(
417419
"""
418420
from matplotlib.text import Text
419421

420-
n = len(labels)
421-
sep = elements.text.margin
422+
seps = elements.text.margins
422423
texts: list[Text] = []
423-
has = elements.has(n)
424-
vas = elements.vas(n)
424+
has = elements.text.has
425+
vas = elements.text.vas
426+
width = elements.key_width
425427

426428
# The horizontal and vertical alignments are set in the theme
427429
# or dynamically calculates in GuideElements and added to the
428430
# themeable properties dict
429431
if elements.is_vertical:
430-
if elements.text_position == "right":
431-
xs = [elements.key_width + sep] * n
432-
else:
433-
xs = [-sep] * n
432+
xs = [
433+
width + sep if side == "right" else -sep
434+
for side, sep in zip(elements.text_positions, seps)
435+
]
434436
else:
435437
xs = ys
436-
if elements.text_position == "bottom":
437-
ys = [-sep] * n
438-
else:
439-
ys = [elements.key_width + sep] * n
438+
ys = [
439+
-sep if side == "bottom" else width + sep
440+
for side, sep in zip(elements.text_positions, seps)
441+
]
440442

441443
for x, y, s, ha, va in zip(xs, ys, labels, has, vas):
442444
t = Text(x, y, s, ha=ha, va=va)
@@ -475,43 +477,52 @@ def text(self):
475477
ha = self.theme.getp(("legend_text_colorbar", "ha"))
476478
va = self.theme.getp(("legend_text_colorbar", "va"))
477479
is_blank = self.theme.T.is_blank("legend_text_colorbar")
480+
n = self.guide.num_breaks
478481

479482
# Default text alignment depends on the direction of the
480483
# colorbar
481-
_loc = get_opposite_side(self.text_position)
484+
centers = ("center",) * n
485+
has = (ha,) * n if isinstance(ha, str) else ha
486+
vas = (va,) * n if isinstance(va, str) else va
487+
opposite_sides = [get_opposite_side(s) for s in self.text_positions]
482488
if self.is_vertical:
483-
ha = ha or _loc
484-
va = va or "center"
489+
has = has or opposite_sides
490+
vas = vas or centers
485491
else:
486-
va = va or _loc
487-
ha = ha or "center"
488-
489-
return NS(
490-
margin=self._text_margin,
491-
align=None,
492+
vas = vas or opposite_sides
493+
has = has or centers
494+
return guide_text(
495+
self._text_margin,
496+
aligns=centers,
492497
fontsize=size,
493-
ha=ha,
494-
va=va,
498+
has=has, # pyright: ignore[reportArgumentType]
499+
vas=vas, # pyright: ignore[reportArgumentType]
495500
is_blank=is_blank,
496501
)
497502

498503
@cached_property
499-
def text_position(self) -> Side:
500-
if not (position := self.theme.getp("legend_text_position")):
504+
def text_positions(self) -> Sequence[Side]:
505+
if not (user_position := self.theme.getp("legend_text_position")):
501506
position = "right" if self.is_vertical else "bottom"
507+
return (position,) * self.guide.num_breaks
502508

503-
if self.is_vertical and position not in ("right", "left"):
504-
msg = (
505-
"The text position for a vertical legend must be "
506-
"either left or right."
507-
)
508-
raise PlotnineError(msg)
509-
elif self.is_horizontal and position not in ("bottom", "top"):
510-
msg = (
511-
"The text position for a horizonta legend must be "
512-
"either top or bottom."
509+
alternate = {"left-right", "right-left", "bottom-top", "top-bottom"}
510+
if user_position in alternate:
511+
tup = user_position.split("-")
512+
return [tup[i % 2] for i in range(self.guide.num_breaks)]
513+
514+
position = cast("Side | Sequence[Side]", user_position)
515+
516+
if isinstance(position, str):
517+
position = (position,) * self.guide.num_breaks
518+
519+
valid = {"right", "left"} if self.is_vertical else {"bottom", "top"}
520+
if any(p for p in position if p not in valid):
521+
raise PlotnineError(
522+
"The text position for a horizontal legend must be "
523+
f"either one of {valid!r}. I got {user_position!r}."
513524
)
514-
raise PlotnineError(msg)
525+
515526
return position
516527

517528
@cached_property

plotnine/guides/guide_legend.py

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,21 @@
55
from dataclasses import dataclass, field
66
from functools import cached_property
77
from itertools import islice
8-
from types import SimpleNamespace as NS
98
from typing import TYPE_CHECKING, cast
109
from warnings import warn
1110

1211
import numpy as np
1312
import pandas as pd
1413

14+
from plotnine.iapi import guide_text
15+
1516
from .._utils import remove_missing
1617
from ..exceptions import PlotnineError, PlotnineWarning
1718
from ..mapping.aes import rename_aesthetics
1819
from .guide import GuideElements, guide
1920

2021
if TYPE_CHECKING:
21-
from typing import Any, Optional
22+
from typing import Any, Optional, Sequence
2223

2324
from matplotlib.artist import Artist
2425
from matplotlib.offsetbox import PackerBase
@@ -209,7 +210,7 @@ def _calculate_rows_and_cols(
209210
self, elements: GuideElementsLegend
210211
) -> tuple[int, int]:
211212
nrow, ncol = self.nrow, self.ncol
212-
nbreak = len(self.key)
213+
nbreak = self.num_breaks
213214

214215
if nrow and ncol:
215216
if nrow * ncol < nbreak:
@@ -248,7 +249,7 @@ def draw(self):
248249

249250
obverse = slice(0, None)
250251
reverse = slice(None, None, -1)
251-
nbreak = len(self.key)
252+
nbreak = self.num_breaks
252253
targets = self.theme.targets
253254
keys_order = reverse if self.reverse else obverse
254255
elements = self.elements
@@ -259,8 +260,12 @@ def draw(self):
259260
targets.legend_title = title_box._text # type: ignore
260261

261262
# labels
262-
props = {"ha": elements.text.ha, "va": elements.text.va}
263-
labels = [TextArea(s, textprops=props) for s in self.key["label"]]
263+
has = elements.text.has
264+
vas = elements.text.vas
265+
labels = [
266+
TextArea(s, textprops={"ha": ha, "va": va})
267+
for s, ha, va in zip(self.key["label"], has, vas)
268+
]
264269
_texts = [l._text for l in labels] # type: ignore
265270
targets.legend_text_legend = _texts
266271

@@ -287,18 +292,28 @@ def draw(self):
287292
"bottom": (VPacker, reverse),
288293
"top": (VPacker, obverse),
289294
}
290-
packer, slc = lookup[elements.text_position]
295+
291296
if self.elements.text.is_blank:
292297
key_boxes = [d for d in drawings][keys_order]
293298
else:
299+
packers, slices = [], []
300+
for side in elements.text_positions:
301+
tup = lookup[side]
302+
packers.append(tup[0])
303+
slices.append(tup[1])
304+
305+
seps = elements.text.margins
306+
aligns = elements.text.aligns
294307
key_boxes = [
295308
packer(
296309
children=[l, d][slc],
297-
sep=elements.text.margin,
298-
align=elements.text.align,
310+
sep=sep,
311+
align=align,
299312
pad=0,
300313
)
301-
for d, l in zip(drawings, labels)
314+
for d, l, packer, slc, sep, align in zip(
315+
drawings, labels, packers, slices, seps, aligns
316+
)
302317
][keys_order]
303318

304319
# Put the entries together in rows or columns
@@ -326,7 +341,7 @@ def draw(self):
326341
break
327342

328343
chunk_boxes: list[Artist] = [
329-
packer_dim1(children=chunk, align="left", sep=sep1, pad=0)
344+
packer_dim1(children=chunk, align="right", sep=sep1, pad=0)
330345
for chunk in chunks
331346
]
332347

@@ -364,26 +379,38 @@ def text(self):
364379
ha = self.theme.getp(("legend_text_legend", "ha"), "center")
365380
va = self.theme.getp(("legend_text_legend", "va"), "center")
366381
is_blank = self.theme.T.is_blank("legend_text_legend")
382+
n = self.guide.num_breaks
367383

368384
# The original ha & va values are used by the HPacker/VPacker
369385
# to align the TextArea with the DrawingArea.
370386
# We set ha & va to values that combine best with the aligning
371387
# for the text area.
372-
align = va if self.text_position in {"left", "right"} else ha
373-
return NS(
374-
margin=self._text_margin,
375-
align=align,
388+
has = (ha,) * n if isinstance(ha, str) else ha
389+
vas = (va,) * n if isinstance(va, str) else va
390+
aligns = [
391+
va if side in ("right", "left") else ha
392+
for side, ha, va in zip(self.text_positions, has, vas)
393+
]
394+
return guide_text(
395+
margins=self._text_margin,
396+
aligns=aligns, # pyright: ignore[reportArgumentType]
376397
fontsize=size,
377-
ha="center",
378-
va="baseline",
398+
has=("center",) * n,
399+
vas=("baseline",) * n,
379400
is_blank=is_blank,
380401
)
381402

382403
@cached_property
383-
def text_position(self) -> Side:
384-
if not (pos := self.theme.getp("legend_text_position")):
385-
pos = "right"
386-
return pos
404+
def text_positions(self) -> Sequence[Side]:
405+
if not (position := self.theme.getp("legend_text_position")):
406+
return ("right",) * self.guide.num_breaks
407+
408+
position = cast("Side | Sequence[Side]", position)
409+
410+
if isinstance(position, str):
411+
position = (position,) * self.guide.num_breaks
412+
413+
return position
387414

388415
@cached_property
389416
def key_spacing_x(self) -> float:

plotnine/guides/guides.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@
3131
from plotnine.scales.scale import scale
3232
from plotnine.scales.scales import Scales
3333
from plotnine.typing import (
34+
Justification,
3435
LegendPosition,
3536
NoGuide,
3637
Orientation,
3738
ScaledAestheticsName,
3839
Side,
39-
TextJustification,
4040
)
4141

4242
LegendOrColorbar: TypeAlias = (
@@ -437,7 +437,7 @@ def _position_inside(self) -> LegendPosition:
437437
return ensure_xy_location(just)
438438

439439
@cached_property
440-
def box_just(self) -> TextJustification:
440+
def box_just(self) -> Justification | Literal["baseline"]:
441441
if not (box_just := self.theme.getp("legend_box_just")):
442442
box_just = (
443443
"left" if self.position in {"left", "right"} else "right"

0 commit comments

Comments
 (0)