plot-io 0.12.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-x86_64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. 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)