Skip to content

Commit cbc9520

Browse files
committed
Align axis titles in a composition
1 parent 925a2b2 commit cbc9520

File tree

9 files changed

+264
-3
lines changed

9 files changed

+264
-3
lines changed

plotnine/_mpl/layout_manager/_layout_tree.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def harmonise(self):
118118
"""
119119
Align and resize plots in composition to look good
120120
"""
121+
self.align_axis_titles()
121122
self.align()
122123
self.resize()
123124

@@ -147,6 +148,20 @@ def align_sub_compositions(self):
147148
for tree in self.sub_compositions:
148149
tree.align()
149150

151+
@abc.abstractmethod
152+
def align_axis_titles(self):
153+
"""
154+
Align the axis titles along the composing dimension
155+
156+
Since the alignment value used to for this purpose is one of
157+
the fields in the _side_space, it affects the space created
158+
for the panel.
159+
160+
We could align the titles within self.align but we would have
161+
to store the value outside the _side_space and pick it up when
162+
setting the position of the texts!
163+
"""
164+
150165
def resize_sub_compositions(self):
151166
"""
152167
Resize panels in the compositions contained in this one
@@ -365,6 +380,50 @@ def top_tags_align(self) -> bool:
365380
arr = np.array(self.top_tag_heights)
366381
return all(arr == arr[0])
367382

383+
@property
384+
def left_axis_titles_align(self) -> bool:
385+
"""
386+
Return True if the left axis titles align
387+
"""
388+
arr = np.array(self.left_axis_title_clearances)
389+
return all(arr == arr[0])
390+
391+
@property
392+
def bottom_axis_titles_align(self) -> bool:
393+
"""
394+
Return True if the bottom axis titles align
395+
"""
396+
arr = np.array(self.bottom_axis_title_clearances)
397+
return all(arr == arr[0])
398+
399+
@cached_property
400+
@abc.abstractmethod
401+
def left_axis_title_clearance(self) -> float:
402+
"""
403+
Distance between the left y-axis title and the panel
404+
"""
405+
406+
@cached_property
407+
@abc.abstractmethod
408+
def bottom_axis_title_clearance(self) -> float:
409+
"""
410+
Distance between the left x-axis title and the panel
411+
"""
412+
413+
@cached_property
414+
def left_axis_title_clearances(self) -> list[float]:
415+
"""
416+
Distances between the left y-axis titles and the panels
417+
"""
418+
return [node.left_axis_title_clearance for node in self.nodes]
419+
420+
@cached_property
421+
def bottom_axis_title_clearances(self) -> list[float]:
422+
"""
423+
Distances between the bottom x-axis titles and the panels
424+
"""
425+
return [node.bottom_axis_title_clearance for node in self.nodes]
426+
368427
@abc.abstractmethod
369428
def set_left_margin_alignment(self, value: float):
370429
"""
@@ -429,6 +488,22 @@ def set_top_tag_alignment(self, value: float):
429488
In figure dimenstions
430489
"""
431490

491+
@abc.abstractmethod
492+
def set_left_axis_title_alignment(self, value: float):
493+
"""
494+
Set the space to align the left axis titles in this composition
495+
496+
In figure dimenstions
497+
"""
498+
499+
@abc.abstractmethod
500+
def set_bottom_axis_title_alignment(self, value: float):
501+
"""
502+
Set the space to align the bottom axis titles in this composition
503+
504+
In figure dimenstions
505+
"""
506+
432507

433508
@dataclass
434509
class ColumnsTree(LayoutTree):
@@ -458,6 +533,11 @@ def align(self):
458533
self.align_panel_bottoms()
459534
self.align_sub_compositions()
460535

536+
def align_axis_titles(self):
537+
self.align_bottom_axis_titles()
538+
for tree in self.sub_compositions:
539+
tree.align_axis_titles()
540+
461541
def resize(self):
462542
"""
463543
Resize the widths of gridspec so that panels have equal widths
@@ -552,6 +632,23 @@ def align_top_tags(self):
552632
else:
553633
item.set_top_tag_alignment(value)
554634

635+
def align_bottom_axis_titles(self):
636+
if self.bottom_axis_titles_align:
637+
pass
638+
639+
values = max(self.bottom_axis_title_clearances) - np.array(
640+
self.bottom_axis_title_clearances
641+
)
642+
# We ignore 0 values since they can undo values
643+
# set to align this composition with an outer one.
644+
for item, value in zip(self.nodes, values):
645+
if value == 0:
646+
continue
647+
if isinstance(item, LayoutSpaces):
648+
item.b.axis_title_alignment = value
649+
else:
650+
item.set_bottom_axis_title_alignment(value)
651+
555652
@cached_property
556653
def panel_lefts(self):
557654
left_item = self.nodes[0]
@@ -620,6 +717,14 @@ def bottom_tag_height(self) -> float:
620717
def top_tag_height(self) -> float:
621718
return max(self.top_tag_heights)
622719

720+
@cached_property
721+
def left_axis_title_clearance(self) -> float:
722+
return self.left_axis_title_clearances[0]
723+
724+
@cached_property
725+
def bottom_axis_title_clearance(self) -> float:
726+
return max(self.bottom_axis_title_clearances)
727+
623728
def set_left_margin_alignment(self, value: float):
624729
left_item = self.nodes[0]
625730
if isinstance(left_item, LayoutSpaces):
@@ -662,6 +767,20 @@ def set_top_tag_alignment(self, value: float):
662767
else:
663768
item.set_top_tag_alignment(value)
664769

770+
def set_bottom_axis_title_alignment(self, value: float):
771+
for item in self.nodes:
772+
if isinstance(item, LayoutSpaces):
773+
item.b.axis_title_alignment = value
774+
else:
775+
item.set_bottom_axis_title_alignment(value)
776+
777+
def set_left_axis_title_alignment(self, value: float):
778+
left_item = self.nodes[0]
779+
if isinstance(left_item, LayoutSpaces):
780+
left_item.l.axis_title_alignment = value
781+
else:
782+
left_item.set_left_axis_title_alignment(value)
783+
665784

666785
@dataclass
667786
class RowsTree(LayoutTree):
@@ -688,6 +807,11 @@ def align(self):
688807
self.align_panel_rights()
689808
self.align_sub_compositions()
690809

810+
def align_axis_titles(self):
811+
self.align_left_axis_titles()
812+
for tree in self.sub_compositions:
813+
tree.align_axis_titles()
814+
691815
def resize(self):
692816
"""
693817
Resize the heights of gridspec so that panels have equal heights
@@ -804,6 +928,21 @@ def align_right_tags(self):
804928
else:
805929
item.set_right_tag_alignment(value)
806930

931+
def align_left_axis_titles(self):
932+
if self.left_axis_titles_align:
933+
pass
934+
935+
values = max(self.left_axis_title_clearances) - np.array(
936+
self.left_axis_title_clearances
937+
)
938+
for item, value in zip(self.nodes, values):
939+
if value == 0:
940+
continue
941+
if isinstance(item, LayoutSpaces):
942+
item.l.axis_title_alignment = value
943+
else:
944+
item.set_left_axis_title_alignment(value)
945+
807946
@cached_property
808947
def panel_lefts(self):
809948
values = []
@@ -872,6 +1011,14 @@ def top_tag_height(self) -> float:
8721011
def bottom_tag_height(self) -> float:
8731012
return self.bottom_tag_heights[-1]
8741013

1014+
@cached_property
1015+
def left_axis_title_clearance(self) -> float:
1016+
return max(self.left_axis_title_clearances)
1017+
1018+
@cached_property
1019+
def bottom_axis_title_clearance(self) -> float:
1020+
return self.bottom_axis_title_clearances[-1]
1021+
8751022
def set_left_margin_alignment(self, value: float):
8761023
for item in self.nodes:
8771024
if isinstance(item, LayoutSpaces):
@@ -913,3 +1060,17 @@ def set_right_tag_alignment(self, value: float):
9131060
item.r.tag_alignment = value
9141061
else:
9151062
item.set_right_tag_alignment(value)
1063+
1064+
def set_left_axis_title_alignment(self, value: float):
1065+
for item in self.nodes:
1066+
if isinstance(item, LayoutSpaces):
1067+
item.l.axis_title_alignment = value
1068+
else:
1069+
item.set_left_axis_title_alignment(value)
1070+
1071+
def set_bottom_axis_title_alignment(self, value: float):
1072+
bottom_item = self.nodes[-1]
1073+
if isinstance(bottom_item, LayoutSpaces):
1074+
bottom_item.b.axis_title_alignment = value
1075+
else:
1076+
bottom_item.set_bottom_axis_title_alignment(value)

plotnine/_mpl/layout_manager/_spaces.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from functools import cached_property
1717
from typing import TYPE_CHECKING, cast
1818

19+
from plotnine.exceptions import PlotnineError
1920
from plotnine.facets import facet_grid, facet_null, facet_wrap
2021

2122
from ._layout_items import LayoutItems
@@ -233,7 +234,7 @@ def tag_width(self) -> float:
233234
"""
234235
The width of the tag including the margins
235236
236-
The value is zero expect if all these are true:
237+
The value is zero except if all these are true:
237238
- The tag is in the margin `theme(plot_tag_position = "margin")`
238239
- The tag at one one of the the following locations;
239240
left, right, topleft, topright, bottomleft or bottomright
@@ -245,13 +246,41 @@ def tag_height(self) -> float:
245246
"""
246247
The height of the tag including the margins
247248
248-
The value is zero expect if all these are true:
249+
The value is zero except if all these are true:
249250
- The tag is in the margin `theme(plot_tag_position = "margin")`
250251
- The tag at one one of the the following locations;
251252
top, bottom, topleft, topright, bottomleft or bottomright
252253
"""
253254
return 0
254255

256+
@property
257+
def axis_title_clearance(self) -> float:
258+
"""
259+
The distance between the axis title and the panel
260+
261+
Figure
262+
----------------------------
263+
| Panel |
264+
| ----------- |
265+
| | | |
266+
| | | |
267+
| Y<--->| | |
268+
| | | |
269+
| | | |
270+
| ----------- |
271+
| |
272+
----------------------------
273+
274+
We use this value to when aligning axis titles in a
275+
plot composition.
276+
"""
277+
278+
try:
279+
return self.total - self.sum_upto("axis_title_alignment")
280+
except AttributeError as err:
281+
# There is probably an error in in the layout manager
282+
raise PlotnineError("Side has no axis title") from err
283+
255284

256285
@dataclass
257286
class left_spaces(_side_spaces):
@@ -311,6 +340,14 @@ class left_spaces(_side_spaces):
311340
axis_title_y_margin_left: float = 0
312341
axis_title_y: float = 0
313342
axis_title_y_margin_right: float = 0
343+
axis_title_alignment: float = 0
344+
"""
345+
Space added to align the axis title with others in a composition
346+
347+
This value is calculated during the layout process. The amount is
348+
the difference between the largest and smallest axis_title_clearance
349+
among the items in the composition.
350+
"""
314351
axis_text_y_margin_left: float = 0
315352
axis_text_y: float = 0
316353
axis_text_y_margin_right: float = 0
@@ -674,6 +711,15 @@ class bottom_spaces(_side_spaces):
674711
axis_title_x_margin_bottom: float = 0
675712
axis_title_x: float = 0
676713
axis_title_x_margin_top: float = 0
714+
axis_title_alignment: float = 0
715+
"""
716+
Space added to align the axis title with others in a composition
717+
718+
This value is calculated during the layout process in a tree structure
719+
that has convenient access to the sides/edges of the panels in the
720+
composition. It's amount is the difference in height between this axis
721+
text (and it's margins) and the tallest axis text (and it's margin).
722+
"""
677723
axis_text_x_margin_bottom: float = 0
678724
axis_text_x: float = 0
679725
axis_text_x_margin_top: float = 0
@@ -945,6 +991,24 @@ def bottom_tag_height(self) -> float:
945991
"""
946992
return self.b.tag_height
947993

994+
@property
995+
def left_axis_title_clearance(self) -> float:
996+
"""
997+
Distance between the left y-axis title and the panel
998+
999+
In figure dimensions.
1000+
"""
1001+
return self.l.axis_title_clearance
1002+
1003+
@property
1004+
def bottom_axis_title_clearance(self) -> float:
1005+
"""
1006+
Distance between the bottom x-axis title and the panel
1007+
1008+
In figure dimensions.
1009+
"""
1010+
return self.b.axis_title_clearance
1011+
9481012
def increase_horizontal_plot_margin(self, dw: float):
9491013
"""
9501014
Increase the plot_margin to the right & left of the panels
-91 Bytes
Loading
9.85 KB
Loading
10.7 KB
Loading
9.97 KB
Loading
10.7 KB
Loading

tests/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from matplotlib.testing.compare import compare_images
1212

1313
from plotnine import ggplot, theme
14-
from plotnine.composition import Arrange
14+
from plotnine.composition import Arrange, Beside, Stack
1515
from plotnine.themes.theme import DEFAULT_RCPARAMS
1616

1717
TOLERANCE = 2 # Default tolerance for the tests
@@ -271,4 +271,8 @@ def composition_equals(cmp: Arrange, name: str) -> bool:
271271
return not err
272272

273273

274+
# Note that, dataclass subclasses have their own __eq__ and not that of the
275+
# parent class.
274276
Arrange.__eq__ = composition_equals # pyright: ignore[reportAttributeAccessIssue]
277+
Beside.__eq__ = composition_equals # pyright: ignore[reportAttributeAccessIssue]
278+
Stack.__eq__ = composition_equals # pyright: ignore[reportAttributeAccessIssue]

0 commit comments

Comments
 (0)