iplotx 0.0.1__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.
@@ -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
@@ -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