iplotx 0.3.1__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/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" in self.get_vertices().get_style():
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 = self.get_vertices().get_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
- if hasattr(self, "_leaf_vertices"):
221
- leaf_labels_bbox = self._leaf_vertices.get_datalim(transData)
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
- depth_idx = int(self._ipx_internal_data["layout_name"] == "vertical")
279
- leaf_layout.iloc[:, depth_idx] = leaf_layout.iloc[:, depth_idx].max()
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
- elif orientation in ("left", "ascending"):
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
- leaf_vertex_style = {
432
+ default_leaf_style = {
292
433
  "size": 0,
293
434
  "label": {
294
- "verticalalignment": "center",
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": leaf_vertex_style}):
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 = self.get_vertices().get_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
- edge_style["waypoints"] = waypoints
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)
@@ -74,13 +74,24 @@ def _additional_set_methods(attributes, cls=None):
74
74
  return cls
75
75
 
76
76
 
77
- # FIXME: this method appears quite inconsistent, would be better to improve.
78
- # The issue is that to really know the size of a label on screen, we need to
79
- # render it first. Therefore, we should render the labels, then render the
80
- # vertices. Leaving for now, since this can be styled manually which covers
81
- # many use cases.
82
- def _get_label_width_height(text, hpadding=18, vpadding=12, **kwargs):
83
- """Get the bounding box size for a text with certain properties."""
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 know it don't work well
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
- vmin = np.nanmin(values)
153
- vmax = np.nanmax(values)
154
- norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
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
- from copy import copy
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
@@ -2,4 +2,4 @@
2
2
  iplotx version information module.
3
3
  """
4
4
 
5
- __version__ = "0.3.1"
5
+ __version__ = "0.4.0"
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((0, 0), numVertices=3, radius=size[0] / 2, **kwargs)
383
- elif marker in ("d", "diamond"):
384
- art, _ = make_patch("s", size[0], angle=45, **kwargs)
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), numVertices=3, radius=size[0] / 2, orientation=np.pi, **kwargs
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] / 2, size[1] / 2, **kwargs)
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iplotx
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Plot networkx from igraph and networkx.
5
5
  Project-URL: Homepage, https://github.com/fabilab/iplotx
6
6
  Project-URL: Documentation, https://readthedocs.org/iplotx