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/arrow.py CHANGED
@@ -1,6 +1,7 @@
1
- from typing import (
2
- Never,
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 vertex sizes (max of width and height), not scaled by dpi."""
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) -> Never:
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, (vid1, vid2) in enumerate(self._edge_collection._vertex_ids):
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, array):
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.set_edgecolors(colors)
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)
@@ -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 case the port should go south.
23
- # We can figure it out by checking the sign of the monotonic trans_inv from figure to data coordinates.
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, layout, network, **kwargs
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
- # FIXME: this kind of breaks everything since the vertices' magical "_transforms" does not really
145
- # scale from 72 pixels but rather from the screen's or something. Conclusion: using this keeps
146
- # consistency across dpis but breaks proportionality of vertexpadding and vertex_size (for now).
147
- # NOTE: this might be less bad than initially thought in the sense that even perfect scaling
148
- # does not seem to align the center of the perimeter of the group with the center of the perimeter
149
- # of the vertex when of the same exact size. So we are probably ok winging it as users will adapt.
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