iplotx 0.2.1__py3-none-any.whl → 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
iplotx/label.py CHANGED
@@ -7,6 +7,7 @@ from typing import (
7
7
  Sequence,
8
8
  )
9
9
  import numpy as np
10
+ import pandas as pd
10
11
  import matplotlib as mpl
11
12
 
12
13
  from .style import (
@@ -39,7 +40,7 @@ class LabelCollection(mpl.artist.Artist):
39
40
 
40
41
  def __init__(
41
42
  self,
42
- labels: Sequence[str],
43
+ labels: pd.Series,
43
44
  style: Optional[dict[str, dict]] = None,
44
45
  offsets: Optional[np.ndarray] = None,
45
46
  transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
@@ -97,6 +98,11 @@ class LabelCollection(mpl.artist.Artist):
97
98
  vmargin = stylei.pop("vmargin", 0.0)
98
99
  margins.append((hmargin, vmargin))
99
100
 
101
+ # Initially, ignore autoalignment since we do not know the
102
+ # rotations
103
+ if stylei.get("horizontalalignment") == "auto":
104
+ stylei["horizontalalignment"] = "center"
105
+
100
106
  art = mpl.text.Text(
101
107
  self._offsets[i][0],
102
108
  self._offsets[i][1],
@@ -107,15 +113,20 @@ class LabelCollection(mpl.artist.Artist):
107
113
  arts.append(art)
108
114
  self._labelartists = arts
109
115
  self._margins = np.array(margins)
116
+ self._rotations = np.zeros(len(self._labels))
110
117
 
111
118
  def _update_offsets(self, dpi: float = 72.0) -> None:
112
119
  """Update offsets including margins."""
113
- offsets = self._adjust_offsets_for_margins(self._offsets, dpi=dpi)
114
- self.set_offsets(offsets)
120
+ self.set_offsets(self._offsets, dpi=dpi)
115
121
 
116
- def get_offsets(self) -> np.ndarray:
122
+ def get_offsets(self, with_margins: bool = False) -> np.ndarray:
117
123
  """Get the positions (offsets) of the labels."""
118
- return self._offsets
124
+ if not with_margins:
125
+ return self._offsets
126
+ else:
127
+ return np.array(
128
+ [art.get_position() for art in self._labelartists],
129
+ )
119
130
 
120
131
  def _adjust_offsets_for_margins(self, offsets, dpi=72.0):
121
132
  margins = self._get_margins_with_dpi(dpi=dpi)
@@ -123,31 +134,67 @@ class LabelCollection(mpl.artist.Artist):
123
134
  transform = self.get_transform()
124
135
  trans = transform.transform
125
136
  trans_inv = transform.inverted().transform
126
- offsets = trans_inv(trans(offsets) + margins)
137
+ rotations = self.get_rotations()
138
+ vrot = [np.cos(rotations), np.sin(rotations)]
139
+
140
+ margins_rot = np.empty_like(margins)
141
+ margins_rot[:, 0] = margins[:, 0] * vrot[0] - margins[:, 1] * vrot[1]
142
+ margins_rot[:, 1] = margins[:, 0] * vrot[1] + margins[:, 1] * vrot[0]
143
+ offsets = trans_inv(trans(offsets) + margins_rot)
127
144
  return offsets
128
145
 
129
- def set_offsets(self, offsets) -> None:
146
+ def set_offsets(self, offsets, dpi: float = 72.0) -> None:
130
147
  """Set positions (offsets) of the labels.
131
148
 
132
149
  Parameters:
133
150
  offsets: A sequence of offsets for each label, specifying the position of the label.
134
151
  """
135
152
  self._offsets = np.asarray(offsets)
136
- for art, offset in zip(self._labelartists, self._offsets):
153
+ offsets_with_margins = self._adjust_offsets_for_margins(offsets, dpi=dpi)
154
+ for art, offset in zip(self._labelartists, offsets_with_margins):
137
155
  art.set_position((offset[0], offset[1]))
138
156
 
157
+ def get_rotations(self) -> np.ndarray:
158
+ """Get the rotations of the labels in radians."""
159
+ return self._rotations
160
+
139
161
  def set_rotations(self, rotations: Sequence[float]) -> None:
140
162
  """Set the rotations of the labels.
141
163
 
142
164
  Parameters:
143
165
  rotations: A sequence of rotations in radians for each label.
144
166
  """
167
+ self._rotations = np.asarray(rotations)
168
+ ha = self._style.get("horizontalalignment", "center")
145
169
  for art, rotation in zip(self._labelartists, rotations):
146
170
  rot_deg = 180.0 / np.pi * rotation
147
171
  # Force the font size to be upwards
172
+ if ha == "auto":
173
+ if -90 <= rot_deg < 90:
174
+ art.set_horizontalalignment("left")
175
+ else:
176
+ art.set_horizontalalignment("right")
148
177
  rot_deg = ((rot_deg + 90) % 180) - 90
149
178
  art.set_rotation(rot_deg)
150
179
 
180
+ def get_datalim(self, transData=None) -> mpl.transforms.Bbox:
181
+ """Get the data limits of the labels."""
182
+ bboxes = self.get_datalims_children(transData=transData)
183
+ bbox = mpl.transforms.Bbox.union(bboxes)
184
+ return bbox
185
+
186
+ def get_datalims_children(self, transData=None) -> Sequence[mpl.transforms.Bbox]:
187
+ """Get the data limits of the children of this artist."""
188
+ if transData is None:
189
+ transData = self.get_transform()
190
+ trans_inv = transData.inverted().transform_bbox
191
+ bboxes = []
192
+ for art in self._labelartists:
193
+ bbox_fig = art.get_bbox_patch().get_extents()
194
+ bbox_data = trans_inv(bbox_fig)
195
+ bboxes.append(bbox_data)
196
+ return bboxes
197
+
151
198
  @_stale_wrapper
152
199
  def draw(self, renderer) -> None:
153
200
  """Draw each of the children, with some buffering mechanism."""
iplotx/layout.py CHANGED
@@ -2,35 +2,48 @@
2
2
  Layout functions, currently limited to trees.
3
3
  """
4
4
 
5
- from collections.abc import Hashable
5
+ from typing import Any
6
+ from collections.abc import (
7
+ Hashable,
8
+ Callable,
9
+ )
6
10
 
7
11
  import numpy as np
8
12
 
9
13
 
10
14
  def compute_tree_layout(
11
- tree,
12
15
  layout: str,
13
16
  orientation: str,
17
+ root: Any,
18
+ preorder_fun: Callable,
19
+ postorder_fun: Callable,
20
+ children_fun: Callable,
21
+ branch_length_fun: Callable,
14
22
  **kwargs,
15
23
  ) -> dict[Hashable, list[float]]:
16
24
  """Compute the layout for a tree.
17
25
 
18
26
  Parameters:
19
- tree: The tree to compute the layout for.
20
- layout: The name of the layout, e.g. "horizontal" or "radial".
21
- orientation: The orientation of the layout, e.g. "right", "left", "descending", or
22
- "ascending".
27
+ layout: The name of the layout, e.g. "horizontal", "vertial", or "radial".
28
+ orientation: The orientation of the layout, e.g. "right", "left", "descending",
29
+ "ascending", "clockwise", "anticlockwise".
23
30
 
24
31
  Returns:
25
32
  A layout dictionary with node positions.
26
33
  """
34
+ kwargs["root"] = root
35
+ kwargs["preorder_fun"] = preorder_fun
36
+ kwargs["postorder_fun"] = postorder_fun
37
+ kwargs["children_fun"] = children_fun
38
+ kwargs["branch_length_fun"] = branch_length_fun
39
+ kwargs["orientation"] = orientation
27
40
 
28
41
  if layout == "radial":
29
- layout_dict = _circular_tree_layout(tree, orientation=orientation, **kwargs)
42
+ layout_dict = _radial_tree_layout(**kwargs)
30
43
  elif layout == "horizontal":
31
- layout_dict = _horizontal_tree_layout(tree, orientation=orientation, **kwargs)
44
+ layout_dict = _horizontal_tree_layout(**kwargs)
32
45
  elif layout == "vertical":
33
- layout_dict = _vertical_tree_layout(tree, orientation=orientation, **kwargs)
46
+ layout_dict = _vertical_tree_layout(**kwargs)
34
47
  else:
35
48
  raise ValueError(f"Tree layout not available: {layout}")
36
49
 
@@ -38,12 +51,11 @@ def compute_tree_layout(
38
51
 
39
52
 
40
53
  def _horizontal_tree_layout_right(
41
- tree,
42
- root_fun: callable,
43
- preorder_fun: callable,
44
- postorder_fun: callable,
45
- children_fun: callable,
46
- branch_length_fun: callable,
54
+ root: Any,
55
+ preorder_fun: Callable,
56
+ postorder_fun: Callable,
57
+ children_fun: Callable,
58
+ branch_length_fun: Callable,
47
59
  ) -> dict[Hashable, list[float]]:
48
60
  """Build a tree layout horizontally, left to right.
49
61
 
@@ -58,7 +70,7 @@ def _horizontal_tree_layout_right(
58
70
 
59
71
  # Set the y values for vertices
60
72
  i = 0
61
- for node in postorder_fun(tree):
73
+ for node in postorder_fun():
62
74
  children = children_fun(node)
63
75
  if len(children) == 0:
64
76
  layout[node] = [None, i]
@@ -70,8 +82,8 @@ def _horizontal_tree_layout_right(
70
82
  ]
71
83
 
72
84
  # Set the x values for vertices
73
- layout[root_fun(tree)][0] = 0
74
- for node in preorder_fun(tree):
85
+ layout[root][0] = 0
86
+ for node in preorder_fun():
75
87
  for child in children_fun(node):
76
88
  bl = branch_length_fun(child)
77
89
  if bl is None:
@@ -82,7 +94,6 @@ def _horizontal_tree_layout_right(
82
94
 
83
95
 
84
96
  def _horizontal_tree_layout(
85
- tree,
86
97
  orientation="right",
87
98
  **kwargs,
88
99
  ) -> dict[Hashable, list[float]]:
@@ -90,7 +101,7 @@ def _horizontal_tree_layout(
90
101
  if orientation not in ("right", "left"):
91
102
  raise ValueError("Orientation must be 'right' or 'left'.")
92
103
 
93
- layout = _horizontal_tree_layout_right(tree, **kwargs)
104
+ layout = _horizontal_tree_layout_right(**kwargs)
94
105
 
95
106
  if orientation == "left":
96
107
  for key in layout:
@@ -99,13 +110,12 @@ def _horizontal_tree_layout(
99
110
 
100
111
 
101
112
  def _vertical_tree_layout(
102
- tree,
103
113
  orientation="descending",
104
114
  **kwargs,
105
115
  ) -> dict[Hashable, list[float]]:
106
116
  """Vertical tree layout."""
107
- sign = 1 if orientation == "descending" else -1
108
- layout = _horizontal_tree_layout(tree, **kwargs)
117
+ sign = -1 if orientation == "descending" else 1
118
+ layout = _horizontal_tree_layout(**kwargs)
109
119
  for key, value in layout.items():
110
120
  # Invert x and y
111
121
  layout[key] = value[::-1]
@@ -114,24 +124,35 @@ def _vertical_tree_layout(
114
124
  return layout
115
125
 
116
126
 
117
- def _circular_tree_layout(
118
- tree,
119
- orientation="right",
120
- starting_angle=0,
121
- angular_span=360,
127
+ def _radial_tree_layout(
128
+ orientation: str = "right",
129
+ start: float = 180,
130
+ span: float = 360,
122
131
  **kwargs,
123
- ) -> dict[Hashable, list[float]]:
124
- """Circular tree layout."""
132
+ ) -> dict[Hashable, tuple[float, float]]:
133
+ """Radial tree layout.
134
+
135
+ Parameters:
136
+ orientation: Whether the layout fans out towards the right (clockwise) or left
137
+ (anticlockwise).
138
+ start: The starting angle in degrees, default is -180 (left).
139
+ span: The angular span in degrees, default is 360 (full circle). When this is
140
+ 360, it leaves a small gap at the end to ensure the first and last leaf
141
+ are not overlapping.
142
+ Returns:
143
+ A dictionary with the radial layout.
144
+ """
125
145
  # Short form
126
- th = starting_angle * np.pi / 180
127
- th_span = angular_span * np.pi / 180
128
- sign = 1 if orientation == "right" else -1
146
+ th = start * np.pi / 180
147
+ th_span = span * np.pi / 180
148
+ pad = int(span == 360)
149
+ sign = -1 if orientation in ("right", "clockwise") else 1
129
150
 
130
- layout = _horizontal_tree_layout_right(tree, **kwargs)
151
+ layout = _horizontal_tree_layout_right(**kwargs)
131
152
  ymax = max(point[1] for point in layout.values())
132
153
  for key, (x, y) in layout.items():
133
154
  r = x
134
- theta = sign * th_span * y / (ymax + 1) + th
155
+ theta = sign * th_span * y / (ymax + pad) + th
135
156
  # We export r and theta to ensure theta does not
136
157
  # modulo 2pi if we take the tan and then arctan later.
137
158
  layout[key] = (r, theta)
iplotx/plotting.py CHANGED
@@ -123,9 +123,10 @@ def network(
123
123
  def tree(
124
124
  tree: Optional[TreeType] = None,
125
125
  layout: str | LayoutType = "horizontal",
126
- orientation: str = "right",
126
+ orientation: Optional[str] = None,
127
127
  directed: bool | str = False,
128
128
  vertex_labels: Optional[list | dict | pd.Series] = None,
129
+ leaf_labels: Optional[list | dict | pd.Series] = None,
129
130
  ax: Optional[mpl.axes.Axes] = None,
130
131
  style: str | dict | Sequence[str | dict] = "tree",
131
132
  title: Optional[str] = None,
@@ -138,8 +139,9 @@ def tree(
138
139
  Parameters:
139
140
  tree: The tree to plot. Can be a BioPython.Phylo.Tree object.
140
141
  layout: The layout to use for plotting.
141
- orientation: The orientation of the horizontal layout. Can be "right" or "left". Defaults to
142
- "right".
142
+ orientation: The orientation of the layout. Can be "right" or "left". Defaults to
143
+ "right" for horizontal layout, "descending" or "ascending" for vertical layout,
144
+ and "clockwise" or "anticlockwise" for radial layout.
143
145
  directed: If False, donot draw arrows. If True or "child", draw arrows from parent to child
144
146
  node. If "parent", draw arrows the other way around.
145
147
 
@@ -160,6 +162,7 @@ def tree(
160
162
  transform=mpl.transforms.IdentityTransform(),
161
163
  offset_transform=ax.transData,
162
164
  vertex_labels=vertex_labels,
165
+ leaf_labels=leaf_labels,
163
166
  )
164
167
  ax.add_artist(artist)
165
168
 
iplotx/style.py CHANGED
@@ -23,6 +23,8 @@ style_leaves = (
23
23
  "tension",
24
24
  "looptension",
25
25
  "loopmaxangle",
26
+ "paralleloffset",
27
+ "offset",
26
28
  "rotate",
27
29
  "marker",
28
30
  "waypoints",
@@ -34,15 +36,17 @@ style_leaves = (
34
36
  "hmargin",
35
37
  "vmargin",
36
38
  "ports",
39
+ "extend",
37
40
  )
38
41
 
39
42
  # These properties are not allowed to be rotated (global throughout the graph).
40
43
  # This might change in the future as the API improves.
41
44
  nonrotating_leaves = (
42
- "offset",
45
+ "paralleloffset",
43
46
  "looptension",
44
47
  "loopmaxangle",
45
48
  "vertexpadding",
49
+ "extend",
46
50
  )
47
51
 
48
52
 
@@ -64,7 +68,7 @@ default = {
64
68
  "linestyle": "-",
65
69
  "color": "black",
66
70
  "curved": False,
67
- "offset": 3,
71
+ "paralleloffset": 3,
68
72
  "tension": 1,
69
73
  "looptension": 4,
70
74
  "loopmaxangle": 60,
@@ -111,6 +115,7 @@ hollow["vertex"]["edgecolor"] = "black"
111
115
  hollow["vertex"]["linewidth"] = 1.5
112
116
  hollow["vertex"]["marker"] = "r"
113
117
  hollow["vertex"]["size"] = "label"
118
+ hollow["vertex"]["label"]["color"] = "black"
114
119
 
115
120
  tree = copy_with_deep_values(default)
116
121
  tree["vertex"]["size"] = 0
@@ -123,8 +128,8 @@ tree["vertex"]["label"]["bbox"] = {
123
128
  }
124
129
  tree["vertex"]["label"]["color"] = "black"
125
130
  tree["vertex"]["label"]["size"] = 12
126
- tree["vertex"]["label"]["horizontalalignment"] = "left"
127
- tree["vertex"]["label"]["hmargin"] = 5
131
+ tree["vertex"]["label"]["verticalalignment"] = "center"
132
+ tree["vertex"]["label"]["hmargin"] = 10
128
133
 
129
134
 
130
135
  styles = {
@@ -145,13 +150,17 @@ def get_stylename():
145
150
  return str(stylename)
146
151
 
147
152
 
148
- def get_style(name: str = "") -> dict[str, Any]:
153
+ def get_style(name: str = "", *args) -> dict[str, Any]:
149
154
  """Get a *deep copy* of the chosen style.
150
155
 
151
156
  Parameters:
152
157
  name: The name of the style to get. If empty, the current style is returned.
153
158
  Substyles can be obtained by using a dot notation, e.g. "default.vertex".
154
159
  If "name" starts with a dot, it means a substyle of the current style.
160
+ *args: A single argument is accepted. If present, this value (usually a
161
+ dictionary) is returned if the queried style is not found. For example,
162
+ get_style(".nonexistent") raises an Exception but
163
+ get_style("nonexistent", {}) does not, returning an empty dict instead.
155
164
  Returns:
156
165
  The requected style or substyle.
157
166
 
@@ -160,6 +169,9 @@ def get_style(name: str = "") -> dict[str, Any]:
160
169
  useful for hashables that change hash upon copying, such as Biopython's
161
170
  tree nodes.
162
171
  """
172
+ if len(args) > 1:
173
+ raise ValueError("get_style() accepts at most one additional argument.")
174
+
163
175
  namelist = name.split(".")
164
176
  style = styles
165
177
  for i, namei in enumerate(namelist):
@@ -173,6 +185,8 @@ def get_style(name: str = "") -> dict[str, Any]:
173
185
  # which will not fail unless the uder tries to enter it
174
186
  elif namei not in style_leaves:
175
187
  style = {}
188
+ elif len(args) > 0:
189
+ return args[0]
176
190
  else:
177
191
  raise KeyError(f"Style not found: {name}")
178
192