iplotx 0.2.0__py3-none-any.whl → 0.2.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/edge/__init__.py +160 -419
- iplotx/edge/arrow.py +20 -20
- iplotx/edge/geometry.py +392 -0
- iplotx/edge/ports.py +7 -2
- iplotx/groups.py +24 -14
- iplotx/label.py +49 -14
- iplotx/layout.py +3 -3
- iplotx/network.py +9 -8
- iplotx/style.py +18 -6
- iplotx/tree.py +48 -21
- iplotx/typing.py +19 -0
- iplotx/version.py +1 -1
- iplotx/vertex.py +84 -29
- {iplotx-0.2.0.dist-info → iplotx-0.2.1.dist-info}/METADATA +15 -3
- {iplotx-0.2.0.dist-info → iplotx-0.2.1.dist-info}/RECORD +16 -15
- {iplotx-0.2.0.dist-info → iplotx-0.2.1.dist-info}/WHEEL +0 -0
iplotx/edge/arrow.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
"""
|
|
2
|
+
Module for edge arrows in iplotx.
|
|
3
|
+
"""
|
|
4
|
+
|
|
4
5
|
import numpy as np
|
|
5
6
|
import matplotlib as mpl
|
|
6
7
|
from matplotlib.patches import PathPatch
|
|
@@ -19,10 +20,17 @@ class EdgeArrowCollection(mpl.collections.PatchCollection):
|
|
|
19
20
|
def __init__(
|
|
20
21
|
self,
|
|
21
22
|
edge_collection,
|
|
22
|
-
transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
|
|
23
23
|
*args,
|
|
24
|
+
transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
|
|
24
25
|
**kwargs,
|
|
25
|
-
):
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Initialize the edge arrow collection.
|
|
28
|
+
|
|
29
|
+
Parameters:
|
|
30
|
+
edge_collection: The edge collection to which these arrows belong.
|
|
31
|
+
transform: The transform to apply to the arrows. This related to the arrow size
|
|
32
|
+
scaling, not the arrow tip position which is controlled by set_offset_transform.
|
|
33
|
+
"""
|
|
26
34
|
|
|
27
35
|
self._edge_collection = edge_collection
|
|
28
36
|
self._style = get_style(".edge.arrow")
|
|
@@ -48,10 +56,11 @@ class EdgeArrowCollection(mpl.collections.PatchCollection):
|
|
|
48
56
|
self.set_sizes(sizes)
|
|
49
57
|
|
|
50
58
|
def get_sizes(self):
|
|
51
|
-
"""Get
|
|
59
|
+
"""Get arrow sizes (max of width and height), not scaled by dpi."""
|
|
52
60
|
return self._sizes
|
|
53
61
|
|
|
54
62
|
def get_sizes_dpi(self):
|
|
63
|
+
"""Get arrow sizes (max of width and height) scaled by dpi."""
|
|
55
64
|
return self._transforms[:, 0, 0]
|
|
56
65
|
|
|
57
66
|
def set_sizes(self, sizes, dpi=72.0):
|
|
@@ -77,7 +86,7 @@ class EdgeArrowCollection(mpl.collections.PatchCollection):
|
|
|
77
86
|
get_size = get_sizes
|
|
78
87
|
set_size = set_sizes
|
|
79
88
|
|
|
80
|
-
def set_figure(self, fig) ->
|
|
89
|
+
def set_figure(self, fig) -> None:
|
|
81
90
|
"""Set the figure for this artist and all children."""
|
|
82
91
|
super().set_figure(fig)
|
|
83
92
|
self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
|
|
@@ -85,6 +94,7 @@ class EdgeArrowCollection(mpl.collections.PatchCollection):
|
|
|
85
94
|
child.set_figure(fig)
|
|
86
95
|
|
|
87
96
|
def get_offset_transform(self):
|
|
97
|
+
"""Get offset transform for the edge arrows. This sets the tip of each arrow."""
|
|
88
98
|
return self._edge_collection.get_transform()
|
|
89
99
|
|
|
90
100
|
get_size = get_sizes
|
|
@@ -95,7 +105,7 @@ class EdgeArrowCollection(mpl.collections.PatchCollection):
|
|
|
95
105
|
|
|
96
106
|
patches = []
|
|
97
107
|
sizes = []
|
|
98
|
-
for i
|
|
108
|
+
for i in range(len(self._edge_collection._vertex_ids)):
|
|
99
109
|
stylei = rotate_style(style, index=i)
|
|
100
110
|
if ("facecolor" not in stylei) and ("color" not in stylei):
|
|
101
111
|
stylei["facecolor"] = self._edge_collection.get_edgecolors()[i][:3]
|
|
@@ -114,7 +124,7 @@ class EdgeArrowCollection(mpl.collections.PatchCollection):
|
|
|
114
124
|
|
|
115
125
|
return patches, sizes
|
|
116
126
|
|
|
117
|
-
def set_array(self,
|
|
127
|
+
def set_array(self, A):
|
|
118
128
|
"""Set the array for cmap/norm coloring, but keep the facecolors as set (usually 'none')."""
|
|
119
129
|
raise ValueError("Setting an array for arrows directly is not supported.")
|
|
120
130
|
|
|
@@ -122,20 +132,10 @@ class EdgeArrowCollection(mpl.collections.PatchCollection):
|
|
|
122
132
|
"""Set arrow colors (edge and/or face) based on a colormap."""
|
|
123
133
|
# NOTE: facecolors is always an array because we come from patches
|
|
124
134
|
# It can have zero alpha (i.e. if we choose "none", or a hollow marker)
|
|
125
|
-
self.
|
|
135
|
+
self.set_edgecolor(colors)
|
|
126
136
|
has_facecolor = self._facecolors[:, 3] > 0
|
|
127
137
|
self._facecolors[has_facecolor] = colors[has_facecolor]
|
|
128
138
|
|
|
129
|
-
@property
|
|
130
|
-
def stale(self):
|
|
131
|
-
return super().stale
|
|
132
|
-
|
|
133
|
-
@stale.setter
|
|
134
|
-
def stale(self, val):
|
|
135
|
-
mpl.collections.PatchCollection.stale.fset(self, val)
|
|
136
|
-
if val and hasattr(self, "stale_callback_post"):
|
|
137
|
-
self.stale_callback_post(self)
|
|
138
|
-
|
|
139
139
|
@mpl.artist.allow_rasterization
|
|
140
140
|
def draw(self, renderer):
|
|
141
141
|
self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
|
iplotx/edge/geometry.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Support module with geometry- and path-related functions for edges.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from math import atan2, tan, pi
|
|
7
|
+
import numpy as np
|
|
8
|
+
import matplotlib as mpl
|
|
9
|
+
|
|
10
|
+
from ..typing import (
|
|
11
|
+
Pair,
|
|
12
|
+
)
|
|
13
|
+
from .ports import _get_port_unit_vector
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _compute_loops_per_angle(nloops, angles):
|
|
17
|
+
if len(angles) == 0:
|
|
18
|
+
return [(0, 2 * pi, nloops)]
|
|
19
|
+
|
|
20
|
+
angles_sorted_closed = list(sorted(angles))
|
|
21
|
+
angles_sorted_closed.append(angles_sorted_closed[0] + 2 * pi)
|
|
22
|
+
deltas = np.diff(angles_sorted_closed)
|
|
23
|
+
|
|
24
|
+
# Now we have the deltas and the total number of loops
|
|
25
|
+
# 1. Assign all loops to the largest wedge
|
|
26
|
+
idx_dmax = deltas.argmax()
|
|
27
|
+
if nloops == 1:
|
|
28
|
+
return [
|
|
29
|
+
(
|
|
30
|
+
angles_sorted_closed[idx_dmax],
|
|
31
|
+
angles_sorted_closed[idx_dmax + 1],
|
|
32
|
+
nloops,
|
|
33
|
+
)
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# 2. Check if any other wedges are larger than this
|
|
37
|
+
# If not, we are done (this is the algo in igraph)
|
|
38
|
+
dsplit = deltas[idx_dmax] / nloops
|
|
39
|
+
if (deltas > dsplit).sum() < 2:
|
|
40
|
+
return [
|
|
41
|
+
(
|
|
42
|
+
angles_sorted_closed[idx_dmax],
|
|
43
|
+
angles_sorted_closed[idx_dmax + 1],
|
|
44
|
+
nloops,
|
|
45
|
+
)
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# 3. Check how small the second-largest wedge would become
|
|
49
|
+
idx_dsort = np.argsort(deltas)
|
|
50
|
+
return [
|
|
51
|
+
(
|
|
52
|
+
angles_sorted_closed[idx_dmax],
|
|
53
|
+
angles_sorted_closed[idx_dmax + 1],
|
|
54
|
+
nloops - 1,
|
|
55
|
+
),
|
|
56
|
+
(
|
|
57
|
+
angles_sorted_closed[idx_dsort[-2]],
|
|
58
|
+
angles_sorted_closed[idx_dsort[-2] + 1],
|
|
59
|
+
1,
|
|
60
|
+
),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_shorter_edge_coords(vpath, vsize, theta):
|
|
65
|
+
# Bound theta from -pi to pi (why is that not guaranteed?)
|
|
66
|
+
theta = (theta + pi) % (2 * pi) - pi
|
|
67
|
+
|
|
68
|
+
# Size zero vertices need no shortening
|
|
69
|
+
if vsize == 0:
|
|
70
|
+
return np.array([0, 0])
|
|
71
|
+
|
|
72
|
+
for i in range(len(vpath)):
|
|
73
|
+
v1 = vpath.vertices[i]
|
|
74
|
+
v2 = vpath.vertices[(i + 1) % len(vpath)]
|
|
75
|
+
theta1 = atan2(*((v1)[::-1]))
|
|
76
|
+
theta2 = atan2(*((v2)[::-1]))
|
|
77
|
+
|
|
78
|
+
# atan2 ranges ]-3.14, 3.14]
|
|
79
|
+
# so it can be that theta1 is -3 and theta2 is +3
|
|
80
|
+
# therefore we need two separate cases, one that cuts at pi and one at 0
|
|
81
|
+
cond1 = theta1 <= theta <= theta2
|
|
82
|
+
cond2 = (
|
|
83
|
+
(theta1 + 2 * pi) % (2 * pi)
|
|
84
|
+
<= (theta + 2 * pi) % (2 * pi)
|
|
85
|
+
<= (theta2 + 2 * pi) % (2 * pi)
|
|
86
|
+
)
|
|
87
|
+
if cond1 or cond2:
|
|
88
|
+
break
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError("Angle for patch not found")
|
|
91
|
+
|
|
92
|
+
# The edge meets the patch of the vertex on the v1-v2 size,
|
|
93
|
+
# at angle theta from the center
|
|
94
|
+
mtheta = tan(theta)
|
|
95
|
+
if v2[0] == v1[0]:
|
|
96
|
+
xe = v1[0]
|
|
97
|
+
else:
|
|
98
|
+
m12 = (v2[1] - v1[1]) / (v2[0] - v1[0])
|
|
99
|
+
xe = (v1[1] - m12 * v1[0]) / (mtheta - m12)
|
|
100
|
+
ye = mtheta * xe
|
|
101
|
+
ve = np.array([xe, ye])
|
|
102
|
+
return ve * vsize
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _fix_parallel_edges_straight(
|
|
106
|
+
paths,
|
|
107
|
+
indices,
|
|
108
|
+
indices_inv,
|
|
109
|
+
trans,
|
|
110
|
+
trans_inv,
|
|
111
|
+
offset=3,
|
|
112
|
+
):
|
|
113
|
+
"""Offset parallel edges along the same path."""
|
|
114
|
+
ntot = len(indices) + len(indices_inv)
|
|
115
|
+
|
|
116
|
+
# This is straight so two vertices anyway
|
|
117
|
+
# NOTE: all paths will be the same, which is why we need to offset them
|
|
118
|
+
vs, ve = trans(paths[indices[0]].vertices)
|
|
119
|
+
|
|
120
|
+
# Move orthogonal to the line
|
|
121
|
+
fracs = (vs - ve) / np.sqrt(((vs - ve) ** 2).sum()) @ np.array([[0, 1], [-1, 0]])
|
|
122
|
+
|
|
123
|
+
# NOTE: for now treat both direction the same
|
|
124
|
+
for i, idx in enumerate(indices + indices_inv):
|
|
125
|
+
# Offset the path
|
|
126
|
+
paths[idx].vertices = trans_inv(
|
|
127
|
+
trans(paths[idx].vertices) + fracs * offset * (i - ntot / 2)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _compute_loop_path(
|
|
132
|
+
vcoord_fig,
|
|
133
|
+
vpath,
|
|
134
|
+
vsize,
|
|
135
|
+
angle1,
|
|
136
|
+
angle2,
|
|
137
|
+
trans_inv,
|
|
138
|
+
looptension,
|
|
139
|
+
):
|
|
140
|
+
# Shorten at starting angle
|
|
141
|
+
start = _get_shorter_edge_coords(vpath, vsize, angle1) + vcoord_fig
|
|
142
|
+
# Shorten at end angle
|
|
143
|
+
end = _get_shorter_edge_coords(vpath, vsize, angle2) + vcoord_fig
|
|
144
|
+
|
|
145
|
+
aux1 = (start - vcoord_fig) * looptension + vcoord_fig
|
|
146
|
+
aux2 = (end - vcoord_fig) * looptension + vcoord_fig
|
|
147
|
+
|
|
148
|
+
vertices = np.vstack(
|
|
149
|
+
[
|
|
150
|
+
start,
|
|
151
|
+
aux1,
|
|
152
|
+
aux2,
|
|
153
|
+
end,
|
|
154
|
+
]
|
|
155
|
+
)
|
|
156
|
+
codes = ["MOVETO"] + ["CURVE4"] * 3
|
|
157
|
+
|
|
158
|
+
# Offset to place and transform to data coordinates
|
|
159
|
+
vertices = trans_inv(vertices)
|
|
160
|
+
codes = [getattr(mpl.path.Path, x) for x in codes]
|
|
161
|
+
path = mpl.path.Path(
|
|
162
|
+
vertices,
|
|
163
|
+
codes=codes,
|
|
164
|
+
)
|
|
165
|
+
return path
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _compute_edge_path_straight(
|
|
169
|
+
vcoord_data,
|
|
170
|
+
vpath_fig,
|
|
171
|
+
vsize_fig,
|
|
172
|
+
trans,
|
|
173
|
+
trans_inv,
|
|
174
|
+
**kwargs,
|
|
175
|
+
):
|
|
176
|
+
|
|
177
|
+
# Coordinates in figure (default) coords
|
|
178
|
+
vcoord_fig = trans(vcoord_data)
|
|
179
|
+
|
|
180
|
+
points = []
|
|
181
|
+
|
|
182
|
+
# Angle of the straight line
|
|
183
|
+
theta = atan2(*((vcoord_fig[1] - vcoord_fig[0])[::-1]))
|
|
184
|
+
|
|
185
|
+
# Shorten at starting vertex
|
|
186
|
+
vs = _get_shorter_edge_coords(vpath_fig[0], vsize_fig[0], theta) + vcoord_fig[0]
|
|
187
|
+
points.append(vs)
|
|
188
|
+
|
|
189
|
+
# Shorten at end vertex
|
|
190
|
+
ve = (
|
|
191
|
+
_get_shorter_edge_coords(vpath_fig[1], vsize_fig[1], theta + pi) + vcoord_fig[1]
|
|
192
|
+
)
|
|
193
|
+
points.append(ve)
|
|
194
|
+
|
|
195
|
+
codes = ["MOVETO", "LINETO"]
|
|
196
|
+
path = mpl.path.Path(
|
|
197
|
+
points,
|
|
198
|
+
codes=[getattr(mpl.path.Path, x) for x in codes],
|
|
199
|
+
)
|
|
200
|
+
path.vertices = trans_inv(path.vertices)
|
|
201
|
+
return path, (theta, theta + np.pi)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _compute_edge_path_waypoints(
|
|
205
|
+
waypoints,
|
|
206
|
+
vcoord_data,
|
|
207
|
+
vpath_fig,
|
|
208
|
+
vsize_fig,
|
|
209
|
+
trans,
|
|
210
|
+
trans_inv,
|
|
211
|
+
layout_coordinate_system: str = "cartesian",
|
|
212
|
+
points_per_curve: int = 30,
|
|
213
|
+
**kwargs,
|
|
214
|
+
):
|
|
215
|
+
|
|
216
|
+
if waypoints in ("x0y1", "y0x1"):
|
|
217
|
+
assert layout_coordinate_system == "cartesian"
|
|
218
|
+
|
|
219
|
+
# Coordinates in figure (default) coords
|
|
220
|
+
vcoord_fig = trans(vcoord_data)
|
|
221
|
+
|
|
222
|
+
if waypoints == "x0y1":
|
|
223
|
+
waypoint = np.array([vcoord_fig[0][0], vcoord_fig[1][1]])
|
|
224
|
+
else:
|
|
225
|
+
waypoint = np.array([vcoord_fig[1][0], vcoord_fig[0][1]])
|
|
226
|
+
|
|
227
|
+
# Angles of the straight lines
|
|
228
|
+
theta0 = atan2(*((waypoint - vcoord_fig[0])[::-1]))
|
|
229
|
+
theta1 = atan2(*((waypoint - vcoord_fig[1])[::-1]))
|
|
230
|
+
|
|
231
|
+
# Shorten at starting vertex
|
|
232
|
+
vs = (
|
|
233
|
+
_get_shorter_edge_coords(vpath_fig[0], vsize_fig[0], theta0) + vcoord_fig[0]
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Shorten at end vertex
|
|
237
|
+
ve = (
|
|
238
|
+
_get_shorter_edge_coords(vpath_fig[1], vsize_fig[1], theta1) + vcoord_fig[1]
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
points = [vs, waypoint, ve]
|
|
242
|
+
codes = ["MOVETO", "LINETO", "LINETO"]
|
|
243
|
+
angles = (theta0, theta1)
|
|
244
|
+
elif waypoints == "r0a1":
|
|
245
|
+
assert layout_coordinate_system == "polar"
|
|
246
|
+
|
|
247
|
+
r0, alpha0 = vcoord_data[0]
|
|
248
|
+
r1, alpha1 = vcoord_data[1]
|
|
249
|
+
idx_inner = np.argmin([r0, r1])
|
|
250
|
+
idx_outer = 1 - idx_inner
|
|
251
|
+
alpha_outer = [alpha0, alpha1][idx_outer]
|
|
252
|
+
|
|
253
|
+
# FIXME: this is aware of chirality as stored by the layout function
|
|
254
|
+
betas = np.linspace(alpha0, alpha1, points_per_curve)
|
|
255
|
+
waypoints = [r0, r1][idx_inner] * np.vstack([np.cos(betas), np.sin(betas)]).T
|
|
256
|
+
endpoint = [r0, r1][idx_outer] * np.array(
|
|
257
|
+
[np.cos(alpha_outer), np.sin(alpha_outer)]
|
|
258
|
+
)
|
|
259
|
+
points = np.array(list(waypoints) + [endpoint])
|
|
260
|
+
points = trans(points)
|
|
261
|
+
codes = ["MOVETO"] + ["LINETO"] * len(waypoints)
|
|
262
|
+
# FIXME: same as previus comment
|
|
263
|
+
angles = (alpha0 + pi / 2, alpha1)
|
|
264
|
+
|
|
265
|
+
else:
|
|
266
|
+
raise NotImplementedError(
|
|
267
|
+
f"Edge shortening with waypoints not implemented yet: {waypoints}.",
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
path = mpl.path.Path(
|
|
271
|
+
points,
|
|
272
|
+
codes=[getattr(mpl.path.Path, x) for x in codes],
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
path.vertices = trans_inv(path.vertices)
|
|
276
|
+
return path, angles
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _compute_edge_path_curved(
|
|
280
|
+
tension,
|
|
281
|
+
vcoord_data,
|
|
282
|
+
vpath_fig,
|
|
283
|
+
vsize_fig,
|
|
284
|
+
trans,
|
|
285
|
+
trans_inv,
|
|
286
|
+
ports=(None, None),
|
|
287
|
+
):
|
|
288
|
+
"""Shorten the edge path along a cubic Bezier between the vertex centres.
|
|
289
|
+
|
|
290
|
+
The most important part is that the derivative of the Bezier at the start
|
|
291
|
+
and end point towards the vertex centres: people notice if they do not.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
# Coordinates in figure (default) coords
|
|
295
|
+
vcoord_fig = trans(vcoord_data)
|
|
296
|
+
|
|
297
|
+
dv = vcoord_fig[1] - vcoord_fig[0]
|
|
298
|
+
edge_straight_length = np.sqrt((dv**2).sum())
|
|
299
|
+
|
|
300
|
+
auxs = [None, None]
|
|
301
|
+
for i in range(2):
|
|
302
|
+
if ports[i] is not None:
|
|
303
|
+
der = _get_port_unit_vector(ports[i], trans_inv)
|
|
304
|
+
auxs[i] = der * edge_straight_length * tension + vcoord_fig[i]
|
|
305
|
+
|
|
306
|
+
# Both ports defined, just use them and hope for the best
|
|
307
|
+
# Obviously, if the user specifies ports that make no sense,
|
|
308
|
+
# this is going to be a (technically valid) mess.
|
|
309
|
+
if all(aux is not None for aux in auxs):
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
# If no ports are specified (the most common case), compute
|
|
313
|
+
# the Bezier and shorten it
|
|
314
|
+
elif all(aux is None for aux in auxs):
|
|
315
|
+
# Put auxs along the way
|
|
316
|
+
auxs = np.array(
|
|
317
|
+
[
|
|
318
|
+
vcoord_fig[0] + 0.33 * dv,
|
|
319
|
+
vcoord_fig[1] - 0.33 * dv,
|
|
320
|
+
]
|
|
321
|
+
)
|
|
322
|
+
# Right rotation from the straight edge
|
|
323
|
+
dv_rot = -0.1 * dv @ np.array([[0, 1], [-1, 0]])
|
|
324
|
+
# Shift the auxs orthogonal to the straight edge
|
|
325
|
+
auxs += dv_rot * tension
|
|
326
|
+
|
|
327
|
+
# First port is defined
|
|
328
|
+
elif (auxs[0] is not None) and (auxs[1] is None):
|
|
329
|
+
auxs[1] = auxs[0]
|
|
330
|
+
|
|
331
|
+
# Second port is defined
|
|
332
|
+
else:
|
|
333
|
+
auxs[0] = auxs[1]
|
|
334
|
+
|
|
335
|
+
vs = [None, None]
|
|
336
|
+
thetas = [None, None]
|
|
337
|
+
for i in range(2):
|
|
338
|
+
thetas[i] = atan2(*((auxs[i] - vcoord_fig[i])[::-1]))
|
|
339
|
+
vs[i] = (
|
|
340
|
+
_get_shorter_edge_coords(vpath_fig[i], vsize_fig[i], thetas[i])
|
|
341
|
+
+ vcoord_fig[i]
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
path = {
|
|
345
|
+
"vertices": [
|
|
346
|
+
vs[0],
|
|
347
|
+
auxs[0],
|
|
348
|
+
auxs[1],
|
|
349
|
+
vs[1],
|
|
350
|
+
],
|
|
351
|
+
"codes": ["MOVETO"] + ["CURVE4"] * 3,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
path = mpl.path.Path(
|
|
355
|
+
path["vertices"],
|
|
356
|
+
codes=[getattr(mpl.path.Path, x) for x in path["codes"]],
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Return to data transform
|
|
360
|
+
path.vertices = trans_inv(path.vertices)
|
|
361
|
+
return path, tuple(thetas)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _compute_edge_path(
|
|
365
|
+
*args,
|
|
366
|
+
tension: float = 0,
|
|
367
|
+
waypoints: str = "none",
|
|
368
|
+
ports: Pair[Optional[str]] = (None, None),
|
|
369
|
+
layout_coordinate_system: str = "cartesian",
|
|
370
|
+
**kwargs,
|
|
371
|
+
):
|
|
372
|
+
"""Compute the edge path in a few different ways."""
|
|
373
|
+
if (waypoints != "none") and (tension != 0):
|
|
374
|
+
raise ValueError("Waypoints not supported for curved edges.")
|
|
375
|
+
|
|
376
|
+
if waypoints != "none":
|
|
377
|
+
return _compute_edge_path_waypoints(
|
|
378
|
+
waypoints,
|
|
379
|
+
*args,
|
|
380
|
+
layout_coordinate_system=layout_coordinate_system,
|
|
381
|
+
**kwargs,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if tension == 0:
|
|
385
|
+
return _compute_edge_path_straight(*args, **kwargs)
|
|
386
|
+
|
|
387
|
+
return _compute_edge_path_curved(
|
|
388
|
+
tension,
|
|
389
|
+
*args,
|
|
390
|
+
ports=ports,
|
|
391
|
+
**kwargs,
|
|
392
|
+
)
|
iplotx/edge/ports.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module for handling edge ports in iplotx.
|
|
3
|
+
"""
|
|
4
|
+
|
|
1
5
|
import numpy as np
|
|
2
6
|
|
|
3
7
|
sq2 = np.sqrt(2) / 2
|
|
@@ -19,8 +23,9 @@ def _get_port_unit_vector(
|
|
|
19
23
|
trans_inv,
|
|
20
24
|
):
|
|
21
25
|
"""Get the tangent unit vector from a port string."""
|
|
22
|
-
# The only tricky bit is if the port says e.g. north but the y axis is inverted, in which
|
|
23
|
-
# We can figure it out by checking the sign of the monotonic
|
|
26
|
+
# The only tricky bit is if the port says e.g. north but the y axis is inverted, in which
|
|
27
|
+
# case the port should go south. We can figure it out by checking the sign of the monotonic
|
|
28
|
+
# trans_inv from figure to data coordinates.
|
|
24
29
|
v12 = trans_inv(
|
|
25
30
|
np.array(
|
|
26
31
|
[
|
iplotx/groups.py
CHANGED
|
@@ -40,7 +40,7 @@ class GroupingArtist(PatchCollection):
|
|
|
40
40
|
transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
|
|
41
41
|
*args,
|
|
42
42
|
**kwargs,
|
|
43
|
-
):
|
|
43
|
+
) -> None:
|
|
44
44
|
"""Container artist for vertex groupings, e.g. covers or clusterings.
|
|
45
45
|
|
|
46
46
|
Parameters:
|
|
@@ -65,7 +65,10 @@ class GroupingArtist(PatchCollection):
|
|
|
65
65
|
|
|
66
66
|
network = kwargs.pop("network", None)
|
|
67
67
|
patches, grouping, coords_hulls = self._create_patches(
|
|
68
|
-
grouping,
|
|
68
|
+
grouping,
|
|
69
|
+
layout,
|
|
70
|
+
network,
|
|
71
|
+
**kwargs,
|
|
69
72
|
)
|
|
70
73
|
if "network" in kwargs:
|
|
71
74
|
del kwargs["network"]
|
|
@@ -80,17 +83,17 @@ class GroupingArtist(PatchCollection):
|
|
|
80
83
|
|
|
81
84
|
self.set_transform(transform)
|
|
82
85
|
|
|
83
|
-
def set_figure(self, figure):
|
|
86
|
+
def set_figure(self, figure) -> None:
|
|
84
87
|
"""Set the figure for the grouping, recomputing the paths depending on the figure's dpi."""
|
|
85
88
|
ret = super().set_figure(figure)
|
|
86
89
|
self._compute_paths(self.get_figure(root=True).dpi)
|
|
87
90
|
return ret
|
|
88
91
|
|
|
89
|
-
def get_vertexpadding(self):
|
|
92
|
+
def get_vertexpadding(self) -> float:
|
|
90
93
|
"""Get the vertex padding of each group."""
|
|
91
94
|
return self._vertexpadding
|
|
92
95
|
|
|
93
|
-
def get_vertexpadding_dpi(self, dpi=72.0):
|
|
96
|
+
def get_vertexpadding_dpi(self, dpi: float = 72.0) -> float:
|
|
94
97
|
"""Get vertex padding of each group, scaled by dpi of the figure."""
|
|
95
98
|
return self.get_vertexpadding() * dpi / 72.0 * self._factor
|
|
96
99
|
|
|
@@ -126,7 +129,7 @@ class GroupingArtist(PatchCollection):
|
|
|
126
129
|
patches.append(patch)
|
|
127
130
|
return patches, grouping, coords_hulls
|
|
128
131
|
|
|
129
|
-
def _compute_paths(self, dpi=72.0):
|
|
132
|
+
def _compute_paths(self, dpi: float = 72.0) -> None:
|
|
130
133
|
ppc = self._points_per_curve
|
|
131
134
|
for i, hull in enumerate(self._coords_hulls):
|
|
132
135
|
self._paths[i].vertices = _compute_group_path_with_vertex_padding(
|
|
@@ -137,16 +140,23 @@ class GroupingArtist(PatchCollection):
|
|
|
137
140
|
points_per_curve=ppc,
|
|
138
141
|
)
|
|
139
142
|
|
|
140
|
-
def _process(self):
|
|
143
|
+
def _process(self) -> None:
|
|
141
144
|
self._compute_paths()
|
|
142
145
|
|
|
143
|
-
def draw(self, renderer):
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
#
|
|
146
|
+
def draw(self, renderer) -> None:
|
|
147
|
+
"""Draw or re-draw the grouping patches.
|
|
148
|
+
|
|
149
|
+
Parameters:
|
|
150
|
+
renderer: The renderer to use for drawing the patches.
|
|
151
|
+
"""
|
|
152
|
+
# FIXME: this kind of breaks everything since the vertices' magical "_transforms" does
|
|
153
|
+
# not really scale from 72 pixels but rather from the screen's or something.
|
|
154
|
+
# Conclusion: using this keeps consistency across dpis but breaks proportionality of
|
|
155
|
+
# vertexpadding and vertex_size (for now).
|
|
156
|
+
# NOTE: this might be less bad than initially thought in the sense that even perfect
|
|
157
|
+
# scaling does not seem to align the center of the perimeter of the group with the
|
|
158
|
+
# center of the perimeter of the vertex when of the same exact size. So we are
|
|
159
|
+
# probably ok winging it as users will adapt.
|
|
150
160
|
self._compute_paths(self.get_figure(root=True).dpi)
|
|
151
161
|
super().draw(renderer)
|
|
152
162
|
|