iplotx 0.1.0__py3-none-any.whl → 0.2.1__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,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,162 @@
1
+ """
2
+ Module for label collection in iplotx.
3
+ """
4
+
5
+ from typing import (
6
+ Optional,
7
+ Sequence,
8
+ )
9
+ import numpy as np
10
+ import matplotlib as mpl
11
+
12
+ from .style import (
13
+ rotate_style,
14
+ copy_with_deep_values,
15
+ )
16
+ from .utils.matplotlib import (
17
+ _stale_wrapper,
18
+ _forwarder,
19
+ )
20
+
21
+
22
+ @_forwarder(
23
+ (
24
+ "set_clip_path",
25
+ "set_clip_box",
26
+ "set_snap",
27
+ "set_sketch_params",
28
+ "set_animated",
29
+ "set_picker",
30
+ )
31
+ )
32
+ class LabelCollection(mpl.artist.Artist):
33
+ """Collection of labels for iplotx with styles.
34
+
35
+ NOTE: This class is not a subclass of `mpl.collections.Collection`, although in some ways items
36
+ behaves like one. It is named LabelCollection quite literally to indicate it contains a list of
37
+ labels for vertices, edges, etc.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ labels: Sequence[str],
43
+ style: Optional[dict[str, dict]] = None,
44
+ offsets: Optional[np.ndarray] = None,
45
+ transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
46
+ ) -> None:
47
+ """Initialize a collection of labels.
48
+
49
+ Parameters:
50
+ labels: A sequence of labels to be displayed.
51
+ style: A dictionary of styles to apply to the labels. The keys are style properties.
52
+ offsets: A sequence of offsets for each label, specifying the position of the label.
53
+ transform: A transform to apply to the labels. This is usually ax.transData.
54
+ """
55
+ self._labels = labels
56
+ self._offsets = offsets if offsets is not None else np.zeros((len(labels), 2))
57
+ self._style = style
58
+ super().__init__()
59
+
60
+ self.set_transform(transform)
61
+ self._create_artists()
62
+
63
+ def get_children(self) -> tuple[mpl.artist.Artist]:
64
+ """Get the children of this artist, which are the label artists."""
65
+ return tuple(self._labelartists)
66
+
67
+ def set_figure(self, fig) -> None:
68
+ """Set the figure of this artist.
69
+
70
+ Parameters:
71
+ fig: The figure to set.
72
+ """
73
+ super().set_figure(fig)
74
+ for child in self.get_children():
75
+ child.set_figure(fig)
76
+ self._update_offsets(dpi=fig.dpi)
77
+
78
+ def _get_margins_with_dpi(self, dpi: float = 72.0) -> np.ndarray:
79
+ return self._margins * dpi / 72.0
80
+
81
+ def _create_artists(self) -> None:
82
+ style = copy_with_deep_values(self._style) if self._style is not None else {}
83
+ transform = self.get_transform()
84
+
85
+ margins = []
86
+
87
+ forbidden_props = ["rotate"]
88
+ for prop in forbidden_props:
89
+ if prop in style:
90
+ del style[prop]
91
+
92
+ arts = []
93
+ for i, (anchor_id, label) in enumerate(self._labels.items()):
94
+ stylei = rotate_style(style, index=i, key=anchor_id)
95
+ # Margins are handled separately
96
+ hmargin = stylei.pop("hmargin", 0.0)
97
+ vmargin = stylei.pop("vmargin", 0.0)
98
+ margins.append((hmargin, vmargin))
99
+
100
+ art = mpl.text.Text(
101
+ self._offsets[i][0],
102
+ self._offsets[i][1],
103
+ label,
104
+ transform=transform,
105
+ **stylei,
106
+ )
107
+ arts.append(art)
108
+ self._labelartists = arts
109
+ self._margins = np.array(margins)
110
+
111
+ def _update_offsets(self, dpi: float = 72.0) -> None:
112
+ """Update offsets including margins."""
113
+ offsets = self._adjust_offsets_for_margins(self._offsets, dpi=dpi)
114
+ self.set_offsets(offsets)
115
+
116
+ def get_offsets(self) -> np.ndarray:
117
+ """Get the positions (offsets) of the labels."""
118
+ return self._offsets
119
+
120
+ def _adjust_offsets_for_margins(self, offsets, dpi=72.0):
121
+ margins = self._get_margins_with_dpi(dpi=dpi)
122
+ if (margins != 0).any():
123
+ transform = self.get_transform()
124
+ trans = transform.transform
125
+ trans_inv = transform.inverted().transform
126
+ offsets = trans_inv(trans(offsets) + margins)
127
+ return offsets
128
+
129
+ def set_offsets(self, offsets) -> None:
130
+ """Set positions (offsets) of the labels.
131
+
132
+ Parameters:
133
+ offsets: A sequence of offsets for each label, specifying the position of the label.
134
+ """
135
+ self._offsets = np.asarray(offsets)
136
+ for art, offset in zip(self._labelartists, self._offsets):
137
+ art.set_position((offset[0], offset[1]))
138
+
139
+ def set_rotations(self, rotations: Sequence[float]) -> None:
140
+ """Set the rotations of the labels.
141
+
142
+ Parameters:
143
+ rotations: A sequence of rotations in radians for each label.
144
+ """
145
+ for art, rotation in zip(self._labelartists, rotations):
146
+ rot_deg = 180.0 / np.pi * rotation
147
+ # Force the font size to be upwards
148
+ rot_deg = ((rot_deg + 90) % 180) - 90
149
+ art.set_rotation(rot_deg)
150
+
151
+ @_stale_wrapper
152
+ def draw(self, renderer) -> None:
153
+ """Draw each of the children, with some buffering mechanism."""
154
+ if not self.get_visible():
155
+ return
156
+
157
+ self._update_offsets(dpi=renderer.dpi)
158
+
159
+ # We should manage zorder ourselves, but we need to compute
160
+ # the new offsets and angles of arrows from the edges before drawing them
161
+ for art in self.get_children():
162
+ 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
22
+ "ascending".
23
+
24
+ Returns:
25
+ A layout dictionary with node positions.
26
+ """
27
+
28
+ if layout == "radial":
29
+ layout_dict = _circular_tree_layout(tree, orientation=orientation, **kwargs)
30
+ elif layout == "horizontal":
31
+ layout_dict = _horizontal_tree_layout(tree, orientation=orientation, **kwargs)
32
+ elif layout == "vertical":
33
+ layout_dict = _vertical_tree_layout(tree, orientation=orientation, **kwargs)
34
+ else:
35
+ raise ValueError(f"Tree layout not available: {layout}")
36
+
37
+ return layout_dict
38
+
39
+
40
+ def _horizontal_tree_layout_right(
41
+ tree,
42
+ root_fun: callable,
43
+ preorder_fun: callable,
44
+ postorder_fun: callable,
45
+ children_fun: callable,
46
+ branch_length_fun: callable,
47
+ ) -> dict[Hashable, list[float]]:
48
+ """Build a tree layout horizontally, left to right.
49
+
50
+ The strategy is the usual one:
51
+ 1. Compute the y values for the leaves, from 0 upwards.
52
+ 2. Compute the y values for the internal nodes, bubbling up (postorder).
53
+ 3. Set the x value for the root as 0.
54
+ 4. Compute the x value of all nodes, trickling down (BFS/preorder).
55
+ 5. Compute the edges from the end nodes.
56
+ """
57
+ layout = {}
58
+
59
+ # Set the y values for vertices
60
+ i = 0
61
+ for node in postorder_fun(tree):
62
+ children = children_fun(node)
63
+ if len(children) == 0:
64
+ layout[node] = [None, i]
65
+ i += 1
66
+ else:
67
+ layout[node] = [
68
+ None,
69
+ np.mean([layout[child][1] for child in children]),
70
+ ]
71
+
72
+ # Set the x values for vertices
73
+ layout[root_fun(tree)][0] = 0
74
+ for node in preorder_fun(tree):
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 in layout:
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