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/decorations.py
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
"""Decorations: reference lines, spans, text, annotations and brackets on a plot.
|
|
2
|
+
|
|
3
|
+
Decorations are not series: they take no color of the plot's, are in the legend only with a
|
|
4
|
+
``name``, and change the axis limits only with ``extend_limits=True``. They are drawn
|
|
5
|
+
with the series in ``z`` order (spans under the series, lines and text over them by
|
|
6
|
+
default). Positions are in data units (``coords="data"``), fractions of the axes
|
|
7
|
+
(``"axes"``) or of the figure (``"figure"``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from vec_io import Path
|
|
14
|
+
|
|
15
|
+
from .canvas import Canvas
|
|
16
|
+
from ._methods import AnnotateMixin, AxLineMixin, BracketMixin, HLineMixin, HSpanMixin, TextMixin, VLineMixin, VSpanMixin
|
|
17
|
+
from . import groups
|
|
18
|
+
from . import kernels
|
|
19
|
+
from .color.values import color
|
|
20
|
+
|
|
21
|
+
from .shapes import swatch
|
|
22
|
+
from .settings import Setting, Settings, boolean, either, literal, number, one_of, table, text
|
|
23
|
+
from .validators import angle, length
|
|
24
|
+
from .lines import dash_of
|
|
25
|
+
from .color.values import resolve_color, with_alpha
|
|
26
|
+
from .text import Font, Run, draw_run, font_of, lines_run
|
|
27
|
+
from .times import axis_value
|
|
28
|
+
|
|
29
|
+
__all__ = ["Decoration", "HLine", "VLine", "AxLine", "HSpan", "VSpan", "Text", "Annotate",
|
|
30
|
+
"Bracket"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
#: A position along an axis: a data value (bare numbers), or `%` of the axes / a length
|
|
35
|
+
#: from its start.
|
|
36
|
+
_AXES_POSITION = length(percent="axes_length", bare="data")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _point(v: Any) -> tuple[Any, Any]:
|
|
40
|
+
if not isinstance(v, (tuple, list)) or len(v) != 2:
|
|
41
|
+
raise ValueError(f"expected a point (x, y), got {v!r}")
|
|
42
|
+
return v[0], v[1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_COORDS = one_of("data", "axes", "figure")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _arrow_style(v: Any) -> str:
|
|
49
|
+
if v not in ("->", "-|>", "-"):
|
|
50
|
+
raise ValueError(f"expected '->', '-|>' or '-', got {v!r}")
|
|
51
|
+
return v
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _common() -> dict[str, Setting]:
|
|
55
|
+
return dict(
|
|
56
|
+
name=Setting(text, doc="Legend entry; unset: not in the legend."),
|
|
57
|
+
opacity=Setting(number(0, 1), doc="Opacity."),
|
|
58
|
+
z=Setting(number(), doc="Drawing order among the series (default 0)."),
|
|
59
|
+
extend_limits=Setting(boolean, doc="Include the decoration in the axis limits."),
|
|
60
|
+
clip=Setting(boolean, doc="Clip to the axes area."),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _line() -> dict[str, Setting]:
|
|
65
|
+
return dict(
|
|
66
|
+
**groups.Stroke.embed(only=("color", "line_width", "line_style"),
|
|
67
|
+
inherited=("line_width",)),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _font() -> dict[str, Setting]:
|
|
72
|
+
return dict(
|
|
73
|
+
font_size=Setting(length(positive=True, font=True, percent="em"), inherited=True, doc="Font size."),
|
|
74
|
+
**groups.Font.embed(only=groups.Font.FACE),
|
|
75
|
+
**groups.TextStyle.embed(only=("font_weight", "font_style")),
|
|
76
|
+
text_color=Setting(color, inherited=True, doc="Text color."),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Decoration(Settings):
|
|
81
|
+
"""Base of decorations: behaves like a series towards the axes and the legend."""
|
|
82
|
+
|
|
83
|
+
AXES = True
|
|
84
|
+
DODGE_GROUP = None
|
|
85
|
+
STACK_GROUP = None
|
|
86
|
+
#: The axis roles the decoration's data coordinates are on.
|
|
87
|
+
ROLES: tuple[str, ...] = ()
|
|
88
|
+
|
|
89
|
+
def __init__(self, plot: Any, **settings: Any) -> None:
|
|
90
|
+
self.plot = plot
|
|
91
|
+
super().__init__(**settings)
|
|
92
|
+
plot.decorations.append(self)
|
|
93
|
+
|
|
94
|
+
def _parent(self) -> Settings:
|
|
95
|
+
return self.plot
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
return f"{self.kind}()"
|
|
99
|
+
|
|
100
|
+
# -- what the axes need (only with extend_limits) -------------------------------
|
|
101
|
+
|
|
102
|
+
def has_axis(self, role: str) -> bool:
|
|
103
|
+
return role in self.ROLES and self.resolve("extend_limits")
|
|
104
|
+
|
|
105
|
+
def categories(self, role: str) -> list[Any] | None:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def data_values(self, role: str) -> list[float]:
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
def values(self, role: str) -> Any:
|
|
112
|
+
from array import array
|
|
113
|
+
out = array("d")
|
|
114
|
+
for v in self.data_values(role):
|
|
115
|
+
try:
|
|
116
|
+
out.append(axis_value(v))
|
|
117
|
+
except ValueError: # a category name
|
|
118
|
+
pass
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
def extent(self, role: str, scale: Any) -> tuple[int, float, float]:
|
|
122
|
+
return kernels.extent(self.values(role), scale.function)
|
|
123
|
+
|
|
124
|
+
def inverts(self, role: str) -> bool:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
def zero_default(self, role: str) -> bool:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
def zero_padding(self, role: str) -> bool:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
def sticky(self, role: str) -> list[float]:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
def legend_labels(self) -> list[Any]:
|
|
137
|
+
"""Legend entries of the object: its ``name``, or none."""
|
|
138
|
+
name = self.resolve("name")
|
|
139
|
+
return [] if name is None else [name]
|
|
140
|
+
|
|
141
|
+
def legend_glyph_at(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
|
|
142
|
+
"""The symbol of legend entry ``i`` (series with several entries override this)."""
|
|
143
|
+
self.legend_glyph(ctx, x, y, w, h)
|
|
144
|
+
|
|
145
|
+
def legend_colors_at(self, ctx: Any, i: int) -> tuple[Any, Any]:
|
|
146
|
+
return self.legend_colors(ctx)
|
|
147
|
+
|
|
148
|
+
def legend_key_at(self, ctx: Any, i: int) -> Any:
|
|
149
|
+
"""What the symbol of entry ``i`` draws, for comparing entries."""
|
|
150
|
+
c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
|
|
151
|
+
if "line_style" in self.SETTINGS:
|
|
152
|
+
lw = self.resolve("line_width")
|
|
153
|
+
return "line", c, lw, dash_of(self.resolve("line_style"), lw)
|
|
154
|
+
return "fill", c
|
|
155
|
+
|
|
156
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
157
|
+
c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
|
|
158
|
+
return c, None
|
|
159
|
+
|
|
160
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
161
|
+
c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
|
|
162
|
+
if c is None:
|
|
163
|
+
return
|
|
164
|
+
if "line_style" in self.SETTINGS:
|
|
165
|
+
lw = self.resolve("line_width")
|
|
166
|
+
ctx.canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], stroke=c, stroke_width=lw,
|
|
167
|
+
stroke_dasharray=dash_of(self.resolve("line_style"), lw))
|
|
168
|
+
else:
|
|
169
|
+
ctx.canvas.fill = c
|
|
170
|
+
ctx.canvas.draw_rect(*swatch(x, y, w, h), stroke=None)
|
|
171
|
+
|
|
172
|
+
# -- positions ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def _pos(self, ctx: Any, role: str, v: Any, coords: str) -> float:
|
|
175
|
+
"""Page coordinate of ``v`` along ``role`` (``x`` or ``y``)."""
|
|
176
|
+
x0, y0, x1, y1 = ctx.rect
|
|
177
|
+
if coords == "axes":
|
|
178
|
+
return x0 + float(v) * (x1 - x0) if role == "x" else y1 - float(v) * (y1 - y0)
|
|
179
|
+
if coords == "figure":
|
|
180
|
+
fw, fh = getattr(ctx, "figure_size", (x1, y1))
|
|
181
|
+
return float(v) * fw if role == "x" else (1.0 - float(v)) * fh
|
|
182
|
+
state = ctx.x if role == "x" else ctx.y
|
|
183
|
+
mapper = ctx.xmap if role == "x" else ctx.ymap
|
|
184
|
+
return mapper(state.value(v))
|
|
185
|
+
|
|
186
|
+
def _along(self, ctx: Any, name: str, role: str) -> float:
|
|
187
|
+
"""Page coordinate of position setting ``name`` (``min``, ``max``) along ``role``: a
|
|
188
|
+
data value, or `%` of the axes (a length) from their left or bottom edge."""
|
|
189
|
+
x0, y0, x1, y1 = ctx.rect
|
|
190
|
+
raw = self._raw(name)
|
|
191
|
+
if isinstance(raw, (int, float)):
|
|
192
|
+
return self._pos(ctx, role, raw, "data")
|
|
193
|
+
px = self.resolve(name, of=(x1 - x0) if role == "x" else (y1 - y0))
|
|
194
|
+
return x0 + px if role == "x" else y1 - px
|
|
195
|
+
|
|
196
|
+
def draw(self, ctx: Any) -> None:
|
|
197
|
+
raise NotImplementedError
|
|
198
|
+
|
|
199
|
+
def occupy(self, fonts: Any, ctx: Any, occ: Any) -> None:
|
|
200
|
+
"""Marks what it draws over the series on the occupancy grid ``occ`` (guides
|
|
201
|
+
placed inside the axes avoid it): text and lines; spans, drawn under the series,
|
|
202
|
+
mark nothing."""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class _Rule(Decoration):
|
|
206
|
+
"""A horizontal or vertical reference line across part of the axes."""
|
|
207
|
+
|
|
208
|
+
VERTICAL = False
|
|
209
|
+
value: Any = None
|
|
210
|
+
|
|
211
|
+
def _ends(self, ctx: Any) -> tuple[list[float], list[float]] | None:
|
|
212
|
+
"""The line's ends on the page (``None``: outside the scale's domain)."""
|
|
213
|
+
if self.VERTICAL:
|
|
214
|
+
p = self._pos(ctx, "x", self.value, "data")
|
|
215
|
+
xs, ys = [p, p], [self._along(ctx, "min", "y"), self._along(ctx, "max", "y")]
|
|
216
|
+
else:
|
|
217
|
+
p = self._pos(ctx, "y", self.value, "data")
|
|
218
|
+
xs, ys = [self._along(ctx, "min", "x"), self._along(ctx, "max", "x")], [p, p]
|
|
219
|
+
return (xs, ys) if all(math.isfinite(v) for v in xs + ys) else None
|
|
220
|
+
|
|
221
|
+
def occupy(self, fonts: Any, ctx: Any, occ: Any) -> None:
|
|
222
|
+
ends = self._ends(ctx)
|
|
223
|
+
if ends is not None:
|
|
224
|
+
occ.lines(*ends)
|
|
225
|
+
|
|
226
|
+
def draw(self, ctx: Any) -> None:
|
|
227
|
+
c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
|
|
228
|
+
lw = self.resolve("line_width")
|
|
229
|
+
ends = self._ends(ctx)
|
|
230
|
+
if c is None or ends is None:
|
|
231
|
+
return
|
|
232
|
+
xs, ys = ends
|
|
233
|
+
ctx.canvas.draw_lines(xs, ys, stroke=c, stroke_width=lw, stroke_dasharray=dash_of(self.resolve("line_style"), lw),
|
|
234
|
+
stroke_linecap="butt")
|
|
235
|
+
|
|
236
|
+
def data_values(self, role: str) -> list[float]:
|
|
237
|
+
return [self.value]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class HLine(HLineMixin, _Rule):
|
|
241
|
+
"""A horizontal line at ``y`` from ``min`` to ``max`` (data values, or `%` of the axes
|
|
242
|
+
width).
|
|
243
|
+
|
|
244
|
+
Decorations (lines, spans, text, annotations, brackets) take no color of the plot's, are in
|
|
245
|
+
the legend only with a ``name``, and change the limits only with
|
|
246
|
+
``extend_limits=True``. Spans are drawn under the series (``z=-1``), lines and text over
|
|
247
|
+
them (``z=1``). Positions are axis values (numbers, dates, ISO 8601 text, category
|
|
248
|
+
names), or fractions with ``coords="axes"`` or ``"figure"``.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
SETTINGS = table(
|
|
252
|
+
**_line(),
|
|
253
|
+
min=Setting(_AXES_POSITION, doc="Start: a data value, or `%` (or a length) "
|
|
254
|
+
"from the start of the axes."),
|
|
255
|
+
max=Setting(_AXES_POSITION, doc="End: a data value, or `%` (or a length) from "
|
|
256
|
+
"the start of the axes."),
|
|
257
|
+
**_common(),
|
|
258
|
+
)
|
|
259
|
+
ROLES = ("y",)
|
|
260
|
+
|
|
261
|
+
def __init__(self, plot: Any, y: Any, **settings: Any) -> None:
|
|
262
|
+
self.value = y
|
|
263
|
+
super().__init__(plot, **settings)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class VLine(VLineMixin, _Rule):
|
|
267
|
+
"""A vertical line at ``x`` from ``min`` to ``max`` (data values, or `%` of the axes
|
|
268
|
+
height)."""
|
|
269
|
+
|
|
270
|
+
SETTINGS = HLine.SETTINGS
|
|
271
|
+
ROLES = ("x",)
|
|
272
|
+
VERTICAL = True
|
|
273
|
+
|
|
274
|
+
def __init__(self, plot: Any, x: Any, **settings: Any) -> None:
|
|
275
|
+
self.value = x
|
|
276
|
+
super().__init__(plot, **settings)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class AxLine(AxLineMixin, Decoration):
|
|
280
|
+
"""An infinite straight line through ``point`` and ``point2``, or with ``slope`` (data
|
|
281
|
+
units; linear axes)."""
|
|
282
|
+
|
|
283
|
+
SETTINGS = table(**_line(), **_common())
|
|
284
|
+
ROLES = ("x", "y")
|
|
285
|
+
|
|
286
|
+
def __init__(self, plot: Any, point: Any, slope: Any = None, point2: Any = None,
|
|
287
|
+
**settings: Any) -> None:
|
|
288
|
+
self.p1 = _point(point)
|
|
289
|
+
if (slope is None) == (point2 is None):
|
|
290
|
+
raise ValueError("give either slope or point2")
|
|
291
|
+
self.p2 = _point(point2) if point2 is not None else None
|
|
292
|
+
self.slope = None if slope is None else float(slope)
|
|
293
|
+
super().__init__(plot, **settings)
|
|
294
|
+
|
|
295
|
+
def data_values(self, role: str) -> list[float]:
|
|
296
|
+
i = 0 if role == "x" else 1
|
|
297
|
+
pts = [self.p1] + ([self.p2] if self.p2 is not None else [])
|
|
298
|
+
return [p[i] for p in pts]
|
|
299
|
+
|
|
300
|
+
def draw(self, ctx: Any) -> None:
|
|
301
|
+
c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
|
|
302
|
+
lw = self.resolve("line_width")
|
|
303
|
+
if c is None:
|
|
304
|
+
return
|
|
305
|
+
if self.p2 is None:
|
|
306
|
+
for st in (ctx.x, ctx.y):
|
|
307
|
+
if st.scale is None or st.scale.name != "linear":
|
|
308
|
+
raise ValueError("axline with a slope needs linear axes; give point2")
|
|
309
|
+
p2 = (float(self.p1[0]) + 1.0, float(self.p1[1]) + float(self.slope or 0.0))
|
|
310
|
+
else:
|
|
311
|
+
p2 = self.p2
|
|
312
|
+
ax, ay = self._pos(ctx, "x", self.p1[0], "data"), self._pos(ctx, "y", self.p1[1], "data")
|
|
313
|
+
bx, by = self._pos(ctx, "x", p2[0], "data"), self._pos(ctx, "y", p2[1], "data")
|
|
314
|
+
dx, dy = bx - ax, by - ay
|
|
315
|
+
n = math.hypot(dx, dy)
|
|
316
|
+
if not n or not math.isfinite(n):
|
|
317
|
+
return
|
|
318
|
+
x0, y0, x1, y1 = ctx.rect
|
|
319
|
+
far = 2.0 * (math.hypot(x1 - x0, y1 - y0) + abs(ax - x0) + abs(ay - y0)) / n
|
|
320
|
+
ctx.canvas.draw_lines([ax - far * dx, ax + far * dx], [ay - far * dy, ay + far * dy],
|
|
321
|
+
stroke=c, stroke_width=lw, stroke_dasharray=dash_of(self.resolve("line_style"), lw))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class _Span(Decoration):
|
|
325
|
+
VERTICAL = False
|
|
326
|
+
v0: Any = None
|
|
327
|
+
v1: Any = None
|
|
328
|
+
|
|
329
|
+
def draw(self, ctx: Any) -> None:
|
|
330
|
+
c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
|
|
331
|
+
if c is None:
|
|
332
|
+
return
|
|
333
|
+
if self.VERTICAL:
|
|
334
|
+
a, b = self._end(ctx, "x", self.v0), self._end(ctx, "x", self.v1)
|
|
335
|
+
lo, hi = self._along(ctx, "min", "y"), self._along(ctx, "max", "y")
|
|
336
|
+
r = (min(a, b), min(lo, hi), abs(b - a), abs(lo - hi))
|
|
337
|
+
else:
|
|
338
|
+
a, b = self._end(ctx, "y", self.v0), self._end(ctx, "y", self.v1)
|
|
339
|
+
lo, hi = self._along(ctx, "min", "x"), self._along(ctx, "max", "x")
|
|
340
|
+
r = (min(lo, hi), min(a, b), abs(hi - lo), abs(b - a))
|
|
341
|
+
if not all(math.isfinite(v) for v in r):
|
|
342
|
+
return
|
|
343
|
+
canvas = ctx.canvas
|
|
344
|
+
path = Path().rect(*r)
|
|
345
|
+
canvas.draw_path(path, fill=c, stroke=None)
|
|
346
|
+
lc = ctx.color(self.resolve("edge_color"))
|
|
347
|
+
if lc is not None and self.resolve("edge_width"):
|
|
348
|
+
canvas.stroke_width = self.resolve("edge_width")
|
|
349
|
+
canvas.draw_path(path, fill=None, stroke=lc)
|
|
350
|
+
|
|
351
|
+
def _end(self, ctx: Any, role: str, v: Any) -> float:
|
|
352
|
+
"""Page coordinate of an end of the span: outside the scale's domain (0 on a log
|
|
353
|
+
axis), the axes edge on that side."""
|
|
354
|
+
p = self._pos(ctx, role, v, "data")
|
|
355
|
+
if math.isfinite(p):
|
|
356
|
+
return p
|
|
357
|
+
x0, y0, x1, y1 = ctx.rect
|
|
358
|
+
a, b = (x0, x1) if role == "x" else (y0, y1)
|
|
359
|
+
if math.isnan(p): # below the domain: the low limit's edge
|
|
360
|
+
state = ctx.x if role == "x" else ctx.y
|
|
361
|
+
low = (ctx.xmap if role == "x" else ctx.ymap)(state.lo)
|
|
362
|
+
return a if abs(low - a) <= abs(low - b) else b
|
|
363
|
+
return a if p < a else b
|
|
364
|
+
|
|
365
|
+
def data_values(self, role: str) -> list[float]:
|
|
366
|
+
return [self.v0, self.v1]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class HSpan(HSpanMixin, _Span):
|
|
370
|
+
"""A horizontal band from ``y0`` to ``y1``, from ``min`` to ``max`` of the axes width."""
|
|
371
|
+
|
|
372
|
+
SETTINGS = table(
|
|
373
|
+
color=Setting(color, doc="Fill color."),
|
|
374
|
+
**groups.Stroke.embed("edge_",
|
|
375
|
+
only=("color", "line_width"),
|
|
376
|
+
docs={"color": "Outline color; default none.",
|
|
377
|
+
"line_width": "Outline width."}),
|
|
378
|
+
min=Setting(_AXES_POSITION, doc="Start: a data value, or `%` (or a length) "
|
|
379
|
+
"from the start of the axes."),
|
|
380
|
+
max=Setting(_AXES_POSITION, doc="End: a data value, or `%` (or a length) from "
|
|
381
|
+
"the start of the axes."),
|
|
382
|
+
**_common(),
|
|
383
|
+
)
|
|
384
|
+
ROLES = ("y",)
|
|
385
|
+
|
|
386
|
+
def __init__(self, plot: Any, y0: Any, y1: Any, **settings: Any) -> None:
|
|
387
|
+
self.v0, self.v1 = y0, y1
|
|
388
|
+
super().__init__(plot, **settings)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class VSpan(VSpanMixin, _Span):
|
|
392
|
+
"""A vertical band from ``x0`` to ``x1``, from ``min`` to ``max`` of the axes height."""
|
|
393
|
+
|
|
394
|
+
SETTINGS = HSpan.SETTINGS
|
|
395
|
+
ROLES = ("x",)
|
|
396
|
+
VERTICAL = True
|
|
397
|
+
|
|
398
|
+
def __init__(self, plot: Any, x0: Any, x1: Any, **settings: Any) -> None:
|
|
399
|
+
self.v0, self.v1 = x0, x1
|
|
400
|
+
super().__init__(plot, **settings)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class Text(TextMixin, Decoration):
|
|
404
|
+
"""Text at ``(x, y)``: a string or rich content.
|
|
405
|
+
|
|
406
|
+
A line break starts a line; ``rotation`` turns the text about its point. Text past the
|
|
407
|
+
axes (unclipped) widens the plot's margins; it is never moved.
|
|
408
|
+
"""
|
|
409
|
+
|
|
410
|
+
SETTINGS = table(
|
|
411
|
+
align=Setting(one_of("left", "center", "right"),
|
|
412
|
+
doc="Horizontal alignment on the point."),
|
|
413
|
+
valign=Setting(one_of("baseline", "top", "center", "bottom"),
|
|
414
|
+
doc="Vertical alignment on the point."),
|
|
415
|
+
rotation=Setting(angle(), doc="Rotation (degrees, counterclockwise)."),
|
|
416
|
+
coords=Setting(_COORDS, doc="`data`, `axes` (0–1) or `figure` (0–1)."),
|
|
417
|
+
**groups.TextStyle.embed(only=("color",),
|
|
418
|
+
docs={"color": "Text color; default `text_color`."}),
|
|
419
|
+
**_font(),
|
|
420
|
+
**{k: v for k, v in _common().items() if k != "clip"},
|
|
421
|
+
clip=Setting(boolean, doc="Clip to the axes area."),
|
|
422
|
+
)
|
|
423
|
+
ROLES = ("x", "y")
|
|
424
|
+
|
|
425
|
+
def __init__(self, plot: Any, x: Any, y: Any, content: Any, **settings: Any) -> None:
|
|
426
|
+
self.x, self.y, self.content = x, y, content
|
|
427
|
+
super().__init__(plot, **settings)
|
|
428
|
+
|
|
429
|
+
def has_axis(self, role: str) -> bool:
|
|
430
|
+
return super().has_axis(role) and self.resolve("coords") == "data"
|
|
431
|
+
|
|
432
|
+
def data_values(self, role: str) -> list[float]:
|
|
433
|
+
return [self.x if role == "x" else self.y]
|
|
434
|
+
|
|
435
|
+
def font(self) -> Font:
|
|
436
|
+
c = self.resolve("color")
|
|
437
|
+
return font_of(self, self.resolve("font_size"), self.resolve("font_weight"),
|
|
438
|
+
self.resolve("font_style"), c)
|
|
439
|
+
|
|
440
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
441
|
+
return ctx.color(self.resolve("color")), None
|
|
442
|
+
|
|
443
|
+
def _at(self, ctx: Any) -> tuple[float, float]:
|
|
444
|
+
"""The page point the text is anchored at."""
|
|
445
|
+
coords = self.resolve("coords")
|
|
446
|
+
return self._pos(ctx, "x", self.x, coords), self._pos(ctx, "y", self.y, coords)
|
|
447
|
+
|
|
448
|
+
def _rotation(self) -> float:
|
|
449
|
+
return float(self.resolve("rotation"))
|
|
450
|
+
|
|
451
|
+
def text_run(self, fonts: Any, ctx: Any) -> "Run | tuple[Run, ...] | None":
|
|
452
|
+
"""The text as drawn on the axes of ``ctx``: a run per line (lines split at
|
|
453
|
+
``\\n``; ``None``: nothing drawn, its point outside the scale's domain or no
|
|
454
|
+
content)."""
|
|
455
|
+
px, py = self._at(ctx)
|
|
456
|
+
if not (math.isfinite(px) and math.isfinite(py)):
|
|
457
|
+
return None
|
|
458
|
+
font = self.font()
|
|
459
|
+
font.color = with_alpha(resolve_color(font.color, ctx.cycle), self.resolve("opacity"))
|
|
460
|
+
return lines_run(fonts, self.content, px, py, font, self.resolve("align"),
|
|
461
|
+
self.resolve("valign"), self._rotation())
|
|
462
|
+
|
|
463
|
+
def _corners(self, fonts: Any, ctx: Any) -> tuple[list[float], list[float]] | None:
|
|
464
|
+
"""The corners of the drawn text's (rotated) box on the page."""
|
|
465
|
+
from .labels import run_quad
|
|
466
|
+
q = run_quad(fonts, self.text_run(fonts, ctx))
|
|
467
|
+
if q is None:
|
|
468
|
+
return None
|
|
469
|
+
return [p[0] for p in q], [p[1] for p in q]
|
|
470
|
+
|
|
471
|
+
def occupy(self, fonts: Any, ctx: Any, occ: Any) -> None:
|
|
472
|
+
got = self._corners(fonts, ctx)
|
|
473
|
+
if got is not None:
|
|
474
|
+
xs, ys = got
|
|
475
|
+
occ.rects([min(xs)], [min(ys)], [max(xs)], [max(ys)], solid=True)
|
|
476
|
+
|
|
477
|
+
def reach(self, fonts: Any, ctx: Any) -> tuple[float, float, float, float]:
|
|
478
|
+
"""How far the text drawn on the axes of ``ctx`` passes them on the left, right,
|
|
479
|
+
top and bottom (px; 0 within)."""
|
|
480
|
+
got = self._corners(fonts, ctx)
|
|
481
|
+
if got is None:
|
|
482
|
+
return 0.0, 0.0, 0.0, 0.0
|
|
483
|
+
xs, ys = got
|
|
484
|
+
x0, y0, x1, y1 = ctx.rect
|
|
485
|
+
return (max(0.0, x0 - min(xs)), max(0.0, max(xs) - x1), max(0.0, y0 - min(ys)),
|
|
486
|
+
max(0.0, max(ys) - y1))
|
|
487
|
+
|
|
488
|
+
def counts(self) -> bool:
|
|
489
|
+
"""Whether the margins make room for the text: drawn unclipped, placed by the axes
|
|
490
|
+
(data or axes coordinates; text placed on the figure has no plot to grow)."""
|
|
491
|
+
return not self.resolve("clip") and self.resolve("coords") != "figure"
|
|
492
|
+
|
|
493
|
+
def draw(self, ctx: Any) -> None:
|
|
494
|
+
draw_run(ctx.canvas, self.text_run(ctx.canvas, ctx))
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
class Annotate(AnnotateMixin, Text):
|
|
498
|
+
"""Text at ``xytext`` with an arrow to ``xy``.
|
|
499
|
+
|
|
500
|
+
``text_coords="offset"`` gives ``xytext`` in points from ``xy`` (y up). The arrow runs
|
|
501
|
+
from the edge of the text box to ``xy``, ``shrink`` points short of both.
|
|
502
|
+
"""
|
|
503
|
+
|
|
504
|
+
SETTINGS = table(
|
|
505
|
+
**{k: v for k, v in Text.SETTINGS.items() if k not in ("align", "valign")},
|
|
506
|
+
align=Setting(one_of("left", "center", "right"),
|
|
507
|
+
doc="Horizontal alignment on `xytext`."),
|
|
508
|
+
valign=Setting(one_of("baseline", "top", "center", "bottom"),
|
|
509
|
+
doc="Vertical alignment on `xytext`."),
|
|
510
|
+
text_coords=Setting(one_of("data", "axes", "figure", "offset"),
|
|
511
|
+
doc="Coordinates of `xytext`: as `coords`, or `offset` (px from "
|
|
512
|
+
"`xy`, y up); default `coords`."),
|
|
513
|
+
arrow=Setting(boolean, doc="Draw an arrow from the text to `xy`."),
|
|
514
|
+
arrow_style=Setting(_arrow_style,
|
|
515
|
+
doc="`->` (open head), `-|>` (filled head) or `-` (no head)."),
|
|
516
|
+
**groups.Stroke.embed("arrow_",
|
|
517
|
+
only=("color", "line_width"),
|
|
518
|
+
docs={"color": "Arrow color; default the text color.",
|
|
519
|
+
"line_width": "Arrow line width."}),
|
|
520
|
+
head_length=Setting(length(lo=0), doc="Arrowhead length."),
|
|
521
|
+
shrink=Setting(length(lo=0), doc="Gap left at both ends of the arrow."),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
def __init__(self, plot: Any, content: Any, xy: Any, xytext: Any = None,
|
|
525
|
+
**settings: Any) -> None:
|
|
526
|
+
self.xy = _point(xy)
|
|
527
|
+
self.xytext = _point(xytext) if xytext is not None else None
|
|
528
|
+
x, y = self.xytext if self.xytext is not None else self.xy
|
|
529
|
+
super().__init__(plot, x, y, content, **settings)
|
|
530
|
+
|
|
531
|
+
def data_values(self, role: str) -> list[float]:
|
|
532
|
+
i = 0 if role == "x" else 1
|
|
533
|
+
out = [self.xy[i]]
|
|
534
|
+
if self.xytext is not None and self.resolve("text_coords") == "data":
|
|
535
|
+
out.append(self.xytext[i])
|
|
536
|
+
return out
|
|
537
|
+
|
|
538
|
+
def _point(self, ctx: Any) -> tuple[float, float]:
|
|
539
|
+
"""The page point the arrow points to (``xy``)."""
|
|
540
|
+
coords = self.resolve("coords")
|
|
541
|
+
return self._pos(ctx, "x", self.xy[0], coords), self._pos(ctx, "y", self.xy[1], coords)
|
|
542
|
+
|
|
543
|
+
def _at(self, ctx: Any) -> tuple[float, float]:
|
|
544
|
+
ax, ay = self._point(ctx)
|
|
545
|
+
tx = self.resolve("text_coords")
|
|
546
|
+
if self.xytext is None:
|
|
547
|
+
return ax, ay
|
|
548
|
+
if tx == "offset":
|
|
549
|
+
return ax + float(self.xytext[0]), ay - float(self.xytext[1])
|
|
550
|
+
return self._pos(ctx, "x", self.xytext[0], tx), self._pos(ctx, "y", self.xytext[1], tx)
|
|
551
|
+
|
|
552
|
+
def counts(self) -> bool:
|
|
553
|
+
tc = self.resolve("text_coords")
|
|
554
|
+
return not self.resolve("clip") and (
|
|
555
|
+
tc != "figure" if self.xytext is not None and tc != "offset"
|
|
556
|
+
else self.resolve("coords") != "figure")
|
|
557
|
+
|
|
558
|
+
def draw(self, ctx: Any) -> None:
|
|
559
|
+
canvas = ctx.canvas
|
|
560
|
+
px, py = self._at(ctx)
|
|
561
|
+
if not (math.isfinite(px) and math.isfinite(py)):
|
|
562
|
+
return # outside the scale's domain
|
|
563
|
+
ax, ay = self._point(ctx)
|
|
564
|
+
draw_run(canvas, self.text_run(canvas, ctx))
|
|
565
|
+
got = self._corners(canvas, ctx)
|
|
566
|
+
box = (min(got[0]), min(got[1]), max(got[0]), max(got[1])) if got else (px, py, px, py)
|
|
567
|
+
if self.resolve("arrow") and self.xytext is not None and math.isfinite(ax) \
|
|
568
|
+
and math.isfinite(ay):
|
|
569
|
+
ac = with_alpha(ctx.color(self.resolve("arrow_color")), self.resolve("opacity"))
|
|
570
|
+
self._arrow(canvas, box, ax, ay, ac)
|
|
571
|
+
|
|
572
|
+
def _arrow(self, canvas: Canvas, box: tuple[float, float, float, float], ax: float, ay: float,
|
|
573
|
+
c: Any) -> None:
|
|
574
|
+
x0, y0, x1, y1 = box
|
|
575
|
+
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
|
576
|
+
dx, dy = ax - cx, ay - cy
|
|
577
|
+
n = math.hypot(dx, dy)
|
|
578
|
+
if not n or c is None:
|
|
579
|
+
return
|
|
580
|
+
ux, uy = dx / n, dy / n
|
|
581
|
+
# leave the text box: smallest t where the ray crosses its edge
|
|
582
|
+
ts = []
|
|
583
|
+
if ux:
|
|
584
|
+
ts.append(((x1 if ux > 0 else x0) - cx) / ux)
|
|
585
|
+
if uy:
|
|
586
|
+
ts.append(((y1 if uy > 0 else y0) - cy) / uy)
|
|
587
|
+
t0 = min(t for t in ts if t >= 0) if ts else 0.0
|
|
588
|
+
shrink = self.resolve("shrink")
|
|
589
|
+
t0 += shrink
|
|
590
|
+
t1 = n - shrink
|
|
591
|
+
if t1 <= t0:
|
|
592
|
+
return
|
|
593
|
+
sx, sy = cx + ux * t0, cy + uy * t0
|
|
594
|
+
ex, ey = cx + ux * t1, cy + uy * t1
|
|
595
|
+
lw = self.resolve("arrow_width")
|
|
596
|
+
style = self.resolve("arrow_style")
|
|
597
|
+
hs = self.resolve("head_length")
|
|
598
|
+
if style == "-|>":
|
|
599
|
+
ex2, ey2 = ex - ux * hs * 0.9, ey - uy * hs * 0.9
|
|
600
|
+
else:
|
|
601
|
+
ex2, ey2 = ex, ey
|
|
602
|
+
canvas.draw_lines([sx, ex2], [sy, ey2], stroke=c, stroke_width=lw, stroke_linecap="butt")
|
|
603
|
+
if style == "-" or not hs:
|
|
604
|
+
return
|
|
605
|
+
hw = 0.45 * hs
|
|
606
|
+
px_, py_ = -uy, ux
|
|
607
|
+
bx, by = ex - ux * hs, ey - uy * hs
|
|
608
|
+
with canvas.state():
|
|
609
|
+
path = Path().move_to(bx + px_ * hw, by + py_ * hw).line_to(ex, ey)
|
|
610
|
+
path.line_to(bx - px_ * hw, by - py_ * hw)
|
|
611
|
+
if style == "-|>":
|
|
612
|
+
canvas.draw_path(path.close(), fill=c, stroke=None)
|
|
613
|
+
else:
|
|
614
|
+
canvas.stroke_width = lw
|
|
615
|
+
canvas.stroke_linejoin = "miter"
|
|
616
|
+
canvas.draw_path(path, fill=None, stroke=c)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# -- brackets ----------------------------------------------------------------------------
|
|
620
|
+
|
|
621
|
+
def _stars(v: Any) -> tuple[float, ...]:
|
|
622
|
+
out = tuple(float(number(0, 1)(x)) for x in v)
|
|
623
|
+
if not out or any(a <= b for a, b in zip(out, out[1:])):
|
|
624
|
+
raise ValueError(f"expected decreasing thresholds in (0, 1], got {v!r}")
|
|
625
|
+
return out
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _p_format(v: Any) -> Any:
|
|
629
|
+
if v in ("stars", "p"):
|
|
630
|
+
return v
|
|
631
|
+
if isinstance(v, str) and "{" in v:
|
|
632
|
+
try:
|
|
633
|
+
v.format(p=0.5)
|
|
634
|
+
except (IndexError, KeyError, ValueError) as e:
|
|
635
|
+
raise ValueError(f"invalid format string {v!r}: {e}") from None
|
|
636
|
+
return v
|
|
637
|
+
raise ValueError(f"expected 'stars', 'p' or a format string with {{p}}, got {v!r}")
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
class Bracket(BracketMixin, Decoration):
|
|
641
|
+
"""A line from ``a`` to ``b`` (categories or positions on the category axis) with tips
|
|
642
|
+
toward the data and text beyond it: ``text``, or ``p`` written as ``format`` says.
|
|
643
|
+
Unset ``y``: ``step`` above the highest value the plot's series draw between ``a`` and
|
|
644
|
+
``b``, and ``step`` plus the text (and ``gap``) above every bracket of the plot it
|
|
645
|
+
overlaps (shorter brackets are placed first). Categories on y: the bracket stands
|
|
646
|
+
right of the data, its text rotated.
|
|
647
|
+
|
|
648
|
+
``format``: ``"stars"`` (``*`` to ``****`` at p ≤ 0.05, 0.01, 0.001, 0.0001: ``stars``;
|
|
649
|
+
else ``ns``), ``"p"`` (``p = 0.003``, ``p < 0.0001``, in the plot's number format) or a
|
|
650
|
+
format string (``"{p:.2g}"``). plot_io computes no test: the caller gives p. Tips stop
|
|
651
|
+
short of the data or of the bracket under each end. Brackets are in the axis limits and
|
|
652
|
+
their text widens the margins. ``plot.brackets(pairs, p=…)`` adds one per pair; on a
|
|
653
|
+
facet set, ``panels.bracket`` and ``panels.brackets`` add them to every panel.
|
|
654
|
+
"""
|
|
655
|
+
|
|
656
|
+
SETTINGS = table(
|
|
657
|
+
**_line(),
|
|
658
|
+
y=Setting(either(literal(None), number()),
|
|
659
|
+
doc="Value of the line on the value axis; unset: placed above the data."),
|
|
660
|
+
format=Setting(_p_format,
|
|
661
|
+
doc="Text of `p`: `stars` (`*` … `****` at each of `stars`, else "
|
|
662
|
+
"`ns`), `p` (`p = 0.003`, `p < 0.0001`) or a format string "
|
|
663
|
+
"(`\"{p:.2g}\"`)."),
|
|
664
|
+
stars=Setting(_stars, doc="Thresholds of `*`, `**`, `***`, `****` (p at most each)."),
|
|
665
|
+
tip=Setting(length(lo=0, percent="em"), doc="Length of the tips toward the data."),
|
|
666
|
+
step=Setting(length(lo=0, percent="axes_length", bare="data"),
|
|
667
|
+
doc="Distance above the data and between tiers: `%` of the data's "
|
|
668
|
+
"range on the value axis, or bare numbers in its units."),
|
|
669
|
+
gap=Setting(length(lo=0, percent="em"), doc="Gap between the line and the text."),
|
|
670
|
+
**_font(),
|
|
671
|
+
**_common(),
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
def __init__(self, plot: Any, a: Any, b: Any, text: Any = None, p: Any = None,
|
|
675
|
+
**settings: Any) -> None:
|
|
676
|
+
self.a, self.b, self.text, self.p = a, b, text, p
|
|
677
|
+
if p is not None:
|
|
678
|
+
p = float(p)
|
|
679
|
+
if not 0.0 <= p <= 1.0:
|
|
680
|
+
raise ValueError(f"bracket: p must be in [0, 1], got {p!r}")
|
|
681
|
+
self.p = p
|
|
682
|
+
super().__init__(plot, **settings)
|
|
683
|
+
|
|
684
|
+
def content(self) -> Any:
|
|
685
|
+
"""The text drawn (``None``: none)."""
|
|
686
|
+
if self.text is not None or self.p is None:
|
|
687
|
+
return self.text
|
|
688
|
+
fmt, p = self.resolve("format"), self.p
|
|
689
|
+
if fmt == "stars":
|
|
690
|
+
n = sum(p <= t for t in self.resolve("stars"))
|
|
691
|
+
return "*" * n if n else "ns"
|
|
692
|
+
if fmt == "p":
|
|
693
|
+
from .numbers import NumberStyle, _restyle
|
|
694
|
+
style = NumberStyle.of(self.plot)
|
|
695
|
+
low = min(self.resolve("stars"))
|
|
696
|
+
if p < low:
|
|
697
|
+
return "p < " + _restyle(format(low, "g"), style)
|
|
698
|
+
return "p = " + _restyle(format(p, ".2g"), style)
|
|
699
|
+
return fmt.format(p=p)
|
|
700
|
+
|
|
701
|
+
# -- placement (all brackets of the plot together) -------------------------------
|
|
702
|
+
|
|
703
|
+
def _placed(self, scale: Any) -> tuple[str, float, float, float, float, float]:
|
|
704
|
+
"""``(value role, position of a, of b, line, floor at a, floor at b)``: the line
|
|
705
|
+
and the floors under its ends (the data or lower brackets) in the scale's space."""
|
|
706
|
+
return _layout(self.plot, scale)[id(self)][:6]
|
|
707
|
+
|
|
708
|
+
def _clear(self, fonts: Any) -> float:
|
|
709
|
+
"""How far (px) a bracket above this one passes its line: its text and ``gap``."""
|
|
710
|
+
content = self.content()
|
|
711
|
+
if content is None or content == "":
|
|
712
|
+
return 0.0
|
|
713
|
+
from .labels import quad
|
|
714
|
+
font = font_of(self, self.resolve("font_size"), self.resolve("font_weight"),
|
|
715
|
+
self.resolve("font_style"), self.resolve("text_color"))
|
|
716
|
+
q = quad(fonts, content, font, 0.0, 0.0, "center", "bottom")
|
|
717
|
+
return 0.0 if q is None else (max(p[1] for p in q) - min(p[1] for p in q)
|
|
718
|
+
+ self.resolve("gap"))
|
|
719
|
+
|
|
720
|
+
def _lifts(self, scale: Any, fonts: Any) -> tuple[float, float, float]:
|
|
721
|
+
"""How far (px) the line and the floors at ``a`` and ``b`` lie past their places
|
|
722
|
+
in the scale's space: each automatic line clears the text of the brackets under
|
|
723
|
+
it."""
|
|
724
|
+
got = _layout(self.plot, scale)
|
|
725
|
+
lift: dict[int, float] = {}
|
|
726
|
+
for k in sorted(got, key=lambda k: got[k][3]):
|
|
727
|
+
lift[k] = max((lift[q] + got[q][8]._clear(fonts) for q in got[k][6]),
|
|
728
|
+
default=0.0)
|
|
729
|
+
fa, fb = got[id(self)][7]
|
|
730
|
+
return (lift[id(self)], 0.0 if fa is None else lift[fa],
|
|
731
|
+
0.0 if fb is None else lift[fb])
|
|
732
|
+
|
|
733
|
+
def label_room(self, fonts: Any, role: str) -> list[tuple[float, float, int]]:
|
|
734
|
+
"""The room (px) the axis keeps past the lines that clear lower brackets' text."""
|
|
735
|
+
got = self.plot.__dict__.get("_brackets")
|
|
736
|
+
if got is None or id(self) not in got[2] or role != got[2][id(self)][0]:
|
|
737
|
+
return []
|
|
738
|
+
lift = self._lifts(got[3], fonts)[0]
|
|
739
|
+
v = got[2][id(self)][3]
|
|
740
|
+
if got[3] is not None and got[3].function:
|
|
741
|
+
v = got[3].inverse(v)
|
|
742
|
+
return [(v, lift, 1)] if lift > 0 and math.isfinite(v) else []
|
|
743
|
+
|
|
744
|
+
def has_axis(self, role: str) -> bool:
|
|
745
|
+
return self.resolve("extend_limits") and role == _orient(self.plot)[0]
|
|
746
|
+
|
|
747
|
+
def extent(self, role: str, scale: Any) -> tuple[int, float, float]:
|
|
748
|
+
line = self._placed(scale)[3]
|
|
749
|
+
v = scale.inverse(line) if scale.function else line
|
|
750
|
+
return (1, v, v) if math.isfinite(v) else (0, math.nan, math.nan)
|
|
751
|
+
|
|
752
|
+
def _geometry(self, ctx: Any, fonts: Any = None
|
|
753
|
+
) -> tuple[list[float], list[float], float, float, str] | None:
|
|
754
|
+
"""The polyline (tips and line) on the page, the text's anchor and the value role
|
|
755
|
+
(text measured with ``fonts``, else the context's canvas)."""
|
|
756
|
+
vrole = _orient(self.plot)[0]
|
|
757
|
+
crole = "x" if vrole == "y" else "y"
|
|
758
|
+
st = ctx.y if vrole == "y" else ctx.x
|
|
759
|
+
sc = st.scale
|
|
760
|
+
_, pa, pb, line, fa, fb = self._placed(sc)
|
|
761
|
+
vmap = ctx.ymap if vrole == "y" else ctx.xmap
|
|
762
|
+
up = -1.0 if vrole == "y" else 1.0 # the page direction away from the data
|
|
763
|
+
lifts = self._lifts(sc, ctx.canvas if fonts is None else fonts)
|
|
764
|
+
|
|
765
|
+
def page(f: float, lift: float) -> float:
|
|
766
|
+
return vmap(sc.inverse(f) if sc is not None and sc.function else f) + up * lift
|
|
767
|
+
a, b = self._pos(ctx, crole, pa, "data"), self._pos(ctx, crole, pb, "data")
|
|
768
|
+
lv = page(line, lifts[0])
|
|
769
|
+
if not all(math.isfinite(v) for v in (a, b, lv)):
|
|
770
|
+
return None
|
|
771
|
+
tip = self.resolve("tip")
|
|
772
|
+
tips = []
|
|
773
|
+
for f, lift in ((fa, lifts[1]), (fb, lifts[2])):
|
|
774
|
+
room = abs(page(f, lift) - lv) / 2 if math.isfinite(f) else tip
|
|
775
|
+
tips.append(lv - up * min(tip, room if math.isfinite(room) else tip))
|
|
776
|
+
us, vs = [a, a, b, b], [tips[0], lv, lv, tips[1]]
|
|
777
|
+
gap = self.resolve("gap")
|
|
778
|
+
if vrole == "y":
|
|
779
|
+
return us, vs, (a + b) / 2, lv + up * gap, vrole
|
|
780
|
+
return vs, us, lv + up * gap, (a + b) / 2, vrole
|
|
781
|
+
|
|
782
|
+
def text_run(self, fonts: Any, ctx: Any) -> Any:
|
|
783
|
+
g = self._geometry(ctx, fonts)
|
|
784
|
+
content = self.content()
|
|
785
|
+
if g is None or content is None or content == "":
|
|
786
|
+
return None
|
|
787
|
+
font = font_of(self, self.resolve("font_size"), self.resolve("font_weight"),
|
|
788
|
+
self.resolve("font_style"), self.resolve("text_color"))
|
|
789
|
+
font.color = with_alpha(resolve_color(font.color, ctx.cycle), self.resolve("opacity"))
|
|
790
|
+
if g[4] == "y":
|
|
791
|
+
return lines_run(fonts, content, g[2], g[3], font, "center", "bottom", 0.0)
|
|
792
|
+
return lines_run(fonts, content, g[2], g[3], font, "center", "bottom", -90.0)
|
|
793
|
+
|
|
794
|
+
def _corners(self, fonts: Any, ctx: Any) -> tuple[list[float], list[float]] | None:
|
|
795
|
+
"""The corners of the drawn text's (rotated) box on the page."""
|
|
796
|
+
from .labels import run_quad
|
|
797
|
+
q = run_quad(fonts, self.text_run(fonts, ctx))
|
|
798
|
+
return None if q is None else ([p[0] for p in q], [p[1] for p in q])
|
|
799
|
+
|
|
800
|
+
def reach(self, fonts: Any, ctx: Any) -> tuple[float, float, float, float]:
|
|
801
|
+
"""How far the text passes the axes on the left, right, top and bottom (px)."""
|
|
802
|
+
return Text.reach(self, fonts, ctx) # type: ignore[arg-type]
|
|
803
|
+
|
|
804
|
+
def counts(self) -> bool:
|
|
805
|
+
return not self.resolve("clip")
|
|
806
|
+
|
|
807
|
+
def occupy(self, fonts: Any, ctx: Any, occ: Any) -> None:
|
|
808
|
+
g = self._geometry(ctx, fonts)
|
|
809
|
+
if g is not None:
|
|
810
|
+
occ.lines(g[0], g[1])
|
|
811
|
+
got = self._corners(fonts, ctx)
|
|
812
|
+
if got is not None:
|
|
813
|
+
occ.rects([min(got[0])], [min(got[1])], [max(got[0])], [max(got[1])], solid=True)
|
|
814
|
+
|
|
815
|
+
def draw(self, ctx: Any) -> None:
|
|
816
|
+
g = self._geometry(ctx)
|
|
817
|
+
c = with_alpha(ctx.color(self.resolve("color")), self.resolve("opacity"))
|
|
818
|
+
if g is not None and c is not None:
|
|
819
|
+
lw = self.resolve("line_width")
|
|
820
|
+
ctx.canvas.draw_lines(g[0], g[1], stroke=c, stroke_width=lw,
|
|
821
|
+
stroke_dasharray=dash_of(self.resolve("line_style"), lw),
|
|
822
|
+
stroke_linecap="butt", stroke_linejoin="miter")
|
|
823
|
+
draw_run(ctx.canvas, self.text_run(ctx.canvas, ctx))
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _orient(plot: Any) -> tuple[str, dict[str, list[Any]]]:
|
|
827
|
+
"""The value role of the plot's brackets (``"x"`` when its categories are on y or its
|
|
828
|
+
series are horizontal) and the categories of each axis."""
|
|
829
|
+
from .layers.grammar import flipped
|
|
830
|
+
from .layers.pipeline import plot_calls
|
|
831
|
+
cats: dict[str, list[Any]] = {"x": [], "y": []}
|
|
832
|
+
horizontal = False
|
|
833
|
+
for c in plot_calls(plot):
|
|
834
|
+
for r in ("x", "y"):
|
|
835
|
+
for k in c.categories(r) or ():
|
|
836
|
+
if k not in cats[r]:
|
|
837
|
+
cats[r].append(k)
|
|
838
|
+
horizontal = horizontal or flipped(c.series)
|
|
839
|
+
return ("x" if horizontal or (cats["y"] and not cats["x"]) else "y"), cats
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _layout(plot: Any, scale: Any) -> dict[int, tuple[Any, ...]]:
|
|
843
|
+
"""Every bracket of ``plot`` placed (see :meth:`Bracket._placed`); cached per set of
|
|
844
|
+
calls and scale."""
|
|
845
|
+
from .layers.grammar import top
|
|
846
|
+
from .layers.pipeline import plot_calls
|
|
847
|
+
calls = plot_calls(plot)
|
|
848
|
+
key = (scale.name if scale is not None else None,
|
|
849
|
+
tuple(sorted(scale.options.items())) if scale is not None else ())
|
|
850
|
+
got = plot.__dict__.get("_brackets")
|
|
851
|
+
if got is not None and got[0] is calls and got[1] == key:
|
|
852
|
+
return got[2]
|
|
853
|
+
vrole, cats = _orient(plot)
|
|
854
|
+
crole = "x" if vrole == "y" else "y"
|
|
855
|
+
names = cats[crole]
|
|
856
|
+
half = 0.5 if names else 0.0
|
|
857
|
+
fn = scale.f if scale is not None and scale.function else (lambda v: v)
|
|
858
|
+
|
|
859
|
+
def pos(v: Any) -> float:
|
|
860
|
+
if names and not isinstance(v, (int, float)) and v in names:
|
|
861
|
+
return float(names.index(v))
|
|
862
|
+
return float(axis_value(v))
|
|
863
|
+
|
|
864
|
+
def high(lo: float, hi: float) -> float:
|
|
865
|
+
best = -math.inf
|
|
866
|
+
for c in calls:
|
|
867
|
+
v = top(c, lo, hi, vrole, cats)
|
|
868
|
+
if math.isfinite(v):
|
|
869
|
+
f = fn(v)
|
|
870
|
+
if math.isfinite(f):
|
|
871
|
+
best = max(best, f)
|
|
872
|
+
return best
|
|
873
|
+
lo_f, hi_f = math.inf, -math.inf
|
|
874
|
+
for c in calls:
|
|
875
|
+
n, a, b = c.extent(vrole, scale) if scale is not None else (0, 0, 0)
|
|
876
|
+
if n:
|
|
877
|
+
for v in (fn(a), fn(b)):
|
|
878
|
+
if math.isfinite(v):
|
|
879
|
+
lo_f, hi_f = min(lo_f, v), max(hi_f, v)
|
|
880
|
+
span = hi_f - lo_f if hi_f > lo_f else 1.0
|
|
881
|
+
brs = [d for d in plot.decorations if isinstance(d, Bracket)]
|
|
882
|
+
ends = {id(d): sorted((pos(d.a), pos(d.b))) for d in brs}
|
|
883
|
+
lines: dict[int, float] = {}
|
|
884
|
+
for d in brs:
|
|
885
|
+
y = d.resolve("y")
|
|
886
|
+
if y is not None:
|
|
887
|
+
lines[id(d)] = fn(float(y))
|
|
888
|
+
auto = sorted((d for d in brs if id(d) not in lines),
|
|
889
|
+
key=lambda d: ends[id(d)][1] - ends[id(d)][0])
|
|
890
|
+
for d in auto:
|
|
891
|
+
a, b = ends[id(d)]
|
|
892
|
+
raw = d._raw("step")
|
|
893
|
+
step = float(raw) if isinstance(raw, (int, float)) else d.resolve("step", of=span)
|
|
894
|
+
base = high(a - half, b + half)
|
|
895
|
+
line = (base if math.isfinite(base) else (hi_f if math.isfinite(hi_f) else 0.0)) + step
|
|
896
|
+
for q, ql in lines.items():
|
|
897
|
+
qa, qb = ends[q]
|
|
898
|
+
if qa <= b and a <= qb:
|
|
899
|
+
line = max(line, ql + step)
|
|
900
|
+
lines[id(d)] = line
|
|
901
|
+
out = {}
|
|
902
|
+
placed = {id(d) for d in auto}
|
|
903
|
+
for d in brs:
|
|
904
|
+
a, b = ends[id(d)]
|
|
905
|
+
floors: list[float] = []
|
|
906
|
+
sources: list[int | None] = []
|
|
907
|
+
for e in (a, b):
|
|
908
|
+
f, src = high(e - half, e + half), None
|
|
909
|
+
for q, ql in lines.items():
|
|
910
|
+
qa, qb = ends[q]
|
|
911
|
+
if q != id(d) and qa <= e <= qb and ql < lines[id(d)] and ql > f:
|
|
912
|
+
f, src = ql, q
|
|
913
|
+
floors.append(f)
|
|
914
|
+
sources.append(src)
|
|
915
|
+
under = tuple(q for q, ql in lines.items() if id(d) in placed and q != id(d)
|
|
916
|
+
and ends[q][0] <= b and a <= ends[q][1] and ql < lines[id(d)])
|
|
917
|
+
out[id(d)] = (vrole, a, b, lines[id(d)], floors[0], floors[1], under,
|
|
918
|
+
tuple(sources), d)
|
|
919
|
+
plot.__dict__["_brackets"] = (calls, key, out, scale)
|
|
920
|
+
return out
|