plot-io 0.12.0__py3-none-win_amd64.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-x86_64.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/legend.py
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
"""Legend layout and drawing: one entry per named series."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from vec_io import Path
|
|
7
|
+
from vec_io.arrays import doubles
|
|
8
|
+
|
|
9
|
+
from .canvas import Canvas
|
|
10
|
+
from ._methods import SizeLegendMixin
|
|
11
|
+
from .color.values import resolve_color, with_alpha
|
|
12
|
+
from .settings import Setting, Settings, table, text
|
|
13
|
+
from .validators import length
|
|
14
|
+
from .shapes import swatch
|
|
15
|
+
from .text import Font, draw_run, font_of, measure, run
|
|
16
|
+
|
|
17
|
+
__all__ = ["Entries", "SizeRows", "SizeKey", "size_key_values"]
|
|
18
|
+
|
|
19
|
+
def size_key_values(axis: Any, given: Any = None) -> list[float]:
|
|
20
|
+
"""Values shown in the size key of a trained size axis: ``given``, else the axis'
|
|
21
|
+
``ticks``, else 3–5 nice numbers inside its limits."""
|
|
22
|
+
from .mapped import trained
|
|
23
|
+
from .ticks import numeric_ticks
|
|
24
|
+
if isinstance(given, (list, tuple)):
|
|
25
|
+
return [float(v) for v in given]
|
|
26
|
+
ticks = axis.resolve("ticks")
|
|
27
|
+
if isinstance(ticks, list):
|
|
28
|
+
return [float(v) for v in ticks]
|
|
29
|
+
st = trained(axis)
|
|
30
|
+
scale = st.scale
|
|
31
|
+
assert scale is not None
|
|
32
|
+
lo, hi = sorted((st.lo, st.hi))
|
|
33
|
+
eps = (hi - lo) * 1e-9
|
|
34
|
+
vals: list[float] = []
|
|
35
|
+
for most in (4, 5, 6, 8):
|
|
36
|
+
tk, _ = numeric_ticks(scale, lo, hi, most, minor=False, length=133.0)
|
|
37
|
+
vals = [t.value for t in tk if t.major and lo - eps <= t.value <= hi + eps
|
|
38
|
+
and (t.value > 0 or scale.name != "linear" or lo <= 0)]
|
|
39
|
+
if len(vals) >= 3:
|
|
40
|
+
break
|
|
41
|
+
return vals if len(vals) >= 2 else [lo, hi]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def size_key_labels(axis: Any, values: list[float], plot: Any) -> list[Any]:
|
|
45
|
+
"""The labels of a size key's values: the axis' ``tick_format`` and number format."""
|
|
46
|
+
from .numbers import NumberStyle, format_numbers
|
|
47
|
+
fmt = axis.resolve("tick_format")
|
|
48
|
+
if fmt == "auto" and plot is not None:
|
|
49
|
+
return format_numbers(values, "auto", plot)
|
|
50
|
+
return format_numbers(values, fmt, NumberStyle.of(axis), "tick_format")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _named(label: Any) -> bool:
|
|
54
|
+
"""Whether a legend label has text (``name=""`` makes no entry)."""
|
|
55
|
+
return not (label is None or (isinstance(label, (str, list, tuple)) and not label))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SizeKey:
|
|
59
|
+
"""Entries of a size key: a header (the size axis' label) and sample markers."""
|
|
60
|
+
|
|
61
|
+
header = False
|
|
62
|
+
|
|
63
|
+
def __init__(self, axis: Any, series: list[Any], values: list[float], labels: list[Any],
|
|
64
|
+
title: Any) -> None:
|
|
65
|
+
self.axis = axis
|
|
66
|
+
self.series = series
|
|
67
|
+
self.values = values
|
|
68
|
+
self.labels = labels
|
|
69
|
+
self.title = title
|
|
70
|
+
|
|
71
|
+
def sizes(self) -> list[float]:
|
|
72
|
+
from .markers import area_factor
|
|
73
|
+
from .mapped import trained
|
|
74
|
+
st = trained(self.axis)
|
|
75
|
+
assert st.scale is not None
|
|
76
|
+
s = self.series[0]
|
|
77
|
+
d = self.axis.size_map().map(doubles(self.values), st.flo, st.fhi,
|
|
78
|
+
st.scale.function, s)
|
|
79
|
+
marker = s.resolve("marker") if "marker" in s.SETTINGS else "circle"
|
|
80
|
+
marker = marker[0] if isinstance(marker, list) else marker
|
|
81
|
+
factor = area_factor(marker) if s.resolve("size_basis") == "area" else 1.0
|
|
82
|
+
return [v * factor if v == v else 0.0 for v in d]
|
|
83
|
+
|
|
84
|
+
def glyph(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
|
|
85
|
+
from .markers import resolve
|
|
86
|
+
s = self.series[0]
|
|
87
|
+
size = self.sizes()[i]
|
|
88
|
+
if not size:
|
|
89
|
+
return
|
|
90
|
+
marker = s.resolve("marker") if "marker" in s.SETTINGS else "circle"
|
|
91
|
+
marker = marker[0] if isinstance(marker, list) else marker
|
|
92
|
+
from .values import Values, is_computed
|
|
93
|
+
mapped = any(isinstance(s.resolve(n), Values) or is_computed(s.resolve(n))
|
|
94
|
+
for n in ("color", "edge_color") if n in s.SETTINGS)
|
|
95
|
+
if len(self.series) == 1 and not mapped:
|
|
96
|
+
from .layers.paint import MarkerStyle, base_color
|
|
97
|
+
st = MarkerStyle(s, ctx, "", base_color(s, ctx), 1, True)
|
|
98
|
+
fill, stroke, lw = st.first_color("fill"), st.first_color("stroke"), st.line_width
|
|
99
|
+
if fill is None and stroke is None:
|
|
100
|
+
fill = base_color(s, ctx)
|
|
101
|
+
else:
|
|
102
|
+
fill, stroke, lw = "#888888", None, None
|
|
103
|
+
ctx.canvas.draw_markers([x + w / 2], [y + h / 2], marker=resolve(marker)[0],
|
|
104
|
+
size=size, fill=fill, stroke=stroke, stroke_width=lw)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def group_key(g: Any, plot: Any) -> SizeKey:
|
|
108
|
+
"""The size key of size group ``g`` (:class:`plot_io.mapped.SizeGroup`), its labels
|
|
109
|
+
formatted as ``plot``'s numbers."""
|
|
110
|
+
members, axis = g.listed, g.axis
|
|
111
|
+
values = size_key_values(axis)
|
|
112
|
+
labels = size_key_labels(axis, values, plot)
|
|
113
|
+
title = axis.resolve("label")
|
|
114
|
+
if title is None:
|
|
115
|
+
title = next((s.columns["size"] for s in members
|
|
116
|
+
if "size" in getattr(s, "columns", {})), None)
|
|
117
|
+
return SizeKey(axis, members, values, labels, title)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _Header:
|
|
121
|
+
header = True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Entries:
|
|
125
|
+
"""A legend's content (the guide's, :class:`~plot_io.guides.GuideState`): its entries,
|
|
126
|
+
those of a plot and its twins (a twin's: none) or, merged, those of a figure legend's
|
|
127
|
+
plots, then its size keys; laid out in rows and columns."""
|
|
128
|
+
|
|
129
|
+
form = "entries"
|
|
130
|
+
guide: Any = None
|
|
131
|
+
|
|
132
|
+
def __init__(self, legend: Any, ps: Any = None) -> None:
|
|
133
|
+
self.ps = ps
|
|
134
|
+
self.legend = legend
|
|
135
|
+
self.twin = False
|
|
136
|
+
#: the entries of the series and decorations (without size keys)
|
|
137
|
+
self.listed: list[tuple[Any, Any, int]] = []
|
|
138
|
+
#: whether a figure legend shows the listed entries instead
|
|
139
|
+
self.shared = False
|
|
140
|
+
self.keys: list[SizeKey] = []
|
|
141
|
+
#: a figure legend: ``id(source)`` → the plot state whose context draws its symbol
|
|
142
|
+
self.owners: dict[int, Any] = {}
|
|
143
|
+
self.entries: list[tuple[Any, Any, int]] = []
|
|
144
|
+
self.size = (0.0, 0.0)
|
|
145
|
+
self._cols: list[float] = []
|
|
146
|
+
self._rows = 0
|
|
147
|
+
self._ncols = 0
|
|
148
|
+
self._heights: list[float] = []
|
|
149
|
+
self._title_h = 0.0
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def of_plot(cls, plot_state: Any) -> "Entries":
|
|
153
|
+
"""A plot's legend: the entries of the plot and its twins (a twin's: none)."""
|
|
154
|
+
plot = plot_state.plot
|
|
155
|
+
self = cls(plot.legend, plot_state)
|
|
156
|
+
from .layers.pipeline import plot_calls
|
|
157
|
+
#: a twin shows its entries in its main plot's legend (one legend per axes)
|
|
158
|
+
self.twin = getattr(plot, "twin_of", None) is not None
|
|
159
|
+
plots = [] if self.twin else [plot, *getattr(plot, "twins", [])]
|
|
160
|
+
own = [c for q in plots for c in plot_calls(q)]
|
|
161
|
+
self.listed = [(e, e.label, 0) for c in own for e in c.entries if _named(e.label)]
|
|
162
|
+
self.listed += [(d, label, i) for q in plots for d in q.decorations
|
|
163
|
+
for i, label in enumerate(d.legend_labels()) if _named(label)]
|
|
164
|
+
self.keys = self._size_keys(plot_state)
|
|
165
|
+
self.compose()
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def merged(cls, legend: Any, states: list[Any]) -> "Entries":
|
|
170
|
+
"""A figure legend (:class:`~plot_io.plot.FigureLegend`): the entries of its plots
|
|
171
|
+
(``states``), merged (the same label and symbol once)."""
|
|
172
|
+
self = cls(legend)
|
|
173
|
+
by_label: dict[str, list[tuple[Any, int]]] = {}
|
|
174
|
+
from .plot import _figure_legend_title
|
|
175
|
+
headers = _figure_legend_title(legend) is None # plot titles head their entries
|
|
176
|
+
headed: list[str] = []
|
|
177
|
+
for ps in states:
|
|
178
|
+
header = ps.plot.legend.resolve("title") if headers else None
|
|
179
|
+
for src, label, k in ps.legend.listed:
|
|
180
|
+
if getattr(src, "header", False): # a level section's title
|
|
181
|
+
if _label_key(label) not in headed:
|
|
182
|
+
headed.append(_label_key(label))
|
|
183
|
+
self.listed.append((src, label, k))
|
|
184
|
+
continue
|
|
185
|
+
self.owners[id(src)] = ps
|
|
186
|
+
same = by_label.setdefault(_label_key(label), [])
|
|
187
|
+
if any(self._same(src, k, other, j) for other, j in same):
|
|
188
|
+
continue
|
|
189
|
+
same.append((src, k))
|
|
190
|
+
if header is not None and _label_key(header) not in headed:
|
|
191
|
+
headed.append(_label_key(header))
|
|
192
|
+
self.listed.append((_Header(), header, 0))
|
|
193
|
+
header = None
|
|
194
|
+
self.listed.append((src, label, k))
|
|
195
|
+
self.compose()
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def compose(self) -> None:
|
|
199
|
+
"""The entries shown: the listed ones (unless ``shared``), then the size keys."""
|
|
200
|
+
self.entries = [] if self.shared else list(self.listed)
|
|
201
|
+
for key in self.keys:
|
|
202
|
+
if key.title is not None:
|
|
203
|
+
self.entries.append((_Header(), key.title, 0))
|
|
204
|
+
self.entries.extend((key, lab, i) for i, lab in enumerate(key.labels))
|
|
205
|
+
|
|
206
|
+
def _size_keys(self, ps: Any) -> list[SizeKey]:
|
|
207
|
+
"""The size keys the plot's legend shows (one per shared size axis across the
|
|
208
|
+
figure: :func:`plot_io.mapped.size_keys`)."""
|
|
209
|
+
from .spec import current
|
|
210
|
+
r = current()
|
|
211
|
+
if self.twin:
|
|
212
|
+
return []
|
|
213
|
+
plots = {id(ps.plot), *(id(t) for t in getattr(ps.plot, "twins", []))}
|
|
214
|
+
return [group_key(g, g.plot) for g in (r.size_keys.values() if r is not None else ())
|
|
215
|
+
if id(g.plot) in plots]
|
|
216
|
+
|
|
217
|
+
def font(self) -> Font:
|
|
218
|
+
g = self.legend
|
|
219
|
+
return font_of(g, g.resolve("font_size"), color=g.resolve("color"))
|
|
220
|
+
|
|
221
|
+
def _metrics(self) -> tuple[float, float, float, float]:
|
|
222
|
+
g = self.legend
|
|
223
|
+
return (g.resolve("glyph_length"), g.resolve("glyph_gap"), g.resolve("row_height"),
|
|
224
|
+
g.resolve("inner_pad"))
|
|
225
|
+
|
|
226
|
+
def _columns(self, labels: list[float], heights: list[float], room: float | None) -> int:
|
|
227
|
+
"""The number of columns of the entries (``labels``: their label widths,
|
|
228
|
+
``heights``: their row heights): ``columns`` when set; else within ``room`` px along
|
|
229
|
+
the legend's side (its width when entries go in rows, its height when in columns;
|
|
230
|
+
``None``: unlimited), the most columns whose rows fit, or the fewest columns whose
|
|
231
|
+
height fits (at least one column, one row)."""
|
|
232
|
+
n = len(labels)
|
|
233
|
+
cols = min(self.legend.resolve("columns"), n)
|
|
234
|
+
if self.legend.is_set("columns"):
|
|
235
|
+
return cols
|
|
236
|
+
row_major = self.guide.row_major
|
|
237
|
+
if room is None:
|
|
238
|
+
return n if row_major else cols
|
|
239
|
+
glyph, gap, _, inner = self._metrics()
|
|
240
|
+
if row_major:
|
|
241
|
+
col_gap = self.legend.resolve("column_gap")
|
|
242
|
+
for cols in range(n, 1, -1):
|
|
243
|
+
cells = [0.0] * cols
|
|
244
|
+
for i, lw in enumerate(labels):
|
|
245
|
+
cells[i % cols] = max(cells[i % cols], lw)
|
|
246
|
+
if sum(glyph + gap + c for c in cells) + col_gap * (cols - 1) + 2 * inner <= room:
|
|
247
|
+
return cols
|
|
248
|
+
return 1
|
|
249
|
+
for cols in range(1, n):
|
|
250
|
+
rows = math.ceil(n / cols)
|
|
251
|
+
tall = [0.0] * rows
|
|
252
|
+
for i, rh in enumerate(heights):
|
|
253
|
+
tall[i % rows] = max(tall[i % rows], rh)
|
|
254
|
+
if self._title_h + sum(tall) + 2 * inner <= room:
|
|
255
|
+
return cols
|
|
256
|
+
return max(n, 1)
|
|
257
|
+
|
|
258
|
+
def _pos(self, i: int) -> tuple[int, int]:
|
|
259
|
+
"""Column and row of entry ``i``."""
|
|
260
|
+
if self.guide.row_major:
|
|
261
|
+
r, c = divmod(i, self._ncols)
|
|
262
|
+
return c, r
|
|
263
|
+
return divmod(i, self._rows)
|
|
264
|
+
|
|
265
|
+
def measure(self, fonts: Any, room: float | None = None) -> tuple[float, float]:
|
|
266
|
+
"""The legend's size; ``room``: the length along its side it wraps within (its
|
|
267
|
+
width when entries go in rows, its height when in columns; see :meth:`_columns`)."""
|
|
268
|
+
if not self.guide.active():
|
|
269
|
+
self.size = (0.0, 0.0)
|
|
270
|
+
return self.size
|
|
271
|
+
font = self.font()
|
|
272
|
+
glyph, gap, row, inner = self._metrics()
|
|
273
|
+
n = len(self.entries)
|
|
274
|
+
labels = []
|
|
275
|
+
heights = []
|
|
276
|
+
for s, label, k in self.entries:
|
|
277
|
+
lw = measure(fonts, label, font)[0]
|
|
278
|
+
if getattr(s, "header", False):
|
|
279
|
+
lw -= glyph + gap
|
|
280
|
+
labels.append(lw)
|
|
281
|
+
heights.append(max(row, 1.15 * s.sizes()[k]) if isinstance(s, SizeKey) else row)
|
|
282
|
+
title = self.legend.resolve("title")
|
|
283
|
+
self._title_h = 0.0
|
|
284
|
+
tw = 0.0
|
|
285
|
+
if title is not None:
|
|
286
|
+
tw, a, d = measure(fonts, title, font)
|
|
287
|
+
self._title_h = a + d + self.legend.resolve("title_gap")
|
|
288
|
+
cols = self._columns(labels, heights, room)
|
|
289
|
+
rows = math.ceil(n / cols)
|
|
290
|
+
self._rows, self._ncols = rows, cols
|
|
291
|
+
widths = [0.0] * cols
|
|
292
|
+
self._heights = [row] * rows
|
|
293
|
+
for i in range(n):
|
|
294
|
+
c, r = self._pos(i)
|
|
295
|
+
widths[c] = max(widths[c], labels[i])
|
|
296
|
+
self._heights[r] = max(self._heights[r], heights[i])
|
|
297
|
+
self._cols = [glyph + gap + w for w in widths]
|
|
298
|
+
w = max(sum(self._cols) + self.legend.resolve("column_gap") * (cols - 1), tw)
|
|
299
|
+
h = self._title_h + sum(self._heights)
|
|
300
|
+
self.size = (w + 2 * inner, h + 2 * inner)
|
|
301
|
+
return self.size
|
|
302
|
+
|
|
303
|
+
def place_at(self, fonts: Any, x: float, y: float) -> Any:
|
|
304
|
+
"""The legend's geometry with its box's top-left corner at ``(x, y)``."""
|
|
305
|
+
from .geometry import LegendGeometry, LegendItem
|
|
306
|
+
w, h = self.size
|
|
307
|
+
font = self.font()
|
|
308
|
+
glyph, gap, row, inner = self._metrics()
|
|
309
|
+
cx, cy = x + inner, y + inner
|
|
310
|
+
title = self.legend.resolve("title")
|
|
311
|
+
head = None
|
|
312
|
+
if title is not None:
|
|
313
|
+
head = run(fonts, title, cx, cy, font, "left", "top")
|
|
314
|
+
cy += self._title_h
|
|
315
|
+
items = []
|
|
316
|
+
col_gap = self.legend.resolve("column_gap")
|
|
317
|
+
for i, (s, label, k) in enumerate(self.entries):
|
|
318
|
+
c, r = self._pos(i)
|
|
319
|
+
ex = cx + sum(self._cols[:c]) + col_gap * c
|
|
320
|
+
ey = cy + sum(self._heights[:r])
|
|
321
|
+
rh = self._heights[r]
|
|
322
|
+
if getattr(s, "header", False):
|
|
323
|
+
items.append(LegendItem("header", s, k, (ex, ey, 0.0, rh),
|
|
324
|
+
run(fonts, label, ex, ey + rh / 2, font, "left",
|
|
325
|
+
"center")))
|
|
326
|
+
continue
|
|
327
|
+
if isinstance(s, SizeKey):
|
|
328
|
+
items.append(LegendItem("size", s, k, (ex, ey, glyph, rh),
|
|
329
|
+
run(fonts, label, ex + glyph + gap, ey + rh / 2, font,
|
|
330
|
+
"left", "center")))
|
|
331
|
+
continue
|
|
332
|
+
ey += (rh - row) / 2
|
|
333
|
+
items.append(LegendItem("entry", s, k, (ex, ey + 0.1 * row, glyph, 0.8 * row),
|
|
334
|
+
run(fonts, label, ex + glyph + gap, ey + row / 2, font,
|
|
335
|
+
"left", "center")))
|
|
336
|
+
return LegendGeometry((x, y, w, h), head, tuple(items))
|
|
337
|
+
|
|
338
|
+
def draw(self, canvas: Canvas, ctx: Any, g: Any) -> None:
|
|
339
|
+
"""Draw the legend placed as ``g`` (see :meth:`~plot_io.guides.GuideState.place`)."""
|
|
340
|
+
self._draw(canvas, g, ctx.cycle, lambda item: ctx)
|
|
341
|
+
|
|
342
|
+
def _draw(self, canvas: Canvas, g: Any, cycle: Any, ctx_of: Any) -> None:
|
|
343
|
+
"""Draw the legend placed as ``g``: its frame with ``cycle`` (the plot's colors), each item's symbol
|
|
344
|
+
in the drawing context ``ctx_of(item)`` gives."""
|
|
345
|
+
if g is None:
|
|
346
|
+
return
|
|
347
|
+
x, y, w, h = g.box
|
|
348
|
+
lg = self.legend
|
|
349
|
+
if lg.resolve("frame"):
|
|
350
|
+
opacity = lg.resolve("frame_opacity")
|
|
351
|
+
bg = with_alpha(resolve_color(lg.resolve("background"), cycle), opacity)
|
|
352
|
+
fc = with_alpha(resolve_color(lg.resolve("frame_color"), cycle), opacity)
|
|
353
|
+
with canvas.state():
|
|
354
|
+
r = min(lg.resolve("frame_radius"), w / 2, h / 2)
|
|
355
|
+
path = Path().rect(x, y, w, h, rx=r)
|
|
356
|
+
if bg is not None:
|
|
357
|
+
canvas.draw_path(path, fill=bg, stroke=None)
|
|
358
|
+
if fc is not None and lg.resolve("frame_width"):
|
|
359
|
+
canvas.stroke_width = lg.resolve("frame_width")
|
|
360
|
+
canvas.draw_path(path, fill=None, stroke=fc)
|
|
361
|
+
draw_run(canvas, g.title)
|
|
362
|
+
for item in g.items:
|
|
363
|
+
if item.kind != "header":
|
|
364
|
+
with canvas.state():
|
|
365
|
+
self.glyph(ctx_of(item), item.kind, item.source, item.index,
|
|
366
|
+
*item.glyph)
|
|
367
|
+
draw_run(canvas, item.label)
|
|
368
|
+
|
|
369
|
+
def glyph(self, ctx: Any, kind: str, source: Any, index: int, x: float, y: float,
|
|
370
|
+
w: float, h: float) -> None:
|
|
371
|
+
"""The symbol of an item (``kind``: ``"size"`` or ``"entry"``) in box ``x, y, w,
|
|
372
|
+
h``."""
|
|
373
|
+
if kind == "size":
|
|
374
|
+
source.glyph(ctx, index, x, y, w, h)
|
|
375
|
+
return
|
|
376
|
+
style = self.legend.resolve("glyph")
|
|
377
|
+
if style == "series":
|
|
378
|
+
source.legend_glyph_at(ctx, index, x, y, w, h)
|
|
379
|
+
else:
|
|
380
|
+
_simple_glyph(ctx, source, index, style, self.legend.resolve("glyph_opacity"),
|
|
381
|
+
x, y, w, h)
|
|
382
|
+
|
|
383
|
+
def _same(self, a: Any, i: int, b: Any, j: int) -> bool:
|
|
384
|
+
"""Whether two entries draw the same symbol: equal symbols and values (simple
|
|
385
|
+
symbols: equal colors)."""
|
|
386
|
+
try:
|
|
387
|
+
ca, cb = self._context(a), self._context(b)
|
|
388
|
+
if self.legend.resolve("glyph") == "series":
|
|
389
|
+
return a.legend_key_at(ca, i) == b.legend_key_at(cb, j)
|
|
390
|
+
return repr(a.legend_colors_at(ca, i)) == repr(b.legend_colors_at(cb, j))
|
|
391
|
+
except Exception:
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
def _context(self, src: Any) -> Any:
|
|
395
|
+
"""A drawing context of the plot of an entry, for its values."""
|
|
396
|
+
from .layers.context import DrawContext
|
|
397
|
+
ps = self.owners[id(src)]
|
|
398
|
+
return DrawContext(None, ps.plot, (0.0, 0.0, 100.0, 100.0), ps.x, ps.y, ps.cycle)
|
|
399
|
+
|
|
400
|
+
def draw_figure(self, canvas: Canvas, g: Any, contexts: dict[int, Any]) -> None:
|
|
401
|
+
"""Draw the legend placed as ``g``, each symbol in the context of its plot
|
|
402
|
+
(``contexts``: ``id(plot)`` → its drawing context)."""
|
|
403
|
+
def ctx_of(item: Any) -> Any:
|
|
404
|
+
if isinstance(item.source, SizeKey):
|
|
405
|
+
plot = item.source.series[0].plot
|
|
406
|
+
plot = plot.twin_of[0] if getattr(plot, "twin_of", None) else plot
|
|
407
|
+
return contexts[id(plot)]
|
|
408
|
+
return contexts[id(self.owners[id(item.source)].plot)]
|
|
409
|
+
first = next(iter(contexts.values()), None)
|
|
410
|
+
self._draw(canvas, g, first.cycle if first is not None else (), ctx_of)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _label_key(label: Any) -> str:
|
|
414
|
+
"""A legend label compared as written (strings and rich content)."""
|
|
415
|
+
return label if isinstance(label, str) else repr(label)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _opaque(c: Any) -> Any:
|
|
419
|
+
if c is None:
|
|
420
|
+
return None
|
|
421
|
+
from .color.values import rgba_of
|
|
422
|
+
rgba = rgba_of(c)
|
|
423
|
+
return None if rgba is None else (rgba[0], rgba[1], rgba[2], 1.0)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _simple_glyph(ctx: Any, series: Any, k: int, kind: str, opacity: Any, x: float, y: float,
|
|
427
|
+
w: float, h: float) -> None:
|
|
428
|
+
"""A legend symbol in the series' colors: a filled box, a circle or a line (``opacity``:
|
|
429
|
+
the legend's ``glyph_opacity``)."""
|
|
430
|
+
canvas = ctx.canvas
|
|
431
|
+
fill, stroke = series.legend_colors_at(ctx, k)
|
|
432
|
+
if opacity is not None and kind != "line":
|
|
433
|
+
base = _opaque(fill if fill is not None else stroke)
|
|
434
|
+
if base is not None:
|
|
435
|
+
fill, stroke = with_alpha(base, opacity), (stroke if stroke is not None else base)
|
|
436
|
+
if kind == "line":
|
|
437
|
+
c = stroke if stroke is not None else fill
|
|
438
|
+
if c is not None:
|
|
439
|
+
canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], stroke=c,
|
|
440
|
+
stroke_width=min(2.0, h / 3))
|
|
441
|
+
return
|
|
442
|
+
if kind == "circle":
|
|
443
|
+
d = 0.75 * h
|
|
444
|
+
canvas.draw_markers([x + w / 2], [y + h / 2], size=d, fill=fill, stroke=stroke,
|
|
445
|
+
stroke_width=1.25 if stroke is not None else None)
|
|
446
|
+
return
|
|
447
|
+
path = Path().rect(*swatch(x, y, w, h))
|
|
448
|
+
if fill is not None:
|
|
449
|
+
canvas.draw_path(path, fill=fill, stroke=None)
|
|
450
|
+
if stroke is not None:
|
|
451
|
+
canvas.stroke_width = 1.25
|
|
452
|
+
canvas.draw_path(path, fill=None, stroke=stroke)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _values(v: Any) -> tuple[float, ...]:
|
|
456
|
+
if isinstance(v, (str, bytes)) or not hasattr(v, "__iter__"):
|
|
457
|
+
raise ValueError(f"expected a list of numbers, got {v!r}")
|
|
458
|
+
return tuple(float(x) for x in v)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class SizeLegend(SizeLegendMixin, Settings):
|
|
462
|
+
"""A size key in its own grid area (``figure.size_legend(source, id=…)``): the guide of
|
|
463
|
+
a size axis (``source``: a series mapping sizes, or a size axis).
|
|
464
|
+
|
|
465
|
+
A series whose ``size`` goes through a size axis has a size key: by default a key like a
|
|
466
|
+
colorbar's (:class:`Colorbar`); with ``size_key="legend"``, rows in its plot's legend
|
|
467
|
+
below the series entries: the axis' ``label`` (default the ``size`` column) as a header,
|
|
468
|
+
then sample markers at their real size with their values (the axis' ``ticks``, else 3–5
|
|
469
|
+
nice numbers). Series sharing a size axis share one key, in grey (one series: its
|
|
470
|
+
color), in the legend of the rightmost, then topmost, of their plots. A size legend
|
|
471
|
+
area shows every series of its axis, and no plot legend repeats it.
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
SETTINGS = table(
|
|
475
|
+
values=Setting(_values, expr=False,
|
|
476
|
+
doc="The sizes shown, one marker each; default: the axis' `ticks`, "
|
|
477
|
+
"else 3–5 nice numbers inside its limits."),
|
|
478
|
+
title=Setting(text, doc="Title; default: the size axis' `label`."),
|
|
479
|
+
font_size=Setting(length(positive=True, percent="em"),
|
|
480
|
+
doc="Font size; default: the legends' (`Legend.font_size`)."),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
def __init__(self, source: Any, figure: Any, **settings: Any) -> None:
|
|
484
|
+
from .mapped import SizeAxis, size_axis_of
|
|
485
|
+
axis = source if isinstance(source, SizeAxis) else \
|
|
486
|
+
size_axis_of(source) if hasattr(source, "SETTINGS") else None
|
|
487
|
+
if axis is None:
|
|
488
|
+
raise ValueError("a size legend shows a size axis: give a series mapping sizes, "
|
|
489
|
+
"or a shared figure.size_axis(…)")
|
|
490
|
+
self.size_axis = axis
|
|
491
|
+
self.figure = figure
|
|
492
|
+
self.id: Any = None
|
|
493
|
+
super().__init__(**settings)
|
|
494
|
+
|
|
495
|
+
def _parent(self) -> Settings:
|
|
496
|
+
return self.figure
|
|
497
|
+
|
|
498
|
+
def __repr__(self) -> str:
|
|
499
|
+
return f"SizeLegend({self.size_axis!r})"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
class SizeRows:
|
|
503
|
+
"""A size legend's content (the guide's, :class:`~plot_io.guides.GuideState`, in its
|
|
504
|
+
own grid area): a size key's rows, its title first."""
|
|
505
|
+
|
|
506
|
+
form = "rows"
|
|
507
|
+
guide: Any = None
|
|
508
|
+
vertical = True
|
|
509
|
+
|
|
510
|
+
def __init__(self, sl: SizeLegend) -> None:
|
|
511
|
+
from .mapped import size_group
|
|
512
|
+
from .plot import Legend, Plot
|
|
513
|
+
self.sl = sl
|
|
514
|
+
fig = sl.figure
|
|
515
|
+
axis = sl.size_axis
|
|
516
|
+
group = size_group(axis)
|
|
517
|
+
users = group.users if group is not None else []
|
|
518
|
+
self.key = None
|
|
519
|
+
self.thickness = 1.0
|
|
520
|
+
if users:
|
|
521
|
+
given = sl.resolve("values")
|
|
522
|
+
values = size_key_values(axis, None if given is None else list(given))
|
|
523
|
+
labels = size_key_labels(axis, values, users[0].plot if isinstance(
|
|
524
|
+
users[0].plot, Plot) else None)
|
|
525
|
+
title = sl.resolve("title")
|
|
526
|
+
if title is None:
|
|
527
|
+
title = axis.resolve("label")
|
|
528
|
+
self.key = SizeKey(axis, users, values, labels, title)
|
|
529
|
+
self.legend = Legend(fig) # the legend settings of the figure's style
|
|
530
|
+
if sl.resolve("font_size") is not None:
|
|
531
|
+
self.legend.set(font_size=sl.resolve("font_size"))
|
|
532
|
+
size = self.legend.resolve("font_size")
|
|
533
|
+
self.font = font_of(fig, size, color=fig.resolve("text_color"))
|
|
534
|
+
self._layout: list[tuple[str, Any, float]] = []
|
|
535
|
+
|
|
536
|
+
@property
|
|
537
|
+
def cell_size(self) -> float:
|
|
538
|
+
"""Its width, as a cell of the grid of its own."""
|
|
539
|
+
return self.thickness
|
|
540
|
+
|
|
541
|
+
def measure_area(self, fonts: Any, w: float, h: float) -> tuple[float, float, float, float]:
|
|
542
|
+
"""Margins of the size legend in its area ``w`` × ``h`` (none: its width is its
|
|
543
|
+
cell's)."""
|
|
544
|
+
if self.key is None:
|
|
545
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
546
|
+
g = self.legend
|
|
547
|
+
glyph, gap, row = g.resolve("glyph_length"), g.resolve("glyph_gap"), g.resolve("row_height")
|
|
548
|
+
sizes = self.key.sizes()
|
|
549
|
+
width = 0.0
|
|
550
|
+
rows: list[tuple[str, Any, float]] = []
|
|
551
|
+
if self.key.title is not None:
|
|
552
|
+
width = measure(fonts, self.key.title, self.font)[0]
|
|
553
|
+
rows.append(("title", self.key.title, row))
|
|
554
|
+
g = max([glyph] + [1.1 * s for s in sizes])
|
|
555
|
+
for lab, s in zip(self.key.labels, sizes):
|
|
556
|
+
width = max(width, g + gap + measure(fonts, lab, self.font)[0])
|
|
557
|
+
rows.append(("entry", lab, max(row, 1.15 * s)))
|
|
558
|
+
self._layout = rows
|
|
559
|
+
self._glyph = g
|
|
560
|
+
self.thickness = width
|
|
561
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
562
|
+
|
|
563
|
+
def bar_rect(self, rect: tuple[float, float, float, float]) -> tuple:
|
|
564
|
+
x0, y0, x1, y1 = rect
|
|
565
|
+
return (x0, y0, min(x1, x0 + self.thickness), y1)
|
|
566
|
+
|
|
567
|
+
def place_area(self, fonts: Any, rect: tuple[float, float, float, float]) -> Any:
|
|
568
|
+
"""The key's rows placed from the top-left corner of ``rect``."""
|
|
569
|
+
from .geometry import KeyGeometry
|
|
570
|
+
if self.key is None:
|
|
571
|
+
return KeyGeometry(self, ())
|
|
572
|
+
x0, y = rect[0], rect[1]
|
|
573
|
+
rows = []
|
|
574
|
+
gap = self.legend.resolve("glyph_gap")
|
|
575
|
+
for kind, lab, h in self._layout:
|
|
576
|
+
if kind == "title":
|
|
577
|
+
rows.append((kind, (x0, y, 0.0, h),
|
|
578
|
+
run(fonts, lab, x0, y + h / 2, self.font, "left", "center")))
|
|
579
|
+
else:
|
|
580
|
+
rows.append((kind, (x0, y, self._glyph, h),
|
|
581
|
+
run(fonts, lab, x0 + self._glyph + gap, y + h / 2, self.font,
|
|
582
|
+
"left", "center")))
|
|
583
|
+
y += h
|
|
584
|
+
return KeyGeometry(self, tuple(rows))
|
|
585
|
+
|
|
586
|
+
def draw(self, canvas: Canvas, g: Any) -> None:
|
|
587
|
+
if self.key is None:
|
|
588
|
+
return
|
|
589
|
+
from .color.maps import cycle_of, map_of
|
|
590
|
+
from .color.values import resolve_color
|
|
591
|
+
plot = self.key.series[0].plot
|
|
592
|
+
cycle = cycle_of(map_of(plot, "qualitative"), 10)
|
|
593
|
+
|
|
594
|
+
class _Ctx:
|
|
595
|
+
def color(self, value: Any) -> Any:
|
|
596
|
+
return resolve_color(value, cycle)
|
|
597
|
+
ctx: Any = _Ctx()
|
|
598
|
+
ctx.canvas, ctx.cycle, ctx.plot = canvas, cycle, plot
|
|
599
|
+
i = 0
|
|
600
|
+
for kind, glyph, label in g.rows:
|
|
601
|
+
if kind != "title":
|
|
602
|
+
with canvas.state():
|
|
603
|
+
self.key.glyph(ctx, i, *glyph)
|
|
604
|
+
i += 1
|
|
605
|
+
draw_run(canvas, label)
|