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/render.py
ADDED
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
"""Layout and drawing of figures.
|
|
2
|
+
|
|
3
|
+
Drawing a figure resolves every plot (scales, limits, map limits), measures the
|
|
4
|
+
decorations of each area for its axes size, solves the grid (gutters for
|
|
5
|
+
``align="axes"``, boxes for ``align="borders"``), repeats until the axes sizes settle,
|
|
6
|
+
then draws. All lengths here are points.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import pdf_io
|
|
14
|
+
|
|
15
|
+
from . import _backend
|
|
16
|
+
from .axis import AxisState
|
|
17
|
+
from .limits import needs_quantiles, resolve_limits
|
|
18
|
+
from .scales import get_scale
|
|
19
|
+
from .series import DrawContext
|
|
20
|
+
from .style import resolve_color
|
|
21
|
+
from .text import Font, box, draw_text, measure, superscript
|
|
22
|
+
|
|
23
|
+
__all__ = ["draw", "save", "to_bytes", "size", "Layout"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Arrangement
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _z(item: Any) -> float:
|
|
33
|
+
"""Drawing order key: ``z``, unset (call order) counting as 0 (sorting is stable)."""
|
|
34
|
+
z = item.get("z")
|
|
35
|
+
return 0.0 if z is None else z
|
|
36
|
+
|
|
37
|
+
class Area:
|
|
38
|
+
"""A plot or colorbar placed on cells ``[r0, r1) × [c0, c1)``."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, obj: Any, r0: int, c0: int, r1: int, c1: int) -> None:
|
|
41
|
+
self.obj = obj
|
|
42
|
+
self.r0, self.c0, self.r1, self.c1 = r0, c0, r1, c1
|
|
43
|
+
self.state: Any = None
|
|
44
|
+
self.margins = (0.0, 0.0, 0.0, 0.0) # left, right, top, bottom
|
|
45
|
+
self.axes = (0.0, 0.0, 0.0, 0.0) # x0, y0, x1, y1
|
|
46
|
+
self.box = (0.0, 0.0, 0.0, 0.0)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_bar(self) -> bool:
|
|
50
|
+
"""A colorbar or a size legend: an area of fixed thickness."""
|
|
51
|
+
from .colorbar import Colorbar
|
|
52
|
+
from .legend import SizeLegend
|
|
53
|
+
return isinstance(self.obj, (Colorbar, SizeLegend))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def arrange(fig: Any, only: Any = None) -> tuple[list[Area], int, int]:
|
|
57
|
+
"""The areas of the figure and the grid size."""
|
|
58
|
+
if only is not None:
|
|
59
|
+
return [Area(only, 0, 0, 1, 1)], 1, 1
|
|
60
|
+
items = list(fig._areas.items())
|
|
61
|
+
grid = fig.get("grid")
|
|
62
|
+
if grid is not None:
|
|
63
|
+
areas = []
|
|
64
|
+
for k, obj in items:
|
|
65
|
+
cells = [(i, j) for i, r in enumerate(grid) for j, v in enumerate(r) if v == k]
|
|
66
|
+
if not cells:
|
|
67
|
+
raise ValueError(f"id {k!r} is not in the grid")
|
|
68
|
+
r0 = min(c[0] for c in cells)
|
|
69
|
+
c0 = min(c[1] for c in cells)
|
|
70
|
+
areas.append(Area(obj, r0, c0, max(c[0] for c in cells) + 1,
|
|
71
|
+
max(c[1] for c in cells) + 1))
|
|
72
|
+
return areas, len(grid), len(grid[0])
|
|
73
|
+
n = len(items)
|
|
74
|
+
rows, cols, order = fig.get("rows"), fig.get("cols"), fig.get("order")
|
|
75
|
+
if rows and cols:
|
|
76
|
+
nr, nc = rows, cols
|
|
77
|
+
by_row = order != "col"
|
|
78
|
+
elif cols:
|
|
79
|
+
nc = cols
|
|
80
|
+
nr = max(1, math.ceil(n / cols))
|
|
81
|
+
by_row = order != "col"
|
|
82
|
+
elif rows:
|
|
83
|
+
nr = rows
|
|
84
|
+
nc = max(1, math.ceil(n / rows))
|
|
85
|
+
by_row = order == "row"
|
|
86
|
+
else:
|
|
87
|
+
nc = max(1, math.ceil(math.sqrt(n)))
|
|
88
|
+
nr = max(1, math.ceil(n / nc))
|
|
89
|
+
by_row = order != "col"
|
|
90
|
+
areas = []
|
|
91
|
+
for i, (_, obj) in enumerate(items):
|
|
92
|
+
r, c = (i // nc, i % nc) if by_row else (i % nr, i // nr)
|
|
93
|
+
areas.append(Area(obj, r, c, r + 1, c + 1))
|
|
94
|
+
return areas, nr, nc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Color and size map limits
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def resolve_maps(series: Sequence[Any], extra: Sequence[Any] = ()) -> dict[int, tuple]:
|
|
102
|
+
"""Limits of every color and size map used, after the map's scale function:
|
|
103
|
+
``id(map) → (flo, fhi, data_lo, data_hi)``."""
|
|
104
|
+
users: dict[int, tuple[Any, list[Any]]] = {}
|
|
105
|
+
for s in series:
|
|
106
|
+
for m, values in list(s.color_sources()) + list(s.size_sources()):
|
|
107
|
+
users.setdefault(id(m), (m, []))[1].append(values)
|
|
108
|
+
for m in extra:
|
|
109
|
+
users.setdefault(id(m), (m, []))
|
|
110
|
+
out: dict[int, tuple] = {}
|
|
111
|
+
for key, (m, value_list) in users.items():
|
|
112
|
+
scale = get_scale(m.scale)
|
|
113
|
+
count, lo, hi = 0, math.inf, -math.inf
|
|
114
|
+
for v in value_list:
|
|
115
|
+
n, a, b = _backend.extent(v, scale.function)
|
|
116
|
+
if n:
|
|
117
|
+
count += n
|
|
118
|
+
lo, hi = min(lo, a), max(hi, b)
|
|
119
|
+
if not count:
|
|
120
|
+
lo, hi = (1.0, 10.0) if scale.name.startswith("log") else (0.0, 1.0)
|
|
121
|
+
qs = needs_quantiles(m.limits)
|
|
122
|
+
qmap: dict[float, float] = {}
|
|
123
|
+
if qs:
|
|
124
|
+
from array import array
|
|
125
|
+
allv = array("d")
|
|
126
|
+
for v in value_list:
|
|
127
|
+
allv.extend(_backend.doubles(v))
|
|
128
|
+
allv = array("d", [x for x in allv if scale.contains(x)])
|
|
129
|
+
qmap = dict(zip(qs, _backend.quantiles(allv, qs)))
|
|
130
|
+
a, b, _, _ = resolve_limits(m.limits, lo, hi, lambda q: qmap[q])
|
|
131
|
+
fa, fb = scale.f(a), scale.f(b)
|
|
132
|
+
if not (math.isfinite(fa) and math.isfinite(fb)):
|
|
133
|
+
raise ValueError(f"map limits {a!r}/{b!r} are outside the {scale.name} scale's domain")
|
|
134
|
+
if fa == fb:
|
|
135
|
+
fa, fb = fa - 0.5, fb + 0.5
|
|
136
|
+
out[key] = (fa, fb, lo, hi)
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Plot state: resolution, measurement, drawing
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _label_content(state: AxisState) -> Any:
|
|
145
|
+
"""The axis title: its label, or the label it takes from a data column."""
|
|
146
|
+
label = state.axis.get("label")
|
|
147
|
+
return state.auto_label if label is None else label
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _offset_content(state: AxisState) -> Any:
|
|
151
|
+
"""What is written at the end of the axis: the common ×10ᵏ factor of the tick labels,
|
|
152
|
+
or the common date part of a time axis; ``None`` when there is none."""
|
|
153
|
+
if state.offset:
|
|
154
|
+
return state.offset
|
|
155
|
+
if not state.factor:
|
|
156
|
+
return None
|
|
157
|
+
from .numbers import NumberStyle
|
|
158
|
+
style = NumberStyle.of(state.axis)
|
|
159
|
+
return ["\u00d710", superscript(style.exponent(state.factor))]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class PlotState:
|
|
163
|
+
"""A plot while its figure is drawn."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, plot: Any, maps: dict[int, tuple], palette: Any = None) -> None:
|
|
166
|
+
self.plot = plot
|
|
167
|
+
self.maps = maps
|
|
168
|
+
self.x = AxisState(plot.x_axis)
|
|
169
|
+
self.y = AxisState(plot.y_axis)
|
|
170
|
+
series = list(plot.series)
|
|
171
|
+
self.series = sorted(series, key=_z)
|
|
172
|
+
from .palettes import palette_colors
|
|
173
|
+
twins = list(getattr(plot, "twins", []))
|
|
174
|
+
colored = series + [s for t in twins for s in t.series] # palette continues
|
|
175
|
+
n_auto = sum(s.palette_count() for s in colored
|
|
176
|
+
if s.USES_PALETTE and not s.has_own_color())
|
|
177
|
+
refs = [int(v[1:]) + 1 for s in colored for v in s.settings().values()
|
|
178
|
+
if isinstance(v, str) and v[:1] == "C" and v[1:].isdigit()]
|
|
179
|
+
if palette is None:
|
|
180
|
+
palette = palette_colors(plot.get("palette"), max([n_auto] + refs))
|
|
181
|
+
self.palette = palette
|
|
182
|
+
i = 0
|
|
183
|
+
for s in (colored if not getattr(plot, "twin_of", None) else []):
|
|
184
|
+
if s.USES_PALETTE and not s.has_own_color():
|
|
185
|
+
k = s.palette_count()
|
|
186
|
+
s._auto_colors = [palette[(i + j) % len(palette)] for j in range(k)]
|
|
187
|
+
s._auto_color = s._auto_colors[0]
|
|
188
|
+
i += k
|
|
189
|
+
else:
|
|
190
|
+
s._auto_color = None
|
|
191
|
+
s._auto_colors = None
|
|
192
|
+
kinds = {s.AXES for s in series}
|
|
193
|
+
if len(kinds) > 1:
|
|
194
|
+
raise ValueError(f"{plot!r} mixes series drawn without axes (pie) and with axes")
|
|
195
|
+
self.axes_on = plot.get("axes") and kinds != {False}
|
|
196
|
+
decos = list(plot.decorations)
|
|
197
|
+
self.decorations = decos
|
|
198
|
+
limit_items = series + [d for d in decos if d.get("extend_limits")]
|
|
199
|
+
self.limit_items, self.padded = limit_items, self.axes_on
|
|
200
|
+
for role, st in (("x", self.x), ("y", self.y)):
|
|
201
|
+
st.auto_label = next((t for t in (s.axis_title(role) for s in series)
|
|
202
|
+
if t is not None), None)
|
|
203
|
+
self.twins = [PlotState(t, maps, palette) for t in twins]
|
|
204
|
+
self.role_items = {"x": list(limit_items), "y": list(limit_items)}
|
|
205
|
+
for t in self.twins: # a twin shares the other axis
|
|
206
|
+
shared = "x" if t.plot.twin_of[1] == "y" else "y"
|
|
207
|
+
self.role_items[shared] += t.limit_items
|
|
208
|
+
self.x.resolve(self.role_items["x"], self.padded)
|
|
209
|
+
self.y.resolve(self.role_items["y"], self.padded)
|
|
210
|
+
for t in self.twins:
|
|
211
|
+
shared = "x" if t.plot.twin_of[1] == "y" else "y"
|
|
212
|
+
mine, theirs = getattr(self, shared), getattr(t, shared)
|
|
213
|
+
theirs.copy_limits(mine)
|
|
214
|
+
theirs.shared = mine
|
|
215
|
+
from .colorbar import BarState
|
|
216
|
+
from .legend import LegendState
|
|
217
|
+
self.bars = [BarState(cb, maps, plot) for cb in plot.colorbars]
|
|
218
|
+
self.legend = LegendState(self)
|
|
219
|
+
self.ticks_for: tuple[float, float] | None = None
|
|
220
|
+
|
|
221
|
+
# -- measurement ----------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
def measure(self, canvas: Any, w: float, h: float) -> tuple[float, float, float, float]:
|
|
224
|
+
"""Margins (left, right, top, bottom) around axes ``w`` × ``h``."""
|
|
225
|
+
plot = self.plot
|
|
226
|
+
x, y = self.x, self.y
|
|
227
|
+
w, h = max(w, 1.0), max(h, 1.0)
|
|
228
|
+
x.compute_ticks(canvas, w, True)
|
|
229
|
+
y.compute_ticks(canvas, h, False)
|
|
230
|
+
self.ticks_for = (w, h)
|
|
231
|
+
left = right = top = bottom = 0.0
|
|
232
|
+
over_l = over_r = over_t = over_b = 0.0
|
|
233
|
+
y_offset = False # a ×10ᵏ factor or date part above the y axis
|
|
234
|
+
xa, ya = plot.x_axis, plot.y_axis
|
|
235
|
+
if self.axes_on and xa.get("visible"):
|
|
236
|
+
out, _ = x.tick_out()
|
|
237
|
+
font = x.tick_font()
|
|
238
|
+
labels = [(t, t.label) for t in x.ticks if t.major and t.label is not None
|
|
239
|
+
and not x.hide_labels]
|
|
240
|
+
xh = out + xa.get("spine_offset")
|
|
241
|
+
mirror = (out + xa.get("spine_offset")) if xa.get("mirror") and x.ticks else 0.0
|
|
242
|
+
if labels:
|
|
243
|
+
xh += xa.get("tick_pad") + max(box(canvas, lab, font, x.rotation)[1]
|
|
244
|
+
for _, lab in labels)
|
|
245
|
+
mapper = x.mapper(0.0, w)
|
|
246
|
+
for t, lab in labels:
|
|
247
|
+
bw = box(canvas, lab, font, x.rotation)[0]
|
|
248
|
+
p = mapper(t.value)
|
|
249
|
+
if x.rotation:
|
|
250
|
+
over_l = max(over_l, bw - p)
|
|
251
|
+
else:
|
|
252
|
+
over_l = max(over_l, bw / 2 - p)
|
|
253
|
+
over_r = max(over_r, p + bw / 2 - w)
|
|
254
|
+
content = None if x.hide_labels else _label_content(x)
|
|
255
|
+
offset = None if x.hide_labels else _offset_content(x)
|
|
256
|
+
row = 0.0
|
|
257
|
+
if content is not None:
|
|
258
|
+
_, a, d = measure(canvas, content, x.label_font())
|
|
259
|
+
row = a + d
|
|
260
|
+
if offset is not None:
|
|
261
|
+
row = max(row, box(canvas, offset, font, 0.0)[1])
|
|
262
|
+
if row:
|
|
263
|
+
xh += xa.get("label_pad") + row
|
|
264
|
+
if x.on_far_side():
|
|
265
|
+
top, bottom = xh, mirror
|
|
266
|
+
else:
|
|
267
|
+
top, bottom = mirror, xh
|
|
268
|
+
if self.axes_on and ya.get("visible"):
|
|
269
|
+
out, _ = y.tick_out()
|
|
270
|
+
font = y.tick_font()
|
|
271
|
+
labels = [(t, t.label) for t in y.ticks if t.major and t.label is not None
|
|
272
|
+
and not y.hide_labels]
|
|
273
|
+
yw = out + ya.get("spine_offset")
|
|
274
|
+
mirror = (out + ya.get("spine_offset")) if ya.get("mirror") and y.ticks else 0.0
|
|
275
|
+
if labels:
|
|
276
|
+
yw += ya.get("tick_pad") + max(box(canvas, lab, font, y.rotation)[0]
|
|
277
|
+
for _, lab in labels)
|
|
278
|
+
mapper = y.mapper(h, 0.0)
|
|
279
|
+
for t, lab in labels:
|
|
280
|
+
bh = box(canvas, lab, font, y.rotation)[1]
|
|
281
|
+
p = mapper(t.value)
|
|
282
|
+
over_t = max(over_t, bh / 2 - p)
|
|
283
|
+
over_b = max(over_b, p + bh / 2 - h)
|
|
284
|
+
content = None if y.hide_labels else _label_content(y)
|
|
285
|
+
if content is not None:
|
|
286
|
+
_, a, d = measure(canvas, content, y.label_font())
|
|
287
|
+
yw += ya.get("label_pad") + a + d
|
|
288
|
+
if y.on_far_side():
|
|
289
|
+
left, right = mirror, yw
|
|
290
|
+
else:
|
|
291
|
+
left, right = yw, mirror
|
|
292
|
+
offset = None if y.hide_labels else _offset_content(y)
|
|
293
|
+
if offset is not None:
|
|
294
|
+
ow, oh = box(canvas, offset, font, 0.0)
|
|
295
|
+
top += ya.get("tick_pad") + oh
|
|
296
|
+
y_offset = True
|
|
297
|
+
if y.on_far_side():
|
|
298
|
+
over_r = max(over_r, ow - yw)
|
|
299
|
+
else:
|
|
300
|
+
over_l = max(over_l, ow - yw)
|
|
301
|
+
self._twin_extent = [0.0, 0.0, 0.0, 0.0]
|
|
302
|
+
for tw in self.twins:
|
|
303
|
+
tl, tr, tt, tb = tw.measure(canvas, w, h)
|
|
304
|
+
if tw.plot.twin_of[1] == "y":
|
|
305
|
+
left, right = left + tl, right + tr
|
|
306
|
+
over_t, over_b = max(over_t, tt), max(over_b, tb)
|
|
307
|
+
else:
|
|
308
|
+
top, bottom = top + tt, bottom + tb
|
|
309
|
+
over_l, over_r = max(over_l, tl), max(over_r, tr)
|
|
310
|
+
for k, v in enumerate((tl, tr, tt, tb)):
|
|
311
|
+
self._twin_extent[k] += v
|
|
312
|
+
title = plot.title
|
|
313
|
+
text = title.get("text")
|
|
314
|
+
if text is not None:
|
|
315
|
+
_, a, d = measure(canvas, text, self.title_font())
|
|
316
|
+
stacked = x.on_far_side() or y_offset
|
|
317
|
+
top = (top if stacked else 0.0) + max(
|
|
318
|
+
title.get("pad") + a + d, 0.0 if stacked else top)
|
|
319
|
+
lw, lh = self.legend.measure(canvas)
|
|
320
|
+
pos = self.legend.position()
|
|
321
|
+
if lw and pos == "outside right":
|
|
322
|
+
right += plot.legend.get("pad") + lw
|
|
323
|
+
elif lh and pos == "outside top":
|
|
324
|
+
top += plot.legend.get("pad") + lh
|
|
325
|
+
elif lh and pos == "outside bottom":
|
|
326
|
+
bottom += plot.legend.get("pad") + lh
|
|
327
|
+
for bar in self.bars:
|
|
328
|
+
side = bar.position
|
|
329
|
+
extent = bar.measure_attached(canvas, w if side in ("top", "bottom") else h)
|
|
330
|
+
if side == "right":
|
|
331
|
+
right += extent
|
|
332
|
+
elif side == "left":
|
|
333
|
+
left += extent
|
|
334
|
+
elif side == "top":
|
|
335
|
+
top += extent
|
|
336
|
+
else:
|
|
337
|
+
bottom += extent
|
|
338
|
+
return (max(left, over_l), max(right, over_r), max(top, over_t), max(bottom, over_b))
|
|
339
|
+
|
|
340
|
+
def title_font(self) -> Font:
|
|
341
|
+
t = self.plot.title
|
|
342
|
+
return Font(t.get("font_size"), t.get("font_family"), t.get("font_weight"),
|
|
343
|
+
color=t.get("color"))
|
|
344
|
+
|
|
345
|
+
def aspect_rect(self, rect: tuple[float, float, float, float]) -> tuple:
|
|
346
|
+
aspect = self.plot.get("aspect") or (None if self.axes_on else 1.0)
|
|
347
|
+
if not aspect:
|
|
348
|
+
return rect
|
|
349
|
+
x0, y0, x1, y1 = rect
|
|
350
|
+
w, h = x1 - x0, y1 - y0
|
|
351
|
+
dx = abs(self.x.fhi - self.x.flo) or 1.0
|
|
352
|
+
dy = abs(self.y.fhi - self.y.flo) or 1.0
|
|
353
|
+
want = aspect * dy / dx # h / w
|
|
354
|
+
if h > w * want:
|
|
355
|
+
nh = w * want
|
|
356
|
+
y0 += (h - nh) / 2
|
|
357
|
+
y1 = y0 + nh
|
|
358
|
+
else:
|
|
359
|
+
nw = h / want
|
|
360
|
+
x0 += (w - nw) / 2
|
|
361
|
+
x1 = x0 + nw
|
|
362
|
+
return (x0, y0, x1, y1)
|
|
363
|
+
|
|
364
|
+
# -- drawing --------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def draw(self, canvas: Any, rect: tuple[float, float, float, float]) -> None:
|
|
367
|
+
plot = self.plot
|
|
368
|
+
x0, y0, x1, y1 = rect
|
|
369
|
+
w, h = x1 - x0, y1 - y0
|
|
370
|
+
if self.ticks_for is None or abs(self.ticks_for[0] - w) > 0.01 \
|
|
371
|
+
or abs(self.ticks_for[1] - h) > 0.01:
|
|
372
|
+
self.x.compute_ticks(canvas, w, True)
|
|
373
|
+
self.y.compute_ticks(canvas, h, False)
|
|
374
|
+
palette = self.palette
|
|
375
|
+
ctx = DrawContext(canvas, plot, rect, self.x, self.y, self.maps, palette)
|
|
376
|
+
ctx.figure_size = getattr(self, "figure_size", (x1, y1))
|
|
377
|
+
bg = plot.get("background")
|
|
378
|
+
if bg is not None:
|
|
379
|
+
canvas.save()
|
|
380
|
+
canvas.fill_style = resolve_color(bg, palette)
|
|
381
|
+
canvas.fill_rect(x0, y0, w, h)
|
|
382
|
+
canvas.restore()
|
|
383
|
+
if self.axes_on:
|
|
384
|
+
self._grid(canvas, ctx)
|
|
385
|
+
items = sorted(list(self.series) + self.decorations, key=_z)
|
|
386
|
+
clipped = None
|
|
387
|
+
try:
|
|
388
|
+
for s in items:
|
|
389
|
+
want = bool(plot.get("clip")) and (s.get("clip") if "clip" in s.SETTINGS
|
|
390
|
+
else True)
|
|
391
|
+
if want != clipped:
|
|
392
|
+
if clipped is not None:
|
|
393
|
+
canvas.restore()
|
|
394
|
+
canvas.save()
|
|
395
|
+
if want:
|
|
396
|
+
canvas.begin_path()
|
|
397
|
+
canvas.rect(x0, y0, w, h)
|
|
398
|
+
canvas.clip()
|
|
399
|
+
canvas.begin_path()
|
|
400
|
+
clipped = want
|
|
401
|
+
r = s.get("rasterize") if "rasterize" in s.SETTINGS else None
|
|
402
|
+
if r:
|
|
403
|
+
dpi = None if r is True else float(r)
|
|
404
|
+
with canvas.group(rasterize=True, dpi=dpi):
|
|
405
|
+
s.draw(ctx)
|
|
406
|
+
else:
|
|
407
|
+
s.draw(ctx)
|
|
408
|
+
finally:
|
|
409
|
+
if clipped is not None:
|
|
410
|
+
canvas.restore()
|
|
411
|
+
tctxs = []
|
|
412
|
+
for t in self.twins:
|
|
413
|
+
if t.ticks_for is None or abs(t.ticks_for[0] - w) > 0.01 \
|
|
414
|
+
or abs(t.ticks_for[1] - h) > 0.01:
|
|
415
|
+
t.x.compute_ticks(canvas, w, True)
|
|
416
|
+
t.y.compute_ticks(canvas, h, False)
|
|
417
|
+
tctx = DrawContext(canvas, t.plot, rect, t.x, t.y, self.maps, palette)
|
|
418
|
+
tctx.figure_size = ctx.figure_size
|
|
419
|
+
tctxs.append((t, tctx))
|
|
420
|
+
t._grid(canvas, tctx)
|
|
421
|
+
canvas.save()
|
|
422
|
+
if plot.get("clip"):
|
|
423
|
+
canvas.begin_path()
|
|
424
|
+
canvas.rect(x0, y0, w, h)
|
|
425
|
+
canvas.clip()
|
|
426
|
+
canvas.begin_path()
|
|
427
|
+
for s in sorted(t.series, key=_z):
|
|
428
|
+
s.draw(tctx)
|
|
429
|
+
canvas.restore()
|
|
430
|
+
for s in self.series:
|
|
431
|
+
s.draw_overlay(ctx)
|
|
432
|
+
border = plot.get("border")
|
|
433
|
+
if border is not None and plot.get("border_width"):
|
|
434
|
+
canvas.save()
|
|
435
|
+
canvas.stroke_style = resolve_color(border, palette)
|
|
436
|
+
canvas.line_width = plot.get("border_width")
|
|
437
|
+
canvas.begin_path()
|
|
438
|
+
canvas.rect(x0, y0, w, h)
|
|
439
|
+
canvas.stroke()
|
|
440
|
+
canvas.restore()
|
|
441
|
+
if self.axes_on:
|
|
442
|
+
self._spines(canvas, rect)
|
|
443
|
+
self._axis_x(canvas, ctx)
|
|
444
|
+
self._axis_y(canvas, ctx)
|
|
445
|
+
for t, tctx in tctxs:
|
|
446
|
+
t._spines(canvas, rect)
|
|
447
|
+
t._axis_x(canvas, tctx)
|
|
448
|
+
t._axis_y(canvas, tctx)
|
|
449
|
+
if t.legend.active():
|
|
450
|
+
t.legend.draw(canvas, tctx)
|
|
451
|
+
if self.twins: # attached items start beyond the twin axes
|
|
452
|
+
self._left_used += self._twin_extent[0]
|
|
453
|
+
self._right_used += self._twin_extent[1]
|
|
454
|
+
self._top_used += self._twin_extent[2]
|
|
455
|
+
self._bottom_used += self._twin_extent[3]
|
|
456
|
+
self._top_bars = sum(b.measure_attached(canvas, w) for b in self.bars
|
|
457
|
+
if b.position == "top")
|
|
458
|
+
self._title(canvas, rect)
|
|
459
|
+
self.legend.draw(canvas, ctx)
|
|
460
|
+
offsets = {"left": 0.0, "right": 0.0, "top": 0.0, "bottom": 0.0}
|
|
461
|
+
for bar in self.bars:
|
|
462
|
+
offsets[bar.position] += bar.draw_attached(canvas, rect, offsets[bar.position],
|
|
463
|
+
self.attached_base(bar.position))
|
|
464
|
+
|
|
465
|
+
def attached_base(self, side: str) -> float:
|
|
466
|
+
"""Distance from the axes to where attached colorbars start on ``side``."""
|
|
467
|
+
if side == "right" and self.legend.outside() == "outside right":
|
|
468
|
+
return self._right_used + self.plot.legend.get("pad") + self.legend.size[0]
|
|
469
|
+
if side == "right":
|
|
470
|
+
return self._right_used
|
|
471
|
+
if side == "top" and self._top_used:
|
|
472
|
+
return self._top_used
|
|
473
|
+
if side == "bottom" and self.legend.outside() == "outside bottom":
|
|
474
|
+
return self._bottom_used + self.plot.legend.get("pad") + self.legend.size[1]
|
|
475
|
+
if side == "bottom":
|
|
476
|
+
return self._bottom_used
|
|
477
|
+
if side == "left":
|
|
478
|
+
return self._left_used
|
|
479
|
+
if side == "top" and self.legend.outside() == "outside top":
|
|
480
|
+
return self.plot.legend.get("pad") + self.legend.size[1]
|
|
481
|
+
return 0.0
|
|
482
|
+
|
|
483
|
+
_bottom_used = 0.0
|
|
484
|
+
_left_used = 0.0
|
|
485
|
+
_top_used = 0.0
|
|
486
|
+
_right_used = 0.0
|
|
487
|
+
_top_bars = 0.0
|
|
488
|
+
|
|
489
|
+
def _grid(self, canvas: Any, ctx: DrawContext) -> None:
|
|
490
|
+
for state, horizontal in ((self.x, False), (self.y, True)):
|
|
491
|
+
axis = state.axis
|
|
492
|
+
if axis.get("minor_grid"):
|
|
493
|
+
self._grid_lines(canvas, ctx, state.minor_grid_positions(), horizontal,
|
|
494
|
+
axis.get("minor_grid_color"), axis.get("minor_grid_width"),
|
|
495
|
+
axis.get("minor_grid_style"))
|
|
496
|
+
if axis.get("grid"):
|
|
497
|
+
self._grid_lines(canvas, ctx, [t.value for t in state.ticks if t.major],
|
|
498
|
+
horizontal, axis.get("grid_color"), axis.get("grid_width"),
|
|
499
|
+
axis.get("grid_style"))
|
|
500
|
+
for state, horizontal in ((self.x, False), (self.y, True)):
|
|
501
|
+
axis = state.axis
|
|
502
|
+
if (axis.get("zero_line") and state.scale is not None
|
|
503
|
+
and min(state.lo, state.hi) <= 0.0 <= max(state.lo, state.hi)
|
|
504
|
+
and math.isfinite(state.f(0.0))):
|
|
505
|
+
self._grid_lines(canvas, ctx, [0.0], horizontal, axis.get("zero_line_color"),
|
|
506
|
+
axis.get("zero_line_width"), "solid")
|
|
507
|
+
|
|
508
|
+
def _grid_lines(self, canvas: Any, ctx: DrawContext, values: list[float],
|
|
509
|
+
horizontal: bool, color: Any, width: float, style: Any) -> None:
|
|
510
|
+
from .style import dash_of
|
|
511
|
+
x0, y0, x1, y1 = ctx.rect
|
|
512
|
+
canvas.save()
|
|
513
|
+
canvas.stroke_style = resolve_color(color, ctx.palette)
|
|
514
|
+
canvas.line_width = width
|
|
515
|
+
canvas.line_dash = dash_of(style, width)
|
|
516
|
+
canvas.begin_path()
|
|
517
|
+
mapper = ctx.ymap if horizontal else ctx.xmap
|
|
518
|
+
for v in values:
|
|
519
|
+
p = mapper(v)
|
|
520
|
+
if horizontal:
|
|
521
|
+
canvas.move_to(x0, p)
|
|
522
|
+
canvas.line_to(x1, p)
|
|
523
|
+
else:
|
|
524
|
+
canvas.move_to(p, y0)
|
|
525
|
+
canvas.line_to(p, y1)
|
|
526
|
+
canvas.stroke()
|
|
527
|
+
canvas.restore()
|
|
528
|
+
|
|
529
|
+
def _spines(self, canvas: Any, rect: tuple[float, float, float, float]) -> None:
|
|
530
|
+
x0, y0, x1, y1 = rect
|
|
531
|
+
spines = set(self.plot.get("spines"))
|
|
532
|
+
swap = {"left": "right", "right": "left", "top": "bottom", "bottom": "top"}
|
|
533
|
+
if self.x.on_far_side():
|
|
534
|
+
spines = {swap[s] if s in ("top", "bottom") else s for s in spines}
|
|
535
|
+
if self.y.on_far_side():
|
|
536
|
+
spines = {swap[s] if s in ("left", "right") else s for s in spines}
|
|
537
|
+
if not self.plot.is_set("spines"):
|
|
538
|
+
if self.x.bare:
|
|
539
|
+
spines -= {"bottom", "top"}
|
|
540
|
+
if self.y.bare:
|
|
541
|
+
spines -= {"left", "right"}
|
|
542
|
+
xa, ya = self.plot.x_axis, self.plot.y_axis
|
|
543
|
+
ox, oy = xa.get("spine_offset"), ya.get("spine_offset")
|
|
544
|
+
xs0, xs1 = self._trim(self.x, xa, x0, x1, True)
|
|
545
|
+
ys0, ys1 = self._trim(self.y, ya, y0, y1, False)
|
|
546
|
+
lines = {"left": (ya, x0 - oy, ys0, x0 - oy, ys1),
|
|
547
|
+
"right": (ya, x1 + oy, ys0, x1 + oy, ys1),
|
|
548
|
+
"bottom": (xa, xs0, y1 + ox, xs1, y1 + ox),
|
|
549
|
+
"top": (xa, xs0, y0 - ox, xs1, y0 - ox)}
|
|
550
|
+
canvas.save()
|
|
551
|
+
canvas.line_cap = "square"
|
|
552
|
+
for side in ("left", "right", "bottom", "top"):
|
|
553
|
+
if side not in spines:
|
|
554
|
+
continue
|
|
555
|
+
axis, a, b, c, d = lines[side]
|
|
556
|
+
lw = axis.get("line_width")
|
|
557
|
+
if not lw:
|
|
558
|
+
continue
|
|
559
|
+
canvas.stroke_style = resolve_color(axis.get("line_color"), self.palette)
|
|
560
|
+
canvas.line_width = lw
|
|
561
|
+
canvas.begin_path()
|
|
562
|
+
canvas.move_to(a, b)
|
|
563
|
+
canvas.line_to(c, d)
|
|
564
|
+
canvas.stroke()
|
|
565
|
+
canvas.restore()
|
|
566
|
+
|
|
567
|
+
def _trim(self, state: AxisState, axis: Any, p0: float, p1: float,
|
|
568
|
+
horizontal: bool) -> tuple[float, float]:
|
|
569
|
+
"""Extent of an axis line: the axes side, or (``spine_trim``) first to last major
|
|
570
|
+
tick."""
|
|
571
|
+
if not axis.get("spine_trim"):
|
|
572
|
+
return p0, p1
|
|
573
|
+
majors = [t.value for t in state.ticks if t.major]
|
|
574
|
+
if not majors:
|
|
575
|
+
return p0, p1
|
|
576
|
+
m = state.mapper(p0, p1) if horizontal else state.mapper(p1, p0)
|
|
577
|
+
ps = [m(v) for v in majors]
|
|
578
|
+
return min(ps), max(ps)
|
|
579
|
+
|
|
580
|
+
def _ticks(self, canvas: Any, state: AxisState, mapper: Any, horizontal: bool,
|
|
581
|
+
base: float, flip: bool = False) -> None:
|
|
582
|
+
"""Tick marks; ``base`` is the axis line coordinate (y for x axes, x for y axes);
|
|
583
|
+
``flip``: on the opposite side of the axes (``mirror``)."""
|
|
584
|
+
axis = state.axis
|
|
585
|
+
out, inside = state.tick_out()
|
|
586
|
+
if not state.ticks or not (out or inside):
|
|
587
|
+
return
|
|
588
|
+
if flip:
|
|
589
|
+
out, inside = -out, -inside
|
|
590
|
+
canvas.save()
|
|
591
|
+
canvas.stroke_style = resolve_color(axis.get("tick_color"), self.palette)
|
|
592
|
+
canvas.line_width = axis.get("tick_width")
|
|
593
|
+
ml = axis.get("minor_tick_length") * (-1.0 if flip else 1.0)
|
|
594
|
+
m_out, m_in = (ml if out else 0.0), (ml if inside else 0.0)
|
|
595
|
+
canvas.begin_path()
|
|
596
|
+
for t in state.ticks:
|
|
597
|
+
p = mapper(t.value)
|
|
598
|
+
o, i = (out, inside) if t.major else (m_out, m_in)
|
|
599
|
+
if horizontal:
|
|
600
|
+
canvas.move_to(p, base - i)
|
|
601
|
+
canvas.line_to(p, base + o)
|
|
602
|
+
else:
|
|
603
|
+
canvas.move_to(base + i, p)
|
|
604
|
+
canvas.line_to(base - o, p)
|
|
605
|
+
canvas.stroke()
|
|
606
|
+
canvas.restore()
|
|
607
|
+
|
|
608
|
+
def _axis_x(self, canvas: Any, ctx: DrawContext) -> None:
|
|
609
|
+
axis = self.plot.x_axis
|
|
610
|
+
x0, y0, x1, y1 = ctx.rect
|
|
611
|
+
self._bottom_used = self._top_used = 0.0
|
|
612
|
+
if not axis.get("visible"):
|
|
613
|
+
return
|
|
614
|
+
state = self.x
|
|
615
|
+
top = state.on_far_side()
|
|
616
|
+
sign = -1.0 if top else 1.0 # away from the axes
|
|
617
|
+
off = axis.get("spine_offset")
|
|
618
|
+
edge, other = (y0, y1) if top else (y1, y0)
|
|
619
|
+
base = edge + sign * off
|
|
620
|
+
self._ticks(canvas, state, ctx.xmap, True, base, flip=top)
|
|
621
|
+
if axis.get("mirror"):
|
|
622
|
+
self._ticks(canvas, state, ctx.xmap, True, other - sign * off, flip=not top)
|
|
623
|
+
out, _ = state.tick_out()
|
|
624
|
+
y = base + sign * out
|
|
625
|
+
va = "bottom" if top else "top"
|
|
626
|
+
font = state.tick_font()
|
|
627
|
+
labels = [t for t in state.ticks if t.major and t.label is not None
|
|
628
|
+
and not state.hide_labels]
|
|
629
|
+
if labels:
|
|
630
|
+
y += sign * axis.get("tick_pad")
|
|
631
|
+
rows = 0.0
|
|
632
|
+
for t in labels:
|
|
633
|
+
p = ctx.xmap(t.value)
|
|
634
|
+
if state.rotation:
|
|
635
|
+
draw_text(canvas, t.label, p, y, font, "left" if top else "right", va,
|
|
636
|
+
state.rotation)
|
|
637
|
+
else:
|
|
638
|
+
draw_text(canvas, t.label, p, y, font, "center", va)
|
|
639
|
+
rows = max(rows, box(canvas, t.label, font, state.rotation)[1])
|
|
640
|
+
y += sign * rows
|
|
641
|
+
content = None if state.hide_labels else _label_content(state)
|
|
642
|
+
offset = None if state.hide_labels else _offset_content(state)
|
|
643
|
+
if content is not None or offset is not None:
|
|
644
|
+
y += sign * axis.get("label_pad")
|
|
645
|
+
row = 0.0
|
|
646
|
+
if content is not None:
|
|
647
|
+
lf = state.label_font()
|
|
648
|
+
draw_text(canvas, content, (x0 + x1) / 2, y, lf, "center", va)
|
|
649
|
+
_, a, d = measure(canvas, content, lf)
|
|
650
|
+
row = a + d
|
|
651
|
+
if offset is not None:
|
|
652
|
+
draw_text(canvas, offset, x1, y, font, "right", va)
|
|
653
|
+
row = max(row, box(canvas, offset, font, 0.0)[1])
|
|
654
|
+
y += sign * row
|
|
655
|
+
if top:
|
|
656
|
+
self._top_used = y0 - y
|
|
657
|
+
else:
|
|
658
|
+
self._bottom_used = y - y1
|
|
659
|
+
|
|
660
|
+
def _axis_y(self, canvas: Any, ctx: DrawContext) -> None:
|
|
661
|
+
axis = self.plot.y_axis
|
|
662
|
+
x0, y0, x1, y1 = ctx.rect
|
|
663
|
+
self._left_used = self._right_used = 0.0
|
|
664
|
+
if not axis.get("visible"):
|
|
665
|
+
return
|
|
666
|
+
state = self.y
|
|
667
|
+
right = state.on_far_side()
|
|
668
|
+
sign = 1.0 if right else -1.0 # away from the axes
|
|
669
|
+
off = axis.get("spine_offset")
|
|
670
|
+
edge, other = (x1, x0) if right else (x0, x1)
|
|
671
|
+
base = edge + sign * off
|
|
672
|
+
self._ticks(canvas, state, ctx.ymap, False, base, flip=right)
|
|
673
|
+
if axis.get("mirror"):
|
|
674
|
+
self._ticks(canvas, state, ctx.ymap, False, other - sign * off, flip=not right)
|
|
675
|
+
out, _ = state.tick_out()
|
|
676
|
+
x = base + sign * out
|
|
677
|
+
font = state.tick_font()
|
|
678
|
+
labels = [t for t in state.ticks if t.major and t.label is not None
|
|
679
|
+
and not state.hide_labels]
|
|
680
|
+
if labels:
|
|
681
|
+
x += sign * axis.get("tick_pad")
|
|
682
|
+
widest = 0.0
|
|
683
|
+
for t in labels:
|
|
684
|
+
p = ctx.ymap(t.value)
|
|
685
|
+
draw_text(canvas, t.label, x, p, font, "left" if right else "right", "center",
|
|
686
|
+
state.rotation)
|
|
687
|
+
widest = max(widest, box(canvas, t.label, font, state.rotation)[0])
|
|
688
|
+
x += sign * widest
|
|
689
|
+
content = None if state.hide_labels else _label_content(state)
|
|
690
|
+
if content is not None:
|
|
691
|
+
x += sign * axis.get("label_pad")
|
|
692
|
+
lf = state.label_font()
|
|
693
|
+
draw_text(canvas, content, x, (y0 + y1) / 2, lf, "center",
|
|
694
|
+
"top" if right else "bottom", 90.0)
|
|
695
|
+
_, a, d = measure(canvas, content, lf)
|
|
696
|
+
x += sign * (a + d)
|
|
697
|
+
if right:
|
|
698
|
+
self._right_used = x - x1
|
|
699
|
+
else:
|
|
700
|
+
self._left_used = x0 - x
|
|
701
|
+
offset = None if state.hide_labels else _offset_content(state)
|
|
702
|
+
if offset is not None:
|
|
703
|
+
above = self._top_used or (self.x.tick_out()[0] if self.plot.x_axis.get("mirror")
|
|
704
|
+
else 0.0)
|
|
705
|
+
yb = y0 - above - axis.get("tick_pad")
|
|
706
|
+
draw_text(canvas, offset, base, yb, font, "right" if right else "left", "bottom")
|
|
707
|
+
self._top_used = above + axis.get("tick_pad") + box(canvas, offset, font, 0.0)[1]
|
|
708
|
+
|
|
709
|
+
def _title(self, canvas: Any, rect: tuple[float, float, float, float]) -> None:
|
|
710
|
+
t = self.plot.title
|
|
711
|
+
text = t.get("text")
|
|
712
|
+
if text is None:
|
|
713
|
+
return
|
|
714
|
+
x0, y0, x1, _ = rect
|
|
715
|
+
y = y0 - t.get("pad") - self._top_bars - self._top_used
|
|
716
|
+
if self.legend.outside() == "outside top":
|
|
717
|
+
y -= self.plot.legend.get("pad") + self.legend.size[1]
|
|
718
|
+
if t.get("over") == "plot":
|
|
719
|
+
x0 -= self._left_used
|
|
720
|
+
align = t.get("align")
|
|
721
|
+
x = {"center": (x0 + x1) / 2, "left": x0, "right": x1}[align]
|
|
722
|
+
ha = {"center": "center", "left": "left", "right": "right"}[align]
|
|
723
|
+
draw_text(canvas, text, x, y, self.title_font(), ha, "bottom")
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# ---------------------------------------------------------------------------
|
|
727
|
+
# Layout
|
|
728
|
+
# ---------------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
class Layout:
|
|
731
|
+
"""Solved geometry: figure size and the rectangles of every area (points)."""
|
|
732
|
+
|
|
733
|
+
def __init__(self, areas: list[Area], width: float, height: float) -> None:
|
|
734
|
+
self.areas = areas
|
|
735
|
+
self.width = width
|
|
736
|
+
self.height = height
|
|
737
|
+
self.title: tuple[float, float, float] | None = None # x, y (top), width
|
|
738
|
+
self.rule: tuple[float, float, float, float] | None = None # x, y, width, height
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _split(n: int, ratios: list[float] | None, what: str) -> list[float]:
|
|
742
|
+
if ratios is None:
|
|
743
|
+
return [1.0] * n
|
|
744
|
+
if len(ratios) != n:
|
|
745
|
+
raise ValueError(f"{what} has {len(ratios)} values for {n} {'columns' if what == 'widths' else 'rows'}")
|
|
746
|
+
return list(ratios)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _solve_axis(areas: list[Area], n: int, ratios: list[float], fixed_total: float | None,
|
|
750
|
+
cell: float, spacing: float, margin: float, align: str, horizontal: bool,
|
|
751
|
+
fixed_cells: dict[int, float]) -> tuple[list[float], list[float], float]:
|
|
752
|
+
"""Positions and sizes of the cells along one direction, and the total length.
|
|
753
|
+
``fixed_cells``: cells whose size is given (colorbar-only columns)."""
|
|
754
|
+
if horizontal:
|
|
755
|
+
def span(a): return a.c0, a.c1
|
|
756
|
+
def need(a): return a.margins[0], a.margins[1]
|
|
757
|
+
else:
|
|
758
|
+
def span(a): return a.r0, a.r1
|
|
759
|
+
def need(a): return a.margins[2], a.margins[3]
|
|
760
|
+
free = [i for i in range(n) if i not in fixed_cells]
|
|
761
|
+
rsum = sum(ratios[i] for i in free) or 1.0
|
|
762
|
+
if align == "axes":
|
|
763
|
+
lo_need = [0.0] * (n + 1)
|
|
764
|
+
hi_need = [0.0] * (n + 1)
|
|
765
|
+
for a in areas:
|
|
766
|
+
s0, s1 = span(a)
|
|
767
|
+
m0, m1 = need(a)
|
|
768
|
+
lo_need[s0] = max(lo_need[s0], m0)
|
|
769
|
+
hi_need[s1] = max(hi_need[s1], m1)
|
|
770
|
+
gut = [hi_need[i] + lo_need[i] + (margin if i in (0, n) else spacing)
|
|
771
|
+
for i in range(n + 1)]
|
|
772
|
+
if fixed_total is not None:
|
|
773
|
+
avail = fixed_total - sum(gut) - sum(fixed_cells.values())
|
|
774
|
+
sizes = [fixed_cells[i] if i in fixed_cells else max(1.0, avail * ratios[i] / rsum)
|
|
775
|
+
for i in range(n)]
|
|
776
|
+
else:
|
|
777
|
+
sizes = [fixed_cells.get(i, cell * ratios[i]) for i in range(n)]
|
|
778
|
+
pos = []
|
|
779
|
+
p = gut[0]
|
|
780
|
+
for i in range(n):
|
|
781
|
+
pos.append(p)
|
|
782
|
+
p += sizes[i] + gut[i + 1]
|
|
783
|
+
return pos, sizes, p
|
|
784
|
+
if fixed_total is not None:
|
|
785
|
+
avail = fixed_total - 2 * margin - spacing * (n - 1) - sum(fixed_cells.values())
|
|
786
|
+
sizes = [fixed_cells[i] if i in fixed_cells else max(1.0, avail * ratios[i] / rsum)
|
|
787
|
+
for i in range(n)]
|
|
788
|
+
else:
|
|
789
|
+
sizes = [fixed_cells.get(i, cell * ratios[i]) for i in range(n)]
|
|
790
|
+
pos = []
|
|
791
|
+
p = margin
|
|
792
|
+
for i in range(n):
|
|
793
|
+
pos.append(p)
|
|
794
|
+
p += sizes[i] + (spacing if i < n - 1 else 0.0)
|
|
795
|
+
return pos, sizes, p + margin
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _bar_cells(areas: list[Area], n: int, horizontal: bool, align: str) -> dict[int, float]:
|
|
799
|
+
"""Cells covered only by colorbars running across this direction: their size is the
|
|
800
|
+
bar's (``axes``) or the bar's box (``borders``)."""
|
|
801
|
+
out: dict[int, float] = {}
|
|
802
|
+
for i in range(n):
|
|
803
|
+
covering = [a for a in areas
|
|
804
|
+
if (a.c0 <= i < a.c1 if horizontal else a.r0 <= i < a.r1)]
|
|
805
|
+
if not covering or not all(a.is_bar for a in covering):
|
|
806
|
+
continue
|
|
807
|
+
sizes = []
|
|
808
|
+
for a in covering:
|
|
809
|
+
bar = a.state
|
|
810
|
+
if bar.vertical != horizontal:
|
|
811
|
+
break
|
|
812
|
+
t = bar.thickness
|
|
813
|
+
if align == "borders":
|
|
814
|
+
m = a.margins
|
|
815
|
+
t += (m[0] + m[1]) if horizontal else (m[2] + m[3])
|
|
816
|
+
sizes.append(t)
|
|
817
|
+
else:
|
|
818
|
+
out[i] = max(sizes)
|
|
819
|
+
return out
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def solve(fig: Any, areas: list[Area], nr: int, nc: int, canvas: Any,
|
|
823
|
+
fixed: tuple[float, float] | None = None, height: float | None = None) -> Layout:
|
|
824
|
+
"""Measure and place every area (iterating until the axes sizes settle)."""
|
|
825
|
+
k = fig.points_per_unit
|
|
826
|
+
align = fig.get("align")
|
|
827
|
+
spacing = fig.get("spacing") * k
|
|
828
|
+
margin = fig.get("margin") * k
|
|
829
|
+
if fixed is not None:
|
|
830
|
+
cw, ch = fixed
|
|
831
|
+
fw = fh = None
|
|
832
|
+
else:
|
|
833
|
+
fw = fig.get("width") * k if fig.is_set("width") else None
|
|
834
|
+
fh = fig.get("height") * k if fig.is_set("height") else None
|
|
835
|
+
if height is not None:
|
|
836
|
+
fh = height
|
|
837
|
+
cw = fig.get("plot_width") * k
|
|
838
|
+
ch = fig.get("plot_height") * k
|
|
839
|
+
wr = _split(nc, fig.get("widths") if fixed is None else None, "widths")
|
|
840
|
+
hr = _split(nr, fig.get("heights") if fixed is None else None, "heights")
|
|
841
|
+
sizes = {id(a): (cw, ch) for a in areas}
|
|
842
|
+
xs: list[float] = []
|
|
843
|
+
ys: list[float] = []
|
|
844
|
+
W = H = 0.0
|
|
845
|
+
for it in range(5):
|
|
846
|
+
for a in areas:
|
|
847
|
+
aw, ah = sizes[id(a)]
|
|
848
|
+
a.margins = a.state.measure(canvas, aw, ah)
|
|
849
|
+
xb = {} if (fig.is_set("widths") and fixed is None) else _bar_cells(areas, nc, True, align)
|
|
850
|
+
yb = {} if (fig.is_set("heights") and fixed is None) else _bar_cells(areas, nr, False, align)
|
|
851
|
+
xs, xw, W = _solve_axis(areas, nc, wr, fw, cw, spacing, margin, align, True, xb)
|
|
852
|
+
ys, yh, H = _solve_axis(areas, nr, hr, fh, ch, spacing, margin, align, False, yb)
|
|
853
|
+
changed = False
|
|
854
|
+
for a in areas:
|
|
855
|
+
x0 = xs[a.c0]
|
|
856
|
+
x1 = xs[a.c1 - 1] + xw[a.c1 - 1]
|
|
857
|
+
y0 = ys[a.r0]
|
|
858
|
+
y1 = ys[a.r1 - 1] + yh[a.r1 - 1]
|
|
859
|
+
l, r, t, b = a.margins
|
|
860
|
+
if align == "axes":
|
|
861
|
+
a.axes = (x0, y0, x1, y1)
|
|
862
|
+
a.box = (x0 - l, y0 - t, x1 + r, y1 + b)
|
|
863
|
+
else:
|
|
864
|
+
a.box = (x0, y0, x1, y1)
|
|
865
|
+
a.axes = (x0 + l, y0 + t, max(x0 + l + 1, x1 - r), max(y0 + t + 1, y1 - b))
|
|
866
|
+
if a.is_bar:
|
|
867
|
+
a.axes = a.state.bar_rect(a.axes)
|
|
868
|
+
new = (a.axes[2] - a.axes[0], a.axes[3] - a.axes[1])
|
|
869
|
+
old = sizes[id(a)]
|
|
870
|
+
if abs(new[0] - old[0]) > 0.25 or abs(new[1] - old[1]) > 0.25:
|
|
871
|
+
changed = True
|
|
872
|
+
sizes[id(a)] = new
|
|
873
|
+
if not changed:
|
|
874
|
+
break
|
|
875
|
+
for a in areas:
|
|
876
|
+
if not a.is_bar:
|
|
877
|
+
a.axes = a.state.aspect_rect(a.axes)
|
|
878
|
+
return Layout(areas, W, H)
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _states(fig: Any, areas: list[Area]) -> None:
|
|
882
|
+
from .colorbar import BarState
|
|
883
|
+
from .plot import Plot
|
|
884
|
+
|
|
885
|
+
from .legend import SizeLegend, SizeLegendState
|
|
886
|
+
plots = [a.obj for a in areas if isinstance(a.obj, Plot)]
|
|
887
|
+
series = [s for p in plots for s in p.series]
|
|
888
|
+
extra = []
|
|
889
|
+
bars = [a.obj for a in areas if not isinstance(a.obj, (Plot, SizeLegend))]
|
|
890
|
+
for p in plots:
|
|
891
|
+
bars.extend(p.colorbars)
|
|
892
|
+
for cb in bars:
|
|
893
|
+
m = cb.color_map()
|
|
894
|
+
if m is not None:
|
|
895
|
+
extra.append(m)
|
|
896
|
+
maps = resolve_maps(series, extra)
|
|
897
|
+
for a in areas:
|
|
898
|
+
if isinstance(a.obj, Plot):
|
|
899
|
+
a.state = PlotState(a.obj, maps)
|
|
900
|
+
elif isinstance(a.obj, SizeLegend):
|
|
901
|
+
a.state = SizeLegendState(a.obj, maps)
|
|
902
|
+
else:
|
|
903
|
+
a.state = BarState(a.obj, maps, None)
|
|
904
|
+
from .sharing import apply
|
|
905
|
+
apply(fig, areas)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _title_layout(fig: Any, canvas: Any, width: float) -> Any:
|
|
909
|
+
t = fig.title
|
|
910
|
+
font = Font(t.get("font_size"), t.get("font_family"), t.get("font_weight"),
|
|
911
|
+
color=t.get("color"))
|
|
912
|
+
font.apply(canvas)
|
|
913
|
+
align = {"center": "center", "left": "start", "right": "end"}[t.get("align")]
|
|
914
|
+
return canvas.layout_text(t.get("text"), max(width, 1.0), align=align)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _title_box(fig: Any, lay: Layout) -> tuple[float, float]:
|
|
918
|
+
"""Left edge and width the figure title is aligned within."""
|
|
919
|
+
from .plot import Plot
|
|
920
|
+
margin = fig.get("margin") * fig.points_per_unit
|
|
921
|
+
plots = [a for a in lay.areas if isinstance(a.obj, Plot)]
|
|
922
|
+
if fig.title.get("over") == "axes" and plots:
|
|
923
|
+
x0 = min(a.axes[0] for a in plots)
|
|
924
|
+
x1 = max(a.axes[2] for a in plots)
|
|
925
|
+
return x0, x1 - x0
|
|
926
|
+
return margin, lay.width - 2 * margin
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _with_title(fig: Any, lay: Layout, areas: list[Area], nr: int, nc: int,
|
|
930
|
+
canvas: Any) -> Layout:
|
|
931
|
+
"""The layout with the figure title's band at the top."""
|
|
932
|
+
if fig.title.get("text") is None:
|
|
933
|
+
return lay
|
|
934
|
+
x0, width = _title_box(fig, lay)
|
|
935
|
+
band = _title_layout(fig, canvas, width).height + fig.title.get("pad")
|
|
936
|
+
rule = 0.0
|
|
937
|
+
if fig.get("title_rule") is not None and fig.get("title_rule_width"):
|
|
938
|
+
rule = fig.get("title_rule_width") + 0.5 * fig.title.get("font_size")
|
|
939
|
+
band += rule
|
|
940
|
+
fixed = fig.is_set("height")
|
|
941
|
+
if fixed:
|
|
942
|
+
lay = solve(fig, areas, nr, nc, canvas, height=lay.height - band)
|
|
943
|
+
x0, width = _title_box(fig, lay)
|
|
944
|
+
for a in lay.areas:
|
|
945
|
+
a.axes = (a.axes[0], a.axes[1] + band, a.axes[2], a.axes[3] + band)
|
|
946
|
+
a.box = (a.box[0], a.box[1] + band, a.box[2], a.box[3] + band)
|
|
947
|
+
lay.height += band
|
|
948
|
+
top = fig.get("margin") * fig.points_per_unit
|
|
949
|
+
lay.title = (x0, top + rule, width)
|
|
950
|
+
if rule:
|
|
951
|
+
lay.rule = (x0, top, width, fig.get("title_rule_width"))
|
|
952
|
+
return lay
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _context(a: Area) -> Any:
|
|
956
|
+
"""A drawing context for area ``a`` once placed (no canvas: for layout decisions)."""
|
|
957
|
+
from .series import DrawContext
|
|
958
|
+
st = a.state
|
|
959
|
+
return DrawContext(None, a.obj, a.axes, st.x, st.y, st.maps, st.palette)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _refine(fig: Any, areas: list[Area], nr: int, nc: int, canvas: Any,
|
|
963
|
+
lay: Layout) -> Layout:
|
|
964
|
+
"""Decisions that need the placed axes: hexagon lattices in page units (which change the
|
|
965
|
+
counts, hence the color maps), then legend positions for ``"auto"`` (which may move a
|
|
966
|
+
legend outside, changing the margins)."""
|
|
967
|
+
from .plot import Plot
|
|
968
|
+
for _ in range(3):
|
|
969
|
+
changed = False
|
|
970
|
+
for a in areas:
|
|
971
|
+
if isinstance(a.obj, Plot):
|
|
972
|
+
ctx = _context(a)
|
|
973
|
+
for s in a.obj.series:
|
|
974
|
+
frame = getattr(s, "set_frame", None)
|
|
975
|
+
if frame is not None and frame(ctx):
|
|
976
|
+
changed = True
|
|
977
|
+
if not changed:
|
|
978
|
+
break
|
|
979
|
+
_states(fig, areas)
|
|
980
|
+
lay = solve(fig, areas, nr, nc, canvas)
|
|
981
|
+
moved = False
|
|
982
|
+
for a in areas:
|
|
983
|
+
if isinstance(a.obj, Plot):
|
|
984
|
+
leg = a.state.legend
|
|
985
|
+
if leg.active() and a.obj.legend.get("position") == "auto":
|
|
986
|
+
leg.choice = leg.choose(_context(a))
|
|
987
|
+
moved = moved or leg.choice.startswith("outside")
|
|
988
|
+
if moved:
|
|
989
|
+
lay = solve(fig, areas, nr, nc, canvas)
|
|
990
|
+
return lay
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def layout(fig: Any, canvas: Any, only: Any = None) -> Layout:
|
|
994
|
+
areas, nr, nc = arrange(fig, None)
|
|
995
|
+
_states(fig, areas)
|
|
996
|
+
lay = solve(fig, areas, nr, nc, canvas)
|
|
997
|
+
lay = _refine(fig, areas, nr, nc, canvas, lay)
|
|
998
|
+
if only is None:
|
|
999
|
+
return _with_title(fig, lay, areas, nr, nc, canvas)
|
|
1000
|
+
target = next(a for a in areas if a.obj is only)
|
|
1001
|
+
if fig.get("align") == "axes":
|
|
1002
|
+
rect = target.axes
|
|
1003
|
+
else:
|
|
1004
|
+
rect = target.box
|
|
1005
|
+
single = [Area(only, 0, 0, 1, 1)]
|
|
1006
|
+
single[0].state = target.state
|
|
1007
|
+
target.state.x.hide_labels = target.state.y.hide_labels = False # alone: outer
|
|
1008
|
+
return solve(fig, single, 1, 1, canvas, fixed=(rect[2] - rect[0], rect[3] - rect[1]))
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
# ---------------------------------------------------------------------------
|
|
1012
|
+
# Output
|
|
1013
|
+
# ---------------------------------------------------------------------------
|
|
1014
|
+
|
|
1015
|
+
def _draw_layout(fig: Any, lay: Layout, canvas: Any) -> None:
|
|
1016
|
+
bg = fig.get("background")
|
|
1017
|
+
if bg is not None:
|
|
1018
|
+
canvas.save()
|
|
1019
|
+
from .palettes import palette_colors
|
|
1020
|
+
canvas.fill_style = resolve_color(bg, palette_colors(fig.get("palette")))
|
|
1021
|
+
canvas.fill_rect(0, 0, lay.width, lay.height)
|
|
1022
|
+
canvas.restore()
|
|
1023
|
+
for a in lay.areas:
|
|
1024
|
+
a.state.figure_size = (lay.width, lay.height)
|
|
1025
|
+
if a.is_bar:
|
|
1026
|
+
a.state.draw_area(canvas, a.axes)
|
|
1027
|
+
else:
|
|
1028
|
+
a.state.draw(canvas, a.axes)
|
|
1029
|
+
if lay.rule is not None:
|
|
1030
|
+
rx, ry, rw, rh = lay.rule
|
|
1031
|
+
canvas.save()
|
|
1032
|
+
canvas.fill_style = resolve_color(fig.get("title_rule"), ())
|
|
1033
|
+
canvas.fill_rect(rx, ry, rw, rh)
|
|
1034
|
+
canvas.restore()
|
|
1035
|
+
if lay.title is not None:
|
|
1036
|
+
x, y, width = lay.title
|
|
1037
|
+
canvas.save()
|
|
1038
|
+
_title_layout(fig, canvas, width).draw(x, y)
|
|
1039
|
+
canvas.restore()
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def draw(fig: Any, canvas: Any, x: float = 0.0, y: float = 0.0,
|
|
1043
|
+
only: Any = None) -> tuple[float, float]:
|
|
1044
|
+
"""Draw on a pdf_io canvas at ``(x, y)`` (page units); returns the size in points."""
|
|
1045
|
+
canvas.save()
|
|
1046
|
+
try:
|
|
1047
|
+
scale = 1.0 / canvas.unit_scale
|
|
1048
|
+
canvas.translate(x, y)
|
|
1049
|
+
if scale != 1.0:
|
|
1050
|
+
canvas.scale(scale)
|
|
1051
|
+
lay = layout(fig, canvas, only)
|
|
1052
|
+
_draw_layout(fig, lay, canvas)
|
|
1053
|
+
finally:
|
|
1054
|
+
canvas.restore()
|
|
1055
|
+
return lay.width, lay.height
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
_DOC_KEYS = ("title", "author", "subject", "keywords", "creator", "compress", "compression",
|
|
1059
|
+
"font_dirs", "system_fonts", "base_dir", "color_mode", "ink_limit",
|
|
1060
|
+
"icc_profile", "pdf_a", "creation_date", "simplify")
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def _render(fig: Any, only: Any, options: dict[str, Any],
|
|
1064
|
+
pdf: bool) -> tuple[Any, Any, dict[str, Any]]:
|
|
1065
|
+
# quality: of rasterized content in a PDF, of the image otherwise
|
|
1066
|
+
keys = _DOC_KEYS + ("quality",) if pdf else _DOC_KEYS
|
|
1067
|
+
doc_opts = {k: v for k, v in options.items() if k in keys}
|
|
1068
|
+
if pdf and "quality" not in doc_opts and fig.get("quality") is not None:
|
|
1069
|
+
doc_opts["quality"] = fig.get("quality")
|
|
1070
|
+
rest = {k: v for k, v in options.items() if k not in keys}
|
|
1071
|
+
unknown = set(rest) - {"dpi", "background", "quality"}
|
|
1072
|
+
if unknown:
|
|
1073
|
+
raise ValueError(f"unknown output option {sorted(unknown)[0]!r}")
|
|
1074
|
+
doc = pdf_io.Document(**doc_opts)
|
|
1075
|
+
scratch = pdf_io.Document(font_db=doc.font_db, pdf_a=doc_opts.get("pdf_a"))
|
|
1076
|
+
probe = scratch.add_page(100, 100)
|
|
1077
|
+
lay = layout(fig, probe, only)
|
|
1078
|
+
page = doc.add_page(max(lay.width, 1.0), max(lay.height, 1.0), unit="pt")
|
|
1079
|
+
_draw_layout(fig, lay, page)
|
|
1080
|
+
return doc, page, rest
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def size(fig: Any, only: Any = None) -> tuple[float, float]:
|
|
1084
|
+
probe = pdf_io.Document().add_page(100, 100)
|
|
1085
|
+
lay = layout(fig, probe, only)
|
|
1086
|
+
return lay.width, lay.height
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def to_bytes(fig: Any, format: str = "pdf", only: Any = None, **options: Any) -> bytes:
|
|
1090
|
+
fmt = format.lower().lstrip(".")
|
|
1091
|
+
if fmt == "pdf":
|
|
1092
|
+
doc, page, rest = _render(fig, only, options, True)
|
|
1093
|
+
else:
|
|
1094
|
+
# An image: nothing is culled (see _backend.simplify_arg), so the page is not
|
|
1095
|
+
# given the document-level simplify either.
|
|
1096
|
+
with _backend.image_output():
|
|
1097
|
+
doc, page, rest = _render(fig, only, {k: v for k, v in options.items()
|
|
1098
|
+
if k != "simplify"}, False)
|
|
1099
|
+
dpi = rest.get("dpi", fig.get("dpi"))
|
|
1100
|
+
bg = fig.get("background")
|
|
1101
|
+
bg = rest.get("background", fig.get("image_background") if bg is None else bg)
|
|
1102
|
+
if fmt == "pdf":
|
|
1103
|
+
if set(rest) - {"background"}:
|
|
1104
|
+
raise ValueError("dpi applies to PNG, JPEG and JPEG XL output")
|
|
1105
|
+
return doc.to_bytes()
|
|
1106
|
+
if fmt == "png":
|
|
1107
|
+
return page.to_png(dpi=dpi, background=bg)
|
|
1108
|
+
quality = rest.get("quality", fig.get("quality"))
|
|
1109
|
+
q = {} if quality is None else {"quality": quality}
|
|
1110
|
+
if fmt in ("jpg", "jpeg"):
|
|
1111
|
+
return page.to_jpeg(dpi=dpi, background=bg, **q)
|
|
1112
|
+
if fmt == "jxl":
|
|
1113
|
+
return page.to_jxl(dpi=dpi, background=bg, **q)
|
|
1114
|
+
raise ValueError(f"unknown format {format!r} (use pdf, png, jpeg or jxl)")
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def save(fig: Any, path: Any, only: Any = None, **options: Any) -> None:
|
|
1118
|
+
import os
|
|
1119
|
+
ext = os.path.splitext(os.fspath(path))[1].lower().lstrip(".") or "pdf"
|
|
1120
|
+
data = to_bytes(fig, ext, only, **options)
|
|
1121
|
+
with open(path, "wb") as f:
|
|
1122
|
+
f.write(data)
|