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/labels.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Value labels on bars, histogram bins and pie wedges."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .settings import one_of
|
|
6
|
+
from .text import Font, draw_text, measure
|
|
7
|
+
|
|
8
|
+
__all__ = ["check_labels", "ink_for", "bar_labels"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_labels(v: Any) -> Any:
|
|
12
|
+
if v is True:
|
|
13
|
+
return "outside"
|
|
14
|
+
if v is False or v is None:
|
|
15
|
+
return False
|
|
16
|
+
return one_of("inside", "outside", "center")(v)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def luminance(c: Any) -> float:
|
|
20
|
+
if c is None:
|
|
21
|
+
return 1.0
|
|
22
|
+
from pdf_io.colors import to_rgba
|
|
23
|
+
rgba = to_rgba(c)
|
|
24
|
+
if rgba is None:
|
|
25
|
+
return 1.0
|
|
26
|
+
r, g, b = (float(x) for x in rgba[:3])
|
|
27
|
+
a = float(rgba[3]) if len(rgba) > 3 else 1.0
|
|
28
|
+
lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
29
|
+
return a * lum + (1.0 - a) # over a white background
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ink_for(fill: Any) -> str:
|
|
33
|
+
"""Black or white, whichever contrasts more with ``fill``."""
|
|
34
|
+
return "black" if luminance(fill) > 0.45 else "white"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bar_labels(canvas: Any, rects: list[tuple[float, float, float, float, bool]],
|
|
38
|
+
texts: list[Any], fills: list[Any], where: str, horizontal: bool,
|
|
39
|
+
font: Font, pad: float = 2.0) -> None:
|
|
40
|
+
"""Labels of bars ``(x0, y0, x1, y1, negative)`` (page units): ``outside`` beyond the
|
|
41
|
+
bar's end, ``inside`` just within it, ``center`` in its middle."""
|
|
42
|
+
for (x0, y0, x1, y1, negative), text, fill in zip(rects, texts, fills):
|
|
43
|
+
if text is None or text == "":
|
|
44
|
+
continue
|
|
45
|
+
inner = where in ("inside", "center")
|
|
46
|
+
f = Font(font.size, font.family, font.weight, font.style,
|
|
47
|
+
ink_for(fill) if inner else font.color)
|
|
48
|
+
if where == "center":
|
|
49
|
+
draw_text(canvas, text, (x0 + x1) / 2, (y0 + y1) / 2, f, "center", "center")
|
|
50
|
+
continue
|
|
51
|
+
if horizontal:
|
|
52
|
+
end = x0 if negative else x1
|
|
53
|
+
out = (where == "outside") != negative
|
|
54
|
+
ha = "left" if out else "right"
|
|
55
|
+
draw_text(canvas, text, end + (pad if out else -pad), (y0 + y1) / 2, f, ha, "center")
|
|
56
|
+
else:
|
|
57
|
+
end = y1 if negative else y0
|
|
58
|
+
up = (where == "outside") != negative
|
|
59
|
+
if up:
|
|
60
|
+
draw_text(canvas, text, (x0 + x1) / 2, end - pad, f, "center", "bottom")
|
|
61
|
+
else:
|
|
62
|
+
w, a, d = measure(canvas, text, f)
|
|
63
|
+
draw_text(canvas, text, (x0 + x1) / 2, end + pad, f, "center", "top")
|
plot_io/legend.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Legend layout and drawing: one entry per named series."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .style import resolve_color, with_alpha
|
|
7
|
+
from .text import Font, draw_text, measure
|
|
8
|
+
|
|
9
|
+
__all__ = ["LegendState", "SizeKey", "size_key_values"]
|
|
10
|
+
|
|
11
|
+
#: Inside candidates of ``position="auto"``, in order of preference (ties go to the first).
|
|
12
|
+
CANDIDATES = ("top right", "top left", "bottom left", "bottom right", "top", "bottom",
|
|
13
|
+
"center right", "center left")
|
|
14
|
+
#: ``"auto"`` puts the legend outside right when every inside position covers more than
|
|
15
|
+
#: this fraction of drawn cells.
|
|
16
|
+
OUTSIDE_THRESHOLD = 0.02
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def size_key_values(smap: Any, flo: float, fhi: float, given: Any = None) -> list[float]:
|
|
20
|
+
"""Values shown in a size key: ``given``, else 3–5 nice numbers inside the map's
|
|
21
|
+
limits (``flo``/``fhi`` after its scale function)."""
|
|
22
|
+
from .scales import get_scale
|
|
23
|
+
from .ticks import numeric_ticks
|
|
24
|
+
if isinstance(given, (list, tuple)):
|
|
25
|
+
return [float(v) for v in given]
|
|
26
|
+
scale = get_scale(smap.scale)
|
|
27
|
+
lo, hi = sorted((scale.inverse(flo), scale.inverse(fhi)))
|
|
28
|
+
eps = (hi - lo) * 1e-9
|
|
29
|
+
vals: list[float] = []
|
|
30
|
+
for most in (4, 5, 6, 8):
|
|
31
|
+
ticks, _ = numeric_ticks(scale, lo, hi, most, minor=False, length=100.0)
|
|
32
|
+
vals = [t.value for t in ticks if t.major and lo - eps <= t.value <= hi + eps
|
|
33
|
+
and (t.value > 0 or scale.name != "linear" or lo <= 0)]
|
|
34
|
+
if len(vals) >= 3:
|
|
35
|
+
break
|
|
36
|
+
return vals if len(vals) >= 2 else [lo, hi]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SizeKey:
|
|
40
|
+
"""Entries of a size key: a header (the map's label) and sample markers."""
|
|
41
|
+
|
|
42
|
+
header = False
|
|
43
|
+
|
|
44
|
+
def __init__(self, smap: Any, series: list[Any], values: list[float], labels: list[Any],
|
|
45
|
+
title: Any) -> None:
|
|
46
|
+
self.smap = smap
|
|
47
|
+
self.series = series
|
|
48
|
+
self.values = values
|
|
49
|
+
self.labels = labels
|
|
50
|
+
self.title = title
|
|
51
|
+
|
|
52
|
+
def sizes(self, ctx: Any) -> list[float]:
|
|
53
|
+
from . import _backend
|
|
54
|
+
from .markers import area_factor
|
|
55
|
+
from .scales import get_scale
|
|
56
|
+
flo, fhi = ctx.maps[id(self.smap)][:2]
|
|
57
|
+
d = self.smap.map(_backend.doubles(self.values), flo, fhi,
|
|
58
|
+
get_scale(self.smap.scale).function)
|
|
59
|
+
s = self.series[0]
|
|
60
|
+
marker = s.get("marker") if "marker" in s.SETTINGS else "circle"
|
|
61
|
+
marker = marker[0] if isinstance(marker, list) else marker
|
|
62
|
+
factor = area_factor(marker) if s.get("size_basis") == "area" else 1.0
|
|
63
|
+
return [v * factor if v == v else 0.0 for v in d]
|
|
64
|
+
|
|
65
|
+
def glyph(self, ctx: Any, i: int, x: float, y: float, w: float, h: float) -> None:
|
|
66
|
+
from .markers import resolve
|
|
67
|
+
s = self.series[0]
|
|
68
|
+
size = self.sizes(ctx)[i]
|
|
69
|
+
if not size:
|
|
70
|
+
return
|
|
71
|
+
marker = s.get("marker") if "marker" in s.SETTINGS else "circle"
|
|
72
|
+
marker = marker[0] if isinstance(marker, list) else marker
|
|
73
|
+
from .values import Values, is_computed
|
|
74
|
+
mapped = any(isinstance(s.get(n), Values) or is_computed(s.get(n))
|
|
75
|
+
for n in ("color", "line_color") if n in s.SETTINGS)
|
|
76
|
+
if len(self.series) == 1 and not mapped:
|
|
77
|
+
from .series.points import MarkerStyle
|
|
78
|
+
st = MarkerStyle(s, ctx, "", s.base_color(ctx), 1, True)
|
|
79
|
+
fill, stroke, lw = st.first_color("fill"), st.first_color("stroke"), st.line_width
|
|
80
|
+
if fill is None and stroke is None:
|
|
81
|
+
fill = s.base_color(ctx)
|
|
82
|
+
else:
|
|
83
|
+
fill, stroke, lw = "#888888", None, None
|
|
84
|
+
ctx.canvas.draw_markers([x + w / 2], [y + h / 2], marker=resolve(marker)[0],
|
|
85
|
+
size=size, fill=fill, stroke=stroke, line_width=lw)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Header:
|
|
89
|
+
header = True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class LegendState:
|
|
93
|
+
"""A plot's legend while drawing."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, plot_state: Any) -> None:
|
|
96
|
+
self.ps = plot_state
|
|
97
|
+
self.legend = plot_state.plot.legend
|
|
98
|
+
plot = plot_state.plot
|
|
99
|
+
own = list(plot.series)
|
|
100
|
+
if getattr(plot, "twin_of", None) and not plot.legend.is_set("position"):
|
|
101
|
+
own = [] # listed in the main plot's legend
|
|
102
|
+
for t in getattr(plot, "twins", []):
|
|
103
|
+
if not t.legend.is_set("position"):
|
|
104
|
+
own += list(t.series)
|
|
105
|
+
self.entries = [(s, label, i) for s in own + list(plot.decorations)
|
|
106
|
+
for i, label in enumerate(s.legend_labels())]
|
|
107
|
+
self.keys = self._size_keys(plot_state)
|
|
108
|
+
for key in self.keys:
|
|
109
|
+
if key.title is not None:
|
|
110
|
+
self.entries.append((_Header(), key.title, 0))
|
|
111
|
+
self.entries.extend((key, lab, i) for i, lab in enumerate(key.labels))
|
|
112
|
+
self.size = (0.0, 0.0)
|
|
113
|
+
self._cols: list[float] = []
|
|
114
|
+
self._rows = 0
|
|
115
|
+
self._heights: list[float] = []
|
|
116
|
+
self._title_h = 0.0
|
|
117
|
+
#: The position chosen for ``"auto"`` once the axes are known (see :meth:`choose`).
|
|
118
|
+
self.choice: str | None = None
|
|
119
|
+
self.free: tuple[float, float] = (0.0, 0.0)
|
|
120
|
+
|
|
121
|
+
def _size_keys(self, ps: Any) -> list[SizeKey]:
|
|
122
|
+
"""Size keys of the plot's series with a ``size_map`` (one per map)."""
|
|
123
|
+
from .numbers import format_numbers
|
|
124
|
+
by_map: dict[int, list[Any]] = {}
|
|
125
|
+
maps: dict[int, Any] = {}
|
|
126
|
+
for s in ps.plot.series:
|
|
127
|
+
m = s.size_map_obj() if hasattr(s, "size_map_obj") else None
|
|
128
|
+
if m is None or ("size_legend" in s.SETTINGS and s.get("size_legend") is False):
|
|
129
|
+
continue
|
|
130
|
+
by_map.setdefault(id(m), []).append(s)
|
|
131
|
+
maps[id(m)] = m
|
|
132
|
+
keys = []
|
|
133
|
+
for k, members in by_map.items():
|
|
134
|
+
m = maps[k]
|
|
135
|
+
if k not in ps.maps:
|
|
136
|
+
continue
|
|
137
|
+
flo, fhi = ps.maps[k][:2]
|
|
138
|
+
given = members[0].get("size_legend") if "size_legend" in members[0].SETTINGS \
|
|
139
|
+
else None
|
|
140
|
+
values = size_key_values(m, flo, fhi, given)
|
|
141
|
+
labels = format_numbers(values, "auto", ps.plot)
|
|
142
|
+
title = m.label
|
|
143
|
+
if title is None:
|
|
144
|
+
title = next((s.columns["size"] for s in members
|
|
145
|
+
if "size" in getattr(s, "columns", {})), None)
|
|
146
|
+
keys.append(SizeKey(m, members, values, labels, title))
|
|
147
|
+
return keys
|
|
148
|
+
|
|
149
|
+
def font(self) -> Font:
|
|
150
|
+
g = self.legend
|
|
151
|
+
return Font(g.get("font_size"), g.get("font_family"), color=g.get("color"))
|
|
152
|
+
|
|
153
|
+
def active(self) -> bool:
|
|
154
|
+
return bool(self.entries) and self.legend.get("position") != "none"
|
|
155
|
+
|
|
156
|
+
def position(self) -> str:
|
|
157
|
+
"""The position setting, with ``"auto"`` replaced by its choice once made."""
|
|
158
|
+
pos = self.legend.get("position")
|
|
159
|
+
return self.choice if pos == "auto" and self.choice else pos
|
|
160
|
+
|
|
161
|
+
def horizontal(self) -> bool:
|
|
162
|
+
o = self.legend.get("orientation")
|
|
163
|
+
if o == "auto":
|
|
164
|
+
# The setting, not an ``"auto"`` choice: the box was measured before choosing.
|
|
165
|
+
return self.legend.get("position") in ("top", "bottom", "outside top",
|
|
166
|
+
"outside bottom")
|
|
167
|
+
return o == "horizontal"
|
|
168
|
+
|
|
169
|
+
def outside(self) -> str | None:
|
|
170
|
+
pos = self.position()
|
|
171
|
+
return pos if self.active() and pos.startswith("outside") else None
|
|
172
|
+
|
|
173
|
+
def _metrics(self) -> tuple[float, float, float, float]:
|
|
174
|
+
fs = self.font().size
|
|
175
|
+
g = self.legend
|
|
176
|
+
return (g.get("glyph_width") * fs, 0.5 * fs, g.get("row_height") * fs,
|
|
177
|
+
(0.5 * fs if g.get("frame") else 0.15 * fs))
|
|
178
|
+
|
|
179
|
+
def _ctx(self) -> Any:
|
|
180
|
+
"""A context for size computations (the maps)."""
|
|
181
|
+
class _C:
|
|
182
|
+
maps = self.ps.maps
|
|
183
|
+
return _C()
|
|
184
|
+
|
|
185
|
+
def measure(self, canvas: Any) -> tuple[float, float]:
|
|
186
|
+
if not self.active():
|
|
187
|
+
self.size = (0.0, 0.0)
|
|
188
|
+
return self.size
|
|
189
|
+
font = self.font()
|
|
190
|
+
glyph, gap, row, inner = self._metrics()
|
|
191
|
+
n = len(self.entries)
|
|
192
|
+
cols = min(self.legend.get("columns"), n)
|
|
193
|
+
if self.horizontal() and not self.legend.is_set("columns"):
|
|
194
|
+
cols = n
|
|
195
|
+
rows = math.ceil(n / cols)
|
|
196
|
+
widths = [0.0] * cols
|
|
197
|
+
self._heights = [row] * rows
|
|
198
|
+
for i, (s, label, k) in enumerate(self.entries):
|
|
199
|
+
c, r = divmod(i, rows)
|
|
200
|
+
lw = measure(canvas, label, font)[0]
|
|
201
|
+
if getattr(s, "header", False):
|
|
202
|
+
lw -= glyph + gap
|
|
203
|
+
widths[c] = max(widths[c], lw)
|
|
204
|
+
if isinstance(s, SizeKey):
|
|
205
|
+
self._heights[r] = max(self._heights[r], 1.15 * s.sizes(self._ctx())[k])
|
|
206
|
+
self._cols = [glyph + gap + w for w in widths]
|
|
207
|
+
self._rows = rows
|
|
208
|
+
w = sum(self._cols) + font.size * (cols - 1)
|
|
209
|
+
title = self.legend.get("title")
|
|
210
|
+
self._title_h = 0.0
|
|
211
|
+
if title is not None:
|
|
212
|
+
tw, a, d = measure(canvas, title, font)
|
|
213
|
+
w = max(w, tw)
|
|
214
|
+
self._title_h = a + d + 0.3 * font.size
|
|
215
|
+
h = self._title_h + sum(self._heights)
|
|
216
|
+
self.size = (w + 2 * inner, h + 2 * inner)
|
|
217
|
+
return self.size
|
|
218
|
+
|
|
219
|
+
def _place(self, ctx: Any) -> tuple[float, float]:
|
|
220
|
+
"""Top-left corner of the legend box."""
|
|
221
|
+
x0, y0, x1, y1 = ctx.rect
|
|
222
|
+
w, h = self.size
|
|
223
|
+
pad = self.legend.get("pad")
|
|
224
|
+
pos = self.position()
|
|
225
|
+
if pos == "outside right":
|
|
226
|
+
return x1 + self.ps._right_used + pad, y0
|
|
227
|
+
if pos == "outside top":
|
|
228
|
+
return (x0 + x1 - w) / 2 if self.horizontal() else x0, y0 - pad - h
|
|
229
|
+
if pos == "outside bottom":
|
|
230
|
+
return ((x0 + x1 - w) / 2 if self.horizontal() else x0,
|
|
231
|
+
y1 + self.ps._bottom_used + pad)
|
|
232
|
+
if pos == "auto":
|
|
233
|
+
pos = self.choose(ctx, outside=False)
|
|
234
|
+
return self._box(ctx, pos)
|
|
235
|
+
|
|
236
|
+
def _box(self, ctx: Any, pos: str) -> tuple[float, float]:
|
|
237
|
+
"""Top-left corner of the legend box at an inside position."""
|
|
238
|
+
x0, y0, x1, y1 = ctx.rect
|
|
239
|
+
w, h = self.size
|
|
240
|
+
pad = self.legend.get("pad")
|
|
241
|
+
if pos == "inside":
|
|
242
|
+
fx, fy = self.free
|
|
243
|
+
return (x0 + pad + fx * max(0.0, x1 - x0 - 2 * pad - w),
|
|
244
|
+
y0 + pad + fy * max(0.0, y1 - y0 - 2 * pad - h))
|
|
245
|
+
vertical, _, horizontal = pos.partition(" ")
|
|
246
|
+
if pos in ("top", "bottom"):
|
|
247
|
+
vertical, horizontal = pos, "center"
|
|
248
|
+
x = {"left": x0 + pad, "right": x1 - pad - w, "center": (x0 + x1 - w) / 2}[horizontal]
|
|
249
|
+
y = {"top": y0 + pad, "bottom": y1 - pad - h, "center": (y0 + y1 - h) / 2}[vertical]
|
|
250
|
+
return x, y
|
|
251
|
+
|
|
252
|
+
def choose(self, ctx: Any, outside: bool = True) -> str:
|
|
253
|
+
"""The position for ``"auto"``, from the occupancy grid (every series marks what it
|
|
254
|
+
draws: points, lines, bars, areas, …):
|
|
255
|
+
|
|
256
|
+
1. the first candidate (``CANDIDATES``) whose box covers no drawn cell;
|
|
257
|
+
2. else the free box (one grid cell apart, anywhere inside) nearest to a corner of
|
|
258
|
+
the axes (``"inside"``, its place kept as fractions of the free room);
|
|
259
|
+
3. else ``"outside right"`` (when ``outside``) if every candidate covers more than
|
|
260
|
+
``OUTSIDE_THRESHOLD``; else the candidate covering the fewest cells."""
|
|
261
|
+
from .occupancy import Occupancy
|
|
262
|
+
|
|
263
|
+
occ = Occupancy(ctx.rect)
|
|
264
|
+
for s in self.ps.series:
|
|
265
|
+
s.occupy(ctx, occ)
|
|
266
|
+
from .series import DrawContext
|
|
267
|
+
for t in getattr(self.ps, "twins", []):
|
|
268
|
+
tctx = DrawContext(ctx.canvas, t.plot, ctx.rect, t.x, t.y, t.maps, t.palette)
|
|
269
|
+
for s in t.series:
|
|
270
|
+
s.occupy(tctx, occ)
|
|
271
|
+
w, h = self.size
|
|
272
|
+
best, best_f = CANDIDATES[0], math.inf
|
|
273
|
+
for pos in CANDIDATES:
|
|
274
|
+
x, y = self._box(ctx, pos)
|
|
275
|
+
f = occ.covered(x, y, w, h)
|
|
276
|
+
if f == 0.0:
|
|
277
|
+
return pos
|
|
278
|
+
if f < best_f - 1e-12:
|
|
279
|
+
best, best_f = pos, f
|
|
280
|
+
x0, y0, x1, y1 = ctx.rect
|
|
281
|
+
pad = self.legend.get("pad")
|
|
282
|
+
free = occ.free_boxes(w, h, pad)
|
|
283
|
+
if free:
|
|
284
|
+
def corner(p: tuple[float, float]) -> float:
|
|
285
|
+
x, y = p
|
|
286
|
+
return min(x - x0, x1 - x - w) + min(y - y0, y1 - y - h)
|
|
287
|
+
x, y = min(free, key=corner)
|
|
288
|
+
room_x, room_y = x1 - x0 - 2 * pad - w, y1 - y0 - 2 * pad - h
|
|
289
|
+
self.free = ((x - x0 - pad) / room_x if room_x > 0 else 0.0,
|
|
290
|
+
(y - y0 - pad) / room_y if room_y > 0 else 0.0)
|
|
291
|
+
return "inside"
|
|
292
|
+
if outside and best_f > OUTSIDE_THRESHOLD:
|
|
293
|
+
return "outside right"
|
|
294
|
+
return best
|
|
295
|
+
|
|
296
|
+
def draw(self, canvas: Any, ctx: Any) -> None:
|
|
297
|
+
if not self.active() or not self.size[0]:
|
|
298
|
+
return
|
|
299
|
+
x, y = self._place(ctx)
|
|
300
|
+
w, h = self.size
|
|
301
|
+
font = self.font()
|
|
302
|
+
glyph, gap, row, inner = self._metrics()
|
|
303
|
+
g = self.legend
|
|
304
|
+
if g.get("frame"):
|
|
305
|
+
alpha = g.get("frame_alpha")
|
|
306
|
+
bg = with_alpha(resolve_color(g.get("background"), ctx.palette), alpha)
|
|
307
|
+
fc = with_alpha(resolve_color(g.get("frame_color"), ctx.palette), alpha)
|
|
308
|
+
canvas.save()
|
|
309
|
+
canvas.begin_path()
|
|
310
|
+
r = min(g.get("frame_radius"), w / 2, h / 2)
|
|
311
|
+
if r:
|
|
312
|
+
canvas.rounded_rect(x, y, w, h, r)
|
|
313
|
+
else:
|
|
314
|
+
canvas.rect(x, y, w, h)
|
|
315
|
+
if bg is not None:
|
|
316
|
+
canvas.fill(bg)
|
|
317
|
+
if fc is not None and g.get("frame_width"):
|
|
318
|
+
canvas.line_width = g.get("frame_width")
|
|
319
|
+
canvas.stroke(fc)
|
|
320
|
+
canvas.restore()
|
|
321
|
+
cx, cy = x + inner, y + inner
|
|
322
|
+
title = self.legend.get("title")
|
|
323
|
+
if title is not None:
|
|
324
|
+
draw_text(canvas, title, cx, cy, font, "left", "top")
|
|
325
|
+
cy += self._title_h
|
|
326
|
+
for i, (s, label, k) in enumerate(self.entries):
|
|
327
|
+
c, r = divmod(i, self._rows)
|
|
328
|
+
ex = cx + sum(self._cols[:c]) + font.size * c
|
|
329
|
+
ey = cy + sum(self._heights[:r])
|
|
330
|
+
rh = self._heights[r]
|
|
331
|
+
if getattr(s, "header", False):
|
|
332
|
+
draw_text(canvas, label, ex, ey + rh / 2, font, "left", "center")
|
|
333
|
+
continue
|
|
334
|
+
if isinstance(s, SizeKey):
|
|
335
|
+
canvas.save()
|
|
336
|
+
try:
|
|
337
|
+
s.glyph(ctx, k, ex, ey, glyph, rh)
|
|
338
|
+
finally:
|
|
339
|
+
canvas.restore()
|
|
340
|
+
draw_text(canvas, label, ex + glyph + gap, ey + rh / 2, font, "left", "center")
|
|
341
|
+
continue
|
|
342
|
+
ey += (rh - row) / 2
|
|
343
|
+
canvas.save()
|
|
344
|
+
try:
|
|
345
|
+
kind = g.get("glyph")
|
|
346
|
+
if kind == "series":
|
|
347
|
+
s.legend_glyph_at(ctx, k, ex, ey + 0.1 * row, glyph, 0.8 * row)
|
|
348
|
+
else:
|
|
349
|
+
_simple_glyph(ctx, s, k, kind, ex, ey + 0.1 * row, glyph, 0.8 * row)
|
|
350
|
+
finally:
|
|
351
|
+
canvas.restore()
|
|
352
|
+
draw_text(canvas, label, ex + glyph + gap, ey + row / 2, font, "left", "center")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _opaque(c: Any) -> Any:
|
|
356
|
+
if c is None:
|
|
357
|
+
return None
|
|
358
|
+
from pdf_io.colors import to_rgba
|
|
359
|
+
rgba = to_rgba(c)
|
|
360
|
+
return None if rgba is None else (rgba[0], rgba[1], rgba[2], 1.0)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _simple_glyph(ctx: Any, series: Any, k: int, kind: str, x: float, y: float, w: float,
|
|
364
|
+
h: float) -> None:
|
|
365
|
+
"""A legend symbol in the series' colors: a filled box, a circle or a line."""
|
|
366
|
+
canvas = ctx.canvas
|
|
367
|
+
fill, stroke = series.legend_colors_at(ctx, k)
|
|
368
|
+
alpha = ctx.plot.legend.get("glyph_alpha")
|
|
369
|
+
if alpha is not None and kind != "line":
|
|
370
|
+
base = _opaque(fill if fill is not None else stroke)
|
|
371
|
+
if base is not None:
|
|
372
|
+
fill, stroke = with_alpha(base, alpha), (stroke if stroke is not None else base)
|
|
373
|
+
if kind == "line":
|
|
374
|
+
c = stroke if stroke is not None else fill
|
|
375
|
+
if c is not None:
|
|
376
|
+
canvas.draw_lines([x, x + w], [y + h / 2, y + h / 2], color=c,
|
|
377
|
+
width=min(1.5, h / 3))
|
|
378
|
+
return
|
|
379
|
+
if kind == "circle":
|
|
380
|
+
d = 0.75 * h
|
|
381
|
+
canvas.draw_markers([x + w / 2], [y + h / 2], size=d, fill=fill, stroke=stroke,
|
|
382
|
+
line_width=1.0 if stroke is not None else None)
|
|
383
|
+
return
|
|
384
|
+
canvas.begin_path()
|
|
385
|
+
canvas.rect(x, y + 0.1 * h, w, 0.8 * h)
|
|
386
|
+
if fill is not None:
|
|
387
|
+
canvas.fill(fill)
|
|
388
|
+
if stroke is not None:
|
|
389
|
+
canvas.line_width = 1.0
|
|
390
|
+
canvas.stroke(stroke)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class SizeLegend:
|
|
394
|
+
"""A size key in its own grid area (``figure.size_legend(size_map, id=…)``)."""
|
|
395
|
+
|
|
396
|
+
def __init__(self, size_map: Any, figure: Any, values: Any = None, font_size: Any = None,
|
|
397
|
+
title: Any = None) -> None:
|
|
398
|
+
from .maps import SizeMap
|
|
399
|
+
if not isinstance(size_map, SizeMap):
|
|
400
|
+
raise ValueError("a size legend needs a SizeMap")
|
|
401
|
+
self.size_map = size_map
|
|
402
|
+
self.figure = figure
|
|
403
|
+
self.values = None if values is None else [float(v) for v in values]
|
|
404
|
+
self.font_size = font_size
|
|
405
|
+
self.title = title
|
|
406
|
+
self.id: Any = None
|
|
407
|
+
|
|
408
|
+
def __repr__(self) -> str:
|
|
409
|
+
return f"SizeLegend({self.size_map!r})"
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class SizeLegendState:
|
|
413
|
+
"""A size legend area while drawing (the colorbar-area interface of the layout)."""
|
|
414
|
+
|
|
415
|
+
vertical = True
|
|
416
|
+
|
|
417
|
+
def __init__(self, sl: SizeLegend, maps: dict[int, tuple]) -> None:
|
|
418
|
+
from .numbers import format_numbers
|
|
419
|
+
from .plot import Plot
|
|
420
|
+
self.sl = sl
|
|
421
|
+
fig = sl.figure
|
|
422
|
+
self.maps = maps
|
|
423
|
+
m = sl.size_map
|
|
424
|
+
users = [s for p in fig.plots for s in p.series
|
|
425
|
+
if hasattr(s, "size_map_obj") and s.size_map_obj() is m]
|
|
426
|
+
self.key = None
|
|
427
|
+
self.thickness = 1.0
|
|
428
|
+
if id(m) in maps and users:
|
|
429
|
+
flo, fhi = maps[id(m)][:2]
|
|
430
|
+
values = size_key_values(m, flo, fhi, sl.values)
|
|
431
|
+
labels = format_numbers(values, "auto", users[0].plot if isinstance(
|
|
432
|
+
users[0].plot, Plot) else None)
|
|
433
|
+
title = sl.title if sl.title is not None else m.label
|
|
434
|
+
self.key = SizeKey(m, users, values, labels, title)
|
|
435
|
+
size = sl.font_size or fig.get("font_size")
|
|
436
|
+
self.font = Font(size, fig.get("font_family"), color=fig.get("text_color"))
|
|
437
|
+
self._layout: list[tuple[str, Any, float]] = []
|
|
438
|
+
|
|
439
|
+
def _ctx(self) -> Any:
|
|
440
|
+
class _C:
|
|
441
|
+
maps = self.maps
|
|
442
|
+
return _C()
|
|
443
|
+
|
|
444
|
+
def measure(self, canvas: Any, w: float, h: float) -> tuple[float, float, float, float]:
|
|
445
|
+
if self.key is None:
|
|
446
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
447
|
+
fs = self.font.size
|
|
448
|
+
glyph, gap, row = 2.0 * fs, 0.5 * fs, 1.35 * fs
|
|
449
|
+
sizes = self.key.sizes(self._ctx())
|
|
450
|
+
width = 0.0
|
|
451
|
+
rows: list[tuple[str, Any, float]] = []
|
|
452
|
+
if self.key.title is not None:
|
|
453
|
+
width = measure(canvas, self.key.title, self.font)[0]
|
|
454
|
+
rows.append(("title", self.key.title, row))
|
|
455
|
+
g = max([glyph] + [1.1 * s for s in sizes])
|
|
456
|
+
for lab, s in zip(self.key.labels, sizes):
|
|
457
|
+
width = max(width, g + gap + measure(canvas, lab, self.font)[0])
|
|
458
|
+
rows.append(("entry", lab, max(row, 1.15 * s)))
|
|
459
|
+
self._layout = rows
|
|
460
|
+
self._glyph = g
|
|
461
|
+
self.thickness = width
|
|
462
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
463
|
+
|
|
464
|
+
def bar_rect(self, rect: tuple[float, float, float, float]) -> tuple:
|
|
465
|
+
x0, y0, x1, y1 = rect
|
|
466
|
+
return (x0, y0, min(x1, x0 + self.thickness), y1)
|
|
467
|
+
|
|
468
|
+
def draw_area(self, canvas: Any, rect: tuple[float, float, float, float]) -> None:
|
|
469
|
+
if self.key is None:
|
|
470
|
+
return
|
|
471
|
+
from .palettes import palette_colors
|
|
472
|
+
from .style import resolve_color
|
|
473
|
+
x0, y = rect[0], rect[1]
|
|
474
|
+
fs = self.font.size
|
|
475
|
+
plot = self.key.series[0].plot
|
|
476
|
+
palette = palette_colors(plot.get("palette"), 10)
|
|
477
|
+
|
|
478
|
+
class _Ctx:
|
|
479
|
+
maps = self.maps
|
|
480
|
+
|
|
481
|
+
def color(self, value: Any) -> Any:
|
|
482
|
+
return resolve_color(value, palette)
|
|
483
|
+
ctx: Any = _Ctx()
|
|
484
|
+
ctx.canvas, ctx.palette, ctx.plot = canvas, palette, plot
|
|
485
|
+
i = 0
|
|
486
|
+
for kind, lab, h in self._layout:
|
|
487
|
+
if kind == "title":
|
|
488
|
+
draw_text(canvas, lab, x0, y + h / 2, self.font, "left", "center")
|
|
489
|
+
else:
|
|
490
|
+
canvas.save()
|
|
491
|
+
try:
|
|
492
|
+
self.key.glyph(ctx, i, x0, y, self._glyph, h)
|
|
493
|
+
finally:
|
|
494
|
+
canvas.restore()
|
|
495
|
+
draw_text(canvas, lab, x0 + self._glyph + 0.5 * fs, y + h / 2, self.font,
|
|
496
|
+
"left", "center")
|
|
497
|
+
i += 1
|
|
498
|
+
y += h
|
plot_io/limits.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Limits: ``"auto"``, ``"lo/hi"`` strings (``min``, ``max``, numbers, ``q:F``) or pairs."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = ["check_limits", "resolve_limits"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _side(token: str) -> Any:
|
|
11
|
+
t = token.strip().lower()
|
|
12
|
+
if t in ("min", "max", "auto", ""):
|
|
13
|
+
return "auto" if t in ("auto", "") else t
|
|
14
|
+
if t.startswith("q:"):
|
|
15
|
+
q = float(t[2:])
|
|
16
|
+
if not 0 <= q <= 1:
|
|
17
|
+
raise ValueError(f"quantile {q} outside [0, 1]")
|
|
18
|
+
return ("q", q)
|
|
19
|
+
try:
|
|
20
|
+
v = float(t)
|
|
21
|
+
except ValueError:
|
|
22
|
+
import datetime as dt
|
|
23
|
+
from .times import seconds
|
|
24
|
+
try:
|
|
25
|
+
v = seconds(dt.datetime.fromisoformat(token.strip()))
|
|
26
|
+
except ValueError:
|
|
27
|
+
raise ValueError(f"invalid limit {token!r}") from None
|
|
28
|
+
if not math.isfinite(v):
|
|
29
|
+
raise ValueError(f"invalid limit {token!r}")
|
|
30
|
+
return v
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_limits(v: Any) -> Any:
|
|
34
|
+
"""Normalize a limits value to ``"auto"`` or a pair of sides (``None``/``"auto"``, a
|
|
35
|
+
number, ``"min"``, ``"max"``, ``("q", F)``)."""
|
|
36
|
+
if isinstance(v, str):
|
|
37
|
+
s = v.strip().lower()
|
|
38
|
+
if s == "auto":
|
|
39
|
+
return "auto"
|
|
40
|
+
parts = s.split("/")
|
|
41
|
+
if len(parts) != 2:
|
|
42
|
+
raise ValueError(f"expected 'lo/hi', got {v!r}")
|
|
43
|
+
return (_side(parts[0]), _side(parts[1]))
|
|
44
|
+
if isinstance(v, (tuple, list)) and len(v) == 2:
|
|
45
|
+
out: list[Any] = []
|
|
46
|
+
for side in v:
|
|
47
|
+
if side is None:
|
|
48
|
+
out.append("auto")
|
|
49
|
+
elif isinstance(side, str):
|
|
50
|
+
out.append(_side(side))
|
|
51
|
+
elif isinstance(side, (int, float)) and not isinstance(side, bool) \
|
|
52
|
+
and math.isfinite(side):
|
|
53
|
+
out.append(float(side))
|
|
54
|
+
elif hasattr(side, "year"): # a date or datetime
|
|
55
|
+
from .times import seconds
|
|
56
|
+
out.append(seconds(side))
|
|
57
|
+
else:
|
|
58
|
+
raise ValueError(f"invalid limit {side!r}")
|
|
59
|
+
return tuple(out)
|
|
60
|
+
raise ValueError(f"expected 'auto', 'lo/hi' or (lo, hi), got {v!r}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_limits(spec: Any, lo: float, hi: float,
|
|
64
|
+
quantile: Callable[[float], float] | None = None) -> tuple[Any, Any, bool, bool]:
|
|
65
|
+
"""``(lo, hi, lo_auto, hi_auto)`` from a limits spec and the data range; ``*_auto``
|
|
66
|
+
tells whether that side follows the data (and takes padding)."""
|
|
67
|
+
sides = ("auto", "auto") if spec == "auto" else spec
|
|
68
|
+
out: list[Any] = []
|
|
69
|
+
autos: list[bool] = []
|
|
70
|
+
for side, data in zip(sides, (lo, hi)):
|
|
71
|
+
if side == "auto":
|
|
72
|
+
out.append(data)
|
|
73
|
+
autos.append(True)
|
|
74
|
+
elif side == "min":
|
|
75
|
+
out.append(lo)
|
|
76
|
+
autos.append(False)
|
|
77
|
+
elif side == "max":
|
|
78
|
+
out.append(hi)
|
|
79
|
+
autos.append(False)
|
|
80
|
+
elif isinstance(side, tuple):
|
|
81
|
+
out.append(quantile(side[1]) if quantile is not None else data)
|
|
82
|
+
autos.append(False)
|
|
83
|
+
else:
|
|
84
|
+
out.append(float(side))
|
|
85
|
+
autos.append(False)
|
|
86
|
+
return out[0], out[1], autos[0], autos[1]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def needs_quantiles(spec: Any) -> Sequence[float]:
|
|
90
|
+
if spec == "auto":
|
|
91
|
+
return ()
|
|
92
|
+
return [s[1] for s in spec if isinstance(s, tuple)]
|