iplotx 0.1.0__py3-none-any.whl → 0.2.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/__init__.py +22 -1
- iplotx/edge/__init__.py +882 -0
- iplotx/edge/arrow.py +220 -10
- iplotx/edge/ports.py +42 -0
- iplotx/groups.py +79 -41
- iplotx/ingest/__init__.py +155 -0
- iplotx/ingest/heuristics.py +209 -0
- iplotx/ingest/providers/network/igraph.py +96 -0
- iplotx/ingest/providers/network/networkx.py +133 -0
- iplotx/ingest/providers/tree/biopython.py +105 -0
- iplotx/ingest/providers/tree/cogent3.py +112 -0
- iplotx/ingest/providers/tree/ete4.py +112 -0
- iplotx/ingest/providers/tree/skbio.py +112 -0
- iplotx/ingest/typing.py +100 -0
- iplotx/label.py +127 -0
- iplotx/layout.py +139 -0
- iplotx/network.py +156 -375
- iplotx/plotting.py +157 -56
- iplotx/style.py +379 -0
- iplotx/tree.py +285 -0
- iplotx/typing.py +33 -38
- iplotx/utils/geometry.py +128 -81
- iplotx/utils/internal.py +3 -0
- iplotx/utils/matplotlib.py +58 -38
- iplotx/utils/style.py +1 -0
- iplotx/version.py +5 -1
- iplotx/vertex.py +250 -55
- {iplotx-0.1.0.dist-info → iplotx-0.2.0.dist-info}/METADATA +37 -8
- iplotx-0.2.0.dist-info/RECORD +30 -0
- iplotx/edge/common.py +0 -47
- iplotx/edge/directed.py +0 -149
- iplotx/edge/label.py +0 -50
- iplotx/edge/undirected.py +0 -447
- iplotx/heuristics.py +0 -114
- iplotx/importing.py +0 -13
- iplotx/styles.py +0 -186
- iplotx-0.1.0.dist-info/RECORD +0 -20
- {iplotx-0.1.0.dist-info → iplotx-0.2.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from typing import (
|
|
2
|
+
Optional,
|
|
3
|
+
Sequence,
|
|
4
|
+
)
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
from operator import attrgetter
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from ....typing import (
|
|
11
|
+
TreeType,
|
|
12
|
+
LayoutType,
|
|
13
|
+
)
|
|
14
|
+
from ...typing import (
|
|
15
|
+
TreeDataProvider,
|
|
16
|
+
TreeData,
|
|
17
|
+
)
|
|
18
|
+
from ...heuristics import (
|
|
19
|
+
normalise_tree_layout,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Cogent3DataProvider(TreeDataProvider):
|
|
24
|
+
def __call__(
|
|
25
|
+
self,
|
|
26
|
+
tree: TreeType,
|
|
27
|
+
layout: str | LayoutType,
|
|
28
|
+
orientation: str = "horizontal",
|
|
29
|
+
directed: bool | str = False,
|
|
30
|
+
vertex_labels: Optional[
|
|
31
|
+
Sequence[str] | dict[Hashable, str] | pd.Series | bool
|
|
32
|
+
] = None,
|
|
33
|
+
edge_labels: Optional[Sequence[str] | dict] = None,
|
|
34
|
+
) -> TreeData:
|
|
35
|
+
"""Create tree data object for iplotx from cogent3.core.tree.PhyloNode classes."""
|
|
36
|
+
|
|
37
|
+
root_fun = lambda tree: tree.root()
|
|
38
|
+
preorder_fun = lambda tree: tree.preorder()
|
|
39
|
+
postorder_fun = lambda tree: tree.postorder()
|
|
40
|
+
children_fun = attrgetter("children")
|
|
41
|
+
branch_length_fun = attrgetter("length")
|
|
42
|
+
leaves_fun = lambda tree: tree.tips()
|
|
43
|
+
|
|
44
|
+
tree_data = {
|
|
45
|
+
"root": root_fun(tree),
|
|
46
|
+
"leaves": leaves_fun(tree),
|
|
47
|
+
"rooted": True,
|
|
48
|
+
"directed": directed,
|
|
49
|
+
"ndim": 2,
|
|
50
|
+
"layout_name": layout,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Add vertex_df including layout
|
|
54
|
+
tree_data["vertex_df"] = normalise_tree_layout(
|
|
55
|
+
layout,
|
|
56
|
+
tree=tree,
|
|
57
|
+
orientation=orientation,
|
|
58
|
+
root_fun=root_fun,
|
|
59
|
+
preorder_fun=preorder_fun,
|
|
60
|
+
postorder_fun=postorder_fun,
|
|
61
|
+
children_fun=children_fun,
|
|
62
|
+
branch_length_fun=branch_length_fun,
|
|
63
|
+
)
|
|
64
|
+
if layout in ("radial",):
|
|
65
|
+
tree_data["layout_coordinate_system"] = "polar"
|
|
66
|
+
else:
|
|
67
|
+
tree_data["layout_coordinate_system"] = "cartesian"
|
|
68
|
+
|
|
69
|
+
# Add edge_df
|
|
70
|
+
edge_data = {"_ipx_source": [], "_ipx_target": []}
|
|
71
|
+
for node in preorder_fun(tree):
|
|
72
|
+
for child in node.children:
|
|
73
|
+
if directed == "parent":
|
|
74
|
+
edge_data["_ipx_source"].append(child)
|
|
75
|
+
edge_data["_ipx_target"].append(node)
|
|
76
|
+
else:
|
|
77
|
+
edge_data["_ipx_source"].append(node)
|
|
78
|
+
edge_data["_ipx_target"].append(child)
|
|
79
|
+
edge_df = pd.DataFrame(edge_data)
|
|
80
|
+
tree_data["edge_df"] = edge_df
|
|
81
|
+
|
|
82
|
+
# Add vertex labels
|
|
83
|
+
if vertex_labels is None:
|
|
84
|
+
vertex_labels = False
|
|
85
|
+
if np.isscalar(vertex_labels) and vertex_labels:
|
|
86
|
+
tree_data["vertex_df"]["label"] = [
|
|
87
|
+
x.name for x in tree_data["vertices"].index
|
|
88
|
+
]
|
|
89
|
+
elif not np.isscalar(vertex_labels):
|
|
90
|
+
# If a dict-like object is passed, it can be incomplete (e.g. only the leaves):
|
|
91
|
+
# we fill the rest with empty strings which are not going to show up in the plot.
|
|
92
|
+
if isinstance(vertex_labels, pd.Series):
|
|
93
|
+
vertex_labels = dict(vertex_labels)
|
|
94
|
+
if isinstance(vertex_labels, dict):
|
|
95
|
+
for vertex in tree_data["vertex_df"].index:
|
|
96
|
+
if vertex not in vertex_labels:
|
|
97
|
+
vertex_labels[vertex] = ""
|
|
98
|
+
tree_data["vertex_df"]["label"] = pd.Series(vertex_labels)
|
|
99
|
+
|
|
100
|
+
return tree_data
|
|
101
|
+
|
|
102
|
+
def check_dependencies(self) -> bool:
|
|
103
|
+
try:
|
|
104
|
+
import cogent3
|
|
105
|
+
except ImportError:
|
|
106
|
+
return False
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def tree_type(self):
|
|
110
|
+
from cogent3.core.tree import PhyloNode
|
|
111
|
+
|
|
112
|
+
return PhyloNode
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from typing import (
|
|
2
|
+
Optional,
|
|
3
|
+
Sequence,
|
|
4
|
+
)
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
from operator import attrgetter
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from ....typing import (
|
|
11
|
+
TreeType,
|
|
12
|
+
LayoutType,
|
|
13
|
+
)
|
|
14
|
+
from ...typing import (
|
|
15
|
+
TreeDataProvider,
|
|
16
|
+
TreeData,
|
|
17
|
+
)
|
|
18
|
+
from ...heuristics import (
|
|
19
|
+
normalise_tree_layout,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Ete4DataProvider(TreeDataProvider):
|
|
24
|
+
def __call__(
|
|
25
|
+
self,
|
|
26
|
+
tree: TreeType,
|
|
27
|
+
layout: str | LayoutType,
|
|
28
|
+
orientation: str = "horizontal",
|
|
29
|
+
directed: bool | str = False,
|
|
30
|
+
vertex_labels: Optional[
|
|
31
|
+
Sequence[str] | dict[Hashable, str] | pd.Series | bool
|
|
32
|
+
] = None,
|
|
33
|
+
edge_labels: Optional[Sequence[str] | dict] = None,
|
|
34
|
+
) -> TreeData:
|
|
35
|
+
"""Create tree data object for iplotx from ete4.core.tre.Tree classes."""
|
|
36
|
+
|
|
37
|
+
root_fun = attrgetter("root")
|
|
38
|
+
preorder_fun = lambda tree: tree.traverse("preorder")
|
|
39
|
+
postorder_fun = lambda tree: tree.traverse("postorder")
|
|
40
|
+
children_fun = attrgetter("children")
|
|
41
|
+
branch_length_fun = lambda node: node.dist if node.dist is not None else 1.0
|
|
42
|
+
leaves_fun = lambda tree: tree.leaves()
|
|
43
|
+
|
|
44
|
+
tree_data = {
|
|
45
|
+
"root": tree.root,
|
|
46
|
+
"leaves": leaves_fun(tree),
|
|
47
|
+
"rooted": True,
|
|
48
|
+
"directed": directed,
|
|
49
|
+
"ndim": 2,
|
|
50
|
+
"layout_name": layout,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Add vertex_df including layout
|
|
54
|
+
tree_data["vertex_df"] = normalise_tree_layout(
|
|
55
|
+
layout,
|
|
56
|
+
tree=tree,
|
|
57
|
+
orientation=orientation,
|
|
58
|
+
root_fun=root_fun,
|
|
59
|
+
preorder_fun=preorder_fun,
|
|
60
|
+
postorder_fun=postorder_fun,
|
|
61
|
+
children_fun=children_fun,
|
|
62
|
+
branch_length_fun=branch_length_fun,
|
|
63
|
+
)
|
|
64
|
+
if layout in ("radial",):
|
|
65
|
+
tree_data["layout_coordinate_system"] = "polar"
|
|
66
|
+
else:
|
|
67
|
+
tree_data["layout_coordinate_system"] = "cartesian"
|
|
68
|
+
|
|
69
|
+
# Add edge_df
|
|
70
|
+
edge_data = {"_ipx_source": [], "_ipx_target": []}
|
|
71
|
+
for node in preorder_fun(tree):
|
|
72
|
+
for child in children_fun(node):
|
|
73
|
+
if directed == "parent":
|
|
74
|
+
edge_data["_ipx_source"].append(child)
|
|
75
|
+
edge_data["_ipx_target"].append(node)
|
|
76
|
+
else:
|
|
77
|
+
edge_data["_ipx_source"].append(node)
|
|
78
|
+
edge_data["_ipx_target"].append(child)
|
|
79
|
+
edge_df = pd.DataFrame(edge_data)
|
|
80
|
+
tree_data["edge_df"] = edge_df
|
|
81
|
+
|
|
82
|
+
# Add vertex labels
|
|
83
|
+
if vertex_labels is None:
|
|
84
|
+
vertex_labels = False
|
|
85
|
+
if np.isscalar(vertex_labels) and vertex_labels:
|
|
86
|
+
tree_data["vertex_df"]["label"] = [
|
|
87
|
+
x.name for x in tree_data["vertices"].index
|
|
88
|
+
]
|
|
89
|
+
elif not np.isscalar(vertex_labels):
|
|
90
|
+
# If a dict-like object is passed, it can be incomplete (e.g. only the leaves):
|
|
91
|
+
# we fill the rest with empty strings which are not going to show up in the plot.
|
|
92
|
+
if isinstance(vertex_labels, pd.Series):
|
|
93
|
+
vertex_labels = dict(vertex_labels)
|
|
94
|
+
if isinstance(vertex_labels, dict):
|
|
95
|
+
for vertex in tree_data["vertex_df"].index:
|
|
96
|
+
if vertex not in vertex_labels:
|
|
97
|
+
vertex_labels[vertex] = ""
|
|
98
|
+
tree_data["vertex_df"]["label"] = pd.Series(vertex_labels)
|
|
99
|
+
|
|
100
|
+
return tree_data
|
|
101
|
+
|
|
102
|
+
def check_dependencies(self) -> bool:
|
|
103
|
+
try:
|
|
104
|
+
from ete4 import Tree
|
|
105
|
+
except ImportError:
|
|
106
|
+
return False
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def tree_type(self):
|
|
110
|
+
from ete4 import Tree
|
|
111
|
+
|
|
112
|
+
return Tree
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from typing import (
|
|
2
|
+
Optional,
|
|
3
|
+
Sequence,
|
|
4
|
+
)
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
from operator import attrgetter
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from ....typing import (
|
|
11
|
+
TreeType,
|
|
12
|
+
LayoutType,
|
|
13
|
+
)
|
|
14
|
+
from ...typing import (
|
|
15
|
+
TreeDataProvider,
|
|
16
|
+
TreeData,
|
|
17
|
+
)
|
|
18
|
+
from ...heuristics import (
|
|
19
|
+
normalise_tree_layout,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SkbioDataProvider(TreeDataProvider):
|
|
24
|
+
def __call__(
|
|
25
|
+
self,
|
|
26
|
+
tree: TreeType,
|
|
27
|
+
layout: str | LayoutType,
|
|
28
|
+
orientation: str = "horizontal",
|
|
29
|
+
directed: bool | str = False,
|
|
30
|
+
vertex_labels: Optional[
|
|
31
|
+
Sequence[str] | dict[Hashable, str] | pd.Series | bool
|
|
32
|
+
] = None,
|
|
33
|
+
edge_labels: Optional[Sequence[str] | dict] = None,
|
|
34
|
+
) -> TreeData:
|
|
35
|
+
"""Create tree data object for iplotx from skbio.tree.TreeNode classes."""
|
|
36
|
+
|
|
37
|
+
root_fun = lambda tree: tree.root()
|
|
38
|
+
preorder_fun = lambda tree: tree.preorder()
|
|
39
|
+
postorder_fun = lambda tree: tree.postorder()
|
|
40
|
+
children_fun = attrgetter("children")
|
|
41
|
+
branch_length_fun = attrgetter("length")
|
|
42
|
+
leaves_fun = lambda tree: tree.tips()
|
|
43
|
+
|
|
44
|
+
tree_data = {
|
|
45
|
+
"root": root_fun(tree),
|
|
46
|
+
"leaves": leaves_fun(tree),
|
|
47
|
+
"rooted": True,
|
|
48
|
+
"directed": directed,
|
|
49
|
+
"ndim": 2,
|
|
50
|
+
"layout_name": layout,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Add vertex_df including layout
|
|
54
|
+
tree_data["vertex_df"] = normalise_tree_layout(
|
|
55
|
+
layout,
|
|
56
|
+
tree=tree,
|
|
57
|
+
orientation=orientation,
|
|
58
|
+
root_fun=root_fun,
|
|
59
|
+
preorder_fun=preorder_fun,
|
|
60
|
+
postorder_fun=postorder_fun,
|
|
61
|
+
children_fun=children_fun,
|
|
62
|
+
branch_length_fun=branch_length_fun,
|
|
63
|
+
)
|
|
64
|
+
if layout in ("radial",):
|
|
65
|
+
tree_data["layout_coordinate_system"] = "polar"
|
|
66
|
+
else:
|
|
67
|
+
tree_data["layout_coordinate_system"] = "cartesian"
|
|
68
|
+
|
|
69
|
+
# Add edge_df
|
|
70
|
+
edge_data = {"_ipx_source": [], "_ipx_target": []}
|
|
71
|
+
for node in preorder_fun(tree):
|
|
72
|
+
for child in children_fun(node):
|
|
73
|
+
if directed == "parent":
|
|
74
|
+
edge_data["_ipx_source"].append(child)
|
|
75
|
+
edge_data["_ipx_target"].append(node)
|
|
76
|
+
else:
|
|
77
|
+
edge_data["_ipx_source"].append(node)
|
|
78
|
+
edge_data["_ipx_target"].append(child)
|
|
79
|
+
edge_df = pd.DataFrame(edge_data)
|
|
80
|
+
tree_data["edge_df"] = edge_df
|
|
81
|
+
|
|
82
|
+
# Add vertex labels
|
|
83
|
+
if vertex_labels is None:
|
|
84
|
+
vertex_labels = False
|
|
85
|
+
if np.isscalar(vertex_labels) and vertex_labels:
|
|
86
|
+
tree_data["vertex_df"]["label"] = [
|
|
87
|
+
x.name for x in tree_data["vertices"].index
|
|
88
|
+
]
|
|
89
|
+
elif not np.isscalar(vertex_labels):
|
|
90
|
+
# If a dict-like object is passed, it can be incomplete (e.g. only the leaves):
|
|
91
|
+
# we fill the rest with empty strings which are not going to show up in the plot.
|
|
92
|
+
if isinstance(vertex_labels, pd.Series):
|
|
93
|
+
vertex_labels = dict(vertex_labels)
|
|
94
|
+
if isinstance(vertex_labels, dict):
|
|
95
|
+
for vertex in tree_data["vertex_df"].index:
|
|
96
|
+
if vertex not in vertex_labels:
|
|
97
|
+
vertex_labels[vertex] = ""
|
|
98
|
+
tree_data["vertex_df"]["label"] = pd.Series(vertex_labels)
|
|
99
|
+
|
|
100
|
+
return tree_data
|
|
101
|
+
|
|
102
|
+
def check_dependencies(self) -> bool:
|
|
103
|
+
try:
|
|
104
|
+
from skbio import TreeNode
|
|
105
|
+
except ImportError:
|
|
106
|
+
return False
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def tree_type(self):
|
|
110
|
+
from skbio import TreeNode
|
|
111
|
+
|
|
112
|
+
return TreeNode
|
iplotx/ingest/typing.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typing module for data/object ingestion. This module described the abstract data types that providers need to comply with to be compatible with iplotx.
|
|
3
|
+
|
|
4
|
+
Networkx and trees are treated separately for practical reasons: many tree analysis libraries rely heavily on recursive data structures, which do not
|
|
5
|
+
work as well on general networks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import (
|
|
9
|
+
NotRequired,
|
|
10
|
+
TypedDict,
|
|
11
|
+
Protocol,
|
|
12
|
+
Optional,
|
|
13
|
+
Sequence,
|
|
14
|
+
)
|
|
15
|
+
from collections.abc import Hashable
|
|
16
|
+
import pandas as pd
|
|
17
|
+
from ..typing import (
|
|
18
|
+
GraphType,
|
|
19
|
+
LayoutType,
|
|
20
|
+
TreeType,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NetworkData(TypedDict):
|
|
25
|
+
"""Network data structure for iplotx."""
|
|
26
|
+
|
|
27
|
+
directed: bool
|
|
28
|
+
vertex_df: pd.DataFrame
|
|
29
|
+
edge_df: pd.DataFrame
|
|
30
|
+
ndim: int
|
|
31
|
+
network_library: NotRequired[str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NetworkDataProvider(Protocol):
|
|
35
|
+
"""Protocol for network data ingestion provider for iplotx."""
|
|
36
|
+
|
|
37
|
+
def __call__(
|
|
38
|
+
self,
|
|
39
|
+
network: GraphType,
|
|
40
|
+
layout: Optional[LayoutType] = None,
|
|
41
|
+
vertex_labels: Optional[Sequence[str] | dict[Hashable, str] | pd.Series] = None,
|
|
42
|
+
edge_labels: Optional[Sequence[str] | dict] = None,
|
|
43
|
+
) -> NetworkData:
|
|
44
|
+
"""Create network data object for iplotx from any provider."""
|
|
45
|
+
raise NotImplementedError("Network data providers must implement this method.")
|
|
46
|
+
|
|
47
|
+
def check_dependencies(
|
|
48
|
+
self,
|
|
49
|
+
):
|
|
50
|
+
"""Check whether the dependencies for this provider are installed."""
|
|
51
|
+
raise NotImplementedError("Network data providers must implement this method.")
|
|
52
|
+
|
|
53
|
+
def graph_type(
|
|
54
|
+
self,
|
|
55
|
+
):
|
|
56
|
+
"""Return the graph type from this provider to check for instances."""
|
|
57
|
+
raise NotImplementedError("Network data providers must implement this method.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TreeData(TypedDict):
|
|
61
|
+
"""Tree data structure for iplotx."""
|
|
62
|
+
|
|
63
|
+
rooted: bool
|
|
64
|
+
directed: bool | str
|
|
65
|
+
root: Optional[Hashable]
|
|
66
|
+
leaves: list[Hashable]
|
|
67
|
+
vertex_df: dict[Hashable, tuple[float, float]]
|
|
68
|
+
edge_df: dict[Hashable, Sequence[tuple[float, float]]]
|
|
69
|
+
layout_coordinate_system: str
|
|
70
|
+
layout_name: str
|
|
71
|
+
ndim: int
|
|
72
|
+
tree_library: NotRequired[str]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TreeDataProvider(Protocol):
|
|
76
|
+
"""Protocol for tree data ingestion provider for iplotx."""
|
|
77
|
+
|
|
78
|
+
def __call__(
|
|
79
|
+
self,
|
|
80
|
+
tree: TreeType,
|
|
81
|
+
layout: str | LayoutType,
|
|
82
|
+
orientation: Optional[str] = None,
|
|
83
|
+
directed: bool | str = False,
|
|
84
|
+
vertex_labels: Optional[Sequence[str] | dict[Hashable, str] | pd.Series] = None,
|
|
85
|
+
edge_labels: Optional[Sequence[str] | dict] = None,
|
|
86
|
+
) -> TreeData:
|
|
87
|
+
"""Create tree data object for iplotx from any provider."""
|
|
88
|
+
raise NotImplementedError("Tree data providers must implement this method.")
|
|
89
|
+
|
|
90
|
+
def check_dependencies(
|
|
91
|
+
self,
|
|
92
|
+
):
|
|
93
|
+
"""Check whether the dependencies for this provider are installed."""
|
|
94
|
+
raise NotImplementedError("Tree data providers must implement this method.")
|
|
95
|
+
|
|
96
|
+
def tree_type(
|
|
97
|
+
self,
|
|
98
|
+
):
|
|
99
|
+
"""Return the tree type from this provider to check for instances."""
|
|
100
|
+
raise NotImplementedError("Tree data providers must implement this method.")
|
iplotx/label.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from typing import (
|
|
2
|
+
Optional,
|
|
3
|
+
Sequence,
|
|
4
|
+
)
|
|
5
|
+
import numpy as np
|
|
6
|
+
import matplotlib as mpl
|
|
7
|
+
|
|
8
|
+
from .style import (
|
|
9
|
+
rotate_style,
|
|
10
|
+
copy_with_deep_values,
|
|
11
|
+
)
|
|
12
|
+
from .utils.matplotlib import (
|
|
13
|
+
_stale_wrapper,
|
|
14
|
+
_forwarder,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@_forwarder(
|
|
19
|
+
(
|
|
20
|
+
"set_clip_path",
|
|
21
|
+
"set_clip_box",
|
|
22
|
+
"set_snap",
|
|
23
|
+
"set_sketch_params",
|
|
24
|
+
"set_animated",
|
|
25
|
+
"set_picker",
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
class LabelCollection(mpl.artist.Artist):
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
labels: Sequence[str],
|
|
32
|
+
style: Optional[dict[str, dict]] = None,
|
|
33
|
+
offsets: Optional[np.ndarray] = None,
|
|
34
|
+
transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
|
|
35
|
+
):
|
|
36
|
+
self._labels = labels
|
|
37
|
+
self._offsets = offsets if offsets is not None else np.zeros((len(labels), 2))
|
|
38
|
+
self._style = style
|
|
39
|
+
super().__init__()
|
|
40
|
+
|
|
41
|
+
self.set_transform(transform)
|
|
42
|
+
self._create_artists()
|
|
43
|
+
|
|
44
|
+
def get_children(self):
|
|
45
|
+
return tuple(self._labelartists)
|
|
46
|
+
|
|
47
|
+
def set_figure(self, figure):
|
|
48
|
+
super().set_figure(figure)
|
|
49
|
+
for child in self.get_children():
|
|
50
|
+
child.set_figure(figure)
|
|
51
|
+
self._update_offsets(dpi=figure.dpi)
|
|
52
|
+
|
|
53
|
+
def _get_margins_with_dpi(self, dpi=72.0):
|
|
54
|
+
return self._margins * dpi / 72.0
|
|
55
|
+
|
|
56
|
+
def _create_artists(self):
|
|
57
|
+
style = copy_with_deep_values(self._style) if self._style is not None else {}
|
|
58
|
+
transform = self.get_transform()
|
|
59
|
+
|
|
60
|
+
margins = []
|
|
61
|
+
|
|
62
|
+
forbidden_props = ["rotate"]
|
|
63
|
+
for prop in forbidden_props:
|
|
64
|
+
if prop in style:
|
|
65
|
+
del style[prop]
|
|
66
|
+
|
|
67
|
+
arts = []
|
|
68
|
+
for i, (anchor_id, label) in enumerate(self._labels.items()):
|
|
69
|
+
stylei = rotate_style(style, index=i, key=anchor_id)
|
|
70
|
+
# Margins are handled separately
|
|
71
|
+
hmargin = stylei.pop("hmargin", 0.0)
|
|
72
|
+
vmargin = stylei.pop("vmargin", 0.0)
|
|
73
|
+
margins.append((hmargin, vmargin))
|
|
74
|
+
|
|
75
|
+
art = mpl.text.Text(
|
|
76
|
+
self._offsets[i][0],
|
|
77
|
+
self._offsets[i][1],
|
|
78
|
+
label,
|
|
79
|
+
transform=transform,
|
|
80
|
+
**stylei,
|
|
81
|
+
)
|
|
82
|
+
arts.append(art)
|
|
83
|
+
self._labelartists = arts
|
|
84
|
+
self._margins = np.array(margins)
|
|
85
|
+
|
|
86
|
+
def _update_offsets(self, dpi=72.0):
|
|
87
|
+
"""Update offsets including margins."""
|
|
88
|
+
offsets = self._adjust_offsets_for_margins(self._offsets, dpi=dpi)
|
|
89
|
+
self.set_offsets(offsets)
|
|
90
|
+
|
|
91
|
+
def get_offsets(self):
|
|
92
|
+
return self._offsets
|
|
93
|
+
|
|
94
|
+
def _adjust_offsets_for_margins(self, offsets, dpi=72.0):
|
|
95
|
+
margins = self._get_margins_with_dpi(dpi=dpi)
|
|
96
|
+
if (margins != 0).any():
|
|
97
|
+
transform = self.get_transform()
|
|
98
|
+
trans = transform.transform
|
|
99
|
+
trans_inv = transform.inverted().transform
|
|
100
|
+
offsets = trans_inv(trans(offsets) + margins)
|
|
101
|
+
return offsets
|
|
102
|
+
|
|
103
|
+
def set_offsets(self, offsets):
|
|
104
|
+
"""Set positions (offsets) of the labels."""
|
|
105
|
+
self._offsets = np.asarray(offsets)
|
|
106
|
+
for art, offset in zip(self._labelartists, self._offsets):
|
|
107
|
+
art.set_position((offset[0], offset[1]))
|
|
108
|
+
|
|
109
|
+
def set_rotations(self, rotations):
|
|
110
|
+
for art, rotation in zip(self._labelartists, rotations):
|
|
111
|
+
rot_deg = 180.0 / np.pi * rotation
|
|
112
|
+
# Force the font size to be upwards
|
|
113
|
+
rot_deg = ((rot_deg + 90) % 180) - 90
|
|
114
|
+
art.set_rotation(rot_deg)
|
|
115
|
+
|
|
116
|
+
@_stale_wrapper
|
|
117
|
+
def draw(self, renderer):
|
|
118
|
+
"""Draw each of the children, with some buffering mechanism."""
|
|
119
|
+
if not self.get_visible():
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
self._update_offsets(dpi=renderer.dpi)
|
|
123
|
+
|
|
124
|
+
# We should manage zorder ourselves, but we need to compute
|
|
125
|
+
# the new offsets and angles of arrows from the edges before drawing them
|
|
126
|
+
for art in self.get_children():
|
|
127
|
+
art.draw(renderer)
|
iplotx/layout.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Layout functions, currently limited to trees.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def compute_tree_layout(
|
|
11
|
+
tree,
|
|
12
|
+
layout: str,
|
|
13
|
+
orientation: str,
|
|
14
|
+
**kwargs,
|
|
15
|
+
) -> dict[Hashable, list[float]]:
|
|
16
|
+
"""Compute the layout for a tree.
|
|
17
|
+
|
|
18
|
+
Parameters:
|
|
19
|
+
tree: The tree to compute the layout for.
|
|
20
|
+
layout: The name of the layout, e.g. "horizontal" or "radial".
|
|
21
|
+
orientation: The orientation of the layout, e.g. "right", "left", "descending", or "ascending".
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A layout dictionary with node positions.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
if layout == "radial":
|
|
28
|
+
layout_dict = _circular_tree_layout(tree, orientation=orientation, **kwargs)
|
|
29
|
+
elif layout == "horizontal":
|
|
30
|
+
layout_dict = _horizontal_tree_layout(tree, orientation=orientation, **kwargs)
|
|
31
|
+
elif layout == "vertical":
|
|
32
|
+
layout_dict = _vertical_tree_layout(tree, orientation=orientation, **kwargs)
|
|
33
|
+
else:
|
|
34
|
+
raise ValueError(f"Tree layout not available: {layout}")
|
|
35
|
+
|
|
36
|
+
return layout_dict
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _horizontal_tree_layout_right(
|
|
40
|
+
tree,
|
|
41
|
+
root_fun: callable,
|
|
42
|
+
preorder_fun: callable,
|
|
43
|
+
postorder_fun: callable,
|
|
44
|
+
children_fun: callable,
|
|
45
|
+
branch_length_fun: callable,
|
|
46
|
+
) -> dict[Hashable, list[float]]:
|
|
47
|
+
"""Build a tree layout horizontally, left to right.
|
|
48
|
+
|
|
49
|
+
The strategy is the usual one:
|
|
50
|
+
1. Compute the y values for the leaves, from 0 upwards.
|
|
51
|
+
2. Compute the y values for the internal nodes, bubbling up (postorder).
|
|
52
|
+
3. Set the x value for the root as 0.
|
|
53
|
+
4. Compute the x value of all nodes, trickling down (BFS/preorder).
|
|
54
|
+
5. Compute the edges from the end nodes.
|
|
55
|
+
"""
|
|
56
|
+
layout = {}
|
|
57
|
+
|
|
58
|
+
# Set the y values for vertices
|
|
59
|
+
i = 0
|
|
60
|
+
for node in postorder_fun(tree):
|
|
61
|
+
children = children_fun(node)
|
|
62
|
+
if len(children) == 0:
|
|
63
|
+
layout[node] = [None, i]
|
|
64
|
+
i += 1
|
|
65
|
+
else:
|
|
66
|
+
layout[node] = [
|
|
67
|
+
None,
|
|
68
|
+
np.mean([layout[child][1] for child in children]),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# Set the x values for vertices
|
|
72
|
+
layout[root_fun(tree)][0] = 0
|
|
73
|
+
for node in preorder_fun(tree):
|
|
74
|
+
x0, y0 = layout[node]
|
|
75
|
+
for child in children_fun(node):
|
|
76
|
+
bl = branch_length_fun(child)
|
|
77
|
+
if bl is None:
|
|
78
|
+
bl = 1.0
|
|
79
|
+
layout[child][0] = layout[node][0] + bl
|
|
80
|
+
|
|
81
|
+
return layout
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _horizontal_tree_layout(
|
|
85
|
+
tree,
|
|
86
|
+
orientation="right",
|
|
87
|
+
**kwargs,
|
|
88
|
+
) -> dict[Hashable, list[float]]:
|
|
89
|
+
"""Horizontal tree layout."""
|
|
90
|
+
if orientation not in ("right", "left"):
|
|
91
|
+
raise ValueError("Orientation must be 'right' or 'left'.")
|
|
92
|
+
|
|
93
|
+
layout = _horizontal_tree_layout_right(tree, **kwargs)
|
|
94
|
+
|
|
95
|
+
if orientation == "left":
|
|
96
|
+
for key, value in layout.items():
|
|
97
|
+
layout[key][0] *= -1
|
|
98
|
+
return layout
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _vertical_tree_layout(
|
|
102
|
+
tree,
|
|
103
|
+
orientation="descending",
|
|
104
|
+
**kwargs,
|
|
105
|
+
) -> dict[Hashable, list[float]]:
|
|
106
|
+
"""Vertical tree layout."""
|
|
107
|
+
sign = 1 if orientation == "descending" else -1
|
|
108
|
+
layout = _horizontal_tree_layout(tree, **kwargs)
|
|
109
|
+
for key, value in layout.items():
|
|
110
|
+
# Invert x and y
|
|
111
|
+
layout[key] = value[::-1]
|
|
112
|
+
# Orient vertically
|
|
113
|
+
layout[key][1] *= sign
|
|
114
|
+
return layout
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _circular_tree_layout(
|
|
118
|
+
tree,
|
|
119
|
+
orientation="right",
|
|
120
|
+
starting_angle=0,
|
|
121
|
+
angular_span=360,
|
|
122
|
+
**kwargs,
|
|
123
|
+
) -> dict[Hashable, list[float]]:
|
|
124
|
+
"""Circular tree layout."""
|
|
125
|
+
# Short form
|
|
126
|
+
th = starting_angle * np.pi / 180
|
|
127
|
+
th_span = angular_span * np.pi / 180
|
|
128
|
+
sign = 1 if orientation == "right" else -1
|
|
129
|
+
|
|
130
|
+
layout = _horizontal_tree_layout_right(tree, **kwargs)
|
|
131
|
+
ymax = max(point[1] for point in layout.values())
|
|
132
|
+
for key, (x, y) in layout.items():
|
|
133
|
+
r = x
|
|
134
|
+
theta = sign * th_span * y / (ymax + 1) + th
|
|
135
|
+
# We export r and theta to ensure theta does not
|
|
136
|
+
# modulo 2pi if we take the tan and then arctan later.
|
|
137
|
+
layout[key] = (r, theta)
|
|
138
|
+
|
|
139
|
+
return layout
|