iplotx 0.0.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/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()