iplotx 0.1.0__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
iplotx/plotting.py CHANGED
@@ -1,4 +1,6 @@
1
- from typing import Union, Sequence
1
+ from typing import Optional, Sequence
2
+ from contextlib import nullcontext
3
+ import numpy as np
2
4
  import pandas as pd
3
5
  import matplotlib as mpl
4
6
  import matplotlib.pyplot as plt
@@ -7,78 +9,176 @@ from .typing import (
7
9
  GraphType,
8
10
  LayoutType,
9
11
  GroupingType,
12
+ TreeType,
10
13
  )
11
14
  from .network import NetworkArtist
12
15
  from .groups import GroupingArtist
13
- from .styles import stylecontext
14
-
15
-
16
- def plot(
17
- network: Union[GraphType, None] = None,
18
- layout: Union[LayoutType, None] = None,
19
- grouping: Union[None, GroupingType] = None,
20
- vertex_labels: Union[None, list, dict, pd.Series] = None,
21
- edge_labels: Union[None, Sequence] = None,
22
- ax: Union[None, object] = None,
23
- style: Sequence[Union[str, dict]] = (),
24
- ):
25
- """Plot this network using the specified layout.
16
+ from .tree import TreeArtist
17
+ from .style import context
18
+
19
+
20
+ def network(
21
+ network: Optional[GraphType] = None,
22
+ layout: Optional[LayoutType] = None,
23
+ grouping: Optional[GroupingType] = None,
24
+ vertex_labels: Optional[list | dict | pd.Series] = None,
25
+ edge_labels: Optional[Sequence] = None,
26
+ ax: Optional[mpl.axes.Axes] = None,
27
+ style: str | dict | Sequence[str | dict] = (),
28
+ title: Optional[str] = None,
29
+ aspect: Optional[str | float] = None,
30
+ margins: float | tuple[float, float] = 0,
31
+ **kwargs,
32
+ ) -> list[mpl.artist.Artist]:
33
+ """Plot this network and/or vertex grouping using the specified layout.
26
34
 
27
35
  Parameters:
28
- network (GraphType): The network to plot. Can be a networkx or igraph graph.
29
- layout (Union[LayoutType, None], optional): The layout to use for plotting. If None, a layout will be looked for in the network object and, if none is found, an exception is raised. Defaults to None.
30
- vertex_labels (list, dict, or pandas.Series): The labels for the vertices. If None, no vertex labels
36
+ network: The network to plot. Can be a networkx or igraph graph.
37
+ layout: The layout to use for plotting. If None, a layout will be looked for in the
38
+ network object and, if none is found, an exception is raised. Defaults to None.
39
+ vertex_labels: The labels for the vertices. If None or False, no vertex labels
31
40
  will be drawn. If a list, the labels are taken from the list. If a dict, the keys
32
- should be the vertex IDs and the values should be the labels.
33
- edge_labels (Union[None, Sequence], optional): The labels for the edges. If None, no edge labels will be drawn. Defaults to None.
34
- ax (Union[None, object], optional): The axis to plot on. If None, a new figure and axis will be created. Defaults to None.
35
- style: Apply this style for the objects to plot. This can be a sequence (e.g. list) of styles and they will be applied in order.
41
+ should be the vertex IDs and the values should be the labels. If True (a single
42
+ bool value), the vertex IDs will be used as labels.
43
+ edge_labels: The labels for the edges. If None, no edge labels will be drawn. Defaults
44
+ to None.
45
+ ax: The axis to plot on. If None, a new figure and axis will be created. Defaults to
46
+ None.
47
+ style: Apply this style for the objects to plot. This can be a sequence (e.g. list)
48
+ of styles and they will be applied in order.
49
+ title: If not None, set the axes title to this value.
50
+ aspect: If not None, set the aspect ratio of the axis to this value. The most common
51
+ value is 1.0, which proportionates x- and y-axes.
52
+ margins: How much margin to leave around the plot. A higher value (e.g. 0.1) can be
53
+ used as a quick fix when some vertex shapes reach beyond the plot edge. This is
54
+ a fraction of the data limits, so 0.1 means 10% of the data limits will be left
55
+ as margin.
56
+ **kwargs: Additional arguments are treated as an alternate way to specify style. If
57
+ both "style" and additional **kwargs are provided, they are both applied in that
58
+ order (style, then **kwargs).
36
59
 
37
60
  Returns:
38
- A NetworkArtist object.
61
+ A list of mpl.artist.Artist objects, set as a direct child of the matplotlib Axes.
62
+ The list can have one or two elements, depending on whether you are requesting to
63
+ plot a network, a grouping, or both.
39
64
  """
40
- if len(style) or isinstance(style, dict):
41
- with stylecontext(style):
42
- return plot(
43
- network=network,
44
- layout=layout,
45
- grouping=grouping,
65
+ stylecontext = context(style, **kwargs) if style or kwargs else nullcontext()
66
+
67
+ with stylecontext:
68
+ if (network is None) and (grouping is None):
69
+ raise ValueError("At least one of network or grouping must be provided.")
70
+
71
+ if ax is None:
72
+ fig, ax = plt.subplots()
73
+
74
+ artists = []
75
+ if network is not None:
76
+ nwkart = NetworkArtist(
77
+ network,
78
+ layout,
79
+ vertex_labels=vertex_labels,
46
80
  edge_labels=edge_labels,
47
- ax=ax,
81
+ transform=mpl.transforms.IdentityTransform(),
82
+ offset_transform=ax.transData,
83
+ )
84
+ ax.add_artist(nwkart)
85
+
86
+ # Set the figure, which itself sets the dpi scale for vertices, edges,
87
+ # arrows, etc. Now data limits can be computed correctly
88
+ nwkart.set_figure(ax.figure)
89
+
90
+ artists.append(nwkart)
91
+
92
+ # Set normailsed layout since we have it by now
93
+ layout = nwkart.get_layout()
94
+
95
+ if grouping is not None:
96
+ grpart = GroupingArtist(
97
+ grouping,
98
+ layout,
99
+ network=network,
100
+ transform=ax.transData,
48
101
  )
102
+ ax.add_artist(grpart)
103
+
104
+ grpart.set_figure(ax.figure)
105
+ artists.append(grpart)
106
+
107
+ if title is not None:
108
+ ax.set_title(title)
49
109
 
50
- if (network is None) and (grouping is None):
51
- raise ValueError("At least one of network or grouping must be provided.")
110
+ if aspect is not None:
111
+ ax.set_aspect(aspect)
52
112
 
53
- if ax is None:
54
- fig, ax = plt.subplots()
113
+ _postprocess_axis(ax, artists)
55
114
 
56
- artists = []
57
- if network is not None:
58
- nwkart = NetworkArtist(
59
- network,
60
- layout,
115
+ if np.isscalar(margins):
116
+ margins = (margins, margins)
117
+ if (margins[0] != 0) or (margins[1] != 0):
118
+ ax.margins(*margins)
119
+
120
+ return artists
121
+
122
+
123
+ def tree(
124
+ tree: Optional[TreeType] = None,
125
+ layout: str | LayoutType = "horizontal",
126
+ orientation: str = "right",
127
+ directed: bool | str = False,
128
+ vertex_labels: Optional[list | dict | pd.Series] = None,
129
+ ax: Optional[mpl.axes.Axes] = None,
130
+ style: str | dict | Sequence[str | dict] = "tree",
131
+ title: Optional[str] = None,
132
+ aspect: Optional[str | float] = None,
133
+ margins: float | tuple[float, float] = 0,
134
+ **kwargs,
135
+ ) -> TreeArtist:
136
+ """Plot a tree using the specified layout.
137
+
138
+ Parameters:
139
+ tree: The tree to plot. Can be a BioPython.Phylo.Tree object.
140
+ layout: The layout to use for plotting.
141
+ orientation: The orientation of the horizontal layout. Can be "right" or "left". Defaults to
142
+ "right".
143
+ directed: If False, donot draw arrows. If True or "child", draw arrows from parent to child
144
+ node. If "parent", draw arrows the other way around.
145
+
146
+ Returns:
147
+ A TreeArtist object, set as a direct child of the matplotlib Axes.
148
+ """
149
+ stylecontext = context(style, **kwargs) if style or kwargs else nullcontext()
150
+
151
+ with stylecontext:
152
+ if ax is None:
153
+ fig, ax = plt.subplots()
154
+
155
+ artist = TreeArtist(
156
+ tree=tree,
157
+ layout=layout,
158
+ orientation=orientation,
159
+ directed=directed,
160
+ transform=mpl.transforms.IdentityTransform(),
161
+ offset_transform=ax.transData,
61
162
  vertex_labels=vertex_labels,
62
- edge_labels=edge_labels,
63
- )
64
- ax.add_artist(nwkart)
65
- # Postprocess for things that require an axis (transform, etc.)
66
- nwkart._process()
67
- artists.append(nwkart)
68
-
69
- if grouping is not None:
70
- grpart = GroupingArtist(
71
- grouping,
72
- layout,
73
163
  )
74
- ax.add_artist(grpart)
75
- # Postprocess for things that require an axis (transform, etc.)
76
- grpart._process()
77
- artists.append(grpart)
164
+ ax.add_artist(artist)
165
+
166
+ artist.set_figure(ax.figure)
167
+
168
+ if title is not None:
169
+ ax.set_title(title)
170
+
171
+ if aspect is not None:
172
+ ax.set_aspect(aspect)
173
+
174
+ _postprocess_axis(ax, [artist])
78
175
 
79
- _postprocess_axis(ax, artists)
176
+ if np.isscalar(margins):
177
+ margins = (margins, margins)
178
+ if (margins[0] != 0) or (margins[1] != 0):
179
+ ax.margins(*margins)
80
180
 
81
- return artists
181
+ return artist
82
182
 
83
183
 
84
184
  # INTERNAL ROUTINES
@@ -99,7 +199,8 @@ def _postprocess_axis(ax, artists):
99
199
  bboxes = []
100
200
  for art in artists:
101
201
  bboxes.append(art.get_datalim(ax.transData))
102
- ax.update_datalim(mpl.transforms.Bbox.union(bboxes))
202
+ bbox = mpl.transforms.Bbox.union(bboxes)
203
+ ax.update_datalim(bbox)
103
204
 
104
205
  # Autoscale for x/y axis limits
105
206
  ax.autoscale_view()
iplotx/style.py ADDED
@@ -0,0 +1,391 @@
1
+ from typing import (
2
+ Any,
3
+ Optional,
4
+ Sequence,
5
+ )
6
+ from collections.abc import Hashable
7
+ import copy
8
+ from contextlib import contextmanager
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+
13
+ style_leaves = (
14
+ "cmap",
15
+ "color",
16
+ "size",
17
+ "edgecolor",
18
+ "facecolor",
19
+ "linewidth",
20
+ "linestyle",
21
+ "alpha",
22
+ "zorder",
23
+ "tension",
24
+ "looptension",
25
+ "loopmaxangle",
26
+ "rotate",
27
+ "marker",
28
+ "waypoints",
29
+ "horizontalalignment",
30
+ "verticalalignment",
31
+ "boxstyle",
32
+ "hpadding",
33
+ "vpadding",
34
+ "hmargin",
35
+ "vmargin",
36
+ "ports",
37
+ )
38
+
39
+ # These properties are not allowed to be rotated (global throughout the graph).
40
+ # This might change in the future as the API improves.
41
+ nonrotating_leaves = (
42
+ "offset",
43
+ "looptension",
44
+ "loopmaxangle",
45
+ "vertexpadding",
46
+ )
47
+
48
+
49
+ default = {
50
+ "vertex": {
51
+ "size": 20,
52
+ "facecolor": "black",
53
+ "marker": "o",
54
+ "label": {
55
+ "color": "white",
56
+ "horizontalalignment": "center",
57
+ "verticalalignment": "center",
58
+ "hpadding": 18,
59
+ "vpadding": 12,
60
+ },
61
+ },
62
+ "edge": {
63
+ "linewidth": 1.5,
64
+ "linestyle": "-",
65
+ "color": "black",
66
+ "curved": False,
67
+ "offset": 3,
68
+ "tension": 1,
69
+ "looptension": 4,
70
+ "loopmaxangle": 60,
71
+ "label": {
72
+ "horizontalalignment": "center",
73
+ "verticalalignment": "center",
74
+ "rotate": False,
75
+ "bbox": {
76
+ "boxstyle": "round",
77
+ "facecolor": "white",
78
+ "edgecolor": "none",
79
+ },
80
+ },
81
+ "arrow": {
82
+ "marker": "|>",
83
+ "width": 8,
84
+ },
85
+ },
86
+ "grouping": {
87
+ "facecolor": ["grey", "steelblue", "tomato"],
88
+ "edgecolor": "black",
89
+ "linewidth": 1.5,
90
+ "alpha": 0.5,
91
+ "vertexpadding": 18,
92
+ },
93
+ }
94
+
95
+
96
+ def copy_with_deep_values(style):
97
+ """Make a deep copy of the style dict but do not create copies of the keys."""
98
+ newdict = {}
99
+ for key, value in style.items():
100
+ if isinstance(value, dict):
101
+ newdict[key] = copy_with_deep_values(value)
102
+ else:
103
+ newdict[key] = copy.copy(value)
104
+ return newdict
105
+
106
+
107
+ hollow = copy_with_deep_values(default)
108
+ hollow["vertex"]["color"] = None
109
+ hollow["vertex"]["facecolor"] = "none"
110
+ hollow["vertex"]["edgecolor"] = "black"
111
+ hollow["vertex"]["linewidth"] = 1.5
112
+ hollow["vertex"]["marker"] = "r"
113
+ hollow["vertex"]["size"] = "label"
114
+
115
+ tree = copy_with_deep_values(default)
116
+ tree["vertex"]["size"] = 0
117
+ tree["vertex"]["alpha"] = 0
118
+ tree["edge"]["linewidth"] = 2.5
119
+ tree["vertex"]["label"]["bbox"] = {
120
+ "boxstyle": "square,pad=0.5",
121
+ "facecolor": "white",
122
+ "edgecolor": "none",
123
+ }
124
+ tree["vertex"]["label"]["color"] = "black"
125
+ tree["vertex"]["label"]["size"] = 12
126
+ tree["vertex"]["label"]["horizontalalignment"] = "left"
127
+ tree["vertex"]["label"]["hmargin"] = 5
128
+
129
+
130
+ styles = {
131
+ "default": default,
132
+ "hollow": hollow,
133
+ "tree": tree,
134
+ }
135
+
136
+
137
+ stylename = "default"
138
+
139
+
140
+ current = copy_with_deep_values(styles["default"])
141
+
142
+
143
+ def get_stylename():
144
+ """Return the name of the current iplotx style."""
145
+ return str(stylename)
146
+
147
+
148
+ def get_style(name: str = "") -> dict[str, Any]:
149
+ """Get a *deep copy* of the chosen style.
150
+
151
+ Parameters:
152
+ name: The name of the style to get. If empty, the current style is returned.
153
+ Substyles can be obtained by using a dot notation, e.g. "default.vertex".
154
+ If "name" starts with a dot, it means a substyle of the current style.
155
+ Returns:
156
+ The requected style or substyle.
157
+
158
+ NOTE: The deep copy is a little different from standard deep copies. Here, keys
159
+ (which need to be hashables) are never copied, but values are. This can be
160
+ useful for hashables that change hash upon copying, such as Biopython's
161
+ tree nodes.
162
+ """
163
+ namelist = name.split(".")
164
+ style = styles
165
+ for i, namei in enumerate(namelist):
166
+ if (i == 0) and (namei == ""):
167
+ style = current
168
+ else:
169
+ if namei in style:
170
+ style = style[namei]
171
+ # NOTE: if asking for a nonexistent, non-leaf style
172
+ # give the benefit of the doubt and set an empty dict
173
+ # which will not fail unless the uder tries to enter it
174
+ elif namei not in style_leaves:
175
+ style = {}
176
+ else:
177
+ raise KeyError(f"Style not found: {name}")
178
+
179
+ style = copy_with_deep_values(style)
180
+ return style
181
+
182
+
183
+ # The following is inspired by matplotlib's style library
184
+ # https://github.com/matplotlib/matplotlib/blob/v3.10.3/lib/matplotlib/style/core.py#L45
185
+ def use(style: Optional[str | dict | Sequence] = None, **kwargs):
186
+ """Use iplotx style setting for a style specification.
187
+
188
+ The style name of 'default' is reserved for reverting back to
189
+ the default style settings.
190
+
191
+ Parameters:
192
+ style: A style specification, currently either a name of an existing style
193
+ or a dict with specific parts of the style to override. The string
194
+ "default" resets the style to the default one. If this is a sequence,
195
+ each style is applied in order.
196
+ **kwargs: Additional style changes to be applied at the end of any style.
197
+ """
198
+ try:
199
+ import networkx as nx
200
+ except ImportError:
201
+ nx = None
202
+
203
+ global current
204
+
205
+ def _sanitize_leaves(style: dict):
206
+ for key, value in style.items():
207
+ if key in style_leaves:
208
+ if nx is not None:
209
+ if isinstance(value, nx.classes.reportviews.NodeView):
210
+ style[key] = dict(value)
211
+ elif isinstance(value, nx.classes.reportviews.EdgeViewABC):
212
+ style[key] = [v for *e, v in value]
213
+ elif isinstance(value, dict):
214
+ _sanitize_leaves(value)
215
+
216
+ def _update(style: dict, current: dict):
217
+ for key, value in style.items():
218
+ if key not in current:
219
+ current[key] = value
220
+ continue
221
+
222
+ # Style leaves are by definition not to be recurred into
223
+ if isinstance(value, dict) and (key not in style_leaves):
224
+ _update(value, current[key])
225
+ elif value is None:
226
+ del current[key]
227
+ else:
228
+ current[key] = value
229
+
230
+ old_style = copy_with_deep_values(current)
231
+
232
+ try:
233
+ if style is None:
234
+ styles = []
235
+ elif isinstance(style, (dict, str)):
236
+ styles = [style]
237
+ else:
238
+ styles = list(style)
239
+
240
+ if kwargs:
241
+ styles.append(kwargs)
242
+
243
+ for style in styles:
244
+ if style == "default":
245
+ reset()
246
+ else:
247
+ if isinstance(style, str):
248
+ current = get_style(style)
249
+ else:
250
+ _sanitize_leaves(style)
251
+ unflatten_style(style)
252
+ _update(style, current)
253
+ except:
254
+ current = old_style
255
+ raise
256
+
257
+
258
+ def reset() -> None:
259
+ """Reset to default style."""
260
+ global current
261
+ current = copy_with_deep_values(styles["default"])
262
+
263
+
264
+ @contextmanager
265
+ def context(style: Optional[str | dict | Sequence] = None, **kwargs):
266
+ """Create a style context for iplotx.
267
+
268
+ Parameters:
269
+ style: A single style specification or a list of style specifications, which are then
270
+ applied in order. Each style can be a string (for an existing style) or a dictionary
271
+ with the elements that are to change.
272
+ **kwargs: Additional style changes to be applied at the end of all styles.
273
+
274
+ Yields:
275
+ A context manager that applies the style and reverts it back to the previous one upon exit.
276
+ """
277
+ current = get_style()
278
+ try:
279
+ use(style, **kwargs)
280
+ yield
281
+ finally:
282
+ use(["default", current])
283
+
284
+
285
+ def unflatten_style(
286
+ style_flat: dict[str, str | dict | int | float],
287
+ ) -> None:
288
+ """Convert a flat or semi-flat style into a fully structured dict.
289
+
290
+ Parameters:
291
+ style_flat: A flat dictionary where keys may contain underscores, which are taken to signify
292
+ subdictionaries.
293
+
294
+ NOTE: The dict is changed *in place*.
295
+
296
+ Example:
297
+ >>> style = {'vertex_size': 20}
298
+ >>> unflatten_style(style)
299
+ >>> print(style)
300
+ {'vertex': {'size': 20}}
301
+ """
302
+
303
+ def _inner(style_flat: dict):
304
+ keys = list(style_flat.keys())
305
+
306
+ for key in keys:
307
+ if "_" not in key:
308
+ continue
309
+
310
+ keyhead, keytail = key.split("_", 1)
311
+ value = style_flat.pop(key)
312
+ if keyhead not in style_flat:
313
+ style_flat[keyhead] = {
314
+ keytail: value,
315
+ }
316
+ else:
317
+ style_flat[keyhead][keytail] = value
318
+
319
+ for key, value in style_flat.items():
320
+ if isinstance(value, dict) and (key not in style_leaves):
321
+ _inner(value)
322
+
323
+ # top-level adjustments
324
+ if "zorder" in style_flat:
325
+ style_flat["network_zorder"] = style_flat["grouping_zorder"] = style_flat.pop(
326
+ "zorder"
327
+ )
328
+
329
+ # Begin recursion
330
+ _inner(style_flat)
331
+
332
+
333
+ def rotate_style(
334
+ style: dict[str, Any],
335
+ index: Optional[int] = None,
336
+ key: Optional[Hashable] = None,
337
+ props: Optional[Sequence[str]] = None,
338
+ ) -> dict[str, Any]:
339
+ """Rotate leaves of a style for a certain index or key.
340
+
341
+ Parameters:
342
+ style: The style to rotate.
343
+ index: The integer to rotate the style leaves into.
344
+ key: For dict-like leaves (e.g. vertex properties specified as a dict-like object over the
345
+ vertices themselves), the key to use for rotation (e.g. the vertex itself).
346
+ props: The properties to rotate, usually all leaf properties.
347
+
348
+ Returns:
349
+ A style with rotated leaves, which describes the properties of a single element (e.g.
350
+ vertex).
351
+
352
+ Example:
353
+ >>> style = {'vertex': {'size': [10, 20]}}
354
+ >>> rotate_style(style, index=0)
355
+ {'vertex': {'size': 10}}
356
+ """
357
+ if (index is None) and (key is None):
358
+ raise ValueError(
359
+ "At least one of 'index' or 'key' must be provided to rotate_style."
360
+ )
361
+
362
+ if props is None:
363
+ props = tuple(prop for prop in style_leaves if prop not in nonrotating_leaves)
364
+
365
+ style = copy_with_deep_values(style)
366
+
367
+ for prop in props:
368
+ val = style.get(prop, None)
369
+ if val is None:
370
+ continue
371
+ # Try integer indexing for ordered types
372
+ if (index is not None) and isinstance(
373
+ val, (tuple, list, np.ndarray, pd.Index, pd.Series)
374
+ ):
375
+ style[prop] = np.asarray(val)[index % len(val)]
376
+ # Try key indexing for unordered, dict-like types
377
+ if (
378
+ (key is not None)
379
+ and (not isinstance(val, (str, tuple, list, np.ndarray)))
380
+ and hasattr(val, "__getitem__")
381
+ ):
382
+ # If only a subset of keys is provided, default the other ones
383
+ # to the empty type constructor (e.g. 0 for ints, 0.0 for floats,
384
+ # empty strings).
385
+ if key in val:
386
+ style[prop] = val[key]
387
+ else:
388
+ valtype = type(next(iter(val.values())))
389
+ style[prop] = valtype()
390
+
391
+ return style