iplotx 1.2.0__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.
iplotx/edge/arrow.py CHANGED
@@ -2,7 +2,12 @@
2
2
  Module for edge arrows in iplotx.
3
3
  """
4
4
 
5
- from typing import Never, Optional
5
+ import sys
6
+ if sys.version_info < (3, 11):
7
+ from typing_extensions import Never
8
+ else:
9
+ from typing import Never
10
+ from typing import Optional
6
11
 
7
12
  from math import atan2, cos, sin
8
13
  import numpy as np
iplotx/ingest/__init__.py CHANGED
@@ -2,16 +2,24 @@
2
2
  This module focuses on how to ingest network/tree data into standard data structures no matter what library they come from.
3
3
  """
4
4
 
5
- import pathlib
6
- import pkgutil
7
- import importlib
8
- import warnings
5
+ import sys
9
6
  from typing import (
10
7
  Optional,
11
8
  Sequence,
12
- Protocol,
13
9
  )
10
+
11
+ # NOTE: __init__ in Protocols has had a difficult gestation
12
+ # https://github.com/python/cpython/issues/88970
13
+ if sys.version_info < (3, 11):
14
+ Protocol = object
15
+ else:
16
+ from typing import Protocol
17
+
14
18
  from collections.abc import Hashable
19
+ import pathlib
20
+ import pkgutil
21
+ import importlib
22
+ import warnings
15
23
  import pandas as pd
16
24
 
17
25
  from ..typing import (
@@ -35,6 +43,9 @@ provider_protocols = {
35
43
  data_providers: dict[str, dict[str, Protocol]] = {kind: {} for kind in provider_protocols}
36
44
  for kind in data_providers:
37
45
  providers_path = pathlib.Path(__file__).parent.joinpath("providers").joinpath(kind)
46
+ if sys.version_info < (3, 11):
47
+ providers_path = str(providers_path)
48
+
38
49
  for importer, module_name, _ in pkgutil.iter_modules([providers_path]):
39
50
  module = importlib.import_module(f"iplotx.ingest.providers.{kind}.{module_name}")
40
51
  for key, val in module.__dict__.items():
@@ -138,11 +149,11 @@ def ingest_tree_data(
138
149
 
139
150
 
140
151
  # INTERNAL FUNCTIONS
141
- def _update_data_providers(kind):
142
- """Update data provieders dynamically from external packages."""
152
+ def _update_data_providers(kind: str):
153
+ """Update data providers dynamically from external packages."""
143
154
  discovered_providers = importlib.metadata.entry_points(group=f"iplotx.{kind}_data_providers")
144
155
  for entry_point in discovered_providers:
145
- if entry_point.name not in data_providers["network"]:
156
+ if entry_point.name not in data_providers[kind]:
146
157
  try:
147
158
  data_providers[kind][entry_point.name] = entry_point.load()
148
159
  except Exception as e:
@@ -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
@@ -3,7 +3,6 @@ from typing import (
3
3
  Optional,
4
4
  Sequence,
5
5
  Iterable,
6
- Self,
7
6
  )
8
7
 
9
8
  from ...typing import (
@@ -19,12 +18,12 @@ class SimpleTree:
19
18
  branch_length: Length of the branch leading to this node/tree.
20
19
  """
21
20
 
22
- children: Sequence[Self] = []
21
+ children: Sequence = []
23
22
  branch_length: float = 1
24
23
  name: str = ""
25
24
 
26
25
  @classmethod
27
- def from_dict(cls, data: dict) -> Self:
26
+ def from_dict(cls, data: dict):
28
27
  """Create a SimpleTree from a dictionary.
29
28
 
30
29
  Parameters:
@@ -65,7 +64,17 @@ class SimpleTreeDataProvider(TreeDataProvider):
65
64
 
66
65
  yield from _recur(self.tree)
67
66
 
68
- 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]:
69
78
  def _recur(node):
70
79
  if len(node.children) == 0:
71
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
@@ -5,15 +5,21 @@ Networkx and trees are treated separately for practical reasons: many tree analy
5
5
  work as well on general networks.
6
6
  """
7
7
 
8
+ import sys
8
9
  from typing import (
9
- NotRequired,
10
- TypedDict,
11
- Protocol,
12
10
  Optional,
13
11
  Sequence,
14
12
  Any,
15
13
  Iterable,
16
14
  )
15
+
16
+ # NOTE: __init__ in Protocols has had a difficult gestation
17
+ # https://github.com/python/cpython/issues/88970
18
+ if sys.version_info < (3, 11):
19
+ Protocol = object
20
+ else:
21
+ from typing import Protocol
22
+
17
23
  from collections.abc import Hashable
18
24
  import numpy as np
19
25
  import pandas as pd
@@ -26,6 +32,11 @@ from .heuristics import (
26
32
  normalise_tree_layout,
27
33
  )
28
34
 
35
+ if sys.version_info < (3, 11):
36
+ from typing_extensions import TypedDict, NotRequired
37
+ else:
38
+ from typing import TypedDict, NotRequired
39
+
29
40
 
30
41
  class NetworkData(TypedDict):
31
42
  """Network data structure for iplotx."""
@@ -146,8 +157,32 @@ class TreeDataProvider(Protocol):
146
157
  return root_attr
147
158
  return self.tree.get_root()
148
159
 
149
- def get_leaves(self) -> Sequence[Any]:
150
- """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.
151
186
 
152
187
  Returns:
153
188
  The leaves or tips of the tree.
@@ -225,8 +260,6 @@ class TreeDataProvider(Protocol):
225
260
  NOTE: individual providers may implement more efficient versions of
226
261
  this function if desired.
227
262
  """
228
- provider = self.__class__
229
-
230
263
  # Find leaves of the selected nodes
231
264
  leaves = set()
232
265
  for node in nodes:
@@ -234,7 +267,7 @@ class TreeDataProvider(Protocol):
234
267
  if len(self.get_children(node)) == 0:
235
268
  leaves.add(node)
236
269
  else:
237
- leaves |= set(provider(node).get_leaves())
270
+ leaves |= set(self.get_leaves(node))
238
271
 
239
272
  # Look for nodes with the same set of leaves, starting from the bottom
240
273
  # and stopping at the first (i.e. lowest) hit.
@@ -243,7 +276,7 @@ class TreeDataProvider(Protocol):
243
276
  if len(self.get_children(node)) == 0:
244
277
  leaves_node = {node}
245
278
  else:
246
- leaves_node = set(provider(node).get_leaves())
279
+ leaves_node = set(self.get_leaves(node))
247
280
  if leaves <= leaves_node:
248
281
  root = node
249
282
  break
@@ -275,9 +308,26 @@ class TreeDataProvider(Protocol):
275
308
  orientation = "right"
276
309
  elif layout == "vertical":
277
310
  orientation = "descending"
278
- elif layout == "radial":
311
+ elif layout in ("radial", "equalangle", "daylight"):
279
312
  orientation = "clockwise"
280
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
+
281
331
  tree_data = {
282
332
  "root": self.get_root(),
283
333
  "rooted": self.is_rooted(),
@@ -294,8 +344,10 @@ class TreeDataProvider(Protocol):
294
344
  root=tree_data["root"],
295
345
  preorder_fun=self.preorder,
296
346
  postorder_fun=self.postorder,
347
+ levelorder_fun=self.levelorder,
297
348
  children_fun=self.get_children,
298
349
  branch_length_fun=self.get_branch_length_default_to_one,
350
+ leaves_fun=self.get_leaves,
299
351
  **layout_style,
300
352
  )
301
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
@@ -1,7 +1,6 @@
1
1
  from typing import (
2
2
  Optional,
3
3
  Sequence,
4
- Self,
5
4
  )
6
5
  import numpy as np
7
6
  import pandas as pd
@@ -103,8 +102,8 @@ class NetworkArtist(mpl.artist.Artist):
103
102
  @classmethod
104
103
  def from_other(
105
104
  cls: "NetworkArtist", # NOTE: This is fixed in Python 3.14
106
- other: Self,
107
- ) -> Self:
105
+ other,
106
+ ):
108
107
  """Create a NetworkArtist as a copy of another one.
109
108
 
110
109
  Parameters:
@@ -123,7 +122,7 @@ class NetworkArtist(mpl.artist.Artist):
123
122
  def from_edgecollection(
124
123
  cls: "NetworkArtist", # NOTE: This is fixed in Python 3.14
125
124
  edge_collection: EdgeCollection | Edge3DCollection,
126
- ) -> Self:
125
+ ):
127
126
  """Create a NetworkArtist from iplotx artists.
128
127
 
129
128
  Parameters:
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.0"
5
+ __version__ = "1.3.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iplotx
3
- Version: 1.2.0
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
@@ -26,8 +26,8 @@ Classifier: Programming Language :: Python :: 3.13
26
26
  Classifier: Topic :: Scientific/Engineering :: Visualization
27
27
  Classifier: Topic :: System :: Networking
28
28
  Classifier: Typing :: Typed
29
- Requires-Python: >=3.11
30
- Requires-Dist: matplotlib>=2.0.0
29
+ Requires-Python: >=3.10
30
+ Requires-Dist: matplotlib>=3.10.0
31
31
  Requires-Dist: numpy>=2.0.0
32
32
  Requires-Dist: pandas>=2.0.0
33
33
  Provides-Extra: igraph
@@ -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,45 +1,48 @@
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=t6hulw8HK7i9Hmcp81rtaFx81Xw1b0UWhjmKsoS2Ld8,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
11
10
  iplotx/art3d/edge/arrow.py,sha256=14BFXY9kDOUGPZl2fMD9gRVGyaaN5kyd-l6ikBg6WHU,3601
12
11
  iplotx/art3d/edge/geometry.py,sha256=76VUmpPG-4Mls7x_994dMwdDPrWWnjT7nHJsHfwK_hA,2467
13
12
  iplotx/edge/__init__.py,sha256=GlY_CmlMVD1DWJeOb5cFQEP_4K1pPneS_fRLqAxNGMk,26573
14
- iplotx/edge/arrow.py,sha256=U7vvBo7IMwo1qiyU9cyUEwraOaBcJLgdu9oU2OyoHL4,17453
13
+ iplotx/edge/arrow.py,sha256=ymup2YT_0GVYMtZw_DSKrZqFHG_ysYteEhmoL6T8Mu4,17563
15
14
  iplotx/edge/geometry.py,sha256=jkTMvQC5425GjB_fmGLIPJeSDAr_7NZF8zZDLTrSj34,15541
16
15
  iplotx/edge/leaf.py,sha256=SyGMv2PIOoH0pey8-aMVaZheK3hNe1Qz_okcyWbc4E4,4268
17
16
  iplotx/edge/ports.py,sha256=BpkbiEhX4mPBBAhOv4jcKFG4Y8hxXz5GRtVLCC0jbtI,1235
18
- iplotx/ingest/__init__.py,sha256=S0YfnXcFKseB7ZBQc4yRt0cNDsLlhqdom0TmSY3OY2E,4756
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=61LwNwrTHVh8eqqC778Gr81zPYcUKW61mDgGCCsuGSk,14181
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=aV9wGqBomJ5klM_aJQeuL_Q_J1pLCv6AFN98BPDiKUw,2593
30
- iplotx/ingest/providers/tree/skbio.py,sha256=O1KUr8tYi28pZ3VVjapgO4Uj-YpMuix3GhOH5je8Lv4,822
31
- iplotx/network/__init__.py,sha256=oEv6f8oFYrtcI_NKabr8a_oIWTc1jXXTl_yO1xox_rE,13575
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
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.0.dist-info/METADATA,sha256=9QR6MWdU5XcKIB6JTwEX58Gw5FQInscAdB27AKKidi4,5000
44
- iplotx-1.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
45
- iplotx-1.2.0.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