plot-io 0.12.0__py3-none-win_arm64.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.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-arm64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/guides.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Guides (legends, colorbars, size keys) and their places: inside the axes at an anchor
|
|
2
|
+
(``inside=``) or outside on a side (``side=``); the ``legend="…"`` shorthand; one
|
|
3
|
+
:class:`GuideState` per guide while drawing, placed by where it is."""
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .occupancy import N
|
|
9
|
+
from .settings import Settings
|
|
10
|
+
from .titles import SIDES, align_of
|
|
11
|
+
|
|
12
|
+
__all__ = ["ANCHORS", "anchor", "where_of", "placement", "set_place", "anchor_box", "along_at",
|
|
13
|
+
"GuideState", "marked", "best_anchor", "place_inside"]
|
|
14
|
+
|
|
15
|
+
#: Places of a guide inside the axes (``inside=``); ``"auto"`` chooses one (a legend may go
|
|
16
|
+
#: outside), ``"best"`` chooses one too (outside only when every place covers a marker).
|
|
17
|
+
ANCHORS = ("auto", "best", "top_right", "top_left", "bottom_right", "bottom_left", "top", "bottom",
|
|
18
|
+
"center_right", "center_left")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _word(v: Any) -> str:
|
|
22
|
+
s = str(getattr(v, "value", v)).strip().lower().replace("-", " ")
|
|
23
|
+
return "_".join(s.replace("_", " ").split())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def anchor(v: Any) -> str:
|
|
27
|
+
s = _word(v)
|
|
28
|
+
if s not in ANCHORS:
|
|
29
|
+
raise ValueError(f"expected one of {', '.join(map(repr, ANCHORS))}, got {v!r}")
|
|
30
|
+
return s
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def where_of(v: Any) -> dict[str, Any]:
|
|
34
|
+
"""The shorthand of a guide's place (``legend="top"``, ``add_legend("top_left")``) as
|
|
35
|
+
settings: a side name is ``side=``, a corner, a centre or ``"auto"`` is ``inside=``,
|
|
36
|
+
``"none"`` hides it."""
|
|
37
|
+
s = _word(v)
|
|
38
|
+
if s in SIDES:
|
|
39
|
+
return {"side": s}
|
|
40
|
+
if s in ANCHORS:
|
|
41
|
+
return {"inside": s}
|
|
42
|
+
if s == "none":
|
|
43
|
+
return {"visible": False}
|
|
44
|
+
raise ValueError(f"expected a side ({', '.join(SIDES)}), a place inside the axes "
|
|
45
|
+
f"({', '.join(ANCHORS)}) or 'none', got {v!r}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def placement(guide: Settings, side_first: bool) -> tuple[str, Any]:
|
|
49
|
+
"""``("inside", anchor)`` or ``("side", side)`` of a guide: the one of ``inside`` and
|
|
50
|
+
``side`` set on it (setting one unsets the other), else from the style the one not
|
|
51
|
+
``None`` (``side_first``: ``side`` when both are)."""
|
|
52
|
+
if guide.is_set("side"):
|
|
53
|
+
return "side", guide.resolve("side")
|
|
54
|
+
if guide.is_set("inside"):
|
|
55
|
+
return "inside", guide.resolve("inside")
|
|
56
|
+
side, inside = guide.resolve("side"), guide.resolve("inside")
|
|
57
|
+
if side is not None and (side_first or inside is None):
|
|
58
|
+
return "side", side
|
|
59
|
+
return "inside", inside
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def set_place(guide: Settings, settings: dict[str, Any]) -> None:
|
|
63
|
+
"""After ``guide.set(**settings)``: ``inside=`` unsets ``side`` and ``side=`` unsets
|
|
64
|
+
``inside`` (the one given last wins)."""
|
|
65
|
+
for given, other in (("inside", "side"), ("side", "inside")):
|
|
66
|
+
if settings.get(given) is not None and settings.get(other) is None:
|
|
67
|
+
guide._values.pop(other, None)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def along_at(lo: float, hi: float, size: float, align: float) -> float:
|
|
71
|
+
"""Where a box ``size`` long starts along ``lo``–``hi`` aligned at ``align`` (0 the
|
|
72
|
+
start, 0.5 the centre, 1 the end)."""
|
|
73
|
+
if align == 0.0:
|
|
74
|
+
return lo
|
|
75
|
+
if align == 1.0:
|
|
76
|
+
return hi - size
|
|
77
|
+
return (lo + hi - size) / 2
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def anchor_box(rect: tuple[float, float, float, float], where: str, w: float, h: float,
|
|
81
|
+
px: float, py: float) -> tuple[float, float]:
|
|
82
|
+
"""Top-left corner of a ``w`` × ``h`` box at anchor ``where`` inside ``rect``, ``px``
|
|
83
|
+
and ``py`` from its edges (a centre: centred)."""
|
|
84
|
+
x0, y0, x1, y1 = rect
|
|
85
|
+
vertical, _, horizontal = where.partition("_")
|
|
86
|
+
if where in ("top", "bottom", "center"):
|
|
87
|
+
vertical, horizontal = where, "center"
|
|
88
|
+
x = {"left": x0 + px, "right": x1 - px - w, "center": (x0 + x1 - w) / 2}[horizontal]
|
|
89
|
+
y = {"top": y0 + py, "bottom": y1 - py - h, "center": (y0 + y1 - h) / 2}[vertical]
|
|
90
|
+
return x, y
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Inside the axes: the occupancy grid
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
#: Inside candidates of ``inside="auto"`` and ``"best"``, in order of preference (ties go to the first).
|
|
100
|
+
CANDIDATES = ("top_right", "top_left", "bottom_left", "bottom_right", "top", "bottom",
|
|
101
|
+
"center_right", "center_left")
|
|
102
|
+
#: Inside candidates of ``"best"``: matplotlib's ``loc="best"`` places, in its order.
|
|
103
|
+
BEST_CANDIDATES = ("top_right", "top_left", "bottom_left", "bottom_right", "center_right",
|
|
104
|
+
"center_left", "bottom", "top", "center")
|
|
105
|
+
#: An inside place covering no marker but other drawn cells is taken when it covers at most
|
|
106
|
+
#: this fraction of them (else ``"auto"`` puts the legend outside right).
|
|
107
|
+
OUTSIDE_THRESHOLD = 0.02
|
|
108
|
+
#: The anchors chosen when drawing (the others are fixed places).
|
|
109
|
+
CHOSEN = ("auto", "best")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _drawn(ps: Any, ctx: Any, fonts: Any = None) -> Any:
|
|
113
|
+
"""The occupancy grid of what a plot and its twins draw on the placed axes (``ctx``):
|
|
114
|
+
series, the row label inside the axes, and with ``fonts`` decorations."""
|
|
115
|
+
from .layers.context import DrawContext
|
|
116
|
+
from .occupancy import Occupancy
|
|
117
|
+
from .rows import occupy_label
|
|
118
|
+
from .states import occupy
|
|
119
|
+
occ = Occupancy(ctx.rect)
|
|
120
|
+
contexts = [(ps, ctx)] + [(t, DrawContext(ctx.canvas, t.plot, ctx.rect, t.x, t.y,
|
|
121
|
+
t.cycle)) for t in getattr(ps, "twins", [])]
|
|
122
|
+
for st, c in contexts:
|
|
123
|
+
for call in st.calls:
|
|
124
|
+
occupy(call, c, occ)
|
|
125
|
+
occupy_label(ps.plot, ctx, occ)
|
|
126
|
+
if fonts is not None:
|
|
127
|
+
for st, c in contexts:
|
|
128
|
+
for d in st.plot.decorations:
|
|
129
|
+
d.occupy(fonts, c, occ)
|
|
130
|
+
return occ
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def marked(ps: Any, ctx: Any, fonts: Any = None) -> Any:
|
|
134
|
+
"""The occupancy grid a plot's legend chooses on: what is drawn (:func:`_drawn`) and
|
|
135
|
+
its colorbars inside the axes (placed in order with ``fonts``, else where last
|
|
136
|
+
placed)."""
|
|
137
|
+
occ = _drawn(ps, ctx, fonts)
|
|
138
|
+
if fonts is not None:
|
|
139
|
+
place_inside(ps, fonts, ctx, occ)
|
|
140
|
+
else:
|
|
141
|
+
for b in ps.bars:
|
|
142
|
+
if b.inside is not None and b.inside_box is not None:
|
|
143
|
+
occ.rects(*([v] for v in b.inside_box), solid=True)
|
|
144
|
+
return occ
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def best_anchor(occ: Any, rect: tuple, w: float, h: float, px: float, py: float,
|
|
148
|
+
candidates: tuple[str, ...] = CANDIDATES) -> tuple[str, float]:
|
|
149
|
+
"""The first of ``candidates`` where a ``w`` × ``h`` box covers no marked cell and
|
|
150
|
+
meets no marker, text or inside guide, else the one covering the fewest (the first of
|
|
151
|
+
equals); with the fraction it covers (a box meeting one in no marked cell: one
|
|
152
|
+
cell's)."""
|
|
153
|
+
best, best_f = candidates[0], math.inf
|
|
154
|
+
for pos in candidates:
|
|
155
|
+
box = (*anchor_box(rect, pos, w, h, px, py), w, h)
|
|
156
|
+
f = occ.covered(*box)
|
|
157
|
+
if f == 0.0:
|
|
158
|
+
if not occ.solid_in(*box):
|
|
159
|
+
return pos, 0.0
|
|
160
|
+
f = 1.0 / (N * N)
|
|
161
|
+
if f < best_f - 1e-12:
|
|
162
|
+
best, best_f = pos, f
|
|
163
|
+
return best, best_f
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def place_inside(ps: Any, fonts: Any, ctx: Any, occ: Any = None) -> list[Any]:
|
|
167
|
+
"""The geometries of a plot's colorbars inside its axes, in order; ``"auto"`` ones
|
|
168
|
+
choose on the occupancy grid ``occ`` (default: :func:`marked` without them), each
|
|
169
|
+
marking it once placed."""
|
|
170
|
+
bars = [b for b in ps.bars if b.inside is not None]
|
|
171
|
+
if not bars:
|
|
172
|
+
return []
|
|
173
|
+
if occ is None and any(b.inside in CHOSEN for b in bars):
|
|
174
|
+
occ = _drawn(ps, ctx, fonts)
|
|
175
|
+
out = []
|
|
176
|
+
for b in bars:
|
|
177
|
+
out.append(b.place(fonts, ctx.rect, occ=occ))
|
|
178
|
+
if occ is not None and b.inside_box is not None:
|
|
179
|
+
occ.rects(*([v] for v in b.inside_box), solid=True)
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# The guide state
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
#: What a :class:`GuideState` holds itself (the rest is its content's).
|
|
188
|
+
_OWN = frozenset({"content", "spot", "choice", "free", "moved", "inside_box"})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class GuideState:
|
|
192
|
+
"""A guide while drawing: its content and its place.
|
|
193
|
+
|
|
194
|
+
``content``: what it shows and how it lays that out, its attributes read through the
|
|
195
|
+
guide: a legend's entries and size keys (:class:`plot_io.legend.Entries`), a colorbar
|
|
196
|
+
or size key (:class:`plot_io.colorbar.Bar`), or a size legend's rows
|
|
197
|
+
(:class:`plot_io.legend.SizeRows`).
|
|
198
|
+
|
|
199
|
+
``spot``, its place in the one vocabulary: ``("inside", anchor)`` inside its plot's
|
|
200
|
+
axes (``"auto"`` included), ``("side", side)`` in a band on a side of its plot,
|
|
201
|
+
``("lanes", side)`` in the lanes beside a set of plots, ``("area", None)`` in its own
|
|
202
|
+
grid area. :meth:`measure` and :meth:`place` lay the content out for it."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, content: Any, spot: tuple[str, Any]) -> None:
|
|
205
|
+
self.content = content
|
|
206
|
+
self.spot = spot
|
|
207
|
+
#: ``inside="auto"``: the anchor chosen once the axes are placed
|
|
208
|
+
self.choice: str | None = None
|
|
209
|
+
#: ``"inside"`` (a free place, not an anchor): where, as fractions of the room
|
|
210
|
+
self.free: tuple[float, float] = (0.0, 0.0)
|
|
211
|
+
#: an inside legend larger than its axes, drawn on their right side instead
|
|
212
|
+
self.moved = False
|
|
213
|
+
#: inside the axes: its box where last placed
|
|
214
|
+
self.inside_box: tuple[float, float, float, float] | None = None
|
|
215
|
+
content.guide = self
|
|
216
|
+
|
|
217
|
+
def __getattr__(self, name: str) -> Any:
|
|
218
|
+
if name == "content":
|
|
219
|
+
raise AttributeError(name)
|
|
220
|
+
return getattr(self.content, name)
|
|
221
|
+
|
|
222
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
223
|
+
if name not in _OWN:
|
|
224
|
+
raise AttributeError(f"GuideState.{name}: set on its content")
|
|
225
|
+
object.__setattr__(self, name, value)
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def for_plot(cls, ps: Any) -> "GuideState":
|
|
229
|
+
"""A plot's legend (a twin's: empty, its main plot's shows its entries)."""
|
|
230
|
+
from .legend import Entries
|
|
231
|
+
return cls(Entries.of_plot(ps), ps.plot.legend.placement())
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def for_figure_legend(cls, legend: Any, states: list[Any]) -> "GuideState":
|
|
235
|
+
"""A figure legend (:class:`~plot_io.plot.FigureLegend`) of the plots ``states``."""
|
|
236
|
+
from .legend import Entries
|
|
237
|
+
return cls(Entries.merged(legend, states), ("lanes", legend.resolve("side")))
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def for_colorbar(cls, cb: Any) -> "GuideState":
|
|
241
|
+
"""A colorbar or size key: attached to its plot, in the lanes, or in its area."""
|
|
242
|
+
from .colorbar import Bar, own_area
|
|
243
|
+
kind, where = ("area", None) if own_area(cb) else cb.placement()
|
|
244
|
+
spot = ("lanes", where) if cb.lanes else (kind, where)
|
|
245
|
+
return cls(Bar(cb, spot), spot)
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def for_size_legend(cls, sl: Any) -> "GuideState":
|
|
249
|
+
"""A size legend in its own area (``figure.size_legend``)."""
|
|
250
|
+
from .legend import SizeRows
|
|
251
|
+
return cls(SizeRows(sl), ("area", None))
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def inside(self) -> str | None:
|
|
255
|
+
"""Its anchor inside the axes (``"auto"`` included), else ``None``."""
|
|
256
|
+
kind, where = self.spot
|
|
257
|
+
return where if kind == "inside" else None
|
|
258
|
+
|
|
259
|
+
# -- a legend's place ---------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def setting(self) -> str:
|
|
262
|
+
"""A legend's place as set: an anchor inside the axes (``"auto"`` included),
|
|
263
|
+
``"outside_<side>"``, or ``"none"`` (hidden)."""
|
|
264
|
+
c = self.content
|
|
265
|
+
if c.twin or not c.legend.resolve("visible"):
|
|
266
|
+
return "none"
|
|
267
|
+
kind, where = self.spot
|
|
268
|
+
return where if kind == "inside" else "outside_" + where
|
|
269
|
+
|
|
270
|
+
def active(self) -> bool:
|
|
271
|
+
"""Whether a legend shows something."""
|
|
272
|
+
if self.spot[0] == "lanes":
|
|
273
|
+
return bool(self.content.entries)
|
|
274
|
+
return bool(self.content.entries) and self.setting() != "none"
|
|
275
|
+
|
|
276
|
+
def position(self) -> str:
|
|
277
|
+
"""A legend's place (:meth:`setting`; in the lanes, its side), ``"auto"`` and
|
|
278
|
+
``"best"`` replaced by the choice once made and ``"outside_right"`` once moved there
|
|
279
|
+
(:meth:`fit`)."""
|
|
280
|
+
if self.spot[0] == "lanes":
|
|
281
|
+
return self.spot[1]
|
|
282
|
+
if self.moved:
|
|
283
|
+
return "outside_right"
|
|
284
|
+
pos = self.setting()
|
|
285
|
+
return self.choice if pos in CHOSEN and self.choice else pos
|
|
286
|
+
|
|
287
|
+
def horizontal(self) -> bool:
|
|
288
|
+
"""Whether a legend runs along a horizontal side."""
|
|
289
|
+
if self.spot[0] == "lanes":
|
|
290
|
+
return self.spot[1] in ("top", "bottom")
|
|
291
|
+
o = self.content.legend.resolve("orientation")
|
|
292
|
+
if o == "auto":
|
|
293
|
+
# The setting, not an ``"auto"`` choice: the box was measured before choosing.
|
|
294
|
+
return not self.moved and self.setting() in ("top", "bottom", "outside_top",
|
|
295
|
+
"outside_bottom")
|
|
296
|
+
return o == "horizontal"
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def row_major(self) -> bool:
|
|
300
|
+
"""A legend's entries in rows (side by side, wrapped), not in columns: horizontal,
|
|
301
|
+
without ``columns`` (in the lanes: on top and bottom, ``columns`` or not)."""
|
|
302
|
+
if self.spot[0] == "lanes":
|
|
303
|
+
return self.horizontal()
|
|
304
|
+
return self.horizontal() and not self.content.legend.is_set("columns")
|
|
305
|
+
|
|
306
|
+
def align(self) -> float:
|
|
307
|
+
"""A plot legend outside its axes, along its side: 0 at the start (left, top), 0.5
|
|
308
|
+
centred, 1 at the end; ``align="auto"``: centred when horizontal on top or
|
|
309
|
+
bottom, else at the start."""
|
|
310
|
+
a = self.content.legend.resolve("align")
|
|
311
|
+
if a == "auto":
|
|
312
|
+
return 0.5 if self.position() in ("outside_top", "outside_bottom") \
|
|
313
|
+
and self.horizontal() else 0.0
|
|
314
|
+
return {"start": 0.0, "center": 0.5, "end": 1.0}[align_of(a)]
|
|
315
|
+
|
|
316
|
+
def outside(self) -> str | None:
|
|
317
|
+
"""A plot legend's place when outside its axes, else ``None``."""
|
|
318
|
+
if self.spot[0] == "lanes":
|
|
319
|
+
return None
|
|
320
|
+
pos = self.position()
|
|
321
|
+
return pos if self.active() and pos.startswith("outside") else None
|
|
322
|
+
|
|
323
|
+
def fit(self, fonts: Any, w: float, h: float) -> None:
|
|
324
|
+
"""Inside axes ``w`` × ``h``: the legend measured to wrap within them (into
|
|
325
|
+
columns, or rows when horizontal); one still larger than them moves outside right
|
|
326
|
+
(``moved``), with a warning when its place was given."""
|
|
327
|
+
c = self.content
|
|
328
|
+
px, py = c.legend.pads()
|
|
329
|
+
room = max(w - 2 * px, 1.0) if self.row_major else max(h - 2 * py, 1.0)
|
|
330
|
+
lw, lh = c.measure(fonts, room)
|
|
331
|
+
if lw <= w - 2 * px + 1e-6 and lh <= h - 2 * py + 1e-6:
|
|
332
|
+
return
|
|
333
|
+
self.moved = True
|
|
334
|
+
if self.setting() not in CHOSEN:
|
|
335
|
+
from .spec import warn
|
|
336
|
+
warn(f"the legend ({len(c.entries)} entries) is larger than its axes inside; "
|
|
337
|
+
f"drawn outside on the right")
|
|
338
|
+
|
|
339
|
+
def choose(self, ctx: Any, outside: bool = True, fonts: Any = None) -> str:
|
|
340
|
+
"""A legend's place for ``"auto"`` and ``"best"``, from the occupancy grid
|
|
341
|
+
(:func:`marked`: every series of the plot and its twins marks what it draws:
|
|
342
|
+
points, lines, bars, areas, …; with ``fonts``, the decorations and the colorbars
|
|
343
|
+
inside the axes too):
|
|
344
|
+
|
|
345
|
+
1. the first candidate (``CANDIDATES``; ``"best"``: ``BEST_CANDIDATES``) whose
|
|
346
|
+
box covers no drawn cell and meets no marker disc, text or inside guide;
|
|
347
|
+
2. else the free box (one grid cell apart, anywhere inside) nearest to a corner of
|
|
348
|
+
the axes and meeting none of them (``"inside"``, its place kept as fractions of
|
|
349
|
+
the free room);
|
|
350
|
+
3. else the candidate meeting no marker, text or guide and covering the fewest
|
|
351
|
+
cells, at most ``OUTSIDE_THRESHOLD`` of them (``"best"``: any number of them);
|
|
352
|
+
4. else ``"outside_right"`` (when ``outside``);
|
|
353
|
+
5. else the candidate covering the fewest marker cells, then the fewest cells."""
|
|
354
|
+
c = self.content
|
|
355
|
+
occ = marked(c.ps, ctx, fonts)
|
|
356
|
+
w, h = c.size
|
|
357
|
+
px, py = c.legend.pads()
|
|
358
|
+
candidates = BEST_CANDIDATES if self.setting() == "best" else CANDIDATES
|
|
359
|
+
best, best_f = best_anchor(occ, ctx.rect, w, h, px, py, candidates)
|
|
360
|
+
if best_f == 0.0:
|
|
361
|
+
return best
|
|
362
|
+
x0, y0, x1, y1 = ctx.rect
|
|
363
|
+
def corner(p: tuple[float, float]) -> float:
|
|
364
|
+
x, y = p
|
|
365
|
+
return min(x - x0, x1 - x - w) + min(y - y0, y1 - y - h)
|
|
366
|
+
free = next((p for p in sorted(occ.free_boxes(w, h, px), key=corner)
|
|
367
|
+
if not occ.solid_in(*p, w, h)), None)
|
|
368
|
+
if free is not None:
|
|
369
|
+
x, y = free
|
|
370
|
+
room_x, room_y = x1 - x0 - 2 * px - w, y1 - y0 - 2 * py - h
|
|
371
|
+
self.free = ((x - x0 - px) / room_x if room_x > 0 else 0.0,
|
|
372
|
+
(y - y0 - py) / room_y if room_y > 0 else 0.0)
|
|
373
|
+
return "inside"
|
|
374
|
+
cover = [] # (marker cells, cells, order, place)
|
|
375
|
+
for i, pos in enumerate(candidates):
|
|
376
|
+
box = (*anchor_box(ctx.rect, pos, w, h, px, py), w, h)
|
|
377
|
+
cover.append((max(occ.covered(*box, markers=True), float(occ.solid_in(*box))),
|
|
378
|
+
occ.covered(*box), i, pos))
|
|
379
|
+
clean = [(f, i, pos) for m, f, i, pos in cover if m == 0.0 and f <= OUTSIDE_THRESHOLD]
|
|
380
|
+
if clean:
|
|
381
|
+
return min(clean)[2]
|
|
382
|
+
if self.setting() == "best": # any amount of lines and areas, no marker
|
|
383
|
+
unmarked = [(f, i, pos) for m, f, i, pos in cover if m == 0.0]
|
|
384
|
+
if unmarked:
|
|
385
|
+
return min(unmarked)[2]
|
|
386
|
+
if outside:
|
|
387
|
+
return "outside_right"
|
|
388
|
+
return min(cover)[3]
|
|
389
|
+
|
|
390
|
+
def _legend_at(self, ctx: Any, used: dict[str, float] | None = None,
|
|
391
|
+
fonts: Any = None) -> tuple[float, float]:
|
|
392
|
+
"""Top-left corner of a plot legend's box: outside, past what the axes and twins
|
|
393
|
+
take on its side (``used``, px); inside, at its position in the axes."""
|
|
394
|
+
c = self.content
|
|
395
|
+
x0, y0, x1, y1 = ctx.rect
|
|
396
|
+
w, h = c.size
|
|
397
|
+
px, py = c.legend.pads()
|
|
398
|
+
pos = self.position()
|
|
399
|
+
used = used or {}
|
|
400
|
+
if pos == "outside_right":
|
|
401
|
+
return x1 + used.get("right", 0.0) + px, along_at(y0, y1, h, self.align())
|
|
402
|
+
if pos == "outside_left":
|
|
403
|
+
return x0 - used.get("left", 0.0) - px - w, along_at(y0, y1, h, self.align())
|
|
404
|
+
if pos == "outside_top":
|
|
405
|
+
return along_at(x0, x1, w, self.align()), y0 - used.get("top", 0.0) - py - h
|
|
406
|
+
if pos == "outside_bottom":
|
|
407
|
+
return along_at(x0, x1, w, self.align()), y1 + used.get("bottom", 0.0) + py
|
|
408
|
+
if pos in CHOSEN:
|
|
409
|
+
pos = self.choose(ctx, outside=False, fonts=fonts)
|
|
410
|
+
if pos == "inside":
|
|
411
|
+
fx, fy = self.free
|
|
412
|
+
return (x0 + px + fx * max(0.0, x1 - x0 - 2 * px - w),
|
|
413
|
+
y0 + py + fy * max(0.0, y1 - y0 - 2 * py - h))
|
|
414
|
+
return anchor_box(ctx.rect, pos, w, h, px, py)
|
|
415
|
+
|
|
416
|
+
def _room(self, pos: str, w: float, h: float) -> float | None:
|
|
417
|
+
"""The length an outside plot legend wraps within along its side: the axes height
|
|
418
|
+
on the left and right (entries in columns), the axes width on top and bottom
|
|
419
|
+
(entries in rows), or in a figure of given size the plot's box along that side
|
|
420
|
+
when longer (the legend passes the axes' ends into the box's margins rather than
|
|
421
|
+
widening and taking the axes' room); ``None`` elsewhere."""
|
|
422
|
+
bw, bh = self.content.ps.box_room
|
|
423
|
+
if pos in ("outside_right", "outside_left") and not self.row_major:
|
|
424
|
+
return h if bh is None else max(h, bh)
|
|
425
|
+
if pos in ("outside_top", "outside_bottom") and self.row_major:
|
|
426
|
+
return w if bw is None else max(w, bw)
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
# -- measure and place, by spot -------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
def measure(self, fonts: Any, w: float, h: float, placed: bool = True) -> Any:
|
|
432
|
+
"""The content laid out for its place, ``w`` × ``h`` the room: the axes (inside,
|
|
433
|
+
side), its area, or in the lanes the length of its span along them (``placed``:
|
|
434
|
+
once the areas are placed; before, a legend there does not wrap). Returns:
|
|
435
|
+
|
|
436
|
+
* a legend: its size (inside its axes: wrapped within them, or moved: :meth:`fit`);
|
|
437
|
+
* a colorbar: inside, its box's size; on a side, its band (pad included); in the
|
|
438
|
+
lanes, its lane's thickness (pad included); in its area, its margins;
|
|
439
|
+
* a size legend: its margins."""
|
|
440
|
+
c = self.content
|
|
441
|
+
kind = self.spot[0]
|
|
442
|
+
if c.form == "entries":
|
|
443
|
+
if kind == "lanes":
|
|
444
|
+
return c.measure(fonts, w if placed and self.row_major else None)
|
|
445
|
+
if self.active() and not self.position().startswith("outside"):
|
|
446
|
+
from .spec import set_boxes
|
|
447
|
+
set_boxes(c.ps.plot, axes_width=w, axes_height=h)
|
|
448
|
+
self.fit(fonts, w, h)
|
|
449
|
+
pos = self.position()
|
|
450
|
+
if not self.active() or pos.startswith("outside"):
|
|
451
|
+
c.measure(fonts, self._room(pos, w, h))
|
|
452
|
+
return c.size
|
|
453
|
+
if kind == "area":
|
|
454
|
+
return c.measure_area(fonts, w, h)
|
|
455
|
+
if kind == "side":
|
|
456
|
+
return c.measure_attached(fonts, w, h)
|
|
457
|
+
if kind == "inside":
|
|
458
|
+
return c.inside_size(fonts, w, h)[:2]
|
|
459
|
+
return c.lane_pad() + c.lane_extent(fonts, w)
|
|
460
|
+
|
|
461
|
+
def place(self, fonts: Any, rect: tuple[float, float, float, float], *, ctx: Any = None,
|
|
462
|
+
offsets: dict[str, float] | None = None, occ: Any = None,
|
|
463
|
+
span: tuple[float, float] | None = None, line: float | None = None) -> Any:
|
|
464
|
+
"""The content's geometry at its place; ``rect``:
|
|
465
|
+
|
|
466
|
+
* inside and on a side: its plot's axes; a legend chooses ``"auto"`` in ``ctx``
|
|
467
|
+
(their drawing context), a colorbar on the occupancy grid ``occ``; ``offsets``:
|
|
468
|
+
by side, where its band starts past the axes; keys sharing a line: ``span``,
|
|
469
|
+
a key's place along the side, and ``line``, how far the line's middle line is
|
|
470
|
+
from where it starts;
|
|
471
|
+
* in the lanes: a legend's box; a colorbar's near edge across its side, its span
|
|
472
|
+
along it (``(near, s0, near, s1)`` on the left and right, ``(s0, near, s1,
|
|
473
|
+
near)`` on top and bottom);
|
|
474
|
+
* in its area: the area's axes.
|
|
475
|
+
|
|
476
|
+
A legend showing nothing: ``None``."""
|
|
477
|
+
c = self.content
|
|
478
|
+
kind, where = self.spot
|
|
479
|
+
if c.form == "entries":
|
|
480
|
+
if kind == "lanes":
|
|
481
|
+
return c.place_at(fonts, rect[0], rect[1])
|
|
482
|
+
if not self.active() or not c.size[0]:
|
|
483
|
+
return None
|
|
484
|
+
return c.place_at(fonts, *self._legend_at(ctx, offsets, fonts))
|
|
485
|
+
if kind == "area":
|
|
486
|
+
return c.place_area(fonts, rect)
|
|
487
|
+
if kind == "lanes":
|
|
488
|
+
if c.side in ("left", "right"):
|
|
489
|
+
return c.place_lane(fonts, rect[0], rect[1], rect[3])
|
|
490
|
+
return c.place_lane(fonts, rect[1], rect[0], rect[2])
|
|
491
|
+
if kind == "side":
|
|
492
|
+
assert offsets is not None
|
|
493
|
+
return c.place_attached(fonts, rect, offsets[c.side], span, line)
|
|
494
|
+
|
|
495
|
+
def at(w: float, h: float, pad: float) -> tuple[float, float]:
|
|
496
|
+
anchor = where
|
|
497
|
+
if anchor in CHOSEN:
|
|
498
|
+
anchor = best_anchor(occ, rect, w, h, pad, pad, BEST_CANDIDATES
|
|
499
|
+
if anchor == "best" else CANDIDATES)[0]
|
|
500
|
+
x, y = anchor_box(rect, anchor, w, h, pad, pad)
|
|
501
|
+
self.inside_box = (x, y, x + w, y + h)
|
|
502
|
+
return x, y
|
|
503
|
+
return c.place_inside(fonts, rect, at)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Plot types and figures built on :mod:`plot_io.ext`: clustering trees and dendrograms
|
|
2
|
+
(:mod:`.tree`, :mod:`.dendrogram`), QQ plots (:mod:`.qq`), rainclouds (:mod:`.raincloud`),
|
|
3
|
+
volcano, MA and Manhattan plots (:mod:`.volcano`), joint plots and pair grids
|
|
4
|
+
(:mod:`.grids`), clustered heatmaps (:mod:`.clustermap`). Each module imports only
|
|
5
|
+
``plot_io.ext``, ``plot_io.kernels`` and public names."""
|
|
6
|
+
|
|
7
|
+
from .dendrogram import Dendrogram
|
|
8
|
+
from .qq import QQ
|
|
9
|
+
from .raincloud import Raincloud
|
|
10
|
+
from .tree import Tree, linkage
|
|
11
|
+
from .volcano import Ma, Manhattan, Volcano
|
|
12
|
+
# the figure helpers after every type (pair cells take each type's method)
|
|
13
|
+
from .grids import Joint, Pairs, joint, pairs # noqa: E402
|
|
14
|
+
from .clustermap import ClusterMap, clustermap # noqa: E402
|
|
15
|
+
|
|
16
|
+
__all__ = ["Tree", "linkage", "Dendrogram", "QQ", "Raincloud", "Volcano", "Ma", "Manhattan",
|
|
17
|
+
"joint", "pairs", "Joint", "Pairs", "clustermap", "ClusterMap"]
|