iplotx 0.1.0__py3-none-any.whl → 0.2.0__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/edge/directed.py DELETED
@@ -1,149 +0,0 @@
1
- from copy import deepcopy
2
- from math import atan2, tan, cos, pi, sin
3
- import numpy as np
4
- import matplotlib as mpl
5
- from matplotlib.transforms import Affine2D
6
-
7
- from .common import _compute_loops_per_angle
8
- from .undirected import UndirectedEdgeCollection
9
- from .arrow import make_arrow_patch
10
- from ..utils.matplotlib import (
11
- _stale_wrapper,
12
- _forwarder,
13
- )
14
-
15
-
16
- @_forwarder(
17
- (
18
- "set_clip_path",
19
- "set_clip_box",
20
- "set_transform",
21
- "set_snap",
22
- "set_sketch_params",
23
- "set_figure",
24
- "set_animated",
25
- "set_picker",
26
- )
27
- )
28
- class DirectedEdgeCollection(mpl.artist.Artist):
29
- def __init__(self, edges, arrows, labels=None, **kwargs):
30
- super().__init__()
31
-
32
- # FIXME: do we need a separate _clear_state and _process like in the network
33
- self._edges = UndirectedEdgeCollection(edges, labels=labels, **kwargs)
34
-
35
- # NOTE: offsets are a placeholder for later
36
- self._arrows = EdgeArrowCollection(
37
- arrows,
38
- offsets=np.zeros((len(arrows), 2)),
39
- offset_transform=kwargs["transform"],
40
- transform=Affine2D(),
41
- match_original=True,
42
- )
43
- self._processed = False
44
-
45
- def get_children(self):
46
- artists = []
47
- # Collect edges first. This way vertices are on top of edges,
48
- # since vertices are drawn later. That is what most people expect.
49
- if self._edges is not None:
50
- artists.append(self._edges)
51
- if self._arrows is not None:
52
- artists.append(self._arrows)
53
- return tuple(artists)
54
-
55
- def get_edges(self):
56
- """Get UndirectedEdgeCollection artist."""
57
- return self._edges
58
-
59
- def get_arrows(self):
60
- """Get EdgeArrowCollection artist."""
61
- return self._arrows
62
-
63
- def get_paths(self):
64
- """Get the edge paths."""
65
- return self._edges.get_paths()
66
-
67
- def _process(self):
68
- # Forward mpl properties to children
69
- # TODO sort out all of the things that need to be forwarded
70
- for child in self.get_children():
71
- # set the figure & axes on child, this ensures each artist
72
- # down the hierarchy knows where to draw
73
- if hasattr(child, "set_figure"):
74
- child.set_figure(self.figure)
75
- child.axes = self.axes
76
-
77
- # forward the clippath/box to the children need this logic
78
- # because mpl exposes some fast-path logic
79
- clip_path = self.get_clip_path()
80
- if clip_path is None:
81
- clip_box = self.get_clip_box()
82
- child.set_clip_box(clip_box)
83
- else:
84
- child.set_clip_path(clip_path)
85
-
86
- self._processed = True
87
-
88
- def _set_edge_info_for_arrows(
89
- self,
90
- which="end",
91
- transform=None,
92
- ):
93
- """Extract the start and/or end angles of the paths to compute arrows."""
94
- if transform is None:
95
- transform = self.get_edges().get_transform()
96
- trans = transform.transform
97
- trans_inv = transform.inverted().transform
98
-
99
- arrow_offsets = self._arrows._offsets
100
- for i, epath in enumerate(self._edges._paths):
101
- # Offset the arrow to point to the end of the edge
102
- self._arrows._offsets[i] = epath.vertices[-1]
103
-
104
- # Rotate the arrow to point in the direction of the edge
105
- apath = self._arrows._paths[i]
106
- # NOTE: because the tip of the arrow is at (0, 0) in patch space,
107
- # in theory it will rotate around that point already
108
- v2 = trans(epath.vertices[-1])
109
- v1 = trans(epath.vertices[-2])
110
- dv = v2 - v1
111
- theta = atan2(*(dv[::-1]))
112
- theta_old = self._arrows._angles[i]
113
- dtheta = theta - theta_old
114
- mrot = np.array([[cos(dtheta), sin(dtheta)], [-sin(dtheta), cos(dtheta)]])
115
- apath.vertices = apath.vertices @ mrot
116
- self._arrows._angles[i] = theta
117
-
118
- @_stale_wrapper
119
- def draw(self, renderer, *args, **kwds):
120
- """Draw each of the children, with some buffering mechanism."""
121
- if not self.get_visible():
122
- return
123
-
124
- if not self._processed:
125
- self._process()
126
-
127
- # We should manage zorder ourselves, but we need to compute
128
- # the new offsets and angles of arrows from the edges before drawing them
129
- self._edges.draw(renderer, *args, **kwds)
130
- self._set_edge_info_for_arrows(which="end")
131
- self._arrows.draw(renderer, *args, **kwds)
132
-
133
-
134
- class EdgeArrowCollection(mpl.collections.PatchCollection):
135
- """Collection of arrow patches for plotting directed edgs."""
136
-
137
- def __init__(self, *args, **kwargs):
138
- super().__init__(*args, **kwargs)
139
- self._angles = np.zeros(len(self._paths))
140
-
141
- @property
142
- def stale(self):
143
- return super().stale
144
-
145
- @stale.setter
146
- def stale(self, val):
147
- mpl.collections.PatchCollection.stale.fset(self, val)
148
- if val and hasattr(self, "stale_callback_post"):
149
- self.stale_callback_post(self)
iplotx/edge/label.py DELETED
@@ -1,50 +0,0 @@
1
- import numpy as np
2
- import matplotlib as mpl
3
-
4
- from ..utils.matplotlib import (
5
- _stale_wrapper,
6
- _forwarder,
7
- _additional_set_methods,
8
- )
9
-
10
-
11
- class LabelCollection(mpl.artist.Artist):
12
- def __init__(self, labels, style=None):
13
- self._labels = labels
14
- self._style = style
15
- super().__init__()
16
-
17
- def _create_labels(self):
18
- style = self._style if self._style is not None else {}
19
-
20
- arts = []
21
- for label in self._labels:
22
- art = mpl.text.Text(
23
- 0,
24
- 0,
25
- label,
26
- transform=self.axes.transData,
27
- **style,
28
- )
29
- art.set_figure(self.figure)
30
- art.axes = self.axes
31
- arts.append(art)
32
- self._labels = arts
33
-
34
- def get_children(self):
35
- return self._labels
36
-
37
- def set_offsets(self, offsets):
38
- for art, offset in zip(self._labels, offsets):
39
- art.set_position((offset[0], offset[1]))
40
-
41
- @_stale_wrapper
42
- def draw(self, renderer, *args, **kwds):
43
- """Draw each of the children, with some buffering mechanism."""
44
- if not self.get_visible():
45
- return
46
-
47
- # We should manage zorder ourselves, but we need to compute
48
- # the new offsets and angles of arrows from the edges before drawing them
49
- for art in self.get_children():
50
- art.draw(renderer, *args, **kwds)
iplotx/edge/undirected.py DELETED
@@ -1,447 +0,0 @@
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