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 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"
@@ -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)
@@ -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
+ )
@@ -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
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21251841.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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