iplotx 1.2.1__py3-none-any.whl → 1.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/edge/__init__.py CHANGED
@@ -370,6 +370,9 @@ class EdgeCollection(mpl.collections.PatchCollection):
370
370
  if edge_stylei.get("curved", False):
371
371
  tension = edge_stylei.get("tension", 5)
372
372
  ports = edge_stylei.get("ports", (None, None))
373
+ elif edge_stylei.get("arc", False):
374
+ tension = edge_stylei.get("tension", 1)
375
+ ports = None
373
376
  else:
374
377
  tension = 0
375
378
  ports = None
@@ -391,6 +394,8 @@ class EdgeCollection(mpl.collections.PatchCollection):
391
394
  if waypoints != "none":
392
395
  ports = edge_stylei.get("ports", (None, None))
393
396
 
397
+ arc = edge_stylei.get("arc", False)
398
+
394
399
  # Compute actual edge path
395
400
  path, angles = _compute_edge_path(
396
401
  vcoord_data,
@@ -401,6 +406,7 @@ class EdgeCollection(mpl.collections.PatchCollection):
401
406
  tension=tension,
402
407
  waypoints=waypoints,
403
408
  ports=ports,
409
+ arc=arc,
404
410
  layout_coordinate_system=self._vertex_collection.get_layout_coordinate_system(),
405
411
  shrink=shrink,
406
412
  )
@@ -712,6 +718,7 @@ def make_stub_patch(**kwargs):
712
718
  "split",
713
719
  "shrink",
714
720
  "depthshade",
721
+ "arc",
715
722
  # DEPRECATED
716
723
  "padding",
717
724
  ]
iplotx/edge/geometry.py CHANGED
@@ -393,6 +393,108 @@ def _compute_edge_path_waypoints(
393
393
  return path, angles
394
394
 
395
395
 
396
+ def _compute_edge_path_arc(
397
+ tension,
398
+ vcoord_data,
399
+ vpath_fig,
400
+ vsize_fig,
401
+ trans,
402
+ trans_inv,
403
+ ports: Pair[Optional[str]] = (None, None),
404
+ shrink: float = 0,
405
+ ):
406
+ """Shorten the edge path along an arc.
407
+
408
+ Parameters:
409
+ tension: the tension of the arc. This is defined, for this function, as the tangent
410
+ of the angle spanning the arc. For instance, for a semicircle, the angle is
411
+ 180 degrees, so the tension is +-1 (depending on the orientation).
412
+ """
413
+
414
+ # Coordinates in figure (default) coords
415
+ vcoord_fig = trans(vcoord_data)
416
+
417
+ dv = vcoord_fig[1] - vcoord_fig[0]
418
+
419
+ # Tension is the fraction of the semicircle covered by the
420
+ # arc. Values are clipped between -1 (left-hand semicircle)
421
+ # and 1 (right-hand semicircle). 0 means a straight line,
422
+ # which is a (degenerate) arc too.
423
+ if tension == 0:
424
+ vs = [None, None]
425
+ thetas = [atan2(dv[1], dv[0])]
426
+ thetas.append(-thetas[0])
427
+ for i in range(2):
428
+ vs[i] = (
429
+ _get_shorter_edge_coords(vpath_fig[i], vsize_fig[i], thetas[i], shrink)
430
+ + vcoord_fig[i]
431
+ )
432
+ auxs = []
433
+
434
+ else:
435
+ edge_straight_length = np.sqrt((dv**2).sum())
436
+ theta_straight = atan2(dv[1], dv[0])
437
+ theta_tension = 4 * np.arctan(tension)
438
+ # print(f"theta_straight: {np.degrees(theta_straight):.2f}")
439
+ # print(f"theta_tension: {np.degrees(theta_tension):.2f}")
440
+ # NOTE: positive tension means an arc shooting off to the right of the straight
441
+ # line, same convensio as for tension elsewhere in the codebase.
442
+ thetas = [theta_straight - theta_tension / 2, np.pi + theta_straight + theta_tension / 2]
443
+ # This is guaranteed to be finite because tension == 0 is taken care of above,
444
+ # and tension = np.inf is not allowed.
445
+ mid = vcoord_fig.mean(axis=0)
446
+ # print(f"theta_s: {thetas}")
447
+ # print(f"mid: {mid}")
448
+ theta_offset = theta_straight + np.pi / 2
449
+ if np.abs(tension) <= 1:
450
+ offset_length = edge_straight_length / 2 / np.tan(theta_tension / 2)
451
+ else:
452
+ # print("Large tension arc")
453
+ offset_length = -edge_straight_length / 2 * np.tan(theta_tension / 2 - np.pi / 2)
454
+ # print(f"theta_offset: {np.degrees(theta_offset):.2f}")
455
+ offset = offset_length * np.array([np.cos(theta_offset), np.sin(theta_offset)])
456
+ # print(f"offset: {offset}")
457
+ center = mid + offset
458
+ # print(f"center: {center}")
459
+
460
+ # Compute shorter start and end points
461
+ vs = [None, None]
462
+ for i in range(2):
463
+ vs[i] = (
464
+ _get_shorter_edge_coords(vpath_fig[i], vsize_fig[i], thetas[i], shrink)
465
+ + vcoord_fig[i]
466
+ )
467
+ angle_start = atan2(*(vs[0] - center)[::-1])
468
+ angle_end = atan2(*(vs[1] - center)[::-1])
469
+ if (np.abs(tension) > 1) and (np.abs(angle_end - angle_start) < np.pi):
470
+ if angle_end > angle_start:
471
+ angle_start += 2 * np.pi
472
+ else:
473
+ angle_end += 2 * np.pi
474
+ # print(f"angle_start: {np.degrees(angle_start):.2f}")
475
+ # print(f"angle_end: {np.degrees(angle_end):.2f}")
476
+
477
+ naux = 30
478
+ angles = np.linspace(angle_start, angle_end, naux + 2)[1:-1]
479
+ auxs = center + np.array([np.cos(angles), np.sin(angles)]).T * np.linalg.norm(
480
+ vs[0] - center
481
+ )
482
+
483
+ path = {
484
+ "vertices": [vs[0]] + list(auxs) + [vs[1]],
485
+ "codes": ["MOVETO"] + ["LINETO"] * (len(auxs) + 1),
486
+ }
487
+
488
+ path = mpl.path.Path(
489
+ path["vertices"],
490
+ codes=[getattr(mpl.path.Path, x) for x in path["codes"]],
491
+ )
492
+
493
+ # Return to data transform
494
+ path.vertices = trans_inv(path.vertices)
495
+ return path, tuple(thetas)
496
+
497
+
396
498
  def _compute_edge_path_curved(
397
499
  tension,
398
500
  vcoord_data,
@@ -483,12 +585,15 @@ def _compute_edge_path(
483
585
  tension: float = 0,
484
586
  waypoints: str | tuple[float, float] | Sequence[tuple[float, float]] | np.ndarray = "none",
485
587
  ports: Pair[Optional[str]] = (None, None),
588
+ arc: bool = False,
486
589
  layout_coordinate_system: str = "cartesian",
487
590
  **kwargs,
488
591
  ):
489
592
  """Compute the edge path in a few different ways."""
490
593
  if (waypoints != "none") and (tension != 0):
491
594
  raise ValueError("Waypoints not supported for curved edges.")
595
+ if (waypoints != "none") and arc:
596
+ raise ValueError("Waypoint not supported for arc edges.")
492
597
 
493
598
  if waypoints != "none":
494
599
  return _compute_edge_path_waypoints(
@@ -506,6 +611,14 @@ def _compute_edge_path(
506
611
  **kwargs,
507
612
  )
508
613
 
614
+ if arc:
615
+ return _compute_edge_path_arc(
616
+ tension,
617
+ *args,
618
+ ports=ports,
619
+ **kwargs,
620
+ )
621
+
509
622
  return _compute_edge_path_curved(
510
623
  tension,
511
624
  *args,
@@ -21,8 +21,9 @@ class BiopythonDataProvider(TreeDataProvider):
21
21
 
22
22
  preorder = partialmethod(_traverse, order="preorder")
23
23
  postorder = partialmethod(_traverse, order="postorder")
24
+ levelorder = partialmethod(_traverse, order="level")
24
25
 
25
- def get_leaves(self) -> Sequence[Any]:
26
+ def _get_leaves(self) -> Sequence[Any]:
26
27
  return self.tree.get_terminals()
27
28
 
28
29
  @staticmethod
@@ -16,7 +16,10 @@ class Cogent3DataProvider(TreeDataProvider):
16
16
  def postorder(self) -> Sequence[Any]:
17
17
  return self.tree.postorder()
18
18
 
19
- def get_leaves(self) -> Sequence[Any]:
19
+ def levelorder(self) -> Sequence[Any]:
20
+ return self.tree.levelorder()
21
+
22
+ def _get_leaves(self) -> Sequence[Any]:
20
23
  return self.tree.tips()
21
24
 
22
25
  @staticmethod
@@ -36,7 +36,16 @@ class DendropyDataProvider(TreeDataProvider):
36
36
  return self.tree.postorder_node_iter()
37
37
  return self.tree.postorder_iter()
38
38
 
39
- def get_leaves(self) -> Sequence[Any]:
39
+ def levelorder(self) -> Any:
40
+ """Levelorder traversal of the tree.
41
+
42
+ NOTE: This will work on both entire Trees and Nodes (which means a subtree including self).
43
+ """
44
+ if hasattr(self.tree, "levelorder_node_iter"):
45
+ return self.tree.levelorder_node_iter()
46
+ return self.tree.levelorder_iter()
47
+
48
+ def _get_leaves(self) -> Sequence[Any]:
40
49
  """Get a list of leaves."""
41
50
  return self.tree.leaf_nodes()
42
51
 
@@ -18,8 +18,9 @@ class Ete4DataProvider(TreeDataProvider):
18
18
 
19
19
  preorder = partialmethod(_traverse, order="preorder")
20
20
  postorder = partialmethod(_traverse, order="postorder")
21
+ levelorder = partialmethod(_traverse, order="levelorder")
21
22
 
22
- def get_leaves(self) -> Sequence[Any]:
23
+ def _get_leaves(self) -> Sequence[Any]:
23
24
  return self.tree.leaves()
24
25
 
25
26
  @staticmethod
@@ -64,7 +64,17 @@ class SimpleTreeDataProvider(TreeDataProvider):
64
64
 
65
65
  yield from _recur(self.tree)
66
66
 
67
- def get_leaves(self) -> Sequence[Any]:
67
+ def levelorder(self) -> Iterable[dict[dict | str, Any]]:
68
+ from collections import deque
69
+
70
+ queue = deque([self.get_root()])
71
+ while queue:
72
+ node = queue.popleft()
73
+ for child in self.get_children(node):
74
+ queue.append(child)
75
+ yield node
76
+
77
+ def _get_leaves(self) -> Sequence[Any]:
68
78
  def _recur(node):
69
79
  if len(node.children) == 0:
70
80
  yield node
@@ -16,7 +16,10 @@ class SkbioDataProvider(TreeDataProvider):
16
16
  def postorder(self) -> Sequence[Any]:
17
17
  return self.tree.postorder()
18
18
 
19
- def get_leaves(self) -> Sequence[Any]:
19
+ def levelorder(self) -> Sequence[Any]:
20
+ return self.tree.levelorder()
21
+
22
+ def _get_leaves(self) -> Sequence[Any]:
20
23
  return self.tree.tips()
21
24
 
22
25
  @staticmethod
iplotx/ingest/typing.py CHANGED
@@ -12,6 +12,7 @@ from typing import (
12
12
  Any,
13
13
  Iterable,
14
14
  )
15
+
15
16
  # NOTE: __init__ in Protocols has had a difficult gestation
16
17
  # https://github.com/python/cpython/issues/88970
17
18
  if sys.version_info < (3, 11):
@@ -156,8 +157,32 @@ class TreeDataProvider(Protocol):
156
157
  return root_attr
157
158
  return self.tree.get_root()
158
159
 
159
- def get_leaves(self) -> Sequence[Any]:
160
- """Get the tree leaves/tips in a provider-specific data structure.
160
+ def get_subtree(self, node: TreeType):
161
+ """Get the subtree rooted at the given node.
162
+
163
+ Parameters:
164
+ node: The node to get the subtree from.
165
+ Returns:
166
+ The subtree rooted at the given node.
167
+ """
168
+ return self.__class__(node)
169
+
170
+ def get_leaves(self, node: Optional[TreeType] = None) -> Sequence[Any]:
171
+ """Get the leaves of the entire tree or a subtree.
172
+
173
+ Parameters:
174
+ node: The node to get the leaves from. If None, get from the entire
175
+ tree.
176
+ Returns:
177
+ The leaves or tips of the tree or node-anchored subtree.
178
+ """
179
+ if node is None:
180
+ return self._get_leaves()
181
+ else:
182
+ return self.get_subtree(node)._get_leaves()
183
+
184
+ def _get_leaves(self) -> Sequence[Any]:
185
+ """Get the whole tree leaves/tips in a provider-specific data structure.
161
186
 
162
187
  Returns:
163
188
  The leaves or tips of the tree.
@@ -235,8 +260,6 @@ class TreeDataProvider(Protocol):
235
260
  NOTE: individual providers may implement more efficient versions of
236
261
  this function if desired.
237
262
  """
238
- provider = self.__class__
239
-
240
263
  # Find leaves of the selected nodes
241
264
  leaves = set()
242
265
  for node in nodes:
@@ -244,7 +267,7 @@ class TreeDataProvider(Protocol):
244
267
  if len(self.get_children(node)) == 0:
245
268
  leaves.add(node)
246
269
  else:
247
- leaves |= set(provider(node).get_leaves())
270
+ leaves |= set(self.get_leaves(node))
248
271
 
249
272
  # Look for nodes with the same set of leaves, starting from the bottom
250
273
  # and stopping at the first (i.e. lowest) hit.
@@ -253,7 +276,7 @@ class TreeDataProvider(Protocol):
253
276
  if len(self.get_children(node)) == 0:
254
277
  leaves_node = {node}
255
278
  else:
256
- leaves_node = set(provider(node).get_leaves())
279
+ leaves_node = set(self.get_leaves(node))
257
280
  if leaves <= leaves_node:
258
281
  root = node
259
282
  break
@@ -285,9 +308,26 @@ class TreeDataProvider(Protocol):
285
308
  orientation = "right"
286
309
  elif layout == "vertical":
287
310
  orientation = "descending"
288
- elif layout == "radial":
311
+ elif layout in ("radial", "equalangle", "daylight"):
289
312
  orientation = "clockwise"
290
313
 
314
+ # Validate orientation
315
+ valid = (layout == "horizontal") and (orientation in ("right", "left"))
316
+ valid |= (layout == "vertical") and (orientation in ("ascending", "descending"))
317
+ valid |= (layout == "radial") and (
318
+ orientation in ("clockwise", "counterclockwise", "left", "right")
319
+ )
320
+ valid |= (layout == "equalangle") and (
321
+ orientation in ("clockwise", "counterclockwise", "left", "right")
322
+ )
323
+ valid |= (layout == "daylight") and (
324
+ orientation in ("clockwise", "counterclockwise", "left", "right")
325
+ )
326
+ if not valid:
327
+ raise ValueError(
328
+ f"Orientation '{orientation}' is not valid for layout '{layout}'.",
329
+ )
330
+
291
331
  tree_data = {
292
332
  "root": self.get_root(),
293
333
  "rooted": self.is_rooted(),
@@ -304,8 +344,10 @@ class TreeDataProvider(Protocol):
304
344
  root=tree_data["root"],
305
345
  preorder_fun=self.preorder,
306
346
  postorder_fun=self.postorder,
347
+ levelorder_fun=self.levelorder,
307
348
  children_fun=self.get_children,
308
349
  branch_length_fun=self.get_branch_length_default_to_one,
350
+ leaves_fun=self.get_leaves,
309
351
  **layout_style,
310
352
  )
311
353
  if layout in ("radial",):
@@ -0,0 +1,9 @@
1
+ """
2
+ Layout functions.
3
+ """
4
+
5
+ from .tree import compute_tree_layout
6
+
7
+ __all__ = [
8
+ "compute_tree_layout",
9
+ ]
@@ -0,0 +1,72 @@
1
+ """
2
+ Tree layout algorithms.
3
+ """
4
+
5
+ from typing import (
6
+ Any,
7
+ )
8
+ from collections.abc import (
9
+ Hashable,
10
+ Callable,
11
+ )
12
+
13
+ from .rooted import (
14
+ _horizontal_tree_layout,
15
+ _vertical_tree_layout,
16
+ _radial_tree_layout,
17
+ )
18
+ from .unrooted import (
19
+ _equalangle_tree_layout,
20
+ _daylight_tree_layout,
21
+ )
22
+
23
+
24
+ def compute_tree_layout(
25
+ layout: str,
26
+ orientation: str,
27
+ root: Any,
28
+ preorder_fun: Callable,
29
+ postorder_fun: Callable,
30
+ levelorder_fun: Callable,
31
+ children_fun: Callable,
32
+ branch_length_fun: Callable,
33
+ leaves_fun: Callable,
34
+ **kwargs,
35
+ ) -> dict[Hashable, list[float]]:
36
+ """Compute the layout for a tree.
37
+
38
+ Parameters:
39
+ layout: The name of the layout, e.g. "horizontal", "vertical", or "radial".
40
+ orientation: The orientation of the layout, e.g. "right", "left", "descending",
41
+ "ascending", "clockwise", "anticlockwise".
42
+
43
+ Returns:
44
+ A layout dictionary with node positions.
45
+ """
46
+ kwargs["root"] = root
47
+ kwargs["preorder_fun"] = preorder_fun
48
+ kwargs["postorder_fun"] = postorder_fun
49
+ kwargs["levelorder_fun"] = levelorder_fun
50
+ kwargs["children_fun"] = children_fun
51
+ kwargs["orientation"] = orientation
52
+ kwargs["branch_length_fun"] = branch_length_fun
53
+ kwargs["leaves_fun"] = leaves_fun
54
+
55
+ # Angular or not, the vertex layout is unchanged. Since we do not
56
+ # currently compute an edge layout here, we can ignore the option.
57
+ kwargs.pop("angular", None)
58
+
59
+ if layout == "radial":
60
+ layout_dict = _radial_tree_layout(**kwargs)
61
+ elif layout == "horizontal":
62
+ layout_dict = _horizontal_tree_layout(**kwargs)
63
+ elif layout == "vertical":
64
+ layout_dict = _vertical_tree_layout(**kwargs)
65
+ elif layout == "equalangle":
66
+ layout_dict = _equalangle_tree_layout(**kwargs)
67
+ elif layout == "daylight":
68
+ layout_dict = _daylight_tree_layout(**kwargs)
69
+ else:
70
+ raise ValueError(f"Tree layout not available: {layout}")
71
+
72
+ return layout_dict
@@ -1,5 +1,5 @@
1
1
  """
2
- Layout functions, currently limited to trees.
2
+ Rooted tree layout for iplotx library.
3
3
  """
4
4
 
5
5
  from typing import (
@@ -14,55 +14,13 @@ from collections.abc import (
14
14
  import numpy as np
15
15
 
16
16
 
17
- def compute_tree_layout(
18
- layout: str,
19
- orientation: str,
20
- root: Any,
21
- preorder_fun: Callable,
22
- postorder_fun: Callable,
23
- children_fun: Callable,
24
- branch_length_fun: Callable,
25
- **kwargs,
26
- ) -> dict[Hashable, list[float]]:
27
- """Compute the layout for a tree.
28
-
29
- Parameters:
30
- layout: The name of the layout, e.g. "horizontal", "vertical", or "radial".
31
- orientation: The orientation of the layout, e.g. "right", "left", "descending",
32
- "ascending", "clockwise", "anticlockwise".
33
-
34
- Returns:
35
- A layout dictionary with node positions.
36
- """
37
- kwargs["root"] = root
38
- kwargs["preorder_fun"] = preorder_fun
39
- kwargs["postorder_fun"] = postorder_fun
40
- kwargs["children_fun"] = children_fun
41
- kwargs["branch_length_fun"] = branch_length_fun
42
- kwargs["orientation"] = orientation
43
-
44
- # Angular or not, the vertex layout is unchanged. Since we do not
45
- # currently compute an edge layout here, we can ignore the option.
46
- kwargs.pop("angular", None)
47
-
48
- if layout == "radial":
49
- layout_dict = _radial_tree_layout(**kwargs)
50
- elif layout == "horizontal":
51
- layout_dict = _horizontal_tree_layout(**kwargs)
52
- elif layout == "vertical":
53
- layout_dict = _vertical_tree_layout(**kwargs)
54
- else:
55
- raise ValueError(f"Tree layout not available: {layout}")
56
-
57
- return layout_dict
58
-
59
-
60
17
  def _horizontal_tree_layout_right(
61
18
  root: Any,
62
19
  preorder_fun: Callable,
63
20
  postorder_fun: Callable,
64
21
  children_fun: Callable,
65
22
  branch_length_fun: Callable,
23
+ **kwargs,
66
24
  ) -> dict[Hashable, list[float]]:
67
25
  """Build a tree layout horizontally, left to right.
68
26
 
@@ -173,7 +131,7 @@ def _radial_tree_layout(
173
131
  360, it leaves a small gap at the end to ensure the first and last leaf
174
132
  are not overlapping.
175
133
  Returns:
176
- A dictionary with the radial layout.
134
+ A dictionary with the layout.
177
135
  """
178
136
  # Short form
179
137
  th = start * np.pi / 180
@@ -0,0 +1,444 @@
1
+ """
2
+ Unrooted tree layout for iplotx.
3
+ """
4
+
5
+ from typing import (
6
+ Any,
7
+ )
8
+ from collections.abc import (
9
+ Hashable,
10
+ Callable,
11
+ )
12
+ import numpy as np
13
+
14
+
15
+ def _equalangle_tree_layout(
16
+ root: Any,
17
+ preorder_fun: Callable,
18
+ postorder_fun: Callable,
19
+ children_fun: Callable,
20
+ branch_length_fun: Callable,
21
+ leaves_fun: Callable,
22
+ orientation: str = "right",
23
+ start: float = 180,
24
+ span: float = 360,
25
+ **kwargs,
26
+ ) -> dict[Hashable, list[float]]:
27
+ """Equal angle unrooted tree layout.
28
+
29
+ Parameters:
30
+ orientation: Whether the layout fans out towards the right (clockwise) or left
31
+ (anticlockwise).
32
+ start: The starting angle in degrees, default is -180 (left).
33
+ span: The angular span in degrees, default is 360 (full circle). When this is
34
+ 360, it leaves a small gap at the end to ensure the first and last leaf
35
+ are not overlapping.
36
+ Returns:
37
+ A dictionary with the layout.
38
+
39
+ Reference: "Inferring Phylogenies" by Joseph Felsenstein, ggtree.
40
+ """
41
+
42
+ props = {
43
+ "layout": {},
44
+ "nleaves": {},
45
+ "start": {},
46
+ "end": {},
47
+ "angle": {},
48
+ }
49
+
50
+ props["layout"][root] = [0.0, 0.0]
51
+ props["start"][root] = 0.0
52
+ props["end"][root] = 360.0
53
+ props["angle"][root] = 0.0
54
+
55
+ # Count the number of leaves in each subtree
56
+ for node in postorder_fun():
57
+ props["nleaves"][node] = sum(props["nleaves"][child] for child in children_fun(node)) or 1
58
+
59
+ # Set the layout of everyone except the root
60
+ # NOTE: In ggtree, it says "postorder", but I cannot quite imagine how that would work,
61
+ # given that in postorder the root is visited last but it's also the only node about
62
+ # which we know anything at this point.
63
+ for node in preorder_fun():
64
+ nleaves = props["nleaves"][node]
65
+ children = children_fun(node)
66
+
67
+ # Get current node props
68
+ start = props["start"].get(node, 0)
69
+ end = props["end"].get(node, 0)
70
+ cur_x, cur_y = props["layout"].get(node, [0.0, 0.0])
71
+
72
+ total_angle = end - start
73
+
74
+ for child in children:
75
+ nleaves_child = props["nleaves"][child]
76
+ alpha = nleaves_child / nleaves * total_angle
77
+ if orientation in ("left", "counterclockwise"):
78
+ alpha = -alpha
79
+ beta = start + alpha / 2
80
+
81
+ props["layout"][child] = [
82
+ cur_x + branch_length_fun(child) * np.cos(np.radians(beta)),
83
+ cur_y + branch_length_fun(child) * np.sin(np.radians(beta)),
84
+ ]
85
+ # props["angle"][child] = -90 - beta * np.sign(beta - 180)
86
+ props["start"][child] = start
87
+ props["end"][child] = start + alpha
88
+ start += alpha
89
+
90
+ # FIXME: figure out how to tell the caller about "angle"
91
+ return props["layout"]
92
+
93
+
94
+ def _daylight_tree_layout(
95
+ root: Any,
96
+ preorder_fun: Callable,
97
+ postorder_fun: Callable,
98
+ levelorder_fun: Callable,
99
+ children_fun: Callable,
100
+ branch_length_fun: Callable,
101
+ leaves_fun: Callable,
102
+ orientation: str = "right",
103
+ start: float = 180,
104
+ span: float = 360,
105
+ max_iter: int = 5,
106
+ dampening: float = 0.33,
107
+ max_correction: float = 10.0,
108
+ **kwargs,
109
+ ) -> dict[Hashable, list[float]]:
110
+ """Daylight unrooted tree layout.
111
+
112
+ Parameters:
113
+ orientation: Whether the layout fans out towards the right (clockwise) or left
114
+ (anticlockwise).
115
+ start: The starting angle in degrees, default is -180 (left).
116
+ span: The angular span in degrees, default is 360 (full circle). When this is
117
+ 360, it leaves a small gap at the end to ensure the first and last leaf
118
+ are not overlapping.
119
+ max_iter: Maximum number of iterations to perform.
120
+ dampening: Dampening factor for angle adjustments. 1.0 means full adjustment.
121
+ The number must be strictily positive (usually between 0 excluded and 1
122
+ included).
123
+ Returns:
124
+ A dictionary with the layout.
125
+
126
+ Reference: "Inferring Phylogenies" by Joseph Felsenstein, ggtree.
127
+ """
128
+
129
+ delta_angle_min = 9.0
130
+
131
+ layout = _equalangle_tree_layout(
132
+ root,
133
+ preorder_fun,
134
+ postorder_fun,
135
+ children_fun,
136
+ branch_length_fun,
137
+ leaves_fun,
138
+ orientation,
139
+ start,
140
+ span,
141
+ **kwargs,
142
+ )
143
+
144
+ if len(layout) <= 2:
145
+ return layout
146
+
147
+ # Make all arrays for easier manipulation
148
+ orig_class = next(iter(layout.values())).__class__
149
+ for key, value in layout.items():
150
+ layout[key] = np.asarray(value)
151
+
152
+ all_leaves = list(leaves_fun(root))
153
+
154
+ change_avg = 1.0
155
+ for it in range(max_iter):
156
+ change_sum = 0
157
+ ninternal = 0
158
+ parents = [None] + list(levelorder_fun())
159
+ for parent in parents:
160
+ if parent is None:
161
+ # If the root has only two children, it's a passthrough node, skip it
162
+ if len(children_fun(root)) < 3:
163
+ continue
164
+ # Else, include it
165
+ children = [root]
166
+ else:
167
+ children = children_fun(parent)
168
+
169
+ for node in children:
170
+ grandchildren = children_fun(node)
171
+ # Exclude leaves, since they have no children subtrees
172
+ # that can be adjusted. Exclude also passthrough nodes with
173
+ # a single child, because they are rotating rigidly when their
174
+ # parent does so or tells them to do so.
175
+ if len(grandchildren) < 2:
176
+ continue
177
+
178
+ res = _apply_daylight_single_node(
179
+ node,
180
+ parent,
181
+ grandchildren,
182
+ all_leaves,
183
+ layout,
184
+ leaves_fun,
185
+ children_fun,
186
+ dampening,
187
+ max_correction,
188
+ )
189
+ change_sum += res
190
+ ninternal += 1
191
+
192
+ change_avg = change_sum / ninternal
193
+ if change_avg < delta_angle_min:
194
+ break
195
+
196
+ # Make all lists again
197
+ for key, value in layout.items():
198
+ layout[key] = orig_class(value)
199
+
200
+ return layout
201
+
202
+
203
+ def _apply_daylight_single_node(
204
+ node: Any,
205
+ parent: Any,
206
+ children: list[Any],
207
+ all_leaves: list[Any],
208
+ layout: dict[Hashable, np.ndarray],
209
+ leaves_fun: Callable,
210
+ children_fun: Callable,
211
+ dampening: float,
212
+ max_correction: float,
213
+ ) -> float:
214
+ """Apply daylight adjustment to a single internal node.
215
+
216
+ Parameters:
217
+ node: The internal node to adjust.
218
+ Returns:
219
+ The total change in angle applied.
220
+
221
+ NOTE: The layout is also changed in place.
222
+
223
+ # Inspired from:
224
+ # https://github.com/thomasp85/ggraph/blob/6c4ce81e460c50a16f9cd97e0b3a089f36901316/src/unrooted.cpp#L122
225
+ """
226
+
227
+ import os
228
+ from builtins import print as _print
229
+
230
+ DEBUG_DAYLIGHT = os.getenv("IPLOTX_DEBUG_DAYLIGHT", "0") == "1"
231
+
232
+ print = _print if DEBUG_DAYLIGHT else lambda *a, **k: None
233
+
234
+ # 1. Find daylight boundary leaves for each subtree. There are always at least two subtrees,
235
+ # the first child and the parent (this function is not called for leaves, and the root hopefully
236
+ # has at least two children).
237
+ p0 = layout[node]
238
+ bounds = {}
239
+
240
+ print("node")
241
+ print(node)
242
+ print(float(p0[0]), float(p0[1]))
243
+
244
+ # To find the parent side leaves, we take all leaves and skip the ones
245
+ # downstream of this node
246
+ leaves_below = leaves_fun(node)
247
+
248
+ # Check the parent first if there is one
249
+ if parent is not None:
250
+ leaves_parent_subtree = [leaf for leaf in all_leaves if leaf not in leaves_below]
251
+ vec1 = layout[parent] - p0
252
+ print("parent side leaves:")
253
+ print(parent)
254
+ print(
255
+ f" node to parent vector: {vec1[0]:.2f}, {vec1[1]:.2f}, angle: {np.degrees(np.arctan2(vec1[1], vec1[0])):.2f}"
256
+ )
257
+ lower_angle, upper_angle = 2 * np.pi, -2 * np.pi
258
+ for leaf in leaves_parent_subtree:
259
+ vec2 = layout[leaf] - p0
260
+ angle = _anticlockwise_angle(vec1, vec2)
261
+ print(" parent side leaf:")
262
+ print(leaf)
263
+ print(
264
+ f" node to leaf vector: {vec2[0]:.2f}, {vec2[1]:.2f}, angle: {np.degrees(np.arctan2(vec2[1], vec2[0])):.2f}"
265
+ )
266
+ print(f" angle: {np.degrees(angle):.2f}")
267
+ if angle < lower_angle:
268
+ print("lowering lower angle")
269
+ lower_angle = angle
270
+ lower = leaf
271
+ else:
272
+ print("not lowering lower angle")
273
+ if angle > upper_angle:
274
+ print("raising upper angle")
275
+ upper_angle = angle
276
+ upper = leaf
277
+ else:
278
+ print("not raising upper angle")
279
+ bounds[parent] = (lower, upper, lower_angle, upper_angle)
280
+
281
+ # Repeat the exact same thing for each child rather than the parent
282
+ print("subtree leaves:")
283
+ for child in children:
284
+ vec1 = layout[child] - p0
285
+ print(
286
+ f" node to child vector: {vec1[0]:.2f}, {vec1[1]:.2f}, angle: {np.degrees(np.arctan2(vec1[1], vec1[0])):.2f}"
287
+ )
288
+ lower_angle, upper_angle = 2 * np.pi, -2 * np.pi
289
+
290
+ for leaf in leaves_fun(child):
291
+ vec2 = layout[leaf] - p0
292
+ angle = _anticlockwise_angle(vec1, vec2)
293
+ print(
294
+ f" node to leaf vector: {vec2[0]:.2f}, {vec2[1]:.2f}, angle: {np.degrees(np.arctan2(vec2[1], vec2[0])):.2f}"
295
+ )
296
+ print(leaf)
297
+ print(f" angle: {np.degrees(angle):.2f}")
298
+ if angle < lower_angle:
299
+ print("lowering lower angle")
300
+ lower_angle = angle
301
+ lower = leaf
302
+ else:
303
+ print("not lowering lower angle")
304
+ if angle > upper_angle:
305
+ print("raising upper angle")
306
+ upper_angle = angle
307
+ upper = leaf
308
+ else:
309
+ print("not raising upper angle")
310
+ bounds[child] = (lower, upper, lower_angle, upper_angle)
311
+
312
+ for subtree, bound in bounds.items():
313
+ vec1 = layout[bound[0]] - p0
314
+ vec2 = layout[bound[1]] - p0
315
+ angle = _anticlockwise_angle(vec1, vec2)
316
+ print("subtree angles:")
317
+ print(f" lower {np.degrees(np.arctan2(vec1[1], vec1[0])):.2f}")
318
+ print(f" upper {np.degrees(np.arctan2(vec2[1], vec2[0])):.2f}")
319
+ print(f" angle {np.degrees(angle):.2f}")
320
+
321
+ # 2. Compute daylight angles
322
+ # NOTE: Since Python 3.6, python keys are ordered by insertion order.
323
+ daylight = {}
324
+ subtrees = list(bounds.keys())
325
+ subtrees += [subtrees[0]] # Repeat first subtree
326
+
327
+ for i in range(len(subtrees) - 1):
328
+ subtree = subtrees[i + 1]
329
+ old_subtree = subtrees[i]
330
+ lower = bounds[subtree][0]
331
+ old_upper = bounds[old_subtree][1]
332
+ vec1 = layout[old_upper] - p0
333
+ vec2 = layout[lower] - p0
334
+ angle = _anticlockwise_angle(vec1, vec2)
335
+ daylight[subtree] = float(angle)
336
+ print("daylight angle:")
337
+ print(f" previous upper {np.degrees(np.arctan2(vec1[1], vec1[0])):.2f}")
338
+ print(f" new lower {np.degrees(np.arctan2(vec2[1], vec2[0])):.2f}")
339
+ print(f" angle: {np.degrees(angle):.2f}")
340
+
341
+ daylight_avg = sum(daylight.values()) / len(daylight)
342
+ print(f"daylight average angle: {np.degrees(daylight_avg):.2f}")
343
+
344
+ # 3. Compute *excess* daylight and corrections
345
+ daylight_correction = {}
346
+ corr_cum = 0.0
347
+ print("daylight correction:")
348
+ for subtree, angle in daylight.items():
349
+ # Correction is negative of the residue
350
+ corr_cum -= angle - daylight_avg
351
+ daylight_correction[subtree] = corr_cum
352
+ print(f" daylight angle: {np.degrees(angle):.2f}, correction: {np.degrees(corr_cum):.2f}")
353
+
354
+ # NOTE: the last daylight correction must be 0, otherwise we are just rotating the entire tree.
355
+ # In most cases, this will be the parent which cannot rotate anyway (for the same reason).
356
+ # However, when applied to the root node with 3+ children, the nonrotating one will be the first
357
+ # child, which is arbitrary but correct: even in this case, we do not want a merry-go-round.
358
+
359
+ # 4. Correct (the last one is dumb)
360
+ daylight_corrections_abs = 0.0
361
+ for subtree, correction in daylight_correction.items():
362
+ correction *= dampening
363
+ correction = np.clip(correction, np.radians(-max_correction), np.radians(max_correction))
364
+ print(f"Applying correction to subtree {subtree}: {np.degrees(correction):.2f}")
365
+ _rotate_subtree_anticlockwise(
366
+ leaf,
367
+ children_fun,
368
+ layout,
369
+ p0,
370
+ correction,
371
+ recur=True,
372
+ )
373
+ daylight_corrections_abs += abs(correction)
374
+
375
+ # __import__("ipdb").set_trace()
376
+
377
+ # Caller wants degrees
378
+ # NOTE: The denominator is -1 because the last correction is always zero anyway, so the
379
+ # actually possible corrections are #subtrees - 1.
380
+ return np.degrees(daylight_corrections_abs / (len(daylight_correction) - 1))
381
+
382
+
383
+ # see: https://stackoverflow.com/questions/14066933/direct-way-of-computing-the-clockwise-angle-between-two-vectors
384
+ def _anticlockwise_angle(v1, v2):
385
+ """Compute the anticlockwise angle between two 2D vectors.
386
+
387
+ Parameters:
388
+ v1: First vector.
389
+ v2: Second vector.
390
+ Returns:
391
+ The angle in radians.
392
+ """
393
+ dot = v1[0] * v2[0] + v1[1] * v2[1]
394
+ determinant = v1[0] * v2[1] - v1[1] * v2[0]
395
+ return np.arctan2(determinant, dot)
396
+
397
+
398
+ def _rotate_subtree_anticlockwise(
399
+ node,
400
+ children_fun: Callable,
401
+ layout: dict[Hashable, list[float]],
402
+ pivot,
403
+ angle,
404
+ recur: bool = True,
405
+ ):
406
+ point = np.asarray(layout[node])
407
+ pivot = np.asarray(pivot)
408
+ layout[node] = _rotate_anticlockwise(
409
+ point,
410
+ pivot,
411
+ angle,
412
+ )
413
+ if not recur:
414
+ return
415
+ for child in children_fun(node):
416
+ _rotate_subtree_anticlockwise(
417
+ child,
418
+ children_fun,
419
+ layout,
420
+ pivot,
421
+ angle,
422
+ )
423
+
424
+
425
+ def _rotate_anticlockwise(
426
+ point,
427
+ pivot,
428
+ angle,
429
+ ):
430
+ """Rotate a point around a piviot by angle (in radians).
431
+
432
+ Parameters:
433
+ point: The point to rotate.
434
+ pivot: The piviot point.
435
+ angle: The angle in radians.
436
+ Returns:
437
+ The rotated point.
438
+ """
439
+ point = np.asarray(point)
440
+ pivot = np.asarray(pivot)
441
+ cos = np.cos(angle)
442
+ sin = np.sin(angle)
443
+ rot = np.array([[cos, -sin], [sin, cos]])
444
+ return pivot + (point - pivot) @ rot
iplotx/style/leaf_info.py CHANGED
@@ -41,6 +41,7 @@ nonrotating_leaves = (
41
41
  "deep",
42
42
  "angular",
43
43
  "curved",
44
+ "arc",
44
45
  "capstyle",
45
46
  "depthshade",
46
47
  )
iplotx/tree/__init__.py CHANGED
@@ -445,7 +445,7 @@ class TreeArtist(mpl.artist.Artist):
445
445
  leaf_layout = self.get_layout("leaf").copy()
446
446
 
447
447
  # Set all to max depth
448
- if user_leaf_style.get("deep", True):
448
+ if user_leaf_style.get("deep", False):
449
449
  if layout_name == "radial":
450
450
  leaf_layout.iloc[:, 0] = leaf_layout.iloc[:, 0].max()
451
451
  elif (layout_name, orientation) == ("horizontal", "right"):
@@ -666,7 +666,10 @@ class TreeArtist(mpl.artist.Artist):
666
666
  norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
667
667
  edge_style["norm"] = norm
668
668
 
669
- if get_style(".layout", {}).get("angular", False):
669
+ angular_layout = get_style(".layout", {}).get("angular", False)
670
+ if self._ipx_internal_data["layout_name"] in ("equalangle", "daylight"):
671
+ angular_layout = True
672
+ if angular_layout:
670
673
  edge_style.pop("waypoints", None)
671
674
  else:
672
675
  edge_style["waypoints"] = waypoints
iplotx/version.py CHANGED
@@ -2,4 +2,4 @@
2
2
  iplotx version information module.
3
3
  """
4
4
 
5
- __version__ = "1.2.1"
5
+ __version__ = "1.4.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iplotx
3
- Version: 1.2.1
3
+ Version: 1.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
@@ -36,9 +36,9 @@ Provides-Extra: networkx
36
36
  Requires-Dist: networkx>=2.0.0; extra == 'networkx'
37
37
  Description-Content-Type: text/markdown
38
38
 
39
- ![Github Actions](https://github.com/fabilab/iplotx/actions/workflows/test.yml/badge.svg)
40
- ![PyPI - Version](https://img.shields.io/pypi/v/iplotx)
41
- ![RTD](https://readthedocs.org/projects/iplotx/badge/?version=latest)
39
+ [![Github Actions](https://github.com/fabilab/iplotx/actions/workflows/test.yml/badge.svg)](https://github.com/fabilab/iplotx/actions/workflows/test.yml)
40
+ [![PyPI - Version](https://img.shields.io/pypi/v/iplotx)](https://pypi.org/project/iplotx/)
41
+ [![RTD](https://readthedocs.org/projects/iplotx/badge/?version=latest)](https://iplotx.readthedocs.io/en/latest/)
42
42
  [![Coverage Status](https://coveralls.io/repos/github/fabilab/iplotx/badge.svg?branch=main)](https://coveralls.io/github/fabilab/iplotx?branch=main)
43
43
  ![pylint](assets/pylint.svg)
44
44
  [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.16599333.svg)](https://doi.org/10.5281/zenodo.16599333)
@@ -89,6 +89,13 @@ See [readthedocs](https://iplotx.readthedocs.io/en/latest/) for the full documen
89
89
  ## Gallery
90
90
  See [gallery](https://iplotx.readthedocs.io/en/latest/gallery/index.html).
91
91
 
92
+ ## Citation
93
+ If you use `iplotx` for publication figures, please cite the [zenodo preprint](https://doi.org/10.5281/zenodo.16599333):
94
+
95
+ ```
96
+ F. Zanini. (2025). Unified network visualisation in Python. Zenodo [PREPRINT]. https://doi.org/10.5281/zenodo.16599333
97
+ ```
98
+
92
99
  ## Features
93
100
  - Plot networks from multiple libraries including networkx, igraph and graph-tool, using Matplotlib. ✅
94
101
  - Plot trees from multiple libraries such as cogent3, ETE4, skbio, biopython, and dendropy. ✅
@@ -1,45 +1,48 @@
1
1
  iplotx/__init__.py,sha256=RKlRSSEAv2qECd6rCiovdLDu-4k1eXMGCOCPt0xwpFA,523
2
2
  iplotx/artists.py,sha256=2dBDT240zGwKb6tIc_y9pXeyU3LuYeF9wjj2tvi4KJo,730
3
3
  iplotx/label.py,sha256=7eS8ByadrhdIFOZz19U4VrS-oXY_ndFYNB-D4RZbFqI,9573
4
- iplotx/layout.py,sha256=S-iFxHaIOzhBDG2JUzl9_oDBRP5TYY1hXnEOs0h1Rck,5588
5
4
  iplotx/plotting.py,sha256=RyAdvaHSpuyJkf8DF3SJBvEXBrPmJEdovUyAlBWQvqU,16228
6
5
  iplotx/typing.py,sha256=QLdzV358IiD1CFe88MVp0D77FSx5sSAVUmM_2WPPE8I,1463
7
- iplotx/version.py,sha256=2CsPuf8-y4qmmHpnn5_1DZwxypX8SRHYrsqTTmN1h5A,66
6
+ iplotx/version.py,sha256=O8IErtvV5ZWlH0H4BksvQyiERBsQVCHqnwNyeNF_Nhk,66
8
7
  iplotx/vertex.py,sha256=_yYyvusn4vYvi6RBEW6CHa3vnbv43GnZylnMIaK4bG0,16040
9
8
  iplotx/art3d/vertex.py,sha256=Xf8Um30X2doCd8KdNN7332F6BxC4k72Mb_GeRAuzQfQ,2545
10
9
  iplotx/art3d/edge/__init__.py,sha256=uw1U_mMXqcZAvea-7JbU1PUKULQD1CMMrbwY02tiWRQ,8529
11
10
  iplotx/art3d/edge/arrow.py,sha256=14BFXY9kDOUGPZl2fMD9gRVGyaaN5kyd-l6ikBg6WHU,3601
12
11
  iplotx/art3d/edge/geometry.py,sha256=76VUmpPG-4Mls7x_994dMwdDPrWWnjT7nHJsHfwK_hA,2467
13
- iplotx/edge/__init__.py,sha256=GlY_CmlMVD1DWJeOb5cFQEP_4K1pPneS_fRLqAxNGMk,26573
12
+ iplotx/edge/__init__.py,sha256=jH0qjRzlD7ZbiNziMv-rVOhiHPWtKc4WqBHRRbDQgvg,26795
14
13
  iplotx/edge/arrow.py,sha256=ymup2YT_0GVYMtZw_DSKrZqFHG_ysYteEhmoL6T8Mu4,17563
15
- iplotx/edge/geometry.py,sha256=jkTMvQC5425GjB_fmGLIPJeSDAr_7NZF8zZDLTrSj34,15541
14
+ iplotx/edge/geometry.py,sha256=dczXCLCn5vlFm_SwXXh_gwf0h7irILSCdNIDKTuuqmA,19646
16
15
  iplotx/edge/leaf.py,sha256=SyGMv2PIOoH0pey8-aMVaZheK3hNe1Qz_okcyWbc4E4,4268
17
16
  iplotx/edge/ports.py,sha256=BpkbiEhX4mPBBAhOv4jcKFG4Y8hxXz5GRtVLCC0jbtI,1235
18
17
  iplotx/ingest/__init__.py,sha256=k1Q-7lSdotMR4RkF1x0t19RFsTknohX0L507Dw69WyU,5035
19
18
  iplotx/ingest/heuristics.py,sha256=715VqgfKek5LOJnu1vTo7RqPgCl-Bb8Cf6o7_Tt57fA,5797
20
- iplotx/ingest/typing.py,sha256=nk0UTqTuZoa9YE7F8RlOTqhxPw4OEYFTqrFFRWQs0jI,14488
19
+ iplotx/ingest/typing.py,sha256=VYWlNGwn047J0i10vC_F3Lfnx2CSiX6IeH_NtAgHLQg,16089
21
20
  iplotx/ingest/providers/network/graph_tool.py,sha256=iTCf4zHe4Zmdd8Tlz6j7Xfo_FwfsIiK5JkQfH3uq7TM,3028
22
21
  iplotx/ingest/providers/network/igraph.py,sha256=WL9Yx2IF5QhUIoKMlozdyq5HWIZ-IJmNoeS8GOhL0KU,2945
23
22
  iplotx/ingest/providers/network/networkx.py,sha256=ehCg4npL073HX-eAG-VoP6refLPsMb3lYG51xt_rNjA,4636
24
23
  iplotx/ingest/providers/network/simple.py,sha256=e_aHhiHhN9DrMoNrt7tEMPURXGhQ1TYRPzsxDEptUlc,3766
25
- iplotx/ingest/providers/tree/biopython.py,sha256=4N_54cVyHHPcASJZGr6pHKE2p5R3i8Cm307SLlSLHLA,1480
26
- iplotx/ingest/providers/tree/cogent3.py,sha256=JmELbDK7LyybiJzFNbmeqZ4ySJoDajvFfJebpNfFKWo,1073
27
- iplotx/ingest/providers/tree/dendropy.py,sha256=uRMe46PfDPUTeNInUO2Gbp4pVr-WIFIZQvrND2tovsg,1548
28
- iplotx/ingest/providers/tree/ete4.py,sha256=D7usSq0MOjzrk3EoLi834IlaDGwv7_qG6Qt0ptfKqfI,928
29
- iplotx/ingest/providers/tree/simple.py,sha256=-T7Kf-G4F4niggy_tNZ8AafDf8fpDdthC-vlkjbEZso,2569
30
- iplotx/ingest/providers/tree/skbio.py,sha256=O1KUr8tYi28pZ3VVjapgO4Uj-YpMuix3GhOH5je8Lv4,822
24
+ iplotx/ingest/providers/tree/biopython.py,sha256=si-ncMVHrdbwDVspznFbO7ajTaa37gqSfYGKdx9eoQ8,1538
25
+ iplotx/ingest/providers/tree/cogent3.py,sha256=6omk0cDSmb2k2J1BQ9depcSPWcfamSyS0eIlRpLRgoM,1156
26
+ iplotx/ingest/providers/tree/dendropy.py,sha256=MRmPdlrPwqCQWLkKM4vJLN4hpqLY7qGtNrBYs5pLP9E,1891
27
+ iplotx/ingest/providers/tree/ete4.py,sha256=JT0Zv-nzHKTSJ0h4I7SxkwFafww_siDZ06Krvz-BT1U,991
28
+ iplotx/ingest/providers/tree/simple.py,sha256=hin38l9ZAnuDGwZAPctxNhIBrmIkQYIkBMFPt9uALuE,2877
29
+ iplotx/ingest/providers/tree/skbio.py,sha256=T3IbOBut98A2GoGJzo6Tzp108uFa9n485mV4M9J5xFk,905
30
+ iplotx/layout/__init__.py,sha256=7on7I9CcbByz4X4hNJGsCc0GDFr3ul1K2UvTyvcGAWo,107
31
+ iplotx/layout/tree/__init__.py,sha256=hxASc8uXMWbpxnEHnChMzb3VQTTIyU4ww7SQRez1hK0,2000
32
+ iplotx/layout/tree/rooted.py,sha256=j3Y_Yd3YCWJRnhfE1qwEX5xCyUgvOJCX9Qfkc81B5BM,4215
33
+ iplotx/layout/tree/unrooted.py,sha256=7iWLcQLwPsZgJpNlAnVKnEj-XI3twpYZhe4WztU9gEc,14668
31
34
  iplotx/network/__init__.py,sha256=cJ6m6s157AOCqg-znUAlsumuZ2jiE9QsVQ3-GCK01wo,13543
32
35
  iplotx/network/groups.py,sha256=E_eYVXRHjv1DcyA4RupTkMa-rRFrIKkt9Rxn_Elw9Nc,6796
33
36
  iplotx/style/__init__.py,sha256=rf1GutrE8hHUhCoe4FGKYX-aNtHuu_U-kYQnqUxZNrY,10282
34
- iplotx/style/leaf_info.py,sha256=3xBn7xv9Uy2KAqdhM9S6ew5ZBJrGRTXRL3xXb8atfLw,1018
37
+ iplotx/style/leaf_info.py,sha256=oBiOH-fHt_D2Nic2eYeE5nG5nLIE_0gSDNbLAYe5g2w,1029
35
38
  iplotx/style/library.py,sha256=58Y8BlllGLsR4pQM7_PVCP5tH6_4GkchXZvJpqGHlcg,8534
36
- iplotx/tree/__init__.py,sha256=nV2iWrtpPhpGZnkEJqK8ydimMi7pJtRQ1Eqn5dgqUyA,31043
39
+ iplotx/tree/__init__.py,sha256=sxVrxZcsDIZddBdtI-TxOs37FyeYbHBLYqkLPWs6c8M,31199
37
40
  iplotx/tree/cascades.py,sha256=Wwqhy46QGeb4LNGUuz_-bgNWUMz6PFzs_dIxIb1dtqc,8394
38
41
  iplotx/tree/scalebar.py,sha256=Yxt_kF8JdTwKGa8Jzqt3qVePPK5ZBG8P0EiONrsh3E8,11863
39
42
  iplotx/utils/geometry.py,sha256=6RrC6qaB0-1vIk1LhGA4CfsiMd-9JNniSPyL_l9mshE,9245
40
43
  iplotx/utils/internal.py,sha256=WWfcZDGK8Ut1y_tOHRGg9wSqY1bwSeLQO7dHM_8Tvwo,107
41
44
  iplotx/utils/matplotlib.py,sha256=p_53Oamof0RI4mtV8HrdDtZbgVqUxeUZ_KDvLZSiBUQ,8604
42
45
  iplotx/utils/style.py,sha256=vyNP80nDYVinqm6_9ltCJCtjK35ZcGlHvOskNv3eQBc,4225
43
- iplotx-1.2.1.dist-info/METADATA,sha256=DVxutHflrg2vXCJfyzmPR83ywc4_5DfGdrViUMvV43E,5001
44
- iplotx-1.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
45
- iplotx-1.2.1.dist-info/RECORD,,
46
+ iplotx-1.4.0.dist-info/METADATA,sha256=8mg2vInkk2LjL7kMr5jc9MXPsf35iAVBIOfOaWoqIsM,5407
47
+ iplotx-1.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
48
+ iplotx-1.4.0.dist-info/RECORD,,
File without changes