plot-io 0.12.0__py3-none-win_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-arm64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/layout.py
ADDED
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
"""The box model: a figure's areas measured into bands, the grid solved, every area placed
|
|
2
|
+
into a frozen :class:`Geometry`.
|
|
3
|
+
|
|
4
|
+
* **Measure.** :func:`plot_io.boxes.measure` gives the :class:`~plot_io.geometry.Bands`
|
|
5
|
+
around a plot's axes of a given size, from its box tree (:mod:`plot_io.boxes`: tick
|
|
6
|
+
marks, tick labels, the label row and ×10ᵏ offsets of each axis, twin axes, attached
|
|
7
|
+
colorbars, an outside legend, the titles and the plot's ``space_above``);
|
|
8
|
+
:func:`plot_io.rows.bands` adds the row label and group letter bands, the outermost on
|
|
9
|
+
the left, aligned per grid column. The ticks the bands hold are computed first for the
|
|
10
|
+
axes' length (:func:`plot_io.boxes.prepare`).
|
|
11
|
+
* **Solve.** The grid (ids with spans, ``fr`` and fixed sizes, ``reference`` and
|
|
12
|
+
``align``) takes, per line between cells, the largest margin of the areas there. Only
|
|
13
|
+
what is circular is iterated, in rounds, until no axes size changes by more than 1/3 px:
|
|
14
|
+
axis length → ticks → label sizes → axis length, ``data_ratio``, stats binned at the
|
|
15
|
+
axes' resolution (hexagon lattices, genome bins), and the lanes of figure titles wrapped
|
|
16
|
+
to their spans.
|
|
17
|
+
``inside="auto"`` and ``"best"`` colorbars, then legends, are chosen on the occupancy
|
|
18
|
+
grid once the axes are placed, before the last round; one that finds no free place
|
|
19
|
+
inside moves out (a colorbar to the bottom of its plot, a legend to the right).
|
|
20
|
+
* **Lanes.** Figure titles, legends and colorbars are nodes on the grid's lines
|
|
21
|
+
(:class:`_Lane`: a thickness measured along their span, a placement from their lane's
|
|
22
|
+
near edge): a top title above the topmost row it covers, a bottom one below the
|
|
23
|
+
bottommost, left and right ones beside the columns; a group's label is a left title over
|
|
24
|
+
its plots; the main title (on top, unrotated, without an ``order``) is the outermost
|
|
25
|
+
lane of the top, over every plot. The lanes of a line add to the gap there: spacing
|
|
26
|
+
between rows (or columns) plus the lanes; at the figure's edge, the margin, the lanes and
|
|
27
|
+
another margin between them and the plots (none when the main title is alone there:
|
|
28
|
+
its ``pad`` parts it from them). On one side of a line, a title nested in another sits
|
|
29
|
+
nearer the plots, disjoint spans share a lane, overlapping ones take lanes in creation
|
|
30
|
+
order, and ``order`` overrides; a lane is as thick as its thickest node. Over the same
|
|
31
|
+
plots, from the plots outward: legend, colorbar, title. A figure legend shows the
|
|
32
|
+
entries of its plots, which those plots' legends then leave out; a figure colorbar
|
|
33
|
+
replaces its plots' attached colorbars of its axis.
|
|
34
|
+
* **Place.** Each area becomes a geometry record (:mod:`plot_io.geometry`; a plot's
|
|
35
|
+
nodes at the offsets their bands were measured at, :func:`plot_io.boxes.place_plot`);
|
|
36
|
+
each lane node its record (the main title the figure's title paragraph). ``only=``
|
|
37
|
+
lays out the whole figure without the lanes and keeps one plot, at its size in the
|
|
38
|
+
figure, with its outer labels, its own titles, its whole legend and its attached
|
|
39
|
+
colorbars.
|
|
40
|
+
|
|
41
|
+
All lengths are px.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import dataclasses
|
|
45
|
+
import math
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
from . import rows
|
|
49
|
+
from .boxes import measure, place_plot, prepare, title_font
|
|
50
|
+
from .boxes import title_need as _title_need
|
|
51
|
+
from .boxes import title_runs as _title_runs
|
|
52
|
+
from .geometry import (Bands, FigureColorbarGeometry, FigureGeometry,
|
|
53
|
+
FigureLegendGeometry, Rect, TitleGeometry)
|
|
54
|
+
from .layers.context import DrawContext
|
|
55
|
+
from .spec import current, rendering, set_boxes, warn, warnings_once
|
|
56
|
+
from .states import build
|
|
57
|
+
from .titles import along, block, rotation_of, rule_extent, rule_inside, rule_rect
|
|
58
|
+
|
|
59
|
+
__all__ = ["Area", "Geometry", "Placed", "arrange", "prepare", "measure", "layout",
|
|
60
|
+
"context", "ROUNDS"]
|
|
61
|
+
|
|
62
|
+
#: Most layout rounds spent on the axes sizes before the other decisions are made (a
|
|
63
|
+
#: layout whose sizes still move by then, oscillating between tick sets, keeps the last).
|
|
64
|
+
SIZE_ROUNDS = 5
|
|
65
|
+
#: Most layout rounds in all (each measures every area and solves the grid).
|
|
66
|
+
ROUNDS = 12
|
|
67
|
+
#: Axes sizes closer than this (px) between rounds have converged.
|
|
68
|
+
TOLERANCE = 1 / 3
|
|
69
|
+
#: The smallest axes length (px) a figure of given size keeps: below it, the figure grows
|
|
70
|
+
#: and warns.
|
|
71
|
+
MIN_AXES = 8.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Areas
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
class Area:
|
|
79
|
+
"""A plot, colorbar or size legend on cells ``[r0, r1) × [c0, c1)``, while laid out."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, obj: Any, r0: int, c0: int, r1: int, c1: int) -> None:
|
|
82
|
+
self.obj = obj
|
|
83
|
+
self.r0, self.c0, self.r1, self.c1 = r0, c0, r1, c1
|
|
84
|
+
self.state: Any = None
|
|
85
|
+
self.bands: Bands | None = None
|
|
86
|
+
self.margins = (0.0, 0.0, 0.0, 0.0) # left, right, top, bottom
|
|
87
|
+
self.axes = (0.0, 0.0, 0.0, 0.0) # x0, y0, x1, y1
|
|
88
|
+
self.box = (0.0, 0.0, 0.0, 0.0)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def is_bar(self) -> bool:
|
|
92
|
+
"""A colorbar or a size legend: an area of fixed thickness."""
|
|
93
|
+
from .colorbar import Colorbar
|
|
94
|
+
from .legend import SizeLegend
|
|
95
|
+
return isinstance(self.obj, (Colorbar, SizeLegend))
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def is_plot(self) -> bool:
|
|
99
|
+
from .plot import Plot
|
|
100
|
+
return isinstance(self.obj, Plot)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
104
|
+
class Placed:
|
|
105
|
+
"""An area placed: its cells, axes rectangle (a colorbar's bar), box, margins, bands,
|
|
106
|
+
its state in the render and its geometry record."""
|
|
107
|
+
|
|
108
|
+
obj: Any
|
|
109
|
+
r0: int
|
|
110
|
+
c0: int
|
|
111
|
+
r1: int
|
|
112
|
+
c1: int
|
|
113
|
+
axes: Rect
|
|
114
|
+
box: Rect
|
|
115
|
+
margins: tuple[float, float, float, float]
|
|
116
|
+
bands: Bands | None
|
|
117
|
+
state: Any
|
|
118
|
+
geometry: Any
|
|
119
|
+
is_bar: bool
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
123
|
+
class Geometry:
|
|
124
|
+
"""A figure laid out: its size, its placed areas, the figure's own parts and the
|
|
125
|
+
number of layout rounds it took."""
|
|
126
|
+
|
|
127
|
+
width: float
|
|
128
|
+
height: float
|
|
129
|
+
areas: tuple[Placed, ...]
|
|
130
|
+
figure: FigureGeometry
|
|
131
|
+
rounds: int = 1
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def title(self) -> Any:
|
|
135
|
+
"""The figure title's paragraph, or ``None``."""
|
|
136
|
+
return self.figure.title
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def arrange(fig: Any, only: Any = None) -> tuple[list[Area], int, int]:
|
|
140
|
+
"""The areas of the figure and the grid size."""
|
|
141
|
+
if only is not None:
|
|
142
|
+
return [Area(only, 0, 0, 1, 1)], 1, 1
|
|
143
|
+
items = list(fig._areas.items())
|
|
144
|
+
grid = fig.explicit_grid()
|
|
145
|
+
if grid is not None:
|
|
146
|
+
areas = []
|
|
147
|
+
for k, obj in items:
|
|
148
|
+
cells = [(i, j) for i, r in enumerate(grid) for j, v in enumerate(r) if v == k]
|
|
149
|
+
if not cells:
|
|
150
|
+
raise ValueError(f"id {k!r} is not in the grid")
|
|
151
|
+
r0 = min(c[0] for c in cells)
|
|
152
|
+
c0 = min(c[1] for c in cells)
|
|
153
|
+
areas.append(Area(obj, r0, c0, max(c[0] for c in cells) + 1,
|
|
154
|
+
max(c[1] for c in cells) + 1))
|
|
155
|
+
return areas, len(grid), len(grid[0])
|
|
156
|
+
n = len(items)
|
|
157
|
+
rows_, cols, order = fig.resolve("rows"), fig.resolve("cols"), fig.resolve("fill_order")
|
|
158
|
+
if rows_ and cols:
|
|
159
|
+
nr, nc = rows_, cols
|
|
160
|
+
by_row = order != "col"
|
|
161
|
+
elif cols:
|
|
162
|
+
nc = cols
|
|
163
|
+
nr = max(1, math.ceil(n / cols))
|
|
164
|
+
by_row = order != "col"
|
|
165
|
+
elif rows_:
|
|
166
|
+
nr = rows_
|
|
167
|
+
nc = max(1, math.ceil(n / rows_))
|
|
168
|
+
by_row = order == "row"
|
|
169
|
+
else:
|
|
170
|
+
nc = max(1, math.ceil(math.sqrt(n)))
|
|
171
|
+
nr = max(1, math.ceil(n / nc))
|
|
172
|
+
by_row = order != "col"
|
|
173
|
+
areas = []
|
|
174
|
+
for i, (_, obj) in enumerate(items):
|
|
175
|
+
r, c = (i // nc, i % nc) if by_row else (i % nr, i // nr)
|
|
176
|
+
areas.append(Area(obj, r, c, r + 1, c + 1))
|
|
177
|
+
return areas, nr, nc
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def context(a: Any) -> DrawContext:
|
|
181
|
+
"""A drawing context of area ``a``'s placed axes, without a canvas (layout decisions:
|
|
182
|
+
legend placement, stats binned at the axes' resolution)."""
|
|
183
|
+
st = a.state
|
|
184
|
+
return DrawContext(None, a.obj, a.axes, st.x, st.y, st.cycle)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Measure: the bands around a plot's axes
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _measure_area(a: Area, fonts: Any, w: float, h: float) -> Bands:
|
|
192
|
+
if a.is_bar:
|
|
193
|
+
m = a.state.measure(fonts, w, h)
|
|
194
|
+
return Bands((w, h), margins=m)
|
|
195
|
+
return measure(a.state, fonts, w, h)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# Solve: the grid
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def _split(fig: Any, n: int, name: str | None) -> tuple[list[float], dict[int, tuple[float, float]]]:
|
|
203
|
+
"""Ratios of the free columns (or rows) and the fixed ones as ``(a, b)``: ``a`` px +
|
|
204
|
+
``b`` × the plots' room (`%`). Bare numbers and ``fr`` are ratios."""
|
|
205
|
+
if name is None or fig.resolve(name) is None:
|
|
206
|
+
return [1.0] * n, {}
|
|
207
|
+
raw = fig._raw(name)
|
|
208
|
+
if len(raw) != n:
|
|
209
|
+
raise ValueError(f"{name} has {len(raw)} values for {n} "
|
|
210
|
+
f"{'columns' if name == 'widths' else 'rows'}")
|
|
211
|
+
from .units import Dim, resolve_raw
|
|
212
|
+
from .validators import length
|
|
213
|
+
check = length(percent="room", fr=True) # a + b × room: checked once combined
|
|
214
|
+
ratios: list[float] = []
|
|
215
|
+
fixed: dict[int, tuple[float, float]] = {}
|
|
216
|
+
for i, v in enumerate(raw):
|
|
217
|
+
if isinstance(v, (int, float)):
|
|
218
|
+
ratios.append(float(v))
|
|
219
|
+
elif isinstance(v, Dim) and v.unit == "fr":
|
|
220
|
+
ratios.append(float(v.number or 0.0))
|
|
221
|
+
else:
|
|
222
|
+
a = resolve_raw(fig, name, check, v, of=0.0)
|
|
223
|
+
fixed[i] = (a, resolve_raw(fig, name, check, v, of=1.0) - a)
|
|
224
|
+
ratios.append(0.0)
|
|
225
|
+
return ratios, fixed
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _solve_axis(areas: list[Area], n: int, ratios: list[float], fixed_total: float | None,
|
|
229
|
+
base: float, spacing: tuple[float, float], margin: tuple[float, float],
|
|
230
|
+
align: str, ref: str, horizontal: bool, fixed_cells: dict[int, float],
|
|
231
|
+
tracks: dict[int, tuple[float, float]],
|
|
232
|
+
lanes: list[float] | None = None, apart: list[float] | None = None
|
|
233
|
+
) -> tuple[list[float], list[float], float]:
|
|
234
|
+
"""Positions and sizes of the cells along one direction, and the total length.
|
|
235
|
+
|
|
236
|
+
Cell sizes are axes sizes with ``align="axes"`` and box sizes with ``"borders"``.
|
|
237
|
+
``fixed_total``: the figure's length (``reference="figure"``); otherwise ``base`` × the
|
|
238
|
+
ratio is the reference box of a cell: its axes (``ref="axes"``) or its plot box
|
|
239
|
+
(``"plot"``). ``fixed_cells``: cells whose size is given (colorbar-only columns);
|
|
240
|
+
``tracks``: cells sized ``a + b × room`` (fixed `widths`; the room: what the cells
|
|
241
|
+
share, the total less the margins, spacing, lanes and, with ``"axes"``, the plots'
|
|
242
|
+
margins; without ``fixed_total``, solved from the other cells). ``spacing`` and ``margin``
|
|
243
|
+
are ``(a, b)`` too: ``a`` px + ``b`` × the total, solved exactly when the total is an
|
|
244
|
+
output. ``lanes``: per line between cells, the thickness of its lanes (px): a line
|
|
245
|
+
inside the grid grows by them, an outer line by them and, where ``apart`` (per line,
|
|
246
|
+
default: with lanes), a margin between them and the plots."""
|
|
247
|
+
if horizontal:
|
|
248
|
+
def span(a): return a.c0, a.c1
|
|
249
|
+
def need(a): return a.margins[0], a.margins[1]
|
|
250
|
+
else:
|
|
251
|
+
def span(a): return a.r0, a.r1
|
|
252
|
+
def need(a): return a.margins[2], a.margins[3]
|
|
253
|
+
lanes = lanes or [0.0] * (n + 1)
|
|
254
|
+
apart = apart or lanes
|
|
255
|
+
outer = [0.0] * (n + 1) # a margin between the outer lanes and the plots
|
|
256
|
+
fixed_cells = {i: v for i, v in fixed_cells.items() if i not in tracks}
|
|
257
|
+
free = [i for i in range(n) if i not in fixed_cells and i not in tracks]
|
|
258
|
+
lo_need = [0.0] * (n + 1) # largest margin of the areas starting at a line
|
|
259
|
+
hi_need = [0.0] * (n + 1) # largest margin of the areas ending at a line
|
|
260
|
+
for a in areas:
|
|
261
|
+
s0, s1 = span(a)
|
|
262
|
+
m0, m1 = need(a)
|
|
263
|
+
lo_need[s0] = max(lo_need[s0], m0)
|
|
264
|
+
hi_need[s1] = max(hi_need[s1], m1)
|
|
265
|
+
|
|
266
|
+
def cell(i: int) -> float:
|
|
267
|
+
if i in fixed_cells:
|
|
268
|
+
return fixed_cells[i]
|
|
269
|
+
size = base * ratios[i]
|
|
270
|
+
if align == "axes" and ref == "plot":
|
|
271
|
+
return max(MIN_AXES, size - lo_need[i] - hi_need[i + 1])
|
|
272
|
+
if align == "borders" and ref == "axes":
|
|
273
|
+
return size + lo_need[i] + hi_need[i + 1]
|
|
274
|
+
return size
|
|
275
|
+
|
|
276
|
+
needs = sum(hi_need[i] + lo_need[i] for i in range(n + 1)) if align == "axes" else 0.0
|
|
277
|
+
name = "widths" if horizontal else "heights"
|
|
278
|
+
pct = sum(b for _, b in tracks.values()) # the % tracks' share of the plots' room
|
|
279
|
+
if pct > 1.0 + 1e-9 or (pct >= 1.0 - 1e-9 and free):
|
|
280
|
+
raise ValueError(f"Figure setting {name!r}: its % take {pct * 100:g} % of the "
|
|
281
|
+
f"plots' room" + ("" if pct > 1.0 + 1e-9 else
|
|
282
|
+
", leaving none for the other cells"))
|
|
283
|
+
room = 0.0
|
|
284
|
+
if fixed_total is not None:
|
|
285
|
+
total = fixed_total
|
|
286
|
+
else:
|
|
287
|
+
cells = (sum(cell(i) for i in range(n) if i not in tracks)
|
|
288
|
+
+ sum(a for a, _ in tracks.values()))
|
|
289
|
+
if pct >= 1.0 - 1e-9: # % tracks only: of the cells' reference sizes
|
|
290
|
+
room = sum(cell(i) if i in fixed_cells else
|
|
291
|
+
_ratio_one(base, align, ref, lo_need[i], hi_need[i + 1])
|
|
292
|
+
for i in range(n))
|
|
293
|
+
else:
|
|
294
|
+
room = cells / (1.0 - pct)
|
|
295
|
+
known = (cells + pct * room + needs + (n - 1) * spacing[0] + 2 * margin[0])
|
|
296
|
+
extra = (apart[0] > 0) + (apart[n] > 0)
|
|
297
|
+
known += sum(lanes) + extra * margin[0]
|
|
298
|
+
share = (n - 1) * spacing[1] + 2 * margin[1] + extra * margin[1]
|
|
299
|
+
if share >= 1.0:
|
|
300
|
+
raise ValueError("spacing and margin in % take 100 % or more of the figure")
|
|
301
|
+
total = known / (1.0 - share)
|
|
302
|
+
sp = spacing[0] + spacing[1] * total
|
|
303
|
+
mg = margin[0] + margin[1] * total
|
|
304
|
+
for i in (0, n):
|
|
305
|
+
if apart[i] > 0:
|
|
306
|
+
outer[i] = mg
|
|
307
|
+
gut = [hi_need[i] + lo_need[i] + (mg if i in (0, n) else sp) + lanes[i] + outer[i]
|
|
308
|
+
for i in range(n + 1)]
|
|
309
|
+
if fixed_total is not None: # what the cells share
|
|
310
|
+
room = fixed_total - (sum(gut) if align == "axes" else
|
|
311
|
+
2 * mg + sp * (n - 1) + sum(lanes) + outer[0] + outer[n])
|
|
312
|
+
for i, (px, per) in tracks.items():
|
|
313
|
+
if not px + per * room > 0:
|
|
314
|
+
raise ValueError(f"Figure setting {name!r}: track {i} resolves to "
|
|
315
|
+
f"{px + per * room:g} px, expected a positive length")
|
|
316
|
+
track = {i: max(1.0, a + b * room) for i, (a, b) in tracks.items()}
|
|
317
|
+
given = {**fixed_cells, **track}
|
|
318
|
+
|
|
319
|
+
if align == "axes":
|
|
320
|
+
if fixed_total is not None:
|
|
321
|
+
avail = room - sum(given.values())
|
|
322
|
+
shares = _shares(avail, free, ratios, [MIN_AXES] * n)
|
|
323
|
+
sizes = [given[i] if i in given else shares[i] for i in range(n)]
|
|
324
|
+
else:
|
|
325
|
+
sizes = [given[i] if i in given else cell(i) for i in range(n)]
|
|
326
|
+
pos = []
|
|
327
|
+
p = gut[0]
|
|
328
|
+
for i in range(n):
|
|
329
|
+
pos.append(p)
|
|
330
|
+
p += sizes[i] + gut[i + 1]
|
|
331
|
+
_check_grown(fixed_total, p, horizontal)
|
|
332
|
+
return pos, sizes, p
|
|
333
|
+
low = [lo_need[i] + hi_need[i + 1] + MIN_AXES for i in range(n)] # margins + axes
|
|
334
|
+
if fixed_total is not None:
|
|
335
|
+
shares = _shares(room - sum(given.values()), free, ratios, low)
|
|
336
|
+
sizes = [given[i] if i in given else shares[i] for i in range(n)]
|
|
337
|
+
else:
|
|
338
|
+
sizes = [given[i] if i in given else max(low[i], cell(i)) for i in range(n)]
|
|
339
|
+
pos = []
|
|
340
|
+
p = mg + lanes[0] + outer[0]
|
|
341
|
+
for i in range(n):
|
|
342
|
+
pos.append(p)
|
|
343
|
+
p += sizes[i] + (sp + lanes[i + 1] if i < n - 1 else 0.0)
|
|
344
|
+
p += lanes[n] + outer[n] + mg
|
|
345
|
+
_check_grown(fixed_total, p, horizontal)
|
|
346
|
+
return pos, sizes, p
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _ratio_one(base: float, align: str, ref: str, lo: float, hi: float) -> float:
|
|
350
|
+
"""A cell of ratio 1 between margins ``lo`` and ``hi``: its axes (``align="axes"``) or
|
|
351
|
+
its box, from the reference box ``base``."""
|
|
352
|
+
if align == "axes" and ref == "plot":
|
|
353
|
+
return max(MIN_AXES, base - lo - hi)
|
|
354
|
+
if align == "borders" and ref == "axes":
|
|
355
|
+
return base + lo + hi
|
|
356
|
+
return base
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _shares(avail: float, free: list[int], ratios: list[float], low: list[float]
|
|
360
|
+
) -> dict[int, float]:
|
|
361
|
+
"""The ``free`` cells' shares of ``avail`` px by their ratios, none below its ``low``
|
|
362
|
+
(a cell held at its low, the others share the rest; their sum passes ``avail`` only
|
|
363
|
+
when the lows do)."""
|
|
364
|
+
out: dict[int, float] = {}
|
|
365
|
+
rest = list(free)
|
|
366
|
+
while rest:
|
|
367
|
+
rsum = sum(ratios[i] for i in rest) or 1.0
|
|
368
|
+
short = [i for i in rest if avail * ratios[i] / rsum < low[i]]
|
|
369
|
+
if not short:
|
|
370
|
+
break
|
|
371
|
+
for i in short:
|
|
372
|
+
out[i] = low[i]
|
|
373
|
+
avail -= low[i]
|
|
374
|
+
rest.remove(i)
|
|
375
|
+
rsum = sum(ratios[i] for i in rest) or 1.0
|
|
376
|
+
for i in rest:
|
|
377
|
+
out[i] = max(low[i], avail * ratios[i] / rsum)
|
|
378
|
+
return out
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _check_grown(fixed: float | None, total: float, horizontal: bool) -> None:
|
|
382
|
+
"""Warn when a figure of a given size (``reference="figure"``) grows to fit what is
|
|
383
|
+
around its plots."""
|
|
384
|
+
if fixed is not None and total > fixed + 0.5:
|
|
385
|
+
warn(f"the figure's {'width' if horizontal else 'height'} {fixed:g} px is too "
|
|
386
|
+
f"small for its plots' margins, titles and spacing: it is drawn {total:.4g} px",
|
|
387
|
+
key=("grown", horizontal))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _bar_cells(areas: list[Area], n: int, horizontal: bool, align: str) -> dict[int, float]:
|
|
391
|
+
"""Cells covered only by colorbars running across this direction: their size is the
|
|
392
|
+
bar's (``axes``) or the bar's box (``borders``)."""
|
|
393
|
+
out: dict[int, float] = {}
|
|
394
|
+
for i in range(n):
|
|
395
|
+
covering = [a for a in areas
|
|
396
|
+
if (a.c0 <= i < a.c1 if horizontal else a.r0 <= i < a.r1)]
|
|
397
|
+
if not covering or not all(a.is_bar for a in covering):
|
|
398
|
+
continue
|
|
399
|
+
sizes = []
|
|
400
|
+
for a in covering:
|
|
401
|
+
bar = a.state
|
|
402
|
+
if bar.vertical != horizontal:
|
|
403
|
+
break
|
|
404
|
+
t = bar.cell_size
|
|
405
|
+
if align == "borders":
|
|
406
|
+
m = a.margins
|
|
407
|
+
t += (m[0] + m[1]) if horizontal else (m[2] + m[3])
|
|
408
|
+
sizes.append(t)
|
|
409
|
+
else:
|
|
410
|
+
out[i] = max(sizes)
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _ratio_cells(areas: list[Area], sizes: dict[int, tuple[float, float]], given: str,
|
|
415
|
+
xb: dict[int, float], yb: dict[int, float]) -> None:
|
|
416
|
+
"""With only the width (height) given, rows (columns) of plots with a data ratio take
|
|
417
|
+
the height (width) it asks for: the largest in the row."""
|
|
418
|
+
for a in areas:
|
|
419
|
+
want = getattr(a.state, "aspect_want", lambda: None)()
|
|
420
|
+
if not want:
|
|
421
|
+
continue
|
|
422
|
+
w, h = sizes[id(a)]
|
|
423
|
+
if given == "width" and a.r1 - a.r0 == 1:
|
|
424
|
+
yb[a.r0] = max(yb.get(a.r0, 0.0), w * want)
|
|
425
|
+
elif given == "height" and a.c1 - a.c0 == 1:
|
|
426
|
+
xb[a.c0] = max(xb.get(a.c0, 0.0), h / want)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class _Grid:
|
|
430
|
+
"""The grid's fixed inputs: alignment, reference box, spacing, margins, the columns'
|
|
431
|
+
and rows' ratios and fixed sizes."""
|
|
432
|
+
|
|
433
|
+
def __init__(self, fig: Any, nr: int, nc: int, fixed: tuple[float, float] | None = None) -> None:
|
|
434
|
+
self.fig, self.nr, self.nc, self.fixed = fig, nr, nc, fixed
|
|
435
|
+
self.align = fig.resolve("align")
|
|
436
|
+
self.spacing = fig.resolve_linear("spacing")
|
|
437
|
+
self.margin = fig.resolve_linear("margin")
|
|
438
|
+
self.fw: float | None = None
|
|
439
|
+
self.fh: float | None = None
|
|
440
|
+
self.mx = self.my = self.margin[0] # the margins as last solved (px)
|
|
441
|
+
#: the grid's extent along x and along y as last solved (its first and last
|
|
442
|
+
#: cells' outer edges)
|
|
443
|
+
self.cells = ([0.0, 0.0], [0.0, 0.0])
|
|
444
|
+
if fixed is not None: # one plot alone, at its size in the figure
|
|
445
|
+
self.cw, self.ch = fixed
|
|
446
|
+
self.ref = "axes" if self.align == "axes" else "plot"
|
|
447
|
+
else:
|
|
448
|
+
self.ref = fig.resolve("reference")
|
|
449
|
+
self.cw, self.ch = fig.reference_size()
|
|
450
|
+
if self.ref == "figure":
|
|
451
|
+
self.fw, self.fh = self.cw, self.ch
|
|
452
|
+
self.cw, self.ch = 150.0, 120.0 # first guess of a cell's axes
|
|
453
|
+
self.wr, self.wt = _split(fig, nc, "widths" if fixed is None else None)
|
|
454
|
+
self.one = fig.one_size_given() if (fixed is None and self.ref == "axes"
|
|
455
|
+
and self.align == "axes") else None
|
|
456
|
+
|
|
457
|
+
def place(self, areas: list[Area], sizes: dict[int, tuple[float, float]],
|
|
458
|
+
lanes: tuple[list[float], ...] | None = None) -> tuple[float, float, bool]:
|
|
459
|
+
"""Place the measured ``areas`` (their margins) on the grid; ``lanes``: the
|
|
460
|
+
lanes' thickness per line between columns and between rows, and per line whether
|
|
461
|
+
a margin parts them from the plots (:func:`_lane_sizes`). Returns the figure
|
|
462
|
+
size and whether an axes size changed by more than the tolerance (``sizes`` is
|
|
463
|
+
updated)."""
|
|
464
|
+
fig, align, fixed = self.fig, self.align, self.fixed
|
|
465
|
+
hr, ht = _split(fig, self.nr, "heights" if fixed is None else None)
|
|
466
|
+
xb = {} if (fig.is_set("widths") and fixed is None) else _bar_cells(areas, self.nc, True, align)
|
|
467
|
+
yb = {} if (fig.is_set("heights") and fixed is None) else _bar_cells(areas, self.nr, False, align)
|
|
468
|
+
if self.one is not None:
|
|
469
|
+
_ratio_cells(areas, sizes, self.one, xb, yb)
|
|
470
|
+
xl, yl, xa, ya = lanes if lanes is not None else (None, None, None, None)
|
|
471
|
+
xs, xw, W = _solve_axis(areas, self.nc, self.wr, self.fw, self.cw, self.spacing,
|
|
472
|
+
self.margin, align, self.ref, True, xb, self.wt, xl, xa)
|
|
473
|
+
ys, yh, H = _solve_axis(areas, self.nr, hr, self.fh, self.ch, self.spacing,
|
|
474
|
+
self.margin, align, self.ref, False, yb, ht, yl, ya)
|
|
475
|
+
self.mx = self.margin[0] + self.margin[1] * W
|
|
476
|
+
self.my = self.margin[0] + self.margin[1] * H
|
|
477
|
+
self.cells = ([xs[0], xs[-1] + xw[-1]], [ys[0], ys[-1] + yh[-1]])
|
|
478
|
+
changed = False
|
|
479
|
+
for a in areas:
|
|
480
|
+
x0 = xs[a.c0]
|
|
481
|
+
x1 = xs[a.c1 - 1] + xw[a.c1 - 1]
|
|
482
|
+
y0 = ys[a.r0]
|
|
483
|
+
y1 = ys[a.r1 - 1] + yh[a.r1 - 1]
|
|
484
|
+
left, right, top, bottom = a.margins
|
|
485
|
+
if align == "axes":
|
|
486
|
+
a.axes = (x0, y0, x1, y1)
|
|
487
|
+
a.box = (x0 - left, y0 - top, x1 + right, y1 + bottom)
|
|
488
|
+
else:
|
|
489
|
+
a.box = (x0, y0, x1, y1)
|
|
490
|
+
a.axes = (x0 + left, y0 + top, max(x0 + left + 1, x1 - right),
|
|
491
|
+
max(y0 + top + 1, y1 - bottom))
|
|
492
|
+
if a.is_bar:
|
|
493
|
+
a.axes = a.state.bar_rect(a.axes)
|
|
494
|
+
new = (a.axes[2] - a.axes[0], a.axes[3] - a.axes[1])
|
|
495
|
+
old = sizes[id(a)]
|
|
496
|
+
if abs(new[0] - old[0]) > TOLERANCE or abs(new[1] - old[1]) > TOLERANCE:
|
|
497
|
+
changed = True
|
|
498
|
+
sizes[id(a)] = new
|
|
499
|
+
for a in areas:
|
|
500
|
+
if not a.is_bar:
|
|
501
|
+
a.axes = a.state.aspect_rect(a.axes)
|
|
502
|
+
return W, H, changed
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
# Rounds: what is circular
|
|
507
|
+
# ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
def _same(f: Any, g: Any) -> bool:
|
|
510
|
+
"""Equal keys of a stat binned at the axes' resolution (floats within 1e-6)."""
|
|
511
|
+
if f is None or g is None or len(f) != len(g):
|
|
512
|
+
return f is g
|
|
513
|
+
for a, b in zip(f, g):
|
|
514
|
+
if isinstance(a, float) and isinstance(b, float):
|
|
515
|
+
if not abs(a - b) <= 1e-6 * max(1.0, abs(a), abs(b)):
|
|
516
|
+
return False
|
|
517
|
+
elif a != b:
|
|
518
|
+
return False
|
|
519
|
+
return True
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _binned(a: Area) -> list[tuple[Any, list[Any]]]:
|
|
523
|
+
"""The plot (and twin) states of an area with calls whose stats bin at the placed
|
|
524
|
+
axes' resolution, with those calls."""
|
|
525
|
+
if not a.is_plot:
|
|
526
|
+
return []
|
|
527
|
+
out = []
|
|
528
|
+
for st in [a.state, *a.state.twins]:
|
|
529
|
+
calls = [c for c in st.calls if c.resolution is not None]
|
|
530
|
+
if calls:
|
|
531
|
+
out.append((st, calls))
|
|
532
|
+
return out
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _place_binned(fig: Any, areas: list[Area], rects: dict[int, Rect],
|
|
536
|
+
lanes: list["_Lane"] | None = None) -> bool:
|
|
537
|
+
"""Record the axes placed at ``rects`` (area id → rectangle) of the plots with stats
|
|
538
|
+
binned at their resolution; when a call's binning changed, train the color and size
|
|
539
|
+
axes again (the counts changed) and update the colorbars (those in the ``lanes``
|
|
540
|
+
too). Returns whether anything changed."""
|
|
541
|
+
r = current()
|
|
542
|
+
assert r is not None
|
|
543
|
+
changed = False
|
|
544
|
+
for a in areas:
|
|
545
|
+
for st, calls in _binned(a):
|
|
546
|
+
ctx = DrawContext(None, st.plot, rects[id(a)], st.x, st.y, st.cycle)
|
|
547
|
+
old = r.placed.get(id(st.plot))
|
|
548
|
+
if old is None or any(not _same(c.resolution(ctx), c.resolution(old))
|
|
549
|
+
for c in calls):
|
|
550
|
+
r.placed[id(st.plot)] = ctx
|
|
551
|
+
changed = True
|
|
552
|
+
if changed:
|
|
553
|
+
from .mapped import train
|
|
554
|
+
train(fig, areas)
|
|
555
|
+
for a in areas:
|
|
556
|
+
if a.is_plot:
|
|
557
|
+
for bar in a.state.bars:
|
|
558
|
+
bar.sync()
|
|
559
|
+
elif hasattr(a.state, "sync"):
|
|
560
|
+
a.state.sync()
|
|
561
|
+
for lt in lanes or ():
|
|
562
|
+
if lt.bar is not None:
|
|
563
|
+
lt.bar.sync()
|
|
564
|
+
return changed
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _widths_changed(areas: list[Area]) -> bool:
|
|
568
|
+
"""Whether a plot's genome data were binned for another axes width than placed."""
|
|
569
|
+
r = current()
|
|
570
|
+
assert r is not None
|
|
571
|
+
for a in areas:
|
|
572
|
+
if a.is_plot and id(a.obj) in r.widths:
|
|
573
|
+
w = a.axes[2] - a.axes[0]
|
|
574
|
+
used = r.widths[id(a.obj)]
|
|
575
|
+
if abs(w - used) > 1e-6 * max(1.0, w):
|
|
576
|
+
return True
|
|
577
|
+
return False
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _rebuild(fig: Any, areas: list[Area], lanes: list["_Lane"], out: set[int]) -> None:
|
|
581
|
+
"""The states again, from calls computed for the placed axes widths (legend choices
|
|
582
|
+
kept, the inside colorbars of ``out`` moved out again, figure legends collected
|
|
583
|
+
again)."""
|
|
584
|
+
r = current()
|
|
585
|
+
assert r is not None
|
|
586
|
+
chosen = {}
|
|
587
|
+
for a in areas:
|
|
588
|
+
if a.is_plot:
|
|
589
|
+
leg = a.state.legend
|
|
590
|
+
chosen[id(a)] = (leg.choice, leg.free)
|
|
591
|
+
w, h = a.axes[2] - a.axes[0], a.axes[3] - a.axes[1]
|
|
592
|
+
set_boxes(a.obj, axes_width=w, axes_height=h)
|
|
593
|
+
r.plots.clear()
|
|
594
|
+
r.calls.clear()
|
|
595
|
+
r.widths.clear()
|
|
596
|
+
build(fig, areas)
|
|
597
|
+
_share_legends(lanes, areas)
|
|
598
|
+
for lt in lanes:
|
|
599
|
+
if lt.bar is not None:
|
|
600
|
+
lt.bar.sync()
|
|
601
|
+
_drop_bars(lanes, areas)
|
|
602
|
+
_move_out(areas, out)
|
|
603
|
+
for a in areas:
|
|
604
|
+
if id(a) in chosen:
|
|
605
|
+
a.state.legend.choice, a.state.legend.free = chosen[id(a)]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _choose_legends(areas: list[Area], fonts: Any) -> bool:
|
|
609
|
+
"""Choose the places of ``"auto"`` legends on the occupancy grid of the placed axes;
|
|
610
|
+
whether one moved outside (its band changes the margins)."""
|
|
611
|
+
moved = False
|
|
612
|
+
for a in areas:
|
|
613
|
+
if a.is_plot:
|
|
614
|
+
leg = a.state.legend
|
|
615
|
+
if leg.active() and leg.choice is None and not leg.moved \
|
|
616
|
+
and leg.setting() in ("auto", "best"):
|
|
617
|
+
leg.choice = leg.choose(context(a), outside=True, fonts=fonts)
|
|
618
|
+
moved = moved or leg.choice.startswith("outside")
|
|
619
|
+
return moved
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _choose_bars(areas: list[Area], fonts: Any, out: set[int]) -> bool:
|
|
623
|
+
"""Inside colorbars with ``inside="auto"`` (or ``"best"``) larger than the axes, or
|
|
624
|
+
whose best place inside them meets a marker, a text or another guide, or covers more
|
|
625
|
+
than ``OUTSIDE_THRESHOLD`` of what is drawn: moved out, to the bottom of their plot (their
|
|
626
|
+
ids added to ``out``); whether one moved."""
|
|
627
|
+
from .guides import (BEST_CANDIDATES, CANDIDATES, CHOSEN, OUTSIDE_THRESHOLD,
|
|
628
|
+
_drawn, anchor_box, best_anchor)
|
|
629
|
+
moved = False
|
|
630
|
+
for a in areas:
|
|
631
|
+
if not a.is_plot or not any(b.inside in CHOSEN for b in a.state.bars):
|
|
632
|
+
continue
|
|
633
|
+
ctx = context(a)
|
|
634
|
+
x0, y0, x1, y1 = ctx.rect
|
|
635
|
+
occ = _drawn(a.state, ctx, fonts)
|
|
636
|
+
for b in a.state.bars:
|
|
637
|
+
where = b.inside
|
|
638
|
+
if where is None:
|
|
639
|
+
continue
|
|
640
|
+
w, h, _ = b.content.inside_size(fonts, x1 - x0, y1 - y0)
|
|
641
|
+
pad = b.content.size("pad")
|
|
642
|
+
if where in CHOSEN:
|
|
643
|
+
where, f = best_anchor(occ, ctx.rect, w, h, pad, pad,
|
|
644
|
+
BEST_CANDIDATES if where == "best" else CANDIDATES)
|
|
645
|
+
box = (*anchor_box(ctx.rect, where, w, h, pad, pad), w, h)
|
|
646
|
+
if f > OUTSIDE_THRESHOLD or occ.solid_in(*box) \
|
|
647
|
+
or occ.covered(*box, markers=True) \
|
|
648
|
+
or w > x1 - x0 - 2 * pad or h > y1 - y0 - 2 * pad:
|
|
649
|
+
out.add(id(b.cb))
|
|
650
|
+
moved = True
|
|
651
|
+
continue
|
|
652
|
+
x, y = anchor_box(ctx.rect, where, w, h, pad, pad)
|
|
653
|
+
occ.rects([x], [y], [x + w], [y + h], solid=True)
|
|
654
|
+
if moved:
|
|
655
|
+
_move_out(areas, out)
|
|
656
|
+
return moved
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _move_out(areas: list[Area], out: set[int]) -> None:
|
|
660
|
+
"""The inside colorbars of ``out`` (ids) attached at the bottom of their plots."""
|
|
661
|
+
if not out:
|
|
662
|
+
return
|
|
663
|
+
from .colorbar import Bar
|
|
664
|
+
from .guides import GuideState
|
|
665
|
+
spot = ("side", "bottom")
|
|
666
|
+
for a in areas:
|
|
667
|
+
if a.is_plot:
|
|
668
|
+
a.state.bars = [GuideState(Bar(b.cb, spot), spot)
|
|
669
|
+
if b.inside is not None and id(b.cb) in out else b
|
|
670
|
+
for b in a.state.bars]
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _solve(fig: Any, areas: list[Area], grid: _Grid, fonts: Any,
|
|
674
|
+
lanes: list["_Lane"] | None = None) -> tuple[float, float, int]:
|
|
675
|
+
"""Measure and place every area, round after round, until nothing circular changes.
|
|
676
|
+
``lanes``: the nodes on the grid lines (figure titles, the main title among them,
|
|
677
|
+
legends and colorbars; those on top and bottom wrap to their span, which the placed
|
|
678
|
+
widths give). Returns the figure size and the number of rounds."""
|
|
679
|
+
sizes = {id(a): (grid.cw, grid.ch) for a in areas}
|
|
680
|
+
W = H = 0.0
|
|
681
|
+
lanes = lanes or []
|
|
682
|
+
thick = None
|
|
683
|
+
if lanes: # unwrapped until the widths are placed
|
|
684
|
+
thick = _lane_sizes(fig, lanes, fonts, areas, grid, grid.fw or 0.0, placed=False)
|
|
685
|
+
first = True
|
|
686
|
+
out: set[int] = set() # inside colorbars moved out
|
|
687
|
+
phase = 0 # rounds since a decision changed the inputs
|
|
688
|
+
for n in range(1, ROUNDS + 1):
|
|
689
|
+
phase += 1
|
|
690
|
+
for a in areas:
|
|
691
|
+
if a.is_plot:
|
|
692
|
+
prepare(a.state, fonts, *sizes[id(a)])
|
|
693
|
+
if first: # stats binned for the axes sizes measured
|
|
694
|
+
first = False
|
|
695
|
+
_place_binned(fig, areas, {id(a): (0.0, 0.0, max(sizes[id(a)][0], 1.0),
|
|
696
|
+
max(sizes[id(a)][1], 1.0)) for a in areas},
|
|
697
|
+
lanes)
|
|
698
|
+
for a in areas:
|
|
699
|
+
a.bands = _measure_area(a, fonts, *sizes[id(a)])
|
|
700
|
+
a.margins = a.bands.margins
|
|
701
|
+
rows.bands(fig, areas, fonts) # kept on the plots' states too
|
|
702
|
+
W, H, changed = grid.place(areas, sizes, thick)
|
|
703
|
+
for a in areas:
|
|
704
|
+
if a.is_plot:
|
|
705
|
+
x0, y0, x1, y1 = a.box
|
|
706
|
+
a.state.box_room = (None if grid.fw is None else x1 - x0,
|
|
707
|
+
None if grid.fh is None else y1 - y0)
|
|
708
|
+
if changed and phase < SIZE_ROUNDS:
|
|
709
|
+
continue
|
|
710
|
+
phase = 0
|
|
711
|
+
if _place_binned(fig, areas, {id(a): a.axes for a in areas}, lanes):
|
|
712
|
+
continue
|
|
713
|
+
if _widths_changed(areas):
|
|
714
|
+
_rebuild(fig, areas, lanes, out)
|
|
715
|
+
first = True
|
|
716
|
+
continue
|
|
717
|
+
if _choose_bars(areas, fonts, out):
|
|
718
|
+
continue
|
|
719
|
+
if _choose_legends(areas, fonts):
|
|
720
|
+
continue
|
|
721
|
+
if thick is not None:
|
|
722
|
+
now = _lane_sizes(fig, lanes, fonts, areas, grid, W)
|
|
723
|
+
if any(abs(u - v) > TOLERANCE for was, new_ in zip(thick, now)
|
|
724
|
+
for u, v in zip(was, new_)):
|
|
725
|
+
thick = now
|
|
726
|
+
continue
|
|
727
|
+
return W, H, n
|
|
728
|
+
return W, H, ROUNDS
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# ---------------------------------------------------------------------------
|
|
732
|
+
# Place: geometry records
|
|
733
|
+
# ---------------------------------------------------------------------------
|
|
734
|
+
|
|
735
|
+
def _place_area(a: Area, fonts: Any, figure_size: tuple[float, float]) -> Any:
|
|
736
|
+
if a.is_bar:
|
|
737
|
+
return a.state.place(fonts, a.axes)
|
|
738
|
+
return place_plot(a.state, fonts, a.axes, figure_size)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _freeze(fig: Any, areas: list[Area], fonts: Any, W: float, H: float, n: int,
|
|
742
|
+
main: tuple[TitleGeometry, float] | None = None,
|
|
743
|
+
titles: tuple[TitleGeometry, ...] = (),
|
|
744
|
+
legends: tuple[FigureLegendGeometry, ...] = (),
|
|
745
|
+
colorbars: tuple[FigureColorbarGeometry, ...] = ()) -> Geometry:
|
|
746
|
+
"""The figure's geometry record: the areas placed, the lane nodes as placed (the
|
|
747
|
+
``main`` title, outermost on top, with its span's length, as the figure's title)."""
|
|
748
|
+
title, title_at, width, rule = None, (0.0, 0.0), 0.0, None
|
|
749
|
+
if main is not None:
|
|
750
|
+
tg, width = main
|
|
751
|
+
title, title_at, rule = tg.paragraph, tg.at or title_at, tg.rule
|
|
752
|
+
placed = []
|
|
753
|
+
for a in areas:
|
|
754
|
+
g = _place_area(a, fonts, (W, H))
|
|
755
|
+
placed.append(Placed(a.obj, a.r0, a.c0, a.r1, a.c1, a.axes, a.box, a.margins,
|
|
756
|
+
a.bands, a.state, g, a.is_bar))
|
|
757
|
+
spans, lines, groups = rows.place_figure(fig, fonts, placed)
|
|
758
|
+
return Geometry(W, H, tuple(placed), FigureGeometry(title, title_at, width, rule,
|
|
759
|
+
spans, lines, groups, titles,
|
|
760
|
+
legends, colorbars), n)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def layout(fig: Any, fonts: Any, only: Any = None) -> Geometry:
|
|
764
|
+
"""The figure laid out (``only``: one plot of it, alone); text is shaped with ``fonts``
|
|
765
|
+
(a ``FontDB``, or a canvas whose document's fonts are used)."""
|
|
766
|
+
with rendering(fig), warnings_once():
|
|
767
|
+
return _layout(fig, fonts, only)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _layout(fig: Any, fonts: Any, only: Any = None) -> Geometry:
|
|
771
|
+
areas, nr, nc = arrange(fig, None)
|
|
772
|
+
grid = _Grid(fig, nr, nc)
|
|
773
|
+
for a in areas: # what stats binned for the axes width read
|
|
774
|
+
if a.is_plot: # before the first round
|
|
775
|
+
set_boxes(a.obj, axes_width=max(grid.cw, 1.0), axes_height=max(grid.ch, 1.0))
|
|
776
|
+
build(fig, areas)
|
|
777
|
+
lanes = lane_titles(fig, areas, nr, nc) if only is None else []
|
|
778
|
+
W, H, n = _solve(fig, areas, grid, fonts, lanes)
|
|
779
|
+
if only is not None:
|
|
780
|
+
return _alone(fig, areas, fonts, only, n)
|
|
781
|
+
titles, legends, bars, main = _place_lanes(fig, lanes, fonts, areas, grid, W, H)
|
|
782
|
+
return _freeze(fig, areas, fonts, W, H, n, main, titles, legends, bars)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def title_span(title: Any, areas: list[Area], name: str | None = None
|
|
786
|
+
) -> tuple[int, int, int, int]:
|
|
787
|
+
"""The grid rectangle ``(r0, c0, r1, c1)`` a figure title covers: the cells of its
|
|
788
|
+
plots; a plot inside it that the title does not name is an error (naming the title as
|
|
789
|
+
``name``, default its repr)."""
|
|
790
|
+
plots = [a for a in areas if a.is_plot]
|
|
791
|
+
named = title.plots
|
|
792
|
+
mine = plots if named is None else [a for a in plots if any(a.obj is p for p in named)]
|
|
793
|
+
if not mine:
|
|
794
|
+
raise ValueError(f"{name or repr(title)}: its plots are not in the figure's grid")
|
|
795
|
+
r0, c0 = min(a.r0 for a in mine), min(a.c0 for a in mine)
|
|
796
|
+
r1, c1 = max(a.r1 for a in mine), max(a.c1 for a in mine)
|
|
797
|
+
for a in plots:
|
|
798
|
+
if any(a is m for m in mine):
|
|
799
|
+
continue
|
|
800
|
+
if a.r0 < r1 and r0 < a.r1 and a.c0 < c1 and c0 < a.c1:
|
|
801
|
+
over = ", ".join(repr(m.obj) for m in mine)
|
|
802
|
+
raise ValueError(f"{name or repr(title)} over plots {over}: "
|
|
803
|
+
f"{a.obj!r} is inside their grid rectangle but not one of them")
|
|
804
|
+
return r0, c0, r1, c1
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# ---------------------------------------------------------------------------
|
|
808
|
+
# Lanes: nodes on the grid lines
|
|
809
|
+
# ---------------------------------------------------------------------------
|
|
810
|
+
|
|
811
|
+
class _Lane:
|
|
812
|
+
"""A node on a grid line: a figure title, legend or colorbar beside a set of plots.
|
|
813
|
+
Its creation number ``k``, side, grid rectangle ``span`` ``(r0, c0, r1, c1)`` and the
|
|
814
|
+
plot areas in it; the boundary it sits in (``line``: the row line above its topmost
|
|
815
|
+
row for a top node, below its bottommost for a bottom one, the column lines likewise),
|
|
816
|
+
its cells along that line ``[lo, hi)``, its lane there (0 nearest the plots) and
|
|
817
|
+
whether it wraps (``flow``: on top or bottom, a title unrotated; a legend's entries in
|
|
818
|
+
rows as wide as its span). ``outer``: the main title on top, unrotated and without an
|
|
819
|
+
``order``: the outermost lane of the top. :meth:`measure` gives its content and its thickness across the line,
|
|
820
|
+
:meth:`place` its geometry record from its lane's near edge."""
|
|
821
|
+
|
|
822
|
+
__slots__ = ("obj", "k", "side", "span", "plots", "line", "lo", "hi", "lane", "flow",
|
|
823
|
+
"outer", "legend", "bar", "drops")
|
|
824
|
+
#: over the same plots, from the plots outward: legends, colorbars, titles
|
|
825
|
+
rank = 2
|
|
826
|
+
|
|
827
|
+
def __init__(self, obj: Any, k: int, span: tuple[int, int, int, int],
|
|
828
|
+
areas: list[Area]) -> None:
|
|
829
|
+
self.obj, self.k, self.span = obj, k, span
|
|
830
|
+
self.side = side = obj.resolve("side")
|
|
831
|
+
r0, c0, r1, c1 = span
|
|
832
|
+
self.plots = [a for a in areas if a.is_plot and r0 <= a.r0 and a.r1 <= r1
|
|
833
|
+
and c0 <= a.c0 and a.c1 <= c1]
|
|
834
|
+
self.line = {"top": r0, "bottom": r1, "left": c0, "right": c1}[side]
|
|
835
|
+
self.lo, self.hi = (c0, c1) if side in ("top", "bottom") else (r0, r1)
|
|
836
|
+
self.lane = 0
|
|
837
|
+
self.flow = side in ("top", "bottom")
|
|
838
|
+
self.outer = False
|
|
839
|
+
self.legend: Any = None
|
|
840
|
+
self.bar: Any = None
|
|
841
|
+
self.drops: set[int] = set()
|
|
842
|
+
|
|
843
|
+
def overlaps(self, other: "_Lane") -> bool:
|
|
844
|
+
return self.lo < other.hi and other.lo < self.hi
|
|
845
|
+
|
|
846
|
+
def nearer(self, other: "_Lane") -> bool:
|
|
847
|
+
"""Whether it sits nearer the plots than ``other`` by the rules: nested inside it
|
|
848
|
+
(its plots a subset of the other's), over the same plots a legend before a
|
|
849
|
+
colorbar before a title, else created first."""
|
|
850
|
+
mine = {id(a) for a in self.plots}
|
|
851
|
+
theirs = {id(a) for a in other.plots}
|
|
852
|
+
if mine != theirs and (mine <= theirs or theirs <= mine):
|
|
853
|
+
return mine < theirs
|
|
854
|
+
if mine == theirs and self.rank != other.rank:
|
|
855
|
+
return self.rank < other.rank
|
|
856
|
+
return self.k < other.k
|
|
857
|
+
|
|
858
|
+
def over(self) -> str:
|
|
859
|
+
"""What it is aligned within along its side: ``"axes"``, ``"plot"`` or
|
|
860
|
+
``"figure"``."""
|
|
861
|
+
return "axes"
|
|
862
|
+
|
|
863
|
+
def measure(self, fonts: Any, s0: float, s1: float, placed: bool) -> tuple[Any, float]:
|
|
864
|
+
"""Its content laid out along its span ``[s0, s1]`` (once the areas are
|
|
865
|
+
``placed``; before, unwrapped) and the thickness of its lane."""
|
|
866
|
+
raise NotImplementedError
|
|
867
|
+
|
|
868
|
+
def place(self, fonts: Any, near: float, s0: float, s1: float, content: Any,
|
|
869
|
+
room: tuple[float, float]) -> Any:
|
|
870
|
+
"""Its geometry record: its lane's near edge at ``near`` across its side, its span
|
|
871
|
+
``[s0, s1]``, its ``content`` as measured; ``room``: the figure's content along
|
|
872
|
+
its side (within the margins)."""
|
|
873
|
+
raise NotImplementedError
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
class _TitleLane(_Lane):
|
|
877
|
+
"""A figure title in a lane (the main title, others, a group's label)."""
|
|
878
|
+
|
|
879
|
+
__slots__ = ("font", "laid")
|
|
880
|
+
|
|
881
|
+
def __init__(self, obj: Any, k: int, span: tuple[int, int, int, int],
|
|
882
|
+
areas: list[Area], main: bool = False) -> None:
|
|
883
|
+
super().__init__(obj, k, span, areas)
|
|
884
|
+
self.font = title_font(obj)
|
|
885
|
+
self.laid: tuple[float, Any] | None = None # the last paragraph and its width
|
|
886
|
+
self.flow = self.flow and rotation_of(obj) == 0
|
|
887
|
+
self.outer = main and self.side == "top" and self.flow \
|
|
888
|
+
and obj.resolve("order") is None
|
|
889
|
+
|
|
890
|
+
def over(self) -> str:
|
|
891
|
+
return self.obj.resolve("over")
|
|
892
|
+
|
|
893
|
+
def measure(self, fonts: Any, s0: float, s1: float, placed: bool) -> tuple[Any, float]:
|
|
894
|
+
t = self.obj
|
|
895
|
+
if self.flow:
|
|
896
|
+
width = max(s1 - s0, 1.0) if placed else 1e6
|
|
897
|
+
if self.laid is None or self.laid[0] != width:
|
|
898
|
+
self.laid = (width, paragraph(fonts, t, width))
|
|
899
|
+
text = self.laid[1]
|
|
900
|
+
return text, t.resolve("pad") + text.height + rule_extent(t)
|
|
901
|
+
b = block(fonts, t.resolve("text"), self.font)
|
|
902
|
+
return b, _title_need(t, b, self.side)[0]
|
|
903
|
+
|
|
904
|
+
def place(self, fonts: Any, near: float, s0: float, s1: float, content: Any,
|
|
905
|
+
room: tuple[float, float]) -> TitleGeometry:
|
|
906
|
+
t, side = self.obj, self.side
|
|
907
|
+
sign = -1.0 if side in ("top", "left") else 1.0
|
|
908
|
+
near += sign * (t.resolve("pad") + rule_inside(t))
|
|
909
|
+
if self.flow:
|
|
910
|
+
para = content
|
|
911
|
+
across = para.height
|
|
912
|
+
y = near - across if side == "top" else near
|
|
913
|
+
x = {"start": s0, "center": (s0 + s1 - para.width) / 2,
|
|
914
|
+
"end": s1 - para.width}[along(t)]
|
|
915
|
+
return TitleGeometry(t, side, self.lane, (x, y, para.width, para.height), (),
|
|
916
|
+
rule_rect(t, side, near, across, s0, s1), para, (s0, y))
|
|
917
|
+
runs, box_ = _title_runs(fonts, content, self.font, side, rotation_of(t), along(t),
|
|
918
|
+
near, s0, s1)
|
|
919
|
+
across = _title_need(t, content, side)[1]
|
|
920
|
+
return TitleGeometry(t, side, self.lane, box_, runs,
|
|
921
|
+
rule_rect(t, side, near, across, s0, s1))
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
class _LegendLane(_Lane):
|
|
925
|
+
"""A figure legend in a lane (``legend``: its :class:`~plot_io.guides.GuideState`)."""
|
|
926
|
+
|
|
927
|
+
__slots__ = ()
|
|
928
|
+
rank = 0
|
|
929
|
+
|
|
930
|
+
def measure(self, fonts: Any, s0: float, s1: float, placed: bool) -> tuple[Any, float]:
|
|
931
|
+
w, h = self.legend.measure(fonts, max(s1 - s0, 1.0), max(s1 - s0, 1.0), placed)
|
|
932
|
+
return (w, h), self.obj.resolve("pad") + (h if self.flow else w)
|
|
933
|
+
|
|
934
|
+
def place(self, fonts: Any, near: float, s0: float, s1: float, content: Any,
|
|
935
|
+
room: tuple[float, float]) -> FigureLegendGeometry:
|
|
936
|
+
side, flat = self.side, self.flow
|
|
937
|
+
near += (-1.0 if side in ("top", "left") else 1.0) * self.obj.resolve("pad")
|
|
938
|
+
w, h = content
|
|
939
|
+
length = w if flat else h
|
|
940
|
+
start = {"start": s0, "center": (s0 + s1 - length) / 2,
|
|
941
|
+
"end": s1 - length}[self.obj.along()]
|
|
942
|
+
start = max(room[0], min(start, room[1] - length))
|
|
943
|
+
if flat:
|
|
944
|
+
x, y = start, (near - h if side == "top" else near)
|
|
945
|
+
else:
|
|
946
|
+
x, y = (near - w if side == "left" else near), start
|
|
947
|
+
return FigureLegendGeometry(self.legend, side, self.lane,
|
|
948
|
+
self.legend.place(fonts, (x, y, x + w, y + h)))
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
class _BarLane(_Lane):
|
|
952
|
+
"""A figure colorbar in a lane (``bar``: its :class:`~plot_io.guides.GuideState`;
|
|
953
|
+
``drops``: the ids of the attached colorbars it shows instead)."""
|
|
954
|
+
|
|
955
|
+
__slots__ = ()
|
|
956
|
+
rank = 1
|
|
957
|
+
|
|
958
|
+
def __init__(self, obj: Any, k: int, span: tuple[int, int, int, int],
|
|
959
|
+
areas: list[Area], bar: Any) -> None:
|
|
960
|
+
super().__init__(obj, k, span, areas)
|
|
961
|
+
self.bar = bar
|
|
962
|
+
self.flow = False
|
|
963
|
+
|
|
964
|
+
def measure(self, fonts: Any, s0: float, s1: float, placed: bool) -> tuple[Any, float]:
|
|
965
|
+
return None, self.bar.measure(fonts, max(s1 - s0, 1.0), max(s1 - s0, 1.0), placed)
|
|
966
|
+
|
|
967
|
+
def place(self, fonts: Any, near: float, s0: float, s1: float, content: Any,
|
|
968
|
+
room: tuple[float, float]) -> FigureColorbarGeometry:
|
|
969
|
+
near += (-1.0 if self.side in ("top", "left") else 1.0) * self.bar.lane_pad()
|
|
970
|
+
line = (near, s0, near, s1) if self.side in ("left", "right") else (s0, near, s1, near)
|
|
971
|
+
return FigureColorbarGeometry(self.side, self.lane, self.bar.place(fonts, line))
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def lane_titles(fig: Any, areas: list[Area], nr: int, nc: int) -> list[_Lane]:
|
|
975
|
+
"""The nodes on the grid lines of an ``nr`` × ``nc`` grid: the figure titles with text
|
|
976
|
+
(the main title over every plot, the whole grid without plots; the groups' labels:
|
|
977
|
+
:meth:`plot_io.rows.FigureGroup.title`), the figure legends with entries and the
|
|
978
|
+
figure colorbars, their spans checked (:func:`title_span`), each in its lane."""
|
|
979
|
+
out: list[_Lane] = []
|
|
980
|
+
plots = any(a.is_plot for a in areas)
|
|
981
|
+
for t in fig.titles:
|
|
982
|
+
if t.resolve("text") is None:
|
|
983
|
+
continue
|
|
984
|
+
main = t is fig.title
|
|
985
|
+
span = title_span(t, areas) if plots or not main else (0, 0, nr, nc)
|
|
986
|
+
out.append(_TitleLane(t, t._made, span, areas, main))
|
|
987
|
+
for g in fig.groups:
|
|
988
|
+
t = g.title()
|
|
989
|
+
if t is None:
|
|
990
|
+
continue
|
|
991
|
+
span = title_span(t, areas, f"group label {t.resolve('text')!r}")
|
|
992
|
+
out.append(_TitleLane(t, t._made, span, areas))
|
|
993
|
+
out += _legend_lanes(fig, areas)
|
|
994
|
+
out += _colorbar_lanes(fig, areas)
|
|
995
|
+
stacks: dict[tuple[str, int], list[_Lane]] = {}
|
|
996
|
+
for lt in out:
|
|
997
|
+
stacks.setdefault((lt.side, lt.line), []).append(lt)
|
|
998
|
+
for stack in stacks.values():
|
|
999
|
+
_stack(stack)
|
|
1000
|
+
return out
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _legend_lanes(fig: Any, areas: list[Area]) -> list[_Lane]:
|
|
1004
|
+
"""The figure legends with entries: those of ``add_legend``, then those
|
|
1005
|
+
``share_legend`` makes for its groups of plots (:func:`plot_io.sharing.groups`; a group
|
|
1006
|
+
with a plot an ``add_legend`` legend covers makes none): one on the right of each row
|
|
1007
|
+
(``"row"``) or of the figure (``"all"``), one at the bottom of each column
|
|
1008
|
+
(``"col"``)."""
|
|
1009
|
+
from .plot import FigureLegend
|
|
1010
|
+
from .sharing import groups
|
|
1011
|
+
lanes: list[_Lane] = [_LegendLane(g, g._made, title_span(g, areas), areas) for g in fig.legends]
|
|
1012
|
+
covered = {id(a) for lt in lanes for a in lt.plots}
|
|
1013
|
+
side = "bottom" if fig.resolve("share_legend") == "col" else "right"
|
|
1014
|
+
for members, _ in groups(fig, areas, "legend"):
|
|
1015
|
+
if any(id(a) in covered for a in members):
|
|
1016
|
+
continue
|
|
1017
|
+
g = FigureLegend(fig, [a.obj for a in sorted(members, key=lambda a: (a.r0, a.c0))],
|
|
1018
|
+
side=side)
|
|
1019
|
+
lanes.append(_LegendLane(g, g._made, title_span(g, areas), areas))
|
|
1020
|
+
_share_legends(lanes, areas)
|
|
1021
|
+
return [lt for lt in lanes if lt.legend.active()]
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _colorbar_lanes(fig: Any, areas: list[Area]) -> list[_Lane]:
|
|
1025
|
+
"""The figure colorbars: those of ``add_colorbar``, then one per shared color (size)
|
|
1026
|
+
axis whose plots have attached colorbars (size keys) of it: per group of
|
|
1027
|
+
``share_color`` (``share_size``; on the right of a row or of the figure, at the bottom
|
|
1028
|
+
of a column), then per axis shown by the attached colorbars of two plots or more
|
|
1029
|
+
(``figure.color_axis()``, ``figure.size_axis()``; at the bottom of a column, else on
|
|
1030
|
+
the right). Each takes the attached colorbars of its axis out of its plots (but those
|
|
1031
|
+
whose ``side`` is set); plots that are not a grid rectangle keep theirs."""
|
|
1032
|
+
from .colorbar import FigureColorbar
|
|
1033
|
+
from .guides import GuideState
|
|
1034
|
+
from .mapped import trained
|
|
1035
|
+
from .sharing import groups
|
|
1036
|
+
|
|
1037
|
+
def axis_of(b: Any) -> int:
|
|
1038
|
+
return id(trained(b.axis))
|
|
1039
|
+
|
|
1040
|
+
attached = [(a, b) for a in areas if a.is_plot for b in a.state.bars
|
|
1041
|
+
if not b.cb.is_set("side")]
|
|
1042
|
+
lanes: list[_Lane] = [_BarLane(cb, cb._made, title_span(cb, areas), areas, GuideState.for_colorbar(cb))
|
|
1043
|
+
for cb in fig.colorbars]
|
|
1044
|
+
taken: set[int] = set()
|
|
1045
|
+
for lt in lanes:
|
|
1046
|
+
mine, shown = {id(a) for a in lt.plots}, axis_of(lt.bar)
|
|
1047
|
+
lt.drops = {id(b.cb) for a, b in attached if id(a) in mine and axis_of(b) == shown}
|
|
1048
|
+
taken |= lt.drops
|
|
1049
|
+
|
|
1050
|
+
def make(members: list[Area], pairs: list[tuple[Area, Any]], side: str) -> None:
|
|
1051
|
+
first = pairs[0][1].cb
|
|
1052
|
+
settings = {k: v for k, v in first._values.items()
|
|
1053
|
+
if k not in ("side", "align", "order", "pad", "orientation")}
|
|
1054
|
+
plots = [a.obj for a in sorted(members, key=lambda a: (a.r0, a.c0))]
|
|
1055
|
+
cb = FigureColorbar(fig, first.shown(), plots, side=side, **settings)
|
|
1056
|
+
cb.marks = first.marks
|
|
1057
|
+
try:
|
|
1058
|
+
span = title_span(cb, areas)
|
|
1059
|
+
except ValueError: # not a grid rectangle: kept attached
|
|
1060
|
+
return
|
|
1061
|
+
lt = _BarLane(cb, cb._made, span, areas, GuideState.for_colorbar(cb))
|
|
1062
|
+
lt.drops = {id(b.cb) for _, b in pairs}
|
|
1063
|
+
taken.update(lt.drops)
|
|
1064
|
+
lanes.append(lt)
|
|
1065
|
+
|
|
1066
|
+
for role in ("color", "size"):
|
|
1067
|
+
side = "bottom" if fig.resolve(f"share_{role}") == "col" else "right"
|
|
1068
|
+
for members, _ in groups(fig, areas, role):
|
|
1069
|
+
ids = {id(a) for a in members}
|
|
1070
|
+
by_axis: dict[int, list[tuple[Area, Any]]] = {}
|
|
1071
|
+
for a, b in attached:
|
|
1072
|
+
if id(a) in ids and id(b.cb) not in taken and b.sizes == (role == "size"):
|
|
1073
|
+
by_axis.setdefault(axis_of(b), []).append((a, b))
|
|
1074
|
+
for pairs in by_axis.values():
|
|
1075
|
+
make(members, pairs, side)
|
|
1076
|
+
by_axis = {}
|
|
1077
|
+
for a, b in attached:
|
|
1078
|
+
if id(b.cb) not in taken:
|
|
1079
|
+
by_axis.setdefault(axis_of(b), []).append((a, b))
|
|
1080
|
+
for pairs in by_axis.values():
|
|
1081
|
+
members = list({id(a): a for a, _ in pairs}.values())
|
|
1082
|
+
if len(members) > 1:
|
|
1083
|
+
column = len({(a.c0, a.c1) for a in members}) == 1
|
|
1084
|
+
make(members, pairs, "bottom" if column else "right")
|
|
1085
|
+
_drop_bars(lanes, areas)
|
|
1086
|
+
return lanes
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _drop_bars(lanes: list[_Lane], areas: list[Area]) -> None:
|
|
1090
|
+
"""Take the attached colorbars that figure colorbars show out of their plots."""
|
|
1091
|
+
drops = set().union(*(lt.drops for lt in lanes))
|
|
1092
|
+
if drops:
|
|
1093
|
+
for a in areas:
|
|
1094
|
+
if a.is_plot:
|
|
1095
|
+
a.state.bars = [b for b in a.state.bars if id(b.cb) not in drops]
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _main_plot(plot: Any) -> Any:
|
|
1099
|
+
twin = getattr(plot, "twin_of", None)
|
|
1100
|
+
return twin[0] if twin else plot
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _hidden_legend(plot: Any) -> bool:
|
|
1104
|
+
lg = plot.legend
|
|
1105
|
+
return lg.is_set("visible") and not lg.resolve("visible")
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _share_legends(lanes: list[_Lane], areas: list[Area]) -> None:
|
|
1109
|
+
"""Collect the figure legends' entries from their plots' states (row by row, then in
|
|
1110
|
+
series order; the same label and symbol once), and take them out of those plots'
|
|
1111
|
+
legends unless a plot's legend has its place set (``inside``, ``side``, ``visible``);
|
|
1112
|
+
a plot whose legend is hidden (``visible=False``) gives none; a size key whose series'
|
|
1113
|
+
plots a figure legend covers goes into the first such legend instead of a plot
|
|
1114
|
+
legend."""
|
|
1115
|
+
from .guides import GuideState
|
|
1116
|
+
from .legend import group_key
|
|
1117
|
+
legends = sorted((lt for lt in lanes if _is_legend(lt.obj)), key=lambda lt: lt.k)
|
|
1118
|
+
if not legends:
|
|
1119
|
+
return
|
|
1120
|
+
for lt in legends:
|
|
1121
|
+
plots = sorted(lt.plots, key=lambda a: (a.r0, a.c0))
|
|
1122
|
+
lt.legend = GuideState.for_figure_legend(
|
|
1123
|
+
lt.obj, [a.state for a in plots if not _hidden_legend(a.obj)])
|
|
1124
|
+
for a in plots:
|
|
1125
|
+
if not a.obj.legend.is_placed():
|
|
1126
|
+
a.state.legend.content.shared = True
|
|
1127
|
+
r = current()
|
|
1128
|
+
states = {id(st.plot): st for a in areas if a.is_plot for st in [a.state, *a.state.twins]}
|
|
1129
|
+
for g in (r.size_keys.values() if r is not None else ()):
|
|
1130
|
+
if g.plot is None:
|
|
1131
|
+
continue
|
|
1132
|
+
owners = {id(_main_plot(s.plot)) for s in g.listed}
|
|
1133
|
+
into = next((lt for lt in legends if owners <= {id(a.obj) for a in lt.plots}), None)
|
|
1134
|
+
if into is None:
|
|
1135
|
+
continue
|
|
1136
|
+
into.legend.keys.append(group_key(g, g.plot))
|
|
1137
|
+
st = states.get(id(_main_plot(g.plot)))
|
|
1138
|
+
if st is not None and not _main_plot(g.plot).legend.is_placed():
|
|
1139
|
+
st.legend.content.keys = [k for k in st.legend.keys if k.series is not g.listed]
|
|
1140
|
+
for lt in legends:
|
|
1141
|
+
lt.legend.compose()
|
|
1142
|
+
for st in states.values():
|
|
1143
|
+
st.legend.compose()
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _is_legend(obj: Any) -> bool:
|
|
1147
|
+
from .plot import FigureLegend
|
|
1148
|
+
return isinstance(obj, FigureLegend)
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _pack(ordered: list[_Lane], tiers: list[Any] | None = None) -> None:
|
|
1152
|
+
"""Lanes in this order, from the plots outward: each title in the first lane past
|
|
1153
|
+
those of the titles before it whose spans overlap its own (disjoint spans share), and
|
|
1154
|
+
with ``tiers`` (one per title, ascending), past every lane of a lower tier."""
|
|
1155
|
+
done: list[tuple[_Lane, Any]] = []
|
|
1156
|
+
for i, lt in enumerate(ordered):
|
|
1157
|
+
tier = None if tiers is None else tiers[i]
|
|
1158
|
+
lt.lane = 1 + max((d.lane for d, dt in done
|
|
1159
|
+
if d.overlaps(lt) or (tier is not None and dt < tier)), default=-1)
|
|
1160
|
+
done.append((lt, tier))
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _stack(stack: list[_Lane]) -> None:
|
|
1164
|
+
"""The lanes of one side of one boundary: nested titles nearer the plots, overlapping
|
|
1165
|
+
ones in creation order, disjoint ones side by side; then ``order`` (lower nearer, unset:
|
|
1166
|
+
the lane the rules give): a title sits past every lane of a lower order, titles of
|
|
1167
|
+
one order side by side where their spans are disjoint. An ``outer`` node (the main
|
|
1168
|
+
title) is past them all."""
|
|
1169
|
+
outer = [t for t in stack if t.outer]
|
|
1170
|
+
stack = [t for t in stack if not t.outer]
|
|
1171
|
+
rest = sorted(stack, key=lambda t: t.k)
|
|
1172
|
+
ordered = []
|
|
1173
|
+
while rest:
|
|
1174
|
+
ready = [t for t in rest
|
|
1175
|
+
if not any(s is not t and s.overlaps(t) and s.nearer(t) for s in rest)]
|
|
1176
|
+
t = (ready or rest)[0]
|
|
1177
|
+
ordered.append(t)
|
|
1178
|
+
rest.remove(t)
|
|
1179
|
+
_pack(ordered)
|
|
1180
|
+
orders = [t.obj.resolve("order") for t in stack]
|
|
1181
|
+
if any(o is not None for o in orders):
|
|
1182
|
+
rank = {id(t): (t.lane if o is None else o, t.lane, t.k) for t, o in zip(stack, orders)}
|
|
1183
|
+
ordered = sorted(stack, key=lambda t: rank[id(t)])
|
|
1184
|
+
_pack(ordered, [rank[id(t)][0] for t in ordered])
|
|
1185
|
+
for t in outer:
|
|
1186
|
+
t.lane = 1 + max((s.lane for s in stack), default=-1)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _lane_span(fig: Any, lt: _Lane, W: float, H: float) -> tuple[float, float]:
|
|
1190
|
+
"""The span a node is aligned (and wrapped) within, along its side: from the first
|
|
1191
|
+
axes it covers to the last (``over="axes"``), their boxes (``"plot"``) or the figure
|
|
1192
|
+
within its margin (``"figure"``, or without plots)."""
|
|
1193
|
+
over = lt.over()
|
|
1194
|
+
across = lt.side in ("top", "bottom")
|
|
1195
|
+
if over == "figure" or not lt.plots:
|
|
1196
|
+
length = W if across else H
|
|
1197
|
+
m = fig.resolve("margin", of=length)
|
|
1198
|
+
return m, length - m
|
|
1199
|
+
rects = [a.axes if over == "axes" else a.box for a in lt.plots]
|
|
1200
|
+
if across:
|
|
1201
|
+
return min(r[0] for r in rects), max(r[2] for r in rects)
|
|
1202
|
+
return min(r[1] for r in rects), max(r[3] for r in rects)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _lane_sizes(fig: Any, lanes: list[_Lane], fonts: Any, areas: list[Area], grid: _Grid,
|
|
1206
|
+
W: float, placed: bool = True) -> tuple[list[float], ...]:
|
|
1207
|
+
"""The lanes' thickness per line between columns and between rows: per side of a
|
|
1208
|
+
line, the sum of its lanes, each as thick as its thickest node (titles and legends on
|
|
1209
|
+
top and bottom wrapped to their span as ``placed``, else unwrapped). Once ``placed``,
|
|
1210
|
+
a legend longer than the figure's content along its side adds what it lacks to the
|
|
1211
|
+
last line (the bottom, or the right). Then, per line between columns and between
|
|
1212
|
+
rows, 1 where a margin parts its lanes from the plots: where a node but an ``outer``
|
|
1213
|
+
one is (the main title alone on top sits against the plots' boxes, past its
|
|
1214
|
+
``pad``)."""
|
|
1215
|
+
thick = _thickness(fig, lanes, fonts, W, 0.0, placed)[0]
|
|
1216
|
+
xs, ys = _totals(thick, grid)
|
|
1217
|
+
if placed:
|
|
1218
|
+
inner = _totals(thick, grid, _outer(lanes))
|
|
1219
|
+
for across, totals in ((True, xs), (False, ys)):
|
|
1220
|
+
need = max((_along(lt) for lt in lanes if lt.legend is not None
|
|
1221
|
+
and (lt.side in ("top", "bottom")) == across), default=0.0)
|
|
1222
|
+
if need:
|
|
1223
|
+
lo, hi = _extent(areas, grid, inner, across)
|
|
1224
|
+
totals[-1] += max(0.0, need - (hi - lo))
|
|
1225
|
+
xa = [0.0] * (grid.nc + 1)
|
|
1226
|
+
ya = [0.0] * (grid.nr + 1)
|
|
1227
|
+
for lt in lanes:
|
|
1228
|
+
if not lt.outer:
|
|
1229
|
+
(ya if lt.side in ("top", "bottom") else xa)[lt.line] = 1.0
|
|
1230
|
+
return xs, ys, xa, ya
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _outer(lanes: list[_Lane]) -> set[tuple[str, int, int]]:
|
|
1234
|
+
"""The lanes ``(side, line, lane)`` of the ``outer`` nodes."""
|
|
1235
|
+
return {(lt.side, lt.line, lt.lane) for lt in lanes if lt.outer}
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _totals(thick: dict[tuple[str, int], dict[int, float]], grid: _Grid,
|
|
1239
|
+
skip: frozenset[tuple[str, int, int]] | set[tuple[str, int, int]] = frozenset()) -> tuple[list[float], list[float]]:
|
|
1240
|
+
"""The lanes' thickness summed per line between columns and between rows (but the
|
|
1241
|
+
lanes in ``skip``)."""
|
|
1242
|
+
xs = [0.0] * (grid.nc + 1)
|
|
1243
|
+
ys = [0.0] * (grid.nr + 1)
|
|
1244
|
+
for (side, line), sizes in thick.items():
|
|
1245
|
+
(ys if side in ("top", "bottom") else xs)[line] += sum(
|
|
1246
|
+
v for j, v in sizes.items() if (side, line, j) not in skip)
|
|
1247
|
+
return xs, ys
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _extent(areas: list[Area], grid: _Grid, totals: tuple[list[float], list[float]],
|
|
1251
|
+
across: bool) -> tuple[float, float]:
|
|
1252
|
+
"""The figure's content along x (``across``) or y: the areas' boxes and the lanes of
|
|
1253
|
+
the outer lines, with the margin between them and the plots."""
|
|
1254
|
+
xs, ys = totals
|
|
1255
|
+
if across:
|
|
1256
|
+
lo = min(a.box[0] for a in areas) - xs[0] - (grid.mx if xs[0] else 0.0)
|
|
1257
|
+
return lo, max(a.box[2] for a in areas) + xs[-1] + (grid.mx if xs[-1] else 0.0)
|
|
1258
|
+
lo = min(a.box[1] for a in areas) - ys[0] - (grid.my if ys[0] else 0.0)
|
|
1259
|
+
return lo, max(a.box[3] for a in areas) + ys[-1] + (grid.my if ys[-1] else 0.0)
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _along(lt: _Lane) -> float:
|
|
1263
|
+
"""A legend's length along its side (as last measured)."""
|
|
1264
|
+
w, h = lt.legend.size
|
|
1265
|
+
return w if lt.side in ("top", "bottom") else h
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def _thickness(fig: Any, lanes: list[_Lane], fonts: Any, W: float, H: float,
|
|
1269
|
+
placed: bool) -> tuple[dict[tuple[str, int], dict[int, float]],
|
|
1270
|
+
dict[int, tuple[float, float, Any]]]:
|
|
1271
|
+
"""Per side and line, each lane's thickness; per node, its span and laid-out
|
|
1272
|
+
content (wrapped to the span once ``placed``; before, unwrapped, but an ``outer``
|
|
1273
|
+
node in a figure ``W`` wide)."""
|
|
1274
|
+
thick: dict[tuple[str, int], dict[int, float]] = {}
|
|
1275
|
+
texts: dict[int, tuple[float, float, Any]] = {}
|
|
1276
|
+
for lt in lanes:
|
|
1277
|
+
s0, s1 = _lane_span(fig, lt, W, H)
|
|
1278
|
+
wrap = placed
|
|
1279
|
+
if not placed and lt.outer and W > 0: # the main title, first wrapped to a
|
|
1280
|
+
m = fig.resolve("margin", of=W) # figure's given width
|
|
1281
|
+
s0, s1, wrap = m, W - m, True
|
|
1282
|
+
content, need = lt.measure(fonts, s0, s1, wrap)
|
|
1283
|
+
texts[id(lt)] = (s0, s1, content)
|
|
1284
|
+
sizes = thick.setdefault((lt.side, lt.line), {})
|
|
1285
|
+
sizes[lt.lane] = max(sizes.get(lt.lane, 0.0), need)
|
|
1286
|
+
return thick, texts
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def paragraph(fonts: Any, t: Any, width: float) -> Any:
|
|
1290
|
+
"""Figure title ``t`` laid out ``width`` wide (``vec_io.text`` paragraphs, drawn as
|
|
1291
|
+
they are)."""
|
|
1292
|
+
from .text import paragraphs
|
|
1293
|
+
return paragraphs(fonts, t.resolve("text"), title_font(t), max(width, 1.0), along(t))
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _lane_edge(lt: _Lane, lanes: list[_Lane], areas: list[Area], grid: _Grid) -> float:
|
|
1297
|
+
"""Where a node's lanes start, across its side: the boxes of the plots on its line
|
|
1298
|
+
(the outermost of them; without areas there, the grid's cells), past the figure's
|
|
1299
|
+
margin on an outer line unless only ``outer`` nodes are there."""
|
|
1300
|
+
side, line = lt.side, lt.line
|
|
1301
|
+
on = {"top": lambda a: a.r0 == line, "bottom": lambda a: a.r1 == line,
|
|
1302
|
+
"left": lambda a: a.c0 == line, "right": lambda a: a.c1 == line}[side]
|
|
1303
|
+
boxes = [a.box for a in areas if on(a)] or [a.box for a in lt.plots]
|
|
1304
|
+
if not boxes:
|
|
1305
|
+
xs, ys = grid.cells
|
|
1306
|
+
boxes = [(xs[0], ys[0], xs[-1], ys[-1])]
|
|
1307
|
+
apart = any(o.side == side and o.line == line and not o.outer for o in lanes)
|
|
1308
|
+
my = grid.my if apart else 0.0
|
|
1309
|
+
mx = grid.mx if apart else 0.0
|
|
1310
|
+
if side == "top":
|
|
1311
|
+
return min(b[1] for b in boxes) - (my if line == 0 else 0.0)
|
|
1312
|
+
if side == "bottom":
|
|
1313
|
+
return max(b[3] for b in boxes) + (my if line == grid.nr else 0.0)
|
|
1314
|
+
if side == "left":
|
|
1315
|
+
return min(b[0] for b in boxes) - (mx if line == 0 else 0.0)
|
|
1316
|
+
return max(b[2] for b in boxes) + (mx if line == grid.nc else 0.0)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _place_lanes(fig: Any, lanes: list[_Lane], fonts: Any, areas: list[Area], grid: _Grid,
|
|
1320
|
+
W: float, H: float) -> tuple[tuple[TitleGeometry, ...],
|
|
1321
|
+
tuple[FigureLegendGeometry, ...],
|
|
1322
|
+
tuple[FigureColorbarGeometry, ...],
|
|
1323
|
+
tuple[TitleGeometry, float] | None]:
|
|
1324
|
+
"""The nodes on the grid lines placed, in creation order: each lane past the nearer
|
|
1325
|
+
lanes of its side and line, each node against the
|
|
1326
|
+
near edge of its lane, aligned along its span. The main title, when ``outer``, comes
|
|
1327
|
+
last, with its span's length."""
|
|
1328
|
+
thick, texts = _thickness(fig, lanes, fonts, W, H, True)
|
|
1329
|
+
xs, ys = _totals(thick, grid, _outer(lanes))
|
|
1330
|
+
titles = []
|
|
1331
|
+
legends = []
|
|
1332
|
+
bars = []
|
|
1333
|
+
main = None
|
|
1334
|
+
for lt in sorted(lanes, key=lambda t: t.k):
|
|
1335
|
+
side = lt.side
|
|
1336
|
+
sizes = thick[(side, lt.line)]
|
|
1337
|
+
sign = -1.0 if side in ("top", "left") else 1.0
|
|
1338
|
+
near = _lane_edge(lt, lanes, areas, grid) + sign * sum(
|
|
1339
|
+
sizes.get(j, 0.0) for j in range(lt.lane))
|
|
1340
|
+
s0, s1, content = texts[id(lt)]
|
|
1341
|
+
room = (0.0, 0.0)
|
|
1342
|
+
if lt.legend is not None: # within the figure's content and margin
|
|
1343
|
+
flat = side in ("top", "bottom")
|
|
1344
|
+
room = (_extent(areas, grid, (xs, ys), flat)[0],
|
|
1345
|
+
(W - grid.mx) if flat else (H - grid.my))
|
|
1346
|
+
g = lt.place(fonts, near, s0, s1, content, room)
|
|
1347
|
+
if isinstance(lt, _LegendLane):
|
|
1348
|
+
legends.append(g)
|
|
1349
|
+
elif isinstance(lt, _BarLane):
|
|
1350
|
+
bars.append(g)
|
|
1351
|
+
elif lt.outer and lt.obj is fig.title:
|
|
1352
|
+
main = (g, s1 - s0)
|
|
1353
|
+
else:
|
|
1354
|
+
titles.append(g)
|
|
1355
|
+
return tuple(titles), tuple(legends), tuple(bars), main
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _alone(fig: Any, areas: list[Area], fonts: Any, only: Any, n: int) -> Geometry:
|
|
1359
|
+
"""Plot ``only`` of the laid-out figure, alone at its size in the figure, with its
|
|
1360
|
+
outer labels and its own row label band: its axes keep their size (``align="axes"``:
|
|
1361
|
+
measured once), or its box does (``"borders"``: the axes are what the margins leave,
|
|
1362
|
+
in rounds as the figure's, from the box)."""
|
|
1363
|
+
target = next(a for a in areas if a.obj is only)
|
|
1364
|
+
rect = target.axes if fig.resolve("align") == "axes" else target.box
|
|
1365
|
+
st = target.state
|
|
1366
|
+
st.x.hide_labels = st.y.hide_labels = False
|
|
1367
|
+
single = Area(only, 0, 0, 1, 1)
|
|
1368
|
+
single.state = st
|
|
1369
|
+
grid = _Grid(fig, 1, 1, fixed=(rect[2] - rect[0], rect[3] - rect[1]))
|
|
1370
|
+
sizes = {id(single): (grid.cw, grid.ch)}
|
|
1371
|
+
W = H = 0.0
|
|
1372
|
+
for _ in range(SIZE_ROUNDS):
|
|
1373
|
+
w, h = sizes[id(single)]
|
|
1374
|
+
prepare(st, fonts, w, h)
|
|
1375
|
+
single.bands = measure(st, fonts, w, h)
|
|
1376
|
+
single.margins = single.bands.margins
|
|
1377
|
+
rows.bands(fig, [single], fonts)
|
|
1378
|
+
W, H, changed = grid.place([single], sizes)
|
|
1379
|
+
if not changed:
|
|
1380
|
+
break
|
|
1381
|
+
return _freeze(fig, [single], fonts, W, H, n)
|