phylustrator 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {phylustrator-0.3.0/src/phylustrator.egg-info → phylustrator-0.4.0}/PKG-INFO +2 -2
- {phylustrator-0.3.0 → phylustrator-0.4.0}/README.md +1 -1
- {phylustrator-0.3.0 → phylustrator-0.4.0}/pyproject.toml +1 -1
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/__init__.py +2 -1
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/__init__.py +2 -1
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/guides.py +86 -16
- {phylustrator-0.3.0 → phylustrator-0.4.0/src/phylustrator.egg-info}/PKG-INFO +2 -2
- {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_figure.py +148 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/LICENSE +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/setup.cfg +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/__init__.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/__main__.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/cli.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/color.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/compose.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/__init__.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/figure.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/genome.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/io.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layers/__init__.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layers/genes.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layers/guides.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layers/highlight.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layers/synteny.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/layout.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/matrix.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/panels.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/genomes/track.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/render.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/style.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/figure.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/io.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/clades.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/coloring.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/events.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/labels.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layers/tracks.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/layout.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/panels.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/skeleton.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/trees/tree.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator/zombi.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator.egg-info/SOURCES.txt +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator.egg-info/dependency_links.txt +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator.egg-info/entry_points.txt +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator.egg-info/requires.txt +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/src/phylustrator.egg-info/top_level.txt +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_below.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_cli.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_genomes.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_io.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_layout.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_tree.py +0 -0
- {phylustrator-0.3.0 → phylustrator-0.4.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phylustrator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A composable plotter for evolutionary figures: phylogenetic trees, genomes, synteny and alignments
|
|
5
5
|
Author-email: "Adrian A. Davin" <aaredav@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -74,7 +74,7 @@ That is the whole idea: `plot(tree)` starts a figure and each `+ layer` adds one
|
|
|
74
74
|
- **Layouts** — `rectangular` (default), `radial`, `unrooted`.
|
|
75
75
|
- **Layers** — `color_branches`, `color_history`, `tip_labels`, `node_labels`, `tip_track`,
|
|
76
76
|
`branch_events`, `colorbar`, `legend`, `time_axis`, `time_marker`, `scale_bar`, `note`,
|
|
77
|
-
`highlight_clade`.
|
|
77
|
+
`title`, `highlight_clade`. `legend` and `note` take a corner; `title` is centred over the panel.
|
|
78
78
|
- **Panels** — `node_points`, placed under a rectangular tree with `ph.below(tree, panel)`. The panel
|
|
79
79
|
shares the tree's time axis, so a value measured at a node sits directly under that node.
|
|
80
80
|
|
|
@@ -35,7 +35,7 @@ That is the whole idea: `plot(tree)` starts a figure and each `+ layer` adds one
|
|
|
35
35
|
- **Layouts** — `rectangular` (default), `radial`, `unrooted`.
|
|
36
36
|
- **Layers** — `color_branches`, `color_history`, `tip_labels`, `node_labels`, `tip_track`,
|
|
37
37
|
`branch_events`, `colorbar`, `legend`, `time_axis`, `time_marker`, `scale_bar`, `note`,
|
|
38
|
-
`highlight_clade`.
|
|
38
|
+
`title`, `highlight_clade`. `legend` and `note` take a corner; `title` is centred over the panel.
|
|
39
39
|
- **Panels** — `node_points`, placed under a rectangular tree with `ph.below(tree, panel)`. The panel
|
|
40
40
|
shares the tree's time axis, so a value measured at a node sits directly under that node.
|
|
41
41
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "phylustrator"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "A composable plotter for evolutionary figures: phylogenetic trees, genomes, synteny and alignments"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -26,6 +26,7 @@ from .layers import (
|
|
|
26
26
|
time_marker,
|
|
27
27
|
tip_labels,
|
|
28
28
|
tip_track,
|
|
29
|
+
title,
|
|
29
30
|
)
|
|
30
31
|
from .panels import NodePoints, node_points
|
|
31
32
|
from .tree import Node, Tree
|
|
@@ -35,7 +36,7 @@ __all__ = [
|
|
|
35
36
|
"plot", "Figure", "Geometry", "NodePos", "TipPos",
|
|
36
37
|
"color_branches", "color_history", "color_lanes", "tip_labels", "node_labels", "tip_track", "ring",
|
|
37
38
|
"rubberband",
|
|
38
|
-
"branch_events", "colorbar", "legend", "note", "time_axis", "time_marker", "scale_bar",
|
|
39
|
+
"branch_events", "colorbar", "legend", "note", "time_axis", "time_marker", "scale_bar", "title",
|
|
39
40
|
"highlight_clade",
|
|
40
41
|
"node_points", "NodePoints",
|
|
41
42
|
]
|
|
@@ -8,7 +8,7 @@ Each layer is a callable ``(canvas, tree, layout, style) -> None``. Import them
|
|
|
8
8
|
from .clades import highlight_clade
|
|
9
9
|
from .coloring import color_branches, color_history, color_lanes
|
|
10
10
|
from .events import branch_events
|
|
11
|
-
from .guides import colorbar, legend, note, scale_bar, time_axis, time_marker
|
|
11
|
+
from .guides import colorbar, legend, note, scale_bar, time_axis, time_marker, title
|
|
12
12
|
from .labels import node_labels, tip_labels
|
|
13
13
|
from .tracks import ring, rubberband, tip_track
|
|
14
14
|
|
|
@@ -28,5 +28,6 @@ __all__ = [
|
|
|
28
28
|
"time_axis",
|
|
29
29
|
"time_marker",
|
|
30
30
|
"scale_bar",
|
|
31
|
+
"title",
|
|
31
32
|
"highlight_clade",
|
|
32
33
|
]
|
|
@@ -18,6 +18,20 @@ def _clear_behind(canvas, x, y, w, h, pad: float = 6.0) -> None:
|
|
|
18
18
|
fill=_KEY_BACKDROP, stroke="none", stroke_width=0.0)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
#: The corners a pinned layer may sit in — the four ``branch_events`` has always taken.
|
|
22
|
+
_CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _corner(loc: str, who: str) -> str:
|
|
26
|
+
"""Check a corner name at the point it is written. ``"top-centre"`` used to fall through the
|
|
27
|
+
``"left" in loc`` test and print as though it had been asked for on the right, which is worse
|
|
28
|
+
than an error."""
|
|
29
|
+
if loc not in _CORNERS:
|
|
30
|
+
centred = " (a centred title is `title()`)" if "cent" in loc.lower() else ""
|
|
31
|
+
raise ValueError(f"{who}: loc must be one of {', '.join(_CORNERS)}, not {loc!r}{centred}")
|
|
32
|
+
return loc
|
|
33
|
+
|
|
34
|
+
|
|
21
35
|
def _text_width(text: str, size: float) -> float:
|
|
22
36
|
"""A serviceable width for a string at this font size — the canvas has no font metrics, and this
|
|
23
37
|
only has to be close enough to clear the tree behind it."""
|
|
@@ -64,13 +78,20 @@ def colorbar(title: str = "", *, loc: str = "top-left", width: float = 130.0, he
|
|
|
64
78
|
return layer
|
|
65
79
|
|
|
66
80
|
|
|
67
|
-
def legend(title: str = "", *,
|
|
68
|
-
entries: dict | None = None, dy: float = 0.0,
|
|
69
|
-
|
|
70
|
-
swatch
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
def legend(title: str = "", *, loc: str = "top-left", swatch: float | None = None,
|
|
82
|
+
size: float | None = None, entries: dict | None = None, dy: float = 0.0,
|
|
83
|
+
inset: float | None = None):
|
|
84
|
+
"""A category swatch list pinned to a corner — ``"top-left"`` by default, or any of the four
|
|
85
|
+
``branch_events`` takes. A rectangular tree fills the upper left of its panel, so the top left
|
|
86
|
+
is often the one corner a legend cannot share; a right corner right-aligns the block on the
|
|
87
|
+
right margin, and a bottom corner ends it on the bottom margin.
|
|
88
|
+
|
|
89
|
+
``size`` sets the label font (default the style's) and the swatch scales with it. Reads the
|
|
90
|
+
recorded categorical scale, or ``entries`` (``{label: colour}``) to draw an explicit list — a
|
|
91
|
+
figure whose scale slot is taken by a continuous ring still gets its categorical legend that
|
|
92
|
+
way. ``dy`` shifts the list down, so it can sit below a ``colorbar`` in the same corner. No-op
|
|
93
|
+
without a source of entries."""
|
|
94
|
+
_corner(loc, "legend")
|
|
74
95
|
|
|
75
96
|
def layer(canvas, tree, layout, style):
|
|
76
97
|
palette = entries
|
|
@@ -79,14 +100,22 @@ def legend(title: str = "", *, swatch: float | None = None, size: float | None =
|
|
|
79
100
|
if not scale or scale.get("kind") != "categorical":
|
|
80
101
|
return
|
|
81
102
|
palette = scale["palette"]
|
|
103
|
+
w, h = canvas.size
|
|
82
104
|
fs = size if size is not None else style.font_size
|
|
83
105
|
sw = swatch if swatch is not None else fs * 0.95
|
|
84
|
-
x = inset if inset is not None else style.margin_at("left")
|
|
85
|
-
y = (inset if inset is not None else style.margin_at("top")) + dy
|
|
86
106
|
rows = len(palette) + (1 if title else 0)
|
|
87
107
|
widest = max([_text_width(str(k), fs) for k in palette] +
|
|
88
108
|
[_text_width(title, fs) if title else 0.0])
|
|
89
|
-
|
|
109
|
+
box_w = sw + 8 + widest
|
|
110
|
+
|
|
111
|
+
def side(name):
|
|
112
|
+
return inset if inset is not None else style.margin_at(name)
|
|
113
|
+
|
|
114
|
+
# the drop from the first row's baseline to the last one's; a title row is the taller one
|
|
115
|
+
drop = (fs * 1.7 if title else 0.0) + max(len(palette) - 1, 0) * fs * 1.6
|
|
116
|
+
x = side("left") if "left" in loc else w - side("right") - box_w
|
|
117
|
+
y = (side("top") if "top" in loc else h - side("bottom") - drop - fs * 0.5) + dy
|
|
118
|
+
_clear_behind(canvas, x, y - fs, box_w, rows * fs * 1.65)
|
|
90
119
|
if title:
|
|
91
120
|
canvas.raw_text(x, y, title, anchor="start", weight="bold", size=fs)
|
|
92
121
|
y += fs * 1.7
|
|
@@ -100,15 +129,35 @@ def legend(title: str = "", *, swatch: float | None = None, size: float | None =
|
|
|
100
129
|
|
|
101
130
|
def time_marker(*times, color: str = "#444444", width: float = 1.5, dash: bool = True,
|
|
102
131
|
label: str | None = None, label_size: float | None = None):
|
|
103
|
-
"""
|
|
104
|
-
|
|
105
|
-
|
|
132
|
+
"""Reference marker(s) at the given distance/time value(s) — e.g. a mass-extinction moment or a
|
|
133
|
+
rate shift. Rectangular: a vertical line crossing the tree. Radial: a circle at that distance
|
|
134
|
+
from the centre, which is what a depth threshold looks like on a tree too big to read as a
|
|
135
|
+
rectangle. The unrooted layout places branches by angle and has no distance-from-origin axis, so
|
|
136
|
+
it raises rather than drawing nothing. ``label`` (if given) is written above the first marker.
|
|
137
|
+
|
|
138
|
+
Times come on the stem-inclusive scale of the rectangular layout, the same scale
|
|
139
|
+
``branch_events`` takes. The radial layout starts at the crown, so the stem comes off there."""
|
|
106
140
|
|
|
107
141
|
def layer(canvas, tree, layout, style):
|
|
108
|
-
if
|
|
142
|
+
ls = label_size if label_size is not None else style.font_size
|
|
143
|
+
if layout.kind == "unrooted":
|
|
144
|
+
raise ValueError("time_marker needs a layout with a distance axis, and the unrooted "
|
|
145
|
+
"layout has none: it places branches by angle, so a distance from the "
|
|
146
|
+
"origin is not a place on it. Use rectangular or radial.")
|
|
147
|
+
if layout.kind == "radial":
|
|
148
|
+
# the radial layout drops the stem and starts at the crown, so shift the times by it —
|
|
149
|
+
# branch_events shifts the same way, for the same reason
|
|
150
|
+
stem = float(tree.root.length or 0.0)
|
|
151
|
+
for i, t in enumerate(times):
|
|
152
|
+
r = t - stem
|
|
153
|
+
if r < 0:
|
|
154
|
+
raise ValueError(f"time_marker at {t:g} falls inside the root stem, which the "
|
|
155
|
+
f"radial layout does not draw: its centre is the crown, at {stem:g}")
|
|
156
|
+
canvas.data_ring(r, color, width, dash=dash)
|
|
157
|
+
if label and i == 0:
|
|
158
|
+
canvas.text(0.0, -r, label, dy=-8, anchor="middle", color=color, size=ls)
|
|
109
159
|
return
|
|
110
160
|
y0, y1 = layout.ylim
|
|
111
|
-
ls = label_size if label_size is not None else style.font_size
|
|
112
161
|
for i, t in enumerate(times):
|
|
113
162
|
canvas.line(t, y0, t, y1, color, width, dash=dash)
|
|
114
163
|
if label and i == 0:
|
|
@@ -124,7 +173,8 @@ def note(text: str, *, loc: str = "top-left", size: float | None = None,
|
|
|
124
173
|
|
|
125
174
|
``dy`` nudges it in pixels, negative up. The corner is fixed to the margin, which is the right
|
|
126
175
|
place for a note *about* the figure; a note read as a **title** wants a little more air between
|
|
127
|
-
it and the tree than a margin the tree also uses can give
|
|
176
|
+
it and the tree than a margin the tree also uses can give, so a centred title is :func:`title`."""
|
|
177
|
+
_corner(loc, "note")
|
|
128
178
|
|
|
129
179
|
def layer(canvas, tree, layout, style):
|
|
130
180
|
w, h = canvas.size
|
|
@@ -139,6 +189,26 @@ def note(text: str, *, loc: str = "top-left", size: float | None = None,
|
|
|
139
189
|
return layer
|
|
140
190
|
|
|
141
191
|
|
|
192
|
+
def title(text: str, *, size: float | None = None, dy: float = 0.0, color: str | None = None,
|
|
193
|
+
weight: str = "bold"):
|
|
194
|
+
"""A title centred over the panel, in the top margin above the tree.
|
|
195
|
+
|
|
196
|
+
``note`` pins text to a corner, which is where something *about* a figure belongs; a title
|
|
197
|
+
belongs over the middle of it. ``size`` defaults to half again the style's font, ``dy`` nudges
|
|
198
|
+
it in pixels (negative up), and the title stays on the page even on a thin top margin. Give the
|
|
199
|
+
figure a larger ``margin_top`` when the title sits too close to the tree."""
|
|
200
|
+
|
|
201
|
+
def layer(canvas, tree, layout, style):
|
|
202
|
+
w, _ = canvas.size
|
|
203
|
+
fs = size if size is not None else style.font_size * 1.4
|
|
204
|
+
# halfway up the top margin, but never so high that the page edge cuts it
|
|
205
|
+
y = max(style.margin_at("top") * 0.5, fs * 0.75) + dy
|
|
206
|
+
canvas.raw_text(w / 2, y, text, anchor="middle", size=fs,
|
|
207
|
+
color=color or style.label_color, weight=weight)
|
|
208
|
+
|
|
209
|
+
return layer
|
|
210
|
+
|
|
211
|
+
|
|
142
212
|
def scale_bar(length: float | None = None, label: str | None = None):
|
|
143
213
|
"""A short bar of a fixed distance, bottom-right — the branch-length key for any layout. Defaults
|
|
144
214
|
to a round fraction of the tree's extent. Returns a layer."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: phylustrator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A composable plotter for evolutionary figures: phylogenetic trees, genomes, synteny and alignments
|
|
5
5
|
Author-email: "Adrian A. Davin" <aaredav@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -74,7 +74,7 @@ That is the whole idea: `plot(tree)` starts a figure and each `+ layer` adds one
|
|
|
74
74
|
- **Layouts** — `rectangular` (default), `radial`, `unrooted`.
|
|
75
75
|
- **Layers** — `color_branches`, `color_history`, `tip_labels`, `node_labels`, `tip_track`,
|
|
76
76
|
`branch_events`, `colorbar`, `legend`, `time_axis`, `time_marker`, `scale_bar`, `note`,
|
|
77
|
-
`highlight_clade`.
|
|
77
|
+
`title`, `highlight_clade`. `legend` and `note` take a corner; `title` is centred over the panel.
|
|
78
78
|
- **Panels** — `node_points`, placed under a rectangular tree with `ph.below(tree, panel)`. The panel
|
|
79
79
|
shares the tree's time axis, so a value measured at a node sits directly under that node.
|
|
80
80
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""Figure: the skeleton renders to SVG, and the stem shows up as one extra branch."""
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
import re
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
|
|
8
|
+
from phylustrator import Style
|
|
7
9
|
from phylustrator.trees import (
|
|
8
10
|
branch_events,
|
|
9
11
|
color_branches,
|
|
@@ -15,6 +17,8 @@ from phylustrator.trees import (
|
|
|
15
17
|
note,
|
|
16
18
|
plot,
|
|
17
19
|
rubberband,
|
|
20
|
+
time_marker,
|
|
21
|
+
title,
|
|
18
22
|
)
|
|
19
23
|
|
|
20
24
|
|
|
@@ -231,3 +235,147 @@ def test_rubberband_survives_a_lopsided_tree_and_stays_smooth():
|
|
|
231
235
|
+ rubberband(pops, palette=palette, gap=8.0, smooth=smooth)).as_svg()
|
|
232
236
|
assert svg.count("<path") > 0
|
|
233
237
|
assert "#207080" in svg and "#c77bad" in svg
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --- time_marker across the layouts (issue #8) ------------------------------------------------
|
|
241
|
+
|
|
242
|
+
_MARKER_TREE = "((((A:1,B:1)F:1,C:2)G:1,D:3)H:1,E:4)R;"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _circle(svg):
|
|
246
|
+
"""The (cx, cy, r) of the one circle in the figure — the radial marker."""
|
|
247
|
+
found = re.findall(r'<circle cx="([-\d.]+)" cy="([-\d.]+)" r="([-\d.]+)"', svg)
|
|
248
|
+
assert len(found) == 1, f"expected one circle, found {len(found)}"
|
|
249
|
+
return tuple(float(v) for v in found[0])
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_time_marker_draws_in_rectangular_and_radial():
|
|
253
|
+
"""It rendered nothing at all in radial, and said nothing about it."""
|
|
254
|
+
tree = loads(_MARKER_TREE)
|
|
255
|
+
for layout in ("rectangular", "radial"):
|
|
256
|
+
svg = (plot(tree, layout=layout) + time_marker(2.0, color="#D55E00", width=4)).as_svg()
|
|
257
|
+
assert "#D55E00" in svg, f"the marker is missing from the {layout} layout"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_the_radial_marker_is_a_circle_through_the_nodes_at_that_distance():
|
|
261
|
+
tree = loads(_MARKER_TREE)
|
|
262
|
+
fig = plot(tree, layout="radial") + time_marker(2.0, color="#D55E00")
|
|
263
|
+
cx, cy, r = _circle(fig.as_svg())
|
|
264
|
+
at_two = [n for n in fig.geometry().nodes if n.name == "G"][0] # G sits at distance 2
|
|
265
|
+
assert math.hypot(at_two.x - cx, at_two.y - cy) == pytest.approx(r)
|
|
266
|
+
assert r > 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_the_radial_marker_takes_the_stem_off_the_time():
|
|
270
|
+
"""The radial layout starts at the crown, as branch_events already assumed for its events."""
|
|
271
|
+
tree = loads("((((A:1,B:1)F:1,C:2)G:1,D:3)H:1,E:4)R:1.5;")
|
|
272
|
+
fig = plot(tree, layout="radial") + time_marker(3.5) # 2.0 past the crown
|
|
273
|
+
cx, cy, r = _circle(fig.as_svg())
|
|
274
|
+
at_two = [n for n in fig.geometry().nodes if n.name == "G"][0]
|
|
275
|
+
assert math.hypot(at_two.x - cx, at_two.y - cy) == pytest.approx(r)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_the_radial_marker_keeps_its_dash():
|
|
279
|
+
tree = loads(_MARKER_TREE)
|
|
280
|
+
assert "stroke-dasharray" in (plot(tree, layout="radial") + time_marker(2.0)).as_svg()
|
|
281
|
+
assert "stroke-dasharray" not in (plot(tree, layout="radial")
|
|
282
|
+
+ time_marker(2.0, dash=False)).as_svg()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_time_marker_refuses_the_unrooted_layout():
|
|
286
|
+
"""A distance from the origin is not a place on an equal-angle layout. Say so, do not skip."""
|
|
287
|
+
with pytest.raises(ValueError, match="unrooted"):
|
|
288
|
+
(plot(loads(_MARKER_TREE), layout="unrooted") + time_marker(2.0)).as_svg()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_time_marker_refuses_a_time_the_radial_layout_cannot_show():
|
|
292
|
+
with pytest.raises(ValueError, match="stem"):
|
|
293
|
+
(plot(loads("((A:1,B:1)F:1,C:2)R:1.5;"), layout="radial") + time_marker(0.5)).as_svg()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# --- a centred title, and a legend in any corner (issues #9 and #10) ---------------------------
|
|
297
|
+
|
|
298
|
+
_PLACED = Style(width=400, height=300, margin=20)
|
|
299
|
+
_ENTRIES = {"a": "#111111", "b": "#222222"}
|
|
300
|
+
_CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _rect(svg, fill):
|
|
304
|
+
found = re.search(rf'<rect x="([-\d.]+)" y="([-\d.]+)" width="([-\d.]+)" height="([-\d.]+)" '
|
|
305
|
+
rf'fill="{fill}"', svg)
|
|
306
|
+
assert found, f"no rect filled {fill}"
|
|
307
|
+
return tuple(float(v) for v in found.groups())
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _swatches(svg):
|
|
311
|
+
return [_rect(svg, fill) for fill in _ENTRIES.values()]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _backdrop(svg):
|
|
315
|
+
"""The white patch the legend paints behind itself — the block's own box."""
|
|
316
|
+
boxes = re.findall(r'<rect x="([-\d.]+)" y="([-\d.]+)" width="([-\d.]+)" height="([-\d.]+)" '
|
|
317
|
+
r'fill="#ffffff"', svg)
|
|
318
|
+
boxes = [tuple(float(v) for v in b) for b in boxes if (float(b[0]), float(b[1])) != (0.0, 0.0)]
|
|
319
|
+
assert len(boxes) == 1, f"expected one backdrop, found {len(boxes)}"
|
|
320
|
+
return boxes[0]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _legend_svg(loc):
|
|
324
|
+
return (plot(loads("((A:1,B:1)C:1,D:2)R;"), style=_PLACED)
|
|
325
|
+
+ legend("state", loc=loc, entries=_ENTRIES)).as_svg()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_title_sits_centred_over_the_panel():
|
|
329
|
+
"""note pins text to a corner; a title belongs over the middle of the figure."""
|
|
330
|
+
svg = (plot(loads("((A:1,B:1)C:1,D:2)R;"), style=_PLACED) + title("Primates")).as_svg()
|
|
331
|
+
found = re.search(r'<text x="([-\d.]+)" y="([-\d.]+)"[^>]*text-anchor="(\w+)"[^>]*>Primates</text>', svg)
|
|
332
|
+
assert found
|
|
333
|
+
x, y, anchor = float(found.group(1)), float(found.group(2)), found.group(3)
|
|
334
|
+
assert x == pytest.approx(_PLACED.width / 2) and anchor == "middle"
|
|
335
|
+
assert y < _PLACED.margin, "the title should sit in the top margin, above the tree"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_a_title_stays_on_the_page_when_the_top_margin_is_thin():
|
|
339
|
+
svg = (plot(loads("(A:1,B:1)R;"), style=Style(width=400, height=300, margin=4))
|
|
340
|
+
+ title("Primates", size=20)).as_svg()
|
|
341
|
+
y = float(re.search(r'<text x="[-\d.]+" y="([-\d.]+)"[^>]*>Primates</text>', svg).group(1))
|
|
342
|
+
assert y >= 10.0, "half the title would be cut off by the top edge"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_note_refuses_a_loc_that_is_not_a_corner():
|
|
346
|
+
"""`loc="top-centre"` used to fall through and print right-aligned instead."""
|
|
347
|
+
with pytest.raises(ValueError, match="title"):
|
|
348
|
+
note("Primates", loc="top-centre")
|
|
349
|
+
with pytest.raises(ValueError, match="top-left"):
|
|
350
|
+
note("Primates", loc="top")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_the_legend_default_corner_is_unchanged():
|
|
354
|
+
assert _legend_svg("top-left") == (plot(loads("((A:1,B:1)C:1,D:2)R;"), style=_PLACED)
|
|
355
|
+
+ legend("state", entries=_ENTRIES)).as_svg()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_the_legend_sits_in_the_corner_it_is_given():
|
|
359
|
+
"""branch_events has always taken the four corners; legend was pinned to the top left."""
|
|
360
|
+
top_left = _swatches(_legend_svg("top-left"))
|
|
361
|
+
for loc in _CORNERS:
|
|
362
|
+
svg = _legend_svg(loc)
|
|
363
|
+
swatches = _swatches(svg)
|
|
364
|
+
bx, by, bw, bh = _backdrop(svg)
|
|
365
|
+
assert bx >= 0 and by >= 0, f"{loc}: the legend runs off the page"
|
|
366
|
+
assert bx + bw <= _PLACED.width and by + bh <= _PLACED.height, f"{loc}: off the page"
|
|
367
|
+
if "left" in loc:
|
|
368
|
+
assert swatches[0][0] == pytest.approx(_PLACED.margin), loc
|
|
369
|
+
else:
|
|
370
|
+
# the block is right-aligned on the right margin (the backdrop adds 6px of padding)
|
|
371
|
+
assert bx + bw - 6 == pytest.approx(_PLACED.width - _PLACED.margin), loc
|
|
372
|
+
if "top" in loc:
|
|
373
|
+
assert swatches[0][1] == pytest.approx(top_left[0][1]), loc
|
|
374
|
+
else:
|
|
375
|
+
lowest = max(y + h for _, y, _, h in swatches)
|
|
376
|
+
assert lowest == pytest.approx(_PLACED.height - _PLACED.margin, abs=1.0), loc
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def test_legend_refuses_a_loc_that_is_not_a_corner():
|
|
380
|
+
with pytest.raises(ValueError, match="bottom-right"):
|
|
381
|
+
legend("state", loc="middle", entries=_ENTRIES)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|