iplotx 0.1.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/arrow.py CHANGED
@@ -1,16 +1,171 @@
1
+ """
2
+ Module for edge arrows in iplotx.
3
+ """
4
+
1
5
  import numpy as np
2
6
  import matplotlib as mpl
3
7
  from matplotlib.patches import PathPatch
4
8
 
9
+ from ..style import (
10
+ get_style,
11
+ rotate_style,
12
+ )
13
+
14
+
15
+ class EdgeArrowCollection(mpl.collections.PatchCollection):
16
+ """Collection of arrow patches for plotting directed edgs."""
17
+
18
+ _factor = 1.0
19
+
20
+ def __init__(
21
+ self,
22
+ edge_collection,
23
+ *args,
24
+ transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
25
+ **kwargs,
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
+ """
34
+
35
+ self._edge_collection = edge_collection
36
+ self._style = get_style(".edge.arrow")
37
+
38
+ patches, sizes = self._create_artists()
39
+
40
+ if "cmap" in self._edge_collection._style:
41
+ kwargs["cmap"] = self._edge_collection._style["cmap"]
42
+ kwargs["norm"] = self._edge_collection._style["norm"]
43
+
44
+ super().__init__(
45
+ patches,
46
+ offsets=np.zeros((len(patches), 2)),
47
+ offset_transform=self.get_offset_transform(),
48
+ transform=transform,
49
+ match_original=True,
50
+ *args,
51
+ **kwargs,
52
+ )
53
+ self._angles = np.zeros(len(self._paths))
54
+
55
+ # Compute _transforms like in _CollectionWithScales for dpi issues
56
+ self.set_sizes(sizes)
57
+
58
+ def get_sizes(self):
59
+ """Get arrow sizes (max of width and height), not scaled by dpi."""
60
+ return self._sizes
61
+
62
+ def get_sizes_dpi(self):
63
+ """Get arrow sizes (max of width and height) scaled by dpi."""
64
+ return self._transforms[:, 0, 0]
65
+
66
+ def set_sizes(self, sizes, dpi=72.0):
67
+ """Set vertex sizes.
68
+
69
+ This rescales the current vertex symbol/path linearly, using this
70
+ value as the largest of width and height.
71
+
72
+ @param sizes: A sequence of vertex sizes or a single size.
73
+ """
74
+ if sizes is None:
75
+ self._sizes = np.array([])
76
+ self._transforms = np.empty((0, 3, 3))
77
+ else:
78
+ self._sizes = np.asarray(sizes)
79
+ self._transforms = np.zeros((len(self._sizes), 3, 3))
80
+ scale = self._sizes * dpi / 72.0 * self._factor
81
+ self._transforms[:, 0, 0] = scale
82
+ self._transforms[:, 1, 1] = scale
83
+ self._transforms[:, 2, 2] = 1.0
84
+ self.stale = True
85
+
86
+ get_size = get_sizes
87
+ set_size = set_sizes
88
+
89
+ def set_figure(self, fig) -> None:
90
+ """Set the figure for this artist and all children."""
91
+ super().set_figure(fig)
92
+ self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
93
+ for child in self.get_children():
94
+ child.set_figure(fig)
95
+
96
+ def get_offset_transform(self):
97
+ """Get offset transform for the edge arrows. This sets the tip of each arrow."""
98
+ return self._edge_collection.get_transform()
99
+
100
+ get_size = get_sizes
101
+ set_size = set_sizes
102
+
103
+ def _create_artists(self):
104
+ style = self._style if self._style is not None else {}
105
+
106
+ patches = []
107
+ sizes = []
108
+ for i in range(len(self._edge_collection._vertex_ids)):
109
+ stylei = rotate_style(style, index=i)
110
+ if ("facecolor" not in stylei) and ("color" not in stylei):
111
+ stylei["facecolor"] = self._edge_collection.get_edgecolors()[i][:3]
112
+ if ("edgecolor" not in stylei) and ("color" not in stylei):
113
+ stylei["edgecolor"] = self._edge_collection.get_edgecolors()[i][:3]
114
+ if "alpha" not in stylei:
115
+ stylei["alpha"] = self._edge_collection.get_edgecolors()[i][3]
116
+ if "linewidth" not in stylei:
117
+ stylei["linewidth"] = self._edge_collection.get_linewidths()[i]
118
+
119
+ patch, size = make_arrow_patch(
120
+ **stylei,
121
+ )
122
+ patches.append(patch)
123
+ sizes.append(size)
124
+
125
+ return patches, sizes
126
+
127
+ def set_array(self, A):
128
+ """Set the array for cmap/norm coloring, but keep the facecolors as set (usually 'none')."""
129
+ raise ValueError("Setting an array for arrows directly is not supported.")
130
+
131
+ def set_colors(self, colors):
132
+ """Set arrow colors (edge and/or face) based on a colormap."""
133
+ # NOTE: facecolors is always an array because we come from patches
134
+ # It can have zero alpha (i.e. if we choose "none", or a hollow marker)
135
+ self.set_edgecolor(colors)
136
+ has_facecolor = self._facecolors[:, 3] > 0
137
+ self._facecolors[has_facecolor] = colors[has_facecolor]
138
+
139
+ @mpl.artist.allow_rasterization
140
+ def draw(self, renderer):
141
+ self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
142
+ super().draw(renderer)
143
+
5
144
 
6
145
  def make_arrow_patch(marker: str = "|>", width: float = 8, **kwargs):
7
146
  """Make a patch of the given marker shape and size."""
8
147
  height = kwargs.pop("height", width * 1.3)
9
148
 
149
+ # Normalise by the max size, this is taken care of in _transforms
150
+ # subsequently in a way that is nice to dpi scaling
151
+ size_max = max(width, height)
152
+ if size_max > 0:
153
+ height /= size_max
154
+ width /= size_max
155
+
10
156
  if marker == "|>":
11
- codes = ["MOVETO", "LINETO", "LINETO"]
157
+ codes = ["MOVETO", "LINETO", "LINETO", "CLOSEPOLY"]
158
+ if "color" in kwargs:
159
+ kwargs["facecolor"] = kwargs["edgecolor"] = kwargs.pop("color")
12
160
  path = mpl.path.Path(
13
- np.array([[-height, width * 0.5], [-height, -width * 0.5], [0, 0]]),
161
+ np.array(
162
+ [
163
+ [-height, width * 0.5],
164
+ [-height, -width * 0.5],
165
+ [0, 0],
166
+ [-height, width * 0.5],
167
+ ]
168
+ ),
14
169
  codes=[getattr(mpl.path.Path, x) for x in codes],
15
170
  closed=True,
16
171
  )
@@ -25,8 +180,10 @@ def make_arrow_patch(marker: str = "|>", width: float = 8, **kwargs):
25
180
  closed=False,
26
181
  )
27
182
  elif marker == ">>":
183
+ if "color" in kwargs:
184
+ kwargs["facecolor"] = kwargs["edgecolor"] = kwargs.pop("color")
28
185
  overhang = kwargs.pop("overhang", 0.25)
29
- codes = ["MOVETO", "LINETO", "LINETO", "LINETO"]
186
+ codes = ["MOVETO", "LINETO", "LINETO", "LINETO", "CLOSEPOLY"]
30
187
  path = mpl.path.Path(
31
188
  np.array(
32
189
  [
@@ -34,14 +191,17 @@ def make_arrow_patch(marker: str = "|>", width: float = 8, **kwargs):
34
191
  [-height, -width * 0.5],
35
192
  [-height * (1.0 - overhang), 0],
36
193
  [-height, width * 0.5],
194
+ [0, 0],
37
195
  ]
38
196
  ),
39
197
  codes=[getattr(mpl.path.Path, x) for x in codes],
40
198
  closed=True,
41
199
  )
42
200
  elif marker == ")>":
201
+ if "color" in kwargs:
202
+ kwargs["facecolor"] = kwargs["edgecolor"] = kwargs.pop("color")
43
203
  overhang = kwargs.pop("overhang", 0.25)
44
- codes = ["MOVETO", "LINETO", "CURVE3", "CURVE3"]
204
+ codes = ["MOVETO", "LINETO", "CURVE3", "CURVE3", "CLOSEPOLY"]
45
205
  path = mpl.path.Path(
46
206
  np.array(
47
207
  [
@@ -49,6 +209,7 @@ def make_arrow_patch(marker: str = "|>", width: float = 8, **kwargs):
49
209
  [-height, -width * 0.5],
50
210
  [-height * (1.0 - overhang), 0],
51
211
  [-height, width * 0.5],
212
+ [0, 0],
52
213
  ]
53
214
  ),
54
215
  codes=[getattr(mpl.path.Path, x) for x in codes],
@@ -70,8 +231,37 @@ def make_arrow_patch(marker: str = "|>", width: float = 8, **kwargs):
70
231
  codes=[getattr(mpl.path.Path, x) for x in codes],
71
232
  closed=False,
72
233
  )
234
+ elif marker == "|":
235
+ kwargs["facecolor"] = "none"
236
+ if "color" in kwargs:
237
+ kwargs["edgecolor"] = kwargs.pop("color")
238
+ codes = ["MOVETO", "LINETO"]
239
+ path = mpl.path.Path(
240
+ np.array([[-height, width * 0.5], [-height, -width * 0.5]]),
241
+ codes=[getattr(mpl.path.Path, x) for x in codes],
242
+ closed=False,
243
+ )
244
+ elif marker == "s":
245
+ if "color" in kwargs:
246
+ kwargs["facecolor"] = kwargs["edgecolor"] = kwargs.pop("color")
247
+ codes = ["MOVETO", "LINETO", "LINETO", "LINETO", "CLOSEPOLY"]
248
+ path = mpl.path.Path(
249
+ np.array(
250
+ [
251
+ [-height, width * 0.5],
252
+ [-height, -width * 0.5],
253
+ [0, -width * 0.5],
254
+ [0, width * 0.5],
255
+ [-height, width * 0.5],
256
+ ]
257
+ ),
258
+ codes=[getattr(mpl.path.Path, x) for x in codes],
259
+ closed=True,
260
+ )
73
261
  elif marker == "d":
74
- codes = ["MOVETO", "LINETO", "LINETO", "LINETO"]
262
+ if "color" in kwargs:
263
+ kwargs["facecolor"] = kwargs["edgecolor"] = kwargs.pop("color")
264
+ codes = ["MOVETO", "LINETO", "LINETO", "LINETO", "CLOSEPOLY"]
75
265
  path = mpl.path.Path(
76
266
  np.array(
77
267
  [
@@ -79,13 +269,16 @@ def make_arrow_patch(marker: str = "|>", width: float = 8, **kwargs):
79
269
  [-height, 0],
80
270
  [-height * 0.5, -width * 0.5],
81
271
  [0, 0],
272
+ [-height * 0.5, width * 0.5],
82
273
  ]
83
274
  ),
84
275
  codes=[getattr(mpl.path.Path, x) for x in codes],
85
276
  closed=True,
86
277
  )
87
278
  elif marker == "p":
88
- codes = ["MOVETO", "LINETO", "LINETO", "LINETO"]
279
+ if "color" in kwargs:
280
+ kwargs["facecolor"] = kwargs["edgecolor"] = kwargs.pop("color")
281
+ codes = ["MOVETO", "LINETO", "LINETO", "LINETO", "CLOSEPOLY"]
89
282
  path = mpl.path.Path(
90
283
  np.array(
91
284
  [
@@ -93,13 +286,16 @@ def make_arrow_patch(marker: str = "|>", width: float = 8, **kwargs):
93
286
  [0, 0],
94
287
  [0, -width],
95
288
  [-height, -width],
289
+ [-height, 0],
96
290
  ]
97
291
  ),
98
292
  codes=[getattr(mpl.path.Path, x) for x in codes],
99
293
  closed=True,
100
294
  )
101
295
  elif marker == "q":
102
- codes = ["MOVETO", "LINETO", "LINETO", "LINETO"]
296
+ if "color" in kwargs:
297
+ kwargs["facecolor"] = kwargs["edgecolor"] = kwargs.pop("color")
298
+ codes = ["MOVETO", "LINETO", "LINETO", "LINETO", "CLOSEPOLY"]
103
299
  path = mpl.path.Path(
104
300
  np.array(
105
301
  [
@@ -107,16 +303,30 @@ def make_arrow_patch(marker: str = "|>", width: float = 8, **kwargs):
107
303
  [0, 0],
108
304
  [0, width],
109
305
  [-height, width],
306
+ [-height, 0],
307
+ ]
308
+ ),
309
+ codes=[getattr(mpl.path.Path, x) for x in codes],
310
+ closed=True,
311
+ )
312
+ elif marker == "none":
313
+ if "color" in kwargs:
314
+ kwargs["facecolor"] = kwargs["edgecolor"] = kwargs.pop("color")
315
+ codes = ["MOVETO"]
316
+ path = mpl.path.Path(
317
+ np.array(
318
+ [
319
+ [0, 0],
110
320
  ]
111
321
  ),
112
322
  codes=[getattr(mpl.path.Path, x) for x in codes],
113
323
  closed=True,
114
324
  )
325
+ else:
326
+ raise ValueError(f"Arrow marker not found: {marker}.")
115
327
 
116
328
  patch = PathPatch(
117
329
  path,
118
330
  **kwargs,
119
331
  )
120
- return patch
121
-
122
- raise KeyError(f"Unknown marker: {marker}")
332
+ return patch, size_max
@@ -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
+ )