iplotx 0.0.1__py2.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 +2 -0
- iplotx/edge/arrow.py +122 -0
- iplotx/edge/common.py +47 -0
- iplotx/edge/directed.py +149 -0
- iplotx/edge/label.py +50 -0
- iplotx/edge/undirected.py +447 -0
- iplotx/groups.py +141 -0
- iplotx/heuristics.py +114 -0
- iplotx/importing.py +13 -0
- iplotx/network.py +507 -0
- iplotx/plotting.py +104 -0
- iplotx/styles.py +186 -0
- iplotx/typing.py +41 -0
- iplotx/utils/geometry.py +227 -0
- iplotx/utils/matplotlib.py +136 -0
- iplotx/version.py +1 -0
- iplotx/vertex.py +112 -0
- iplotx-0.0.1.dist-info/METADATA +39 -0
- iplotx-0.0.1.dist-info/RECORD +20 -0
- iplotx-0.0.1.dist-info/WHEEL +5 -0
iplotx/importing.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
try:
|
|
2
|
+
import igraph
|
|
3
|
+
except ImportError:
|
|
4
|
+
igraph = None
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import networkx
|
|
8
|
+
except ImportError:
|
|
9
|
+
networkx = None
|
|
10
|
+
|
|
11
|
+
if igraph is None and networkx is None:
|
|
12
|
+
raise ImportError("At least one of igraph or networkx must be installed to use this module.")
|
|
13
|
+
|
iplotx/network.py
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
from typing import Union, Sequence
|
|
2
|
+
import warnings
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import matplotlib as mpl
|
|
6
|
+
from matplotlib.transforms import Affine2D
|
|
7
|
+
|
|
8
|
+
from .typing import (
|
|
9
|
+
GraphType,
|
|
10
|
+
LayoutType,
|
|
11
|
+
)
|
|
12
|
+
from .styles import (
|
|
13
|
+
get_style,
|
|
14
|
+
rotate_style,
|
|
15
|
+
)
|
|
16
|
+
from .heuristics import (
|
|
17
|
+
network_library,
|
|
18
|
+
normalise_layout,
|
|
19
|
+
detect_directedness,
|
|
20
|
+
)
|
|
21
|
+
from .utils.matplotlib import (
|
|
22
|
+
_stale_wrapper,
|
|
23
|
+
_forwarder,
|
|
24
|
+
_get_label_width_height,
|
|
25
|
+
)
|
|
26
|
+
from .vertex import (
|
|
27
|
+
VertexCollection,
|
|
28
|
+
make_patch as make_vertex_patch,
|
|
29
|
+
)
|
|
30
|
+
from .edge.undirected import (
|
|
31
|
+
UndirectedEdgeCollection,
|
|
32
|
+
make_stub_patch as make_undirected_edge_patch,
|
|
33
|
+
)
|
|
34
|
+
from .edge.directed import (
|
|
35
|
+
DirectedEdgeCollection,
|
|
36
|
+
make_arrow_patch,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@_forwarder(
|
|
41
|
+
(
|
|
42
|
+
"set_clip_path",
|
|
43
|
+
"set_clip_box",
|
|
44
|
+
"set_transform",
|
|
45
|
+
"set_snap",
|
|
46
|
+
"set_sketch_params",
|
|
47
|
+
"set_figure",
|
|
48
|
+
"set_animated",
|
|
49
|
+
"set_picker",
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
class NetworkArtist(mpl.artist.Artist):
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
network: GraphType,
|
|
56
|
+
layout: LayoutType = None,
|
|
57
|
+
vertex_labels: Union[None, list, dict, pd.Series] = None,
|
|
58
|
+
edge_labels: Union[None, Sequence] = None,
|
|
59
|
+
):
|
|
60
|
+
"""Network container artist that groups all plotting elements.
|
|
61
|
+
|
|
62
|
+
Parameters:
|
|
63
|
+
network (networkx.Graph or igraph.Graph): The network to plot.
|
|
64
|
+
layout (array-like): The layout of the network. If None, this function will attempt to
|
|
65
|
+
infer the layout from the network metadata, using heuristics. If that fails, an
|
|
66
|
+
exception will be raised.
|
|
67
|
+
vertex_labels (list, dict, or pandas.Series): The labels for the vertices. If None, no vertex labels
|
|
68
|
+
will be drawn. If a list, the labels are taken from the list. If a dict, the keys
|
|
69
|
+
should be the vertex IDs and the values should be the labels.
|
|
70
|
+
elge_labels (sequence): The labels for the edges. If None, no edge labels will be drawn.
|
|
71
|
+
"""
|
|
72
|
+
super().__init__()
|
|
73
|
+
|
|
74
|
+
self.network = network
|
|
75
|
+
self._ipx_internal_data = _create_internal_data(
|
|
76
|
+
network,
|
|
77
|
+
layout,
|
|
78
|
+
vertex_labels=vertex_labels,
|
|
79
|
+
edge_labels=edge_labels,
|
|
80
|
+
)
|
|
81
|
+
self._clear_state()
|
|
82
|
+
|
|
83
|
+
def _clear_state(self):
|
|
84
|
+
self._vertices = None
|
|
85
|
+
self._edges = None
|
|
86
|
+
self._vertex_labels = []
|
|
87
|
+
self._edge_labels = []
|
|
88
|
+
|
|
89
|
+
def get_children(self):
|
|
90
|
+
artists = []
|
|
91
|
+
# Collect edges first. This way vertices are on top of edges,
|
|
92
|
+
# since vertices are drawn later. That is what most people expect.
|
|
93
|
+
if self._edges is not None:
|
|
94
|
+
artists.append(self._edges)
|
|
95
|
+
if self._vertices is not None:
|
|
96
|
+
artists.append(self._vertices)
|
|
97
|
+
artists.extend(self._edge_labels)
|
|
98
|
+
artists.extend(self._vertex_labels)
|
|
99
|
+
return tuple(artists)
|
|
100
|
+
|
|
101
|
+
def get_vertices(self):
|
|
102
|
+
"""Get VertexCollection artist."""
|
|
103
|
+
return self._vertices
|
|
104
|
+
|
|
105
|
+
def get_edges(self):
|
|
106
|
+
"""Get EdgeCollection artist."""
|
|
107
|
+
return self._edges
|
|
108
|
+
|
|
109
|
+
def get_vertex_labels(self):
|
|
110
|
+
"""Get list of vertex label artists."""
|
|
111
|
+
return self._vertex_labels
|
|
112
|
+
|
|
113
|
+
def get_edge_labels(self):
|
|
114
|
+
"""Get list of edge label artists."""
|
|
115
|
+
return self._edge_labels
|
|
116
|
+
|
|
117
|
+
def get_datalim(self, transData, pad=0.05):
|
|
118
|
+
"""Get limits on x/y axes based on the graph layout data.
|
|
119
|
+
|
|
120
|
+
Parameters:
|
|
121
|
+
transData (Transform): The transform to use for the data.
|
|
122
|
+
pad (float): Padding to add to the limits. Default is 0.05.
|
|
123
|
+
Units are a fraction of total axis range before padding.
|
|
124
|
+
"""
|
|
125
|
+
# FIXME: transData works here, but it's probably kind of broken in general
|
|
126
|
+
import numpy as np
|
|
127
|
+
|
|
128
|
+
layout_columns = [
|
|
129
|
+
f"_ipx_layout_{i}" for i in range(self._ipx_internal_data["ndim"])
|
|
130
|
+
]
|
|
131
|
+
layout = self._ipx_internal_data["vertex_df"][layout_columns].values
|
|
132
|
+
|
|
133
|
+
if len(layout) == 0:
|
|
134
|
+
mins = np.array([0, 0])
|
|
135
|
+
maxs = np.array([1, 1])
|
|
136
|
+
return (mins, maxs)
|
|
137
|
+
|
|
138
|
+
# Use the layout as a base, and expand using bboxes from other artists
|
|
139
|
+
mins = np.min(layout, axis=0).astype(float)
|
|
140
|
+
maxs = np.max(layout, axis=0).astype(float)
|
|
141
|
+
|
|
142
|
+
# NOTE: unlike other Collections, the vertices are basically a
|
|
143
|
+
# PatchCollection with an offset transform using transData. Therefore,
|
|
144
|
+
# care should be taken if one wants to include it here
|
|
145
|
+
if self._vertices is not None:
|
|
146
|
+
trans = transData.transform
|
|
147
|
+
trans_inv = transData.inverted().transform
|
|
148
|
+
verts = self._vertices
|
|
149
|
+
for path, offset in zip(verts.get_paths(), verts._offsets):
|
|
150
|
+
bbox = path.get_extents()
|
|
151
|
+
mins = np.minimum(mins, trans_inv(bbox.min + trans(offset)))
|
|
152
|
+
maxs = np.maximum(maxs, trans_inv(bbox.max + trans(offset)))
|
|
153
|
+
|
|
154
|
+
if self._edges is not None:
|
|
155
|
+
for path in self._edges.get_paths():
|
|
156
|
+
bbox = path.get_extents()
|
|
157
|
+
mins = np.minimum(mins, bbox.min)
|
|
158
|
+
maxs = np.maximum(maxs, bbox.max)
|
|
159
|
+
|
|
160
|
+
if hasattr(self, "_groups") and self._groups is not None:
|
|
161
|
+
for path in self._groups.get_paths():
|
|
162
|
+
bbox = path.get_extents()
|
|
163
|
+
mins = np.minimum(mins, bbox.min)
|
|
164
|
+
maxs = np.maximum(maxs, bbox.max)
|
|
165
|
+
|
|
166
|
+
# 5% padding, on each side
|
|
167
|
+
pad = (maxs - mins) * pad
|
|
168
|
+
mins -= pad
|
|
169
|
+
maxs += pad
|
|
170
|
+
|
|
171
|
+
return mpl.transforms.Bbox([mins, maxs])
|
|
172
|
+
|
|
173
|
+
def _add_vertices(self):
|
|
174
|
+
"""Draw the vertices"""
|
|
175
|
+
vertex_style = get_style(".vertex")
|
|
176
|
+
|
|
177
|
+
layout_columns = [
|
|
178
|
+
f"_ipx_layout_{i}" for i in range(self._ipx_internal_data["ndim"])
|
|
179
|
+
]
|
|
180
|
+
vertex_layout_df = self._ipx_internal_data["vertex_df"][layout_columns]
|
|
181
|
+
if vertex_style.get("size") == "label":
|
|
182
|
+
if "label" not in self._ipx_internal_data["vertex_df"].columns:
|
|
183
|
+
warnings.warn(
|
|
184
|
+
"No labels found, cannot resize vertices based on labels."
|
|
185
|
+
)
|
|
186
|
+
vertex_style["size"] = get_style("default.vertex")["size"]
|
|
187
|
+
else:
|
|
188
|
+
vertex_labels = self._ipx_internal_data["vertex_df"]["label"]
|
|
189
|
+
|
|
190
|
+
# FIXME:: this would be better off in the VertexCollection itself, like we do for groups
|
|
191
|
+
offsets = []
|
|
192
|
+
patches = []
|
|
193
|
+
for i, (vid, row) in enumerate(vertex_layout_df.iterrows()):
|
|
194
|
+
# Centre of the vertex
|
|
195
|
+
offsets.append(list(row[layout_columns].values))
|
|
196
|
+
|
|
197
|
+
if vertex_style.get("size") == "label":
|
|
198
|
+
# NOTE: it's ok to overwrite the dict here
|
|
199
|
+
vertex_style["size"] = _get_label_width_height(
|
|
200
|
+
vertex_labels[vid], **vertex_style.get("label", {})
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
vertex_stylei = rotate_style(vertex_style, index=i, id=vid)
|
|
204
|
+
|
|
205
|
+
# Shape of the vertex (Patch)
|
|
206
|
+
art = make_vertex_patch(**vertex_stylei)
|
|
207
|
+
patches.append(art)
|
|
208
|
+
|
|
209
|
+
art = VertexCollection(
|
|
210
|
+
patches,
|
|
211
|
+
offsets=offsets if offsets else None,
|
|
212
|
+
offset_transform=self.axes.transData,
|
|
213
|
+
transform=Affine2D(),
|
|
214
|
+
match_original=True,
|
|
215
|
+
)
|
|
216
|
+
self._vertices = art
|
|
217
|
+
|
|
218
|
+
def _add_vertex_labels(self):
|
|
219
|
+
"""Draw vertex labels."""
|
|
220
|
+
label_style = get_style(".vertex.label")
|
|
221
|
+
forbidden_props = ["hpadding", "vpadding"]
|
|
222
|
+
for prop in forbidden_props:
|
|
223
|
+
if prop in label_style:
|
|
224
|
+
del label_style[prop]
|
|
225
|
+
|
|
226
|
+
texts = []
|
|
227
|
+
vertex_labels = self._ipx_internal_data["vertex_df"]["label"]
|
|
228
|
+
for offset, label in zip(self._vertices._offsets, vertex_labels):
|
|
229
|
+
text = mpl.text.Text(
|
|
230
|
+
offset[0],
|
|
231
|
+
offset[1],
|
|
232
|
+
label,
|
|
233
|
+
transform=self.axes.transData,
|
|
234
|
+
**label_style,
|
|
235
|
+
)
|
|
236
|
+
texts.append(text)
|
|
237
|
+
self._vertex_labels = texts
|
|
238
|
+
|
|
239
|
+
def _add_edges(self):
|
|
240
|
+
"""Draw the edges."""
|
|
241
|
+
if "labels" in self._ipx_internal_data["edge_df"].columns:
|
|
242
|
+
labels = self._ipx_internal_data["edge_df"]["labels"]
|
|
243
|
+
else:
|
|
244
|
+
labels = None
|
|
245
|
+
|
|
246
|
+
if self._ipx_internal_data["directed"]:
|
|
247
|
+
return self._add_directed_edges(labels=labels)
|
|
248
|
+
return self._add_undirected_edges(labels=labels)
|
|
249
|
+
|
|
250
|
+
def _add_directed_edges(self, labels=None):
|
|
251
|
+
"""Draw directed edges."""
|
|
252
|
+
edge_style = get_style(".edge")
|
|
253
|
+
arrow_style = get_style(".arrow")
|
|
254
|
+
|
|
255
|
+
layout_columns = [
|
|
256
|
+
f"_ipx_layout_{i}" for i in range(self._ipx_internal_data["ndim"])
|
|
257
|
+
]
|
|
258
|
+
vertex_layout_df = self._ipx_internal_data["vertex_df"][layout_columns]
|
|
259
|
+
edge_df = self._ipx_internal_data["edge_df"].set_index(
|
|
260
|
+
["_ipx_source", "_ipx_target"]
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# This contains the patches for vertices, for edge shortening and such
|
|
264
|
+
vertex_paths = self._vertices._paths
|
|
265
|
+
vertex_indices = pd.Series(
|
|
266
|
+
np.arange(len(vertex_layout_df)), index=vertex_layout_df.index
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
edgepatches = []
|
|
270
|
+
arrowpatches = []
|
|
271
|
+
adjacent_vertex_ids = []
|
|
272
|
+
adjecent_vertex_centers = []
|
|
273
|
+
adjecent_vertex_paths = []
|
|
274
|
+
for i, (vid1, vid2) in enumerate(edge_df.index):
|
|
275
|
+
# Get the vertices for this edge
|
|
276
|
+
vcenter1 = vertex_layout_df.loc[vid1, layout_columns].values
|
|
277
|
+
vcenter2 = vertex_layout_df.loc[vid2, layout_columns].values
|
|
278
|
+
vpath1 = vertex_paths[vertex_indices[vid1]]
|
|
279
|
+
vpath2 = vertex_paths[vertex_indices[vid2]]
|
|
280
|
+
|
|
281
|
+
edge_stylei = rotate_style(edge_style, index=i, id=(vid1, vid2))
|
|
282
|
+
|
|
283
|
+
# These are not the actual edges drawn, only stubs to establish
|
|
284
|
+
# the styles which are then fed into the dynamic, optimised
|
|
285
|
+
# factory (the collection) below
|
|
286
|
+
patch = make_undirected_edge_patch(
|
|
287
|
+
**edge_stylei,
|
|
288
|
+
)
|
|
289
|
+
edgepatches.append(patch)
|
|
290
|
+
adjacent_vertex_ids.append((vid1, vid2))
|
|
291
|
+
adjecent_vertex_centers.append((vcenter1, vcenter2))
|
|
292
|
+
adjecent_vertex_paths.append((vpath1, vpath2))
|
|
293
|
+
|
|
294
|
+
arrow_patch = make_arrow_patch(
|
|
295
|
+
**arrow_style,
|
|
296
|
+
)
|
|
297
|
+
arrowpatches.append(arrow_patch)
|
|
298
|
+
|
|
299
|
+
adjacent_vertex_ids = np.array(adjacent_vertex_ids)
|
|
300
|
+
adjecent_vertex_centers = np.array(adjecent_vertex_centers)
|
|
301
|
+
# NOTE: the paths might have different number of sides, so it cannot be recast
|
|
302
|
+
|
|
303
|
+
# TODO:: deal with "ports" a la graphviz
|
|
304
|
+
|
|
305
|
+
art = DirectedEdgeCollection(
|
|
306
|
+
edges=edgepatches,
|
|
307
|
+
arrows=arrowpatches,
|
|
308
|
+
labels=labels,
|
|
309
|
+
vertex_ids=adjacent_vertex_ids,
|
|
310
|
+
vertex_paths=adjecent_vertex_paths,
|
|
311
|
+
vertex_centers=adjecent_vertex_centers,
|
|
312
|
+
transform=self.axes.transData,
|
|
313
|
+
style=edge_style,
|
|
314
|
+
)
|
|
315
|
+
self._edges = art
|
|
316
|
+
|
|
317
|
+
def _add_undirected_edges(self, labels=None):
|
|
318
|
+
"""Draw undirected edges."""
|
|
319
|
+
edge_style = get_style(".edge")
|
|
320
|
+
|
|
321
|
+
layout_columns = [
|
|
322
|
+
f"_ipx_layout_{i}" for i in range(self._ipx_internal_data["ndim"])
|
|
323
|
+
]
|
|
324
|
+
vertex_layout_df = self._ipx_internal_data["vertex_df"][layout_columns]
|
|
325
|
+
edge_df = self._ipx_internal_data["edge_df"].set_index(
|
|
326
|
+
["_ipx_source", "_ipx_target"]
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# This contains the patches for vertices, for edge shortening and such
|
|
330
|
+
vertex_paths = self._vertices._paths
|
|
331
|
+
vertex_indices = pd.Series(
|
|
332
|
+
np.arange(len(vertex_layout_df)), index=vertex_layout_df.index
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
edgepatches = []
|
|
336
|
+
adjacent_vertex_ids = []
|
|
337
|
+
adjecent_vertex_centers = []
|
|
338
|
+
adjecent_vertex_paths = []
|
|
339
|
+
for i, (vid1, vid2) in enumerate(edge_df.index):
|
|
340
|
+
# Get the vertices for this edge
|
|
341
|
+
vcenter1 = vertex_layout_df.loc[vid1, layout_columns].values
|
|
342
|
+
vcenter2 = vertex_layout_df.loc[vid2, layout_columns].values
|
|
343
|
+
vpath1 = vertex_paths[vertex_indices[vid1]]
|
|
344
|
+
vpath2 = vertex_paths[vertex_indices[vid2]]
|
|
345
|
+
|
|
346
|
+
edge_stylei = rotate_style(edge_style, index=i, id=(vid1, vid2))
|
|
347
|
+
|
|
348
|
+
# These are not the actual edges drawn, only stubs to establish
|
|
349
|
+
# the styles which are then fed into the dynamic, optimised
|
|
350
|
+
# factory (the collection) below
|
|
351
|
+
patch = make_undirected_edge_patch(
|
|
352
|
+
**edge_stylei,
|
|
353
|
+
)
|
|
354
|
+
edgepatches.append(patch)
|
|
355
|
+
adjacent_vertex_ids.append((vid1, vid2))
|
|
356
|
+
adjecent_vertex_centers.append((vcenter1, vcenter2))
|
|
357
|
+
adjecent_vertex_paths.append((vpath1, vpath2))
|
|
358
|
+
|
|
359
|
+
adjacent_vertex_ids = np.array(adjacent_vertex_ids)
|
|
360
|
+
adjecent_vertex_centers = np.array(adjecent_vertex_centers)
|
|
361
|
+
# NOTE: the paths might have different number of sides, so it cannot be recast
|
|
362
|
+
|
|
363
|
+
# TODO:: deal with "ports" a la graphviz
|
|
364
|
+
|
|
365
|
+
art = UndirectedEdgeCollection(
|
|
366
|
+
edgepatches,
|
|
367
|
+
labels=labels,
|
|
368
|
+
vertex_ids=adjacent_vertex_ids,
|
|
369
|
+
vertex_paths=adjecent_vertex_paths,
|
|
370
|
+
vertex_centers=adjecent_vertex_centers,
|
|
371
|
+
transform=self.axes.transData,
|
|
372
|
+
style=edge_style,
|
|
373
|
+
)
|
|
374
|
+
self._edges = art
|
|
375
|
+
|
|
376
|
+
def _process(self):
|
|
377
|
+
self._clear_state()
|
|
378
|
+
|
|
379
|
+
# TODO: some more things might be plotted before this
|
|
380
|
+
|
|
381
|
+
# NOTE: we plot vertices first to get size etc. for edge shortening
|
|
382
|
+
# but when the mpl engine runs down all children artists for actual
|
|
383
|
+
# drawing it uses get_children() to get the order. Whatever is last
|
|
384
|
+
# in that order will get drawn on top (vis-a-vis zorder).
|
|
385
|
+
self._add_vertices()
|
|
386
|
+
self._add_edges()
|
|
387
|
+
if "label" in self._ipx_internal_data["vertex_df"].columns:
|
|
388
|
+
self._add_vertex_labels()
|
|
389
|
+
|
|
390
|
+
# TODO: callbacks for stale vertices/edges
|
|
391
|
+
|
|
392
|
+
# Forward mpl properties to children
|
|
393
|
+
# TODO sort out all of the things that need to be forwarded
|
|
394
|
+
for child in self.get_children():
|
|
395
|
+
# set the figure & axes on child, this ensures each artist
|
|
396
|
+
# down the hierarchy knows where to draw
|
|
397
|
+
if hasattr(child, "set_figure"):
|
|
398
|
+
child.set_figure(self.figure)
|
|
399
|
+
child.axes = self.axes
|
|
400
|
+
|
|
401
|
+
# forward the clippath/box to the children need this logic
|
|
402
|
+
# because mpl exposes some fast-path logic
|
|
403
|
+
clip_path = self.get_clip_path()
|
|
404
|
+
if clip_path is None:
|
|
405
|
+
clip_box = self.get_clip_box()
|
|
406
|
+
child.set_clip_box(clip_box)
|
|
407
|
+
else:
|
|
408
|
+
child.set_clip_path(clip_path)
|
|
409
|
+
|
|
410
|
+
@_stale_wrapper
|
|
411
|
+
def draw(self, renderer, *args, **kwds):
|
|
412
|
+
"""Draw each of the children, with some buffering mechanism."""
|
|
413
|
+
if not self.get_visible():
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
if not self.get_children():
|
|
417
|
+
self._process()
|
|
418
|
+
|
|
419
|
+
# NOTE: looks like we have to manage the zorder ourselves
|
|
420
|
+
# this is kind of funny actually
|
|
421
|
+
children = list(self.get_children())
|
|
422
|
+
children.sort(key=lambda x: x.zorder)
|
|
423
|
+
for art in children:
|
|
424
|
+
art.draw(renderer, *args, **kwds)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# INTERNAL ROUTINES
|
|
428
|
+
def _create_internal_data(
|
|
429
|
+
network,
|
|
430
|
+
layout=None,
|
|
431
|
+
vertex_labels=None,
|
|
432
|
+
edge_labels=None,
|
|
433
|
+
):
|
|
434
|
+
"""Create internal data for the network."""
|
|
435
|
+
nl = network_library(network)
|
|
436
|
+
directed = detect_directedness(network)
|
|
437
|
+
|
|
438
|
+
if nl == "networkx":
|
|
439
|
+
# Vertices are indexed by node ID
|
|
440
|
+
vertex_df = normalise_layout(layout).loc[pd.Index(network.nodes)]
|
|
441
|
+
ndim = vertex_df.shape[1]
|
|
442
|
+
vertex_df.columns = [f"_ipx_layout_{i}" for i in range(ndim)]
|
|
443
|
+
|
|
444
|
+
# Vertex labels
|
|
445
|
+
if vertex_labels is not None:
|
|
446
|
+
if len(vertex_labels) != len(vertex_df):
|
|
447
|
+
raise ValueError(
|
|
448
|
+
"Vertex labels must be the same length as the number of vertices."
|
|
449
|
+
)
|
|
450
|
+
vertex_df["label"] = vertex_labels
|
|
451
|
+
|
|
452
|
+
# Edges are a list of tuples, because of multiedges
|
|
453
|
+
tmp = []
|
|
454
|
+
for u, v, d in network.edges.data():
|
|
455
|
+
row = {"_ipx_source": u, "_ipx_target": v}
|
|
456
|
+
row.update(d)
|
|
457
|
+
tmp.append(row)
|
|
458
|
+
edge_df = pd.DataFrame(tmp)
|
|
459
|
+
del tmp
|
|
460
|
+
|
|
461
|
+
# Edge labels
|
|
462
|
+
if edge_labels is not None:
|
|
463
|
+
if len(edge_labels) != len(edge_df):
|
|
464
|
+
raise ValueError(
|
|
465
|
+
"Edge labels must be the same length as the number of edges."
|
|
466
|
+
)
|
|
467
|
+
edge_df["labels"] = edge_labels
|
|
468
|
+
|
|
469
|
+
else:
|
|
470
|
+
# Vertices are ordered integers, no gaps
|
|
471
|
+
vertex_df = normalise_layout(layout)
|
|
472
|
+
ndim = vertex_df.shape[1]
|
|
473
|
+
vertex_df.columns = [f"_ipx_layout_{i}" for i in range(ndim)]
|
|
474
|
+
|
|
475
|
+
# Vertex labels
|
|
476
|
+
if vertex_labels is not None:
|
|
477
|
+
if len(vertex_labels) != len(vertex_df):
|
|
478
|
+
raise ValueError(
|
|
479
|
+
"Vertex labels must be the same length as the number of vertices."
|
|
480
|
+
)
|
|
481
|
+
vertex_df["label"] = vertex_labels
|
|
482
|
+
|
|
483
|
+
# Edges are a list of tuples, because of multiedges
|
|
484
|
+
tmp = []
|
|
485
|
+
for edge in network.es:
|
|
486
|
+
row = {"_ipx_source": edge.source, "_ipx_target": edge.target}
|
|
487
|
+
row.update(edge.attributes())
|
|
488
|
+
tmp.append(row)
|
|
489
|
+
edge_df = pd.DataFrame(tmp)
|
|
490
|
+
del tmp
|
|
491
|
+
|
|
492
|
+
# Edge labels
|
|
493
|
+
if edge_labels is not None:
|
|
494
|
+
if len(edge_labels) != len(edge_df):
|
|
495
|
+
raise ValueError(
|
|
496
|
+
"Edge labels must be the same length as the number of edges."
|
|
497
|
+
)
|
|
498
|
+
edge_df["labels"] = edge_labels
|
|
499
|
+
|
|
500
|
+
internal_data = {
|
|
501
|
+
"vertex_df": vertex_df,
|
|
502
|
+
"edge_df": edge_df,
|
|
503
|
+
"directed": directed,
|
|
504
|
+
"network_library": nl,
|
|
505
|
+
"ndim": ndim,
|
|
506
|
+
}
|
|
507
|
+
return internal_data
|
iplotx/plotting.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from typing import Union, Sequence
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import matplotlib as mpl
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
|
|
6
|
+
from .typing import (
|
|
7
|
+
GraphType,
|
|
8
|
+
LayoutType,
|
|
9
|
+
GroupingType,
|
|
10
|
+
)
|
|
11
|
+
from .network import NetworkArtist
|
|
12
|
+
from .groups import GroupingArtist
|
|
13
|
+
from .styles import stylecontext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def plot(
|
|
17
|
+
network: Union[GraphType, None] = None,
|
|
18
|
+
layout: Union[LayoutType, None] = None,
|
|
19
|
+
grouping: Union[None, GroupingType] = None,
|
|
20
|
+
vertex_labels: Union[None, list, dict, pd.Series] = None,
|
|
21
|
+
edge_labels: Union[None, Sequence] = None,
|
|
22
|
+
ax: Union[None, object] = None,
|
|
23
|
+
styles: Sequence[Union[str, dict]] = (),
|
|
24
|
+
):
|
|
25
|
+
"""Plot this network using the specified layout.
|
|
26
|
+
|
|
27
|
+
Parameters:
|
|
28
|
+
network (GraphType): The network to plot. Can be a networkx or igraph graph.
|
|
29
|
+
layout (Union[LayoutType, None], optional): The layout to use for plotting. If None, a layout will be looked for in the network object and, if none is found, an exception is raised. Defaults to None.
|
|
30
|
+
vertex_labels (list, dict, or pandas.Series): The labels for the vertices. If None, no vertex labels
|
|
31
|
+
will be drawn. If a list, the labels are taken from the list. If a dict, the keys
|
|
32
|
+
should be the vertex IDs and the values should be the labels.
|
|
33
|
+
edge_labels (Union[None, Sequence], optional): The labels for the edges. If None, no edge labels will be drawn. Defaults to None.
|
|
34
|
+
ax (Union[None, object], optional): The axis to plot on. If None, a new figure and axis will be created. Defaults to None.
|
|
35
|
+
**style: Additional keyword arguments are treated as style for the objects to plot.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A NetworkArtist object.
|
|
39
|
+
"""
|
|
40
|
+
if len(styles):
|
|
41
|
+
with stylecontext(styles):
|
|
42
|
+
return plot(
|
|
43
|
+
network=network,
|
|
44
|
+
layout=layout,
|
|
45
|
+
grouping=grouping,
|
|
46
|
+
edge_labels=edge_labels,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if (network is None) and (grouping is None):
|
|
50
|
+
raise ValueError("At least one of network or grouping must be provided.")
|
|
51
|
+
|
|
52
|
+
if ax is None:
|
|
53
|
+
fig, ax = plt.subplots()
|
|
54
|
+
|
|
55
|
+
artists = []
|
|
56
|
+
if network is not None:
|
|
57
|
+
nwkart = NetworkArtist(
|
|
58
|
+
network,
|
|
59
|
+
layout,
|
|
60
|
+
vertex_labels=vertex_labels,
|
|
61
|
+
edge_labels=edge_labels,
|
|
62
|
+
)
|
|
63
|
+
ax.add_artist(nwkart)
|
|
64
|
+
# Postprocess for things that require an axis (transform, etc.)
|
|
65
|
+
nwkart._process()
|
|
66
|
+
artists.append(nwkart)
|
|
67
|
+
|
|
68
|
+
if grouping is not None:
|
|
69
|
+
grpart = GroupingArtist(
|
|
70
|
+
grouping,
|
|
71
|
+
layout,
|
|
72
|
+
)
|
|
73
|
+
ax.add_artist(grpart)
|
|
74
|
+
# Postprocess for things that require an axis (transform, etc.)
|
|
75
|
+
grpart._process()
|
|
76
|
+
artists.append(grpart)
|
|
77
|
+
|
|
78
|
+
_postprocess_axis(ax, artists)
|
|
79
|
+
|
|
80
|
+
return artists
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# INTERNAL ROUTINES
|
|
84
|
+
def _postprocess_axis(ax, artists):
|
|
85
|
+
"""Postprocess axis after plotting."""
|
|
86
|
+
|
|
87
|
+
# Despine
|
|
88
|
+
ax.spines["right"].set_visible(False)
|
|
89
|
+
ax.spines["top"].set_visible(False)
|
|
90
|
+
ax.spines["left"].set_visible(False)
|
|
91
|
+
ax.spines["bottom"].set_visible(False)
|
|
92
|
+
|
|
93
|
+
# Remove axis ticks
|
|
94
|
+
ax.set_xticks([])
|
|
95
|
+
ax.set_yticks([])
|
|
96
|
+
|
|
97
|
+
# Set new data limits
|
|
98
|
+
bboxes = []
|
|
99
|
+
for art in artists:
|
|
100
|
+
bboxes.append(art.get_datalim(ax.transData))
|
|
101
|
+
ax.update_datalim(mpl.transforms.Bbox.union(bboxes))
|
|
102
|
+
|
|
103
|
+
# Autoscale for x/y axis limits
|
|
104
|
+
ax.autoscale_view()
|