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/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .version import __version__
2
+ from .plotting import plot
iplotx/edge/arrow.py ADDED
@@ -0,0 +1,122 @@
1
+ import numpy as np
2
+ import matplotlib as mpl
3
+ from matplotlib.patches import PathPatch
4
+
5
+
6
+ def make_arrow_patch(marker: str = "|>", width: float = 8, **kwargs):
7
+ """Make a patch of the given marker shape and size."""
8
+ height = kwargs.pop("height", width * 1.3)
9
+
10
+ if marker == "|>":
11
+ codes = ["MOVETO", "LINETO", "LINETO"]
12
+ path = mpl.path.Path(
13
+ np.array([[-height, width * 0.5], [-height, -width * 0.5], [0, 0]]),
14
+ codes=[getattr(mpl.path.Path, x) for x in codes],
15
+ closed=True,
16
+ )
17
+ elif marker == ">":
18
+ kwargs["facecolor"] = "none"
19
+ if "color" in kwargs:
20
+ kwargs["edgecolor"] = kwargs.pop("color")
21
+ codes = ["MOVETO", "LINETO", "LINETO"]
22
+ path = mpl.path.Path(
23
+ np.array([[-height, width * 0.5], [0, 0], [-height, -width * 0.5]]),
24
+ codes=[getattr(mpl.path.Path, x) for x in codes],
25
+ closed=False,
26
+ )
27
+ elif marker == ">>":
28
+ overhang = kwargs.pop("overhang", 0.25)
29
+ codes = ["MOVETO", "LINETO", "LINETO", "LINETO"]
30
+ path = mpl.path.Path(
31
+ np.array(
32
+ [
33
+ [0, 0],
34
+ [-height, -width * 0.5],
35
+ [-height * (1.0 - overhang), 0],
36
+ [-height, width * 0.5],
37
+ ]
38
+ ),
39
+ codes=[getattr(mpl.path.Path, x) for x in codes],
40
+ closed=True,
41
+ )
42
+ elif marker == ")>":
43
+ overhang = kwargs.pop("overhang", 0.25)
44
+ codes = ["MOVETO", "LINETO", "CURVE3", "CURVE3"]
45
+ path = mpl.path.Path(
46
+ np.array(
47
+ [
48
+ [0, 0],
49
+ [-height, -width * 0.5],
50
+ [-height * (1.0 - overhang), 0],
51
+ [-height, width * 0.5],
52
+ ]
53
+ ),
54
+ codes=[getattr(mpl.path.Path, x) for x in codes],
55
+ closed=True,
56
+ )
57
+ elif marker == ")":
58
+ kwargs["facecolor"] = "none"
59
+ if "color" in kwargs:
60
+ kwargs["edgecolor"] = kwargs.pop("color")
61
+ codes = ["MOVETO", "CURVE3", "CURVE3"]
62
+ path = mpl.path.Path(
63
+ np.array(
64
+ [
65
+ [-height * 0.5, width * 0.5],
66
+ [height * 0.5, 0],
67
+ [-height * 0.5, -width * 0.5],
68
+ ]
69
+ ),
70
+ codes=[getattr(mpl.path.Path, x) for x in codes],
71
+ closed=False,
72
+ )
73
+ elif marker == "d":
74
+ codes = ["MOVETO", "LINETO", "LINETO", "LINETO"]
75
+ path = mpl.path.Path(
76
+ np.array(
77
+ [
78
+ [-height * 0.5, width * 0.5],
79
+ [-height, 0],
80
+ [-height * 0.5, -width * 0.5],
81
+ [0, 0],
82
+ ]
83
+ ),
84
+ codes=[getattr(mpl.path.Path, x) for x in codes],
85
+ closed=True,
86
+ )
87
+ elif marker == "p":
88
+ codes = ["MOVETO", "LINETO", "LINETO", "LINETO"]
89
+ path = mpl.path.Path(
90
+ np.array(
91
+ [
92
+ [-height, 0],
93
+ [0, 0],
94
+ [0, -width],
95
+ [-height, -width],
96
+ ]
97
+ ),
98
+ codes=[getattr(mpl.path.Path, x) for x in codes],
99
+ closed=True,
100
+ )
101
+ elif marker == "q":
102
+ codes = ["MOVETO", "LINETO", "LINETO", "LINETO"]
103
+ path = mpl.path.Path(
104
+ np.array(
105
+ [
106
+ [-height, 0],
107
+ [0, 0],
108
+ [0, width],
109
+ [-height, width],
110
+ ]
111
+ ),
112
+ codes=[getattr(mpl.path.Path, x) for x in codes],
113
+ closed=True,
114
+ )
115
+
116
+ patch = PathPatch(
117
+ path,
118
+ **kwargs,
119
+ )
120
+ return patch
121
+
122
+ raise KeyError(f"Unknown marker: {marker}")
iplotx/edge/common.py ADDED
@@ -0,0 +1,47 @@
1
+ from math import pi
2
+ import numpy as np
3
+
4
+
5
+ def _compute_loops_per_angle(nloops, angles):
6
+ if len(angles) == 0:
7
+ return [(0, 2 * pi, nloops)]
8
+
9
+ angles_sorted_closed = list(sorted(angles))
10
+ angles_sorted_closed.append(angles_sorted_closed[0] + 2 * pi)
11
+ deltas = np.diff(angles_sorted_closed)
12
+
13
+ # Now we have the deltas and the total number of loops
14
+ # 1. Assign all loops to the largest wedge
15
+ idx_dmax = deltas.argmax()
16
+ if nloops == 1:
17
+ return [
18
+ (angles_sorted_closed[idx_dmax], angles_sorted_closed[idx_dmax + 1], nloops)
19
+ ]
20
+
21
+ # 2. Check if any other wedges are larger than this
22
+ # If not, we are done (this is the algo in igraph)
23
+ dsplit = deltas[idx_dmax] / nloops
24
+ if (deltas > dsplit).sum() < 2:
25
+ return [
26
+ (angles_sorted_closed[idx_dmax], angles_sorted_closed[idx_dmax + 1], nloops)
27
+ ]
28
+
29
+ # 3. Check how small the second-largest wedge would become
30
+ idx_dsort = np.argsort(deltas)
31
+ return [
32
+ (
33
+ angles_sorted_closed[idx_dmax],
34
+ angles_sorted_closed[idx_dmax + 1],
35
+ nloops - 1,
36
+ ),
37
+ (
38
+ angles_sorted_closed[idx_dsort[-2]],
39
+ angles_sorted_closed[idx_dsort[-2] + 1],
40
+ 1,
41
+ ),
42
+ ]
43
+
44
+ ## TODO: we should greedily iterate from this
45
+ ## TODO: finish this
46
+ # dsplit_new = dsplit * nloops / (nloops - 1)
47
+ # dsplit2_new = deltas[idx_dsort[-2]]
@@ -0,0 +1,149 @@
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 ADDED
@@ -0,0 +1,50 @@
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)