iplotx 0.0.1__py3-none-any.whl → 0.2.0__py3-none-any.whl

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