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.
@@ -0,0 +1,447 @@
1
+ from math import atan2, tan, cos, pi, sin
2
+ from collections import defaultdict
3
+ import numpy as np
4
+ import matplotlib as mpl
5
+
6
+ from .common import _compute_loops_per_angle
7
+ from .label import LabelCollection
8
+ from ..utils.matplotlib import (
9
+ _compute_mid_coord,
10
+ _stale_wrapper,
11
+ )
12
+
13
+
14
+ class UndirectedEdgeCollection(mpl.collections.PatchCollection):
15
+ def __init__(self, *args, **kwargs):
16
+ kwargs["match_original"] = True
17
+ self._vertex_ids = kwargs.pop("vertex_ids", None)
18
+ self._vertex_centers = kwargs.pop("vertex_centers", None)
19
+ self._vertex_paths = kwargs.pop("vertex_paths", None)
20
+ self._style = kwargs.pop("style", None)
21
+ self._labels = kwargs.pop("labels", None)
22
+ super().__init__(*args, **kwargs)
23
+
24
+ @staticmethod
25
+ def _get_edge_vertex_sizes(edge_vertices):
26
+ sizes = []
27
+ for visual_vertex in edge_vertices:
28
+ if visual_vertex.size is not None:
29
+ sizes.append(visual_vertex.size)
30
+ else:
31
+ sizes.append(max(visual_vertex.width, visual_vertex.height))
32
+ return sizes
33
+
34
+ @staticmethod
35
+ def _compute_edge_angles(path, trans):
36
+ """Compute edge angles for both starting and ending vertices.
37
+
38
+ NOTE: The domain of atan2 is (-pi, pi].
39
+ """
40
+ positions = trans(path.vertices)
41
+
42
+ # first angle
43
+ x1, y1 = positions[0]
44
+ x2, y2 = positions[1]
45
+ angle1 = atan2(y2 - y1, x2 - x1)
46
+
47
+ # second angle
48
+ x1, y1 = positions[-1]
49
+ x2, y2 = positions[-2]
50
+ angle2 = atan2(y2 - y1, x2 - x1)
51
+ return (angle1, angle2)
52
+
53
+ def _compute_paths(self, transform=None):
54
+ """Compute paths for the edges.
55
+
56
+ Loops split the largest wedge left open by other
57
+ edges of that vertex. The algo is:
58
+ (i) Find what vertices each loop belongs to
59
+ (ii) While going through the edges, record the angles
60
+ for vertices with loops
61
+ (iii) Plot each loop based on the recorded angles
62
+ """
63
+ vids = self._vertex_ids
64
+ vpaths = self._vertex_paths
65
+ vcenters = self._vertex_centers
66
+ if transform is None:
67
+ transform = self.get_transform()
68
+ trans = transform.transform
69
+ trans_inv = transform.inverted().transform
70
+
71
+ # 1. Make a list of vertices with loops, and store them for later
72
+ loop_vertex_dict = {}
73
+ for i, (v1, v2) in enumerate(vids):
74
+ if v1 != v2:
75
+ continue
76
+ if v1 not in loop_vertex_dict:
77
+ loop_vertex_dict[v1] = {
78
+ "indices": [],
79
+ "edge_angles": [],
80
+ }
81
+ loop_vertex_dict[v1]["indices"].append(i)
82
+
83
+ # 2. Make paths for non-loop edges
84
+ # NOTE: keep track of parallel edges to offset them
85
+ parallel_edges = defaultdict(list)
86
+
87
+ # Get actual coordinates of the vertex border
88
+ paths = []
89
+ for i, (v1, v2) in enumerate(vids):
90
+ # Postpone loops (step 3)
91
+ if v1 == v2:
92
+ paths.append(None)
93
+ continue
94
+
95
+ # Coordinates of the adjacent vertices, in data coords
96
+ vcoord_data = vcenters[i]
97
+
98
+ # Coordinates in figure (default) coords
99
+ vcoord_fig = trans(vcoord_data)
100
+
101
+ # Vertex paths in figure (default) coords
102
+ vpath_fig = vpaths[i]
103
+
104
+ # Shorten edge
105
+ if not self._style.get("curved", False):
106
+ path = self._shorten_path_undirected_straight(
107
+ vcoord_fig,
108
+ vpath_fig,
109
+ trans_inv,
110
+ )
111
+ else:
112
+ path = self._shorten_path_undirected_curved(
113
+ vcoord_fig,
114
+ vpath_fig,
115
+ trans_inv,
116
+ tension=self._style.get("tension", 1.5),
117
+ )
118
+
119
+ # Collect angles for this vertex, to be used for loops plotting below
120
+ if (v1 in loop_vertex_dict) or (v2 in loop_vertex_dict):
121
+ angles = self._compute_edge_angles(
122
+ path,
123
+ trans,
124
+ )
125
+ if v1 in loop_vertex_dict:
126
+ loop_vertex_dict[v1]["edge_angles"].append(angles[0])
127
+ if v2 in loop_vertex_dict:
128
+ loop_vertex_dict[v2]["edge_angles"].append(angles[1])
129
+
130
+ # Add the path for this non-loop edge
131
+ paths.append(path)
132
+ # FIXME: curved parallel edges depend on the direction of curvature...!
133
+ parallel_edges[(v1, v2)].append(i)
134
+
135
+ # Fix parallel edges
136
+ # If none found, empty the dictionary already
137
+ if max(parallel_edges.values(), key=len) == 1:
138
+ parallel_edges = {}
139
+ if not self._style.get("curved", False):
140
+ while len(parallel_edges) > 0:
141
+ (v1, v2), indices = parallel_edges.popitem()
142
+ indices_inv = parallel_edges.pop((v2, v1), [])
143
+ nparallel = len(indices)
144
+ nparallel_inv = len(indices_inv)
145
+ ntot = len(indices) + len(indices_inv)
146
+ if ntot > 1:
147
+ self._fix_parallel_edges_straight(
148
+ paths,
149
+ indices,
150
+ indices_inv,
151
+ trans,
152
+ trans_inv,
153
+ offset=self._style.get("offset", 3),
154
+ )
155
+
156
+ # 3. Deal with loops at the end
157
+ for vid, ldict in loop_vertex_dict.items():
158
+ vpath = vpaths[ldict["indices"][0]][0]
159
+ vcoord_fig = trans(vcenters[ldict["indices"][0]][0])
160
+ nloops = len(ldict["indices"])
161
+ edge_angles = ldict["edge_angles"]
162
+
163
+ # The space between the existing angles is where we can fit the loops
164
+ # One loop we can fit in the largest wedge, multiple loops we need
165
+ nloops_per_angle = _compute_loops_per_angle(nloops, edge_angles)
166
+
167
+ idx = 0
168
+ for theta1, theta2, nloops in nloops_per_angle:
169
+ # Angular size of each loop in this wedge
170
+ delta = (theta2 - theta1) / nloops
171
+
172
+ # Iterate over individual loops
173
+ for j in range(nloops):
174
+ thetaj1 = theta1 + j * delta
175
+ # Use 60 degrees as the largest possible loop wedge
176
+ thetaj2 = thetaj1 + min(delta, pi / 3)
177
+
178
+ # Get the path for this loop
179
+ path = self._compute_loop_path(
180
+ vcoord_fig,
181
+ vpath,
182
+ thetaj1,
183
+ thetaj2,
184
+ trans_inv,
185
+ )
186
+ paths[ldict["indices"][idx]] = path
187
+ idx += 1
188
+
189
+ return paths
190
+
191
+ def _fix_parallel_edges_straight(
192
+ self,
193
+ paths,
194
+ indices,
195
+ indices_inv,
196
+ trans,
197
+ trans_inv,
198
+ offset=3,
199
+ ):
200
+ """Offset parallel edges along the same path."""
201
+ ntot = len(indices) + len(indices_inv)
202
+
203
+ # This is straight so two vertices anyway
204
+ # NOTE: all paths will be the same, which is why we need to offset them
205
+ vs, ve = trans(paths[indices[0]].vertices)
206
+
207
+ # Move orthogonal to the line
208
+ fracs = (
209
+ (vs - ve) / np.sqrt(((vs - ve) ** 2).sum()) @ np.array([[0, 1], [-1, 0]])
210
+ )
211
+
212
+ # NOTE: for now treat both direction the same
213
+ for i, idx in enumerate(indices + indices_inv):
214
+ # Offset the path
215
+ paths[idx].vertices = trans_inv(
216
+ trans(paths[idx].vertices) + fracs * offset * (i - ntot / 2)
217
+ )
218
+
219
+ def _compute_loop_path(
220
+ self,
221
+ vcoord_fig,
222
+ vpath,
223
+ angle1,
224
+ angle2,
225
+ trans_inv,
226
+ ):
227
+ # Shorten at starting angle
228
+ start = _get_shorter_edge_coords(vpath, angle1) + vcoord_fig
229
+ # Shorten at end angle
230
+ end = _get_shorter_edge_coords(vpath, angle2) + vcoord_fig
231
+
232
+ aux1 = (start - vcoord_fig) * 2.5 + vcoord_fig
233
+ aux2 = (end - vcoord_fig) * 2.5 + vcoord_fig
234
+
235
+ vertices = np.vstack(
236
+ [
237
+ start,
238
+ aux1,
239
+ aux2,
240
+ end,
241
+ ]
242
+ )
243
+ codes = ["MOVETO"] + ["CURVE4"] * 3
244
+
245
+ # Offset to place and transform to data coordinates
246
+ vertices = trans_inv(vertices)
247
+ codes = [getattr(mpl.path.Path, x) for x in codes]
248
+ path = mpl.path.Path(
249
+ vertices,
250
+ codes=codes,
251
+ )
252
+ return path
253
+
254
+ def _shorten_path_undirected_straight(
255
+ self,
256
+ vcoord_fig,
257
+ vpath_fig,
258
+ trans_inv,
259
+ ):
260
+ # Straight SVG instructions
261
+ path = {
262
+ "vertices": [],
263
+ "codes": ["MOVETO", "LINETO"],
264
+ }
265
+
266
+ # Angle of the straight line
267
+ theta = atan2(*((vcoord_fig[1] - vcoord_fig[0])[::-1]))
268
+
269
+ # Shorten at starting vertex
270
+ vs = _get_shorter_edge_coords(vpath_fig[0], theta) + vcoord_fig[0]
271
+ path["vertices"].append(vs)
272
+
273
+ # Shorten at end vertex
274
+ ve = _get_shorter_edge_coords(vpath_fig[1], theta + pi) + vcoord_fig[1]
275
+ path["vertices"].append(ve)
276
+
277
+ path = mpl.path.Path(
278
+ path["vertices"],
279
+ codes=[getattr(mpl.path.Path, x) for x in path["codes"]],
280
+ )
281
+ path.vertices = trans_inv(path.vertices)
282
+ return path
283
+
284
+ def _shorten_path_undirected_curved(
285
+ self,
286
+ vcoord_fig,
287
+ vpath_fig,
288
+ trans_inv,
289
+ tension=+1.5,
290
+ ):
291
+ # Angle of the straight line
292
+ theta = atan2(*((vcoord_fig[1] - vcoord_fig[0])[::-1]))
293
+
294
+ # Shorten at starting vertex
295
+ vs = _get_shorter_edge_coords(vpath_fig[0], theta) + vcoord_fig[0]
296
+
297
+ # Shorten at end vertex
298
+ ve = _get_shorter_edge_coords(vpath_fig[1], theta + pi) + vcoord_fig[1]
299
+
300
+ edge_straight_length = np.sqrt(((ve - vs) ** 2).sum())
301
+
302
+ aux1 = vs + 0.33 * (ve - vs)
303
+ aux2 = vs + 0.67 * (ve - vs)
304
+
305
+ # Move Bezier points orthogonal to the line
306
+ fracs = (
307
+ (vs - ve) / np.sqrt(((vs - ve) ** 2).sum()) @ np.array([[0, 1], [-1, 0]])
308
+ )
309
+ aux1 += 0.1 * fracs * tension * edge_straight_length
310
+ aux2 += 0.1 * fracs * tension * edge_straight_length
311
+
312
+ path = {
313
+ "vertices": [
314
+ vs,
315
+ aux1,
316
+ aux2,
317
+ ve,
318
+ ],
319
+ "codes": ["MOVETO"] + ["CURVE4"] * 3,
320
+ }
321
+
322
+ path = mpl.path.Path(
323
+ path["vertices"],
324
+ codes=[getattr(mpl.path.Path, x) for x in path["codes"]],
325
+ )
326
+ path.vertices = trans_inv(path.vertices)
327
+ return path
328
+
329
+ def _compute_labels(self):
330
+ style = self._style.get("label", None) if self._style is not None else None
331
+ offsets = []
332
+ for path in self._paths:
333
+ offset = _compute_mid_coord(path)
334
+ offsets.append(offset)
335
+
336
+ if not hasattr(self, "_label_collection"):
337
+ self._label_collection = LabelCollection(
338
+ self._labels,
339
+ style=style,
340
+ )
341
+
342
+ # Forward a bunch of mpl settings that are needed
343
+ self._label_collection.set_figure(self.figure)
344
+ self._label_collection.axes = self.axes
345
+ # forward the clippath/box to the children need this logic
346
+ # because mpl exposes some fast-path logic
347
+ clip_path = self.get_clip_path()
348
+ if clip_path is None:
349
+ clip_box = self.get_clip_box()
350
+ self._label_collection.set_clip_box(clip_box)
351
+ else:
352
+ self._label_collection.set_clip_path(clip_path)
353
+
354
+ # Finally make the patches
355
+ self._label_collection._create_labels()
356
+ self._label_collection.set_offsets(offsets)
357
+
358
+ def get_children(self):
359
+ children = []
360
+ if hasattr(self, "_label_collection"):
361
+ children.append(self._label_collection)
362
+ return children
363
+
364
+ @_stale_wrapper
365
+ def draw(self, renderer, *args, **kwds):
366
+ if self._vertex_paths is not None:
367
+ self._paths = self._compute_paths()
368
+ if self._labels is not None:
369
+ self._compute_labels()
370
+ super().draw(renderer)
371
+
372
+ for child in self.get_children():
373
+ child.draw(renderer, *args, **kwds)
374
+
375
+ @property
376
+ def stale(self):
377
+ return super().stale
378
+
379
+ @stale.setter
380
+ def stale(self, val):
381
+ mpl.collections.PatchCollection.stale.fset(self, val)
382
+ if val and hasattr(self, "stale_callback_post"):
383
+ self.stale_callback_post(self)
384
+
385
+
386
+ def make_stub_patch(**kwargs):
387
+ """Make a stub undirected edge patch, without actual path information."""
388
+ kwargs["clip_on"] = kwargs.get("clip_on", True)
389
+ if ("color" in kwargs) and ("edgecolor" not in kwargs):
390
+ kwargs["edgecolor"] = kwargs.pop("color")
391
+ # Edges are always hollow, because they are not closed paths
392
+ kwargs["facecolor"] = "none"
393
+
394
+ # Forget specific properties that are not supported here
395
+ forbidden_props = [
396
+ "curved",
397
+ "tension",
398
+ "offset",
399
+ "label",
400
+ ]
401
+ for prop in forbidden_props:
402
+ if prop in kwargs:
403
+ kwargs.pop(prop)
404
+
405
+ # NOTE: the path is overwritten later anyway, so no reason to spend any time here
406
+ art = mpl.patches.PathPatch(
407
+ mpl.path.Path([[0, 0]]),
408
+ **kwargs,
409
+ )
410
+ return art
411
+
412
+
413
+ def _get_shorter_edge_coords(vpath, theta):
414
+ # Bound theta from -pi to pi (why is that not guaranteed?)
415
+ theta = (theta + pi) % (2 * pi) - pi
416
+
417
+ for i in range(len(vpath)):
418
+ v1 = vpath.vertices[i]
419
+ v2 = vpath.vertices[(i + 1) % len(vpath)]
420
+ theta1 = atan2(*((v1)[::-1]))
421
+ theta2 = atan2(*((v2)[::-1]))
422
+
423
+ # atan2 ranges ]-3.14, 3.14]
424
+ # so it can be that theta1 is -3 and theta2 is +3
425
+ # therefore we need two separate cases, one that cuts at pi and one at 0
426
+ cond1 = theta1 <= theta <= theta2
427
+ cond2 = (
428
+ (theta1 + 2 * pi) % (2 * pi)
429
+ <= (theta + 2 * pi) % (2 * pi)
430
+ <= (theta2 + 2 * pi) % (2 * pi)
431
+ )
432
+ if cond1 or cond2:
433
+ break
434
+ else:
435
+ raise ValueError("Angle for patch not found")
436
+
437
+ # The edge meets the patch of the vertex on the v1-v2 size,
438
+ # at angle theta from the center
439
+ mtheta = tan(theta)
440
+ if v2[0] == v1[0]:
441
+ xe = v1[0]
442
+ else:
443
+ m12 = (v2[1] - v1[1]) / (v2[0] - v1[0])
444
+ xe = (v1[1] - m12 * v1[0]) / (mtheta - m12)
445
+ ye = mtheta * xe
446
+ ve = np.array([xe, ye])
447
+ return ve
iplotx/groups.py ADDED
@@ -0,0 +1,141 @@
1
+ from typing import Union, Sequence
2
+ from copy import deepcopy
3
+ from collections import defaultdict
4
+ import numpy as np
5
+ import pandas as pd
6
+ import matplotlib as mpl
7
+ from matplotlib.collections import PatchCollection
8
+
9
+
10
+ from .importing import igraph
11
+ from .typing import (
12
+ GroupingType,
13
+ LayoutType,
14
+ )
15
+ from .heuristics import normalise_layout, normalise_grouping
16
+ from .styles import get_style, rotate_style
17
+ from .utils.geometry import (
18
+ convex_hull,
19
+ _compute_group_path_with_vertex_padding,
20
+ )
21
+
22
+
23
+ class GroupingArtist(PatchCollection):
24
+ def __init__(
25
+ self,
26
+ grouping: GroupingType,
27
+ layout: LayoutType,
28
+ vertexpadding: Union[None, int] = None,
29
+ *args,
30
+ **kwargs,
31
+ ):
32
+ """Container artist for vertex groupings, e.g. covers or clusterings.
33
+
34
+ Parameters:
35
+ grouping: This can be a sequence of sets (a la networkx), an igraph Clustering
36
+ or Cover instance (including VertexClustering/VertexCover), or a sequence
37
+ of integers/strings indicating memberships for each vertex.
38
+ layout: The layout of the vertices. If this object has no keys/index, the
39
+ vertices are assumed to have IDs corresponding to integers starting from
40
+ zero.
41
+ """
42
+ if vertexpadding is not None:
43
+ self._vertexpadding = vertexpadding
44
+ else:
45
+ style = get_style(".grouping")
46
+ self._vertexpadding = style.get("vertexpadding", 10)
47
+ patches, grouping, layout = self._create_patches(grouping, layout, **kwargs)
48
+ self._grouping = grouping
49
+ self._layout = layout
50
+ kwargs["match_original"] = True
51
+
52
+ super().__init__(patches, *args, **kwargs)
53
+
54
+ def _create_patches(self, grouping, layout, **kwargs):
55
+ layout = normalise_layout(layout)
56
+ grouping = normalise_grouping(grouping, layout)
57
+ style = get_style(".grouping")
58
+ style.pop("vertexpadding", None)
59
+
60
+ style.update(kwargs)
61
+
62
+ patches = []
63
+ for i, (name, vids) in enumerate(grouping.items()):
64
+ if len(vids) == 0:
65
+ continue
66
+ vids = np.array(list(vids))
67
+ coords = layout.loc[vids].values
68
+ idx_hull = convex_hull(coords)
69
+ coords_hull = coords[idx_hull]
70
+
71
+ stylei = rotate_style(style, i)
72
+
73
+ # NOTE: the transform is set later on
74
+ patch = _compute_group_patch_stub(
75
+ coords_hull,
76
+ self._vertexpadding,
77
+ label=name,
78
+ **stylei,
79
+ )
80
+
81
+ patches.append(patch)
82
+ return patches, grouping, layout
83
+
84
+ def _compute_paths(self):
85
+ if self._vertexpadding > 0:
86
+ for i, path in enumerate(self._paths):
87
+ self._paths[i].vertices = _compute_group_path_with_vertex_padding(
88
+ path.vertices,
89
+ self.get_transform(),
90
+ vertexpadding=self._vertexpadding,
91
+ )
92
+
93
+ def _process(self):
94
+ self.set_transform(self.axes.transData)
95
+ self._compute_paths()
96
+
97
+ def draw(self, renderer):
98
+ self._compute_paths()
99
+ super().draw(renderer)
100
+
101
+
102
+ def _compute_group_patch_stub(
103
+ points,
104
+ vertexpadding,
105
+ **kwargs,
106
+ ):
107
+ if vertexpadding == 0:
108
+ return mpl.patches.Polygon(
109
+ points,
110
+ **kwargs,
111
+ )
112
+
113
+ # NOTE: Closing point: mpl is a bit quirky here
114
+ vertices = []
115
+ codes = []
116
+ if len(points) == 0:
117
+ vertices = np.zeros((0, 2))
118
+ elif len(points) == 1:
119
+ vertices = [points[0]] * 9
120
+ codes = ["MOVETO"] + ["CURVE3"] * 8
121
+ elif len(points) == 2:
122
+ vertices = [points[0]] * 5 + [points[1]] * 5 + [points[0]]
123
+ codes = ["MOVETO"] + ["CURVE3"] * 4 + ["LINETO"] + ["CURVE3"] * 4 + ["LINETO"]
124
+ else:
125
+ for point in points:
126
+ vertices.extend([point] * 3)
127
+ codes.extend(["LINETO", "CURVE3", "CURVE3"])
128
+ vertices.append(vertices[0])
129
+ codes.append("LINETO")
130
+ codes[0] = "MOVETO"
131
+
132
+ codes = [getattr(mpl.path.Path, x) for x in codes]
133
+ patch = mpl.patches.PathPatch(
134
+ mpl.path.Path(
135
+ vertices,
136
+ codes=codes,
137
+ ),
138
+ **kwargs,
139
+ )
140
+
141
+ return patch
iplotx/heuristics.py ADDED
@@ -0,0 +1,114 @@
1
+ from collections import defaultdict
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ from .importing import igraph, networkx
6
+ from .typing import GraphType, GroupingType, LayoutType
7
+
8
+
9
+ def network_library(
10
+ network: GraphType,
11
+ ) -> str:
12
+ if igraph is not None and isinstance(network, igraph.Graph):
13
+ return "igraph"
14
+ if networkx is not None:
15
+ if isinstance(network, networkx.Graph):
16
+ return "networkx"
17
+ if isinstance(network, networkx.DiGraph):
18
+ return "networkx"
19
+ if isinstance(network, networkx.MultiGraph):
20
+ return "networkx"
21
+ if isinstance(network, networkx.MultiDiGraph):
22
+ return "networkx"
23
+ raise TypeError("Unsupported graph type. Supported types are igraph and networkx.")
24
+
25
+
26
+ def detect_directedness(
27
+ network: GraphType,
28
+ ) -> np.ndarray:
29
+ """Detect if the network is directed or not."""
30
+ if network_library(network) == "igraph":
31
+ return network.is_directed()
32
+ if isinstance(network, (networkx.DiGraph, networkx.MultiDiGraph)):
33
+ return True
34
+ return False
35
+
36
+
37
+ def normalise_layout(layout):
38
+ """Normalise the layout to a pandas.DataFrame."""
39
+ if layout is None:
40
+ return None
41
+ if isinstance(layout, dict):
42
+ layout = pd.DataFrame(layout).T
43
+ if isinstance(layout, str):
44
+ raise NotImplementedError("Layout as a string is not supported yet.")
45
+ if isinstance(layout, (list, tuple)):
46
+ return pd.DataFrame(np.array(layout))
47
+ if isinstance(layout, pd.DataFrame):
48
+ return layout
49
+ if isinstance(layout, np.ndarray):
50
+ return pd.DataFrame(layout)
51
+ raise TypeError(
52
+ "Layout must be a string, list, tuple, numpy array or pandas DataFrame."
53
+ )
54
+
55
+
56
+ def normalise_grouping(
57
+ grouping: GroupingType,
58
+ layout: LayoutType,
59
+ ) -> dict[set]:
60
+
61
+ if len(grouping) == 0:
62
+ return {}
63
+
64
+ if isinstance(grouping, dict):
65
+ val0 = next(iter(grouping.values()))
66
+ # If already the right data type or compatible, leave as is
67
+ if isinstance(val0, (set, frozenset)):
68
+ return grouping
69
+
70
+ # If a dict of integers or strings, assume each key is a vertex id and each value is a
71
+ # group, convert (i.e. invert the dict)
72
+ if isinstance(val0, (int, str)):
73
+ group_dic = defaultdict(set)
74
+ for key, val in grouping.items():
75
+ group_dic[val].add(key)
76
+ return group_dic
77
+
78
+ # If an igraph object, convert to a dict of sets
79
+ if igraph is not None:
80
+ if isinstance(grouping, igraph.clustering.Clustering):
81
+ layout = normalise_layout(layout)
82
+ group_dic = defaultdict(set)
83
+ for i, member in enumerate(grouping.membership):
84
+ group_dic[member].add(i)
85
+ return group_dic
86
+
87
+ if isinstance(grouping, igraph.clustering.Cover):
88
+ layout = normalise_layout(layout)
89
+ group_dic = defaultdict(set)
90
+ for i, members in enumerate(grouping.membership):
91
+ for member in members:
92
+ group_dic[member].add(i)
93
+ return group_dic
94
+
95
+ # Assume it's a sequence, so convert to list
96
+ grouping = list(grouping)
97
+
98
+ # If the values are already sets, assume group indices are integers
99
+ # and values are as is
100
+ if isinstance(grouping[0], set):
101
+ group_dic = {i: val for i, val in enumerate(grouping)}
102
+ return group_dic
103
+
104
+ # If the values are integers or strings, assume each key is a vertex id and each value is a
105
+ # group, convert to dict of sets
106
+ if isinstance(grouping[0], (int, str)):
107
+ group_dic = defaultdict(set)
108
+ for i, val in enumerate(grouping):
109
+ group_dic[val].add(i)
110
+ return group_dic
111
+
112
+ raise TypeError(
113
+ "Could not standardise grouping from object.",
114
+ )