plot-io 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plot_io/__init__.py +66 -0
- plot_io/_backend.py +114 -0
- plot_io/axis.py +574 -0
- plot_io/colorbar.py +342 -0
- plot_io/decorations.py +496 -0
- plot_io/defaults.py +766 -0
- plot_io/density.py +97 -0
- plot_io/enums.py +63 -0
- plot_io/facets.py +184 -0
- plot_io/figure.py +331 -0
- plot_io/labels.py +63 -0
- plot_io/legend.py +498 -0
- plot_io/limits.py +92 -0
- plot_io/maps/__init__.py +365 -0
- plot_io/maps/data.py +333 -0
- plot_io/markers.py +117 -0
- plot_io/numbers.py +289 -0
- plot_io/occupancy.py +219 -0
- plot_io/palettes.py +142 -0
- plot_io/plot.py +435 -0
- plot_io/render.py +1122 -0
- plot_io/scales.py +93 -0
- plot_io/series/__init__.py +498 -0
- plot_io/series/area.py +181 -0
- plot_io/series/bar.py +294 -0
- plot_io/series/box.py +401 -0
- plot_io/series/catdata.py +23 -0
- plot_io/series/catpoints.py +182 -0
- plot_io/series/contour.py +294 -0
- plot_io/series/curves.py +128 -0
- plot_io/series/ecdf.py +143 -0
- plot_io/series/errorbar.py +111 -0
- plot_io/series/errors.py +100 -0
- plot_io/series/fill.py +168 -0
- plot_io/series/heatmap.py +304 -0
- plot_io/series/hexbin.py +216 -0
- plot_io/series/hist.py +379 -0
- plot_io/series/hist2d.py +163 -0
- plot_io/series/kde.py +228 -0
- plot_io/series/kde2d.py +153 -0
- plot_io/series/line.py +173 -0
- plot_io/series/pie.py +304 -0
- plot_io/series/points.py +341 -0
- plot_io/series/regression.py +250 -0
- plot_io/series/scatter.py +103 -0
- plot_io/series/stem.py +112 -0
- plot_io/series/violin.py +321 -0
- plot_io/settings.py +281 -0
- plot_io/shapes.py +57 -0
- plot_io/sharing.py +160 -0
- plot_io/slots.py +52 -0
- plot_io/stacking.py +63 -0
- plot_io/stats.py +140 -0
- plot_io/style.py +71 -0
- plot_io/styles/__init__.py +234 -0
- plot_io/styles/library.py +732 -0
- plot_io/tables.py +141 -0
- plot_io/text.py +109 -0
- plot_io/ticks.py +284 -0
- plot_io/times.py +199 -0
- plot_io/values.py +161 -0
- plot_io-0.1.0.dist-info/METADATA +102 -0
- plot_io-0.1.0.dist-info/RECORD +65 -0
- plot_io-0.1.0.dist-info/WHEEL +4 -0
- plot_io-0.1.0.dist-info/licenses/LICENSE +28 -0
plot_io/decorations.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Decorations: reference lines, spans, text and annotations on a plot.
|
|
2
|
+
|
|
3
|
+
Decorations are not series: they take no palette color, 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 .settings import (Setting, Settings, boolean, color, number, one_of, table, text)
|
|
14
|
+
from .style import dash_of, resolve_color, with_alpha
|
|
15
|
+
from .text import Font, draw_text, measure
|
|
16
|
+
|
|
17
|
+
__all__ = ["Decoration", "HLine", "VLine", "AxLine", "HSpan", "VSpan", "Text", "Annotate"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _dash(v: Any) -> Any:
|
|
21
|
+
if isinstance(v, (list, tuple)):
|
|
22
|
+
return [number(lo=0)(x) for x in v]
|
|
23
|
+
return one_of("solid", "dashed", "dotted", "dashdot")(v)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _point(v: Any) -> tuple[Any, Any]:
|
|
27
|
+
if not isinstance(v, (tuple, list)) or len(v) != 2:
|
|
28
|
+
raise ValueError(f"expected a point (x, y), got {v!r}")
|
|
29
|
+
return v[0], v[1]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_COORDS = one_of("data", "axes", "figure")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _arrow_style(v: Any) -> str:
|
|
36
|
+
if v not in ("->", "-|>", "-"):
|
|
37
|
+
raise ValueError(f"expected '->', '-|>' or '-', got {v!r}")
|
|
38
|
+
return v
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _common() -> dict[str, Setting]:
|
|
42
|
+
return dict(
|
|
43
|
+
name=Setting(text, doc="Legend entry; unset: not in the legend."),
|
|
44
|
+
alpha=Setting(number(0, 1), doc="Opacity."),
|
|
45
|
+
z=Setting(number(), doc="Drawing order among the series (default 0)."),
|
|
46
|
+
extend_limits=Setting(boolean, doc="Include the decoration in the axis limits."),
|
|
47
|
+
clip=Setting(boolean, doc="Clip to the axes area."),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _line() -> dict[str, Setting]:
|
|
52
|
+
return dict(
|
|
53
|
+
color=Setting(color, doc="Line color."),
|
|
54
|
+
line_width=Setting(number(lo=0), inherited=True, doc="Line width (pt).", length=True),
|
|
55
|
+
line_style=Setting(_dash,
|
|
56
|
+
doc="`solid`, `dashed`, `dotted`, `dashdot` or a dash list."),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _font() -> dict[str, Setting]:
|
|
61
|
+
return dict(
|
|
62
|
+
font_size=Setting(number(positive=True), inherited=True, doc="Font size (pt).",
|
|
63
|
+
length=True),
|
|
64
|
+
font_family=Setting(str, inherited=True, doc="Font family."),
|
|
65
|
+
font_weight=Setting(one_of("normal", "bold"), doc="`normal` or `bold`."),
|
|
66
|
+
text_color=Setting(color, inherited=True, doc="Text color."),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Decoration(Settings):
|
|
71
|
+
"""Base of decorations: behaves like a series towards the axes and the legend."""
|
|
72
|
+
|
|
73
|
+
USES_PALETTE = False
|
|
74
|
+
AXES = True
|
|
75
|
+
DODGE_GROUP = None
|
|
76
|
+
STACK_GROUP = None
|
|
77
|
+
#: The axis roles the decoration's data coordinates are on.
|
|
78
|
+
ROLES: tuple[str, ...] = ()
|
|
79
|
+
|
|
80
|
+
def __init__(self, plot: Any, **settings: Any) -> None:
|
|
81
|
+
self.plot = plot
|
|
82
|
+
super().__init__(**settings)
|
|
83
|
+
plot.decorations.append(self)
|
|
84
|
+
|
|
85
|
+
def _parent(self) -> Settings:
|
|
86
|
+
return self.plot
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return f"{self.kind}()"
|
|
90
|
+
|
|
91
|
+
# -- what the axes need (only with extend_limits) -------------------------------
|
|
92
|
+
|
|
93
|
+
def has_axis(self, role: str) -> bool:
|
|
94
|
+
return role in self.ROLES and self.get("extend_limits")
|
|
95
|
+
|
|
96
|
+
def categories(self, role: str) -> list[Any] | None:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def data_values(self, role: str) -> list[float]:
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
def values(self, role: str) -> Any:
|
|
103
|
+
from array import array
|
|
104
|
+
return array("d", [float(v) for v in self.data_values(role)
|
|
105
|
+
if isinstance(v, (int, float))])
|
|
106
|
+
|
|
107
|
+
def extent(self, role: str, scale: Any) -> tuple[int, float, float]:
|
|
108
|
+
from . import _backend
|
|
109
|
+
return _backend.extent(self.values(role), scale.function)
|
|
110
|
+
|
|
111
|
+
def inverts(self, role: str) -> bool:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def zero_default(self, role: str) -> bool:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def zero_padding(self, role: str) -> bool:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
def color_sources(self) -> list[Any]:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
def size_sources(self) -> list[Any]:
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
def has_own_color(self) -> bool:
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
def legend_labels(self) -> list[Any]:
|
|
130
|
+
"""Legend entries of the object: its ``name``, or none."""
|
|
131
|
+
name = self.get("name")
|
|
132
|
+
return [] if name is None else [name]
|
|
133
|
+
|
|
134
|
+
def legend_glyph_at(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
|
|
135
|
+
"""The symbol of legend entry ``i`` (series with several entries override this)."""
|
|
136
|
+
self.legend_glyph(ctx, x, y, w, h)
|
|
137
|
+
|
|
138
|
+
def legend_colors_at(self, ctx: Any, i: int) -> tuple[Any, Any]:
|
|
139
|
+
return self.legend_colors(ctx)
|
|
140
|
+
|
|
141
|
+
def legend_points(self) -> Any:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
145
|
+
c = with_alpha(ctx.color(self.get("color")), self.get("alpha"))
|
|
146
|
+
return c, None
|
|
147
|
+
|
|
148
|
+
def legend_glyph(self, ctx: Any, x: float, y: float, w: float, h: float) -> None:
|
|
149
|
+
c = with_alpha(ctx.color(self.get("color")), self.get("alpha"))
|
|
150
|
+
if c is None:
|
|
151
|
+
return
|
|
152
|
+
if "line_style" in self.SETTINGS:
|
|
153
|
+
lw = self.get("line_width")
|
|
154
|
+
ctx.canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], color=c, width=lw,
|
|
155
|
+
dash=dash_of(self.get("line_style"), lw))
|
|
156
|
+
else:
|
|
157
|
+
ctx.canvas.fill_style = c
|
|
158
|
+
ctx.canvas.fill_rect(x, y, w, h)
|
|
159
|
+
|
|
160
|
+
# -- positions ------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def _pos(self, ctx: Any, role: str, v: Any, coords: str) -> float:
|
|
163
|
+
"""Page coordinate of ``v`` along ``role`` (``x`` or ``y``)."""
|
|
164
|
+
x0, y0, x1, y1 = ctx.rect
|
|
165
|
+
if coords == "axes":
|
|
166
|
+
return x0 + float(v) * (x1 - x0) if role == "x" else y1 - float(v) * (y1 - y0)
|
|
167
|
+
if coords == "figure":
|
|
168
|
+
fw, fh = getattr(ctx, "figure_size", (x1, y1))
|
|
169
|
+
return float(v) * fw if role == "x" else (1.0 - float(v)) * fh
|
|
170
|
+
state = ctx.x if role == "x" else ctx.y
|
|
171
|
+
if isinstance(v, str):
|
|
172
|
+
if not state.is_category or v not in state.categories:
|
|
173
|
+
raise ValueError(f"{v!r} is not a category of the {role} axis")
|
|
174
|
+
v = state.categories.index(v)
|
|
175
|
+
mapper = ctx.xmap if role == "x" else ctx.ymap
|
|
176
|
+
return mapper(float(v))
|
|
177
|
+
|
|
178
|
+
def draw(self, ctx: Any) -> None:
|
|
179
|
+
raise NotImplementedError
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _Rule(Decoration):
|
|
183
|
+
"""A horizontal or vertical reference line across part of the axes."""
|
|
184
|
+
|
|
185
|
+
VERTICAL = False
|
|
186
|
+
value: Any = None
|
|
187
|
+
|
|
188
|
+
def draw(self, ctx: Any) -> None:
|
|
189
|
+
c = with_alpha(ctx.color(self.get("color")), self.get("alpha"))
|
|
190
|
+
lw = self.get("line_width")
|
|
191
|
+
if c is None:
|
|
192
|
+
return
|
|
193
|
+
x0, y0, x1, y1 = ctx.rect
|
|
194
|
+
lo, hi = self.get("min"), self.get("max")
|
|
195
|
+
if self.VERTICAL:
|
|
196
|
+
p = self._pos(ctx, "x", self.value, "data")
|
|
197
|
+
xs, ys = [p, p], [y1 - lo * (y1 - y0), y1 - hi * (y1 - y0)]
|
|
198
|
+
else:
|
|
199
|
+
p = self._pos(ctx, "y", self.value, "data")
|
|
200
|
+
xs, ys = [x0 + lo * (x1 - x0), x0 + hi * (x1 - x0)], [p, p]
|
|
201
|
+
if not all(math.isfinite(v) for v in xs + ys):
|
|
202
|
+
return
|
|
203
|
+
ctx.canvas.draw_lines(xs, ys, color=c, width=lw, dash=dash_of(self.get("line_style"), lw),
|
|
204
|
+
cap="butt")
|
|
205
|
+
|
|
206
|
+
def data_values(self, role: str) -> list[float]:
|
|
207
|
+
return [self.value]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class HLine(_Rule):
|
|
211
|
+
"""A horizontal line at ``y`` from ``min`` to ``max`` (fractions of the axes width)."""
|
|
212
|
+
|
|
213
|
+
SETTINGS = table(
|
|
214
|
+
**_line(),
|
|
215
|
+
min=Setting(number(0, 1), doc="Start, as a fraction of the axes width."),
|
|
216
|
+
max=Setting(number(0, 1), doc="End, as a fraction of the axes width."),
|
|
217
|
+
**_common(),
|
|
218
|
+
)
|
|
219
|
+
ROLES = ("y",)
|
|
220
|
+
|
|
221
|
+
def __init__(self, plot: Any, y: Any, **settings: Any) -> None:
|
|
222
|
+
self.value = y
|
|
223
|
+
super().__init__(plot, **settings)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class VLine(_Rule):
|
|
227
|
+
"""A vertical line at ``x`` from ``min`` to ``max`` (fractions of the axes height)."""
|
|
228
|
+
|
|
229
|
+
SETTINGS = HLine.SETTINGS
|
|
230
|
+
ROLES = ("x",)
|
|
231
|
+
VERTICAL = True
|
|
232
|
+
|
|
233
|
+
def __init__(self, plot: Any, x: Any, **settings: Any) -> None:
|
|
234
|
+
self.value = x
|
|
235
|
+
super().__init__(plot, **settings)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class AxLine(Decoration):
|
|
239
|
+
"""An infinite straight line through ``point`` and ``point2``, or with ``slope`` (data
|
|
240
|
+
units; linear axes)."""
|
|
241
|
+
|
|
242
|
+
SETTINGS = table(**_line(), **_common())
|
|
243
|
+
ROLES = ("x", "y")
|
|
244
|
+
|
|
245
|
+
def __init__(self, plot: Any, point: Any, slope: Any = None, point2: Any = None,
|
|
246
|
+
**settings: Any) -> None:
|
|
247
|
+
self.p1 = _point(point)
|
|
248
|
+
if (slope is None) == (point2 is None):
|
|
249
|
+
raise ValueError("give either slope or point2")
|
|
250
|
+
self.p2 = _point(point2) if point2 is not None else None
|
|
251
|
+
self.slope = None if slope is None else float(slope)
|
|
252
|
+
super().__init__(plot, **settings)
|
|
253
|
+
|
|
254
|
+
def data_values(self, role: str) -> list[float]:
|
|
255
|
+
i = 0 if role == "x" else 1
|
|
256
|
+
pts = [self.p1] + ([self.p2] if self.p2 is not None else [])
|
|
257
|
+
return [p[i] for p in pts]
|
|
258
|
+
|
|
259
|
+
def draw(self, ctx: Any) -> None:
|
|
260
|
+
c = with_alpha(ctx.color(self.get("color")), self.get("alpha"))
|
|
261
|
+
lw = self.get("line_width")
|
|
262
|
+
if c is None:
|
|
263
|
+
return
|
|
264
|
+
if self.p2 is None:
|
|
265
|
+
for st in (ctx.x, ctx.y):
|
|
266
|
+
if st.scale is None or st.scale.name != "linear":
|
|
267
|
+
raise ValueError("axline with a slope needs linear axes; give point2")
|
|
268
|
+
p2 = (float(self.p1[0]) + 1.0, float(self.p1[1]) + float(self.slope or 0.0))
|
|
269
|
+
else:
|
|
270
|
+
p2 = self.p2
|
|
271
|
+
ax, ay = self._pos(ctx, "x", self.p1[0], "data"), self._pos(ctx, "y", self.p1[1], "data")
|
|
272
|
+
bx, by = self._pos(ctx, "x", p2[0], "data"), self._pos(ctx, "y", p2[1], "data")
|
|
273
|
+
dx, dy = bx - ax, by - ay
|
|
274
|
+
n = math.hypot(dx, dy)
|
|
275
|
+
if not n or not math.isfinite(n):
|
|
276
|
+
return
|
|
277
|
+
x0, y0, x1, y1 = ctx.rect
|
|
278
|
+
far = 2.0 * (math.hypot(x1 - x0, y1 - y0) + abs(ax - x0) + abs(ay - y0)) / n
|
|
279
|
+
ctx.canvas.draw_lines([ax - far * dx, ax + far * dx], [ay - far * dy, ay + far * dy],
|
|
280
|
+
color=c, width=lw, dash=dash_of(self.get("line_style"), lw))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class _Span(Decoration):
|
|
284
|
+
VERTICAL = False
|
|
285
|
+
v0: Any = None
|
|
286
|
+
v1: Any = None
|
|
287
|
+
|
|
288
|
+
def draw(self, ctx: Any) -> None:
|
|
289
|
+
c = with_alpha(ctx.color(self.get("color")), self.get("alpha"))
|
|
290
|
+
if c is None:
|
|
291
|
+
return
|
|
292
|
+
x0, y0, x1, y1 = ctx.rect
|
|
293
|
+
lo, hi = self.get("min"), self.get("max")
|
|
294
|
+
if self.VERTICAL:
|
|
295
|
+
a, b = self._pos(ctx, "x", self.v0, "data"), self._pos(ctx, "x", self.v1, "data")
|
|
296
|
+
r = (min(a, b), y1 - hi * (y1 - y0), abs(b - a), (hi - lo) * (y1 - y0))
|
|
297
|
+
else:
|
|
298
|
+
a, b = self._pos(ctx, "y", self.v0, "data"), self._pos(ctx, "y", self.v1, "data")
|
|
299
|
+
r = (x0 + lo * (x1 - x0), min(a, b), (hi - lo) * (x1 - x0), abs(b - a))
|
|
300
|
+
if not all(math.isfinite(v) for v in r):
|
|
301
|
+
return
|
|
302
|
+
canvas = ctx.canvas
|
|
303
|
+
canvas.begin_path()
|
|
304
|
+
canvas.rect(*r)
|
|
305
|
+
canvas.fill(c)
|
|
306
|
+
lc = ctx.color(self.get("line_color"))
|
|
307
|
+
if lc is not None and self.get("line_width"):
|
|
308
|
+
canvas.line_width = self.get("line_width")
|
|
309
|
+
canvas.stroke(lc)
|
|
310
|
+
|
|
311
|
+
def data_values(self, role: str) -> list[float]:
|
|
312
|
+
return [self.v0, self.v1]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class HSpan(_Span):
|
|
316
|
+
"""A horizontal band from ``y0`` to ``y1``, from ``min`` to ``max`` of the axes width."""
|
|
317
|
+
|
|
318
|
+
SETTINGS = table(
|
|
319
|
+
color=Setting(color, doc="Fill color."),
|
|
320
|
+
line_color=Setting(color, doc="Outline color; default none."),
|
|
321
|
+
line_width=Setting(number(lo=0), doc="Outline width (pt).", length=True),
|
|
322
|
+
min=Setting(number(0, 1), doc="Start, as a fraction of the axes width."),
|
|
323
|
+
max=Setting(number(0, 1), doc="End, as a fraction of the axes width."),
|
|
324
|
+
**_common(),
|
|
325
|
+
)
|
|
326
|
+
ROLES = ("y",)
|
|
327
|
+
|
|
328
|
+
def __init__(self, plot: Any, y0: Any, y1: Any, **settings: Any) -> None:
|
|
329
|
+
self.v0, self.v1 = y0, y1
|
|
330
|
+
super().__init__(plot, **settings)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class VSpan(_Span):
|
|
334
|
+
"""A vertical band from ``x0`` to ``x1``, from ``min`` to ``max`` of the axes height."""
|
|
335
|
+
|
|
336
|
+
SETTINGS = HSpan.SETTINGS
|
|
337
|
+
ROLES = ("x",)
|
|
338
|
+
VERTICAL = True
|
|
339
|
+
|
|
340
|
+
def __init__(self, plot: Any, x0: Any, x1: Any, **settings: Any) -> None:
|
|
341
|
+
self.v0, self.v1 = x0, x1
|
|
342
|
+
super().__init__(plot, **settings)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class Text(Decoration):
|
|
346
|
+
"""Text at ``(x, y)``: a string or rich content."""
|
|
347
|
+
|
|
348
|
+
SETTINGS = table(
|
|
349
|
+
align=Setting(one_of("left", "center", "right"),
|
|
350
|
+
doc="Horizontal alignment on the point."),
|
|
351
|
+
valign=Setting(one_of("baseline", "top", "center", "bottom"),
|
|
352
|
+
doc="Vertical alignment on the point."),
|
|
353
|
+
rotation=Setting(number(), doc="Rotation (degrees, counterclockwise)."),
|
|
354
|
+
coords=Setting(_COORDS, doc="`data`, `axes` (0–1) or `figure` (0–1)."),
|
|
355
|
+
color=Setting(color, doc="Text color; default `text_color`."),
|
|
356
|
+
**_font(),
|
|
357
|
+
**{k: v for k, v in _common().items() if k != "clip"},
|
|
358
|
+
clip=Setting(boolean, doc="Clip to the axes area."),
|
|
359
|
+
)
|
|
360
|
+
ROLES = ("x", "y")
|
|
361
|
+
|
|
362
|
+
def __init__(self, plot: Any, x: Any, y: Any, content: Any, **settings: Any) -> None:
|
|
363
|
+
self.x, self.y, self.content = x, y, content
|
|
364
|
+
super().__init__(plot, **settings)
|
|
365
|
+
|
|
366
|
+
def has_axis(self, role: str) -> bool:
|
|
367
|
+
return super().has_axis(role) and self.get("coords") == "data"
|
|
368
|
+
|
|
369
|
+
def data_values(self, role: str) -> list[float]:
|
|
370
|
+
return [self.x if role == "x" else self.y]
|
|
371
|
+
|
|
372
|
+
def font(self) -> Font:
|
|
373
|
+
c = self.get("color")
|
|
374
|
+
return Font(self.get("font_size"), self.get("font_family"), self.get("font_weight"),
|
|
375
|
+
color=c)
|
|
376
|
+
|
|
377
|
+
def legend_colors(self, ctx: Any) -> tuple[Any, Any]:
|
|
378
|
+
return ctx.color(self.get("color")), None
|
|
379
|
+
|
|
380
|
+
def draw(self, ctx: Any) -> None:
|
|
381
|
+
coords = self.get("coords")
|
|
382
|
+
px, py = self._pos(ctx, "x", self.x, coords), self._pos(ctx, "y", self.y, coords)
|
|
383
|
+
font = self.font()
|
|
384
|
+
font.color = with_alpha(resolve_color(font.color, ctx.palette), self.get("alpha"))
|
|
385
|
+
draw_text(ctx.canvas, self.content, px, py, font, self.get("align"), self.get("valign"),
|
|
386
|
+
self.get("rotation"))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class Annotate(Text):
|
|
390
|
+
"""Text at ``xytext`` with an arrow to ``xy``."""
|
|
391
|
+
|
|
392
|
+
SETTINGS = table(
|
|
393
|
+
**{k: v for k, v in Text.SETTINGS.items() if k not in ("align", "valign")},
|
|
394
|
+
align=Setting(one_of("left", "center", "right"),
|
|
395
|
+
doc="Horizontal alignment on `xytext`."),
|
|
396
|
+
valign=Setting(one_of("baseline", "top", "center", "bottom"),
|
|
397
|
+
doc="Vertical alignment on `xytext`."),
|
|
398
|
+
text_coords=Setting(one_of("data", "axes", "figure", "offset"),
|
|
399
|
+
doc="Coordinates of `xytext`: as `coords`, or `offset` (pt from "
|
|
400
|
+
"`xy`, y up); default `coords`."),
|
|
401
|
+
arrow=Setting(boolean, doc="Draw an arrow from the text to `xy`."),
|
|
402
|
+
arrow_style=Setting(_arrow_style,
|
|
403
|
+
doc="`->` (open head), `-|>` (filled head) or `-` (no head)."),
|
|
404
|
+
arrow_color=Setting(color, doc="Arrow color; default the text color."),
|
|
405
|
+
arrow_width=Setting(number(lo=0), doc="Arrow line width (pt).", length=True),
|
|
406
|
+
head_size=Setting(number(lo=0), doc="Arrowhead length (pt).", length=True),
|
|
407
|
+
shrink=Setting(number(lo=0), doc="Gap left at both ends of the arrow (pt).",
|
|
408
|
+
length=True),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def __init__(self, plot: Any, content: Any, xy: Any, xytext: Any = None,
|
|
412
|
+
**settings: Any) -> None:
|
|
413
|
+
self.xy = _point(xy)
|
|
414
|
+
self.xytext = _point(xytext) if xytext is not None else None
|
|
415
|
+
x, y = self.xytext if self.xytext is not None else self.xy
|
|
416
|
+
super().__init__(plot, x, y, content, **settings)
|
|
417
|
+
|
|
418
|
+
def data_values(self, role: str) -> list[float]:
|
|
419
|
+
i = 0 if role == "x" else 1
|
|
420
|
+
out = [self.xy[i]]
|
|
421
|
+
if self.xytext is not None and self.get("text_coords") == "data":
|
|
422
|
+
out.append(self.xytext[i])
|
|
423
|
+
return out
|
|
424
|
+
|
|
425
|
+
def draw(self, ctx: Any) -> None:
|
|
426
|
+
coords = self.get("coords")
|
|
427
|
+
tx = self.get("text_coords")
|
|
428
|
+
ax, ay = self._pos(ctx, "x", self.xy[0], coords), self._pos(ctx, "y", self.xy[1], coords)
|
|
429
|
+
if self.xytext is None:
|
|
430
|
+
px, py = ax, ay
|
|
431
|
+
elif tx == "offset":
|
|
432
|
+
px, py = ax + float(self.xytext[0]), ay - float(self.xytext[1])
|
|
433
|
+
else:
|
|
434
|
+
px, py = self._pos(ctx, "x", self.xytext[0], tx), self._pos(ctx, "y", self.xytext[1], tx)
|
|
435
|
+
font = self.font()
|
|
436
|
+
canvas = ctx.canvas
|
|
437
|
+
tc = with_alpha(resolve_color(font.color, ctx.palette), self.get("alpha"))
|
|
438
|
+
font.color = tc
|
|
439
|
+
w, a, d = measure(canvas, self.content, font)
|
|
440
|
+
bx = px + {"left": 0.0, "center": -w / 2, "right": -w}[self.get("align")]
|
|
441
|
+
top = py - {"baseline": a, "top": 0.0, "center": (a + d) / 2, "bottom": a + d}[
|
|
442
|
+
self.get("valign")]
|
|
443
|
+
draw_text(canvas, self.content, px, py, font, self.get("align"), self.get("valign"))
|
|
444
|
+
if self.get("arrow") and self.xytext is not None:
|
|
445
|
+
ac = with_alpha(ctx.color(self.get("arrow_color")), self.get("alpha"))
|
|
446
|
+
self._arrow(canvas, (bx, top, bx + w, top + a + d), ax, ay, ac)
|
|
447
|
+
|
|
448
|
+
def _arrow(self, canvas: Any, box: tuple[float, float, float, float], ax: float, ay: float,
|
|
449
|
+
c: Any) -> None:
|
|
450
|
+
x0, y0, x1, y1 = box
|
|
451
|
+
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
|
452
|
+
dx, dy = ax - cx, ay - cy
|
|
453
|
+
n = math.hypot(dx, dy)
|
|
454
|
+
if not n or c is None:
|
|
455
|
+
return
|
|
456
|
+
ux, uy = dx / n, dy / n
|
|
457
|
+
# leave the text box: smallest t where the ray crosses its edge
|
|
458
|
+
ts = []
|
|
459
|
+
if ux:
|
|
460
|
+
ts.append(((x1 if ux > 0 else x0) - cx) / ux)
|
|
461
|
+
if uy:
|
|
462
|
+
ts.append(((y1 if uy > 0 else y0) - cy) / uy)
|
|
463
|
+
t0 = min(t for t in ts if t >= 0) if ts else 0.0
|
|
464
|
+
shrink = self.get("shrink")
|
|
465
|
+
t0 += shrink
|
|
466
|
+
t1 = n - shrink
|
|
467
|
+
if t1 <= t0:
|
|
468
|
+
return
|
|
469
|
+
sx, sy = cx + ux * t0, cy + uy * t0
|
|
470
|
+
ex, ey = cx + ux * t1, cy + uy * t1
|
|
471
|
+
lw = self.get("arrow_width")
|
|
472
|
+
style = self.get("arrow_style")
|
|
473
|
+
hs = self.get("head_size")
|
|
474
|
+
if style == "-|>":
|
|
475
|
+
ex2, ey2 = ex - ux * hs * 0.9, ey - uy * hs * 0.9
|
|
476
|
+
else:
|
|
477
|
+
ex2, ey2 = ex, ey
|
|
478
|
+
canvas.draw_lines([sx, ex2], [sy, ey2], color=c, width=lw, cap="butt")
|
|
479
|
+
if style == "-" or not hs:
|
|
480
|
+
return
|
|
481
|
+
hw = 0.45 * hs
|
|
482
|
+
px_, py_ = -uy, ux
|
|
483
|
+
bx, by = ex - ux * hs, ey - uy * hs
|
|
484
|
+
canvas.save()
|
|
485
|
+
canvas.begin_path()
|
|
486
|
+
canvas.move_to(bx + px_ * hw, by + py_ * hw)
|
|
487
|
+
canvas.line_to(ex, ey)
|
|
488
|
+
canvas.line_to(bx - px_ * hw, by - py_ * hw)
|
|
489
|
+
if style == "-|>":
|
|
490
|
+
canvas.close_path()
|
|
491
|
+
canvas.fill(c)
|
|
492
|
+
else:
|
|
493
|
+
canvas.line_width = lw
|
|
494
|
+
canvas.line_join = "miter"
|
|
495
|
+
canvas.stroke(c)
|
|
496
|
+
canvas.restore()
|