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.
- iplotx/__init__.py +22 -1
- iplotx/edge/__init__.py +623 -0
- iplotx/edge/arrow.py +220 -10
- iplotx/edge/geometry.py +392 -0
- iplotx/edge/ports.py +47 -0
- iplotx/groups.py +93 -45
- 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 +162 -0
- iplotx/layout.py +139 -0
- iplotx/network.py +161 -379
- iplotx/plotting.py +157 -56
- iplotx/style.py +391 -0
- iplotx/tree.py +312 -0
- iplotx/typing.py +55 -41
- 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 +305 -55
- iplotx-0.2.1.dist-info/METADATA +88 -0
- iplotx-0.2.1.dist-info/RECORD +31 -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/METADATA +0 -47
- iplotx-0.1.0.dist-info/RECORD +0 -20
- {iplotx-0.1.0.dist-info → iplotx-0.2.1.dist-info}/WHEEL +0 -0
iplotx/utils/matplotlib.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from functools import wraps, partial
|
|
2
2
|
from math import atan2
|
|
3
|
+
import numpy as np
|
|
3
4
|
import matplotlib as mpl
|
|
4
5
|
|
|
5
6
|
from .geometry import (
|
|
@@ -29,6 +30,7 @@ def _forwarder(forwards, cls=None):
|
|
|
29
30
|
|
|
30
31
|
def make_forward(name):
|
|
31
32
|
def method(self, *args, **kwargs):
|
|
33
|
+
"""Each decorated method is called on the decorated class and then, nonrecursively, on all children."""
|
|
32
34
|
ret = getattr(cls.mro()[1], name)(self, *args, **kwargs)
|
|
33
35
|
for c in self.get_children():
|
|
34
36
|
getattr(c, name)(*args, **kwargs)
|
|
@@ -46,7 +48,13 @@ def _forwarder(forwards, cls=None):
|
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
def _additional_set_methods(attributes, cls=None):
|
|
49
|
-
"""Decorator to add specific set methods for children properties.
|
|
51
|
+
"""Decorator to add specific set methods for children properties.
|
|
52
|
+
|
|
53
|
+
This is useful to autogenerate methods a la set_<key>(value), for
|
|
54
|
+
instance set_alpha(value). It works by delegating to set(alpha=value).
|
|
55
|
+
|
|
56
|
+
Overall, this is a minor tweak compared to the previous decorator.
|
|
57
|
+
"""
|
|
50
58
|
if cls is None:
|
|
51
59
|
return partial(_additional_set_methods, attributes)
|
|
52
60
|
|
|
@@ -73,64 +81,76 @@ def _additional_set_methods(attributes, cls=None):
|
|
|
73
81
|
# many use cases.
|
|
74
82
|
def _get_label_width_height(text, hpadding=18, vpadding=12, **kwargs):
|
|
75
83
|
"""Get the bounding box size for a text with certain properties."""
|
|
76
|
-
forbidden_props = [
|
|
84
|
+
forbidden_props = [
|
|
85
|
+
"horizontalalignment",
|
|
86
|
+
"verticalalignment",
|
|
87
|
+
"ha",
|
|
88
|
+
"va",
|
|
89
|
+
"color",
|
|
90
|
+
"edgecolor",
|
|
91
|
+
"facecolor",
|
|
92
|
+
]
|
|
77
93
|
for prop in forbidden_props:
|
|
78
94
|
if prop in kwargs:
|
|
79
95
|
del kwargs[prop]
|
|
80
96
|
|
|
81
97
|
path = mpl.textpath.TextPath((0, 0), text, **kwargs)
|
|
82
98
|
boundingbox = path.get_extents()
|
|
83
|
-
width = boundingbox.width
|
|
84
|
-
height = boundingbox.height
|
|
99
|
+
width = boundingbox.width
|
|
100
|
+
height = boundingbox.height
|
|
101
|
+
|
|
102
|
+
# Scaling with font size appears broken... try to patch it up linearly here, even though we know it don't work well
|
|
103
|
+
width *= kwargs.get("size", 12) / 12.0
|
|
104
|
+
height *= kwargs.get("size", 12) / 12.0
|
|
105
|
+
|
|
106
|
+
width += hpadding
|
|
107
|
+
height += vpadding
|
|
85
108
|
return (width, height)
|
|
86
109
|
|
|
87
110
|
|
|
88
|
-
def
|
|
111
|
+
def _compute_mid_coord_and_rot(path, trans):
|
|
89
112
|
"""Compute mid point of an edge, straight or curved."""
|
|
90
113
|
# Distinguish between straight and curved paths
|
|
91
114
|
if path.codes[-1] == mpl.path.Path.LINETO:
|
|
92
|
-
|
|
115
|
+
coord = path.vertices.mean(axis=0)
|
|
116
|
+
vtr = trans(path.vertices)
|
|
117
|
+
rot = atan2(
|
|
118
|
+
vtr[-1, 1] - vtr[0, 1],
|
|
119
|
+
vtr[-1, 0] - vtr[0, 0],
|
|
120
|
+
)
|
|
93
121
|
|
|
94
122
|
# Cubic Bezier
|
|
95
|
-
|
|
96
|
-
|
|
123
|
+
elif path.codes[-1] == mpl.path.Path.CURVE4:
|
|
124
|
+
coord = _evaluate_cubic_bezier(path.vertices, 0.5)
|
|
125
|
+
# TODO:
|
|
126
|
+
rot = 0
|
|
97
127
|
|
|
98
128
|
# Square Bezier
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"Curve type not straight and not squared/cubic Bezier, cannot compute mid point."
|
|
104
|
-
)
|
|
129
|
+
elif path.codes[-1] == mpl.path.Path.CURVE3:
|
|
130
|
+
coord = _evaluate_squared_bezier(path.vertices, 0.5)
|
|
131
|
+
# TODO:
|
|
132
|
+
rot = 0
|
|
105
133
|
|
|
134
|
+
else:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
"Curve type not straight and not squared/cubic Bezier, cannot compute mid point."
|
|
137
|
+
)
|
|
106
138
|
|
|
107
|
-
|
|
108
|
-
points,
|
|
109
|
-
transform,
|
|
110
|
-
vertexpadding=10,
|
|
111
|
-
):
|
|
112
|
-
"""Offset path for a group based on vertex padding.
|
|
139
|
+
return coord, rot
|
|
113
140
|
|
|
114
|
-
At the input, the structure is [v1, v1, v1, v2, v2, v2, ...]
|
|
115
|
-
"""
|
|
116
141
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
points = trans(points)
|
|
142
|
+
def _build_cmap_fun(values, cmap):
|
|
143
|
+
"""Map colormap on top of numerical values."""
|
|
144
|
+
cmap = mpl.cm._ensure_cmap(cmap)
|
|
121
145
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
mprev = atan2(points[0, 1] - vprev[1], points[0, 0] - vprev[0])
|
|
125
|
-
for i, vcur in enumerate(points[::3]):
|
|
126
|
-
vnext = points[(i + 1) * 3]
|
|
127
|
-
mnext = atan2(vnext[1] - vcur[1], vnext[0] - vcur[0])
|
|
146
|
+
if np.isscalar(values):
|
|
147
|
+
values = [values]
|
|
128
148
|
|
|
129
|
-
|
|
130
|
-
|
|
149
|
+
if isinstance(values, dict):
|
|
150
|
+
values = np.array(list(values.values()))
|
|
131
151
|
|
|
132
|
-
|
|
133
|
-
|
|
152
|
+
vmin = np.nanmin(values)
|
|
153
|
+
vmax = np.nanmax(values)
|
|
154
|
+
norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
|
|
134
155
|
|
|
135
|
-
|
|
136
|
-
return points
|
|
156
|
+
return lambda x: cmap(norm(x))
|
iplotx/utils/style.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from copy import copy
|
iplotx/version.py
CHANGED
iplotx/vertex.py
CHANGED
|
@@ -1,51 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module containing code to manipulate vertex visualisations, especially the VertexCollection class.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import (
|
|
6
|
+
Optional,
|
|
7
|
+
Sequence,
|
|
8
|
+
Any,
|
|
9
|
+
)
|
|
10
|
+
import warnings
|
|
1
11
|
import numpy as np
|
|
2
|
-
|
|
12
|
+
import pandas as pd
|
|
13
|
+
import matplotlib as mpl
|
|
3
14
|
from matplotlib.collections import PatchCollection
|
|
4
15
|
from matplotlib.patches import (
|
|
16
|
+
Patch,
|
|
5
17
|
Ellipse,
|
|
6
18
|
Circle,
|
|
7
19
|
RegularPolygon,
|
|
8
20
|
Rectangle,
|
|
9
21
|
)
|
|
10
22
|
|
|
23
|
+
from .style import (
|
|
24
|
+
get_style,
|
|
25
|
+
rotate_style,
|
|
26
|
+
copy_with_deep_values,
|
|
27
|
+
)
|
|
28
|
+
from .utils.matplotlib import (
|
|
29
|
+
_get_label_width_height,
|
|
30
|
+
_build_cmap_fun,
|
|
31
|
+
_forwarder,
|
|
32
|
+
)
|
|
33
|
+
from .label import LabelCollection
|
|
34
|
+
|
|
11
35
|
|
|
36
|
+
@_forwarder(
|
|
37
|
+
(
|
|
38
|
+
"set_clip_path",
|
|
39
|
+
"set_clip_box",
|
|
40
|
+
"set_snap",
|
|
41
|
+
"set_sketch_params",
|
|
42
|
+
"set_animated",
|
|
43
|
+
"set_picker",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
12
46
|
class VertexCollection(PatchCollection):
|
|
13
|
-
"""Collection of vertex patches for plotting.
|
|
47
|
+
"""Collection of vertex patches for plotting."""
|
|
14
48
|
|
|
15
|
-
|
|
49
|
+
_factor = 1.0
|
|
16
50
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
layout: pd.DataFrame,
|
|
54
|
+
*args,
|
|
55
|
+
layout_coordinate_system: str = "cartesian",
|
|
56
|
+
style: Optional[dict[str, Any]] = None,
|
|
57
|
+
labels: Optional[Sequence[str]] = None,
|
|
58
|
+
**kwargs,
|
|
59
|
+
):
|
|
60
|
+
"""Initialise the VertexCollection.
|
|
22
61
|
|
|
23
|
-
|
|
24
|
-
|
|
62
|
+
Parameters:
|
|
63
|
+
layout: The vertex layout.
|
|
64
|
+
layout_coordinate_system: The coordinate system for the layout, usually "cartesian").
|
|
65
|
+
style: The vertex style (subdictionary "vertex") to apply.
|
|
66
|
+
labels: The vertex labels, if present.
|
|
67
|
+
"""
|
|
25
68
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
69
|
+
self._index = layout.index
|
|
70
|
+
self._style = style
|
|
71
|
+
self._labels = labels
|
|
72
|
+
self._layout = layout
|
|
73
|
+
self._layout_coordinate_system = layout_coordinate_system
|
|
29
74
|
|
|
30
|
-
|
|
31
|
-
|
|
75
|
+
# Create patches from structured data
|
|
76
|
+
patches, sizes, kwargs2 = self._init_vertex_patches()
|
|
32
77
|
|
|
33
|
-
|
|
78
|
+
kwargs.update(kwargs2)
|
|
79
|
+
kwargs["match_original"] = True
|
|
34
80
|
|
|
35
|
-
|
|
81
|
+
# Pass to PatchCollection constructor
|
|
82
|
+
super().__init__(patches, *args, **kwargs)
|
|
83
|
+
|
|
84
|
+
# Set offsets in coordinate system
|
|
85
|
+
self._update_offsets_from_layout()
|
|
86
|
+
|
|
87
|
+
# Compute _transforms like in _CollectionWithScales for dpi issues
|
|
88
|
+
self.set_sizes(sizes)
|
|
89
|
+
|
|
90
|
+
if self._labels is not None:
|
|
91
|
+
self._compute_label_collection()
|
|
92
|
+
|
|
93
|
+
def get_children(self) -> tuple[mpl.artist.Artist]:
|
|
94
|
+
"""Get the children artists.
|
|
95
|
+
|
|
96
|
+
This can include the labels as a LabelCollection.
|
|
36
97
|
"""
|
|
37
|
-
|
|
98
|
+
children = []
|
|
99
|
+
if hasattr(self, "_label_collection"):
|
|
100
|
+
children.append(self._label_collection)
|
|
101
|
+
return tuple(children)
|
|
38
102
|
|
|
39
|
-
|
|
40
|
-
for
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
sizes.append(size)
|
|
46
|
-
return np.array(sizes)
|
|
103
|
+
def set_figure(self, fig) -> None:
|
|
104
|
+
"""Set the figure for this artist and all children."""
|
|
105
|
+
super().set_figure(fig)
|
|
106
|
+
self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
|
|
107
|
+
for child in self.get_children():
|
|
108
|
+
child.set_figure(fig)
|
|
47
109
|
|
|
48
|
-
def
|
|
110
|
+
def get_index(self):
|
|
111
|
+
"""Get the VertexCollection index."""
|
|
112
|
+
return self._index
|
|
113
|
+
|
|
114
|
+
def get_vertex_id(self, index):
|
|
115
|
+
"""Get the id of a single vertex at a positional index."""
|
|
116
|
+
return self._index[index]
|
|
117
|
+
|
|
118
|
+
def get_sizes(self):
|
|
119
|
+
"""Get vertex sizes (max of width and height), not scaled by dpi."""
|
|
120
|
+
return self._sizes
|
|
121
|
+
|
|
122
|
+
def get_sizes_dpi(self):
|
|
123
|
+
"""Get vertex sizes (max of width and height), scaled by dpi."""
|
|
124
|
+
return self._transforms[:, 0, 0]
|
|
125
|
+
|
|
126
|
+
def set_sizes(self, sizes, dpi: float = 72.0) -> None:
|
|
49
127
|
"""Set vertex sizes.
|
|
50
128
|
|
|
51
129
|
This rescales the current vertex symbol/path linearly, using this
|
|
@@ -53,26 +131,163 @@ class VertexCollection(PatchCollection):
|
|
|
53
131
|
|
|
54
132
|
@param sizes: A sequence of vertex sizes or a single size.
|
|
55
133
|
"""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
134
|
+
if sizes is None:
|
|
135
|
+
self._sizes = np.array([])
|
|
136
|
+
self._transforms = np.empty((0, 3, 3))
|
|
137
|
+
else:
|
|
138
|
+
self._sizes = np.asarray(sizes)
|
|
139
|
+
self._transforms = np.zeros((len(self._sizes), 3, 3))
|
|
140
|
+
scale = self._sizes * dpi / 72.0 * self._factor
|
|
141
|
+
self._transforms[:, 0, 0] = scale
|
|
142
|
+
self._transforms[:, 1, 1] = scale
|
|
143
|
+
self._transforms[:, 2, 2] = 1.0
|
|
144
|
+
self.stale = True
|
|
145
|
+
|
|
146
|
+
get_size = get_sizes
|
|
147
|
+
set_size = set_sizes
|
|
148
|
+
|
|
149
|
+
def get_layout(self) -> pd.DataFrame:
|
|
150
|
+
"""Get the vertex layout.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The vertex layout as a DataFrame.
|
|
154
|
+
"""
|
|
155
|
+
return self._layout
|
|
156
|
+
|
|
157
|
+
def get_layout_coordinate_system(self) -> str:
|
|
158
|
+
"""Get the layout coordinate system.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Name of the layout coordinate system, e.g. "cartesian" or "polar".
|
|
162
|
+
"""
|
|
163
|
+
return self._layout_coordinate_system
|
|
164
|
+
|
|
165
|
+
def get_offsets(self, ignore_layout: bool = True) -> np.ndarray:
|
|
166
|
+
"""Get the vertex offsets.
|
|
70
167
|
|
|
168
|
+
Parameters:
|
|
169
|
+
ignore_layout: If True, return the matplotlib Artist._offsets directly, ignoring the
|
|
170
|
+
layout coordinate system. If False, it's equivalent to get_layout().values.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The vertex offsets as a 2D numpy array.
|
|
174
|
+
|
|
175
|
+
Note: It is best for users to *not* ignore the layout coordinate system, as it may lead
|
|
176
|
+
to inconsistencies. However, some internal matplotlib functions require the default
|
|
177
|
+
signature of this function to look at the vanilla offsets, hence the default parameters.
|
|
178
|
+
"""
|
|
179
|
+
if not ignore_layout:
|
|
180
|
+
return self.get_layout().values
|
|
181
|
+
else:
|
|
182
|
+
return self._offsets
|
|
183
|
+
|
|
184
|
+
def _update_offsets_from_layout(self) -> None:
|
|
185
|
+
"""Update offsets in matplotlib coordinates from the layout DataFrame."""
|
|
186
|
+
if self._layout_coordinate_system == "cartesian":
|
|
187
|
+
self._offsets = self._layout.values
|
|
188
|
+
elif self._layout_coordinate_system == "polar":
|
|
189
|
+
# Convert polar coordinates (r, theta) to cartesian (x, y)
|
|
190
|
+
r = self._layout.iloc[:, 0].values
|
|
191
|
+
theta = self._layout.iloc[:, 1].values
|
|
192
|
+
if self._offsets is None:
|
|
193
|
+
self._offsets = np.zeros((len(r), 2))
|
|
194
|
+
self._offsets[:, 0] = r * np.cos(theta)
|
|
195
|
+
self._offsets[:, 1] = r * np.sin(theta)
|
|
196
|
+
else:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"Layout coordinate system not supported: {self._layout_coordinate_system}."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def set_offsets(self, offsets: np.ndarray) -> None:
|
|
202
|
+
"""Set the vertex positions/offsets in layout coordinates.
|
|
203
|
+
|
|
204
|
+
Parameters:
|
|
205
|
+
offsets: Array of coordinates in the layout coordinate system. For polar layouts,
|
|
206
|
+
these should be in the form of (r, theta) pairs.
|
|
207
|
+
"""
|
|
208
|
+
self._layout.values[:] = offsets
|
|
209
|
+
self._update_offsets_from_layout()
|
|
71
210
|
self.stale = True
|
|
72
211
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
212
|
+
def _init_vertex_patches(self):
|
|
213
|
+
style = self._style or {}
|
|
214
|
+
if "cmap" in style:
|
|
215
|
+
cmap_fun = _build_cmap_fun(
|
|
216
|
+
style["facecolor"],
|
|
217
|
+
style["cmap"],
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
cmap_fun = None
|
|
221
|
+
|
|
222
|
+
if style.get("size", 20) == "label":
|
|
223
|
+
if self._labels is None:
|
|
224
|
+
warnings.warn(
|
|
225
|
+
"No labels found, cannot resize vertices based on labels."
|
|
226
|
+
)
|
|
227
|
+
style["size"] = get_style("default.vertex")["size"]
|
|
228
|
+
|
|
229
|
+
if "cmap" in style:
|
|
230
|
+
colorarray = []
|
|
231
|
+
patches = []
|
|
232
|
+
sizes = []
|
|
233
|
+
for i, (vid, row) in enumerate(self._layout.iterrows()):
|
|
234
|
+
if style.get("size", 20) == "label":
|
|
235
|
+
# NOTE: it's ok to overwrite the dict here
|
|
236
|
+
style["size"] = _get_label_width_height(
|
|
237
|
+
str(self._labels[vid]), **style.get("label", {})
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
stylei = rotate_style(style, index=i, key=vid)
|
|
241
|
+
if cmap_fun is not None:
|
|
242
|
+
colorarray.append(style["facecolor"])
|
|
243
|
+
stylei["facecolor"] = cmap_fun(stylei["facecolor"])
|
|
244
|
+
|
|
245
|
+
# Shape of the vertex (Patch)
|
|
246
|
+
art, size = make_patch(**stylei)
|
|
247
|
+
patches.append(art)
|
|
248
|
+
sizes.append(size)
|
|
249
|
+
|
|
250
|
+
kwargs = {}
|
|
251
|
+
if "cmap" in style:
|
|
252
|
+
vmin = np.min(colorarray)
|
|
253
|
+
vmax = np.max(colorarray)
|
|
254
|
+
norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
|
|
255
|
+
kwargs["cmap"] = style["cmap"]
|
|
256
|
+
kwargs["norm"] = norm
|
|
257
|
+
|
|
258
|
+
return patches, sizes, kwargs
|
|
259
|
+
|
|
260
|
+
def _compute_label_collection(self):
|
|
261
|
+
transform = self.get_offset_transform()
|
|
262
|
+
|
|
263
|
+
style = (
|
|
264
|
+
copy_with_deep_values(self._style.get("label", None))
|
|
265
|
+
if self._style is not None
|
|
266
|
+
else {}
|
|
267
|
+
)
|
|
268
|
+
forbidden_props = ["hpadding", "vpadding"]
|
|
269
|
+
for prop in forbidden_props:
|
|
270
|
+
if prop in style:
|
|
271
|
+
del style[prop]
|
|
272
|
+
|
|
273
|
+
self._label_collection = LabelCollection(
|
|
274
|
+
self._labels,
|
|
275
|
+
style=style,
|
|
276
|
+
offsets=self._offsets,
|
|
277
|
+
transform=transform,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def get_labels(self):
|
|
281
|
+
"""Get the vertex labels.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
The artist with the LabelCollection.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
if hasattr(self, "_label_collection"):
|
|
288
|
+
return self._label_collection
|
|
289
|
+
else:
|
|
290
|
+
return None
|
|
76
291
|
|
|
77
292
|
@property
|
|
78
293
|
def stale(self):
|
|
@@ -84,29 +299,64 @@ class VertexCollection(PatchCollection):
|
|
|
84
299
|
if val and hasattr(self, "stale_callback_post"):
|
|
85
300
|
self.stale_callback_post(self)
|
|
86
301
|
|
|
302
|
+
@mpl.artist.allow_rasterization
|
|
303
|
+
def draw(self, renderer):
|
|
304
|
+
if not self.get_visible():
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
# null graph, no need to draw anything
|
|
308
|
+
# NOTE: I would expect this to be already a clause in the superclass by oh well
|
|
309
|
+
if len(self.get_paths()) == 0:
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
|
|
313
|
+
|
|
314
|
+
# NOTE: This draws the vertices first, then the labels.
|
|
315
|
+
# The correct order would be vertex1->label1->vertex2->label2, etc.
|
|
316
|
+
# We might fix if we manage to find a way to do it.
|
|
317
|
+
super().draw(renderer)
|
|
318
|
+
for child in self.get_children():
|
|
319
|
+
child.draw(renderer)
|
|
87
320
|
|
|
88
|
-
|
|
321
|
+
|
|
322
|
+
def make_patch(
|
|
323
|
+
marker: str, size: float | Sequence[float], **kwargs
|
|
324
|
+
) -> tuple[Patch, float]:
|
|
89
325
|
"""Make a patch of the given marker shape and size."""
|
|
90
|
-
forbidden_props = ["label"]
|
|
326
|
+
forbidden_props = ["label", "cmap", "norm"]
|
|
91
327
|
for prop in forbidden_props:
|
|
92
328
|
if prop in kwargs:
|
|
93
329
|
kwargs.pop(prop)
|
|
94
330
|
|
|
95
|
-
if
|
|
331
|
+
if np.isscalar(size):
|
|
332
|
+
size = float(size)
|
|
96
333
|
size = (size, size)
|
|
97
334
|
|
|
98
|
-
|
|
99
|
-
|
|
335
|
+
# Size of vertices is determined in self._transforms, which scales with dpi, rather than here,
|
|
336
|
+
# so normalise by the average dimension (btw x and y) to keep the ratio of the marker.
|
|
337
|
+
# If you check in get_sizes, you will see that rescaling also happens with the max of width
|
|
338
|
+
# and height.
|
|
339
|
+
size = np.asarray(size, dtype=float)
|
|
340
|
+
size_max = size.max()
|
|
341
|
+
if size_max > 0:
|
|
342
|
+
size /= size_max
|
|
343
|
+
|
|
344
|
+
art: Patch
|
|
345
|
+
if marker in ("o", "c", "circle"):
|
|
346
|
+
art = Circle((0, 0), size[0] / 2, **kwargs)
|
|
100
347
|
elif marker in ("s", "square", "r", "rectangle"):
|
|
101
|
-
|
|
348
|
+
art = Rectangle((-size[0] / 2, -size[1] / 2), size[0], size[1], **kwargs)
|
|
102
349
|
elif marker in ("^", "triangle"):
|
|
103
|
-
|
|
350
|
+
art = RegularPolygon((0, 0), numVertices=3, radius=size[0] / 2, **kwargs)
|
|
104
351
|
elif marker in ("d", "diamond"):
|
|
105
|
-
|
|
352
|
+
art, _ = make_patch("s", size[0], angle=45, **kwargs)
|
|
106
353
|
elif marker in ("v", "triangle_down"):
|
|
107
|
-
|
|
354
|
+
art = RegularPolygon(
|
|
108
355
|
(0, 0), numVertices=3, radius=size[0] / 2, orientation=np.pi, **kwargs
|
|
109
356
|
)
|
|
110
357
|
elif marker in ("e", "ellipse"):
|
|
111
|
-
|
|
112
|
-
|
|
358
|
+
art = Ellipse((0, 0), size[0] / 2, size[1] / 2, **kwargs)
|
|
359
|
+
else:
|
|
360
|
+
raise KeyError(f"Unknown marker: {marker}")
|
|
361
|
+
|
|
362
|
+
return (art, size_max)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iplotx
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Plot networkx from igraph and networkx.
|
|
5
|
+
Project-URL: Homepage, https://github.com/fabilab/iplotx
|
|
6
|
+
Project-URL: Documentation, https://readthedocs.org/iplotx
|
|
7
|
+
Project-URL: Repository, https://github.com/fabilab/iplotx.git
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/fabilab/iplotx/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/fabilab/iplotx/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Fabio Zanini <fabio.zanini@unsw.edu.au>
|
|
11
|
+
Maintainer-email: Fabio Zanini <fabio.zanini@unsw.edu.au>
|
|
12
|
+
License: MIT
|
|
13
|
+
Keywords: graph,network,plotting,visualisation
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Education
|
|
17
|
+
Classifier: Intended Audience :: Science/Research
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Natural Language :: English
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
26
|
+
Classifier: Topic :: System :: Networking
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Requires-Dist: matplotlib>=2.0.0
|
|
30
|
+
Requires-Dist: numpy>=2.0.0
|
|
31
|
+
Requires-Dist: pandas>=2.0.0
|
|
32
|
+
Requires-Dist: pylint>=3.3.7
|
|
33
|
+
Provides-Extra: igraph
|
|
34
|
+
Requires-Dist: igraph>=0.11.0; extra == 'igraph'
|
|
35
|
+
Provides-Extra: networkx
|
|
36
|
+
Requires-Dist: networkx>=2.0.0; extra == 'networkx'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+

|
|
41
|
+

|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
# iplotx
|
|
45
|
+
Plotting networks from igraph and networkx.
|
|
46
|
+
|
|
47
|
+
**NOTE**: This is currently beta quality software. The API and functionality are settling in and might break occasionally.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
```bash
|
|
51
|
+
pip install iplotx
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Quick Start
|
|
55
|
+
```python
|
|
56
|
+
import networkx as nx
|
|
57
|
+
import matplotlib.pyplot as plt
|
|
58
|
+
import iplotx as ipx
|
|
59
|
+
|
|
60
|
+
g = nx.Graph([(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)])
|
|
61
|
+
layout = nx.layout.circular_layout(g)
|
|
62
|
+
fig, ax = plt.subplots(figsize=(3, 3))
|
|
63
|
+
ipx.plot(g, ax=ax, layout=layout)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+

|
|
67
|
+
|
|
68
|
+
## Documentation
|
|
69
|
+
See [readthedocs](https://iplotx.readthedocs.io/en/latest/) for the full documentation.
|
|
70
|
+
|
|
71
|
+
## Gallery
|
|
72
|
+
See [gallery](https://iplotx.readthedocs.io/en/latest/gallery/index.html).
|
|
73
|
+
|
|
74
|
+
## Roadmap
|
|
75
|
+
- Plot networks from igraph and networkx interchangeably, using matplotlib as a backend. ✅
|
|
76
|
+
- Support interactive plotting, e.g. zooming and panning after the plot is created. ✅
|
|
77
|
+
- Support storing the plot to disk thanks to the many matplotlib backends (SVG, PNG, PDF, etc.). ✅
|
|
78
|
+
- Support flexible yet easy styling. ✅
|
|
79
|
+
- Efficient plotting of large graphs using matplotlib's collection functionality. ✅
|
|
80
|
+
- Support editing plotting elements after the plot is created, e.g. changing node colors, labels, etc. ✅
|
|
81
|
+
- Support animations, e.g. showing the evolution of a network over time. ✅
|
|
82
|
+
- Support trees from special libraries such as ete3, biopython, etc. This will need a dedicated function and layouting. ✅
|
|
83
|
+
- Support uni- and bi-directional communication between graph object and plot object.🏗️
|
|
84
|
+
|
|
85
|
+
**NOTE:** The last item can probably be achieved already by using `matplotlib`'s existing callback functionality. It is currently untested, but if you manage to get it to work on your graph let me know and I'll add it to the examples (with credit).
|
|
86
|
+
|
|
87
|
+
## Authors
|
|
88
|
+
Fabio Zanini (https://fabilab.org)
|