iplotx 0.1.0__py3-none-any.whl → 0.2.0__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
+ from typing import (
2
+ Never,
3
+ )
1
4
  import numpy as np
2
5
  import matplotlib as mpl
3
6
  from matplotlib.patches import PathPatch
4
7
 
8
+ from ..style import (
9
+ get_style,
10
+ rotate_style,
11
+ )
12
+
13
+
14
+ class EdgeArrowCollection(mpl.collections.PatchCollection):
15
+ """Collection of arrow patches for plotting directed edgs."""
16
+
17
+ _factor = 1.0
18
+
19
+ def __init__(
20
+ self,
21
+ edge_collection,
22
+ transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
23
+ *args,
24
+ **kwargs,
25
+ ):
26
+
27
+ self._edge_collection = edge_collection
28
+ self._style = get_style(".edge.arrow")
29
+
30
+ patches, sizes = self._create_artists()
31
+
32
+ if "cmap" in self._edge_collection._style:
33
+ kwargs["cmap"] = self._edge_collection._style["cmap"]
34
+ kwargs["norm"] = self._edge_collection._style["norm"]
35
+
36
+ super().__init__(
37
+ patches,
38
+ offsets=np.zeros((len(patches), 2)),
39
+ offset_transform=self.get_offset_transform(),
40
+ transform=transform,
41
+ match_original=True,
42
+ *args,
43
+ **kwargs,
44
+ )
45
+ self._angles = np.zeros(len(self._paths))
46
+
47
+ # Compute _transforms like in _CollectionWithScales for dpi issues
48
+ self.set_sizes(sizes)
49
+
50
+ def get_sizes(self):
51
+ """Get vertex sizes (max of width and height), not scaled by dpi."""
52
+ return self._sizes
53
+
54
+ def get_sizes_dpi(self):
55
+ return self._transforms[:, 0, 0]
56
+
57
+ def set_sizes(self, sizes, dpi=72.0):
58
+ """Set vertex sizes.
59
+
60
+ This rescales the current vertex symbol/path linearly, using this
61
+ value as the largest of width and height.
62
+
63
+ @param sizes: A sequence of vertex sizes or a single size.
64
+ """
65
+ if sizes is None:
66
+ self._sizes = np.array([])
67
+ self._transforms = np.empty((0, 3, 3))
68
+ else:
69
+ self._sizes = np.asarray(sizes)
70
+ self._transforms = np.zeros((len(self._sizes), 3, 3))
71
+ scale = self._sizes * dpi / 72.0 * self._factor
72
+ self._transforms[:, 0, 0] = scale
73
+ self._transforms[:, 1, 1] = scale
74
+ self._transforms[:, 2, 2] = 1.0
75
+ self.stale = True
76
+
77
+ get_size = get_sizes
78
+ set_size = set_sizes
79
+
80
+ def set_figure(self, fig) -> Never:
81
+ """Set the figure for this artist and all children."""
82
+ super().set_figure(fig)
83
+ self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
84
+ for child in self.get_children():
85
+ child.set_figure(fig)
86
+
87
+ def get_offset_transform(self):
88
+ return self._edge_collection.get_transform()
89
+
90
+ get_size = get_sizes
91
+ set_size = set_sizes
92
+
93
+ def _create_artists(self):
94
+ style = self._style if self._style is not None else {}
95
+
96
+ patches = []
97
+ sizes = []
98
+ for i, (vid1, vid2) in enumerate(self._edge_collection._vertex_ids):
99
+ stylei = rotate_style(style, index=i)
100
+ if ("facecolor" not in stylei) and ("color" not in stylei):
101
+ stylei["facecolor"] = self._edge_collection.get_edgecolors()[i][:3]
102
+ if ("edgecolor" not in stylei) and ("color" not in stylei):
103
+ stylei["edgecolor"] = self._edge_collection.get_edgecolors()[i][:3]
104
+ if "alpha" not in stylei:
105
+ stylei["alpha"] = self._edge_collection.get_edgecolors()[i][3]
106
+ if "linewidth" not in stylei:
107
+ stylei["linewidth"] = self._edge_collection.get_linewidths()[i]
108
+
109
+ patch, size = make_arrow_patch(
110
+ **stylei,
111
+ )
112
+ patches.append(patch)
113
+ sizes.append(size)
114
+
115
+ return patches, sizes
116
+
117
+ def set_array(self, array):
118
+ """Set the array for cmap/norm coloring, but keep the facecolors as set (usually 'none')."""
119
+ raise ValueError("Setting an array for arrows directly is not supported.")
120
+
121
+ def set_colors(self, colors):
122
+ """Set arrow colors (edge and/or face) based on a colormap."""
123
+ # NOTE: facecolors is always an array because we come from patches
124
+ # It can have zero alpha (i.e. if we choose "none", or a hollow marker)
125
+ self.set_edgecolors(colors)
126
+ has_facecolor = self._facecolors[:, 3] > 0
127
+ self._facecolors[has_facecolor] = colors[has_facecolor]
128
+
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
+ @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
iplotx/edge/ports.py ADDED
@@ -0,0 +1,42 @@
1
+ import numpy as np
2
+
3
+ sq2 = np.sqrt(2) / 2
4
+
5
+ port_dict = {
6
+ "s": (0, -1),
7
+ "w": (-1, 0),
8
+ "n": (0, 1),
9
+ "e": (1, 0),
10
+ "sw": (-sq2, -sq2),
11
+ "nw": (-sq2, sq2),
12
+ "ne": (sq2, sq2),
13
+ "se": (sq2, -sq2),
14
+ }
15
+
16
+
17
+ def _get_port_unit_vector(
18
+ portstring,
19
+ trans_inv,
20
+ ):
21
+ """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.
24
+ v12 = trans_inv(
25
+ np.array(
26
+ [
27
+ [0, 0],
28
+ [1, 1],
29
+ ]
30
+ )
31
+ )
32
+ invertx = v12[1, 0] - v12[0, 0] < 0
33
+ inverty = v12[1, 1] - v12[0, 1] < 0
34
+
35
+ if invertx:
36
+ portstring = portstring.replace("w", "x").replace("e", "w").replace("x", "e")
37
+ if inverty:
38
+ portstring = portstring.replace("n", "x").replace("s", "n").replace("x", "s")
39
+
40
+ if portstring not in port_dict:
41
+ raise KeyError(f"Port not found: {portstring}")
42
+ return np.array(port_dict[portstring])
iplotx/groups.py CHANGED
@@ -1,19 +1,22 @@
1
- from typing import Union, Sequence
2
- from copy import deepcopy
3
- from collections import defaultdict
1
+ """
2
+ Module for vertex groupings code, especially the GroupingArtist class.
3
+ """
4
+
5
+ from typing import Union
4
6
  import numpy as np
5
- import pandas as pd
6
7
  import matplotlib as mpl
7
8
  from matplotlib.collections import PatchCollection
8
9
 
9
10
 
10
- from .importing import igraph
11
11
  from .typing import (
12
12
  GroupingType,
13
13
  LayoutType,
14
14
  )
15
- from .heuristics import normalise_layout, normalise_grouping
16
- from .styles import get_style, rotate_style
15
+ from .ingest.heuristics import (
16
+ normalise_layout,
17
+ normalise_grouping,
18
+ )
19
+ from .style import get_style, rotate_style
17
20
  from .utils.geometry import (
18
21
  convex_hull,
19
22
  _compute_group_path_with_vertex_padding,
@@ -21,11 +24,20 @@ from .utils.geometry import (
21
24
 
22
25
 
23
26
  class GroupingArtist(PatchCollection):
27
+ """Matplotlib artist for a vertex grouping (clustering/cover).
28
+
29
+ This class is used to plot patches surrounding groups of vertices in a network.
30
+ """
31
+
32
+ _factor = 1.0
33
+
24
34
  def __init__(
25
35
  self,
26
36
  grouping: GroupingType,
27
37
  layout: LayoutType,
28
38
  vertexpadding: Union[None, int] = None,
39
+ points_per_curve: int = 30,
40
+ transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
29
41
  *args,
30
42
  **kwargs,
31
43
  ):
@@ -38,21 +50,52 @@ class GroupingArtist(PatchCollection):
38
50
  layout: The layout of the vertices. If this object has no keys/index, the
39
51
  vertices are assumed to have IDs corresponding to integers starting from
40
52
  zero.
53
+ vertexpadding: How may points of padding to leave around each vertex centre.
54
+ points_per_curve: How many points to use to approximate a round envelope around
55
+ each convex hull vertex.
56
+ transform: The matplotlib transform to use for the patches (typically transData).
41
57
  """
42
58
  if vertexpadding is not None:
43
59
  self._vertexpadding = vertexpadding
44
60
  else:
45
61
  style = get_style(".grouping")
46
62
  self._vertexpadding = style.get("vertexpadding", 10)
47
- patches, grouping, layout = self._create_patches(grouping, layout, **kwargs)
63
+
64
+ self._points_per_curve = points_per_curve
65
+
66
+ network = kwargs.pop("network", None)
67
+ patches, grouping, coords_hulls = self._create_patches(
68
+ grouping, layout, network, **kwargs
69
+ )
70
+ if "network" in kwargs:
71
+ del kwargs["network"]
48
72
  self._grouping = grouping
49
- self._layout = layout
73
+ self._coords_hulls = coords_hulls
50
74
  kwargs["match_original"] = True
51
75
 
52
76
  super().__init__(patches, *args, **kwargs)
53
77
 
54
- def _create_patches(self, grouping, layout, **kwargs):
55
- layout = normalise_layout(layout)
78
+ zorder = get_style(".grouping").get("zorder", 1)
79
+ self.set_zorder(zorder)
80
+
81
+ self.set_transform(transform)
82
+
83
+ def set_figure(self, figure):
84
+ """Set the figure for the grouping, recomputing the paths depending on the figure's dpi."""
85
+ ret = super().set_figure(figure)
86
+ self._compute_paths(self.get_figure(root=True).dpi)
87
+ return ret
88
+
89
+ def get_vertexpadding(self):
90
+ """Get the vertex padding of each group."""
91
+ return self._vertexpadding
92
+
93
+ def get_vertexpadding_dpi(self, dpi=72.0):
94
+ """Get vertex padding of each group, scaled by dpi of the figure."""
95
+ return self.get_vertexpadding() * dpi / 72.0 * self._factor
96
+
97
+ def _create_patches(self, grouping, layout, network, **kwargs):
98
+ layout = normalise_layout(layout, network=network)
56
99
  grouping = normalise_grouping(grouping, layout)
57
100
  style = get_style(".grouping")
58
101
  style.pop("vertexpadding", None)
@@ -60,6 +103,7 @@ class GroupingArtist(PatchCollection):
60
103
  style.update(kwargs)
61
104
 
62
105
  patches = []
106
+ coords_hulls = []
63
107
  for i, (name, vids) in enumerate(grouping.items()):
64
108
  if len(vids) == 0:
65
109
  continue
@@ -67,6 +111,7 @@ class GroupingArtist(PatchCollection):
67
111
  coords = layout.loc[vids].values
68
112
  idx_hull = convex_hull(coords)
69
113
  coords_hull = coords[idx_hull]
114
+ coords_hulls.append(coords_hull)
70
115
 
71
116
  stylei = rotate_style(style, i)
72
117
 
@@ -79,23 +124,30 @@ class GroupingArtist(PatchCollection):
79
124
  )
80
125
 
81
126
  patches.append(patch)
82
- return patches, grouping, layout
83
-
84
- def _compute_paths(self):
85
- if self._vertexpadding > 0:
86
- for i, path in enumerate(self._paths):
87
- self._paths[i].vertices = _compute_group_path_with_vertex_padding(
88
- path.vertices,
89
- self.get_transform(),
90
- vertexpadding=self._vertexpadding,
91
- )
127
+ return patches, grouping, coords_hulls
128
+
129
+ def _compute_paths(self, dpi=72.0):
130
+ ppc = self._points_per_curve
131
+ for i, hull in enumerate(self._coords_hulls):
132
+ self._paths[i].vertices = _compute_group_path_with_vertex_padding(
133
+ hull,
134
+ self._paths[i].vertices,
135
+ self.get_transform(),
136
+ vertexpadding=self.get_vertexpadding_dpi(dpi),
137
+ points_per_curve=ppc,
138
+ )
92
139
 
93
140
  def _process(self):
94
- self.set_transform(self.axes.transData)
95
141
  self._compute_paths()
96
142
 
97
143
  def draw(self, renderer):
98
- self._compute_paths()
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.
150
+ self._compute_paths(self.get_figure(root=True).dpi)
99
151
  super().draw(renderer)
100
152
 
101
153
 
@@ -111,24 +163,10 @@ def _compute_group_patch_stub(
111
163
  )
112
164
 
113
165
  # NOTE: Closing point: mpl is a bit quirky here
114
- vertices = []
115
- codes = []
116
- if len(points) == 0:
117
- vertices = np.zeros((0, 2))
118
- elif len(points) == 1:
119
- vertices = [points[0]] * 9
120
- codes = ["MOVETO"] + ["CURVE3"] * 8
121
- elif len(points) == 2:
122
- vertices = [points[0]] * 5 + [points[1]] * 5 + [points[0]]
123
- codes = ["MOVETO"] + ["CURVE3"] * 4 + ["LINETO"] + ["CURVE3"] * 4 + ["LINETO"]
124
- else:
125
- for point in points:
126
- vertices.extend([point] * 3)
127
- codes.extend(["LINETO", "CURVE3", "CURVE3"])
128
- vertices.append(vertices[0])
129
- codes.append("LINETO")
130
- codes[0] = "MOVETO"
131
-
166
+ vertices = np.zeros(
167
+ (1 + 30 * len(points), 2),
168
+ )
169
+ codes = ["MOVETO"] + ["LINETO"] * (len(vertices) - 2) + ["CLOSEPOLY"]
132
170
  codes = [getattr(mpl.path.Path, x) for x in codes]
133
171
  patch = mpl.patches.PathPatch(
134
172
  mpl.path.Path(