iplotx 0.9.0__py3-none-any.whl → 0.11.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.
@@ -0,0 +1,147 @@
1
+ """
2
+ Module containing code to manipulate edge visualisations in 3D, especially the Edge3DCollection class.
3
+ """
4
+
5
+ from mpl_toolkits.mplot3d import Axes3D
6
+ from mpl_toolkits.mplot3d.art3d import (
7
+ Line3DCollection,
8
+ )
9
+
10
+ from ...utils.matplotlib import (
11
+ _forwarder,
12
+ )
13
+ from ...edge import (
14
+ EdgeCollection,
15
+ )
16
+ from .arrow import (
17
+ arrow_collection_2d_to_3d,
18
+ )
19
+ from .geometry import (
20
+ _compute_edge_segments as _compute_single_edge_segments,
21
+ )
22
+
23
+
24
+ @_forwarder(
25
+ (
26
+ "set_clip_path",
27
+ "set_clip_box",
28
+ "set_snap",
29
+ "set_sketch_params",
30
+ "set_animated",
31
+ "set_picker",
32
+ )
33
+ )
34
+ class Edge3DCollection(Line3DCollection):
35
+ """Collection of vertex patches for plotting."""
36
+
37
+ def get_children(self) -> tuple:
38
+ children = []
39
+ if hasattr(self, "_subedges"):
40
+ children.append(self._subedges)
41
+ if hasattr(self, "_arrows"):
42
+ children.append(self._arrows)
43
+ if hasattr(self, "_label_collection"):
44
+ children.append(self._label_collection)
45
+ return tuple(children)
46
+
47
+ def set_figure(self, fig) -> None:
48
+ super().set_figure(fig)
49
+ for child in self.get_children():
50
+ child.set_figure(fig)
51
+
52
+ @property
53
+ def axes(self):
54
+ return Line3DCollection.axes.__get__(self)
55
+
56
+ @axes.setter
57
+ def axes(self, new_axes):
58
+ Line3DCollection.axes.__set__(self, new_axes)
59
+ for child in self.get_children():
60
+ child.axes = new_axes
61
+
62
+ _get_adjacent_vertices_info = EdgeCollection._get_adjacent_vertices_info
63
+
64
+ def _compute_edge_segments(self):
65
+ """Compute the edge segments for all edges."""
66
+ vinfo = self._get_adjacent_vertices_info()
67
+
68
+ segments3d = []
69
+ for vcoord_data in vinfo["offsets"]:
70
+ segment = _compute_single_edge_segments(
71
+ vcoord_data,
72
+ )
73
+ segments3d.append(segment)
74
+ self.set_segments(segments3d)
75
+
76
+ def _update_before_draw(self) -> None:
77
+ """Update the collection before drawing."""
78
+ if isinstance(self.axes, Axes3D) and hasattr(self, "do_3d_projection"):
79
+ self.do_3d_projection()
80
+
81
+ # TODO: Here's where we would shorten the edges to fit the vertex
82
+ # projections from 3D onto 2D, if we wanted to do that. Because edges
83
+ # in 3D are chains of segments rathen than splines, the shortening
84
+ # needs to be done in a different way to how it's done in 2D.
85
+
86
+ def draw(self, renderer) -> None:
87
+ """Draw the collection of vertices in 3D.
88
+
89
+ Parameters:
90
+ renderer: The renderer to use for drawing.
91
+ """
92
+ # Prepare the collection for drawing
93
+ self._update_before_draw()
94
+
95
+ # Render the Line3DCollection
96
+ # NOTE: we are NOT calling EdgeCollection.draw here
97
+ super().draw(renderer)
98
+
99
+ # This sets the labels offsets
100
+ # TODO: implement labels in 3D (one could copy the function from 2D,
101
+ # but would also need to promote the 2D labels into 3D labels similarly to
102
+ # how it's done for 3D vertices).
103
+ # self._update_labels()
104
+
105
+ # Now attempt to draw the arrows
106
+ for child in self.get_children():
107
+ child.draw(renderer)
108
+
109
+
110
+ def edge_collection_2d_to_3d(
111
+ col: EdgeCollection,
112
+ zdir: str = "z",
113
+ axlim_clip: bool = False,
114
+ ):
115
+ """Convert a 2D EdgeCollection to a 3D Edge3DCollection.
116
+
117
+ Parameters:
118
+ col: The 2D EdgeCollection to convert.
119
+ zs: The z coordinate(s) to use for the 3D vertices.
120
+ zdir: The axis to use as the z axis (default is "z").
121
+ depthshade: Whether to apply depth shading (default is True).
122
+ axlim_clip: Whether to clip the vertices to the axes limits (default is False).
123
+ """
124
+ if not isinstance(col, EdgeCollection):
125
+ raise TypeError("vertices must be a VertexCollection")
126
+
127
+ # NOTE: after this line, none of the EdgeCollection methods will work
128
+ # It's become a static drawer now. It uses segments instead of paths.
129
+ col.__class__ = Edge3DCollection
130
+ col._compute_edge_segments()
131
+
132
+ col._axlim_clip = axlim_clip
133
+
134
+ # Convert the arrow collection if present
135
+ if hasattr(col, "_arrows"):
136
+ segments3d = col._segments3d
137
+
138
+ # Fix the x and y to the center of the target vertex (for now)
139
+ col._arrows._offsets[:] = [segment[-1][:2] for segment in segments3d]
140
+ zs = [segment[-1][2] for segment in segments3d]
141
+ arrow_collection_2d_to_3d(
142
+ col._arrows,
143
+ zs=zs,
144
+ zdir=zdir,
145
+ depthshade=False,
146
+ axlim_clip=axlim_clip,
147
+ )
@@ -0,0 +1,115 @@
1
+ """
2
+ Module containing code to manipulate arrow visualisations in 3D, especially the EdgeArrow3DCollection class.
3
+ """
4
+
5
+ from typing import (
6
+ Sequence,
7
+ )
8
+ from math import atan2, cos, sin
9
+ import numpy as np
10
+ from matplotlib import (
11
+ cbook,
12
+ )
13
+ from mpl_toolkits.mplot3d import Axes3D
14
+ from mpl_toolkits.mplot3d.art3d import (
15
+ Path3DCollection,
16
+ )
17
+
18
+ from ...utils.matplotlib import (
19
+ _forwarder,
20
+ )
21
+ from ...edge.arrow import (
22
+ EdgeArrowCollection,
23
+ )
24
+
25
+
26
+ @_forwarder(
27
+ (
28
+ "set_clip_path",
29
+ "set_clip_box",
30
+ "set_snap",
31
+ "set_sketch_params",
32
+ "set_animated",
33
+ "set_picker",
34
+ )
35
+ )
36
+ class EdgeArrow3DCollection(EdgeArrowCollection, Path3DCollection):
37
+ """Collection of vertex patches for plotting."""
38
+
39
+ def _update_before_draw(self) -> None:
40
+ """Update the collection before drawing."""
41
+ if (
42
+ isinstance(self.axes, Axes3D)
43
+ and hasattr(self, "do_3d_projection")
44
+ and (self.axes.M is not None)
45
+ ):
46
+ self.do_3d_projection()
47
+
48
+ # The original EdgeArrowCollection method for
49
+ # _update_before_draw cannot be used because it
50
+ # relies on paths, whereas edges are now a
51
+ # Line3DCollection which uses segments.
52
+ self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
53
+
54
+ if (not hasattr(self, "_z_markers_idx")) or (
55
+ not isinstance(self._z_markers_idx, np.ndarray)
56
+ ):
57
+ return
58
+
59
+ trans = self.get_offset_transform().transform
60
+
61
+ # The do_3d_projection method above reorders the
62
+ # arrow offsets in some way, so we might have to figure out
63
+ # what edge index corres
64
+ for i, ie in enumerate(self._z_markers_idx):
65
+ segments_2d = self._edge_collection.get_segments()[ie]
66
+
67
+ # We could reset the 3d projection here, might be a way to
68
+ # skip the function call above.
69
+ v2 = trans(segments_2d[-1])
70
+ v1 = trans(segments_2d[-2])
71
+ dv = v2 - v1
72
+ theta = atan2(*(dv[::-1]))
73
+ theta_old = self._angles[i]
74
+ dtheta = theta - theta_old
75
+ mrot = np.array([[cos(dtheta), sin(dtheta)], [-sin(dtheta), cos(dtheta)]])
76
+
77
+ apath = self._paths[i]
78
+ apath.vertices = apath.vertices @ mrot
79
+ self._angles[i] = theta
80
+
81
+ def draw(self, renderer) -> None:
82
+ """Draw the collection of vertices in 3D.
83
+
84
+ Parameters:
85
+ renderer: The renderer to use for drawing.
86
+ """
87
+ with self._use_zordered_offset():
88
+ with cbook._setattr_cm(self, _in_draw=True):
89
+ EdgeArrowCollection.draw(self, renderer)
90
+
91
+
92
+ def arrow_collection_2d_to_3d(
93
+ col: EdgeArrowCollection,
94
+ zs: np.ndarray | float | Sequence[float] = 0,
95
+ zdir: str = "z",
96
+ depthshade: bool = True,
97
+ axlim_clip: bool = False,
98
+ ):
99
+ """Convert a 2D EdgeArrowCollection to a 3D EdgeArrow3DCollection.
100
+
101
+ Parameters:
102
+ col: The 2D EdgeArrowCollection to convert.
103
+ zs: The z coordinate(s) to use for the 3D vertices.
104
+ zdir: The axis to use as the z axis (default is "z").
105
+ depthshade: Whether to apply depth shading (default is True).
106
+ axlim_clip: Whether to clip the vertices to the axes limits (default is False).
107
+ """
108
+ if not isinstance(col, EdgeArrowCollection):
109
+ raise TypeError("vertices must be a EdgeArrowCollection")
110
+
111
+ col.__class__ = EdgeArrow3DCollection
112
+ col._offset_zordered = None
113
+ col._depthshade = depthshade
114
+ col._in_draw = False
115
+ col.set_3d_properties(zs, zdir, axlim_clip)
@@ -0,0 +1,82 @@
1
+ """
2
+ Support for computing edge paths in 3D.
3
+ """
4
+
5
+ from typing import (
6
+ Optional,
7
+ Sequence,
8
+ )
9
+ import numpy as np
10
+
11
+ from ...typing import (
12
+ Pair,
13
+ )
14
+
15
+
16
+ def _compute_edge_segments_straight(
17
+ vcoord_data,
18
+ layout_coordinate_system: str = "cartesian",
19
+ shrink: float = 0,
20
+ **kwargs,
21
+ ):
22
+ """Compute straight edge path between two vertices, in 3D.
23
+
24
+ Parameters:
25
+ vcoord_data: Vertex coordinates in data coordinates, shape (2, 3).
26
+ vpath_fig: Vertex path in figure coordinates.
27
+ vsize_fig: Vertex size in figure coordinates.
28
+ trans: Transformation from data to figure coordinates.
29
+ trans_inv: Inverse transformation from figure to data coordinates.
30
+ layout_coordinate_system: The coordinate system of the layout.
31
+ shrink: Amount to shorten the edge at each end, in figure coordinates.
32
+ **kwargs: Additional keyword arguments (not used).
33
+ Returns:
34
+ A pair with the path and a tuple of angles of exit and entry, in radians.
35
+
36
+ """
37
+
38
+ if layout_coordinate_system not in ("cartesian"):
39
+ raise ValueError(
40
+ f"Layout coordinate system not supported for straight edges in 3D: {layout_coordinate_system}.",
41
+ )
42
+
43
+ segments = [vcoord_data[0], vcoord_data[1]]
44
+ return segments
45
+
46
+
47
+ def _compute_edge_segments(
48
+ *args,
49
+ tension: float = 0,
50
+ waypoints: str | tuple[float, float] | Sequence[tuple[float, float]] | np.ndarray = "none",
51
+ ports: Pair[Optional[str]] = (None, None),
52
+ layout_coordinate_system: str = "cartesian",
53
+ **kwargs,
54
+ ):
55
+ """Compute the edge path in a few different ways."""
56
+ if (waypoints != "none") and (tension != 0):
57
+ raise ValueError("Waypoints not supported for curved edges.")
58
+
59
+ if waypoints != "none":
60
+ raise NotImplementedError("Waypoints not implemented for 3D edges.")
61
+ # return _compute_edge_path_waypoints(
62
+ # waypoints,
63
+ # *args,
64
+ # layout_coordinate_system=layout_coordinate_system,
65
+ # ports=ports,
66
+ # **kwargs,
67
+ # )
68
+
69
+ if np.isscalar(tension) and (tension == 0):
70
+ return _compute_edge_segments_straight(
71
+ *args,
72
+ layout_coordinate_system=layout_coordinate_system,
73
+ **kwargs,
74
+ )
75
+
76
+ raise NotImplementedError("Curved edges not implemented for 3D edges.")
77
+ # return _compute_edge_path_curved(
78
+ # tension,
79
+ # *args,
80
+ # ports=ports,
81
+ # **kwargs,
82
+ # )
iplotx/art3d/vertex.py ADDED
@@ -0,0 +1,87 @@
1
+ """
2
+ Module containing code to manipulate vertex visualisations in 3D, especially the Vertex3DCollection class.
3
+ """
4
+
5
+ from typing import (
6
+ Sequence,
7
+ )
8
+ import numpy as np
9
+ from matplotlib import (
10
+ cbook,
11
+ )
12
+ from mpl_toolkits.mplot3d import Axes3D
13
+ from mpl_toolkits.mplot3d.art3d import (
14
+ Path3DCollection,
15
+ text_2d_to_3d,
16
+ )
17
+
18
+ from ..utils.matplotlib import (
19
+ _forwarder,
20
+ )
21
+ from ..vertex import (
22
+ VertexCollection,
23
+ )
24
+
25
+
26
+ @_forwarder(
27
+ (
28
+ "set_clip_path",
29
+ "set_clip_box",
30
+ "set_snap",
31
+ "set_sketch_params",
32
+ "set_animated",
33
+ "set_picker",
34
+ )
35
+ )
36
+ class Vertex3DCollection(VertexCollection, Path3DCollection):
37
+ """Collection of vertex patches for plotting."""
38
+
39
+ def _update_before_draw(self) -> None:
40
+ """Update the collection before drawing."""
41
+ # Set the sizes according to the current figure dpi
42
+ VertexCollection._update_before_draw(self)
43
+
44
+ if isinstance(self.axes, Axes3D) and hasattr(self, "do_3d_projection"):
45
+ self.do_3d_projection()
46
+
47
+ def draw(self, renderer) -> None:
48
+ """Draw the collection of vertices in 3D.
49
+
50
+ Parameters:
51
+ renderer: The renderer to use for drawing.
52
+ """
53
+ with self._use_zordered_offset():
54
+ with cbook._setattr_cm(self, _in_draw=True):
55
+ VertexCollection.draw(self, renderer)
56
+
57
+
58
+ def vertex_collection_2d_to_3d(
59
+ col: VertexCollection,
60
+ zs: np.ndarray | float | Sequence[float] = 0,
61
+ zdir: str = "z",
62
+ depthshade: bool = True,
63
+ axlim_clip: bool = False,
64
+ ):
65
+ """Convert a 2D VertexCollection to a 3D Vertex3DCollection.
66
+
67
+ Parameters:
68
+ col: The 2D VertexCollection to convert.
69
+ zs: The z coordinate(s) to use for the 3D vertices.
70
+ zdir: The axis to use as the z axis (default is "z").
71
+ depthshade: Whether to aply depth shading (default is True).
72
+ axlim_clip: Whether to clip the vertices to the axes limits (default is False).
73
+ """
74
+ if not isinstance(col, VertexCollection):
75
+ raise TypeError("vertices must be a VertexCollection")
76
+
77
+ col.__class__ = Vertex3DCollection
78
+ col._offset_zordered = None
79
+ col._depthshade = depthshade
80
+ col._in_draw = False
81
+ col.set_3d_properties(zs, zdir, axlim_clip)
82
+
83
+ # Labels if present
84
+ if col.get_labels() is not None:
85
+ for z, art in zip(zs, col.get_labels()._labelartists):
86
+ # zdir=None means the text is always horizontal facing the camera
87
+ text_2d_to_3d(art, z, zdir=None, axlim_clip=axlim_clip)
iplotx/artists.py CHANGED
@@ -10,6 +10,8 @@ from .label import LabelCollection
10
10
  from .edge.arrow import EdgeArrowCollection
11
11
  from .edge.leaf import LeafEdgeCollection
12
12
  from .cascades import CascadeCollection
13
+ from .art3d.vertex import Vertex3DCollection
14
+ from .art3d.edge import Edge3DCollection
13
15
 
14
16
 
15
17
  ___all__ = (
@@ -21,4 +23,6 @@ ___all__ = (
21
23
  LabelCollection,
22
24
  EdgeArrowCollection,
23
25
  CascadeCollection,
26
+ Vertex3DCollection,
27
+ Edge3DCollection,
24
28
  )
iplotx/edge/__init__.py CHANGED
@@ -9,7 +9,7 @@ from typing import (
9
9
  Optional,
10
10
  Any,
11
11
  )
12
- from math import atan2, cos, pi, sin
12
+ from math import pi
13
13
  from collections import defaultdict
14
14
  import numpy as np
15
15
  import pandas as pd
@@ -181,17 +181,33 @@ class EdgeCollection(mpl.collections.PatchCollection):
181
181
 
182
182
  def set_figure(self, fig) -> None:
183
183
  super().set_figure(fig)
184
- self._update_paths()
184
+ self._update_before_draw()
185
185
  # NOTE: This sets the correct offsets in the arrows,
186
186
  # but not the correct sizes (see below)
187
- self._update_children()
187
+ self._update_labels()
188
188
  for child in self.get_children():
189
189
  # NOTE: This sets the sizes with correct dpi scaling in the arrows
190
190
  child.set_figure(fig)
191
191
 
192
- def _update_children(self):
193
- self._update_arrows()
194
- self._update_labels()
192
+ @property
193
+ def axes(self):
194
+ return mpl.artist.Artist.axes.__get__(self)
195
+
196
+ @axes.setter
197
+ def axes(self, new_axes):
198
+ mpl.artist.Artist.axes.__set__(self, new_axes)
199
+ for child in self.get_children():
200
+ child.axes = new_axes
201
+
202
+ def set_transform(self, transform: mpl.transforms.Transform) -> None:
203
+ """Set the transform for the edges and their children."""
204
+ super().set_transform(transform)
205
+ if hasattr(self, "_subedges"):
206
+ self._subedges.set_transform(transform)
207
+ if hasattr(self, "_arrows"):
208
+ self._arrows.set_offset_transform(transform)
209
+ if hasattr(self, "_label_collection"):
210
+ self._label_collection.set_transform(transform)
195
211
 
196
212
  @property
197
213
  def directed(self) -> bool:
@@ -293,7 +309,7 @@ class EdgeCollection(mpl.collections.PatchCollection):
293
309
  "sizes": vsizes,
294
310
  }
295
311
 
296
- def _update_paths(self, transform=None):
312
+ def _update_before_draw(self, transform=None):
297
313
  """Compute paths for the edges.
298
314
 
299
315
  Loops split the largest wedge left open by other
@@ -502,42 +518,6 @@ class EdgeCollection(mpl.collections.PatchCollection):
502
518
  if not style.get("rotate", True):
503
519
  self._label_collection.set_rotations(rotations)
504
520
 
505
- def _update_arrows(
506
- self,
507
- ) -> None:
508
- """Extract the start and/or end angles of the paths to compute arrows.
509
-
510
- Parameters:
511
- which: Which end of the edge to put an arrow on. Currently only "end" is accepted.
512
-
513
- NOTE: This function does *not* update the arrow sizes/_transforms to the correct dpi
514
- scaling. That's ok since the correct dpi scaling is set whenever there is a different
515
- figure (before first draw) and whenever a draw is called.
516
- """
517
- if not hasattr(self, "_arrows"):
518
- return
519
-
520
- transform = self.get_transform()
521
- trans = transform.transform
522
-
523
- for i, epath in enumerate(self.get_paths()):
524
- # Offset the arrow to point to the end of the edge
525
- self._arrows._offsets[i] = epath.vertices[-1]
526
-
527
- # Rotate the arrow to point in the direction of the edge
528
- apath = self._arrows._paths[i]
529
- # NOTE: because the tip of the arrow is at (0, 0) in patch space,
530
- # in theory it will rotate around that point already
531
- v2 = trans(epath.vertices[-1])
532
- v1 = trans(epath.vertices[-2])
533
- dv = v2 - v1
534
- theta = atan2(*(dv[::-1]))
535
- theta_old = self._arrows._angles[i]
536
- dtheta = theta - theta_old
537
- mrot = np.array([[cos(dtheta), sin(dtheta)], [-sin(dtheta), cos(dtheta)]])
538
- apath.vertices = apath.vertices @ mrot
539
- self._arrows._angles[i] = theta
540
-
541
521
  @_stale_wrapper
542
522
  def draw(self, renderer):
543
523
  # Visibility affects the children too
@@ -545,11 +525,15 @@ class EdgeCollection(mpl.collections.PatchCollection):
545
525
  return
546
526
 
547
527
  # This includes the subedges if present
548
- self._update_paths()
549
- # This sets the arrow offsets
550
- self._update_children()
528
+ self._update_before_draw()
551
529
 
530
+ # Now you can draw the edges
552
531
  super().draw(renderer)
532
+
533
+ # This sets the labels offsets
534
+ self._update_labels()
535
+
536
+ # Now you can draw arrows and labels
553
537
  for child in self.get_children():
554
538
  child.draw(renderer)
555
539
 
iplotx/edge/arrow.py CHANGED
@@ -4,6 +4,7 @@ Module for edge arrows in iplotx.
4
4
 
5
5
  from typing import Never, Optional
6
6
 
7
+ from math import atan2, cos, sin
7
8
  import numpy as np
8
9
  import matplotlib as mpl
9
10
  from matplotlib.patches import PathPatch
@@ -91,9 +92,7 @@ class EdgeArrowCollection(mpl.collections.PatchCollection):
91
92
  def set_figure(self, fig) -> None:
92
93
  """Set the figure for this artist and all children."""
93
94
  super().set_figure(fig)
94
- self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
95
- for child in self.get_children():
96
- child.set_figure(fig)
95
+ self._update_before_draw()
97
96
 
98
97
  def get_offset_transform(self):
99
98
  """Get offset transform for the edge arrows. This sets the tip of each arrow."""
@@ -126,6 +125,30 @@ class EdgeArrowCollection(mpl.collections.PatchCollection):
126
125
 
127
126
  return patches, sizes
128
127
 
128
+ def _update_before_draw(self) -> None:
129
+ """Update the arrow paths and directions before drawing, based on the edge collection."""
130
+ self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
131
+
132
+ trans = self.get_offset_transform().transform
133
+
134
+ for i, epath in enumerate(self._edge_collection.get_paths()):
135
+ # Offset the arrow to point to the end of the edge
136
+ self._offsets[i] = epath.vertices[-1]
137
+
138
+ # Rotate the arrow to point in the direction of the edge
139
+ apath = self._paths[i]
140
+ # NOTE: because the tip of the arrow is at (0, 0) in patch space,
141
+ # in theory it will rotate around that point already
142
+ v2 = trans(epath.vertices[-1])
143
+ v1 = trans(epath.vertices[-2])
144
+ dv = v2 - v1
145
+ theta = atan2(*(dv[::-1]))
146
+ theta_old = self._angles[i]
147
+ dtheta = theta - theta_old
148
+ mrot = np.array([[cos(dtheta), sin(dtheta)], [-sin(dtheta), cos(dtheta)]])
149
+ apath.vertices = apath.vertices @ mrot
150
+ self._angles[i] = theta
151
+
129
152
  def set_array(self, A: np.ndarray) -> Never:
130
153
  """Set the array for cmap/norm coloring, but keep the facecolors as set (usually 'none')."""
131
154
  raise ValueError("Setting an array for arrows directly is not supported.")
@@ -145,7 +168,7 @@ class EdgeArrowCollection(mpl.collections.PatchCollection):
145
168
 
146
169
  @mpl.artist.allow_rasterization
147
170
  def draw(self, renderer):
148
- self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
171
+ self._update_before_draw()
149
172
  super().draw(renderer)
150
173
 
151
174
 
iplotx/edge/geometry.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """
2
2
  Support module with geometry- and path-related functions for edges.
3
+
4
+ 3D geometry is in a separate module.
3
5
  """
4
6
 
5
7
  from typing import (
@@ -65,6 +67,14 @@ def _compute_loops_per_angle(nloops, angles):
65
67
 
66
68
 
67
69
  def _get_shorter_edge_coords(vpath, vsize, theta, shrink=0):
70
+ """Get the coordinates of an edge tip such that it touches the vertex border.
71
+
72
+ Parameters:
73
+ vpath: the vertex path, in figure coordinates (so scaled by dpi).
74
+ vsize: the vertex max size, in figure coordinates (so scaled by dpi).
75
+ theta: the angle of the edge inpinging into the vertex, in radians, in figure coordinates.
76
+ shrink: additional shrinking of the edge, in figure coordinates (so scaled by dpi).
77
+ """
68
78
  # Bound theta from -pi to pi (why is that not guaranteed?)
69
79
  theta = (theta + pi) % (2 * pi) - pi
70
80
 
iplotx/groups.py CHANGED
@@ -4,6 +4,7 @@ Module for vertex groupings code, especially the GroupingArtist class.
4
4
 
5
5
  from typing import Union
6
6
  import numpy as np
7
+ import pandas as pd
7
8
  import matplotlib as mpl
8
9
  from matplotlib.collections import PatchCollection
9
10
 
@@ -64,6 +65,9 @@ class GroupingArtist(PatchCollection):
64
65
  self._points_per_curve = points_per_curve
65
66
 
66
67
  network = kwargs.pop("network", None)
68
+ self.layout = normalise_layout(layout, network=network)
69
+ self.ndim = layout.shape[1]
70
+
67
71
  patches, grouping, coords_hulls = self._create_patches(
68
72
  grouping,
69
73
  layout,
@@ -89,6 +93,21 @@ class GroupingArtist(PatchCollection):
89
93
  self._compute_paths(self.get_figure(root=True).dpi)
90
94
  return ret
91
95
 
96
+ @property
97
+ def axes(self):
98
+ return PatchCollection.axes.__get__(self)
99
+
100
+ @axes.setter
101
+ def axes(self, new_axes):
102
+ PatchCollection.axes.__set__(self, new_axes)
103
+ for child in self.get_children():
104
+ child.axes = new_axes
105
+ self.set_figure(new_axes.figure)
106
+
107
+ def get_layout(self) -> pd.DataFrame:
108
+ """Get the layout used for this grouping."""
109
+ return self.layout
110
+
92
111
  def get_vertexpadding(self) -> float:
93
112
  """Get the vertex padding of each group."""
94
113
  return self._vertexpadding
@@ -98,7 +117,6 @@ class GroupingArtist(PatchCollection):
98
117
  return self.get_vertexpadding() * dpi / 72.0 * self._factor
99
118
 
100
119
  def _create_patches(self, grouping, layout, network, **kwargs):
101
- layout = normalise_layout(layout, network=network)
102
120
  grouping = normalise_grouping(grouping, layout)
103
121
  style = get_style(".grouping")
104
122
  style.pop("vertexpadding", None)
@@ -30,23 +30,22 @@ class IGraphDataProvider(NetworkDataProvider):
30
30
  edge_labels: Optional[Sequence[str] | dict[str]] = None,
31
31
  ) -> NetworkData:
32
32
  """Create network data object for iplotx from an igraph object."""
33
- network = self.network
34
- directed = self.is_directed()
35
33
 
36
- # Recast vertex_labels=False as vertex_labels=None
37
- if np.isscalar(vertex_labels) and (not vertex_labels):
38
- vertex_labels = None
39
-
40
- # Vertices are ordered integers, no gaps
34
+ # Get layout
41
35
  vertex_df = normalise_layout(
42
36
  layout,
43
- network=network,
37
+ network=self.network,
44
38
  nvertices=self.number_of_vertices(),
45
39
  )
46
40
  ndim = vertex_df.shape[1]
47
41
  vertex_df.columns = _make_layout_columns(ndim)
48
42
 
43
+ # Vertices are ordered integers, no gaps
44
+
49
45
  # Vertex labels
46
+ # Recast vertex_labels=False as vertex_labels=None
47
+ if np.isscalar(vertex_labels) and (not vertex_labels):
48
+ vertex_labels = None
50
49
  if vertex_labels is not None:
51
50
  if np.isscalar(vertex_labels):
52
51
  vertex_df["label"] = vertex_df.index.astype(str)
@@ -57,7 +56,7 @@ class IGraphDataProvider(NetworkDataProvider):
57
56
 
58
57
  # Edges are a list of tuples, because of multiedges
59
58
  tmp = []
60
- for edge in network.es:
59
+ for edge in self.network.es:
61
60
  row = {"_ipx_source": edge.source, "_ipx_target": edge.target}
62
61
  row.update(edge.attributes())
63
62
  tmp.append(row)
@@ -76,7 +75,7 @@ class IGraphDataProvider(NetworkDataProvider):
76
75
  network_data = {
77
76
  "vertex_df": vertex_df,
78
77
  "edge_df": edge_df,
79
- "directed": directed,
78
+ "directed": self.is_directed(),
80
79
  "ndim": ndim,
81
80
  }
82
81
  return network_data
@@ -33,25 +33,20 @@ class NetworkXDataProvider(NetworkDataProvider):
33
33
 
34
34
  import networkx as nx
35
35
 
36
- network = self.network
37
-
38
- directed = self.is_directed()
39
-
40
- # Recast vertex_labels=False as vertex_labels=None
41
- if np.isscalar(vertex_labels) and (not vertex_labels):
42
- vertex_labels = None
43
-
44
- # Vertices are indexed by node ID
36
+ # Get layout
45
37
  vertex_df = normalise_layout(
46
38
  layout,
47
- network=network,
39
+ network=self.network,
48
40
  nvertices=self.number_of_vertices(),
49
- ).loc[pd.Index(network.nodes)]
41
+ )
50
42
  ndim = vertex_df.shape[1]
51
43
  vertex_df.columns = _make_layout_columns(ndim)
52
44
 
45
+ # Vertices are indexed by node ID
46
+ vertex_df = vertex_df.loc[pd.Index(self.network.nodes)]
47
+
53
48
  # Vertex internal properties
54
- tmp = pd.DataFrame(dict(network.nodes.data())).T
49
+ tmp = pd.DataFrame(dict(self.network.nodes.data())).T
55
50
  # Arrays become a single column, which we have already anyway
56
51
  if isinstance(layout, str) and (layout in tmp.columns):
57
52
  del tmp[layout]
@@ -60,6 +55,9 @@ class NetworkXDataProvider(NetworkDataProvider):
60
55
  del tmp
61
56
 
62
57
  # Vertex labels
58
+ # Recast vertex_labels=False as vertex_labels=None
59
+ if np.isscalar(vertex_labels) and (not vertex_labels):
60
+ vertex_labels = None
63
61
  if vertex_labels is None:
64
62
  if "label" in vertex_df:
65
63
  del vertex_df["label"]
@@ -78,7 +76,7 @@ class NetworkXDataProvider(NetworkDataProvider):
78
76
 
79
77
  # Edges are a list of tuples, because of multiedges
80
78
  tmp = []
81
- for u, v, d in network.edges.data():
79
+ for u, v, d in self.network.edges.data():
82
80
  row = {"_ipx_source": u, "_ipx_target": v}
83
81
  row.update(d)
84
82
  tmp.append(row)
@@ -112,7 +110,7 @@ class NetworkXDataProvider(NetworkDataProvider):
112
110
  network_data = {
113
111
  "vertex_df": vertex_df,
114
112
  "edge_df": edge_df,
115
- "directed": directed,
113
+ "directed": self.is_directed(),
116
114
  "ndim": ndim,
117
115
  }
118
116
  return network_data
iplotx/label.py CHANGED
@@ -77,6 +77,27 @@ class LabelCollection(mpl.artist.Artist):
77
77
  child.set_figure(fig)
78
78
  self._update_offsets(dpi=fig.dpi)
79
79
 
80
+ @property
81
+ def axes(self):
82
+ return mpl.artist.Artist.axes.__get__(self)
83
+
84
+ @axes.setter
85
+ def axes(self, new_axes):
86
+ mpl.artist.Artist.axes.__set__(self, new_axes)
87
+ for child in self.get_children():
88
+ child.axes = new_axes
89
+
90
+ def set_transform(self, transform: mpl.transforms.Transform) -> None:
91
+ """Set the transform for this artist and children.
92
+
93
+ Parameters:
94
+ transform: The transform to set.
95
+ """
96
+ super().set_transform(transform)
97
+ if hasattr(self, "_labelartists"):
98
+ for art in self._labelartists:
99
+ art.set_transform(transform)
100
+
80
101
  def get_texts(self):
81
102
  """Get the texts of the labels."""
82
103
  return [child.get_text() for child in self.get_children()]
iplotx/network.py CHANGED
@@ -30,6 +30,13 @@ from .edge import (
30
30
  EdgeCollection,
31
31
  make_stub_patch as make_undirected_edge_patch,
32
32
  )
33
+ from .art3d.vertex import (
34
+ vertex_collection_2d_to_3d,
35
+ )
36
+ from .art3d.edge import (
37
+ Edge3DCollection,
38
+ edge_collection_2d_to_3d,
39
+ )
33
40
 
34
41
 
35
42
  @_forwarder(
@@ -63,6 +70,10 @@ class NetworkArtist(mpl.artist.Artist):
63
70
  will be drawn. If a list, the labels are taken from the list. If a dict, the keys
64
71
  should be the vertex IDs and the values should be the labels.
65
72
  edge_labels: The labels for the edges. If None, no edge labels will be drawn.
73
+ transform: The transform to use for the vertices. Default is IdentityTransform.
74
+ offset_transform: The transform to use as offset transform for the vertices and main
75
+ transform for the edges. Default is None, but this should eventually be set to
76
+ ax.transData once the artist is added to an Axes.
66
77
 
67
78
  """
68
79
  self.network = network
@@ -111,7 +122,7 @@ class NetworkArtist(mpl.artist.Artist):
111
122
  @classmethod
112
123
  def from_edgecollection(
113
124
  cls: "NetworkArtist", # NOTE: This is fixed in Python 3.14
114
- edge_collection: EdgeCollection,
125
+ edge_collection: EdgeCollection | Edge3DCollection,
115
126
  ) -> Self:
116
127
  """Create a NetworkArtist from iplotx artists.
117
128
 
@@ -125,7 +136,7 @@ class NetworkArtist(mpl.artist.Artist):
125
136
  vertex_collection = edge_collection._vertex_collection
126
137
  layout = vertex_collection._layout
127
138
  transform = vertex_collection.get_transform()
128
- offset_transform = edge_collection.get_transform()
139
+ offset_transform = vertex_collection.get_offset_transform()
129
140
 
130
141
  # Follow the steps in the normal constructor
131
142
  self = cls(
@@ -134,6 +145,7 @@ class NetworkArtist(mpl.artist.Artist):
134
145
  transform=transform,
135
146
  offset_transform=offset_transform,
136
147
  )
148
+ # TODO: should we make copies here?
137
149
  self._vertices = vertex_collection
138
150
  self._edges = edge_collection
139
151
 
@@ -150,6 +162,17 @@ class NetworkArtist(mpl.artist.Artist):
150
162
  for child in self.get_children():
151
163
  child.set_figure(fig)
152
164
 
165
+ @property
166
+ def axes(self):
167
+ return mpl.artist.Artist.axes.__get__(self)
168
+
169
+ @axes.setter
170
+ def axes(self, new_axes):
171
+ mpl.artist.Artist.axes.__set__(self, new_axes)
172
+ for child in self.get_children():
173
+ child.axes = new_axes
174
+ self.set_figure(new_axes.figure)
175
+
153
176
  def get_offset_transform(self):
154
177
  """Get the offset transform (for vertices/edges)."""
155
178
  return self._offset_transform
@@ -157,6 +180,10 @@ class NetworkArtist(mpl.artist.Artist):
157
180
  def set_offset_transform(self, offset_transform):
158
181
  """Set the offset transform (for vertices/edges)."""
159
182
  self._offset_transform = offset_transform
183
+ if hasattr(self, "_vertices"):
184
+ self._vertices.set_offset_transform(offset_transform)
185
+ if hasattr(self, "_edges"):
186
+ self._edges.set_transform(offset_transform)
160
187
 
161
188
  def get_vertices(self):
162
189
  """Get VertexCollection artist."""
@@ -210,10 +237,23 @@ class NetworkArtist(mpl.artist.Artist):
210
237
  self.axes.autoscale_view(tight=tight)
211
238
 
212
239
  def get_layout(self):
213
- layout_columns = [f"_ipx_layout_{i}" for i in range(self._ipx_internal_data["ndim"])]
240
+ """Get the vertex layout.
241
+
242
+ Returns:
243
+ The vertex layout as a DataFrame.
244
+ """
245
+ layout_columns = [f"_ipx_layout_{i}" for i in range(self.get_ndim())]
214
246
  vertex_layout_df = self._ipx_internal_data["vertex_df"][layout_columns]
215
247
  return vertex_layout_df
216
248
 
249
+ def get_ndim(self):
250
+ """Get the dimensionality of the layout.
251
+
252
+ Returns:
253
+ The dimensionality of the layout (2 or 3).
254
+ """
255
+ return self._ipx_internal_data["ndim"]
256
+
217
257
  def _get_label_series(self, kind):
218
258
  # Equivalence vertex/node
219
259
  if kind == "node":
@@ -238,6 +278,13 @@ class NetworkArtist(mpl.artist.Artist):
238
278
  offset_transform=self.get_offset_transform(),
239
279
  )
240
280
 
281
+ if self.get_ndim() == 3:
282
+ vertex_collection_2d_to_3d(
283
+ self._vertices,
284
+ zs=self.get_layout().iloc[:, 2].values,
285
+ depthshade=False,
286
+ )
287
+
241
288
  def _add_edges(self):
242
289
  """Add edges to the network artist.
243
290
 
@@ -319,6 +366,11 @@ class NetworkArtist(mpl.artist.Artist):
319
366
  if "cmap" in edge_style:
320
367
  self._edges.set_array(colorarray)
321
368
 
369
+ if self.get_ndim() == 3:
370
+ edge_collection_2d_to_3d(
371
+ self._edges,
372
+ )
373
+
322
374
  @_stale_wrapper
323
375
  def draw(self, renderer):
324
376
  """Draw each of the children, with some buffering mechanism."""
@@ -332,8 +384,8 @@ class NetworkArtist(mpl.artist.Artist):
332
384
  # Handle zorder manually, just like in AxesBase in mpl
333
385
  children = list(self.get_children())
334
386
  children.sort(key=lambda x: x.zorder)
335
- for art in children:
336
- art.draw(renderer)
387
+ for child in children:
388
+ child.draw(renderer)
337
389
 
338
390
 
339
391
  def _update_from_internal(style, row, kind):
iplotx/plotting.py CHANGED
@@ -3,6 +3,7 @@ from contextlib import nullcontext
3
3
  import numpy as np
4
4
  import pandas as pd
5
5
  import matplotlib as mpl
6
+ from mpl_toolkits.mplot3d.axes3d import Axes3D
6
7
  import matplotlib.pyplot as plt
7
8
 
8
9
  from .typing import (
@@ -28,7 +29,7 @@ def network(
28
29
  style: str | dict | Sequence[str | dict] = (),
29
30
  title: Optional[str] = None,
30
31
  aspect: Optional[str | float] = None,
31
- margins: float | tuple[float, float] = 0,
32
+ margins: float | tuple[float, float] | tuple[float, float, float] = 0,
32
33
  strip_axes: bool = True,
33
34
  figsize: Optional[tuple[float, float]] = None,
34
35
  **kwargs,
@@ -53,13 +54,15 @@ def network(
53
54
  style: Apply this style for the objects to plot. This can be a sequence (e.g. list)
54
55
  of styles and they will be applied in order.
55
56
  title: If not None, set the axes title to this value.
56
- aspect: If not None, set the aspect ratio of the axis to this value. The most common
57
- value is 1.0, which proportionates x- and y-axes.
57
+ aspect: If not None, set the aspect ratio of the axis to this value. In 2D, the most
58
+ common value is 1.0, which proportionates x- and y-axes. In 3D, only string
59
+ values are accepted (see the documentation of Axes.set_aspect).
58
60
  margins: How much margin to leave around the plot. A higher value (e.g. 0.1) can be
59
61
  used as a quick fix when some vertex shapes reach beyond the plot edge. This is
60
62
  a fraction of the data limits, so 0.1 means 10% of the data limits will be left
61
- as margin.
62
- strip_axes: If True, remove axis spines and ticks.
63
+ as margin. A pair (in 2D) or triplet (in 3D) of floats can also be provided and
64
+ applied to each axis separately.
65
+ strip_axes: If True, remove axis spines and ticks. In 3D, only ticks are removed.
63
66
  figsize: If ax is None, a new matplotlib Figure is created. This argument specifies
64
67
  the (width, height) dimension of the figure in inches. If ax is not None, this
65
68
  argument is ignored. If None, the default matplotlib figure size is used.
@@ -83,9 +86,6 @@ def network(
83
86
  if (network is None) and (grouping is None):
84
87
  raise ValueError("At least one of network or grouping must be provided.")
85
88
 
86
- if ax is None:
87
- fig, ax = plt.subplots(figsize=figsize)
88
-
89
89
  artists = []
90
90
  if network is not None:
91
91
  nwkart = NetworkArtist(
@@ -94,30 +94,58 @@ def network(
94
94
  vertex_labels=vertex_labels,
95
95
  edge_labels=edge_labels,
96
96
  transform=mpl.transforms.IdentityTransform(),
97
- offset_transform=ax.transData,
98
97
  )
99
- ax.add_artist(nwkart)
100
-
101
- # Set the figure, which itself sets the dpi scale for vertices, edges,
102
- # arrows, etc. Now data limits can be computed correctly
103
- nwkart.set_figure(ax.figure)
104
-
105
98
  artists.append(nwkart)
106
-
107
- # Set normailsed layout since we have it by now
108
99
  layout = nwkart.get_layout()
100
+ else:
101
+ nwkart = None
109
102
 
110
103
  if grouping is not None:
111
104
  grpart = GroupingArtist(
112
105
  grouping,
113
106
  layout,
114
107
  network=network,
115
- transform=ax.transData,
116
108
  )
117
- ax.add_artist(grpart)
118
-
119
- grpart.set_figure(ax.figure)
109
+ layout = grpart.get_layout()
120
110
  artists.append(grpart)
111
+ else:
112
+ grpart = None
113
+
114
+ if (nwkart is not None) or (grpart is not None):
115
+ ndim = layout.shape[1]
116
+ else:
117
+ ndim = None
118
+
119
+ if ax is None:
120
+ if ndim == 3:
121
+ fig = plt.figure(figsize=figsize)
122
+ ax = fig.add_subplot(111, projection="3d")
123
+ else:
124
+ fig, ax = plt.subplots(figsize=figsize)
125
+ ndim = 2
126
+ else:
127
+ # Check that the expected axis projection is used (3d for 3d layouts)
128
+ if ndim == 3:
129
+ assert isinstance(ax, Axes3D)
130
+ elif ndim == 2:
131
+ # NOTE: technically we probably want it to be cartesian (not polar, etc.)
132
+ # but let's be flexible for now and let that request bubble up from users
133
+ assert not isinstance(ax, Axes3D)
134
+
135
+ # This is used in 3D for autoscaling
136
+ had_data = ax.has_data()
137
+
138
+ if nwkart is not None:
139
+ # Set the figure, which itself sets the dpi scale for vertices, edges,
140
+ # arrows, etc. Now data limits can be computed correctly
141
+ nwkart.set_offset_transform(ax.transData)
142
+ ax.add_artist(nwkart)
143
+ nwkart.axes = ax
144
+
145
+ if grpart is not None:
146
+ grpart.set_transform(ax.transData)
147
+ ax.add_artist(grpart)
148
+ grpart.ax = ax
121
149
 
122
150
  if title is not None:
123
151
  ax.set_title(title)
@@ -125,11 +153,11 @@ def network(
125
153
  if aspect is not None:
126
154
  ax.set_aspect(aspect)
127
155
 
128
- _postprocess_axes(ax, artists, strip=strip_axes)
156
+ _postprocess_axes(ax, artists, strip=strip_axes, had_data=had_data)
129
157
 
130
158
  if np.isscalar(margins):
131
- margins = (margins, margins)
132
- if (margins[0] != 0) or (margins[1] != 0):
159
+ margins = [margins] * ndim
160
+ if (margins[0] != 0) or (margins[1] != 0) or ((len(margins) == 3) and (margins[2] != 0)):
133
161
  ax.margins(*margins)
134
162
 
135
163
  return artists
@@ -223,7 +251,6 @@ def tree(
223
251
  show_support=show_support,
224
252
  )
225
253
  ax.add_artist(artist)
226
-
227
254
  artist.set_figure(ax.figure)
228
255
 
229
256
  if title is not None:
@@ -243,26 +270,46 @@ def tree(
243
270
 
244
271
 
245
272
  # INTERNAL ROUTINES
246
- def _postprocess_axes(ax, artists, strip=True):
273
+ def _postprocess_axes(ax, artists, strip=True, had_data=None):
247
274
  """Postprocess axis after plotting."""
248
275
 
249
276
  if strip:
250
- # Despine
251
- ax.spines["right"].set_visible(False)
252
- ax.spines["top"].set_visible(False)
253
- ax.spines["left"].set_visible(False)
254
- ax.spines["bottom"].set_visible(False)
277
+ if not isinstance(ax, Axes3D):
278
+ # Despine
279
+ ax.spines["right"].set_visible(False)
280
+ ax.spines["top"].set_visible(False)
281
+ ax.spines["left"].set_visible(False)
282
+ ax.spines["bottom"].set_visible(False)
255
283
 
256
284
  # Remove axis ticks
257
285
  ax.set_xticks([])
258
286
  ax.set_yticks([])
259
-
260
- # Set new data limits
261
- bboxes = []
262
- for art in artists:
263
- bboxes.append(art.get_datalim(ax.transData))
264
- bbox = mpl.transforms.Bbox.union(bboxes)
265
- ax.update_datalim(bbox)
266
-
267
- # Autoscale for x/y axis limits
268
- ax.autoscale_view()
287
+ if isinstance(ax, Axes3D):
288
+ ax.set_zticks([])
289
+
290
+ # NOTE: bboxes appear to be not that well defined in 3D axes
291
+ # instead, there is a dedicated function that is a little
292
+ # pedestrian
293
+ if isinstance(ax, Axes3D):
294
+ for art in artists:
295
+ XYZ = art.get_layout().values.T
296
+ if ax._zmargin < 0.05 and XYZ[0].size > 0:
297
+ ax.set_zmargin(0.05)
298
+ ax.auto_scale_xyz(
299
+ *XYZ,
300
+ had_data=had_data,
301
+ )
302
+ # NOTE: breaking is not needed, worst case it will
303
+ # autoscale twice (for network and grouping), which
304
+ # is better, at this stage of development, than
305
+ # trying to be too clever by doing the math outselves
306
+ else:
307
+ # Set new data limits
308
+ bboxes = []
309
+ for art in artists:
310
+ bboxes.append(art.get_datalim(ax.transData))
311
+ bbox = mpl.transforms.Bbox.union(bboxes)
312
+ ax.update_datalim(bbox)
313
+
314
+ # Autoscale for x/y axis limits
315
+ ax.autoscale_view()
iplotx/version.py CHANGED
@@ -2,4 +2,4 @@
2
2
  iplotx version information module.
3
3
  """
4
4
 
5
- __version__ = "0.9.0"
5
+ __version__ = "0.11.0"
iplotx/vertex.py CHANGED
@@ -119,6 +119,16 @@ class VertexCollection(PatchCollection):
119
119
  for child in self.get_children():
120
120
  child.set_figure(fig)
121
121
 
122
+ @property
123
+ def axes(self):
124
+ return PatchCollection.axes.__get__(self)
125
+
126
+ @axes.setter
127
+ def axes(self, new_axes):
128
+ PatchCollection.axes.__set__(self, new_axes)
129
+ for child in self.get_children():
130
+ child.axes = new_axes
131
+
122
132
  def get_index(self):
123
133
  """Get the VertexCollection index."""
124
134
  return self._index
@@ -196,7 +206,9 @@ class VertexCollection(PatchCollection):
196
206
  def _update_offsets_from_layout(self) -> None:
197
207
  """Update offsets in matplotlib coordinates from the layout DataFrame."""
198
208
  if self._layout_coordinate_system == "cartesian":
199
- self._offsets = self._layout.values
209
+ # Make sure we accept 3D values and ignore the z component if present
210
+ # This makes life upstream a little more readable
211
+ self._offsets = self._layout.values[:, :2]
200
212
  elif self._layout_coordinate_system == "polar":
201
213
  # Convert polar coordinates (r, theta) to cartesian (x, y)
202
214
  r = self._layout.iloc[:, 0].values
@@ -221,6 +233,16 @@ class VertexCollection(PatchCollection):
221
233
  self._update_offsets_from_layout()
222
234
  self.stale = True
223
235
 
236
+ def set_offset_transform(self, transform: mpl.transforms.Transform) -> None:
237
+ """Set the offset transform for the vertices.
238
+
239
+ Parameters:
240
+ transform: The matplotlib transform to use for the offsets.
241
+ """
242
+ super().set_offset_transform(transform)
243
+ if hasattr(self, "_label_collection"):
244
+ self._label_collection.set_transform(transform)
245
+
224
246
  def get_style(self) -> Optional[dict[str, Any]]:
225
247
  """Get the style dictionary for the vertices."""
226
248
  return self._style
@@ -293,6 +315,10 @@ class VertexCollection(PatchCollection):
293
315
  transform=transform,
294
316
  )
295
317
 
318
+ def get_ndim(self):
319
+ """Get the number of dimensions of the layout."""
320
+ return self._layout.shape[1]
321
+
296
322
  def get_labels(self):
297
323
  """Get the vertex labels.
298
324
 
@@ -339,6 +365,10 @@ class VertexCollection(PatchCollection):
339
365
  rotations = np.arctan2(doffsets_fig[:, 1], doffsets_fig[:, 0])
340
366
  self.get_labels().set_rotations(rotations)
341
367
 
368
+ def _update_before_draw(self) -> None:
369
+ """Update the collection before drawing."""
370
+ self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
371
+
342
372
  @mpl.artist.allow_rasterization
343
373
  def draw(self, renderer):
344
374
  if not self.get_visible():
@@ -349,7 +379,8 @@ class VertexCollection(PatchCollection):
349
379
  if len(self.get_paths()) == 0:
350
380
  return
351
381
 
352
- self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
382
+ self._update_before_draw()
383
+ super().draw(renderer)
353
384
 
354
385
  # Set the label rotations already, hopefully this is not too early
355
386
  self._update_children()
@@ -357,7 +388,6 @@ class VertexCollection(PatchCollection):
357
388
  # NOTE: This draws the vertices first, then the labels.
358
389
  # The correct order would be vertex1->label1->vertex2->label2, etc.
359
390
  # We might fix if we manage to find a way to do it.
360
- super().draw(renderer)
361
391
  for child in self.get_children():
362
392
  child.draw(renderer)
363
393
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iplotx
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Plot networkx from igraph and networkx.
5
5
  Project-URL: Homepage, https://github.com/fabilab/iplotx
6
6
  Project-URL: Documentation, https://readthedocs.org/iplotx
@@ -29,7 +29,6 @@ Requires-Python: >=3.11
29
29
  Requires-Dist: matplotlib>=2.0.0
30
30
  Requires-Dist: numpy>=2.0.0
31
31
  Requires-Dist: pandas>=2.0.0
32
- Requires-Dist: pylint>=3.3.7
33
32
  Provides-Extra: igraph
34
33
  Requires-Dist: igraph>=0.11.0; extra == 'igraph'
35
34
  Provides-Extra: networkx
@@ -1,25 +1,29 @@
1
1
  iplotx/__init__.py,sha256=RzSct91jO8abrxOIn33rKEnDUgYpu1oj4olbObgX_hs,489
2
- iplotx/artists.py,sha256=Bpn6NS8S_B_E4OW88JYW6aEu2bIuIQJmbs2paTmBAoY,522
2
+ iplotx/artists.py,sha256=XNtRwuvQdKkZCAejILydLD3J5B87sg5xPXuZFv_Gkk8,654
3
3
  iplotx/cascades.py,sha256=OPqF7Huls-HFmDA5MCF6DEZlUeRVaXsbQcHBoKAgNJs,8182
4
- iplotx/groups.py,sha256=_9KdIiTAi1kXtd2mDywgBJCbqoRq2z-5fzOPf76Wgb8,6287
5
- iplotx/label.py,sha256=6am3a0ejcW_bWEXSOODE1Ke3AyCU1lJ45RfnXNbHAQw,8923
4
+ iplotx/groups.py,sha256=X0G-EULkd7WBn1j82r-cBgpzZRd7gQ1cfqFoYNweLns,6775
5
+ iplotx/label.py,sha256=7eS8ByadrhdIFOZz19U4VrS-oXY_ndFYNB-D4RZbFqI,9573
6
6
  iplotx/layout.py,sha256=KxmRLqjo8AYCBAmXez8rIiLU2sM34qhb6ox9AHYwRyE,4839
7
- iplotx/network.py,sha256=SGmXXrFxqgOoQcEJSZdL3WiI6rHDlPOGg5loGPrYpDk,11688
8
- iplotx/plotting.py,sha256=imlJZdx3S9B59TQPqrHEwQwJEnpI9SljthK34n3QJQY,11007
7
+ iplotx/network.py,sha256=ae5rZwzWxmcBQXx1Y0q24jaXcM1hT1kip-JKsyk11QY,13385
8
+ iplotx/plotting.py,sha256=icEefWJnS2lEGLp4t1LhDSP40JuvNKgOie3FDLOnTMk,13195
9
9
  iplotx/tree.py,sha256=TxbNoBHS0CfswrcMIWCNtnOl_3e4-PwCrVo0goywC0U,28807
10
10
  iplotx/typing.py,sha256=QLdzV358IiD1CFe88MVp0D77FSx5sSAVUmM_2WPPE8I,1463
11
- iplotx/version.py,sha256=rlV8GqlJtRzwlHxPle9bW-H7xuYNreaGGD2bFrax930,66
12
- iplotx/vertex.py,sha256=hqdlD9fRBSwH5bRvlpaaPu7jgUR4z9nob1SYfPWDxtI,14966
13
- iplotx/edge/__init__.py,sha256=AVnLsrDWWCkix1LVhrjpWKEKDxOp8joM4tF6RqEHC8I,27115
14
- iplotx/edge/arrow.py,sha256=ZKt3UNZ7XRa2S3KxpoQfd4q_6eSUHOS476BZNqlf2pw,16462
15
- iplotx/edge/geometry.py,sha256=wpFTi12-BaUaWr6Ie-nHV_SMAdSGJvjzJaqeEaSPf9w,15053
11
+ iplotx/version.py,sha256=mharC6dtEtQmAi9lgWMRhn8D3jCoxBqPbGjIoeD7D9Y,67
12
+ iplotx/vertex.py,sha256=bjvAy9UciPWkA1J-SroWF9ZaTXRzNKtDZXBlZ80VM60,16026
13
+ iplotx/art3d/vertex.py,sha256=Xf8Um30X2doCd8KdNN7332F6BxC4k72Mb_GeRAuzQfQ,2545
14
+ iplotx/art3d/edge/__init__.py,sha256=EzzW06YEeyIu52gXormkGIobae-etwKevZ_PDBr-S9c,4624
15
+ iplotx/art3d/edge/arrow.py,sha256=14BFXY9kDOUGPZl2fMD9gRVGyaaN5kyd-l6ikBg6WHU,3601
16
+ iplotx/art3d/edge/geometry.py,sha256=76VUmpPG-4Mls7x_994dMwdDPrWWnjT7nHJsHfwK_hA,2467
17
+ iplotx/edge/__init__.py,sha256=wMKXD1h5SBaUv6HmebIc5wc9k8AuukaXzAOBu7epaqA,26341
18
+ iplotx/edge/arrow.py,sha256=U7vvBo7IMwo1qiyU9cyUEwraOaBcJLgdu9oU2OyoHL4,17453
19
+ iplotx/edge/geometry.py,sha256=jkTMvQC5425GjB_fmGLIPJeSDAr_7NZF8zZDLTrSj34,15541
16
20
  iplotx/edge/leaf.py,sha256=SyGMv2PIOoH0pey8-aMVaZheK3hNe1Qz_okcyWbc4E4,4268
17
21
  iplotx/edge/ports.py,sha256=BpkbiEhX4mPBBAhOv4jcKFG4Y8hxXz5GRtVLCC0jbtI,1235
18
22
  iplotx/ingest/__init__.py,sha256=S0YfnXcFKseB7ZBQc4yRt0cNDsLlhqdom0TmSY3OY2E,4756
19
23
  iplotx/ingest/heuristics.py,sha256=715VqgfKek5LOJnu1vTo7RqPgCl-Bb8Cf6o7_Tt57fA,5797
20
24
  iplotx/ingest/typing.py,sha256=61LwNwrTHVh8eqqC778Gr81zPYcUKW61mDgGCCsuGSk,14181
21
- iplotx/ingest/providers/network/igraph.py,sha256=8dWeaQ_ZNdltC098V2YeLXsGdJHQnBa6shF1GAfl0Zg,2973
22
- iplotx/ingest/providers/network/networkx.py,sha256=4sPFOx87ipOYlXu0hjJl25Z4So_RnhO1CYYozGp-wJg,4626
25
+ iplotx/ingest/providers/network/igraph.py,sha256=WL9Yx2IF5QhUIoKMlozdyq5HWIZ-IJmNoeS8GOhL0KU,2945
26
+ iplotx/ingest/providers/network/networkx.py,sha256=ehCg4npL073HX-eAG-VoP6refLPsMb3lYG51xt_rNjA,4636
23
27
  iplotx/ingest/providers/network/simple.py,sha256=e_aHhiHhN9DrMoNrt7tEMPURXGhQ1TYRPzsxDEptUlc,3766
24
28
  iplotx/ingest/providers/tree/biopython.py,sha256=4N_54cVyHHPcASJZGr6pHKE2p5R3i8Cm307SLlSLHLA,1480
25
29
  iplotx/ingest/providers/tree/cogent3.py,sha256=JmELbDK7LyybiJzFNbmeqZ4ySJoDajvFfJebpNfFKWo,1073
@@ -33,6 +37,6 @@ iplotx/utils/geometry.py,sha256=6RrC6qaB0-1vIk1LhGA4CfsiMd-9JNniSPyL_l9mshE,9245
33
37
  iplotx/utils/internal.py,sha256=WWfcZDGK8Ut1y_tOHRGg9wSqY1bwSeLQO7dHM_8Tvwo,107
34
38
  iplotx/utils/matplotlib.py,sha256=wELE73quQv10-1w9uA5eDTgkZkylJvjg7pd3K5tZPOo,6294
35
39
  iplotx/utils/style.py,sha256=vyNP80nDYVinqm6_9ltCJCtjK35ZcGlHvOskNv3eQBc,4225
36
- iplotx-0.9.0.dist-info/METADATA,sha256=HQtb9YO4hhXGn0K8gaXBIAerAu1Eu0b4QiIHWCIZv2c,4908
37
- iplotx-0.9.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
38
- iplotx-0.9.0.dist-info/RECORD,,
40
+ iplotx-0.11.0.dist-info/METADATA,sha256=yTnevMcILo2NHdvx7EOniBU6zX4vD4ujJWdA3RR7hVU,4880
41
+ iplotx-0.11.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
42
+ iplotx-0.11.0.dist-info/RECORD,,