iplotx 1.2.1__py3-none-any.whl → 1.4.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/__init__.py +7 -0
- iplotx/edge/geometry.py +113 -0
- iplotx/ingest/providers/tree/biopython.py +2 -1
- iplotx/ingest/providers/tree/cogent3.py +4 -1
- iplotx/ingest/providers/tree/dendropy.py +10 -1
- iplotx/ingest/providers/tree/ete4.py +2 -1
- iplotx/ingest/providers/tree/simple.py +11 -1
- iplotx/ingest/providers/tree/skbio.py +4 -1
- iplotx/ingest/typing.py +49 -7
- iplotx/layout/__init__.py +9 -0
- iplotx/layout/tree/__init__.py +72 -0
- iplotx/{layout.py → layout/tree/rooted.py} +3 -45
- iplotx/layout/tree/unrooted.py +444 -0
- iplotx/style/leaf_info.py +1 -0
- iplotx/tree/__init__.py +5 -2
- iplotx/version.py +1 -1
- {iplotx-1.2.1.dist-info → iplotx-1.4.0.dist-info}/METADATA +11 -4
- {iplotx-1.2.1.dist-info → iplotx-1.4.0.dist-info}/RECORD +19 -16
- {iplotx-1.2.1.dist-info → iplotx-1.4.0.dist-info}/WHEEL +0 -0
iplotx/edge/__init__.py
CHANGED
|
@@ -370,6 +370,9 @@ class EdgeCollection(mpl.collections.PatchCollection):
|
|
|
370
370
|
if edge_stylei.get("curved", False):
|
|
371
371
|
tension = edge_stylei.get("tension", 5)
|
|
372
372
|
ports = edge_stylei.get("ports", (None, None))
|
|
373
|
+
elif edge_stylei.get("arc", False):
|
|
374
|
+
tension = edge_stylei.get("tension", 1)
|
|
375
|
+
ports = None
|
|
373
376
|
else:
|
|
374
377
|
tension = 0
|
|
375
378
|
ports = None
|
|
@@ -391,6 +394,8 @@ class EdgeCollection(mpl.collections.PatchCollection):
|
|
|
391
394
|
if waypoints != "none":
|
|
392
395
|
ports = edge_stylei.get("ports", (None, None))
|
|
393
396
|
|
|
397
|
+
arc = edge_stylei.get("arc", False)
|
|
398
|
+
|
|
394
399
|
# Compute actual edge path
|
|
395
400
|
path, angles = _compute_edge_path(
|
|
396
401
|
vcoord_data,
|
|
@@ -401,6 +406,7 @@ class EdgeCollection(mpl.collections.PatchCollection):
|
|
|
401
406
|
tension=tension,
|
|
402
407
|
waypoints=waypoints,
|
|
403
408
|
ports=ports,
|
|
409
|
+
arc=arc,
|
|
404
410
|
layout_coordinate_system=self._vertex_collection.get_layout_coordinate_system(),
|
|
405
411
|
shrink=shrink,
|
|
406
412
|
)
|
|
@@ -712,6 +718,7 @@ def make_stub_patch(**kwargs):
|
|
|
712
718
|
"split",
|
|
713
719
|
"shrink",
|
|
714
720
|
"depthshade",
|
|
721
|
+
"arc",
|
|
715
722
|
# DEPRECATED
|
|
716
723
|
"padding",
|
|
717
724
|
]
|
iplotx/edge/geometry.py
CHANGED
|
@@ -393,6 +393,108 @@ def _compute_edge_path_waypoints(
|
|
|
393
393
|
return path, angles
|
|
394
394
|
|
|
395
395
|
|
|
396
|
+
def _compute_edge_path_arc(
|
|
397
|
+
tension,
|
|
398
|
+
vcoord_data,
|
|
399
|
+
vpath_fig,
|
|
400
|
+
vsize_fig,
|
|
401
|
+
trans,
|
|
402
|
+
trans_inv,
|
|
403
|
+
ports: Pair[Optional[str]] = (None, None),
|
|
404
|
+
shrink: float = 0,
|
|
405
|
+
):
|
|
406
|
+
"""Shorten the edge path along an arc.
|
|
407
|
+
|
|
408
|
+
Parameters:
|
|
409
|
+
tension: the tension of the arc. This is defined, for this function, as the tangent
|
|
410
|
+
of the angle spanning the arc. For instance, for a semicircle, the angle is
|
|
411
|
+
180 degrees, so the tension is +-1 (depending on the orientation).
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
# Coordinates in figure (default) coords
|
|
415
|
+
vcoord_fig = trans(vcoord_data)
|
|
416
|
+
|
|
417
|
+
dv = vcoord_fig[1] - vcoord_fig[0]
|
|
418
|
+
|
|
419
|
+
# Tension is the fraction of the semicircle covered by the
|
|
420
|
+
# arc. Values are clipped between -1 (left-hand semicircle)
|
|
421
|
+
# and 1 (right-hand semicircle). 0 means a straight line,
|
|
422
|
+
# which is a (degenerate) arc too.
|
|
423
|
+
if tension == 0:
|
|
424
|
+
vs = [None, None]
|
|
425
|
+
thetas = [atan2(dv[1], dv[0])]
|
|
426
|
+
thetas.append(-thetas[0])
|
|
427
|
+
for i in range(2):
|
|
428
|
+
vs[i] = (
|
|
429
|
+
_get_shorter_edge_coords(vpath_fig[i], vsize_fig[i], thetas[i], shrink)
|
|
430
|
+
+ vcoord_fig[i]
|
|
431
|
+
)
|
|
432
|
+
auxs = []
|
|
433
|
+
|
|
434
|
+
else:
|
|
435
|
+
edge_straight_length = np.sqrt((dv**2).sum())
|
|
436
|
+
theta_straight = atan2(dv[1], dv[0])
|
|
437
|
+
theta_tension = 4 * np.arctan(tension)
|
|
438
|
+
# print(f"theta_straight: {np.degrees(theta_straight):.2f}")
|
|
439
|
+
# print(f"theta_tension: {np.degrees(theta_tension):.2f}")
|
|
440
|
+
# NOTE: positive tension means an arc shooting off to the right of the straight
|
|
441
|
+
# line, same convensio as for tension elsewhere in the codebase.
|
|
442
|
+
thetas = [theta_straight - theta_tension / 2, np.pi + theta_straight + theta_tension / 2]
|
|
443
|
+
# This is guaranteed to be finite because tension == 0 is taken care of above,
|
|
444
|
+
# and tension = np.inf is not allowed.
|
|
445
|
+
mid = vcoord_fig.mean(axis=0)
|
|
446
|
+
# print(f"theta_s: {thetas}")
|
|
447
|
+
# print(f"mid: {mid}")
|
|
448
|
+
theta_offset = theta_straight + np.pi / 2
|
|
449
|
+
if np.abs(tension) <= 1:
|
|
450
|
+
offset_length = edge_straight_length / 2 / np.tan(theta_tension / 2)
|
|
451
|
+
else:
|
|
452
|
+
# print("Large tension arc")
|
|
453
|
+
offset_length = -edge_straight_length / 2 * np.tan(theta_tension / 2 - np.pi / 2)
|
|
454
|
+
# print(f"theta_offset: {np.degrees(theta_offset):.2f}")
|
|
455
|
+
offset = offset_length * np.array([np.cos(theta_offset), np.sin(theta_offset)])
|
|
456
|
+
# print(f"offset: {offset}")
|
|
457
|
+
center = mid + offset
|
|
458
|
+
# print(f"center: {center}")
|
|
459
|
+
|
|
460
|
+
# Compute shorter start and end points
|
|
461
|
+
vs = [None, None]
|
|
462
|
+
for i in range(2):
|
|
463
|
+
vs[i] = (
|
|
464
|
+
_get_shorter_edge_coords(vpath_fig[i], vsize_fig[i], thetas[i], shrink)
|
|
465
|
+
+ vcoord_fig[i]
|
|
466
|
+
)
|
|
467
|
+
angle_start = atan2(*(vs[0] - center)[::-1])
|
|
468
|
+
angle_end = atan2(*(vs[1] - center)[::-1])
|
|
469
|
+
if (np.abs(tension) > 1) and (np.abs(angle_end - angle_start) < np.pi):
|
|
470
|
+
if angle_end > angle_start:
|
|
471
|
+
angle_start += 2 * np.pi
|
|
472
|
+
else:
|
|
473
|
+
angle_end += 2 * np.pi
|
|
474
|
+
# print(f"angle_start: {np.degrees(angle_start):.2f}")
|
|
475
|
+
# print(f"angle_end: {np.degrees(angle_end):.2f}")
|
|
476
|
+
|
|
477
|
+
naux = 30
|
|
478
|
+
angles = np.linspace(angle_start, angle_end, naux + 2)[1:-1]
|
|
479
|
+
auxs = center + np.array([np.cos(angles), np.sin(angles)]).T * np.linalg.norm(
|
|
480
|
+
vs[0] - center
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
path = {
|
|
484
|
+
"vertices": [vs[0]] + list(auxs) + [vs[1]],
|
|
485
|
+
"codes": ["MOVETO"] + ["LINETO"] * (len(auxs) + 1),
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
path = mpl.path.Path(
|
|
489
|
+
path["vertices"],
|
|
490
|
+
codes=[getattr(mpl.path.Path, x) for x in path["codes"]],
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Return to data transform
|
|
494
|
+
path.vertices = trans_inv(path.vertices)
|
|
495
|
+
return path, tuple(thetas)
|
|
496
|
+
|
|
497
|
+
|
|
396
498
|
def _compute_edge_path_curved(
|
|
397
499
|
tension,
|
|
398
500
|
vcoord_data,
|
|
@@ -483,12 +585,15 @@ def _compute_edge_path(
|
|
|
483
585
|
tension: float = 0,
|
|
484
586
|
waypoints: str | tuple[float, float] | Sequence[tuple[float, float]] | np.ndarray = "none",
|
|
485
587
|
ports: Pair[Optional[str]] = (None, None),
|
|
588
|
+
arc: bool = False,
|
|
486
589
|
layout_coordinate_system: str = "cartesian",
|
|
487
590
|
**kwargs,
|
|
488
591
|
):
|
|
489
592
|
"""Compute the edge path in a few different ways."""
|
|
490
593
|
if (waypoints != "none") and (tension != 0):
|
|
491
594
|
raise ValueError("Waypoints not supported for curved edges.")
|
|
595
|
+
if (waypoints != "none") and arc:
|
|
596
|
+
raise ValueError("Waypoint not supported for arc edges.")
|
|
492
597
|
|
|
493
598
|
if waypoints != "none":
|
|
494
599
|
return _compute_edge_path_waypoints(
|
|
@@ -506,6 +611,14 @@ def _compute_edge_path(
|
|
|
506
611
|
**kwargs,
|
|
507
612
|
)
|
|
508
613
|
|
|
614
|
+
if arc:
|
|
615
|
+
return _compute_edge_path_arc(
|
|
616
|
+
tension,
|
|
617
|
+
*args,
|
|
618
|
+
ports=ports,
|
|
619
|
+
**kwargs,
|
|
620
|
+
)
|
|
621
|
+
|
|
509
622
|
return _compute_edge_path_curved(
|
|
510
623
|
tension,
|
|
511
624
|
*args,
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
160
|
-
"""Get the
|
|
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(
|
|
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(
|
|
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
|
|
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,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
|
-
|
|
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
|
|
134
|
+
A dictionary with the layout.
|
|
177
135
|
"""
|
|
178
136
|
# Short form
|
|
179
137
|
th = start * np.pi / 180
|
|
@@ -0,0 +1,444 @@
|
|
|
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
|
+
if orientation in ("left", "counterclockwise"):
|
|
78
|
+
alpha = -alpha
|
|
79
|
+
beta = start + alpha / 2
|
|
80
|
+
|
|
81
|
+
props["layout"][child] = [
|
|
82
|
+
cur_x + branch_length_fun(child) * np.cos(np.radians(beta)),
|
|
83
|
+
cur_y + branch_length_fun(child) * np.sin(np.radians(beta)),
|
|
84
|
+
]
|
|
85
|
+
# props["angle"][child] = -90 - beta * np.sign(beta - 180)
|
|
86
|
+
props["start"][child] = start
|
|
87
|
+
props["end"][child] = start + alpha
|
|
88
|
+
start += alpha
|
|
89
|
+
|
|
90
|
+
# FIXME: figure out how to tell the caller about "angle"
|
|
91
|
+
return props["layout"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _daylight_tree_layout(
|
|
95
|
+
root: Any,
|
|
96
|
+
preorder_fun: Callable,
|
|
97
|
+
postorder_fun: Callable,
|
|
98
|
+
levelorder_fun: Callable,
|
|
99
|
+
children_fun: Callable,
|
|
100
|
+
branch_length_fun: Callable,
|
|
101
|
+
leaves_fun: Callable,
|
|
102
|
+
orientation: str = "right",
|
|
103
|
+
start: float = 180,
|
|
104
|
+
span: float = 360,
|
|
105
|
+
max_iter: int = 5,
|
|
106
|
+
dampening: float = 0.33,
|
|
107
|
+
max_correction: float = 10.0,
|
|
108
|
+
**kwargs,
|
|
109
|
+
) -> dict[Hashable, list[float]]:
|
|
110
|
+
"""Daylight unrooted tree layout.
|
|
111
|
+
|
|
112
|
+
Parameters:
|
|
113
|
+
orientation: Whether the layout fans out towards the right (clockwise) or left
|
|
114
|
+
(anticlockwise).
|
|
115
|
+
start: The starting angle in degrees, default is -180 (left).
|
|
116
|
+
span: The angular span in degrees, default is 360 (full circle). When this is
|
|
117
|
+
360, it leaves a small gap at the end to ensure the first and last leaf
|
|
118
|
+
are not overlapping.
|
|
119
|
+
max_iter: Maximum number of iterations to perform.
|
|
120
|
+
dampening: Dampening factor for angle adjustments. 1.0 means full adjustment.
|
|
121
|
+
The number must be strictily positive (usually between 0 excluded and 1
|
|
122
|
+
included).
|
|
123
|
+
Returns:
|
|
124
|
+
A dictionary with the layout.
|
|
125
|
+
|
|
126
|
+
Reference: "Inferring Phylogenies" by Joseph Felsenstein, ggtree.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
delta_angle_min = 9.0
|
|
130
|
+
|
|
131
|
+
layout = _equalangle_tree_layout(
|
|
132
|
+
root,
|
|
133
|
+
preorder_fun,
|
|
134
|
+
postorder_fun,
|
|
135
|
+
children_fun,
|
|
136
|
+
branch_length_fun,
|
|
137
|
+
leaves_fun,
|
|
138
|
+
orientation,
|
|
139
|
+
start,
|
|
140
|
+
span,
|
|
141
|
+
**kwargs,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if len(layout) <= 2:
|
|
145
|
+
return layout
|
|
146
|
+
|
|
147
|
+
# Make all arrays for easier manipulation
|
|
148
|
+
orig_class = next(iter(layout.values())).__class__
|
|
149
|
+
for key, value in layout.items():
|
|
150
|
+
layout[key] = np.asarray(value)
|
|
151
|
+
|
|
152
|
+
all_leaves = list(leaves_fun(root))
|
|
153
|
+
|
|
154
|
+
change_avg = 1.0
|
|
155
|
+
for it in range(max_iter):
|
|
156
|
+
change_sum = 0
|
|
157
|
+
ninternal = 0
|
|
158
|
+
parents = [None] + list(levelorder_fun())
|
|
159
|
+
for parent in parents:
|
|
160
|
+
if parent is None:
|
|
161
|
+
# If the root has only two children, it's a passthrough node, skip it
|
|
162
|
+
if len(children_fun(root)) < 3:
|
|
163
|
+
continue
|
|
164
|
+
# Else, include it
|
|
165
|
+
children = [root]
|
|
166
|
+
else:
|
|
167
|
+
children = children_fun(parent)
|
|
168
|
+
|
|
169
|
+
for node in children:
|
|
170
|
+
grandchildren = children_fun(node)
|
|
171
|
+
# Exclude leaves, since they have no children subtrees
|
|
172
|
+
# that can be adjusted. Exclude also passthrough nodes with
|
|
173
|
+
# a single child, because they are rotating rigidly when their
|
|
174
|
+
# parent does so or tells them to do so.
|
|
175
|
+
if len(grandchildren) < 2:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
res = _apply_daylight_single_node(
|
|
179
|
+
node,
|
|
180
|
+
parent,
|
|
181
|
+
grandchildren,
|
|
182
|
+
all_leaves,
|
|
183
|
+
layout,
|
|
184
|
+
leaves_fun,
|
|
185
|
+
children_fun,
|
|
186
|
+
dampening,
|
|
187
|
+
max_correction,
|
|
188
|
+
)
|
|
189
|
+
change_sum += res
|
|
190
|
+
ninternal += 1
|
|
191
|
+
|
|
192
|
+
change_avg = change_sum / ninternal
|
|
193
|
+
if change_avg < delta_angle_min:
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
# Make all lists again
|
|
197
|
+
for key, value in layout.items():
|
|
198
|
+
layout[key] = orig_class(value)
|
|
199
|
+
|
|
200
|
+
return layout
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _apply_daylight_single_node(
|
|
204
|
+
node: Any,
|
|
205
|
+
parent: Any,
|
|
206
|
+
children: list[Any],
|
|
207
|
+
all_leaves: list[Any],
|
|
208
|
+
layout: dict[Hashable, np.ndarray],
|
|
209
|
+
leaves_fun: Callable,
|
|
210
|
+
children_fun: Callable,
|
|
211
|
+
dampening: float,
|
|
212
|
+
max_correction: float,
|
|
213
|
+
) -> float:
|
|
214
|
+
"""Apply daylight adjustment to a single internal node.
|
|
215
|
+
|
|
216
|
+
Parameters:
|
|
217
|
+
node: The internal node to adjust.
|
|
218
|
+
Returns:
|
|
219
|
+
The total change in angle applied.
|
|
220
|
+
|
|
221
|
+
NOTE: The layout is also changed in place.
|
|
222
|
+
|
|
223
|
+
# Inspired from:
|
|
224
|
+
# https://github.com/thomasp85/ggraph/blob/6c4ce81e460c50a16f9cd97e0b3a089f36901316/src/unrooted.cpp#L122
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
import os
|
|
228
|
+
from builtins import print as _print
|
|
229
|
+
|
|
230
|
+
DEBUG_DAYLIGHT = os.getenv("IPLOTX_DEBUG_DAYLIGHT", "0") == "1"
|
|
231
|
+
|
|
232
|
+
print = _print if DEBUG_DAYLIGHT else lambda *a, **k: None
|
|
233
|
+
|
|
234
|
+
# 1. Find daylight boundary leaves for each subtree. There are always at least two subtrees,
|
|
235
|
+
# the first child and the parent (this function is not called for leaves, and the root hopefully
|
|
236
|
+
# has at least two children).
|
|
237
|
+
p0 = layout[node]
|
|
238
|
+
bounds = {}
|
|
239
|
+
|
|
240
|
+
print("node")
|
|
241
|
+
print(node)
|
|
242
|
+
print(float(p0[0]), float(p0[1]))
|
|
243
|
+
|
|
244
|
+
# To find the parent side leaves, we take all leaves and skip the ones
|
|
245
|
+
# downstream of this node
|
|
246
|
+
leaves_below = leaves_fun(node)
|
|
247
|
+
|
|
248
|
+
# Check the parent first if there is one
|
|
249
|
+
if parent is not None:
|
|
250
|
+
leaves_parent_subtree = [leaf for leaf in all_leaves if leaf not in leaves_below]
|
|
251
|
+
vec1 = layout[parent] - p0
|
|
252
|
+
print("parent side leaves:")
|
|
253
|
+
print(parent)
|
|
254
|
+
print(
|
|
255
|
+
f" node to parent vector: {vec1[0]:.2f}, {vec1[1]:.2f}, angle: {np.degrees(np.arctan2(vec1[1], vec1[0])):.2f}"
|
|
256
|
+
)
|
|
257
|
+
lower_angle, upper_angle = 2 * np.pi, -2 * np.pi
|
|
258
|
+
for leaf in leaves_parent_subtree:
|
|
259
|
+
vec2 = layout[leaf] - p0
|
|
260
|
+
angle = _anticlockwise_angle(vec1, vec2)
|
|
261
|
+
print(" parent side leaf:")
|
|
262
|
+
print(leaf)
|
|
263
|
+
print(
|
|
264
|
+
f" node to leaf vector: {vec2[0]:.2f}, {vec2[1]:.2f}, angle: {np.degrees(np.arctan2(vec2[1], vec2[0])):.2f}"
|
|
265
|
+
)
|
|
266
|
+
print(f" angle: {np.degrees(angle):.2f}")
|
|
267
|
+
if angle < lower_angle:
|
|
268
|
+
print("lowering lower angle")
|
|
269
|
+
lower_angle = angle
|
|
270
|
+
lower = leaf
|
|
271
|
+
else:
|
|
272
|
+
print("not lowering lower angle")
|
|
273
|
+
if angle > upper_angle:
|
|
274
|
+
print("raising upper angle")
|
|
275
|
+
upper_angle = angle
|
|
276
|
+
upper = leaf
|
|
277
|
+
else:
|
|
278
|
+
print("not raising upper angle")
|
|
279
|
+
bounds[parent] = (lower, upper, lower_angle, upper_angle)
|
|
280
|
+
|
|
281
|
+
# Repeat the exact same thing for each child rather than the parent
|
|
282
|
+
print("subtree leaves:")
|
|
283
|
+
for child in children:
|
|
284
|
+
vec1 = layout[child] - p0
|
|
285
|
+
print(
|
|
286
|
+
f" node to child vector: {vec1[0]:.2f}, {vec1[1]:.2f}, angle: {np.degrees(np.arctan2(vec1[1], vec1[0])):.2f}"
|
|
287
|
+
)
|
|
288
|
+
lower_angle, upper_angle = 2 * np.pi, -2 * np.pi
|
|
289
|
+
|
|
290
|
+
for leaf in leaves_fun(child):
|
|
291
|
+
vec2 = layout[leaf] - p0
|
|
292
|
+
angle = _anticlockwise_angle(vec1, vec2)
|
|
293
|
+
print(
|
|
294
|
+
f" node to leaf vector: {vec2[0]:.2f}, {vec2[1]:.2f}, angle: {np.degrees(np.arctan2(vec2[1], vec2[0])):.2f}"
|
|
295
|
+
)
|
|
296
|
+
print(leaf)
|
|
297
|
+
print(f" angle: {np.degrees(angle):.2f}")
|
|
298
|
+
if angle < lower_angle:
|
|
299
|
+
print("lowering lower angle")
|
|
300
|
+
lower_angle = angle
|
|
301
|
+
lower = leaf
|
|
302
|
+
else:
|
|
303
|
+
print("not lowering lower angle")
|
|
304
|
+
if angle > upper_angle:
|
|
305
|
+
print("raising upper angle")
|
|
306
|
+
upper_angle = angle
|
|
307
|
+
upper = leaf
|
|
308
|
+
else:
|
|
309
|
+
print("not raising upper angle")
|
|
310
|
+
bounds[child] = (lower, upper, lower_angle, upper_angle)
|
|
311
|
+
|
|
312
|
+
for subtree, bound in bounds.items():
|
|
313
|
+
vec1 = layout[bound[0]] - p0
|
|
314
|
+
vec2 = layout[bound[1]] - p0
|
|
315
|
+
angle = _anticlockwise_angle(vec1, vec2)
|
|
316
|
+
print("subtree angles:")
|
|
317
|
+
print(f" lower {np.degrees(np.arctan2(vec1[1], vec1[0])):.2f}")
|
|
318
|
+
print(f" upper {np.degrees(np.arctan2(vec2[1], vec2[0])):.2f}")
|
|
319
|
+
print(f" angle {np.degrees(angle):.2f}")
|
|
320
|
+
|
|
321
|
+
# 2. Compute daylight angles
|
|
322
|
+
# NOTE: Since Python 3.6, python keys are ordered by insertion order.
|
|
323
|
+
daylight = {}
|
|
324
|
+
subtrees = list(bounds.keys())
|
|
325
|
+
subtrees += [subtrees[0]] # Repeat first subtree
|
|
326
|
+
|
|
327
|
+
for i in range(len(subtrees) - 1):
|
|
328
|
+
subtree = subtrees[i + 1]
|
|
329
|
+
old_subtree = subtrees[i]
|
|
330
|
+
lower = bounds[subtree][0]
|
|
331
|
+
old_upper = bounds[old_subtree][1]
|
|
332
|
+
vec1 = layout[old_upper] - p0
|
|
333
|
+
vec2 = layout[lower] - p0
|
|
334
|
+
angle = _anticlockwise_angle(vec1, vec2)
|
|
335
|
+
daylight[subtree] = float(angle)
|
|
336
|
+
print("daylight angle:")
|
|
337
|
+
print(f" previous upper {np.degrees(np.arctan2(vec1[1], vec1[0])):.2f}")
|
|
338
|
+
print(f" new lower {np.degrees(np.arctan2(vec2[1], vec2[0])):.2f}")
|
|
339
|
+
print(f" angle: {np.degrees(angle):.2f}")
|
|
340
|
+
|
|
341
|
+
daylight_avg = sum(daylight.values()) / len(daylight)
|
|
342
|
+
print(f"daylight average angle: {np.degrees(daylight_avg):.2f}")
|
|
343
|
+
|
|
344
|
+
# 3. Compute *excess* daylight and corrections
|
|
345
|
+
daylight_correction = {}
|
|
346
|
+
corr_cum = 0.0
|
|
347
|
+
print("daylight correction:")
|
|
348
|
+
for subtree, angle in daylight.items():
|
|
349
|
+
# Correction is negative of the residue
|
|
350
|
+
corr_cum -= angle - daylight_avg
|
|
351
|
+
daylight_correction[subtree] = corr_cum
|
|
352
|
+
print(f" daylight angle: {np.degrees(angle):.2f}, correction: {np.degrees(corr_cum):.2f}")
|
|
353
|
+
|
|
354
|
+
# NOTE: the last daylight correction must be 0, otherwise we are just rotating the entire tree.
|
|
355
|
+
# In most cases, this will be the parent which cannot rotate anyway (for the same reason).
|
|
356
|
+
# However, when applied to the root node with 3+ children, the nonrotating one will be the first
|
|
357
|
+
# child, which is arbitrary but correct: even in this case, we do not want a merry-go-round.
|
|
358
|
+
|
|
359
|
+
# 4. Correct (the last one is dumb)
|
|
360
|
+
daylight_corrections_abs = 0.0
|
|
361
|
+
for subtree, correction in daylight_correction.items():
|
|
362
|
+
correction *= dampening
|
|
363
|
+
correction = np.clip(correction, np.radians(-max_correction), np.radians(max_correction))
|
|
364
|
+
print(f"Applying correction to subtree {subtree}: {np.degrees(correction):.2f}")
|
|
365
|
+
_rotate_subtree_anticlockwise(
|
|
366
|
+
leaf,
|
|
367
|
+
children_fun,
|
|
368
|
+
layout,
|
|
369
|
+
p0,
|
|
370
|
+
correction,
|
|
371
|
+
recur=True,
|
|
372
|
+
)
|
|
373
|
+
daylight_corrections_abs += abs(correction)
|
|
374
|
+
|
|
375
|
+
# __import__("ipdb").set_trace()
|
|
376
|
+
|
|
377
|
+
# Caller wants degrees
|
|
378
|
+
# NOTE: The denominator is -1 because the last correction is always zero anyway, so the
|
|
379
|
+
# actually possible corrections are #subtrees - 1.
|
|
380
|
+
return np.degrees(daylight_corrections_abs / (len(daylight_correction) - 1))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# see: https://stackoverflow.com/questions/14066933/direct-way-of-computing-the-clockwise-angle-between-two-vectors
|
|
384
|
+
def _anticlockwise_angle(v1, v2):
|
|
385
|
+
"""Compute the anticlockwise angle between two 2D vectors.
|
|
386
|
+
|
|
387
|
+
Parameters:
|
|
388
|
+
v1: First vector.
|
|
389
|
+
v2: Second vector.
|
|
390
|
+
Returns:
|
|
391
|
+
The angle in radians.
|
|
392
|
+
"""
|
|
393
|
+
dot = v1[0] * v2[0] + v1[1] * v2[1]
|
|
394
|
+
determinant = v1[0] * v2[1] - v1[1] * v2[0]
|
|
395
|
+
return np.arctan2(determinant, dot)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _rotate_subtree_anticlockwise(
|
|
399
|
+
node,
|
|
400
|
+
children_fun: Callable,
|
|
401
|
+
layout: dict[Hashable, list[float]],
|
|
402
|
+
pivot,
|
|
403
|
+
angle,
|
|
404
|
+
recur: bool = True,
|
|
405
|
+
):
|
|
406
|
+
point = np.asarray(layout[node])
|
|
407
|
+
pivot = np.asarray(pivot)
|
|
408
|
+
layout[node] = _rotate_anticlockwise(
|
|
409
|
+
point,
|
|
410
|
+
pivot,
|
|
411
|
+
angle,
|
|
412
|
+
)
|
|
413
|
+
if not recur:
|
|
414
|
+
return
|
|
415
|
+
for child in children_fun(node):
|
|
416
|
+
_rotate_subtree_anticlockwise(
|
|
417
|
+
child,
|
|
418
|
+
children_fun,
|
|
419
|
+
layout,
|
|
420
|
+
pivot,
|
|
421
|
+
angle,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _rotate_anticlockwise(
|
|
426
|
+
point,
|
|
427
|
+
pivot,
|
|
428
|
+
angle,
|
|
429
|
+
):
|
|
430
|
+
"""Rotate a point around a piviot by angle (in radians).
|
|
431
|
+
|
|
432
|
+
Parameters:
|
|
433
|
+
point: The point to rotate.
|
|
434
|
+
pivot: The piviot point.
|
|
435
|
+
angle: The angle in radians.
|
|
436
|
+
Returns:
|
|
437
|
+
The rotated point.
|
|
438
|
+
"""
|
|
439
|
+
point = np.asarray(point)
|
|
440
|
+
pivot = np.asarray(pivot)
|
|
441
|
+
cos = np.cos(angle)
|
|
442
|
+
sin = np.sin(angle)
|
|
443
|
+
rot = np.array([[cos, -sin], [sin, cos]])
|
|
444
|
+
return pivot + (point - pivot) @ rot
|
iplotx/style/leaf_info.py
CHANGED
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",
|
|
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
|
-
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iplotx
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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
|
-

|
|
40
|
-

|
|
41
|
-

|
|
39
|
+
[](https://github.com/fabilab/iplotx/actions/workflows/test.yml)
|
|
40
|
+
[](https://pypi.org/project/iplotx/)
|
|
41
|
+
[](https://iplotx.readthedocs.io/en/latest/)
|
|
42
42
|
[](https://coveralls.io/github/fabilab/iplotx?branch=main)
|
|
43
43
|

|
|
44
44
|
[](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=
|
|
6
|
+
iplotx/version.py,sha256=O8IErtvV5ZWlH0H4BksvQyiERBsQVCHqnwNyeNF_Nhk,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
|
-
iplotx/edge/__init__.py,sha256=
|
|
12
|
+
iplotx/edge/__init__.py,sha256=jH0qjRzlD7ZbiNziMv-rVOhiHPWtKc4WqBHRRbDQgvg,26795
|
|
14
13
|
iplotx/edge/arrow.py,sha256=ymup2YT_0GVYMtZw_DSKrZqFHG_ysYteEhmoL6T8Mu4,17563
|
|
15
|
-
iplotx/edge/geometry.py,sha256=
|
|
14
|
+
iplotx/edge/geometry.py,sha256=dczXCLCn5vlFm_SwXXh_gwf0h7irILSCdNIDKTuuqmA,19646
|
|
16
15
|
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=
|
|
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=
|
|
26
|
-
iplotx/ingest/providers/tree/cogent3.py,sha256=
|
|
27
|
-
iplotx/ingest/providers/tree/dendropy.py,sha256=
|
|
28
|
-
iplotx/ingest/providers/tree/ete4.py,sha256=
|
|
29
|
-
iplotx/ingest/providers/tree/simple.py,sha256
|
|
30
|
-
iplotx/ingest/providers/tree/skbio.py,sha256=
|
|
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=7iWLcQLwPsZgJpNlAnVKnEj-XI3twpYZhe4WztU9gEc,14668
|
|
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
|
-
iplotx/style/leaf_info.py,sha256=
|
|
37
|
+
iplotx/style/leaf_info.py,sha256=oBiOH-fHt_D2Nic2eYeE5nG5nLIE_0gSDNbLAYe5g2w,1029
|
|
35
38
|
iplotx/style/library.py,sha256=58Y8BlllGLsR4pQM7_PVCP5tH6_4GkchXZvJpqGHlcg,8534
|
|
36
|
-
iplotx/tree/__init__.py,sha256=
|
|
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.
|
|
44
|
-
iplotx-1.
|
|
45
|
-
iplotx-1.
|
|
46
|
+
iplotx-1.4.0.dist-info/METADATA,sha256=8mg2vInkk2LjL7kMr5jc9MXPsf35iAVBIOfOaWoqIsM,5407
|
|
47
|
+
iplotx-1.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
48
|
+
iplotx-1.4.0.dist-info/RECORD,,
|
|
File without changes
|