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
|
@@ -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
|
+
)
|