dendrofan 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dendrofan/__init__.py +62 -0
- dendrofan/_version.py +1 -0
- dendrofan/annotations.py +109 -0
- dendrofan/clustering.py +197 -0
- dendrofan/exceptions.py +17 -0
- dendrofan/geometry.py +218 -0
- dendrofan/plotting.py +314 -0
- dendrofan/py.typed +0 -0
- dendrofan/styling.py +67 -0
- dendrofan-0.1.1.dist-info/METADATA +170 -0
- dendrofan-0.1.1.dist-info/RECORD +14 -0
- dendrofan-0.1.1.dist-info/WHEEL +5 -0
- dendrofan-0.1.1.dist-info/licenses/LICENSE +21 -0
- dendrofan-0.1.1.dist-info/top_level.txt +1 -0
dendrofan/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dendrofan: circular ("fan") dendrograms for Python.
|
|
3
|
+
|
|
4
|
+
dendrofan draws dendrograms bent onto an annulus, in the visual style
|
|
5
|
+
of ``ape::plot.phylo(type = "fan")`` in R (Paradis & Schliep, 2019),
|
|
6
|
+
using only NumPy, SciPy, and Matplotlib -- no R dependency, no
|
|
7
|
+
reimplementation of clustering. It exists because
|
|
8
|
+
``scipy.cluster.hierarchy`` has no circular layout mode: the usual
|
|
9
|
+
workaround is a one-off script that manually re-projects
|
|
10
|
+
``dendrogram()``'s rectangular ``icoord``/``dcoord`` output into polar
|
|
11
|
+
coordinates. dendrofan generalizes that idea into a small, tested
|
|
12
|
+
library: a documented coordinate transform
|
|
13
|
+
(:class:`dendrofan.geometry.PolarTransform`) plus a plotting API that
|
|
14
|
+
handles the edge cases a one-off script usually skips (single-leaf and
|
|
15
|
+
two-leaf trees, all-zero merge heights, mismatched labels, partial fans
|
|
16
|
+
with an angular gap, inward vs. outward root, etc).
|
|
17
|
+
|
|
18
|
+
Quick start
|
|
19
|
+
-----------
|
|
20
|
+
>>> import numpy as np
|
|
21
|
+
>>> from dendrofan import circular_dendrogram
|
|
22
|
+
>>> rng = np.random.default_rng(0)
|
|
23
|
+
>>> data = rng.normal(size=(12, 5))
|
|
24
|
+
>>> result = circular_dendrogram(data, labels=[f"s{i}" for i in range(12)])
|
|
25
|
+
>>> result.fig.savefig("tree.png", dpi=200)
|
|
26
|
+
|
|
27
|
+
See :func:`circular_dendrogram` for the full parameter reference, and
|
|
28
|
+
the ``examples/`` directory in the source distribution for worked
|
|
29
|
+
examples, including one that reproduces a colored-by-group station
|
|
30
|
+
dendrogram end to end.
|
|
31
|
+
"""
|
|
32
|
+
from ._version import __version__
|
|
33
|
+
from .annotations import add_scale_ring, highlight_sector
|
|
34
|
+
from .clustering import DendrogramLayout, build_layout
|
|
35
|
+
from .exceptions import (
|
|
36
|
+
DegenerateTreeError,
|
|
37
|
+
DendrofanError,
|
|
38
|
+
InvalidLinkageError,
|
|
39
|
+
LabelMismatchError,
|
|
40
|
+
)
|
|
41
|
+
from .geometry import PolarTransform, arc_points, radial_label_alignment
|
|
42
|
+
from .plotting import CircularDendrogramResult, circular_dendrogram
|
|
43
|
+
from .styling import legend_handles, resolve_leaf_colors
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"__version__",
|
|
47
|
+
"circular_dendrogram",
|
|
48
|
+
"CircularDendrogramResult",
|
|
49
|
+
"build_layout",
|
|
50
|
+
"DendrogramLayout",
|
|
51
|
+
"PolarTransform",
|
|
52
|
+
"arc_points",
|
|
53
|
+
"radial_label_alignment",
|
|
54
|
+
"add_scale_ring",
|
|
55
|
+
"highlight_sector",
|
|
56
|
+
"resolve_leaf_colors",
|
|
57
|
+
"legend_handles",
|
|
58
|
+
"DendrofanError",
|
|
59
|
+
"InvalidLinkageError",
|
|
60
|
+
"LabelMismatchError",
|
|
61
|
+
"DegenerateTreeError",
|
|
62
|
+
]
|
dendrofan/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
dendrofan/annotations.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Optional decorations for a circular dendrogram: scale rings and sector highlights.
|
|
2
|
+
|
|
3
|
+
These operate on the ``ax``/``transform`` pair returned by
|
|
4
|
+
:func:`dendrofan.plotting.circular_dendrogram`, so they can be added or
|
|
5
|
+
omitted independently of the tree itself.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional, Sequence
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from matplotlib.axes import Axes
|
|
13
|
+
|
|
14
|
+
from .geometry import PolarTransform
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def add_scale_ring(
|
|
18
|
+
ax: Axes,
|
|
19
|
+
transform: PolarTransform,
|
|
20
|
+
heights: Sequence[float],
|
|
21
|
+
*,
|
|
22
|
+
color: str = "gray",
|
|
23
|
+
linewidth: float = 0.6,
|
|
24
|
+
linestyle: str = "--",
|
|
25
|
+
label_heights: bool = True,
|
|
26
|
+
label_fontsize: float = 8,
|
|
27
|
+
label_angle: float = 90.0,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Draw concentric guide circles at the given linkage heights.
|
|
30
|
+
|
|
31
|
+
The circular analogue of the distance axis ``ape::axisPhylo()``
|
|
32
|
+
draws on a rectangular/fan tree: a light dashed ring at each height
|
|
33
|
+
in ``heights``, optionally labeled with the height value, so
|
|
34
|
+
readers can gauge merge distances even though a circular layout
|
|
35
|
+
has no literal axis line to read off.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
heights : sequence of float
|
|
40
|
+
Linkage heights (in the original units of the tree, i.e. of
|
|
41
|
+
``dcoord`` / the linkage matrix) at which to draw a ring.
|
|
42
|
+
Heights outside ``[0, transform.y_max]`` are skipped.
|
|
43
|
+
label_angle : float, default 90
|
|
44
|
+
Angle, in degrees, at which each ring's height label is placed
|
|
45
|
+
(90 = top of the circle, matching ``start_angle``'s default).
|
|
46
|
+
"""
|
|
47
|
+
theta = np.radians(label_angle)
|
|
48
|
+
for h in heights:
|
|
49
|
+
if h < 0 or h > transform.y_max:
|
|
50
|
+
continue
|
|
51
|
+
r = float(transform.radius(h))
|
|
52
|
+
circle = np.linspace(0, 2 * np.pi, 200)
|
|
53
|
+
ax.plot(
|
|
54
|
+
r * np.cos(circle),
|
|
55
|
+
r * np.sin(circle),
|
|
56
|
+
color=color,
|
|
57
|
+
linewidth=linewidth,
|
|
58
|
+
linestyle=linestyle,
|
|
59
|
+
zorder=0,
|
|
60
|
+
)
|
|
61
|
+
if label_heights:
|
|
62
|
+
ax.text(
|
|
63
|
+
r * np.cos(theta),
|
|
64
|
+
r * np.sin(theta),
|
|
65
|
+
f"{h:g}",
|
|
66
|
+
fontsize=label_fontsize,
|
|
67
|
+
color=color,
|
|
68
|
+
ha="center",
|
|
69
|
+
va="bottom",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def highlight_sector(
|
|
74
|
+
ax: Axes,
|
|
75
|
+
transform: PolarTransform,
|
|
76
|
+
theta_start: float,
|
|
77
|
+
theta_end: float,
|
|
78
|
+
*,
|
|
79
|
+
r_inner: Optional[float] = None,
|
|
80
|
+
r_outer: Optional[float] = None,
|
|
81
|
+
color: str = "gray",
|
|
82
|
+
alpha: float = 0.15,
|
|
83
|
+
n_points: int = 100,
|
|
84
|
+
zorder: float = -1,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Shade a wedge of the plot behind a clade, the way ``ape``'s clade
|
|
87
|
+
highlighting (e.g. via ``ape::ring`` or manually drawn background
|
|
88
|
+
wedges) marks a group of related leaves.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
theta_start, theta_end : float
|
|
93
|
+
Angular bounds of the wedge, in radians (e.g. from
|
|
94
|
+
``CircularDendrogramResult.leaf_theta_by_index``).
|
|
95
|
+
r_inner, r_outer : float, optional
|
|
96
|
+
Radial bounds of the wedge. Default to
|
|
97
|
+
``transform.inner_radius`` and ``transform.outer_radius``
|
|
98
|
+
respectively, i.e. the whole tree depth.
|
|
99
|
+
"""
|
|
100
|
+
r_inner = transform.inner_radius if r_inner is None else r_inner
|
|
101
|
+
r_outer = transform.outer_radius if r_outer is None else r_outer
|
|
102
|
+
|
|
103
|
+
thetas = np.linspace(theta_start, theta_end, n_points)
|
|
104
|
+
outer_x, outer_y = r_outer * np.cos(thetas), r_outer * np.sin(thetas)
|
|
105
|
+
inner_x, inner_y = r_inner * np.cos(thetas[::-1]), r_inner * np.sin(thetas[::-1])
|
|
106
|
+
|
|
107
|
+
xs = np.concatenate([outer_x, inner_x])
|
|
108
|
+
ys = np.concatenate([outer_y, inner_y])
|
|
109
|
+
ax.fill(xs, ys, color=color, alpha=alpha, linewidth=0, zorder=zorder)
|
dendrofan/clustering.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Turn raw data, a condensed distance vector, or a precomputed linkage
|
|
3
|
+
matrix into the rectangular-dendrogram layout that :mod:`dendrofan.geometry`
|
|
4
|
+
bends into a circle.
|
|
5
|
+
|
|
6
|
+
This module is a thin, validating wrapper around
|
|
7
|
+
``scipy.cluster.hierarchy`` -- dendrofan does not reimplement clustering,
|
|
8
|
+
it only makes the SciPy output safe and convenient to consume for
|
|
9
|
+
circular rendering.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
from typing import List, Optional, Sequence, Union
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from scipy.cluster.hierarchy import dendrogram as _scipy_dendrogram
|
|
18
|
+
from scipy.cluster.hierarchy import is_valid_linkage, linkage as _scipy_linkage
|
|
19
|
+
from scipy.spatial.distance import is_valid_y, pdist
|
|
20
|
+
|
|
21
|
+
from .exceptions import DegenerateTreeError, InvalidLinkageError, LabelMismatchError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclasses.dataclass
|
|
25
|
+
class DendrogramLayout:
|
|
26
|
+
"""Rectangular-coordinate layout of a dendrogram, ready to be bent into a circle.
|
|
27
|
+
|
|
28
|
+
This mirrors (a validated, typed subset of) the dict returned by
|
|
29
|
+
``scipy.cluster.hierarchy.dendrogram``.
|
|
30
|
+
|
|
31
|
+
Attributes
|
|
32
|
+
----------
|
|
33
|
+
Z : ndarray
|
|
34
|
+
The linkage matrix underlying the tree.
|
|
35
|
+
icoord, dcoord : ndarray
|
|
36
|
+
Per-merge x (leaf-axis) and y (height-axis) coordinates, shape
|
|
37
|
+
``(n_merges, 4)``, exactly as returned by SciPy: for merge *i*,
|
|
38
|
+
``(icoord[i, 0], dcoord[i, 0])`` and ``(icoord[i, 3], dcoord[i, 3])``
|
|
39
|
+
are the two outer (leaf-ward) corners of the merge's "U" shape,
|
|
40
|
+
and ``dcoord[i, 1] == dcoord[i, 2]`` is the merge height.
|
|
41
|
+
leaves : list of int
|
|
42
|
+
Original observation indices, in left-to-right leaf order.
|
|
43
|
+
leaf_labels : list of str
|
|
44
|
+
Display labels, in the same left-to-right order as ``leaves``.
|
|
45
|
+
color_list : list of str
|
|
46
|
+
Per-merge color, as assigned by SciPy's ``color_threshold`` /
|
|
47
|
+
``link_color_func`` machinery (one entry per row of ``icoord``).
|
|
48
|
+
n_leaves : int
|
|
49
|
+
Number of leaves in the tree.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
Z: np.ndarray
|
|
53
|
+
icoord: np.ndarray
|
|
54
|
+
dcoord: np.ndarray
|
|
55
|
+
leaves: List[int]
|
|
56
|
+
leaf_labels: List[str]
|
|
57
|
+
color_list: List[str]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def n_leaves(self) -> int:
|
|
61
|
+
return len(self.leaves)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def x_min(self) -> float:
|
|
65
|
+
return float(self.icoord.min())
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def x_max(self) -> float:
|
|
69
|
+
return float(self.icoord.max())
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def y_max(self) -> float:
|
|
73
|
+
return float(self.dcoord.max())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
ArrayLike = Union[np.ndarray, Sequence[Sequence[float]]]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate_labels(labels: Optional[Sequence[str]], n_leaves: int) -> List[str]:
|
|
80
|
+
if labels is None:
|
|
81
|
+
return [str(i) for i in range(n_leaves)]
|
|
82
|
+
labels = list(labels)
|
|
83
|
+
if len(labels) != n_leaves:
|
|
84
|
+
raise LabelMismatchError(
|
|
85
|
+
f"got {len(labels)} labels for {n_leaves} leaves; "
|
|
86
|
+
"labels must have exactly one entry per observation"
|
|
87
|
+
)
|
|
88
|
+
return labels
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_layout(
|
|
92
|
+
data: Optional[ArrayLike] = None,
|
|
93
|
+
*,
|
|
94
|
+
Z: Optional[np.ndarray] = None,
|
|
95
|
+
condensed_distances: Optional[ArrayLike] = None,
|
|
96
|
+
labels: Optional[Sequence[str]] = None,
|
|
97
|
+
metric: str = "euclidean",
|
|
98
|
+
method: str = "ward",
|
|
99
|
+
color_threshold: Optional[float] = None,
|
|
100
|
+
link_color_func=None,
|
|
101
|
+
truncate_mode: Optional[str] = None,
|
|
102
|
+
p: int = 30,
|
|
103
|
+
) -> DendrogramLayout:
|
|
104
|
+
"""Build a validated :class:`DendrogramLayout` from one of three inputs.
|
|
105
|
+
|
|
106
|
+
Exactly one of ``data``, ``Z``, or ``condensed_distances`` should be
|
|
107
|
+
given:
|
|
108
|
+
|
|
109
|
+
- ``data``: an ``(n_observations, n_features)`` array of raw
|
|
110
|
+
observations. Pairwise distances (``metric``) and linkage
|
|
111
|
+
(``method``) are computed for you, mirroring what the original
|
|
112
|
+
ad hoc script did with ``pdist`` + ``linkage``.
|
|
113
|
+
- ``Z``: a precomputed linkage matrix (e.g. your own call to
|
|
114
|
+
``scipy.cluster.hierarchy.linkage``, or ``R``'s ``hclust`` output
|
|
115
|
+
converted with ``scipy``-compatible tooling). Validated with
|
|
116
|
+
``scipy.cluster.hierarchy.is_valid_linkage`` before use.
|
|
117
|
+
- ``condensed_distances``: a precomputed condensed distance vector
|
|
118
|
+
(as returned by ``scipy.spatial.distance.pdist``); ``method`` is
|
|
119
|
+
applied to it to obtain ``Z``.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
labels : sequence of str, optional
|
|
124
|
+
One label per leaf, in the same order as the rows of ``data``
|
|
125
|
+
(or the original observations behind ``Z`` /
|
|
126
|
+
``condensed_distances``). Defaults to stringified indices.
|
|
127
|
+
color_threshold, link_color_func, truncate_mode, p
|
|
128
|
+
Passed straight through to ``scipy.cluster.hierarchy.dendrogram``;
|
|
129
|
+
see its docstring. Use ``color_threshold`` to have SciPy assign
|
|
130
|
+
per-cluster colors automatically (a common way to visually mark
|
|
131
|
+
clades below a chosen cut height).
|
|
132
|
+
|
|
133
|
+
Raises
|
|
134
|
+
------
|
|
135
|
+
DendrofanError
|
|
136
|
+
If none or more than one of ``data``/``Z``/``condensed_distances``
|
|
137
|
+
is given, if a given ``Z`` fails SciPy's validity check, if
|
|
138
|
+
``labels`` does not have one entry per leaf, or if the resulting
|
|
139
|
+
tree has fewer than 2 leaves (nothing meaningful to draw).
|
|
140
|
+
"""
|
|
141
|
+
n_inputs_given = sum(x is not None for x in (data, Z, condensed_distances))
|
|
142
|
+
if n_inputs_given != 1:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"pass exactly one of `data`, `Z`, or `condensed_distances` "
|
|
145
|
+
f"(got {n_inputs_given})"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if data is not None:
|
|
149
|
+
data = np.asarray(data, dtype=float)
|
|
150
|
+
if data.ndim != 2:
|
|
151
|
+
raise ValueError("`data` must be a 2-D array (n_observations, n_features)")
|
|
152
|
+
if data.shape[0] < 2:
|
|
153
|
+
raise DegenerateTreeError(
|
|
154
|
+
f"need at least 2 observations to build a tree, got {data.shape[0]}"
|
|
155
|
+
)
|
|
156
|
+
condensed = pdist(data, metric=metric)
|
|
157
|
+
Z = _scipy_linkage(condensed, method=method)
|
|
158
|
+
elif condensed_distances is not None:
|
|
159
|
+
condensed = np.asarray(condensed_distances, dtype=float)
|
|
160
|
+
if not is_valid_y(condensed):
|
|
161
|
+
raise InvalidLinkageError(
|
|
162
|
+
"`condensed_distances` is not a valid condensed distance vector "
|
|
163
|
+
"(check its length matches n*(n-1)/2 for some integer n)"
|
|
164
|
+
)
|
|
165
|
+
Z = _scipy_linkage(condensed, method=method)
|
|
166
|
+
else:
|
|
167
|
+
Z = np.asarray(Z, dtype=float)
|
|
168
|
+
if not is_valid_linkage(Z):
|
|
169
|
+
raise InvalidLinkageError(
|
|
170
|
+
"`Z` is not a valid SciPy linkage matrix; see "
|
|
171
|
+
"scipy.cluster.hierarchy.is_valid_linkage for the required format"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
n_leaves = Z.shape[0] + 1
|
|
175
|
+
if n_leaves < 2:
|
|
176
|
+
raise DegenerateTreeError("need at least 2 leaves to build a tree")
|
|
177
|
+
|
|
178
|
+
resolved_labels = _validate_labels(labels, n_leaves)
|
|
179
|
+
|
|
180
|
+
dendro = _scipy_dendrogram(
|
|
181
|
+
Z,
|
|
182
|
+
labels=resolved_labels,
|
|
183
|
+
no_plot=True,
|
|
184
|
+
color_threshold=color_threshold,
|
|
185
|
+
link_color_func=link_color_func,
|
|
186
|
+
truncate_mode=truncate_mode,
|
|
187
|
+
p=p,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return DendrogramLayout(
|
|
191
|
+
Z=Z,
|
|
192
|
+
icoord=np.asarray(dendro["icoord"], dtype=float),
|
|
193
|
+
dcoord=np.asarray(dendro["dcoord"], dtype=float),
|
|
194
|
+
leaves=list(dendro["leaves"]),
|
|
195
|
+
leaf_labels=list(dendro["ivl"]),
|
|
196
|
+
color_list=list(dendro["color_list"]),
|
|
197
|
+
)
|
dendrofan/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exceptions raised by dendrofan."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DendrofanError(Exception):
|
|
5
|
+
"""Base class for all errors raised by dendrofan."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidLinkageError(DendrofanError):
|
|
9
|
+
"""Raised when a linkage matrix fails SciPy's own consistency checks."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LabelMismatchError(DendrofanError):
|
|
13
|
+
"""Raised when the number of labels does not match the number of leaves."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DegenerateTreeError(DendrofanError):
|
|
17
|
+
"""Raised when a tree has too few leaves to be drawn (n < 2)."""
|
dendrofan/geometry.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core polar-coordinate geometry for circular ("fan") dendrograms.
|
|
3
|
+
|
|
4
|
+
This module contains the one genuinely reusable idea behind a circular
|
|
5
|
+
dendrogram: a pair of coordinate maps that take the rectangular
|
|
6
|
+
coordinates produced by ``scipy.cluster.hierarchy.dendrogram`` --
|
|
7
|
+
``icoord`` (leaf positions along x) and ``dcoord`` (linkage heights along
|
|
8
|
+
y) -- and bend them onto an annulus.
|
|
9
|
+
|
|
10
|
+
Everything else in dendrofan (clustering, styling, plotting) builds on
|
|
11
|
+
top of :class:`PolarTransform`.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import math
|
|
17
|
+
from typing import Callable, Tuple, Union
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
RadiusScaleName = str # "linear" or "sqrt"
|
|
22
|
+
RadiusScale = Union[RadiusScaleName, Callable[[np.ndarray], np.ndarray]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclasses.dataclass(frozen=True)
|
|
26
|
+
class PolarTransform:
|
|
27
|
+
"""Maps rectangular dendrogram coordinates onto an annulus.
|
|
28
|
+
|
|
29
|
+
A rectangular dendrogram, as produced by
|
|
30
|
+
``scipy.cluster.hierarchy.dendrogram``, lives on a plane where the
|
|
31
|
+
x-axis enumerates leaves (evenly spaced, 10 units apart by SciPy's
|
|
32
|
+
convention) and the y-axis is the linkage/merge height, with 0 at
|
|
33
|
+
the leaves and increasing towards the root.
|
|
34
|
+
|
|
35
|
+
This class re-maps that plane onto polar coordinates:
|
|
36
|
+
|
|
37
|
+
- x (leaf position) -> theta (angle), so leaves spread around a
|
|
38
|
+
circle instead of sitting on a line.
|
|
39
|
+
- y (linkage height) -> r (radius), so the root sits near the
|
|
40
|
+
centre and leaves sit on the outer rim (or the reverse, if
|
|
41
|
+
``invert_radius=False``).
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
x_min, x_max : float
|
|
46
|
+
The extent of the x-axis in the source rectangular dendrogram
|
|
47
|
+
(``icoord.min()`` / ``icoord.max()``).
|
|
48
|
+
y_max : float
|
|
49
|
+
The maximum linkage height in the source dendrogram
|
|
50
|
+
(``dcoord.max()``). If this is ``0`` (a degenerate tree where
|
|
51
|
+
every merge happens at height 0), the transform falls back to
|
|
52
|
+
placing every internal node at the outer radius instead of
|
|
53
|
+
raising a ZeroDivisionError.
|
|
54
|
+
start_angle : float, default 90
|
|
55
|
+
Angle, in degrees, of the *first* leaf, measured counter-
|
|
56
|
+
clockwise from the positive x-axis (standard math convention).
|
|
57
|
+
90 puts the first leaf pointing straight up, matching the
|
|
58
|
+
default orientation used by ``ape::plot.phylo(type = "fan")``.
|
|
59
|
+
span : float, default 360
|
|
60
|
+
Total angular extent, in degrees, that the leaves are spread
|
|
61
|
+
across. Use a value below 360 (e.g. 350) to leave an angular
|
|
62
|
+
gap between the first and last leaf, which is useful when
|
|
63
|
+
labels would otherwise collide at the seam -- this mirrors the
|
|
64
|
+
partial-fan idiom available in ``ape``.
|
|
65
|
+
clockwise : bool, default True
|
|
66
|
+
If True, leaves are laid out clockwise from ``start_angle``
|
|
67
|
+
(the conventional reading direction for circular dendrograms
|
|
68
|
+
and the ``ape`` default). If False, they are laid out counter-
|
|
69
|
+
clockwise.
|
|
70
|
+
inner_radius : float, default 0.0
|
|
71
|
+
Radius of the root (or of the centre, if ``inner_radius`` is 0).
|
|
72
|
+
A small positive value reproduces the central gap conventional
|
|
73
|
+
in ``ape``'s fan style and avoids a cluttered centre.
|
|
74
|
+
outer_radius : float, default 1.0
|
|
75
|
+
Radius of the leaves.
|
|
76
|
+
invert_radius : bool, default True
|
|
77
|
+
If True (default), y=0 (leaves) maps to ``outer_radius`` and
|
|
78
|
+
y=y_max (root) maps to ``inner_radius`` -- the usual dendrogram
|
|
79
|
+
reading, root at the centre. If False, the mapping is reversed
|
|
80
|
+
(root at the rim) -- rarely useful, provided for completeness.
|
|
81
|
+
radius_scale : {"linear", "sqrt"} or callable, default "linear"
|
|
82
|
+
How linkage height is mapped to radius.
|
|
83
|
+
|
|
84
|
+
- ``"linear"``: radius is a linear function of height. This
|
|
85
|
+
matches the original ad hoc script and is the correct choice
|
|
86
|
+
when the *radial distance* should be read quantitatively
|
|
87
|
+
(e.g. with a distance-scale ring).
|
|
88
|
+
- ``"sqrt"``: radius is proportional to the square root of the
|
|
89
|
+
(rescaled) height. Because the area of an annulus grows with
|
|
90
|
+
the square of its radius, a linear height mapping visually
|
|
91
|
+
compresses shallow, near-root merges into a tiny central
|
|
92
|
+
area and exaggerates the area given to merges near the rim.
|
|
93
|
+
The square-root mapping keeps the *area* allotted to a given
|
|
94
|
+
range of merge heights roughly constant across radii, which
|
|
95
|
+
is often more legible for trees with many leaves and a long
|
|
96
|
+
tail of shallow merges. This is a purely cosmetic remapping
|
|
97
|
+
and should not be used if radial distances will be read
|
|
98
|
+
quantitatively.
|
|
99
|
+
- a callable ``f(y_norm) -> r_norm`` mapping normalised height
|
|
100
|
+
in [0, 1] to normalised radius in [0, 1], for full control.
|
|
101
|
+
|
|
102
|
+
Notes
|
|
103
|
+
-----
|
|
104
|
+
All angles are handled internally in radians; degrees are only
|
|
105
|
+
used at the public boundary (``start_angle``, ``span``) because
|
|
106
|
+
that is what most users reason in.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
x_min: float
|
|
110
|
+
x_max: float
|
|
111
|
+
y_max: float
|
|
112
|
+
start_angle: float = 90.0
|
|
113
|
+
span: float = 360.0
|
|
114
|
+
clockwise: bool = True
|
|
115
|
+
inner_radius: float = 0.0
|
|
116
|
+
outer_radius: float = 1.0
|
|
117
|
+
invert_radius: bool = True
|
|
118
|
+
radius_scale: RadiusScale = "linear"
|
|
119
|
+
|
|
120
|
+
def __post_init__(self) -> None:
|
|
121
|
+
if self.x_max < self.x_min:
|
|
122
|
+
raise ValueError("x_max must be >= x_min")
|
|
123
|
+
if self.y_max < 0:
|
|
124
|
+
raise ValueError("y_max must be >= 0")
|
|
125
|
+
if not (0 < self.span <= 360):
|
|
126
|
+
raise ValueError("span must be in (0, 360]")
|
|
127
|
+
if self.outer_radius <= self.inner_radius:
|
|
128
|
+
raise ValueError("outer_radius must be > inner_radius")
|
|
129
|
+
if self.inner_radius < 0:
|
|
130
|
+
raise ValueError("inner_radius must be >= 0")
|
|
131
|
+
|
|
132
|
+
# -- angle ---------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def theta(self, x):
|
|
135
|
+
"""Map rectangular x-coordinate(s) (leaf axis) to angle(s), in radians."""
|
|
136
|
+
x = np.asarray(x, dtype=float)
|
|
137
|
+
x_range = self.x_max - self.x_min
|
|
138
|
+
if x_range == 0:
|
|
139
|
+
# A single leaf: park it at start_angle.
|
|
140
|
+
frac = np.zeros_like(x)
|
|
141
|
+
else:
|
|
142
|
+
frac = (x - self.x_min) / x_range
|
|
143
|
+
span_rad = math.radians(self.span)
|
|
144
|
+
start_rad = math.radians(self.start_angle)
|
|
145
|
+
signed_span = -span_rad if self.clockwise else span_rad
|
|
146
|
+
return start_rad + frac * signed_span
|
|
147
|
+
|
|
148
|
+
# -- radius ----------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def radius(self, y):
|
|
151
|
+
"""Map rectangular y-coordinate(s) (linkage height) to radius/radii."""
|
|
152
|
+
y = np.asarray(y, dtype=float)
|
|
153
|
+
if self.y_max == 0:
|
|
154
|
+
# Degenerate tree: every merge happens at height 0. There is
|
|
155
|
+
# no meaningful radial ordering, so place every node at the
|
|
156
|
+
# leaf radius rather than dividing by zero.
|
|
157
|
+
y_norm = np.zeros_like(y)
|
|
158
|
+
else:
|
|
159
|
+
y_norm = np.clip(y / self.y_max, 0.0, 1.0)
|
|
160
|
+
|
|
161
|
+
r_norm = self._apply_radius_scale(y_norm)
|
|
162
|
+
|
|
163
|
+
if self.invert_radius:
|
|
164
|
+
r_norm = 1.0 - r_norm
|
|
165
|
+
|
|
166
|
+
return self.inner_radius + (self.outer_radius - self.inner_radius) * r_norm
|
|
167
|
+
|
|
168
|
+
def _apply_radius_scale(self, y_norm):
|
|
169
|
+
scale = self.radius_scale
|
|
170
|
+
if callable(scale):
|
|
171
|
+
return scale(y_norm)
|
|
172
|
+
if scale == "linear":
|
|
173
|
+
return y_norm
|
|
174
|
+
if scale == "sqrt":
|
|
175
|
+
return np.sqrt(y_norm)
|
|
176
|
+
raise ValueError(
|
|
177
|
+
f"radius_scale must be 'linear', 'sqrt', or a callable, got {scale!r}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# -- combined --------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def to_cartesian(self, x, y):
|
|
183
|
+
"""Map rectangular (x, y) dendrogram coordinates straight to (X, Y)."""
|
|
184
|
+
t = self.theta(x)
|
|
185
|
+
r = self.radius(y)
|
|
186
|
+
return r * np.cos(t), r * np.sin(t)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def arc_points(transform, x_start, x_end, y, n_points=50):
|
|
190
|
+
"""Cartesian points tracing a constant-radius arc between two leaf positions.
|
|
191
|
+
|
|
192
|
+
Used to draw the horizontal bar of a dendrogram merge (a straight
|
|
193
|
+
line in rectangular coordinates) as a properly curved arc once
|
|
194
|
+
bent onto the annulus.
|
|
195
|
+
"""
|
|
196
|
+
xs = np.linspace(x_start, x_end, n_points)
|
|
197
|
+
ys = np.full(n_points, y)
|
|
198
|
+
return transform.to_cartesian(xs, ys)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def radial_label_alignment(theta_rad: float) -> Tuple[float, str]:
|
|
202
|
+
"""Rotation (degrees) and horizontal alignment for a label at angle ``theta_rad``.
|
|
203
|
+
|
|
204
|
+
Labels placed just outside the rim of a circular dendrogram should
|
|
205
|
+
read left-to-right when possible instead of upside down. This
|
|
206
|
+
returns the text rotation (in degrees, as expected by
|
|
207
|
+
``matplotlib``'s ``rotation`` kwarg with ``rotation_mode="anchor"``)
|
|
208
|
+
and the horizontal alignment (``"left"`` or ``"right"``) so that
|
|
209
|
+
text on the left half of the circle is flipped 180 degrees and
|
|
210
|
+
right-aligned, keeping it upright and pointing away from the tree.
|
|
211
|
+
"""
|
|
212
|
+
angle_deg = math.degrees(theta_rad) % 360.0
|
|
213
|
+
# Normalise to (-180, 180] for the upright/flipped test below.
|
|
214
|
+
signed_deg = angle_deg if angle_deg <= 180 else angle_deg - 360
|
|
215
|
+
if -90 <= signed_deg <= 90:
|
|
216
|
+
return signed_deg, "left"
|
|
217
|
+
flipped = signed_deg + 180 if signed_deg < 0 else signed_deg - 180
|
|
218
|
+
return flipped, "right"
|
dendrofan/plotting.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Public plotting API: draw a circular ("fan") dendrogram."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
6
|
+
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import numpy as np
|
|
9
|
+
from matplotlib.axes import Axes
|
|
10
|
+
from matplotlib.figure import Figure
|
|
11
|
+
|
|
12
|
+
from .clustering import ArrayLike, DendrogramLayout, build_layout
|
|
13
|
+
from .geometry import PolarTransform, arc_points, radial_label_alignment
|
|
14
|
+
from .styling import ColorMap, resolve_leaf_colors
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclasses.dataclass
|
|
18
|
+
class CircularDendrogramResult:
|
|
19
|
+
"""Everything a caller might need to annotate a circular dendrogram further.
|
|
20
|
+
|
|
21
|
+
Attributes
|
|
22
|
+
----------
|
|
23
|
+
fig, ax : matplotlib Figure / Axes
|
|
24
|
+
The figure and axes the tree was drawn on.
|
|
25
|
+
layout : DendrogramLayout
|
|
26
|
+
The rectangular-dendrogram layout the tree was built from.
|
|
27
|
+
transform : PolarTransform
|
|
28
|
+
The polar transform used, so callers can map their own extra
|
|
29
|
+
rectangular coordinates (or additional linkage heights) into
|
|
30
|
+
the same coordinate system, e.g. to add a scale ring with
|
|
31
|
+
:func:`dendrofan.annotations.add_scale_ring`.
|
|
32
|
+
leaf_theta : dict of {str: float}
|
|
33
|
+
Angle (radians) of each leaf label's centre, keyed by the
|
|
34
|
+
(possibly duplicated) display label as drawn. For unique
|
|
35
|
+
per-leaf angles regardless of label text, use ``leaf_theta_by_index``.
|
|
36
|
+
leaf_theta_by_index : list of float
|
|
37
|
+
Angle (radians) of each leaf, in the same left-to-right order
|
|
38
|
+
as ``layout.leaf_labels`` / ``layout.leaves``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
fig: Figure
|
|
42
|
+
ax: Axes
|
|
43
|
+
layout: DendrogramLayout
|
|
44
|
+
transform: PolarTransform
|
|
45
|
+
leaf_theta: Dict[str, float]
|
|
46
|
+
leaf_theta_by_index: List[float]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def circular_dendrogram(
|
|
50
|
+
data: Optional[ArrayLike] = None,
|
|
51
|
+
*,
|
|
52
|
+
Z: Optional[np.ndarray] = None,
|
|
53
|
+
condensed_distances: Optional[ArrayLike] = None,
|
|
54
|
+
labels: Optional[Sequence[str]] = None,
|
|
55
|
+
metric: str = "euclidean",
|
|
56
|
+
method: str = "ward",
|
|
57
|
+
color_threshold: Optional[float] = None,
|
|
58
|
+
link_color_func=None,
|
|
59
|
+
truncate_mode: Optional[str] = None,
|
|
60
|
+
p: int = 30,
|
|
61
|
+
ax: Optional[Axes] = None,
|
|
62
|
+
figsize: Tuple[float, float] = (10, 10),
|
|
63
|
+
start_angle: float = 90.0,
|
|
64
|
+
span: float = 360.0,
|
|
65
|
+
clockwise: bool = True,
|
|
66
|
+
inner_radius: float = 0.12,
|
|
67
|
+
outer_radius: float = 1.0,
|
|
68
|
+
radius_scale: str = "linear",
|
|
69
|
+
branch_color: Optional[str] = "black",
|
|
70
|
+
branch_linewidth: float = 1.0,
|
|
71
|
+
n_arc_points: int = 50,
|
|
72
|
+
show_leaf_labels: bool = True,
|
|
73
|
+
label_colors: Optional[ColorMap] = None,
|
|
74
|
+
label_default_color: str = "black",
|
|
75
|
+
label_formatter=None,
|
|
76
|
+
label_fontsize: float = 10,
|
|
77
|
+
label_offset: float = 0.04,
|
|
78
|
+
label_fontstyle: str = "normal",
|
|
79
|
+
label_fontweight: str = "normal",
|
|
80
|
+
leaf_marker: Optional[str] = None,
|
|
81
|
+
leaf_marker_size: float = 20,
|
|
82
|
+
) -> CircularDendrogramResult:
|
|
83
|
+
"""Draw a circular ("fan"/"ape"-style) dendrogram.
|
|
84
|
+
|
|
85
|
+
This is the circular analogue of
|
|
86
|
+
``scipy.cluster.hierarchy.dendrogram``: it takes the same kinds of
|
|
87
|
+
inputs (raw observations, a condensed distance vector, or a
|
|
88
|
+
precomputed linkage matrix), builds the same tree, and draws it
|
|
89
|
+
bent onto an annulus instead of a rectangle -- in the visual style
|
|
90
|
+
of ``ape::plot.phylo(type = "fan")`` in R (Paradis & Schliep, 2019),
|
|
91
|
+
computed and rendered entirely with SciPy and Matplotlib.
|
|
92
|
+
|
|
93
|
+
Exactly one of ``data``, ``Z``, or ``condensed_distances`` must be
|
|
94
|
+
given; see :func:`dendrofan.clustering.build_layout` for details on
|
|
95
|
+
each. All clustering/tree-building parameters
|
|
96
|
+
(``metric``, ``method``, ``color_threshold``, ``link_color_func``,
|
|
97
|
+
``truncate_mode``, ``p``) are forwarded there unchanged.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
labels : sequence of str, optional
|
|
102
|
+
One label per leaf/observation. Defaults to stringified indices.
|
|
103
|
+
ax : matplotlib Axes, optional
|
|
104
|
+
Axes to draw on. If omitted, a new square figure is created.
|
|
105
|
+
The axes are always plain Cartesian (not a matplotlib polar
|
|
106
|
+
projection) so that label rotation, partial fans, and inward
|
|
107
|
+
roots are all under direct control; the polar math itself is
|
|
108
|
+
handled by :class:`dendrofan.geometry.PolarTransform` and
|
|
109
|
+
applied before anything is plotted.
|
|
110
|
+
figsize : (float, float), default (10, 10)
|
|
111
|
+
Figure size when ``ax`` is not given. Ignored if ``ax`` is given.
|
|
112
|
+
start_angle, span, clockwise, inner_radius, outer_radius, radius_scale
|
|
113
|
+
Forwarded to :class:`dendrofan.geometry.PolarTransform`; see
|
|
114
|
+
its docstring for the full explanation of each. In short:
|
|
115
|
+
``span=350`` (instead of 360) opens a gap for labels at the
|
|
116
|
+
seam, and ``radius_scale="sqrt"`` trades quantitative radial
|
|
117
|
+
distance for better legibility on trees with many leaves.
|
|
118
|
+
branch_color : str, optional
|
|
119
|
+
Color for every branch. If ``None``, each merge is colored
|
|
120
|
+
according to SciPy's own ``color_list`` (meaningful only when
|
|
121
|
+
``color_threshold`` or ``link_color_func`` is also set --
|
|
122
|
+
otherwise SciPy colors everything the same default color).
|
|
123
|
+
branch_linewidth : float, default 1.0
|
|
124
|
+
Line width for radial segments and arcs.
|
|
125
|
+
n_arc_points : int, default 50
|
|
126
|
+
Number of straight-line segments used to approximate each
|
|
127
|
+
curved merge arc. Higher values give smoother arcs at a small
|
|
128
|
+
performance cost; 50 is smooth even for large figures.
|
|
129
|
+
show_leaf_labels : bool, default True
|
|
130
|
+
If False, no leaf labels are drawn (useful when leaves will be
|
|
131
|
+
labeled by an external legend or when there are too many to
|
|
132
|
+
read individually).
|
|
133
|
+
label_colors : dict or callable, optional
|
|
134
|
+
Per-leaf label color; see :func:`dendrofan.styling.resolve_leaf_colors`.
|
|
135
|
+
Typically a ``{group_name: color}``-style dict combined with a
|
|
136
|
+
``label -> group_name`` lookup, or directly a
|
|
137
|
+
``{label: color}`` dict.
|
|
138
|
+
label_default_color : str, default "black"
|
|
139
|
+
Fallback color for labels not covered by ``label_colors``.
|
|
140
|
+
label_formatter : callable, optional
|
|
141
|
+
``label -> display_string``, applied to each leaf label right
|
|
142
|
+
before drawing (e.g. to append an annotation such as
|
|
143
|
+
``" (excl.)"``). The unmodified label is still used to look up
|
|
144
|
+
``label_colors``.
|
|
145
|
+
label_fontsize, label_fontstyle, label_fontweight
|
|
146
|
+
Passed straight through to ``ax.text``.
|
|
147
|
+
label_offset : float, default 0.04
|
|
148
|
+
Radial gap between the outer rim (``outer_radius``) and the
|
|
149
|
+
start of each leaf label, in the same units as ``outer_radius``.
|
|
150
|
+
leaf_marker : str, optional
|
|
151
|
+
Matplotlib marker style (e.g. ``"o"``) drawn at each leaf tip.
|
|
152
|
+
``None`` (default) draws no marker, matching the reference
|
|
153
|
+
figure style of the source manuscript.
|
|
154
|
+
leaf_marker_size : float, default 20
|
|
155
|
+
Marker size (``s`` in ``ax.scatter`` units), used only if
|
|
156
|
+
``leaf_marker`` is not ``None``.
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
CircularDendrogramResult
|
|
161
|
+
The figure/axes plus the layout and transform needed to add
|
|
162
|
+
further annotations (scale rings, sector highlights, etc. --
|
|
163
|
+
see :mod:`dendrofan.annotations`).
|
|
164
|
+
|
|
165
|
+
Examples
|
|
166
|
+
--------
|
|
167
|
+
>>> import numpy as np
|
|
168
|
+
>>> from dendrofan import circular_dendrogram
|
|
169
|
+
>>> rng = np.random.default_rng(0)
|
|
170
|
+
>>> data = rng.normal(size=(12, 5))
|
|
171
|
+
>>> result = circular_dendrogram(
|
|
172
|
+
... data,
|
|
173
|
+
... labels=[f"sample_{i}" for i in range(12)],
|
|
174
|
+
... span=350, # leave a small gap at the seam
|
|
175
|
+
... inner_radius=0.15,
|
|
176
|
+
... )
|
|
177
|
+
>>> result.fig.savefig("tree.png", dpi=200)
|
|
178
|
+
"""
|
|
179
|
+
layout = build_layout(
|
|
180
|
+
data=data,
|
|
181
|
+
Z=Z,
|
|
182
|
+
condensed_distances=condensed_distances,
|
|
183
|
+
labels=labels,
|
|
184
|
+
metric=metric,
|
|
185
|
+
method=method,
|
|
186
|
+
color_threshold=color_threshold,
|
|
187
|
+
link_color_func=link_color_func,
|
|
188
|
+
truncate_mode=truncate_mode,
|
|
189
|
+
p=p,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
transform = PolarTransform(
|
|
193
|
+
x_min=layout.x_min,
|
|
194
|
+
x_max=layout.x_max,
|
|
195
|
+
y_max=layout.y_max,
|
|
196
|
+
start_angle=start_angle,
|
|
197
|
+
span=span,
|
|
198
|
+
clockwise=clockwise,
|
|
199
|
+
inner_radius=inner_radius,
|
|
200
|
+
outer_radius=outer_radius,
|
|
201
|
+
radius_scale=radius_scale,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if ax is None:
|
|
205
|
+
fig, ax = plt.subplots(figsize=figsize)
|
|
206
|
+
else:
|
|
207
|
+
fig = ax.figure
|
|
208
|
+
ax.set_aspect("equal")
|
|
209
|
+
|
|
210
|
+
_draw_branches(
|
|
211
|
+
ax,
|
|
212
|
+
layout,
|
|
213
|
+
transform,
|
|
214
|
+
branch_color=branch_color,
|
|
215
|
+
branch_linewidth=branch_linewidth,
|
|
216
|
+
n_arc_points=n_arc_points,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
leaf_colors = resolve_leaf_colors(
|
|
220
|
+
layout.leaf_labels, colors=label_colors, default=label_default_color
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
leaf_theta_by_index = _leaf_angles(layout, transform)
|
|
224
|
+
|
|
225
|
+
if leaf_marker is not None:
|
|
226
|
+
xs, ys = transform.to_cartesian(
|
|
227
|
+
np.array([_leaf_x(layout, i) for i in range(layout.n_leaves)]),
|
|
228
|
+
np.zeros(layout.n_leaves),
|
|
229
|
+
)
|
|
230
|
+
ax.scatter(xs, ys, s=leaf_marker_size, marker=leaf_marker, color=leaf_colors, zorder=3)
|
|
231
|
+
|
|
232
|
+
leaf_theta: Dict[str, float] = {}
|
|
233
|
+
if show_leaf_labels:
|
|
234
|
+
for i, (label, color, theta) in enumerate(
|
|
235
|
+
zip(layout.leaf_labels, leaf_colors, leaf_theta_by_index)
|
|
236
|
+
):
|
|
237
|
+
display = label_formatter(label) if label_formatter else label
|
|
238
|
+
rotation, ha = radial_label_alignment(theta)
|
|
239
|
+
r_text = outer_radius + label_offset
|
|
240
|
+
x_txt, y_txt = r_text * np.cos(theta), r_text * np.sin(theta)
|
|
241
|
+
ax.text(
|
|
242
|
+
x_txt,
|
|
243
|
+
y_txt,
|
|
244
|
+
display,
|
|
245
|
+
rotation=rotation,
|
|
246
|
+
rotation_mode="anchor",
|
|
247
|
+
ha=ha,
|
|
248
|
+
va="center",
|
|
249
|
+
fontsize=label_fontsize,
|
|
250
|
+
fontstyle=label_fontstyle,
|
|
251
|
+
fontweight=label_fontweight,
|
|
252
|
+
color=color,
|
|
253
|
+
)
|
|
254
|
+
leaf_theta[label] = theta
|
|
255
|
+
|
|
256
|
+
margin = outer_radius * 0.85 + label_offset
|
|
257
|
+
limit = outer_radius + margin
|
|
258
|
+
ax.set_xlim(-limit, limit)
|
|
259
|
+
ax.set_ylim(-limit, limit)
|
|
260
|
+
ax.axis("off")
|
|
261
|
+
|
|
262
|
+
return CircularDendrogramResult(
|
|
263
|
+
fig=fig,
|
|
264
|
+
ax=ax,
|
|
265
|
+
layout=layout,
|
|
266
|
+
transform=transform,
|
|
267
|
+
leaf_theta=leaf_theta,
|
|
268
|
+
leaf_theta_by_index=leaf_theta_by_index,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _leaf_x(layout: DendrogramLayout, i: int) -> float:
|
|
273
|
+
"""x-coordinate (rectangular dendrogram axis) of the i-th leaf, left to right.
|
|
274
|
+
|
|
275
|
+
SciPy always spaces leaves 10 units apart starting at 5 (5, 15, 25, ...);
|
|
276
|
+
this is reconstructed rather than assumed, by anchoring to the observed
|
|
277
|
+
``icoord`` extent, so it stays correct under ``truncate_mode``.
|
|
278
|
+
"""
|
|
279
|
+
if layout.n_leaves == 1:
|
|
280
|
+
return (layout.x_min + layout.x_max) / 2.0
|
|
281
|
+
step = (layout.x_max - layout.x_min) / (layout.n_leaves - 1)
|
|
282
|
+
return layout.x_min + i * step
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _leaf_angles(layout: DendrogramLayout, transform: PolarTransform) -> List[float]:
|
|
286
|
+
xs = [_leaf_x(layout, i) for i in range(layout.n_leaves)]
|
|
287
|
+
return [float(transform.theta(x)) for x in xs]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _draw_branches(
|
|
291
|
+
ax: Axes,
|
|
292
|
+
layout: DendrogramLayout,
|
|
293
|
+
transform: PolarTransform,
|
|
294
|
+
*,
|
|
295
|
+
branch_color: Optional[str],
|
|
296
|
+
branch_linewidth: float,
|
|
297
|
+
n_arc_points: int,
|
|
298
|
+
) -> None:
|
|
299
|
+
for row, (xs, ys) in enumerate(zip(layout.icoord, layout.dcoord)):
|
|
300
|
+
x1, x2, x3, x4 = xs
|
|
301
|
+
y1, y2, y3, y4 = ys
|
|
302
|
+
color = branch_color if branch_color is not None else layout.color_list[row]
|
|
303
|
+
|
|
304
|
+
# Left radial segment: from the left child up to the merge height.
|
|
305
|
+
xr, yr = transform.to_cartesian(np.array([x1, x2]), np.array([y1, y2]))
|
|
306
|
+
ax.plot(xr, yr, color=color, linewidth=branch_linewidth)
|
|
307
|
+
|
|
308
|
+
# Right radial segment: from the right child up to the merge height.
|
|
309
|
+
xr, yr = transform.to_cartesian(np.array([x4, x3]), np.array([y4, y3]))
|
|
310
|
+
ax.plot(xr, yr, color=color, linewidth=branch_linewidth)
|
|
311
|
+
|
|
312
|
+
# Arc joining the two children at the (constant) merge height.
|
|
313
|
+
xr, yr = arc_points(transform, x2, x3, y2, n_points=n_arc_points)
|
|
314
|
+
ax.plot(xr, yr, color=color, linewidth=branch_linewidth)
|
dendrofan/py.typed
ADDED
|
File without changes
|
dendrofan/styling.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Color and label-placement helpers for circular dendrograms."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Callable, Dict, List, Optional, Sequence, Tuple, Union
|
|
5
|
+
|
|
6
|
+
ColorMap = Union[Dict[str, str], Callable[[str], str]]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_leaf_colors(
|
|
10
|
+
leaf_labels: Sequence[str],
|
|
11
|
+
colors: Optional[ColorMap] = None,
|
|
12
|
+
default: str = "black",
|
|
13
|
+
) -> List[str]:
|
|
14
|
+
"""Resolve a per-leaf color for each label in ``leaf_labels``.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
leaf_labels : sequence of str
|
|
19
|
+
Leaf labels, in left-to-right (or any) order -- one color is
|
|
20
|
+
produced per entry, in the same order.
|
|
21
|
+
colors : dict or callable, optional
|
|
22
|
+
- dict mapping label -> color. Labels missing from the dict
|
|
23
|
+
fall back to ``default`` rather than raising, since dendrograms
|
|
24
|
+
routinely mix "classified" and "unclassified" leaves (e.g. the
|
|
25
|
+
excluded stations in the source manuscript).
|
|
26
|
+
- callable ``label -> color``.
|
|
27
|
+
- ``None``: every leaf gets ``default``.
|
|
28
|
+
default : str, default "black"
|
|
29
|
+
Fallback color for labels not covered by ``colors``.
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
list of str
|
|
34
|
+
One color per entry of ``leaf_labels``.
|
|
35
|
+
"""
|
|
36
|
+
if colors is None:
|
|
37
|
+
return [default] * len(leaf_labels)
|
|
38
|
+
if callable(colors):
|
|
39
|
+
return [colors(label) for label in leaf_labels]
|
|
40
|
+
return [colors.get(label, default) for label in leaf_labels]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def legend_handles(
|
|
44
|
+
color_map: Dict[str, str], marker: str = "s", markersize: float = 10
|
|
45
|
+
) -> Tuple[list, list]:
|
|
46
|
+
"""Build ``(handles, labels)`` for ``ax.legend()`` from a ``{group: color}`` map.
|
|
47
|
+
|
|
48
|
+
Groups are emitted in the order given by ``color_map`` (a plain
|
|
49
|
+
``dict`` preserves insertion order in Python 3.7+), so callers
|
|
50
|
+
control legend ordering by controlling dict construction order.
|
|
51
|
+
"""
|
|
52
|
+
from matplotlib.lines import Line2D
|
|
53
|
+
|
|
54
|
+
handles = [
|
|
55
|
+
Line2D(
|
|
56
|
+
[0],
|
|
57
|
+
[0],
|
|
58
|
+
marker=marker,
|
|
59
|
+
linestyle="",
|
|
60
|
+
markerfacecolor=color,
|
|
61
|
+
markeredgecolor=color,
|
|
62
|
+
markersize=markersize,
|
|
63
|
+
)
|
|
64
|
+
for color in color_map.values()
|
|
65
|
+
]
|
|
66
|
+
labels = list(color_map.keys())
|
|
67
|
+
return handles, labels
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dendrofan
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Circular ("fan") dendrograms for Python, in the visual style of R's ape package
|
|
5
|
+
Author: Rafael Magallanes Quintanar
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/tiquis/dendrofan
|
|
8
|
+
Project-URL: Issues, https://github.com/tiquis/dendrofan/issues
|
|
9
|
+
Keywords: dendrogram,hierarchical-clustering,circular-plot,fan-plot,phylogenetics,data-visualization,scipy,matplotlib
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: numpy>=1.20
|
|
22
|
+
Requires-Dist: scipy>=1.7
|
|
23
|
+
Requires-Dist: matplotlib>=3.4
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-mpl; extra == "dev"
|
|
27
|
+
Requires-Dist: build; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# dendrofan
|
|
31
|
+
|
|
32
|
+
[](https://doi.org/10.5281/zenodo.21251841)
|
|
33
|
+
|
|
34
|
+
Circular ("fan") dendrograms for Python, in the visual style of R's
|
|
35
|
+
[`ape`](https://cran.r-project.org/package=ape) package
|
|
36
|
+
(`plot.phylo(type = "fan")`, Paradis & Schliep, 2019) — computed and
|
|
37
|
+
rendered entirely with NumPy, SciPy, and Matplotlib. No R dependency,
|
|
38
|
+
no reimplementation of clustering.
|
|
39
|
+
|
|
40
|
+
## Why this exists
|
|
41
|
+
|
|
42
|
+
`scipy.cluster.hierarchy` has no circular/fan layout. The usual
|
|
43
|
+
workaround is a one-off script: call
|
|
44
|
+
`scipy.cluster.hierarchy.dendrogram(..., no_plot=True)`, take its
|
|
45
|
+
rectangular `icoord`/`dcoord` output, and manually re-project it into
|
|
46
|
+
polar coordinates with Matplotlib. That works for one figure, but it
|
|
47
|
+
tends to:
|
|
48
|
+
|
|
49
|
+
- assume a full 360° circle with no gap for labels at the seam,
|
|
50
|
+
- divide by `dcoord.max()` without checking for a degenerate,
|
|
51
|
+
all-zero-height tree,
|
|
52
|
+
- hardcode SciPy's 10-units-per-leaf spacing convention,
|
|
53
|
+
- have no story for trees with 1 or 2 leaves,
|
|
54
|
+
- mix clustering, geometry, and plotting into a single script, so
|
|
55
|
+
none of it is reusable for the next dataset.
|
|
56
|
+
|
|
57
|
+
dendrofan factors the actually-reusable idea — the rectangular-to-polar
|
|
58
|
+
coordinate transform — into a small, tested, documented library, and
|
|
59
|
+
builds a real plotting API on top of it.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install -e . # from a source checkout
|
|
65
|
+
pip install -e ".[dev]" # + pytest, for running the test suite
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import numpy as np
|
|
72
|
+
from dendrofan import circular_dendrogram
|
|
73
|
+
|
|
74
|
+
rng = np.random.default_rng(0)
|
|
75
|
+
data = rng.normal(size=(20, 6))
|
|
76
|
+
labels = [f"sample_{i:02d}" for i in range(20)]
|
|
77
|
+
|
|
78
|
+
result = circular_dendrogram(
|
|
79
|
+
data,
|
|
80
|
+
labels=labels,
|
|
81
|
+
method="ward", # forwarded to scipy.cluster.hierarchy.linkage
|
|
82
|
+
span=350, # leave a small angular gap at the seam
|
|
83
|
+
inner_radius=0.15, # small central gap, ape's fan style
|
|
84
|
+
)
|
|
85
|
+
result.fig.savefig("tree.png", dpi=200, bbox_inches="tight")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`circular_dendrogram` accepts the same three kinds of input as SciPy's
|
|
89
|
+
own `dendrogram`: raw observations (`data=...`), a precomputed linkage
|
|
90
|
+
matrix (`Z=...`), or a precomputed condensed distance vector
|
|
91
|
+
(`condensed_distances=...`).
|
|
92
|
+
|
|
93
|
+
See [`examples/quickstart.py`](examples/quickstart.py) and
|
|
94
|
+
[`examples/reproduce_station_dendrogram.py`](examples/reproduce_station_dendrogram.py)
|
|
95
|
+
(a fully worked, colored-by-group example) for more.
|
|
96
|
+
|
|
97
|
+
## What it handles that the ad hoc version didn't
|
|
98
|
+
|
|
99
|
+
| Case | Ad hoc script | dendrofan |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| Angular gap at the seam (for label room) | not supported (full circle only) | `span=350` (or any value `<= 360`) |
|
|
102
|
+
| All-merge-heights-equal-zero tree | `ZeroDivisionError` / silent NaNs | falls back to placing all nodes at the leaf radius |
|
|
103
|
+
| 1 or 2 leaves | untested, breaks silently | validated; raises `DegenerateTreeError` for < 2, works for 2 |
|
|
104
|
+
| Mismatched label count | silent misalignment | raises `LabelMismatchError` |
|
|
105
|
+
| Invalid linkage matrix / distance vector | undefined behavior | raises `InvalidLinkageError` before plotting |
|
|
106
|
+
| Root at centre vs. rim, radius scale | hardcoded linear, root-at-centre | `inner_radius`/`outer_radius`/`invert_radius`, plus optional `"sqrt"` area-preserving radius scale |
|
|
107
|
+
| Per-clade coloring | manual, one-off | `color_threshold`/`link_color_func` forwarded to SciPy, or a `label_colors` dict/callable |
|
|
108
|
+
| Scale reference / clade highlighting | not present | `dendrofan.annotations.add_scale_ring`, `highlight_sector` |
|
|
109
|
+
| Reuse across datasets | copy-paste the script | one function call |
|
|
110
|
+
|
|
111
|
+
## API overview
|
|
112
|
+
|
|
113
|
+
- `dendrofan.circular_dendrogram(...)` — the main entry point; draws
|
|
114
|
+
the tree and returns a `CircularDendrogramResult` (figure, axes,
|
|
115
|
+
layout, and the `PolarTransform` used, for further annotation).
|
|
116
|
+
- `dendrofan.geometry.PolarTransform` — the reusable rectangular-to-polar
|
|
117
|
+
coordinate map, if you want to bend your own geometry onto the same
|
|
118
|
+
annulus (e.g. a custom decoration).
|
|
119
|
+
- `dendrofan.clustering.build_layout(...)` — validated wrapper around
|
|
120
|
+
`scipy.cluster.hierarchy.linkage` / `dendrogram`, decoupled from
|
|
121
|
+
plotting.
|
|
122
|
+
- `dendrofan.styling.resolve_leaf_colors`, `legend_handles` — color and
|
|
123
|
+
legend helpers.
|
|
124
|
+
- `dendrofan.annotations.add_scale_ring`, `highlight_sector` — optional
|
|
125
|
+
decorations (a distance-reference ring; shaded clade sectors).
|
|
126
|
+
|
|
127
|
+
Every public function and class has a full docstring; `help(...)` in a
|
|
128
|
+
Python session is the fastest way to see the complete parameter
|
|
129
|
+
reference.
|
|
130
|
+
|
|
131
|
+
## Scope
|
|
132
|
+
|
|
133
|
+
dendrofan draws circular dendrograms from hierarchical clustering
|
|
134
|
+
(SciPy linkage matrices) — it does not parse Newick/phylogenetic tree
|
|
135
|
+
files or handle unequal-tip-depth phylogenies the way `ape` itself
|
|
136
|
+
does. If you need that, `ape` (R) or `ete3`/`Bio.Phylo` (Python) are a
|
|
137
|
+
better fit; dendrofan specifically fills the "I have a SciPy
|
|
138
|
+
dendrogram and want it circular" gap.
|
|
139
|
+
|
|
140
|
+
## Testing
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
pytest
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The test suite specifically exercises the edge cases listed above
|
|
147
|
+
(degenerate trees, mismatched labels, invalid linkage matrices, partial
|
|
148
|
+
spans, single/two-leaf trees).
|
|
149
|
+
|
|
150
|
+
## Citation
|
|
151
|
+
|
|
152
|
+
If you use dendrofan in a manuscript, please cite the software itself
|
|
153
|
+
via its Zenodo DOI (see [`CITATION.cff`](CITATION.cff), or use GitHub's
|
|
154
|
+
"Cite this repository" button):
|
|
155
|
+
|
|
156
|
+
> Magallanes Quintanar, R. (2026). dendrofan v0.1.0 [Software]. Zenodo.
|
|
157
|
+
> https://doi.org/10.5281/zenodo.21251841
|
|
158
|
+
|
|
159
|
+
Please also cite the underlying methods it wraps:
|
|
160
|
+
|
|
161
|
+
- Virtanen, P. et al. (2020). SciPy 1.0: fundamental algorithms for
|
|
162
|
+
scientific computing in Python. *Nature Methods*, 17, 261-272.
|
|
163
|
+
*(hierarchical clustering / linkage, which dendrofan builds on)*
|
|
164
|
+
- Paradis, E. & Schliep, K. (2019). ape 5.0: an environment for modern
|
|
165
|
+
phylogenetics and evolutionary analyses in R. *Bioinformatics*,
|
|
166
|
+
35(3), 526-528. *(for the fan-plot visual convention dendrofan follows)*
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
dendrofan/__init__.py,sha256=H1Mfa-_VWHTwytHcDp5oVniZd7sWQQVLcq9u083I39c,2265
|
|
2
|
+
dendrofan/_version.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
|
|
3
|
+
dendrofan/annotations.py,sha256=t9rG79eI9KRtHt-o0x2mCLrcppnPSZ5uyJry9dL1RN8,3649
|
|
4
|
+
dendrofan/clustering.py,sha256=DrOZiKEuj-TT_xxyKIN6WhrqOLNldXvafor1e0HHY9Y,7191
|
|
5
|
+
dendrofan/exceptions.py,sha256=JrSZwNZA_ElmX4scW2ep8nSPhNpkraTgRe1-ffNjtac,490
|
|
6
|
+
dendrofan/geometry.py,sha256=bpfBDmJIq_vMjcM6BFtWGAFGDSGy3I0cm9yr2uHOUtA,9256
|
|
7
|
+
dendrofan/plotting.py,sha256=4JN2aSL5lhoGEnORVy6hw-WKMFKGXSf7kABLGkPmDxs,12115
|
|
8
|
+
dendrofan/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
dendrofan/styling.py,sha256=MhiHs7UamW9Yqmg2Jw2nIYc1rIPe9i8ZzhZT7Yz4U3w,2227
|
|
10
|
+
dendrofan-0.1.1.dist-info/licenses/LICENSE,sha256=uE3J7uh7-vMXpGN6xUIOLTQ8KSAt5OWvh8pHNWKjSBc,1079
|
|
11
|
+
dendrofan-0.1.1.dist-info/METADATA,sha256=B7f5qGD9wAwAt8crDlj6wa-nQMxEAvOTefDfJzIiOY4,7090
|
|
12
|
+
dendrofan-0.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
dendrofan-0.1.1.dist-info/top_level.txt,sha256=KmNquF3hQPPg44xSqscT6pDkVern5jU94QG4o_KKREg,10
|
|
14
|
+
dendrofan-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dendrofan contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dendrofan
|