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 +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
iplotx/__init__.py
ADDED
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]]
|
iplotx/edge/directed.py
ADDED
|
@@ -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)
|