iplotx 1.2.1__py3-none-any.whl → 1.3.0__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.
@@ -21,8 +21,9 @@ class BiopythonDataProvider(TreeDataProvider):
21
21
 
22
22
  preorder = partialmethod(_traverse, order="preorder")
23
23
  postorder = partialmethod(_traverse, order="postorder")
24
+ levelorder = partialmethod(_traverse, order="level")
24
25
 
25
- def get_leaves(self) -> Sequence[Any]:
26
+ def _get_leaves(self) -> Sequence[Any]:
26
27
  return self.tree.get_terminals()
27
28
 
28
29
  @staticmethod
@@ -16,7 +16,10 @@ class Cogent3DataProvider(TreeDataProvider):
16
16
  def postorder(self) -> Sequence[Any]:
17
17
  return self.tree.postorder()
18
18
 
19
- def get_leaves(self) -> Sequence[Any]:
19
+ def levelorder(self) -> Sequence[Any]:
20
+ return self.tree.levelorder()
21
+
22
+ def _get_leaves(self) -> Sequence[Any]:
20
23
  return self.tree.tips()
21
24
 
22
25
  @staticmethod
@@ -36,7 +36,16 @@ class DendropyDataProvider(TreeDataProvider):
36
36
  return self.tree.postorder_node_iter()
37
37
  return self.tree.postorder_iter()
38
38
 
39
- def get_leaves(self) -> Sequence[Any]:
39
+ def levelorder(self) -> Any:
40
+ """Levelorder traversal of the tree.
41
+
42
+ NOTE: This will work on both entire Trees and Nodes (which means a subtree including self).
43
+ """
44
+ if hasattr(self.tree, "levelorder_node_iter"):
45
+ return self.tree.levelorder_node_iter()
46
+ return self.tree.levelorder_iter()
47
+
48
+ def _get_leaves(self) -> Sequence[Any]:
40
49
  """Get a list of leaves."""
41
50
  return self.tree.leaf_nodes()
42
51
 
@@ -18,8 +18,9 @@ class Ete4DataProvider(TreeDataProvider):
18
18
 
19
19
  preorder = partialmethod(_traverse, order="preorder")
20
20
  postorder = partialmethod(_traverse, order="postorder")
21
+ levelorder = partialmethod(_traverse, order="levelorder")
21
22
 
22
- def get_leaves(self) -> Sequence[Any]:
23
+ def _get_leaves(self) -> Sequence[Any]:
23
24
  return self.tree.leaves()
24
25
 
25
26
  @staticmethod
@@ -64,7 +64,17 @@ class SimpleTreeDataProvider(TreeDataProvider):
64
64
 
65
65
  yield from _recur(self.tree)
66
66
 
67
- def get_leaves(self) -> Sequence[Any]:
67
+ def levelorder(self) -> Iterable[dict[dict | str, Any]]:
68
+ from collections import deque
69
+
70
+ queue = deque([self.get_root()])
71
+ while queue:
72
+ node = queue.popleft()
73
+ for child in self.get_children(node):
74
+ queue.append(child)
75
+ yield node
76
+
77
+ def _get_leaves(self) -> Sequence[Any]:
68
78
  def _recur(node):
69
79
  if len(node.children) == 0:
70
80
  yield node
@@ -16,7 +16,10 @@ class SkbioDataProvider(TreeDataProvider):
16
16
  def postorder(self) -> Sequence[Any]:
17
17
  return self.tree.postorder()
18
18
 
19
- def get_leaves(self) -> Sequence[Any]:
19
+ def levelorder(self) -> Sequence[Any]:
20
+ return self.tree.levelorder()
21
+
22
+ def _get_leaves(self) -> Sequence[Any]:
20
23
  return self.tree.tips()
21
24
 
22
25
  @staticmethod
iplotx/ingest/typing.py CHANGED
@@ -12,6 +12,7 @@ from typing import (
12
12
  Any,
13
13
  Iterable,
14
14
  )
15
+
15
16
  # NOTE: __init__ in Protocols has had a difficult gestation
16
17
  # https://github.com/python/cpython/issues/88970
17
18
  if sys.version_info < (3, 11):
@@ -156,8 +157,32 @@ class TreeDataProvider(Protocol):
156
157
  return root_attr
157
158
  return self.tree.get_root()
158
159
 
159
- def get_leaves(self) -> Sequence[Any]:
160
- """Get the tree leaves/tips in a provider-specific data structure.
160
+ def get_subtree(self, node: TreeType):
161
+ """Get the subtree rooted at the given node.
162
+
163
+ Parameters:
164
+ node: The node to get the subtree from.
165
+ Returns:
166
+ The subtree rooted at the given node.
167
+ """
168
+ return self.__class__(node)
169
+
170
+ def get_leaves(self, node: Optional[TreeType] = None) -> Sequence[Any]:
171
+ """Get the leaves of the entire tree or a subtree.
172
+
173
+ Parameters:
174
+ node: The node to get the leaves from. If None, get from the entire
175
+ tree.
176
+ Returns:
177
+ The leaves or tips of the tree or node-anchored subtree.
178
+ """
179
+ if node is None:
180
+ return self._get_leaves()
181
+ else:
182
+ return self.get_subtree(node)._get_leaves()
183
+
184
+ def _get_leaves(self) -> Sequence[Any]:
185
+ """Get the whole tree leaves/tips in a provider-specific data structure.
161
186
 
162
187
  Returns:
163
188
  The leaves or tips of the tree.
@@ -235,8 +260,6 @@ class TreeDataProvider(Protocol):
235
260
  NOTE: individual providers may implement more efficient versions of
236
261
  this function if desired.
237
262
  """
238
- provider = self.__class__
239
-
240
263
  # Find leaves of the selected nodes
241
264
  leaves = set()
242
265
  for node in nodes:
@@ -244,7 +267,7 @@ class TreeDataProvider(Protocol):
244
267
  if len(self.get_children(node)) == 0:
245
268
  leaves.add(node)
246
269
  else:
247
- leaves |= set(provider(node).get_leaves())
270
+ leaves |= set(self.get_leaves(node))
248
271
 
249
272
  # Look for nodes with the same set of leaves, starting from the bottom
250
273
  # and stopping at the first (i.e. lowest) hit.
@@ -253,7 +276,7 @@ class TreeDataProvider(Protocol):
253
276
  if len(self.get_children(node)) == 0:
254
277
  leaves_node = {node}
255
278
  else:
256
- leaves_node = set(provider(node).get_leaves())
279
+ leaves_node = set(self.get_leaves(node))
257
280
  if leaves <= leaves_node:
258
281
  root = node
259
282
  break
@@ -285,9 +308,26 @@ class TreeDataProvider(Protocol):
285
308
  orientation = "right"
286
309
  elif layout == "vertical":
287
310
  orientation = "descending"
288
- elif layout == "radial":
311
+ elif layout in ("radial", "equalangle", "daylight"):
289
312
  orientation = "clockwise"
290
313
 
314
+ # Validate orientation
315
+ valid = (layout == "horizontal") and (orientation in ("right", "left"))
316
+ valid |= (layout == "vertical") and (orientation in ("ascending", "descending"))
317
+ valid |= (layout == "radial") and (
318
+ orientation in ("clockwise", "counterclockwise", "left", "right")
319
+ )
320
+ valid |= (layout == "equalangle") and (
321
+ orientation in ("clockwise", "counterclockwise", "left", "right")
322
+ )
323
+ valid |= (layout == "daylight") and (
324
+ orientation in ("clockwise", "counterclockwise", "left", "right")
325
+ )
326
+ if not valid:
327
+ raise ValueError(
328
+ f"Orientation '{orientation}' is not valid for layout '{layout}'.",
329
+ )
330
+
291
331
  tree_data = {
292
332
  "root": self.get_root(),
293
333
  "rooted": self.is_rooted(),
@@ -304,8 +344,10 @@ class TreeDataProvider(Protocol):
304
344
  root=tree_data["root"],
305
345
  preorder_fun=self.preorder,
306
346
  postorder_fun=self.postorder,
347
+ levelorder_fun=self.levelorder,
307
348
  children_fun=self.get_children,
308
349
  branch_length_fun=self.get_branch_length_default_to_one,
350
+ leaves_fun=self.get_leaves,
309
351
  **layout_style,
310
352
  )
311
353
  if layout in ("radial",):
@@ -0,0 +1,9 @@
1
+ """
2
+ Layout functions.
3
+ """
4
+
5
+ from .tree import compute_tree_layout
6
+
7
+ __all__ = [
8
+ "compute_tree_layout",
9
+ ]
@@ -0,0 +1,72 @@
1
+ """
2
+ Tree layout algorithms.
3
+ """
4
+
5
+ from typing import (
6
+ Any,
7
+ )
8
+ from collections.abc import (
9
+ Hashable,
10
+ Callable,
11
+ )
12
+
13
+ from .rooted import (
14
+ _horizontal_tree_layout,
15
+ _vertical_tree_layout,
16
+ _radial_tree_layout,
17
+ )
18
+ from .unrooted import (
19
+ _equalangle_tree_layout,
20
+ _daylight_tree_layout,
21
+ )
22
+
23
+
24
+ def compute_tree_layout(
25
+ layout: str,
26
+ orientation: str,
27
+ root: Any,
28
+ preorder_fun: Callable,
29
+ postorder_fun: Callable,
30
+ levelorder_fun: Callable,
31
+ children_fun: Callable,
32
+ branch_length_fun: Callable,
33
+ leaves_fun: Callable,
34
+ **kwargs,
35
+ ) -> dict[Hashable, list[float]]:
36
+ """Compute the layout for a tree.
37
+
38
+ Parameters:
39
+ layout: The name of the layout, e.g. "horizontal", "vertical", or "radial".
40
+ orientation: The orientation of the layout, e.g. "right", "left", "descending",
41
+ "ascending", "clockwise", "anticlockwise".
42
+
43
+ Returns:
44
+ A layout dictionary with node positions.
45
+ """
46
+ kwargs["root"] = root
47
+ kwargs["preorder_fun"] = preorder_fun
48
+ kwargs["postorder_fun"] = postorder_fun
49
+ kwargs["levelorder_fun"] = levelorder_fun
50
+ kwargs["children_fun"] = children_fun
51
+ kwargs["orientation"] = orientation
52
+ kwargs["branch_length_fun"] = branch_length_fun
53
+ kwargs["leaves_fun"] = leaves_fun
54
+
55
+ # Angular or not, the vertex layout is unchanged. Since we do not
56
+ # currently compute an edge layout here, we can ignore the option.
57
+ kwargs.pop("angular", None)
58
+
59
+ if layout == "radial":
60
+ layout_dict = _radial_tree_layout(**kwargs)
61
+ elif layout == "horizontal":
62
+ layout_dict = _horizontal_tree_layout(**kwargs)
63
+ elif layout == "vertical":
64
+ layout_dict = _vertical_tree_layout(**kwargs)
65
+ elif layout == "equalangle":
66
+ layout_dict = _equalangle_tree_layout(**kwargs)
67
+ elif layout == "daylight":
68
+ layout_dict = _daylight_tree_layout(**kwargs)
69
+ else:
70
+ raise ValueError(f"Tree layout not available: {layout}")
71
+
72
+ return layout_dict
@@ -1,5 +1,5 @@
1
1
  """
2
- Layout functions, currently limited to trees.
2
+ Rooted tree layout for iplotx library.
3
3
  """
4
4
 
5
5
  from typing import (
@@ -14,55 +14,13 @@ from collections.abc import (
14
14
  import numpy as np
15
15
 
16
16
 
17
- def compute_tree_layout(
18
- layout: str,
19
- orientation: str,
20
- root: Any,
21
- preorder_fun: Callable,
22
- postorder_fun: Callable,
23
- children_fun: Callable,
24
- branch_length_fun: Callable,
25
- **kwargs,
26
- ) -> dict[Hashable, list[float]]:
27
- """Compute the layout for a tree.
28
-
29
- Parameters:
30
- layout: The name of the layout, e.g. "horizontal", "vertical", or "radial".
31
- orientation: The orientation of the layout, e.g. "right", "left", "descending",
32
- "ascending", "clockwise", "anticlockwise".
33
-
34
- Returns:
35
- A layout dictionary with node positions.
36
- """
37
- kwargs["root"] = root
38
- kwargs["preorder_fun"] = preorder_fun
39
- kwargs["postorder_fun"] = postorder_fun
40
- kwargs["children_fun"] = children_fun
41
- kwargs["branch_length_fun"] = branch_length_fun
42
- kwargs["orientation"] = orientation
43
-
44
- # Angular or not, the vertex layout is unchanged. Since we do not
45
- # currently compute an edge layout here, we can ignore the option.
46
- kwargs.pop("angular", None)
47
-
48
- if layout == "radial":
49
- layout_dict = _radial_tree_layout(**kwargs)
50
- elif layout == "horizontal":
51
- layout_dict = _horizontal_tree_layout(**kwargs)
52
- elif layout == "vertical":
53
- layout_dict = _vertical_tree_layout(**kwargs)
54
- else:
55
- raise ValueError(f"Tree layout not available: {layout}")
56
-
57
- return layout_dict
58
-
59
-
60
17
  def _horizontal_tree_layout_right(
61
18
  root: Any,
62
19
  preorder_fun: Callable,
63
20
  postorder_fun: Callable,
64
21
  children_fun: Callable,
65
22
  branch_length_fun: Callable,
23
+ **kwargs,
66
24
  ) -> dict[Hashable, list[float]]:
67
25
  """Build a tree layout horizontally, left to right.
68
26
 
@@ -173,7 +131,7 @@ def _radial_tree_layout(
173
131
  360, it leaves a small gap at the end to ensure the first and last leaf
174
132
  are not overlapping.
175
133
  Returns:
176
- A dictionary with the radial layout.
134
+ A dictionary with the layout.
177
135
  """
178
136
  # Short form
179
137
  th = start * np.pi / 180
@@ -0,0 +1,383 @@
1
+ """
2
+ Unrooted tree layout for iplotx.
3
+ """
4
+
5
+ from typing import (
6
+ Any,
7
+ )
8
+ from collections.abc import (
9
+ Hashable,
10
+ Callable,
11
+ )
12
+ import numpy as np
13
+
14
+
15
+ def _equalangle_tree_layout(
16
+ root: Any,
17
+ preorder_fun: Callable,
18
+ postorder_fun: Callable,
19
+ children_fun: Callable,
20
+ branch_length_fun: Callable,
21
+ leaves_fun: Callable,
22
+ orientation: str = "right",
23
+ start: float = 180,
24
+ span: float = 360,
25
+ **kwargs,
26
+ ) -> dict[Hashable, list[float]]:
27
+ """Equal angle unrooted tree layout.
28
+
29
+ Parameters:
30
+ orientation: Whether the layout fans out towards the right (clockwise) or left
31
+ (anticlockwise).
32
+ start: The starting angle in degrees, default is -180 (left).
33
+ span: The angular span in degrees, default is 360 (full circle). When this is
34
+ 360, it leaves a small gap at the end to ensure the first and last leaf
35
+ are not overlapping.
36
+ Returns:
37
+ A dictionary with the layout.
38
+
39
+ Reference: "Inferring Phylogenies" by Joseph Felsenstein, ggtree.
40
+ """
41
+
42
+ props = {
43
+ "layout": {},
44
+ "nleaves": {},
45
+ "start": {},
46
+ "end": {},
47
+ "angle": {},
48
+ }
49
+
50
+ props["layout"][root] = [0.0, 0.0]
51
+ props["start"][root] = 0.0
52
+ props["end"][root] = 360.0
53
+ props["angle"][root] = 0.0
54
+
55
+ # Count the number of leaves in each subtree
56
+ for node in postorder_fun():
57
+ props["nleaves"][node] = sum(props["nleaves"][child] for child in children_fun(node)) or 1
58
+
59
+ # Set the layout of everyone except the root
60
+ # NOTE: In ggtree, it says "postorder", but I cannot quite imagine how that would work,
61
+ # given that in postorder the root is visited last but it's also the only node about
62
+ # which we know anything at this point.
63
+ for node in preorder_fun():
64
+ nleaves = props["nleaves"][node]
65
+ children = children_fun(node)
66
+
67
+ # Get current node props
68
+ start = props["start"].get(node, 0)
69
+ end = props["end"].get(node, 0)
70
+ cur_x, cur_y = props["layout"].get(node, [0.0, 0.0])
71
+
72
+ total_angle = end - start
73
+
74
+ for child in children:
75
+ nleaves_child = props["nleaves"][child]
76
+ alpha = nleaves_child / nleaves * total_angle
77
+ beta = start + alpha / 2
78
+
79
+ props["layout"][child] = [
80
+ cur_x + branch_length_fun(child) * np.cos(np.radians(beta)),
81
+ cur_y + branch_length_fun(child) * np.sin(np.radians(beta)),
82
+ ]
83
+ props["angle"][child] = -90 - beta * np.sign(beta - 180)
84
+ props["start"][child] = start
85
+ props["end"][child] = start + alpha
86
+ start += alpha
87
+
88
+ # FIXME: figure out how to tell the caller about "angle"
89
+ return props["layout"]
90
+
91
+
92
+ def _daylight_tree_layout(
93
+ root: Any,
94
+ preorder_fun: Callable,
95
+ postorder_fun: Callable,
96
+ levelorder_fun: Callable,
97
+ children_fun: Callable,
98
+ branch_length_fun: Callable,
99
+ leaves_fun: Callable,
100
+ orientation: str = "right",
101
+ start: float = 180,
102
+ span: float = 360,
103
+ max_iter: int = 5,
104
+ dampening: float = 0.3,
105
+ **kwargs,
106
+ ) -> dict[Hashable, list[float]]:
107
+ """Daylight unrooted tree layout.
108
+
109
+ Parameters:
110
+ orientation: Whether the layout fans out towards the right (clockwise) or left
111
+ (anticlockwise).
112
+ start: The starting angle in degrees, default is -180 (left).
113
+ span: The angular span in degrees, default is 360 (full circle). When this is
114
+ 360, it leaves a small gap at the end to ensure the first and last leaf
115
+ are not overlapping.
116
+ max_iter: Maximum number of iterations to perform.
117
+ dampening: Dampening factor for angle adjustments. 1.0 means full adjustment.
118
+ The number must be strictily positive (usually between 0 excluded and 1
119
+ included).
120
+ Returns:
121
+ A dictionary with the layout.
122
+
123
+ Reference: "Inferring Phylogenies" by Joseph Felsenstein, ggtree.
124
+ """
125
+
126
+ delta_angle_min = 9.0
127
+
128
+ layout = _equalangle_tree_layout(
129
+ root,
130
+ preorder_fun,
131
+ postorder_fun,
132
+ children_fun,
133
+ branch_length_fun,
134
+ leaves_fun,
135
+ orientation,
136
+ start,
137
+ span,
138
+ **kwargs,
139
+ )
140
+
141
+ if len(layout) <= 2:
142
+ return layout
143
+
144
+ # Make all arrays for easier manipulation
145
+ orig_class = next(iter(layout.values())).__class__
146
+ for key, value in layout.items():
147
+ layout[key] = np.asarray(value)
148
+
149
+ all_leaves = list(leaves_fun(root))
150
+
151
+ change_avg = 1.0
152
+ for it in range(max_iter):
153
+ change_sum = 0
154
+ ninternal = 0
155
+ parents = [None] + list(levelorder_fun())
156
+ for parent in parents:
157
+ children = children_fun(parent) if parent is not None else [root]
158
+ for node in children:
159
+ res = _apply_daylight_single_node(
160
+ node,
161
+ parent,
162
+ all_leaves,
163
+ layout,
164
+ leaves_fun,
165
+ children_fun,
166
+ dampening,
167
+ )
168
+ change_sum += res
169
+ ninternal += 1
170
+
171
+ change_avg = change_sum / ninternal
172
+ if change_avg < delta_angle_min:
173
+ break
174
+
175
+ # Make all lists again
176
+ for key, value in layout.items():
177
+ layout[key] = orig_class(value)
178
+
179
+ return layout
180
+
181
+
182
+ def _apply_daylight_single_node(
183
+ node: Any,
184
+ parent: Any,
185
+ all_leaves: list[Any],
186
+ layout: dict[Hashable, np.ndarray],
187
+ leaves_fun: Callable,
188
+ children_fun: Callable,
189
+ dampening: float,
190
+ ) -> float:
191
+ """Apply daylight adjustment to a single internal node.
192
+
193
+ Parameters:
194
+ node: The internal node to adjust.
195
+ Returns:
196
+ The total change in angle applied.
197
+
198
+ NOTE: The layout is also changed in place.
199
+
200
+ # Inspired from:
201
+ # https://github.com/thomasp85/ggraph/blob/6c4ce81e460c50a16f9cd97e0b3a089f36901316/src/unrooted.cpp#L122
202
+ """
203
+
204
+ import os
205
+ from builtins import print as _print
206
+
207
+ DEBUG_DAYLIGHT = os.getenv("IPLOTX_DEBUG_DAYLIGHT", "0") == "1"
208
+
209
+ print = _print if DEBUG_DAYLIGHT else lambda *a, **k: None
210
+
211
+ children = children_fun(node)
212
+
213
+ # 1. Find boundary leaves for each child and for the parent
214
+ p0 = layout[node]
215
+ bounds = {}
216
+
217
+ print("node")
218
+ print(node)
219
+ print(float(p0[0]), float(p0[1]))
220
+
221
+ # To find the parent side leaves, we take all leaves and skip the ones
222
+ # downstream of this node
223
+ leaves_below = leaves_fun(node)
224
+
225
+ # Check the parent first if there is one
226
+ if parent is not None:
227
+ vec1 = layout[parent] - p0
228
+ print("parent side leaves:")
229
+ print(parent)
230
+ print(f" node to parent vector: {vec1[0]:.2f}, {vec1[1]:.2f}")
231
+ lower_angle, upper_angle = 2 * np.pi, -2 * np.pi
232
+ for leaf in all_leaves:
233
+ # Skip subtree leaves
234
+ if leaf in leaves_below:
235
+ continue
236
+ vec2 = layout[leaf] - p0
237
+ angle = _anticlockwise_angle(vec1, vec2)
238
+ print(" parent side leaf:")
239
+ print(leaf)
240
+ print(f" node to leaf vector: {vec2[0]:.2f}, {vec2[1]:.2f}")
241
+ print(f" angle: {angle:.2f}")
242
+ if angle < lower_angle:
243
+ lower_angle = angle
244
+ lower = leaf
245
+ if angle > upper_angle:
246
+ upper_angle = angle
247
+ upper = leaf
248
+ bounds[parent] = (lower, upper, lower_angle, upper_angle)
249
+
250
+ # Repeat the exact same thing for each child rather than the parent
251
+ print("subtree leaves:")
252
+ for child in children:
253
+ vec1 = layout[child] - p0
254
+ print(f" node to child vector: {vec1[0]:.2f}, {vec1[1]:.2f}")
255
+ lower_angle, upper_angle = 2 * np.pi, -2 * np.pi
256
+
257
+ for leaf in leaves_fun(child):
258
+ vec2 = layout[leaf] - p0
259
+ angle = _anticlockwise_angle(vec1, vec2)
260
+ print(f" node to leaf vector: {vec2[0]:.2f}, {vec2[1]:.2f}")
261
+ print(leaf)
262
+ print(f" angle: {angle:.2f}")
263
+ if angle < lower_angle:
264
+ lower_angle = angle
265
+ lower = leaf
266
+ if angle > upper_angle:
267
+ upper_angle = angle
268
+ upper = leaf
269
+ bounds[child] = (lower, upper, lower_angle, upper_angle)
270
+
271
+ print("final boundary leaves:")
272
+ print(bounds)
273
+
274
+ # 2. Compute daylight angles
275
+ # NOTE: Since Python 3.6, python keys are ordered by insertion order.
276
+ daylight = {}
277
+ daylight_sum = 0.0
278
+ # TODO: Mayvbe optimise this by avoiding creating all these lists
279
+ prev_leaves = [bound[1] for bound in bounds.values()]
280
+ leaves = [bound[0] for bound in bounds.values()]
281
+ leaves = leaves[1:] + [leaves[0]] # cycle left
282
+ subtrees = children + ([parent] if parent is not None else [])
283
+ for subtree, prev_leaf, leaf in zip(subtrees, prev_leaves, leaves):
284
+ vec1 = layout[prev_leaf] - p0
285
+ vec2 = layout[leaf] - p0
286
+ angle = _anticlockwise_angle(vec1, vec2)
287
+
288
+ daylight_sum += angle
289
+ if leaf != parent:
290
+ # daylight[leaf] = float(angle)
291
+ daylight[subtree] = float(angle)
292
+
293
+ print("daylight")
294
+ print(daylight)
295
+ print(f"daylight sum: {daylight_sum:.2f}")
296
+
297
+ # 3. Compute *excess* daylight, and correct it
298
+ # NOTE: There seems to be this notion that you rotate a node by the accumulated daylight
299
+ # correction (to fill the space on the left) plus its own correction (to fill the space on the right).
300
+ # It reads funny and this is within a BFS iteration anyway so it's probably ok, but it's
301
+ # curious.
302
+ # NOTE: The average adjustment is divided by children + 1 (the parent) bc this is unrooted.
303
+ daylight_avg = daylight_sum / (len(children) + 1)
304
+ daylight_cum_corr = 0.0
305
+ daylight_changes = 0
306
+ for leaf in daylight:
307
+ daylight_cum_corr += daylight_avg - daylight[leaf]
308
+ _rotate_subtree_around_point(
309
+ leaf,
310
+ children_fun,
311
+ layout,
312
+ p0,
313
+ dampening * daylight_cum_corr,
314
+ recur=True,
315
+ )
316
+ daylight_changes += abs(dampening * daylight_cum_corr)
317
+
318
+ # Caller wants degrees
319
+ return np.degrees(daylight_changes / len(leaves))
320
+
321
+
322
+ # see: https://stackoverflow.com/questions/14066933/direct-way-of-computing-the-clockwise-angle-between-two-vectors
323
+ def _anticlockwise_angle(v1, v2):
324
+ """Compute the anticlockwise angle between two 2D vectors.
325
+
326
+ Parameters:
327
+ v1: First vector.
328
+ v2: Second vector.
329
+ Returns:
330
+ The angle in radians.
331
+ """
332
+ dot = v1[0] * v2[0] + v1[1] * v2[1]
333
+ determinant = v1[0] * v2[1] - v1[1] * v2[0]
334
+ return np.arctan2(determinant, dot)
335
+
336
+
337
+ def _rotate_subtree_around_point(
338
+ node,
339
+ children_fun: Callable,
340
+ layout: dict[Hashable, list[float]],
341
+ pivot,
342
+ angle,
343
+ recur: bool = True,
344
+ ):
345
+ point = np.asarray(layout[node])
346
+ pivot = np.asarray(pivot)
347
+ layout[node] = _rotate_around_point(
348
+ point,
349
+ pivot,
350
+ angle,
351
+ )
352
+ if not recur:
353
+ return
354
+ for child in children_fun(node):
355
+ _rotate_subtree_around_point(
356
+ child,
357
+ children_fun,
358
+ layout,
359
+ pivot,
360
+ angle,
361
+ )
362
+
363
+
364
+ def _rotate_around_point(
365
+ point,
366
+ pivot,
367
+ angle,
368
+ ):
369
+ """Rotate a point around a piviot by angle (in radians).
370
+
371
+ Parameters:
372
+ point: The point to rotate.
373
+ pivot: The piviot point.
374
+ angle: The angle in radians.
375
+ Returns:
376
+ The rotated point.
377
+ """
378
+ point = np.asarray(point)
379
+ pivot = np.asarray(pivot)
380
+ cos = np.cos(angle)
381
+ sin = np.sin(angle)
382
+ rot = np.array([[cos, sin], [-sin, cos]])
383
+ return pivot + (point - pivot) @ rot
iplotx/tree/__init__.py CHANGED
@@ -445,7 +445,7 @@ class TreeArtist(mpl.artist.Artist):
445
445
  leaf_layout = self.get_layout("leaf").copy()
446
446
 
447
447
  # Set all to max depth
448
- if user_leaf_style.get("deep", True):
448
+ if user_leaf_style.get("deep", False):
449
449
  if layout_name == "radial":
450
450
  leaf_layout.iloc[:, 0] = leaf_layout.iloc[:, 0].max()
451
451
  elif (layout_name, orientation) == ("horizontal", "right"):
@@ -666,7 +666,10 @@ class TreeArtist(mpl.artist.Artist):
666
666
  norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
667
667
  edge_style["norm"] = norm
668
668
 
669
- if get_style(".layout", {}).get("angular", False):
669
+ angular_layout = get_style(".layout", {}).get("angular", False)
670
+ if self._ipx_internal_data["layout_name"] in ("equalangle", "daylight"):
671
+ angular_layout = True
672
+ if angular_layout:
670
673
  edge_style.pop("waypoints", None)
671
674
  else:
672
675
  edge_style["waypoints"] = waypoints
iplotx/version.py CHANGED
@@ -2,4 +2,4 @@
2
2
  iplotx version information module.
3
3
  """
4
4
 
5
- __version__ = "1.2.1"
5
+ __version__ = "1.3.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iplotx
3
- Version: 1.2.1
3
+ Version: 1.3.0
4
4
  Summary: Plot networkx from igraph and networkx.
5
5
  Project-URL: Homepage, https://github.com/fabilab/iplotx
6
6
  Project-URL: Documentation, https://readthedocs.org/iplotx
@@ -36,9 +36,9 @@ Provides-Extra: networkx
36
36
  Requires-Dist: networkx>=2.0.0; extra == 'networkx'
37
37
  Description-Content-Type: text/markdown
38
38
 
39
- ![Github Actions](https://github.com/fabilab/iplotx/actions/workflows/test.yml/badge.svg)
40
- ![PyPI - Version](https://img.shields.io/pypi/v/iplotx)
41
- ![RTD](https://readthedocs.org/projects/iplotx/badge/?version=latest)
39
+ [![Github Actions](https://github.com/fabilab/iplotx/actions/workflows/test.yml/badge.svg)](https://github.com/fabilab/iplotx/actions/workflows/test.yml)
40
+ [![PyPI - Version](https://img.shields.io/pypi/v/iplotx)](https://pypi.org/project/iplotx/)
41
+ [![RTD](https://readthedocs.org/projects/iplotx/badge/?version=latest)](https://iplotx.readthedocs.io/en/latest/)
42
42
  [![Coverage Status](https://coveralls.io/repos/github/fabilab/iplotx/badge.svg?branch=main)](https://coveralls.io/github/fabilab/iplotx?branch=main)
43
43
  ![pylint](assets/pylint.svg)
44
44
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.16599333.svg)](https://doi.org/10.5281/zenodo.16599333)
@@ -89,6 +89,13 @@ See [readthedocs](https://iplotx.readthedocs.io/en/latest/) for the full documen
89
89
  ## Gallery
90
90
  See [gallery](https://iplotx.readthedocs.io/en/latest/gallery/index.html).
91
91
 
92
+ ## Citation
93
+ If you use `iplotx` for publication figures, please cite the [zenodo preprint](https://doi.org/10.5281/zenodo.16599333):
94
+
95
+ ```
96
+ F. Zanini. (2025). Unified network visualisation in Python. Zenodo [PREPRINT]. https://doi.org/10.5281/zenodo.16599333
97
+ ```
98
+
92
99
  ## Features
93
100
  - Plot networks from multiple libraries including networkx, igraph and graph-tool, using Matplotlib. ✅
94
101
  - Plot trees from multiple libraries such as cogent3, ETE4, skbio, biopython, and dendropy. ✅
@@ -1,10 +1,9 @@
1
1
  iplotx/__init__.py,sha256=RKlRSSEAv2qECd6rCiovdLDu-4k1eXMGCOCPt0xwpFA,523
2
2
  iplotx/artists.py,sha256=2dBDT240zGwKb6tIc_y9pXeyU3LuYeF9wjj2tvi4KJo,730
3
3
  iplotx/label.py,sha256=7eS8ByadrhdIFOZz19U4VrS-oXY_ndFYNB-D4RZbFqI,9573
4
- iplotx/layout.py,sha256=S-iFxHaIOzhBDG2JUzl9_oDBRP5TYY1hXnEOs0h1Rck,5588
5
4
  iplotx/plotting.py,sha256=RyAdvaHSpuyJkf8DF3SJBvEXBrPmJEdovUyAlBWQvqU,16228
6
5
  iplotx/typing.py,sha256=QLdzV358IiD1CFe88MVp0D77FSx5sSAVUmM_2WPPE8I,1463
7
- iplotx/version.py,sha256=2CsPuf8-y4qmmHpnn5_1DZwxypX8SRHYrsqTTmN1h5A,66
6
+ iplotx/version.py,sha256=k2KjblHRw7cpH2h7LRTJqlcSXCjDH-WN6aCDQlzMEH4,66
8
7
  iplotx/vertex.py,sha256=_yYyvusn4vYvi6RBEW6CHa3vnbv43GnZylnMIaK4bG0,16040
9
8
  iplotx/art3d/vertex.py,sha256=Xf8Um30X2doCd8KdNN7332F6BxC4k72Mb_GeRAuzQfQ,2545
10
9
  iplotx/art3d/edge/__init__.py,sha256=uw1U_mMXqcZAvea-7JbU1PUKULQD1CMMrbwY02tiWRQ,8529
@@ -17,29 +16,33 @@ iplotx/edge/leaf.py,sha256=SyGMv2PIOoH0pey8-aMVaZheK3hNe1Qz_okcyWbc4E4,4268
17
16
  iplotx/edge/ports.py,sha256=BpkbiEhX4mPBBAhOv4jcKFG4Y8hxXz5GRtVLCC0jbtI,1235
18
17
  iplotx/ingest/__init__.py,sha256=k1Q-7lSdotMR4RkF1x0t19RFsTknohX0L507Dw69WyU,5035
19
18
  iplotx/ingest/heuristics.py,sha256=715VqgfKek5LOJnu1vTo7RqPgCl-Bb8Cf6o7_Tt57fA,5797
20
- iplotx/ingest/typing.py,sha256=nk0UTqTuZoa9YE7F8RlOTqhxPw4OEYFTqrFFRWQs0jI,14488
19
+ iplotx/ingest/typing.py,sha256=VYWlNGwn047J0i10vC_F3Lfnx2CSiX6IeH_NtAgHLQg,16089
21
20
  iplotx/ingest/providers/network/graph_tool.py,sha256=iTCf4zHe4Zmdd8Tlz6j7Xfo_FwfsIiK5JkQfH3uq7TM,3028
22
21
  iplotx/ingest/providers/network/igraph.py,sha256=WL9Yx2IF5QhUIoKMlozdyq5HWIZ-IJmNoeS8GOhL0KU,2945
23
22
  iplotx/ingest/providers/network/networkx.py,sha256=ehCg4npL073HX-eAG-VoP6refLPsMb3lYG51xt_rNjA,4636
24
23
  iplotx/ingest/providers/network/simple.py,sha256=e_aHhiHhN9DrMoNrt7tEMPURXGhQ1TYRPzsxDEptUlc,3766
25
- iplotx/ingest/providers/tree/biopython.py,sha256=4N_54cVyHHPcASJZGr6pHKE2p5R3i8Cm307SLlSLHLA,1480
26
- iplotx/ingest/providers/tree/cogent3.py,sha256=JmELbDK7LyybiJzFNbmeqZ4ySJoDajvFfJebpNfFKWo,1073
27
- iplotx/ingest/providers/tree/dendropy.py,sha256=uRMe46PfDPUTeNInUO2Gbp4pVr-WIFIZQvrND2tovsg,1548
28
- iplotx/ingest/providers/tree/ete4.py,sha256=D7usSq0MOjzrk3EoLi834IlaDGwv7_qG6Qt0ptfKqfI,928
29
- iplotx/ingest/providers/tree/simple.py,sha256=-T7Kf-G4F4niggy_tNZ8AafDf8fpDdthC-vlkjbEZso,2569
30
- iplotx/ingest/providers/tree/skbio.py,sha256=O1KUr8tYi28pZ3VVjapgO4Uj-YpMuix3GhOH5je8Lv4,822
24
+ iplotx/ingest/providers/tree/biopython.py,sha256=si-ncMVHrdbwDVspznFbO7ajTaa37gqSfYGKdx9eoQ8,1538
25
+ iplotx/ingest/providers/tree/cogent3.py,sha256=6omk0cDSmb2k2J1BQ9depcSPWcfamSyS0eIlRpLRgoM,1156
26
+ iplotx/ingest/providers/tree/dendropy.py,sha256=MRmPdlrPwqCQWLkKM4vJLN4hpqLY7qGtNrBYs5pLP9E,1891
27
+ iplotx/ingest/providers/tree/ete4.py,sha256=JT0Zv-nzHKTSJ0h4I7SxkwFafww_siDZ06Krvz-BT1U,991
28
+ iplotx/ingest/providers/tree/simple.py,sha256=hin38l9ZAnuDGwZAPctxNhIBrmIkQYIkBMFPt9uALuE,2877
29
+ iplotx/ingest/providers/tree/skbio.py,sha256=T3IbOBut98A2GoGJzo6Tzp108uFa9n485mV4M9J5xFk,905
30
+ iplotx/layout/__init__.py,sha256=7on7I9CcbByz4X4hNJGsCc0GDFr3ul1K2UvTyvcGAWo,107
31
+ iplotx/layout/tree/__init__.py,sha256=hxASc8uXMWbpxnEHnChMzb3VQTTIyU4ww7SQRez1hK0,2000
32
+ iplotx/layout/tree/rooted.py,sha256=j3Y_Yd3YCWJRnhfE1qwEX5xCyUgvOJCX9Qfkc81B5BM,4215
33
+ iplotx/layout/tree/unrooted.py,sha256=AH_8f4runflAs1bqXECeTDjtGDmFfO4afJf5JllS1lA,11716
31
34
  iplotx/network/__init__.py,sha256=cJ6m6s157AOCqg-znUAlsumuZ2jiE9QsVQ3-GCK01wo,13543
32
35
  iplotx/network/groups.py,sha256=E_eYVXRHjv1DcyA4RupTkMa-rRFrIKkt9Rxn_Elw9Nc,6796
33
36
  iplotx/style/__init__.py,sha256=rf1GutrE8hHUhCoe4FGKYX-aNtHuu_U-kYQnqUxZNrY,10282
34
37
  iplotx/style/leaf_info.py,sha256=3xBn7xv9Uy2KAqdhM9S6ew5ZBJrGRTXRL3xXb8atfLw,1018
35
38
  iplotx/style/library.py,sha256=58Y8BlllGLsR4pQM7_PVCP5tH6_4GkchXZvJpqGHlcg,8534
36
- iplotx/tree/__init__.py,sha256=nV2iWrtpPhpGZnkEJqK8ydimMi7pJtRQ1Eqn5dgqUyA,31043
39
+ iplotx/tree/__init__.py,sha256=sxVrxZcsDIZddBdtI-TxOs37FyeYbHBLYqkLPWs6c8M,31199
37
40
  iplotx/tree/cascades.py,sha256=Wwqhy46QGeb4LNGUuz_-bgNWUMz6PFzs_dIxIb1dtqc,8394
38
41
  iplotx/tree/scalebar.py,sha256=Yxt_kF8JdTwKGa8Jzqt3qVePPK5ZBG8P0EiONrsh3E8,11863
39
42
  iplotx/utils/geometry.py,sha256=6RrC6qaB0-1vIk1LhGA4CfsiMd-9JNniSPyL_l9mshE,9245
40
43
  iplotx/utils/internal.py,sha256=WWfcZDGK8Ut1y_tOHRGg9wSqY1bwSeLQO7dHM_8Tvwo,107
41
44
  iplotx/utils/matplotlib.py,sha256=p_53Oamof0RI4mtV8HrdDtZbgVqUxeUZ_KDvLZSiBUQ,8604
42
45
  iplotx/utils/style.py,sha256=vyNP80nDYVinqm6_9ltCJCtjK35ZcGlHvOskNv3eQBc,4225
43
- iplotx-1.2.1.dist-info/METADATA,sha256=DVxutHflrg2vXCJfyzmPR83ywc4_5DfGdrViUMvV43E,5001
44
- iplotx-1.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
45
- iplotx-1.2.1.dist-info/RECORD,,
46
+ iplotx-1.3.0.dist-info/METADATA,sha256=laDlBsyQ5RwK1WeA4Hm_3uusbpJGxNifWJZu_0JHYYI,5407
47
+ iplotx-1.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
48
+ iplotx-1.3.0.dist-info/RECORD,,
File without changes