iplotx 0.3.0__py3-none-any.whl → 0.4.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/__init__.py +5 -0
- iplotx/artists.py +24 -0
- iplotx/cascades.py +3 -3
- iplotx/edge/__init__.py +48 -43
- iplotx/edge/arrow.py +34 -0
- iplotx/edge/geometry.py +23 -4
- iplotx/edge/leaf.py +117 -0
- iplotx/ingest/__init__.py +1 -6
- iplotx/ingest/heuristics.py +3 -32
- iplotx/ingest/providers/network/igraph.py +16 -6
- iplotx/ingest/providers/network/networkx.py +14 -4
- iplotx/ingest/providers/network/simple.py +121 -0
- iplotx/ingest/providers/tree/biopython.py +13 -0
- iplotx/ingest/providers/tree/cogent3.py +7 -0
- iplotx/ingest/typing.py +110 -11
- iplotx/label.py +42 -12
- iplotx/layout.py +5 -1
- iplotx/network.py +66 -12
- iplotx/plotting.py +8 -8
- iplotx/{style.py → style/__init__.py} +23 -85
- iplotx/style/leaf_info.py +41 -0
- iplotx/style/library.py +230 -0
- iplotx/tree.py +244 -29
- iplotx/utils/matplotlib.py +30 -12
- iplotx/utils/style.py +12 -1
- iplotx/version.py +1 -1
- iplotx/vertex.py +62 -7
- {iplotx-0.3.0.dist-info → iplotx-0.4.0.dist-info}/METADATA +1 -1
- iplotx-0.4.0.dist-info/RECORD +37 -0
- iplotx-0.3.0.dist-info/RECORD +0 -32
- {iplotx-0.3.0.dist-info → iplotx-0.4.0.dist-info}/WHEEL +0 -0
iplotx/tree.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from typing import (
|
|
2
2
|
Optional,
|
|
3
3
|
Sequence,
|
|
4
|
+
Any,
|
|
4
5
|
)
|
|
5
6
|
from collections.abc import Hashable
|
|
6
7
|
from collections import defaultdict
|
|
@@ -30,6 +31,9 @@ from .edge import (
|
|
|
30
31
|
EdgeCollection,
|
|
31
32
|
make_stub_patch as make_undirected_edge_patch,
|
|
32
33
|
)
|
|
34
|
+
from .edge.leaf import (
|
|
35
|
+
LeafEdgeCollection,
|
|
36
|
+
)
|
|
33
37
|
from .label import (
|
|
34
38
|
LabelCollection,
|
|
35
39
|
)
|
|
@@ -58,7 +62,6 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
58
62
|
self,
|
|
59
63
|
tree,
|
|
60
64
|
layout: Optional[str] = "horizontal",
|
|
61
|
-
orientation: Optional[str] = None,
|
|
62
65
|
directed: bool | str = False,
|
|
63
66
|
vertex_labels: Optional[
|
|
64
67
|
bool | list[str] | dict[Hashable, str] | pd.Series
|
|
@@ -67,15 +70,13 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
67
70
|
leaf_labels: Optional[Sequence | dict[Hashable, str]] | pd.Series = None,
|
|
68
71
|
transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
|
|
69
72
|
offset_transform: Optional[mpl.transforms.Transform] = None,
|
|
73
|
+
show_support: bool = False,
|
|
70
74
|
):
|
|
71
75
|
"""Initialize the TreeArtist.
|
|
72
76
|
|
|
73
77
|
Parameters:
|
|
74
78
|
tree: The tree to plot.
|
|
75
79
|
layout: The layout to use for the tree. Can be "horizontal", "vertical", or "radial".
|
|
76
|
-
orientation: The orientation of the tree layout. Can be "right" or "left" (for
|
|
77
|
-
horizontal and radial layouts) and "descending" or "ascending" (for vertical
|
|
78
|
-
layouts).
|
|
79
80
|
directed: Whether the tree is directed. Can be a boolean or a string with the
|
|
80
81
|
following choices: "parent" or "child".
|
|
81
82
|
vertex_labels: Labels for the vertices. Can be a list, dictionary, or pandas Series.
|
|
@@ -88,13 +89,14 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
88
89
|
transform: The transform to apply to the tree artist. This is usually the identity.
|
|
89
90
|
offset_transform: The offset transform to apply to the tree artist. This is
|
|
90
91
|
usually `ax.transData`.
|
|
92
|
+
show_support: Whether to show support values on the nodes. If both show_support and
|
|
93
|
+
vertex_labels are set, this parameters takes precedence.
|
|
91
94
|
"""
|
|
92
95
|
|
|
93
96
|
self.tree = tree
|
|
94
97
|
self._ipx_internal_data = ingest_tree_data(
|
|
95
98
|
tree,
|
|
96
99
|
layout,
|
|
97
|
-
orientation=orientation,
|
|
98
100
|
directed=directed,
|
|
99
101
|
layout_style=get_style(".layout", {}),
|
|
100
102
|
vertex_labels=vertex_labels,
|
|
@@ -102,6 +104,10 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
102
104
|
leaf_labels=leaf_labels,
|
|
103
105
|
)
|
|
104
106
|
|
|
107
|
+
if show_support:
|
|
108
|
+
support = self._ipx_internal_data["vertex_df"]["support"]
|
|
109
|
+
self._ipx_internal_data["vertex_df"]["label"] = support
|
|
110
|
+
|
|
105
111
|
super().__init__()
|
|
106
112
|
|
|
107
113
|
# This is usually the identity (which scales poorly with dpi)
|
|
@@ -116,10 +122,11 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
116
122
|
self._add_vertices()
|
|
117
123
|
self._add_edges()
|
|
118
124
|
self._add_leaf_vertices()
|
|
125
|
+
self._add_leaf_edges()
|
|
119
126
|
|
|
120
127
|
# NOTE: cascades need to be created after leaf vertices in case
|
|
121
128
|
# they are requested to wrap around them.
|
|
122
|
-
if "cascade"
|
|
129
|
+
if get_style(".cascade") != {}:
|
|
123
130
|
self._add_cascades()
|
|
124
131
|
|
|
125
132
|
def get_children(self) -> tuple[mpl.artist.Artist]:
|
|
@@ -131,6 +138,8 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
131
138
|
children = [self._vertices, self._edges]
|
|
132
139
|
if hasattr(self, "_leaf_vertices"):
|
|
133
140
|
children.append(self._leaf_vertices)
|
|
141
|
+
if hasattr(self, "_leaf_edges"):
|
|
142
|
+
children.append(self._leaf_edges)
|
|
134
143
|
if hasattr(self, "_cascades"):
|
|
135
144
|
children.append(self._cascades)
|
|
136
145
|
return tuple(children)
|
|
@@ -141,12 +150,24 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
141
150
|
Parameters:
|
|
142
151
|
fig: the figure to set for this artist and its children.
|
|
143
152
|
"""
|
|
144
|
-
super().set_figure(fig)
|
|
145
|
-
for child in self.get_children():
|
|
146
|
-
child.set_figure(fig)
|
|
147
|
-
|
|
148
153
|
# At the end, if there are cadcades with extent depending on
|
|
149
154
|
# leaf edges, we should update them
|
|
155
|
+
super().set_figure(fig)
|
|
156
|
+
|
|
157
|
+
# The next two are vanilla NetworkArtist
|
|
158
|
+
self._vertices.set_figure(fig)
|
|
159
|
+
self._edges.set_figure(fig)
|
|
160
|
+
|
|
161
|
+
# For trees, there are a few more elements to coordinate,
|
|
162
|
+
# including possibly text at the fringes (leaf labels)
|
|
163
|
+
# which might require a redraw (without rendering) to compute
|
|
164
|
+
# its actual scren real estate.
|
|
165
|
+
if hasattr(self, "_leaf_vertices"):
|
|
166
|
+
self._leaf_vertices.set_figure(fig)
|
|
167
|
+
if hasattr(self, "_leaf_edges"):
|
|
168
|
+
self._leaf_edges.set_figure(fig)
|
|
169
|
+
if hasattr(self, "_cascades"):
|
|
170
|
+
self._cascades.set_figure(fig)
|
|
150
171
|
self._update_cascades_extent()
|
|
151
172
|
|
|
152
173
|
def _update_cascades_extent(self) -> None:
|
|
@@ -154,7 +175,7 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
154
175
|
if not hasattr(self, "_cascades"):
|
|
155
176
|
return
|
|
156
177
|
|
|
157
|
-
style_cascade =
|
|
178
|
+
style_cascade = get_style(".cascade")
|
|
158
179
|
extend_to_labels = style_cascade.get("extend", False) == "leaf_labels"
|
|
159
180
|
if not extend_to_labels:
|
|
160
181
|
return
|
|
@@ -213,13 +234,16 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
213
234
|
edge_bbox = self._edges.get_datalim(transData)
|
|
214
235
|
bbox = mpl.transforms.Bbox.union([bbox, edge_bbox])
|
|
215
236
|
|
|
237
|
+
if hasattr(self, "_leaf_vertices"):
|
|
238
|
+
leaf_labels_bbox = self._leaf_vertices.get_datalim(transData)
|
|
239
|
+
bbox = mpl.transforms.Bbox.union([bbox, leaf_labels_bbox])
|
|
240
|
+
|
|
216
241
|
if hasattr(self, "_cascades"):
|
|
217
242
|
cascades_bbox = self._cascades.get_datalim(transData)
|
|
218
243
|
bbox = mpl.transforms.Bbox.union([bbox, cascades_bbox])
|
|
219
244
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
bbox = mpl.transforms.Bbox.union([bbox, leaf_labels_bbox])
|
|
245
|
+
# NOTE: We do not need to check leaf edges for bbox, because they are
|
|
246
|
+
# guaranteed within the convex hull of leaf vertices.
|
|
223
247
|
|
|
224
248
|
bbox = bbox.expanded(sw=(1.0 + pad), sh=(1.0 + pad))
|
|
225
249
|
return bbox
|
|
@@ -244,6 +268,12 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
244
268
|
return self._leaf_vertices
|
|
245
269
|
return None
|
|
246
270
|
|
|
271
|
+
def get_leaf_edges(self) -> Optional[LeafEdgeCollection]:
|
|
272
|
+
"""Get LeafEdgeCollection artist if present."""
|
|
273
|
+
if hasattr(self, "_leaf_edges"):
|
|
274
|
+
return self._leaf_edges
|
|
275
|
+
return None
|
|
276
|
+
|
|
247
277
|
def get_vertex_labels(self) -> LabelCollection:
|
|
248
278
|
"""Get list of vertex label artists."""
|
|
249
279
|
return self._vertices.get_labels()
|
|
@@ -253,10 +283,17 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
253
283
|
return self._edges.get_labels()
|
|
254
284
|
|
|
255
285
|
def get_leaf_labels(self) -> Optional[LabelCollection]:
|
|
286
|
+
"""Get the leaf label artist if present."""
|
|
256
287
|
if hasattr(self, "_leaf_vertices"):
|
|
257
288
|
return self._leaf_vertices.get_labels()
|
|
258
289
|
return None
|
|
259
290
|
|
|
291
|
+
def get_leaf_edge_labels(self) -> Optional[LabelCollection]:
|
|
292
|
+
"""Get the leaf edge label artist if present."""
|
|
293
|
+
if hasattr(self, "_leaf_edges"):
|
|
294
|
+
return self._leaf_edges.get_labels()
|
|
295
|
+
return None
|
|
296
|
+
|
|
260
297
|
def _add_vertices(self) -> None:
|
|
261
298
|
"""Add vertices to the tree."""
|
|
262
299
|
self._vertices = VertexCollection(
|
|
@@ -271,36 +308,153 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
271
308
|
offset_transform=self.get_offset_transform(),
|
|
272
309
|
)
|
|
273
310
|
|
|
311
|
+
def _add_leaf_edges(self) -> None:
|
|
312
|
+
"""Add edges from the leaf to the max leaf depth."""
|
|
313
|
+
# If there are no leaves, no leaf labels, or leaves are not deep,
|
|
314
|
+
# skip leaf edges
|
|
315
|
+
if not hasattr(self, "_leaf_vertices"):
|
|
316
|
+
return
|
|
317
|
+
leaf_style = get_style(".leaf", {})
|
|
318
|
+
if ("deep" not in leaf_style) and self.get_leaf_labels() is None:
|
|
319
|
+
return
|
|
320
|
+
if not leaf_style.get("deep", True):
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
edge_style = get_style(
|
|
324
|
+
".leafedge",
|
|
325
|
+
)
|
|
326
|
+
default_style = {
|
|
327
|
+
"linestyle": "--",
|
|
328
|
+
"linewidth": 1,
|
|
329
|
+
"edgecolor": "#111",
|
|
330
|
+
}
|
|
331
|
+
for key, value in default_style.items():
|
|
332
|
+
if key not in edge_style:
|
|
333
|
+
edge_style[key] = value
|
|
334
|
+
|
|
335
|
+
labels = None
|
|
336
|
+
# TODO: implement leaf edge labels
|
|
337
|
+
# self._get_label_series("leafedge")
|
|
338
|
+
|
|
339
|
+
if "cmap" in edge_style:
|
|
340
|
+
cmap_fun = _build_cmap_fun(
|
|
341
|
+
edge_style["color"],
|
|
342
|
+
edge_style["cmap"],
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
cmap_fun = None
|
|
346
|
+
|
|
347
|
+
leaf_shallow_layout = self.get_layout("leaf")
|
|
348
|
+
|
|
349
|
+
if "cmap" in edge_style:
|
|
350
|
+
colorarray = []
|
|
351
|
+
edgepatches = []
|
|
352
|
+
adjacent_vertex_ids = []
|
|
353
|
+
for i, vid in enumerate(leaf_shallow_layout.index):
|
|
354
|
+
edge_stylei = rotate_style(edge_style, index=i, key=vid)
|
|
355
|
+
|
|
356
|
+
if cmap_fun is not None:
|
|
357
|
+
colorarray.append(edge_stylei["color"])
|
|
358
|
+
edge_stylei["color"] = cmap_fun(edge_stylei["color"])
|
|
359
|
+
|
|
360
|
+
# These are not the actual edges drawn, only stubs to establish
|
|
361
|
+
# the styles which are then fed into the dynamic, optimised
|
|
362
|
+
# factory (the collection) below
|
|
363
|
+
patch = make_undirected_edge_patch(
|
|
364
|
+
**edge_stylei,
|
|
365
|
+
)
|
|
366
|
+
edgepatches.append(patch)
|
|
367
|
+
adjacent_vertex_ids.append(vid)
|
|
368
|
+
|
|
369
|
+
if "cmap" in edge_style:
|
|
370
|
+
vmin = np.min(colorarray)
|
|
371
|
+
vmax = np.max(colorarray)
|
|
372
|
+
norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
|
|
373
|
+
edge_style["norm"] = norm
|
|
374
|
+
|
|
375
|
+
self._leaf_edges = LeafEdgeCollection(
|
|
376
|
+
edgepatches,
|
|
377
|
+
vertex_leaf_ids=adjacent_vertex_ids,
|
|
378
|
+
vertex_collection=self._vertices,
|
|
379
|
+
leaf_collection=self._leaf_vertices,
|
|
380
|
+
labels=labels,
|
|
381
|
+
transform=self.get_offset_transform(),
|
|
382
|
+
style=edge_style,
|
|
383
|
+
directed=False,
|
|
384
|
+
)
|
|
385
|
+
if "cmap" in edge_style:
|
|
386
|
+
self._leaf_edges.set_array(colorarray)
|
|
387
|
+
|
|
274
388
|
def _add_leaf_vertices(self) -> None:
|
|
275
389
|
"""Add invisible deep vertices as leaf label anchors."""
|
|
390
|
+
layout_name = self._ipx_internal_data["layout_name"]
|
|
391
|
+
orientation = self._ipx_internal_data["orientation"]
|
|
392
|
+
user_leaf_style = get_style(".leaf", {})
|
|
393
|
+
|
|
276
394
|
leaf_layout = self.get_layout("leaf").copy()
|
|
395
|
+
|
|
277
396
|
# Set all to max depth
|
|
278
|
-
|
|
279
|
-
|
|
397
|
+
if user_leaf_style.get("deep", True):
|
|
398
|
+
if layout_name == "radial":
|
|
399
|
+
leaf_layout.iloc[:, 0] = leaf_layout.iloc[:, 0].max()
|
|
400
|
+
elif layout_name == "horizontal":
|
|
401
|
+
if orientation == "right":
|
|
402
|
+
leaf_layout.iloc[:, 0] = leaf_layout.iloc[:, 0].max()
|
|
403
|
+
else:
|
|
404
|
+
leaf_layout.iloc[:, 0] = leaf_layout.iloc[:, 0].min()
|
|
405
|
+
elif layout_name == "vertical":
|
|
406
|
+
if orientation == "descending":
|
|
407
|
+
leaf_layout.iloc[:, 1] = leaf_layout.iloc[:, 1].min()
|
|
408
|
+
else:
|
|
409
|
+
leaf_layout.iloc[:, 1] = leaf_layout.iloc[:, 1].max()
|
|
410
|
+
else:
|
|
411
|
+
raise ValueError(
|
|
412
|
+
f"Layout and orientation not supported: {layout_name}, {orientation}."
|
|
413
|
+
)
|
|
280
414
|
|
|
281
415
|
# Set invisible vertices with visible labels
|
|
282
|
-
layout_name = self._ipx_internal_data["layout_name"]
|
|
283
|
-
orientation = self._ipx_internal_data["orientation"]
|
|
284
416
|
if layout_name == "radial":
|
|
285
417
|
ha = "auto"
|
|
286
|
-
|
|
418
|
+
rotation = 0
|
|
419
|
+
elif orientation == "right":
|
|
420
|
+
ha = "left"
|
|
421
|
+
rotation = 0
|
|
422
|
+
elif orientation == "left":
|
|
287
423
|
ha = "right"
|
|
424
|
+
rotation = 0
|
|
425
|
+
elif orientation == "ascending":
|
|
426
|
+
ha = "left"
|
|
427
|
+
rotation = 90
|
|
288
428
|
else:
|
|
289
429
|
ha = "left"
|
|
430
|
+
rotation = -90
|
|
290
431
|
|
|
291
|
-
|
|
432
|
+
default_leaf_style = {
|
|
292
433
|
"size": 0,
|
|
293
434
|
"label": {
|
|
294
|
-
"verticalalignment": "
|
|
435
|
+
"verticalalignment": "center_baseline",
|
|
295
436
|
"horizontalalignment": ha,
|
|
437
|
+
"rotation": rotation,
|
|
296
438
|
"hmargin": 5,
|
|
439
|
+
"vmargin": 0,
|
|
297
440
|
"bbox": {
|
|
298
441
|
"facecolor": (1, 1, 1, 0),
|
|
299
442
|
},
|
|
300
443
|
},
|
|
301
444
|
}
|
|
302
|
-
with context({"vertex":
|
|
445
|
+
with context([{"vertex": default_leaf_style}, {"vertex": user_leaf_style}]):
|
|
303
446
|
leaf_vertex_style = get_style(".vertex")
|
|
447
|
+
# Left horizontal layout has no rotation of the labels but we need to
|
|
448
|
+
# reverse hmargin
|
|
449
|
+
if (
|
|
450
|
+
layout_name == "horizontal"
|
|
451
|
+
and orientation == "left"
|
|
452
|
+
and "label" in leaf_vertex_style
|
|
453
|
+
and "hmargin" in leaf_vertex_style["label"]
|
|
454
|
+
):
|
|
455
|
+
# Reverse the horizontal margin
|
|
456
|
+
leaf_vertex_style["label"]["hmargin"] *= -1
|
|
457
|
+
|
|
304
458
|
self._leaf_vertices = VertexCollection(
|
|
305
459
|
layout=leaf_layout,
|
|
306
460
|
layout_coordinate_system=self._ipx_internal_data.get(
|
|
@@ -318,7 +472,7 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
318
472
|
# NOTE: If leaf labels are present and the cascades are requested to wrap around them,
|
|
319
473
|
# we have to compute the max extend of the cascades from the leaf labels.
|
|
320
474
|
maxdepth = None
|
|
321
|
-
style_cascade =
|
|
475
|
+
style_cascade = get_style(".cascade")
|
|
322
476
|
extend_to_labels = style_cascade.get("extend", False) == "leaf_labels"
|
|
323
477
|
has_leaf_labels = self.get_leaf_labels() is not None
|
|
324
478
|
if extend_to_labels and not has_leaf_labels:
|
|
@@ -342,7 +496,6 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
342
496
|
layout_name = self.get_layout_name()
|
|
343
497
|
if layout_name == "radial":
|
|
344
498
|
maxdepth = 0
|
|
345
|
-
# These are the text boxes, they must all be included
|
|
346
499
|
bboxes = self.get_leaf_labels().get_datalims_children(
|
|
347
500
|
self.get_offset_transform()
|
|
348
501
|
)
|
|
@@ -447,7 +600,10 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
447
600
|
norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
|
|
448
601
|
edge_style["norm"] = norm
|
|
449
602
|
|
|
450
|
-
|
|
603
|
+
if get_style(".layout", {}).get("angular", False):
|
|
604
|
+
edge_style.pop("waypoints", None)
|
|
605
|
+
else:
|
|
606
|
+
edge_style["waypoints"] = waypoints
|
|
451
607
|
|
|
452
608
|
# NOTE: Trees are directed is their "directed" property is True, "child", or "parent"
|
|
453
609
|
self._edges = EdgeCollection(
|
|
@@ -470,24 +626,83 @@ class TreeArtist(mpl.artist.Artist):
|
|
|
470
626
|
"""Get the orientation of the tree layout."""
|
|
471
627
|
return self._ipx_internal_data.get("orientation", None)
|
|
472
628
|
|
|
629
|
+
def style_subtree(
|
|
630
|
+
self,
|
|
631
|
+
nodes: Sequence[Hashable],
|
|
632
|
+
style: Optional[dict[str, Any] | Sequence[str | dict[str, Any]]] = None,
|
|
633
|
+
) -> None:
|
|
634
|
+
"""Style a subtree of the tree.
|
|
635
|
+
|
|
636
|
+
Parameters:
|
|
637
|
+
nodes: Sequence of nodes that span the subtree. All elements below including
|
|
638
|
+
the most recent common ancestor of these leaves will be styled.
|
|
639
|
+
style: Style or sequence of styles to apply to the subtree. Each style can
|
|
640
|
+
be either a string, referring to an internal `iplotx` style, or a dictionary
|
|
641
|
+
with custom styling elements.
|
|
642
|
+
"""
|
|
643
|
+
provider = data_providers["tree"][self._ipx_internal_data["tree_library"]]
|
|
644
|
+
|
|
645
|
+
# Get last (deepest) common ancestor of the requested nodes
|
|
646
|
+
root = provider(self.tree).get_lca(nodes)
|
|
647
|
+
|
|
648
|
+
# Populate a DataFrame with the array of properties to update
|
|
649
|
+
vertex_idx = {
|
|
650
|
+
node: i for i, node in enumerate(self._ipx_internal_data["vertex_df"].index)
|
|
651
|
+
}
|
|
652
|
+
edge_idx = {
|
|
653
|
+
node: i
|
|
654
|
+
for i, node in enumerate(
|
|
655
|
+
self._ipx_internal_data["edge_df"]["_ipx_target"].values
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
vertex_props = {}
|
|
659
|
+
edge_props = {}
|
|
660
|
+
vertex_style = style.get("vertex", {})
|
|
661
|
+
edge_style = style.get("edge", {})
|
|
662
|
+
for inode, node in enumerate(provider(root).preorder()):
|
|
663
|
+
for attr, value in vertex_style.items():
|
|
664
|
+
if attr not in vertex_props:
|
|
665
|
+
vertex_props[attr] = list(getattr(self._vertices, f"get_{attr}")())
|
|
666
|
+
vertex_props[attr][vertex_idx[node]] = value
|
|
667
|
+
|
|
668
|
+
# Ignore branch coming into the root node
|
|
669
|
+
if inode == 0:
|
|
670
|
+
continue
|
|
671
|
+
|
|
672
|
+
for attr, value in edge_style.items():
|
|
673
|
+
# Edge color is actually edgecolor
|
|
674
|
+
if attr == "color":
|
|
675
|
+
attr = "edgecolor"
|
|
676
|
+
if attr not in edge_props:
|
|
677
|
+
edge_props[attr] = list(getattr(self._edges, f"get_{attr}")())
|
|
678
|
+
edge_props[attr][edge_idx[node]] = value
|
|
679
|
+
|
|
680
|
+
# Update the properties from the DataFrames
|
|
681
|
+
for attr in vertex_props:
|
|
682
|
+
getattr(self._vertices, f"set_{attr}")(vertex_props[attr])
|
|
683
|
+
for attr in edge_props:
|
|
684
|
+
getattr(self._edges, f"set_{attr}")(edge_props[attr])
|
|
685
|
+
|
|
473
686
|
@_stale_wrapper
|
|
474
687
|
def draw(self, renderer) -> None:
|
|
475
688
|
"""Draw each of the children, with some buffering mechanism."""
|
|
476
689
|
if not self.get_visible():
|
|
477
690
|
return
|
|
478
691
|
|
|
479
|
-
# At the end, if there are cadcades with extent depending on
|
|
480
|
-
# leaf edges, we should update them
|
|
481
|
-
self._update_cascades_extent()
|
|
482
|
-
|
|
483
692
|
# NOTE: looks like we have to manage the zorder ourselves
|
|
484
693
|
# this is kind of funny actually. Btw we need to ensure
|
|
485
694
|
# that cascades are drawn behind (earlier than) vertices
|
|
486
695
|
# and edges at equal zorder because it looks better that way.
|
|
487
696
|
z_suborder = defaultdict(int)
|
|
697
|
+
if hasattr(self, "_leaf_vertices"):
|
|
698
|
+
z_suborder[self._leaf_vertices] = -2
|
|
699
|
+
if hasattr(self, "_leaf_edges"):
|
|
700
|
+
z_suborder[self._leaf_edges] = -2
|
|
488
701
|
if hasattr(self, "_cascades"):
|
|
489
702
|
z_suborder[self._cascades] = -1
|
|
490
703
|
children = list(self.get_children())
|
|
491
704
|
children.sort(key=lambda x: (x.zorder, z_suborder[x]))
|
|
492
705
|
for art in children:
|
|
706
|
+
if isinstance(art, CascadeCollection):
|
|
707
|
+
self._update_cascades_extent()
|
|
493
708
|
art.draw(renderer)
|
iplotx/utils/matplotlib.py
CHANGED
|
@@ -74,13 +74,24 @@ def _additional_set_methods(attributes, cls=None):
|
|
|
74
74
|
return cls
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
def _get_label_width_height(text, hpadding=18, vpadding=12, dpi=72.0, **kwargs):
|
|
78
|
+
"""Get the bounding box size for a text with certain properties.
|
|
79
|
+
|
|
80
|
+
Parameters:
|
|
81
|
+
text: The text to measure.
|
|
82
|
+
hpadding: Horizontal padding to add to the width.
|
|
83
|
+
vpadding: Vertical padding to add to the height.
|
|
84
|
+
**kwargs: Additional keyword arguments for text properties. "fontsize" is accepted,
|
|
85
|
+
as is "size". Many other properties are not used and will raise and exception.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
A tuple (width, height) representing the size of the text bounding box. Because
|
|
89
|
+
some text properties such as weight are not taken into account, ths function is not
|
|
90
|
+
very accurate. Yet, it is often good enough and easier to implement than a careful
|
|
91
|
+
orchestration of Figure.draw_without_rendering.
|
|
92
|
+
"""
|
|
93
|
+
if "fontsize" in kwargs:
|
|
94
|
+
kwargs["size"] = kwargs.pop("fontsize")
|
|
84
95
|
forbidden_props = [
|
|
85
96
|
"horizontalalignment",
|
|
86
97
|
"verticalalignment",
|
|
@@ -99,12 +110,18 @@ def _get_label_width_height(text, hpadding=18, vpadding=12, **kwargs):
|
|
|
99
110
|
width = boundingbox.width
|
|
100
111
|
height = boundingbox.height
|
|
101
112
|
|
|
102
|
-
# Scaling with font size appears broken... try to patch it up linearly here, even though we
|
|
113
|
+
# Scaling with font size appears broken... try to patch it up linearly here, even though we
|
|
114
|
+
# know it does not work terribly accurately
|
|
103
115
|
width *= kwargs.get("size", 12) / 12.0
|
|
104
116
|
height *= kwargs.get("size", 12) / 12.0
|
|
105
117
|
|
|
106
118
|
width += hpadding
|
|
107
119
|
height += vpadding
|
|
120
|
+
|
|
121
|
+
# Scale by dpi
|
|
122
|
+
width *= dpi / 72.0
|
|
123
|
+
height *= dpi / 72.0
|
|
124
|
+
|
|
108
125
|
return (width, height)
|
|
109
126
|
|
|
110
127
|
|
|
@@ -139,7 +156,7 @@ def _compute_mid_coord_and_rot(path, trans):
|
|
|
139
156
|
return coord, rot
|
|
140
157
|
|
|
141
158
|
|
|
142
|
-
def _build_cmap_fun(values, cmap):
|
|
159
|
+
def _build_cmap_fun(values, cmap, norm=None):
|
|
143
160
|
"""Map colormap on top of numerical values."""
|
|
144
161
|
cmap = mpl.cm._ensure_cmap(cmap)
|
|
145
162
|
|
|
@@ -149,8 +166,9 @@ def _build_cmap_fun(values, cmap):
|
|
|
149
166
|
if isinstance(values, dict):
|
|
150
167
|
values = np.array(list(values.values()))
|
|
151
168
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
169
|
+
if norm is None:
|
|
170
|
+
vmin = np.nanmin(values)
|
|
171
|
+
vmax = np.nanmax(values)
|
|
172
|
+
norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
|
|
155
173
|
|
|
156
174
|
return lambda x: cmap(norm(x))
|
iplotx/utils/style.py
CHANGED
|
@@ -1 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
import copy
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def copy_with_deep_values(style):
|
|
5
|
+
"""Make a deep copy of the style dict but do not create copies of the keys."""
|
|
6
|
+
newdict = {}
|
|
7
|
+
for key, value in style.items():
|
|
8
|
+
if isinstance(value, dict):
|
|
9
|
+
newdict[key] = copy_with_deep_values(value)
|
|
10
|
+
else:
|
|
11
|
+
newdict[key] = copy.copy(value)
|
|
12
|
+
return newdict
|
iplotx/version.py
CHANGED
iplotx/vertex.py
CHANGED
|
@@ -14,6 +14,7 @@ import matplotlib as mpl
|
|
|
14
14
|
from matplotlib.collections import PatchCollection
|
|
15
15
|
from matplotlib.patches import (
|
|
16
16
|
Patch,
|
|
17
|
+
Polygon,
|
|
17
18
|
Ellipse,
|
|
18
19
|
Circle,
|
|
19
20
|
RegularPolygon,
|
|
@@ -355,7 +356,7 @@ def make_patch(
|
|
|
355
356
|
**kwargs,
|
|
356
357
|
) -> tuple[Patch, float]:
|
|
357
358
|
"""Make a patch of the given marker shape and size."""
|
|
358
|
-
forbidden_props = ["label", "cmap", "norm", "cascade"]
|
|
359
|
+
forbidden_props = ["label", "cmap", "norm", "cascade", "deep"]
|
|
359
360
|
for prop in forbidden_props:
|
|
360
361
|
if prop in kwargs:
|
|
361
362
|
kwargs.pop(prop)
|
|
@@ -363,12 +364,12 @@ def make_patch(
|
|
|
363
364
|
if np.isscalar(size):
|
|
364
365
|
size = float(size)
|
|
365
366
|
size = (size, size)
|
|
367
|
+
size = np.asarray(size, dtype=float)
|
|
366
368
|
|
|
367
369
|
# Size of vertices is determined in self._transforms, which scales with dpi, rather than here,
|
|
368
370
|
# so normalise by the average dimension (btw x and y) to keep the ratio of the marker.
|
|
369
371
|
# If you check in get_sizes, you will see that rescaling also happens with the max of width
|
|
370
372
|
# and height.
|
|
371
|
-
size = np.asarray(size, dtype=float)
|
|
372
373
|
size_max = size.max()
|
|
373
374
|
if size_max > 0:
|
|
374
375
|
size /= size_max
|
|
@@ -379,15 +380,69 @@ def make_patch(
|
|
|
379
380
|
elif marker in ("s", "square", "r", "rectangle"):
|
|
380
381
|
art = Rectangle((-size[0] / 2, -size[1] / 2), size[0], size[1], **kwargs)
|
|
381
382
|
elif marker in ("^", "triangle"):
|
|
382
|
-
art = RegularPolygon(
|
|
383
|
-
|
|
384
|
-
|
|
383
|
+
art = RegularPolygon(
|
|
384
|
+
(0, 0), numVertices=3, radius=size[0] / np.sqrt(2), **kwargs
|
|
385
|
+
)
|
|
385
386
|
elif marker in ("v", "triangle_down"):
|
|
386
387
|
art = RegularPolygon(
|
|
387
|
-
(0, 0),
|
|
388
|
+
(0, 0),
|
|
389
|
+
numVertices=3,
|
|
390
|
+
radius=size[0] / np.sqrt(2),
|
|
391
|
+
orientation=np.pi,
|
|
392
|
+
**kwargs,
|
|
393
|
+
)
|
|
394
|
+
elif marker in ("<", "triangle_left"):
|
|
395
|
+
art = RegularPolygon(
|
|
396
|
+
(0, 0),
|
|
397
|
+
numVertices=3,
|
|
398
|
+
radius=size[0] / np.sqrt(2),
|
|
399
|
+
orientation=np.pi / 2,
|
|
400
|
+
**kwargs,
|
|
401
|
+
)
|
|
402
|
+
elif marker in (">", "triangle_right"):
|
|
403
|
+
art = RegularPolygon(
|
|
404
|
+
(0, 0),
|
|
405
|
+
numVertices=3,
|
|
406
|
+
radius=size[0] / np.sqrt(2),
|
|
407
|
+
orientation=-np.pi / 2,
|
|
408
|
+
**kwargs,
|
|
409
|
+
)
|
|
410
|
+
elif marker in ("d", "diamond"):
|
|
411
|
+
art = RegularPolygon(
|
|
412
|
+
(0, 0), numVertices=4, radius=size[0] / np.sqrt(2), **kwargs
|
|
413
|
+
)
|
|
414
|
+
elif marker in ("p", "pentagon"):
|
|
415
|
+
art = RegularPolygon(
|
|
416
|
+
(0, 0), numVertices=5, radius=size[0] / np.sqrt(2), **kwargs
|
|
417
|
+
)
|
|
418
|
+
elif marker in ("h", "hexagon"):
|
|
419
|
+
art = RegularPolygon(
|
|
420
|
+
(0, 0), numVertices=6, radius=size[0] / np.sqrt(2), **kwargs
|
|
421
|
+
)
|
|
422
|
+
elif marker in ("8", "octagon"):
|
|
423
|
+
art = RegularPolygon(
|
|
424
|
+
(0, 0), numVertices=8, radius=size[0] / np.sqrt(2), **kwargs
|
|
388
425
|
)
|
|
389
426
|
elif marker in ("e", "ellipse"):
|
|
390
|
-
art = Ellipse((0, 0), size[0]
|
|
427
|
+
art = Ellipse((0, 0), size[0], size[1], **kwargs)
|
|
428
|
+
elif marker in ("*", "star"):
|
|
429
|
+
size *= np.sqrt(2)
|
|
430
|
+
art = Polygon(
|
|
431
|
+
[
|
|
432
|
+
(0, size[1] / 2),
|
|
433
|
+
(size[0] / 7, size[1] / 7),
|
|
434
|
+
(size[0] / 2, size[1] / 7),
|
|
435
|
+
(size[0] / 4, -size[1] / 8),
|
|
436
|
+
(size[0] / 3, -size[1] / 2),
|
|
437
|
+
(0, -0.27 * size[1]),
|
|
438
|
+
(-size[0] / 3, -size[1] / 2),
|
|
439
|
+
(-size[0] / 4, -size[1] / 8),
|
|
440
|
+
(-size[0] / 2, size[1] / 7),
|
|
441
|
+
(-size[0] / 7, size[1] / 7),
|
|
442
|
+
(0, size[1] / 2),
|
|
443
|
+
][::-1],
|
|
444
|
+
**kwargs,
|
|
445
|
+
)
|
|
391
446
|
else:
|
|
392
447
|
raise KeyError(f"Unknown marker: {marker}")
|
|
393
448
|
|