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.
@@ -1,5 +1,6 @@
1
1
  from functools import wraps, partial
2
2
  from math import atan2
3
+ import numpy as np
3
4
  import matplotlib as mpl
4
5
 
5
6
  from .geometry import (
@@ -29,6 +30,7 @@ def _forwarder(forwards, cls=None):
29
30
 
30
31
  def make_forward(name):
31
32
  def method(self, *args, **kwargs):
33
+ """Each decorated method is called on the decorated class and then, nonrecursively, on all children."""
32
34
  ret = getattr(cls.mro()[1], name)(self, *args, **kwargs)
33
35
  for c in self.get_children():
34
36
  getattr(c, name)(*args, **kwargs)
@@ -46,7 +48,13 @@ def _forwarder(forwards, cls=None):
46
48
 
47
49
 
48
50
  def _additional_set_methods(attributes, cls=None):
49
- """Decorator to add specific set methods for children properties."""
51
+ """Decorator to add specific set methods for children properties.
52
+
53
+ This is useful to autogenerate methods a la set_<key>(value), for
54
+ instance set_alpha(value). It works by delegating to set(alpha=value).
55
+
56
+ Overall, this is a minor tweak compared to the previous decorator.
57
+ """
50
58
  if cls is None:
51
59
  return partial(_additional_set_methods, attributes)
52
60
 
@@ -73,64 +81,76 @@ def _additional_set_methods(attributes, cls=None):
73
81
  # many use cases.
74
82
  def _get_label_width_height(text, hpadding=18, vpadding=12, **kwargs):
75
83
  """Get the bounding box size for a text with certain properties."""
76
- forbidden_props = ["horizontalalignment", "verticalalignment", "ha", "va"]
84
+ forbidden_props = [
85
+ "horizontalalignment",
86
+ "verticalalignment",
87
+ "ha",
88
+ "va",
89
+ "color",
90
+ "edgecolor",
91
+ "facecolor",
92
+ ]
77
93
  for prop in forbidden_props:
78
94
  if prop in kwargs:
79
95
  del kwargs[prop]
80
96
 
81
97
  path = mpl.textpath.TextPath((0, 0), text, **kwargs)
82
98
  boundingbox = path.get_extents()
83
- width = boundingbox.width + hpadding
84
- height = boundingbox.height + vpadding
99
+ width = boundingbox.width
100
+ height = boundingbox.height
101
+
102
+ # Scaling with font size appears broken... try to patch it up linearly here, even though we know it don't work well
103
+ width *= kwargs.get("size", 12) / 12.0
104
+ height *= kwargs.get("size", 12) / 12.0
105
+
106
+ width += hpadding
107
+ height += vpadding
85
108
  return (width, height)
86
109
 
87
110
 
88
- def _compute_mid_coord(path):
111
+ def _compute_mid_coord_and_rot(path, trans):
89
112
  """Compute mid point of an edge, straight or curved."""
90
113
  # Distinguish between straight and curved paths
91
114
  if path.codes[-1] == mpl.path.Path.LINETO:
92
- return path.vertices.mean(axis=0)
115
+ coord = path.vertices.mean(axis=0)
116
+ vtr = trans(path.vertices)
117
+ rot = atan2(
118
+ vtr[-1, 1] - vtr[0, 1],
119
+ vtr[-1, 0] - vtr[0, 0],
120
+ )
93
121
 
94
122
  # Cubic Bezier
95
- if path.codes[-1] == mpl.path.Path.CURVE4:
96
- return _evaluate_cubic_bezier(path.vertices, 0.5)
123
+ elif path.codes[-1] == mpl.path.Path.CURVE4:
124
+ coord = _evaluate_cubic_bezier(path.vertices, 0.5)
125
+ # TODO:
126
+ rot = 0
97
127
 
98
128
  # Square Bezier
99
- if path.codes[-1] == mpl.path.Path.CURVE3:
100
- return _evaluate_squared_bezier(path.vertices, 0.5)
101
-
102
- raise ValueError(
103
- "Curve type not straight and not squared/cubic Bezier, cannot compute mid point."
104
- )
129
+ elif path.codes[-1] == mpl.path.Path.CURVE3:
130
+ coord = _evaluate_squared_bezier(path.vertices, 0.5)
131
+ # TODO:
132
+ rot = 0
105
133
 
134
+ else:
135
+ raise ValueError(
136
+ "Curve type not straight and not squared/cubic Bezier, cannot compute mid point."
137
+ )
106
138
 
107
- def _compute_group_path_with_vertex_padding(
108
- points,
109
- transform,
110
- vertexpadding=10,
111
- ):
112
- """Offset path for a group based on vertex padding.
139
+ return coord, rot
113
140
 
114
- At the input, the structure is [v1, v1, v1, v2, v2, v2, ...]
115
- """
116
141
 
117
- # Transform into figure coordinates
118
- trans = transform.transform
119
- trans_inv = transform.inverted().transform
120
- points = trans(points)
142
+ def _build_cmap_fun(values, cmap):
143
+ """Map colormap on top of numerical values."""
144
+ cmap = mpl.cm._ensure_cmap(cmap)
121
145
 
122
- npoints = len(points) // 3
123
- vprev = points[-1]
124
- mprev = atan2(points[0, 1] - vprev[1], points[0, 0] - vprev[0])
125
- for i, vcur in enumerate(points[::3]):
126
- vnext = points[(i + 1) * 3]
127
- mnext = atan2(vnext[1] - vcur[1], vnext[0] - vcur[0])
146
+ if np.isscalar(values):
147
+ values = [values]
128
148
 
129
- mprev_orth = -1 / mprev
130
- points[i * 3] = vcur + vertexpadding * mprev_orth
149
+ if isinstance(values, dict):
150
+ values = np.array(list(values.values()))
131
151
 
132
- vprev = vcur
133
- mprev = mnext
152
+ vmin = np.nanmin(values)
153
+ vmax = np.nanmax(values)
154
+ norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
134
155
 
135
- points = trans_inv(points)
136
- return points
156
+ return lambda x: cmap(norm(x))
iplotx/utils/style.py ADDED
@@ -0,0 +1 @@
1
+ from copy import copy
iplotx/version.py CHANGED
@@ -1 +1,5 @@
1
- __version__ = "0.1.0"
1
+ """
2
+ iplotx version information module.
3
+ """
4
+
5
+ __version__ = "0.2.1"
iplotx/vertex.py CHANGED
@@ -1,51 +1,129 @@
1
+ """
2
+ Module containing code to manipulate vertex visualisations, especially the VertexCollection class.
3
+ """
4
+
5
+ from typing import (
6
+ Optional,
7
+ Sequence,
8
+ Any,
9
+ )
10
+ import warnings
1
11
  import numpy as np
2
- from matplotlib.transforms import IdentityTransform
12
+ import pandas as pd
13
+ import matplotlib as mpl
3
14
  from matplotlib.collections import PatchCollection
4
15
  from matplotlib.patches import (
16
+ Patch,
5
17
  Ellipse,
6
18
  Circle,
7
19
  RegularPolygon,
8
20
  Rectangle,
9
21
  )
10
22
 
23
+ from .style import (
24
+ get_style,
25
+ rotate_style,
26
+ copy_with_deep_values,
27
+ )
28
+ from .utils.matplotlib import (
29
+ _get_label_width_height,
30
+ _build_cmap_fun,
31
+ _forwarder,
32
+ )
33
+ from .label import LabelCollection
34
+
11
35
 
36
+ @_forwarder(
37
+ (
38
+ "set_clip_path",
39
+ "set_clip_box",
40
+ "set_snap",
41
+ "set_sketch_params",
42
+ "set_animated",
43
+ "set_picker",
44
+ )
45
+ )
12
46
  class VertexCollection(PatchCollection):
13
- """Collection of vertex patches for plotting.
47
+ """Collection of vertex patches for plotting."""
14
48
 
15
- This class takes additional keyword arguments compared to PatchCollection:
49
+ _factor = 1.0
16
50
 
17
- @param vertex_builder: A list of vertex builders to construct the visual
18
- vertices. This is updated if the size of the vertices is changed.
19
- @param size_callback: A function to be triggered after vertex sizes are
20
- changed. Typically this redraws the edges.
21
- """
51
+ def __init__(
52
+ self,
53
+ layout: pd.DataFrame,
54
+ *args,
55
+ layout_coordinate_system: str = "cartesian",
56
+ style: Optional[dict[str, Any]] = None,
57
+ labels: Optional[Sequence[str]] = None,
58
+ **kwargs,
59
+ ):
60
+ """Initialise the VertexCollection.
22
61
 
23
- def __init__(self, *args, **kwargs):
24
- super().__init__(*args, **kwargs)
62
+ Parameters:
63
+ layout: The vertex layout.
64
+ layout_coordinate_system: The coordinate system for the layout, usually "cartesian").
65
+ style: The vertex style (subdictionary "vertex") to apply.
66
+ labels: The vertex labels, if present.
67
+ """
25
68
 
26
- def get_sizes(self):
27
- """Same as get_size."""
28
- return self.get_size()
69
+ self._index = layout.index
70
+ self._style = style
71
+ self._labels = labels
72
+ self._layout = layout
73
+ self._layout_coordinate_system = layout_coordinate_system
29
74
 
30
- def get_size(self):
31
- """Get vertex sizes.
75
+ # Create patches from structured data
76
+ patches, sizes, kwargs2 = self._init_vertex_patches()
32
77
 
33
- If width and height are unequal, get the largest of the two.
78
+ kwargs.update(kwargs2)
79
+ kwargs["match_original"] = True
34
80
 
35
- @return: An array of vertex sizes.
81
+ # Pass to PatchCollection constructor
82
+ super().__init__(patches, *args, **kwargs)
83
+
84
+ # Set offsets in coordinate system
85
+ self._update_offsets_from_layout()
86
+
87
+ # Compute _transforms like in _CollectionWithScales for dpi issues
88
+ self.set_sizes(sizes)
89
+
90
+ if self._labels is not None:
91
+ self._compute_label_collection()
92
+
93
+ def get_children(self) -> tuple[mpl.artist.Artist]:
94
+ """Get the children artists.
95
+
96
+ This can include the labels as a LabelCollection.
36
97
  """
37
- import numpy as np
98
+ children = []
99
+ if hasattr(self, "_label_collection"):
100
+ children.append(self._label_collection)
101
+ return tuple(children)
38
102
 
39
- sizes = []
40
- for path in self.get_paths():
41
- bbox = path.get_extents()
42
- mins, maxs = bbox.min, bbox.max
43
- width, height = maxs - mins
44
- size = max(width, height)
45
- sizes.append(size)
46
- return np.array(sizes)
103
+ def set_figure(self, fig) -> None:
104
+ """Set the figure for this artist and all children."""
105
+ super().set_figure(fig)
106
+ self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
107
+ for child in self.get_children():
108
+ child.set_figure(fig)
47
109
 
48
- def set_size(self, sizes):
110
+ def get_index(self):
111
+ """Get the VertexCollection index."""
112
+ return self._index
113
+
114
+ def get_vertex_id(self, index):
115
+ """Get the id of a single vertex at a positional index."""
116
+ return self._index[index]
117
+
118
+ def get_sizes(self):
119
+ """Get vertex sizes (max of width and height), not scaled by dpi."""
120
+ return self._sizes
121
+
122
+ def get_sizes_dpi(self):
123
+ """Get vertex sizes (max of width and height), scaled by dpi."""
124
+ return self._transforms[:, 0, 0]
125
+
126
+ def set_sizes(self, sizes, dpi: float = 72.0) -> None:
49
127
  """Set vertex sizes.
50
128
 
51
129
  This rescales the current vertex symbol/path linearly, using this
@@ -53,26 +131,163 @@ class VertexCollection(PatchCollection):
53
131
 
54
132
  @param sizes: A sequence of vertex sizes or a single size.
55
133
  """
56
- paths = self._paths
57
- try:
58
- iter(sizes)
59
- except TypeError:
60
- sizes = [sizes] * len(paths)
61
-
62
- sizes = list(sizes)
63
- current_sizes = self.get_sizes()
64
- for path, cursize in zip(paths, current_sizes):
65
- # Circular use of sizes
66
- size = sizes.pop(0)
67
- sizes.append(size)
68
- # Rescale the path for this vertex
69
- path.vertices *= size / cursize
134
+ if sizes is None:
135
+ self._sizes = np.array([])
136
+ self._transforms = np.empty((0, 3, 3))
137
+ else:
138
+ self._sizes = np.asarray(sizes)
139
+ self._transforms = np.zeros((len(self._sizes), 3, 3))
140
+ scale = self._sizes * dpi / 72.0 * self._factor
141
+ self._transforms[:, 0, 0] = scale
142
+ self._transforms[:, 1, 1] = scale
143
+ self._transforms[:, 2, 2] = 1.0
144
+ self.stale = True
145
+
146
+ get_size = get_sizes
147
+ set_size = set_sizes
148
+
149
+ def get_layout(self) -> pd.DataFrame:
150
+ """Get the vertex layout.
151
+
152
+ Returns:
153
+ The vertex layout as a DataFrame.
154
+ """
155
+ return self._layout
156
+
157
+ def get_layout_coordinate_system(self) -> str:
158
+ """Get the layout coordinate system.
159
+
160
+ Returns:
161
+ Name of the layout coordinate system, e.g. "cartesian" or "polar".
162
+ """
163
+ return self._layout_coordinate_system
164
+
165
+ def get_offsets(self, ignore_layout: bool = True) -> np.ndarray:
166
+ """Get the vertex offsets.
70
167
 
168
+ Parameters:
169
+ ignore_layout: If True, return the matplotlib Artist._offsets directly, ignoring the
170
+ layout coordinate system. If False, it's equivalent to get_layout().values.
171
+
172
+ Returns:
173
+ The vertex offsets as a 2D numpy array.
174
+
175
+ Note: It is best for users to *not* ignore the layout coordinate system, as it may lead
176
+ to inconsistencies. However, some internal matplotlib functions require the default
177
+ signature of this function to look at the vanilla offsets, hence the default parameters.
178
+ """
179
+ if not ignore_layout:
180
+ return self.get_layout().values
181
+ else:
182
+ return self._offsets
183
+
184
+ def _update_offsets_from_layout(self) -> None:
185
+ """Update offsets in matplotlib coordinates from the layout DataFrame."""
186
+ if self._layout_coordinate_system == "cartesian":
187
+ self._offsets = self._layout.values
188
+ elif self._layout_coordinate_system == "polar":
189
+ # Convert polar coordinates (r, theta) to cartesian (x, y)
190
+ r = self._layout.iloc[:, 0].values
191
+ theta = self._layout.iloc[:, 1].values
192
+ if self._offsets is None:
193
+ self._offsets = np.zeros((len(r), 2))
194
+ self._offsets[:, 0] = r * np.cos(theta)
195
+ self._offsets[:, 1] = r * np.sin(theta)
196
+ else:
197
+ raise ValueError(
198
+ f"Layout coordinate system not supported: {self._layout_coordinate_system}."
199
+ )
200
+
201
+ def set_offsets(self, offsets: np.ndarray) -> None:
202
+ """Set the vertex positions/offsets in layout coordinates.
203
+
204
+ Parameters:
205
+ offsets: Array of coordinates in the layout coordinate system. For polar layouts,
206
+ these should be in the form of (r, theta) pairs.
207
+ """
208
+ self._layout.values[:] = offsets
209
+ self._update_offsets_from_layout()
71
210
  self.stale = True
72
211
 
73
- def set_sizes(self, sizes):
74
- """Same as set_size."""
75
- self.set_size(sizes)
212
+ def _init_vertex_patches(self):
213
+ style = self._style or {}
214
+ if "cmap" in style:
215
+ cmap_fun = _build_cmap_fun(
216
+ style["facecolor"],
217
+ style["cmap"],
218
+ )
219
+ else:
220
+ cmap_fun = None
221
+
222
+ if style.get("size", 20) == "label":
223
+ if self._labels is None:
224
+ warnings.warn(
225
+ "No labels found, cannot resize vertices based on labels."
226
+ )
227
+ style["size"] = get_style("default.vertex")["size"]
228
+
229
+ if "cmap" in style:
230
+ colorarray = []
231
+ patches = []
232
+ sizes = []
233
+ for i, (vid, row) in enumerate(self._layout.iterrows()):
234
+ if style.get("size", 20) == "label":
235
+ # NOTE: it's ok to overwrite the dict here
236
+ style["size"] = _get_label_width_height(
237
+ str(self._labels[vid]), **style.get("label", {})
238
+ )
239
+
240
+ stylei = rotate_style(style, index=i, key=vid)
241
+ if cmap_fun is not None:
242
+ colorarray.append(style["facecolor"])
243
+ stylei["facecolor"] = cmap_fun(stylei["facecolor"])
244
+
245
+ # Shape of the vertex (Patch)
246
+ art, size = make_patch(**stylei)
247
+ patches.append(art)
248
+ sizes.append(size)
249
+
250
+ kwargs = {}
251
+ if "cmap" in style:
252
+ vmin = np.min(colorarray)
253
+ vmax = np.max(colorarray)
254
+ norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
255
+ kwargs["cmap"] = style["cmap"]
256
+ kwargs["norm"] = norm
257
+
258
+ return patches, sizes, kwargs
259
+
260
+ def _compute_label_collection(self):
261
+ transform = self.get_offset_transform()
262
+
263
+ style = (
264
+ copy_with_deep_values(self._style.get("label", None))
265
+ if self._style is not None
266
+ else {}
267
+ )
268
+ forbidden_props = ["hpadding", "vpadding"]
269
+ for prop in forbidden_props:
270
+ if prop in style:
271
+ del style[prop]
272
+
273
+ self._label_collection = LabelCollection(
274
+ self._labels,
275
+ style=style,
276
+ offsets=self._offsets,
277
+ transform=transform,
278
+ )
279
+
280
+ def get_labels(self):
281
+ """Get the vertex labels.
282
+
283
+ Returns:
284
+ The artist with the LabelCollection.
285
+ """
286
+
287
+ if hasattr(self, "_label_collection"):
288
+ return self._label_collection
289
+ else:
290
+ return None
76
291
 
77
292
  @property
78
293
  def stale(self):
@@ -84,29 +299,64 @@ class VertexCollection(PatchCollection):
84
299
  if val and hasattr(self, "stale_callback_post"):
85
300
  self.stale_callback_post(self)
86
301
 
302
+ @mpl.artist.allow_rasterization
303
+ def draw(self, renderer):
304
+ if not self.get_visible():
305
+ return
306
+
307
+ # null graph, no need to draw anything
308
+ # NOTE: I would expect this to be already a clause in the superclass by oh well
309
+ if len(self.get_paths()) == 0:
310
+ return
311
+
312
+ self.set_sizes(self._sizes, self.get_figure(root=True).dpi)
313
+
314
+ # NOTE: This draws the vertices first, then the labels.
315
+ # The correct order would be vertex1->label1->vertex2->label2, etc.
316
+ # We might fix if we manage to find a way to do it.
317
+ super().draw(renderer)
318
+ for child in self.get_children():
319
+ child.draw(renderer)
87
320
 
88
- def make_patch(marker: str, size, **kwargs):
321
+
322
+ def make_patch(
323
+ marker: str, size: float | Sequence[float], **kwargs
324
+ ) -> tuple[Patch, float]:
89
325
  """Make a patch of the given marker shape and size."""
90
- forbidden_props = ["label"]
326
+ forbidden_props = ["label", "cmap", "norm"]
91
327
  for prop in forbidden_props:
92
328
  if prop in kwargs:
93
329
  kwargs.pop(prop)
94
330
 
95
- if isinstance(size, (int, float)):
331
+ if np.isscalar(size):
332
+ size = float(size)
96
333
  size = (size, size)
97
334
 
98
- if marker in ("o", "circle"):
99
- return Circle((0, 0), size[0] / 2, **kwargs)
335
+ # Size of vertices is determined in self._transforms, which scales with dpi, rather than here,
336
+ # so normalise by the average dimension (btw x and y) to keep the ratio of the marker.
337
+ # If you check in get_sizes, you will see that rescaling also happens with the max of width
338
+ # and height.
339
+ size = np.asarray(size, dtype=float)
340
+ size_max = size.max()
341
+ if size_max > 0:
342
+ size /= size_max
343
+
344
+ art: Patch
345
+ if marker in ("o", "c", "circle"):
346
+ art = Circle((0, 0), size[0] / 2, **kwargs)
100
347
  elif marker in ("s", "square", "r", "rectangle"):
101
- return Rectangle((-size[0] / 2, -size[1] / 2), size[0], size[1], **kwargs)
348
+ art = Rectangle((-size[0] / 2, -size[1] / 2), size[0], size[1], **kwargs)
102
349
  elif marker in ("^", "triangle"):
103
- return RegularPolygon((0, 0), numVertices=3, radius=size[0] / 2, **kwargs)
350
+ art = RegularPolygon((0, 0), numVertices=3, radius=size[0] / 2, **kwargs)
104
351
  elif marker in ("d", "diamond"):
105
- return make_patch("s", size[0], angle=45, **kwargs)
352
+ art, _ = make_patch("s", size[0], angle=45, **kwargs)
106
353
  elif marker in ("v", "triangle_down"):
107
- return RegularPolygon(
354
+ art = RegularPolygon(
108
355
  (0, 0), numVertices=3, radius=size[0] / 2, orientation=np.pi, **kwargs
109
356
  )
110
357
  elif marker in ("e", "ellipse"):
111
- return Ellipse((0, 0), size[0] / 2, size[1] / 2, **kwargs)
112
- raise KeyError(f"Unknown marker: {marker}")
358
+ art = Ellipse((0, 0), size[0] / 2, size[1] / 2, **kwargs)
359
+ else:
360
+ raise KeyError(f"Unknown marker: {marker}")
361
+
362
+ return (art, size_max)
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: iplotx
3
+ Version: 0.2.1
4
+ Summary: Plot networkx from igraph and networkx.
5
+ Project-URL: Homepage, https://github.com/fabilab/iplotx
6
+ Project-URL: Documentation, https://readthedocs.org/iplotx
7
+ Project-URL: Repository, https://github.com/fabilab/iplotx.git
8
+ Project-URL: Bug Tracker, https://github.com/fabilab/iplotx/issues
9
+ Project-URL: Changelog, https://github.com/fabilab/iplotx/blob/main/CHANGELOG.md
10
+ Author-email: Fabio Zanini <fabio.zanini@unsw.edu.au>
11
+ Maintainer-email: Fabio Zanini <fabio.zanini@unsw.edu.au>
12
+ License: MIT
13
+ Keywords: graph,network,plotting,visualisation
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Education
17
+ Classifier: Intended Audience :: Science/Research
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Natural Language :: English
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Scientific/Engineering :: Visualization
26
+ Classifier: Topic :: System :: Networking
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.11
29
+ Requires-Dist: matplotlib>=2.0.0
30
+ Requires-Dist: numpy>=2.0.0
31
+ Requires-Dist: pandas>=2.0.0
32
+ Requires-Dist: pylint>=3.3.7
33
+ Provides-Extra: igraph
34
+ Requires-Dist: igraph>=0.11.0; extra == 'igraph'
35
+ Provides-Extra: networkx
36
+ Requires-Dist: networkx>=2.0.0; extra == 'networkx'
37
+ Description-Content-Type: text/markdown
38
+
39
+ ![Github Actions](https://github.com/fabilab/iplotx/actions/workflows/test.yml/badge.svg)
40
+ ![PyPI - Version](https://img.shields.io/pypi/v/iplotx)
41
+ ![RTD](https://readthedocs.org/projects/iplotx/badge/?version=latest)
42
+ ![pylint](assets/pylint.svg)
43
+
44
+ # iplotx
45
+ Plotting networks from igraph and networkx.
46
+
47
+ **NOTE**: This is currently beta quality software. The API and functionality are settling in and might break occasionally.
48
+
49
+ ## Installation
50
+ ```bash
51
+ pip install iplotx
52
+ ```
53
+
54
+ ## Quick Start
55
+ ```python
56
+ import networkx as nx
57
+ import matplotlib.pyplot as plt
58
+ import iplotx as ipx
59
+
60
+ g = nx.Graph([(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)])
61
+ layout = nx.layout.circular_layout(g)
62
+ fig, ax = plt.subplots(figsize=(3, 3))
63
+ ipx.plot(g, ax=ax, layout=layout)
64
+ ```
65
+
66
+ ![Quick start image](docs/source/_static/graph_basic.png)
67
+
68
+ ## Documentation
69
+ See [readthedocs](https://iplotx.readthedocs.io/en/latest/) for the full documentation.
70
+
71
+ ## Gallery
72
+ See [gallery](https://iplotx.readthedocs.io/en/latest/gallery/index.html).
73
+
74
+ ## Roadmap
75
+ - Plot networks from igraph and networkx interchangeably, using matplotlib as a backend. ✅
76
+ - Support interactive plotting, e.g. zooming and panning after the plot is created. ✅
77
+ - Support storing the plot to disk thanks to the many matplotlib backends (SVG, PNG, PDF, etc.). ✅
78
+ - Support flexible yet easy styling. ✅
79
+ - Efficient plotting of large graphs using matplotlib's collection functionality. ✅
80
+ - Support editing plotting elements after the plot is created, e.g. changing node colors, labels, etc. ✅
81
+ - Support animations, e.g. showing the evolution of a network over time. ✅
82
+ - Support trees from special libraries such as ete3, biopython, etc. This will need a dedicated function and layouting. ✅
83
+ - Support uni- and bi-directional communication between graph object and plot object.🏗️
84
+
85
+ **NOTE:** The last item can probably be achieved already by using `matplotlib`'s existing callback functionality. It is currently untested, but if you manage to get it to work on your graph let me know and I'll add it to the examples (with credit).
86
+
87
+ ## Authors
88
+ Fabio Zanini (https://fabilab.org)