universal-render 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- universal_render/__init__.py +277 -0
- universal_render/backend.py +375 -0
- universal_render/compat.py +128 -0
- universal_render/diagnostics.py +249 -0
- universal_render/fonts.py +516 -0
- universal_render/highlevel.py +378 -0
- universal_render/layout.py +967 -0
- universal_render/mpl_support.py +1144 -0
- universal_render/renderer.py +806 -0
- universal_render/scripts.py +311 -0
- universal_render-0.1.0.dist-info/METADATA +189 -0
- universal_render-0.1.0.dist-info/RECORD +15 -0
- universal_render-0.1.0.dist-info/WHEEL +5 -0
- universal_render-0.1.0.dist-info/licenses/LICENSE +21 -0
- universal_render-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
# universal_render/layout.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from matplotlib.offsetbox import AnnotationBbox, OffsetImage
|
|
9
|
+
from matplotlib.transforms import Bbox
|
|
10
|
+
|
|
11
|
+
from .renderer import render_text_qimage
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from PySide6.QtGui import QImage
|
|
15
|
+
except Exception: # pragma: no cover
|
|
16
|
+
QImage = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_MANAGER_REGISTRY: Dict[int, "UniversalLayoutManager"] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
23
|
+
# Utilities
|
|
24
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
def _qimage_to_rgba_array(qimg: QImage) -> np.ndarray:
|
|
27
|
+
qimg = qimg.convertToFormat(QImage.Format.Format_RGBA8888)
|
|
28
|
+
w, h = qimg.width(), qimg.height()
|
|
29
|
+
buf = qimg.bits().tobytes()
|
|
30
|
+
return np.frombuffer(buf, np.uint8).reshape((h, w, 4)) / 255.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _renderer_px_size(fig, r):
|
|
34
|
+
"""
|
|
35
|
+
Canvas size in the RENDERER's pixels. During savefig(dpi=...) the
|
|
36
|
+
renderer runs at a different dpi than fig.dpi, so renderer-measured
|
|
37
|
+
extents must be normalized by this size — not by fig.dpi — or every
|
|
38
|
+
placement drifts by the dpi ratio.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
w, h = float(r.width), float(r.height)
|
|
42
|
+
if w > 0 and h > 0:
|
|
43
|
+
return w, h
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
win, hin = fig.get_size_inches()
|
|
47
|
+
return win * fig.dpi, hin * fig.dpi
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _pixels_to_fig_dx(fig, px: float) -> float:
|
|
51
|
+
fw = fig.get_size_inches()[0] * fig.dpi
|
|
52
|
+
return px / fw if fw > 0 else 0.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _pixels_to_fig_dy(fig, px: float) -> float:
|
|
56
|
+
fh = fig.get_size_inches()[1] * fig.dpi
|
|
57
|
+
return px / fh if fh > 0 else 0.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pixels_to_axes_dx(ax, px: float) -> float:
|
|
61
|
+
bp = ax.get_position()
|
|
62
|
+
fw = ax.figure.get_size_inches()[0] * ax.figure.dpi
|
|
63
|
+
return px / max(1.0, bp.width * fw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _pixels_to_axes_dy(ax, px: float) -> float:
|
|
67
|
+
bp = ax.get_position()
|
|
68
|
+
fh = ax.figure.get_size_inches()[1] * ax.figure.dpi
|
|
69
|
+
return px / max(1.0, bp.height * fh)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _remove_artist_safe(artist) -> None:
|
|
73
|
+
if artist is None:
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
artist.remove()
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _resolve_font_size(fontsize, font_size, default):
|
|
82
|
+
if font_size is not None: return font_size
|
|
83
|
+
if fontsize is not None: return fontsize
|
|
84
|
+
return default
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _ensure_list(value) -> List[Any]:
|
|
88
|
+
if value is None: return []
|
|
89
|
+
if isinstance(value, list): return value
|
|
90
|
+
return list(value)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _union_bboxes(bboxes: List[Optional[Bbox]]) -> Optional[Bbox]:
|
|
94
|
+
valid = [b for b in bboxes
|
|
95
|
+
if b is not None and np.isfinite([b.x0, b.y0, b.x1, b.y1]).all()]
|
|
96
|
+
if not valid:
|
|
97
|
+
return None
|
|
98
|
+
return Bbox.from_extents(
|
|
99
|
+
min(b.x0 for b in valid), min(b.y0 for b in valid),
|
|
100
|
+
max(b.x1 for b in valid), max(b.y1 for b in valid),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
105
|
+
# Data classes
|
|
106
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class ManagedTextItem:
|
|
110
|
+
kind: str
|
|
111
|
+
text: str
|
|
112
|
+
ax: Any = None
|
|
113
|
+
fig: Any = None
|
|
114
|
+
|
|
115
|
+
fontsize: Optional[float] = None
|
|
116
|
+
font_size: Optional[float] = None
|
|
117
|
+
font_family: Optional[str] = None
|
|
118
|
+
font_path: Optional[str] = None
|
|
119
|
+
color: str = "black"
|
|
120
|
+
padding: int = 10
|
|
121
|
+
scale: float = 1.0
|
|
122
|
+
zoom: float = 0.40
|
|
123
|
+
extra_pad_px: float = 8.0
|
|
124
|
+
zorder: int = 5
|
|
125
|
+
weight: Any = None
|
|
126
|
+
italic: bool = False
|
|
127
|
+
alpha: float = 1.0
|
|
128
|
+
loc: str = "center"
|
|
129
|
+
|
|
130
|
+
artist: Any = None
|
|
131
|
+
last_image_size_px: Tuple[int, int] = field(default_factory=lambda: (0, 0))
|
|
132
|
+
last_render_size_px: Tuple[float, float] = field(default_factory=lambda: (0.0, 0.0))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class ManagedTickGroup:
|
|
137
|
+
kind: str
|
|
138
|
+
ax: Any
|
|
139
|
+
positions: Sequence[float]
|
|
140
|
+
labels: Sequence[str]
|
|
141
|
+
|
|
142
|
+
fontsize: Optional[float] = None
|
|
143
|
+
font_size: Optional[float] = None
|
|
144
|
+
font_family: Optional[str] = None
|
|
145
|
+
font_path: Optional[str] = None
|
|
146
|
+
color: str = "black"
|
|
147
|
+
bg: str = "transparent"
|
|
148
|
+
padding: int = 8
|
|
149
|
+
scale: float = 1.0
|
|
150
|
+
zoom: Optional[float] = None
|
|
151
|
+
extra_pad_axes: float = 0.02
|
|
152
|
+
zorder: int = 5
|
|
153
|
+
rotation: float = 0.0
|
|
154
|
+
ha: str = "center"
|
|
155
|
+
va: str = "top"
|
|
156
|
+
hide_native: bool = True
|
|
157
|
+
collision_avoidance: bool = True
|
|
158
|
+
|
|
159
|
+
artists: List[Any] = field(default_factory=list)
|
|
160
|
+
last_sizes_px: List[Tuple[float, float]] = field(default_factory=list)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
164
|
+
# Layout manager
|
|
165
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
class UniversalLayoutManager:
|
|
168
|
+
"""
|
|
169
|
+
ylabel placement — colorbar-aware, correct for any subplot position
|
|
170
|
+
─────────────────────────────────────────────────────────────────────
|
|
171
|
+
Position formula:
|
|
172
|
+
cx_px = strip_right - GAP - screen_width / 2
|
|
173
|
+
|
|
174
|
+
where strip_right = ytick labels left edge (renderer-measured,
|
|
175
|
+
post-colorbar accurate).
|
|
176
|
+
|
|
177
|
+
The x position is clamped to stay left of the axes left edge
|
|
178
|
+
(NOT to a fixed figure fraction like 0.48 — that was wrong for
|
|
179
|
+
right-side subplots where the correct x fraction can be > 0.48).
|
|
180
|
+
|
|
181
|
+
Colorbar detection: if any other axes has its right edge within
|
|
182
|
+
COLORBAR_OVERLAP_TOL px of the ytick left edge, AND the remaining
|
|
183
|
+
space is < MIN_YLABEL_STRIP_PX, the ylabel is skipped.
|
|
184
|
+
|
|
185
|
+
User zoom is used exactly — no auto-zoom manipulation.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
X_TICK_GAP_PX = 4.0
|
|
189
|
+
Y_TICK_GAP_PX = 4.0
|
|
190
|
+
X_LABEL_BASE_GAP_PX = 10.0
|
|
191
|
+
Y_LABEL_BASE_GAP_PX = 14.0
|
|
192
|
+
TITLE_TO_AXES_GAP_PX = 10.0
|
|
193
|
+
SUPTITLE_GAP_PX = 12.0
|
|
194
|
+
LEFT_MARGIN_SAFETY_PX = 10.0
|
|
195
|
+
BOTTOM_MARGIN_SAFETY_PX = 10.0
|
|
196
|
+
TOP_MARGIN_SAFETY_PX = 12.0
|
|
197
|
+
|
|
198
|
+
# Any other axes whose right edge is within this px of ytick_left
|
|
199
|
+
# is treated as potentially blocking the ylabel strip.
|
|
200
|
+
COLORBAR_OVERLAP_TOL = 60.0
|
|
201
|
+
|
|
202
|
+
# Skip ylabel if clear space left of ytick labels is < this.
|
|
203
|
+
MIN_YLABEL_STRIP_PX = 20.0
|
|
204
|
+
|
|
205
|
+
def __init__(self, fig):
|
|
206
|
+
self.fig = fig
|
|
207
|
+
self.items: List[ManagedTextItem] = []
|
|
208
|
+
self.tick_groups: List[ManagedTickGroup] = []
|
|
209
|
+
self._draw_cid = None
|
|
210
|
+
self._resize_cid = None
|
|
211
|
+
self._is_updating = False
|
|
212
|
+
self._orig_savefig = None
|
|
213
|
+
self._connect_events()
|
|
214
|
+
self._wrap_savefig()
|
|
215
|
+
|
|
216
|
+
# ── events ──────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
def _connect_events(self):
|
|
219
|
+
c = self.fig.canvas
|
|
220
|
+
if c is None: return
|
|
221
|
+
self._draw_cid = c.mpl_connect("draw_event", self._on_draw_event)
|
|
222
|
+
self._resize_cid = c.mpl_connect("resize_event", self._on_resize_event)
|
|
223
|
+
|
|
224
|
+
def _wrap_savefig(self):
|
|
225
|
+
"""
|
|
226
|
+
Refresh managed placements immediately before every save.
|
|
227
|
+
|
|
228
|
+
Aspect-locked axes (imshow) and colorbars change the axes box at
|
|
229
|
+
draw time, after labels were placed. Layout cannot run DURING
|
|
230
|
+
the save (see _on_draw_event), so it runs here: update_layout()
|
|
231
|
+
draws once on the normal-dpi canvas, measures the final axes
|
|
232
|
+
geometry, and re-places everything; the save then renders those
|
|
233
|
+
placements untouched.
|
|
234
|
+
"""
|
|
235
|
+
if getattr(self.fig, "_ur_savefig_wrapped", False):
|
|
236
|
+
return
|
|
237
|
+
self._orig_savefig = self.fig.savefig
|
|
238
|
+
manager = self
|
|
239
|
+
|
|
240
|
+
def savefig_with_layout(*args, **kwargs):
|
|
241
|
+
try:
|
|
242
|
+
manager.update_layout()
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
return manager._orig_savefig(*args, **kwargs)
|
|
246
|
+
|
|
247
|
+
self.fig.savefig = savefig_with_layout
|
|
248
|
+
self.fig._ur_savefig_wrapped = True
|
|
249
|
+
|
|
250
|
+
def _unwrap_savefig(self):
|
|
251
|
+
if self._orig_savefig is not None:
|
|
252
|
+
try:
|
|
253
|
+
self.fig.savefig = self._orig_savefig
|
|
254
|
+
self.fig._ur_savefig_wrapped = False
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
self._orig_savefig = None
|
|
258
|
+
|
|
259
|
+
def disconnect(self):
|
|
260
|
+
self._unwrap_savefig()
|
|
261
|
+
c = self.fig.canvas
|
|
262
|
+
if c is None: return
|
|
263
|
+
for attr in ("_draw_cid", "_resize_cid"):
|
|
264
|
+
cid = getattr(self, attr, None)
|
|
265
|
+
if cid is not None:
|
|
266
|
+
try: c.mpl_disconnect(cid)
|
|
267
|
+
except Exception: pass
|
|
268
|
+
setattr(self, attr, None)
|
|
269
|
+
|
|
270
|
+
def _is_saving(self) -> bool:
|
|
271
|
+
"""True while the canvas is inside savefig/print_figure."""
|
|
272
|
+
c = self.fig.canvas
|
|
273
|
+
checker = getattr(c, "is_saving", None)
|
|
274
|
+
if callable(checker):
|
|
275
|
+
try:
|
|
276
|
+
return bool(checker())
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
return bool(getattr(c, "_is_saving", False))
|
|
280
|
+
|
|
281
|
+
def _on_draw_event(self, event):
|
|
282
|
+
# Never re-place during savefig: with bbox_inches="tight" the
|
|
283
|
+
# crop window is computed from one draw and applied to another,
|
|
284
|
+
# and fig.dpi is temporarily the save dpi — any placement change
|
|
285
|
+
# in between shifts artists out of the crop. The pre-save layout
|
|
286
|
+
# refresh happens in the savefig wrapper instead.
|
|
287
|
+
if self._is_saving(): return
|
|
288
|
+
if not self._is_updating: self.update_layout()
|
|
289
|
+
|
|
290
|
+
def _on_resize_event(self, _):
|
|
291
|
+
if self._is_saving(): return
|
|
292
|
+
if not self._is_updating: self.update_layout()
|
|
293
|
+
|
|
294
|
+
# ── registration ────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
def _make_item(self, kind, text, ax=None, fig=None, **kw):
|
|
297
|
+
return ManagedTextItem(
|
|
298
|
+
kind=kind, text=text, ax=ax, fig=fig,
|
|
299
|
+
fontsize=kw.get("fontsize"),
|
|
300
|
+
font_size=kw.get("font_size"),
|
|
301
|
+
font_family=kw.get("font_family"),
|
|
302
|
+
font_path=kw.get("font_path"),
|
|
303
|
+
color=kw.get("color", "black"),
|
|
304
|
+
padding=kw.get("padding", 10),
|
|
305
|
+
scale=kw.get("scale", 1.0),
|
|
306
|
+
zoom=kw.get("zoom", 0.40),
|
|
307
|
+
extra_pad_px=kw.get("extra_pad_px", 8.0),
|
|
308
|
+
zorder=kw.get("zorder", 5),
|
|
309
|
+
weight=kw.get("weight"),
|
|
310
|
+
italic=kw.get("italic", False),
|
|
311
|
+
alpha=kw.get("alpha", 1.0),
|
|
312
|
+
loc=kw.get("loc", "center"),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def add_title(self, ax, text, **kw):
|
|
316
|
+
self._rm_item("title", ax=ax)
|
|
317
|
+
it = self._make_item("title", text, ax=ax, fig=ax.figure, **kw)
|
|
318
|
+
self.items.append(it); ax.set_title("")
|
|
319
|
+
self.update_layout(); return it
|
|
320
|
+
|
|
321
|
+
def add_xlabel(self, ax, text, **kw):
|
|
322
|
+
self._rm_item("xlabel", ax=ax)
|
|
323
|
+
it = self._make_item("xlabel", text, ax=ax, fig=ax.figure, **kw)
|
|
324
|
+
self.items.append(it); ax.set_xlabel("")
|
|
325
|
+
self.update_layout(); return it
|
|
326
|
+
|
|
327
|
+
def add_ylabel(self, ax, text, **kw):
|
|
328
|
+
self._rm_item("ylabel", ax=ax)
|
|
329
|
+
it = self._make_item("ylabel", text, ax=ax, fig=ax.figure, **kw)
|
|
330
|
+
self.items.append(it); ax.set_ylabel("")
|
|
331
|
+
self.update_layout(); return it
|
|
332
|
+
|
|
333
|
+
def add_suptitle(self, fig, text, **kw):
|
|
334
|
+
self._rm_item("suptitle", fig=fig)
|
|
335
|
+
it = self._make_item("suptitle", text, ax=None, fig=fig, **kw)
|
|
336
|
+
self.items.append(it)
|
|
337
|
+
try: fig.suptitle("")
|
|
338
|
+
except Exception: pass
|
|
339
|
+
self.update_layout(); return it
|
|
340
|
+
|
|
341
|
+
def add_xticks(self, ax, positions, labels, **kw):
|
|
342
|
+
self._rm_tick("xticks", ax)
|
|
343
|
+
g = ManagedTickGroup(
|
|
344
|
+
kind="xticks", ax=ax,
|
|
345
|
+
positions=list(positions), labels=list(labels),
|
|
346
|
+
fontsize=kw.get("fontsize"), font_size=kw.get("font_size"),
|
|
347
|
+
font_family=kw.get("font_family"), font_path=kw.get("font_path"),
|
|
348
|
+
color=kw.get("color", "black"), bg=kw.get("bg", "transparent"),
|
|
349
|
+
padding=kw.get("padding", 8), scale=kw.get("scale", 1.0),
|
|
350
|
+
zoom=kw.get("zoom"),
|
|
351
|
+
extra_pad_axes=kw.get("extra_pad_axes", 0.02),
|
|
352
|
+
zorder=kw.get("zorder", 5),
|
|
353
|
+
rotation=kw.get("rotation", 0.0),
|
|
354
|
+
ha=kw.get("ha", "center"), va=kw.get("va", "top"),
|
|
355
|
+
hide_native=kw.get("hide_native", True),
|
|
356
|
+
collision_avoidance=kw.get("collision_avoidance", True),
|
|
357
|
+
)
|
|
358
|
+
self.tick_groups.append(g)
|
|
359
|
+
ax.set_xticks(list(positions))
|
|
360
|
+
if g.hide_native: ax.set_xticklabels([])
|
|
361
|
+
self.update_layout(); return g
|
|
362
|
+
|
|
363
|
+
def add_yticks(self, ax, positions, labels, **kw):
|
|
364
|
+
self._rm_tick("yticks", ax)
|
|
365
|
+
g = ManagedTickGroup(
|
|
366
|
+
kind="yticks", ax=ax,
|
|
367
|
+
positions=list(positions), labels=list(labels),
|
|
368
|
+
fontsize=kw.get("fontsize"), font_size=kw.get("font_size"),
|
|
369
|
+
font_family=kw.get("font_family"), font_path=kw.get("font_path"),
|
|
370
|
+
color=kw.get("color", "black"), bg=kw.get("bg", "transparent"),
|
|
371
|
+
padding=kw.get("padding", 8), scale=kw.get("scale", 1.0),
|
|
372
|
+
zoom=kw.get("zoom"),
|
|
373
|
+
extra_pad_axes=kw.get("extra_pad_axes", 0.02),
|
|
374
|
+
zorder=kw.get("zorder", 5),
|
|
375
|
+
rotation=kw.get("rotation", 0.0),
|
|
376
|
+
ha=kw.get("ha", "right"), va=kw.get("va", "center"),
|
|
377
|
+
hide_native=kw.get("hide_native", True),
|
|
378
|
+
collision_avoidance=kw.get("collision_avoidance", True),
|
|
379
|
+
)
|
|
380
|
+
self.tick_groups.append(g)
|
|
381
|
+
ax.set_yticks(list(positions))
|
|
382
|
+
if g.hide_native: ax.set_yticklabels([])
|
|
383
|
+
self.update_layout(); return g
|
|
384
|
+
|
|
385
|
+
# ── removal ─────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
def _rm_item(self, kind, ax=None, fig=None):
|
|
388
|
+
for it in [x for x in self.items if x.kind == kind and (
|
|
389
|
+
(ax is not None and x.ax is ax) or
|
|
390
|
+
(fig is not None and x.fig is fig))]:
|
|
391
|
+
self.remove_item(it)
|
|
392
|
+
|
|
393
|
+
def _rm_tick(self, kind, ax):
|
|
394
|
+
for g in [x for x in self.tick_groups if x.kind == kind and x.ax is ax]:
|
|
395
|
+
self.remove_tick_group(g)
|
|
396
|
+
|
|
397
|
+
def remove_item(self, it):
|
|
398
|
+
_remove_artist_safe(it.artist); it.artist = None
|
|
399
|
+
try: self.items.remove(it)
|
|
400
|
+
except ValueError: pass
|
|
401
|
+
|
|
402
|
+
def remove_tick_group(self, g):
|
|
403
|
+
for a in _ensure_list(g.artists): _remove_artist_safe(a)
|
|
404
|
+
g.artists = []; g.last_sizes_px = []
|
|
405
|
+
try: self.tick_groups.remove(g)
|
|
406
|
+
except ValueError: pass
|
|
407
|
+
|
|
408
|
+
def clear(self):
|
|
409
|
+
for it in self.items:
|
|
410
|
+
_remove_artist_safe(it.artist); it.artist = None
|
|
411
|
+
self.items.clear()
|
|
412
|
+
for g in self.tick_groups:
|
|
413
|
+
for a in _ensure_list(g.artists): _remove_artist_safe(a)
|
|
414
|
+
g.artists = []; g.last_sizes_px = []
|
|
415
|
+
self.tick_groups.clear()
|
|
416
|
+
|
|
417
|
+
# ── rendering ────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
def _default_fs(self, kind):
|
|
420
|
+
return {"title": 32, "xlabel": 26, "ylabel": 26,
|
|
421
|
+
"suptitle": 34, "xticks": 16, "yticks": 16}.get(kind, 24)
|
|
422
|
+
|
|
423
|
+
def _render_item_image(self, item):
|
|
424
|
+
fs = _resolve_font_size(item.fontsize, item.font_size,
|
|
425
|
+
self._default_fs(item.kind))
|
|
426
|
+
q = render_text_qimage(
|
|
427
|
+
item.text, font_family=item.font_family, font_path=item.font_path,
|
|
428
|
+
font_size=fs, color=item.color, bg="transparent",
|
|
429
|
+
padding=item.padding, scale=item.scale,
|
|
430
|
+
weight=item.weight, italic=item.italic, alpha=item.alpha,
|
|
431
|
+
)
|
|
432
|
+
img = _qimage_to_rgba_array(q)
|
|
433
|
+
rw = q.width() * item.zoom
|
|
434
|
+
rh = q.height() * item.zoom
|
|
435
|
+
item.last_image_size_px = (q.width(), q.height())
|
|
436
|
+
item.last_render_size_px = (rw, rh)
|
|
437
|
+
return fs, q, img, rw, rh
|
|
438
|
+
|
|
439
|
+
def _render_tick_image(self, g, label):
|
|
440
|
+
fs = _resolve_font_size(g.fontsize, g.font_size, self._default_fs(g.kind))
|
|
441
|
+
q = render_text_qimage(
|
|
442
|
+
label, font_family=g.font_family, font_path=g.font_path,
|
|
443
|
+
font_size=fs, color=g.color, bg=g.bg,
|
|
444
|
+
padding=g.padding, scale=g.scale,
|
|
445
|
+
rotation=g.rotation,
|
|
446
|
+
)
|
|
447
|
+
img = _qimage_to_rgba_array(q)
|
|
448
|
+
z = g.zoom if g.zoom is not None else 0.40
|
|
449
|
+
return fs, q, img, q.width() * z, q.height() * z
|
|
450
|
+
|
|
451
|
+
# ── lookup ───────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
def _get_item(self, kind, ax=None, fig=None):
|
|
454
|
+
for it in self.items:
|
|
455
|
+
if it.kind != kind: continue
|
|
456
|
+
if ax is not None and it.ax is ax: return it
|
|
457
|
+
if fig is not None and it.fig is fig: return it
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
def _get_tick_group(self, kind, ax):
|
|
461
|
+
for g in self.tick_groups:
|
|
462
|
+
if g.kind == kind and g.ax is ax: return g
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
def _get_managed_axes(self):
|
|
466
|
+
axes = []
|
|
467
|
+
for it in self.items:
|
|
468
|
+
if it.ax is not None and it.ax not in axes: axes.append(it.ax)
|
|
469
|
+
for g in self.tick_groups:
|
|
470
|
+
if g.ax is not None and g.ax not in axes: axes.append(g.ax)
|
|
471
|
+
return axes
|
|
472
|
+
|
|
473
|
+
# ── renderer ────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
def _get_renderer(self):
|
|
476
|
+
"""
|
|
477
|
+
Draw first so renderer reflects post-colorbar positions.
|
|
478
|
+
Without canvas.draw(), colorbar axes positions are stale.
|
|
479
|
+
"""
|
|
480
|
+
c = self.fig.canvas
|
|
481
|
+
if c is None: return None
|
|
482
|
+
try: c.draw()
|
|
483
|
+
except Exception: pass
|
|
484
|
+
try: return c.get_renderer()
|
|
485
|
+
except Exception: return None
|
|
486
|
+
|
|
487
|
+
# ── bbox helpers ─────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
def _artist_bbox(self, artist, r):
|
|
490
|
+
if artist is None or r is None: return None
|
|
491
|
+
try:
|
|
492
|
+
b = artist.get_window_extent(r)
|
|
493
|
+
return b if (b and b.width > 0 and b.height > 0) else None
|
|
494
|
+
except Exception: return None
|
|
495
|
+
|
|
496
|
+
def _group_bbox(self, g, r):
|
|
497
|
+
if g is None: return None
|
|
498
|
+
return _union_bboxes([self._artist_bbox(a, r) for a in _ensure_list(g.artists)])
|
|
499
|
+
|
|
500
|
+
def _native_tick_bbox(self, ax, axis, r):
|
|
501
|
+
if r is None: return None
|
|
502
|
+
lbls = ax.get_xticklabels() if axis == "x" else ax.get_yticklabels()
|
|
503
|
+
bbs = []
|
|
504
|
+
for lbl in lbls:
|
|
505
|
+
try:
|
|
506
|
+
if not lbl.get_visible() or not lbl.get_text(): continue
|
|
507
|
+
b = lbl.get_window_extent(r)
|
|
508
|
+
if b and b.width > 0 and b.height > 0: bbs.append(b)
|
|
509
|
+
except Exception: pass
|
|
510
|
+
return _union_bboxes(bbs)
|
|
511
|
+
|
|
512
|
+
def _total_x_tick_bbox(self, ax, r):
|
|
513
|
+
return _union_bboxes([
|
|
514
|
+
self._group_bbox(self._get_tick_group("xticks", ax), r),
|
|
515
|
+
self._native_tick_bbox(ax, "x", r),
|
|
516
|
+
])
|
|
517
|
+
|
|
518
|
+
def _total_y_tick_bbox(self, ax, r):
|
|
519
|
+
return _union_bboxes([
|
|
520
|
+
self._group_bbox(self._get_tick_group("yticks", ax), r),
|
|
521
|
+
self._native_tick_bbox(ax, "y", r),
|
|
522
|
+
])
|
|
523
|
+
|
|
524
|
+
def _x_tick_outward_px(self, ax, r):
|
|
525
|
+
b = self._total_x_tick_bbox(ax, r)
|
|
526
|
+
if b is None: return 0.0
|
|
527
|
+
try: return max(0.0, ax.get_window_extent(r).y0 - b.y0)
|
|
528
|
+
except Exception: return 0.0
|
|
529
|
+
|
|
530
|
+
# ── colorbar detection ───────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
def _ylabel_blocked(self, ax, r, cx_px: float, screen_w: float) -> bool:
|
|
533
|
+
"""
|
|
534
|
+
Return True if placing the ylabel at cx_px would overlap any
|
|
535
|
+
other axes (including colorbar axes with their tick labels).
|
|
536
|
+
|
|
537
|
+
Uses two tests:
|
|
538
|
+
1. Direct image bbox collision with any other axes bbox.
|
|
539
|
+
2. Whether cx_px falls inside the gap between any other axes
|
|
540
|
+
and the ytick labels — i.e. cx_px is to the RIGHT of any
|
|
541
|
+
other axes left edge and LEFT of the ytick left edge.
|
|
542
|
+
This catches colorbars whose axes bbox (including tick labels)
|
|
543
|
+
extends past cx_px even if the colorbar bar itself does not.
|
|
544
|
+
"""
|
|
545
|
+
label_left = cx_px - screen_w / 2.0 - 2.0
|
|
546
|
+
label_right = cx_px + screen_w / 2.0 + 2.0
|
|
547
|
+
|
|
548
|
+
for other in self.fig.axes:
|
|
549
|
+
if other is ax: continue
|
|
550
|
+
try:
|
|
551
|
+
ob = other.get_window_extent(r)
|
|
552
|
+
except Exception: continue
|
|
553
|
+
if ob.width <= 0 or ob.height <= 0: continue
|
|
554
|
+
|
|
555
|
+
# Test 1: direct bbox overlap
|
|
556
|
+
if label_right > ob.x0 and label_left < ob.x1:
|
|
557
|
+
return True
|
|
558
|
+
|
|
559
|
+
# Test 2: cx_px is inside the gap between this axes and ours.
|
|
560
|
+
# If another axes has its LEFT edge to the LEFT of cx_px,
|
|
561
|
+
# it means cx_px is in the territory of or to the right of
|
|
562
|
+
# that other axes — which means we are in the gap region
|
|
563
|
+
# between that axes and the ytick labels.
|
|
564
|
+
# This catches colorbars that end before cx_px but whose
|
|
565
|
+
# visual presence occupies the gap.
|
|
566
|
+
if ob.x0 < cx_px and ob.x1 > label_left:
|
|
567
|
+
return True
|
|
568
|
+
|
|
569
|
+
return False
|
|
570
|
+
|
|
571
|
+
# ── tick placement ───────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
def _tick_ba(self, ha, va):
|
|
574
|
+
hm = {"left": 0.0, "center": 0.5, "right": 1.0}
|
|
575
|
+
vm = {"bottom": 0.0, "baseline": 0.0,
|
|
576
|
+
"center": 0.5, "middle": 0.5, "top": 1.0}
|
|
577
|
+
return (hm.get((ha or "center").lower(), 0.5),
|
|
578
|
+
vm.get((va or "center").lower(), 0.5))
|
|
579
|
+
|
|
580
|
+
def _xpx(self, ax, pos):
|
|
581
|
+
pts = ax.transData.transform(
|
|
582
|
+
np.column_stack([pos, np.zeros(len(pos))]))
|
|
583
|
+
return [float(p[0]) for p in pts]
|
|
584
|
+
|
|
585
|
+
def _ypx(self, ax, pos):
|
|
586
|
+
pts = ax.transData.transform(
|
|
587
|
+
np.column_stack([np.zeros(len(pos)), pos]))
|
|
588
|
+
return [float(p[1]) for p in pts]
|
|
589
|
+
|
|
590
|
+
@staticmethod
|
|
591
|
+
def _filt_extents(centers, extents, gap=6.0):
|
|
592
|
+
"""
|
|
593
|
+
Greedy 1-D overlap filter. Processes labels in ascending center
|
|
594
|
+
order (positions may arrive descending, e.g. imshow with
|
|
595
|
+
origin="upper") and hides any label whose span would overlap the
|
|
596
|
+
previously kept one.
|
|
597
|
+
"""
|
|
598
|
+
vis = [True] * len(centers)
|
|
599
|
+
last = None
|
|
600
|
+
for i in sorted(range(len(centers)), key=lambda k: centers[k]):
|
|
601
|
+
lo = centers[i] - extents[i] / 2
|
|
602
|
+
hi = centers[i] + extents[i] / 2
|
|
603
|
+
if last is None or lo >= last + gap:
|
|
604
|
+
last = hi
|
|
605
|
+
else:
|
|
606
|
+
vis[i] = False
|
|
607
|
+
return vis
|
|
608
|
+
|
|
609
|
+
def _filt_x(self, cx, wx, gap=6.0):
|
|
610
|
+
return self._filt_extents(cx, wx, gap)
|
|
611
|
+
|
|
612
|
+
def _filt_y(self, cy, hy, gap=6.0):
|
|
613
|
+
return self._filt_extents(cy, hy, gap)
|
|
614
|
+
|
|
615
|
+
def _place_xticks(self, g):
|
|
616
|
+
ax = g.ax
|
|
617
|
+
for a in _ensure_list(g.artists): _remove_artist_safe(a)
|
|
618
|
+
g.artists = []; g.last_sizes_px = []
|
|
619
|
+
if g.hide_native: ax.set_xticklabels([])
|
|
620
|
+
ax.set_xticks(list(g.positions))
|
|
621
|
+
trans = ax.get_xaxis_transform()
|
|
622
|
+
ba = self._tick_ba(g.ha, g.va)
|
|
623
|
+
gap = _pixels_to_axes_dy(ax, self.X_TICK_GAP_PX)
|
|
624
|
+
pays = [self._render_tick_image(g, lbl) for lbl in g.labels]
|
|
625
|
+
vis = ([True] * len(g.positions) if not g.collision_avoidance
|
|
626
|
+
else self._filt_x(self._xpx(ax, g.positions), [p[3] for p in pays]))
|
|
627
|
+
for i, pos in enumerate(g.positions):
|
|
628
|
+
if not vis[i]: continue
|
|
629
|
+
fs, _, img, dw, dh = pays[i]
|
|
630
|
+
z = g.zoom if g.zoom is not None else 0.40
|
|
631
|
+
ab = AnnotationBbox(OffsetImage(img, zoom=z), (pos, -gap),
|
|
632
|
+
xycoords=trans, frameon=False,
|
|
633
|
+
box_alignment=ba, zorder=g.zorder,
|
|
634
|
+
annotation_clip=False)
|
|
635
|
+
ax.add_artist(ab)
|
|
636
|
+
g.artists.append(ab); g.last_sizes_px.append((dw, dh))
|
|
637
|
+
|
|
638
|
+
def _place_yticks(self, g):
|
|
639
|
+
ax = g.ax
|
|
640
|
+
for a in _ensure_list(g.artists): _remove_artist_safe(a)
|
|
641
|
+
g.artists = []; g.last_sizes_px = []
|
|
642
|
+
if g.hide_native: ax.set_yticklabels([])
|
|
643
|
+
ax.set_yticks(list(g.positions))
|
|
644
|
+
trans = ax.get_yaxis_transform()
|
|
645
|
+
ba = self._tick_ba(g.ha, g.va)
|
|
646
|
+
gap = _pixels_to_axes_dx(ax, self.Y_TICK_GAP_PX)
|
|
647
|
+
pays = [self._render_tick_image(g, lbl) for lbl in g.labels]
|
|
648
|
+
vis = ([True] * len(g.positions) if not g.collision_avoidance
|
|
649
|
+
else self._filt_y(self._ypx(ax, g.positions), [p[4] for p in pays]))
|
|
650
|
+
for i, pos in enumerate(g.positions):
|
|
651
|
+
if not vis[i]: continue
|
|
652
|
+
fs, _, img, dw, dh = pays[i]
|
|
653
|
+
z = g.zoom if g.zoom is not None else 0.40
|
|
654
|
+
ab = AnnotationBbox(OffsetImage(img, zoom=z), (-gap, pos),
|
|
655
|
+
xycoords=trans, frameon=False,
|
|
656
|
+
box_alignment=ba, zorder=g.zorder,
|
|
657
|
+
annotation_clip=False)
|
|
658
|
+
ax.add_artist(ab)
|
|
659
|
+
g.artists.append(ab); g.last_sizes_px.append((dw, dh))
|
|
660
|
+
|
|
661
|
+
# ── label / title placement ──────────────────────────────────────
|
|
662
|
+
|
|
663
|
+
def _place_title(self, item):
|
|
664
|
+
ax, fig = item.ax, item.ax.figure
|
|
665
|
+
bp = ax.get_position()
|
|
666
|
+
_, _, img, _, rh = self._render_item_image(item)
|
|
667
|
+
gap_px = self.TITLE_TO_AXES_GAP_PX + item.extra_pad_px
|
|
668
|
+
# Anchor the title's BOTTOM edge at a fixed gap above the axes,
|
|
669
|
+
# so tall glyphs (conjunct stacks, ascenders) grow upward instead
|
|
670
|
+
# of pushing the title into the axes frame.
|
|
671
|
+
y = min(0.985, bp.y1 + _pixels_to_fig_dy(fig, gap_px))
|
|
672
|
+
|
|
673
|
+
loc = (item.loc or "center").lower()
|
|
674
|
+
if loc == "left":
|
|
675
|
+
x, ba_x = bp.x0, 0.0
|
|
676
|
+
elif loc == "right":
|
|
677
|
+
x, ba_x = bp.x1, 1.0
|
|
678
|
+
else:
|
|
679
|
+
x, ba_x = bp.x0 + bp.width / 2.0, 0.5
|
|
680
|
+
|
|
681
|
+
ab = AnnotationBbox(
|
|
682
|
+
OffsetImage(img, zoom=item.zoom),
|
|
683
|
+
(x, y),
|
|
684
|
+
xycoords=fig.transFigure, frameon=False,
|
|
685
|
+
box_alignment=(ba_x, 0.0), zorder=item.zorder,
|
|
686
|
+
)
|
|
687
|
+
fig.add_artist(ab); return ab
|
|
688
|
+
|
|
689
|
+
def _place_suptitle(self, item, r):
|
|
690
|
+
"""
|
|
691
|
+
Place the figure suptitle above everything: axes tops, per-axes
|
|
692
|
+
titles, and any artists already measured by the renderer.
|
|
693
|
+
"""
|
|
694
|
+
fig = item.fig
|
|
695
|
+
_, fh_r = _renderer_px_size(fig, r)
|
|
696
|
+
|
|
697
|
+
# Highest occupied point, as a figure fraction (renderer units
|
|
698
|
+
# normalized by the renderer canvas so savefig dpi cancels out).
|
|
699
|
+
top_frac = 0.0
|
|
700
|
+
for ax in fig.axes:
|
|
701
|
+
try:
|
|
702
|
+
top_frac = max(top_frac, ax.get_window_extent(r).y1 / fh_r)
|
|
703
|
+
except Exception:
|
|
704
|
+
top_frac = max(top_frac, ax.get_position().y1)
|
|
705
|
+
for other in self.items:
|
|
706
|
+
if other.kind == "title" and other.artist is not None:
|
|
707
|
+
bb = self._artist_bbox(other.artist, r)
|
|
708
|
+
if bb is not None:
|
|
709
|
+
top_frac = max(top_frac, bb.y1 / fh_r)
|
|
710
|
+
|
|
711
|
+
_, _, img, _, rh = self._render_item_image(item)
|
|
712
|
+
gap_frac = _pixels_to_fig_dy(fig, self.SUPTITLE_GAP_PX + item.extra_pad_px)
|
|
713
|
+
y = min(0.995, top_frac + gap_frac)
|
|
714
|
+
|
|
715
|
+
ab = AnnotationBbox(
|
|
716
|
+
OffsetImage(img, zoom=item.zoom),
|
|
717
|
+
(0.5, y),
|
|
718
|
+
xycoords=fig.transFigure, frameon=False,
|
|
719
|
+
box_alignment=(0.5, 0.0), zorder=item.zorder,
|
|
720
|
+
)
|
|
721
|
+
fig.add_artist(ab); return ab
|
|
722
|
+
|
|
723
|
+
def _place_xlabel(self, item, r):
|
|
724
|
+
ax, fig = item.ax, item.ax.figure
|
|
725
|
+
bp = ax.get_position()
|
|
726
|
+
_, _, img, _, rh = self._render_item_image(item)
|
|
727
|
+
# tick_px is renderer-measured: normalize by the renderer canvas.
|
|
728
|
+
# Constant gaps and rendered sizes are in fig.dpi pixels.
|
|
729
|
+
_, fh_r = _renderer_px_size(fig, r)
|
|
730
|
+
tick_frac = self._x_tick_outward_px(ax, r) / max(1.0, fh_r)
|
|
731
|
+
const_frac = _pixels_to_fig_dy(
|
|
732
|
+
fig, self.X_LABEL_BASE_GAP_PX + item.extra_pad_px + rh / 2.0
|
|
733
|
+
)
|
|
734
|
+
y = max(0.0, bp.y0 - tick_frac - const_frac)
|
|
735
|
+
ab = AnnotationBbox(
|
|
736
|
+
OffsetImage(img, zoom=item.zoom),
|
|
737
|
+
(bp.x0 + bp.width / 2.0, y),
|
|
738
|
+
xycoords=fig.transFigure, frameon=False,
|
|
739
|
+
box_alignment=(0.5, 0.5), zorder=item.zorder,
|
|
740
|
+
)
|
|
741
|
+
fig.add_artist(ab); return ab
|
|
742
|
+
|
|
743
|
+
def _place_ylabel(self, item, r):
|
|
744
|
+
"""
|
|
745
|
+
Place the ylabel just left of the ytick labels.
|
|
746
|
+
|
|
747
|
+
Formula:
|
|
748
|
+
screen_width = img_height * zoom (image is rotated 90°)
|
|
749
|
+
cx_px = strip_right - GAP - screen_width / 2
|
|
750
|
+
|
|
751
|
+
where strip_right = ytick labels left edge (renderer-measured).
|
|
752
|
+
|
|
753
|
+
THE KEY FIX:
|
|
754
|
+
x_fig is clamped to [0.01, axes_left - small_margin]
|
|
755
|
+
NOT to a fixed 0.48 maximum.
|
|
756
|
+
Fixed 0.48 was wrong for right-side subplots where the
|
|
757
|
+
correct x fraction can be > 0.48.
|
|
758
|
+
|
|
759
|
+
Skips ylabel silently if a colorbar blocks the strip.
|
|
760
|
+
User zoom is used exactly — no manipulation.
|
|
761
|
+
"""
|
|
762
|
+
ax, fig = item.ax, item.ax.figure
|
|
763
|
+
fw_r, _ = _renderer_px_size(fig, r)
|
|
764
|
+
|
|
765
|
+
# ytick labels left edge as a figure fraction (renderer units
|
|
766
|
+
# over renderer canvas, so savefig dpi cancels out)
|
|
767
|
+
ytick_bb = self._total_y_tick_bbox(ax, r)
|
|
768
|
+
if ytick_bb is not None:
|
|
769
|
+
strip_frac = ytick_bb.x0 / fw_r
|
|
770
|
+
else:
|
|
771
|
+
try: strip_frac = ax.get_window_extent(r).x0 / fw_r
|
|
772
|
+
except Exception: strip_frac = ax.get_position().x0
|
|
773
|
+
|
|
774
|
+
# Render (user zoom unchanged)
|
|
775
|
+
_, _, img, _, _ = self._render_item_image(item)
|
|
776
|
+
rotated = np.rot90(img, k=1)
|
|
777
|
+
|
|
778
|
+
# After 90° rotation: screen_width = original_img_height * zoom
|
|
779
|
+
# (fig.dpi pixels, so normalize with the fig.dpi canvas)
|
|
780
|
+
img_h = item.last_image_size_px[1]
|
|
781
|
+
screen_w_frac = _pixels_to_fig_dx(fig, img_h * item.zoom)
|
|
782
|
+
|
|
783
|
+
# Anchor the label's RIGHT edge a fixed gap left of the ytick
|
|
784
|
+
# strip. Right-edge anchoring means a cramped left margin makes
|
|
785
|
+
# the label encroach on the ticks (visible, recoverable via
|
|
786
|
+
# auto margins) instead of being pushed off the figure edge.
|
|
787
|
+
gap_frac = _pixels_to_fig_dx(fig, self.Y_LABEL_BASE_GAP_PX + item.extra_pad_px)
|
|
788
|
+
right_frac = strip_frac - gap_frac
|
|
789
|
+
# Keep the label fully inside the figure.
|
|
790
|
+
right_frac = max(screen_w_frac + 0.004, right_frac)
|
|
791
|
+
|
|
792
|
+
# Skip if the computed position overlaps any other axes (e.g. a
|
|
793
|
+
# colorbar sitting between subplots). Blocked-test coordinates
|
|
794
|
+
# are in renderer pixels.
|
|
795
|
+
cx_px = (right_frac - screen_w_frac / 2.0) * fw_r
|
|
796
|
+
if self._ylabel_blocked(ax, r, cx_px, screen_w_frac * fw_r):
|
|
797
|
+
return None
|
|
798
|
+
|
|
799
|
+
x_fig = right_frac
|
|
800
|
+
|
|
801
|
+
bp = ax.get_position()
|
|
802
|
+
y_fig = bp.y0 + bp.height / 2.0
|
|
803
|
+
|
|
804
|
+
ab = AnnotationBbox(
|
|
805
|
+
OffsetImage(rotated, zoom=item.zoom),
|
|
806
|
+
(x_fig, y_fig),
|
|
807
|
+
xycoords=fig.transFigure, frameon=False,
|
|
808
|
+
box_alignment=(1.0, 0.5), zorder=item.zorder,
|
|
809
|
+
)
|
|
810
|
+
fig.add_artist(ab)
|
|
811
|
+
item.last_render_size_px = (
|
|
812
|
+
img_h * item.zoom,
|
|
813
|
+
item.last_image_size_px[0] * item.zoom,
|
|
814
|
+
)
|
|
815
|
+
return ab
|
|
816
|
+
|
|
817
|
+
# ── full placement pass ──────────────────────────────────────────
|
|
818
|
+
|
|
819
|
+
def _place_all_artists(self):
|
|
820
|
+
for g in self.tick_groups:
|
|
821
|
+
if g.kind == "xticks": self._place_xticks(g)
|
|
822
|
+
elif g.kind == "yticks": self._place_yticks(g)
|
|
823
|
+
|
|
824
|
+
r = self._get_renderer()
|
|
825
|
+
|
|
826
|
+
for it in self.items:
|
|
827
|
+
if it.kind in ("title", "suptitle"): continue
|
|
828
|
+
_remove_artist_safe(it.artist); it.artist = None
|
|
829
|
+
if it.kind == "xlabel": it.artist = self._place_xlabel(it, r)
|
|
830
|
+
elif it.kind == "ylabel": it.artist = self._place_ylabel(it, r)
|
|
831
|
+
|
|
832
|
+
for it in self.items:
|
|
833
|
+
if it.kind != "title": continue
|
|
834
|
+
_remove_artist_safe(it.artist)
|
|
835
|
+
it.artist = self._place_title(it)
|
|
836
|
+
|
|
837
|
+
# Suptitles go last: they stack above the freshly placed titles.
|
|
838
|
+
suptitles = [it for it in self.items if it.kind == "suptitle"]
|
|
839
|
+
if suptitles:
|
|
840
|
+
r2 = self._get_renderer()
|
|
841
|
+
for it in suptitles:
|
|
842
|
+
_remove_artist_safe(it.artist)
|
|
843
|
+
it.artist = self._place_suptitle(it, r2)
|
|
844
|
+
|
|
845
|
+
def update_layout(self):
|
|
846
|
+
if self._is_updating: return
|
|
847
|
+
self._is_updating = True
|
|
848
|
+
try: self._place_all_artists()
|
|
849
|
+
finally: self._is_updating = False
|
|
850
|
+
|
|
851
|
+
# ── auto_adjust_margins ──────────────────────────────────────────
|
|
852
|
+
|
|
853
|
+
def auto_adjust_margins(
|
|
854
|
+
self,
|
|
855
|
+
min_left=0.12, min_right=0.90,
|
|
856
|
+
min_bottom=0.12, min_top=0.88,
|
|
857
|
+
extra_left_px=10.0, extra_bottom_px=10.0,
|
|
858
|
+
extra_top_px=18.0, extra_right_px=6.0,
|
|
859
|
+
):
|
|
860
|
+
fig = self.fig
|
|
861
|
+
self._place_all_artists()
|
|
862
|
+
try: fig.canvas.draw()
|
|
863
|
+
except Exception: pass
|
|
864
|
+
r = self._get_renderer()
|
|
865
|
+
if r is None: return
|
|
866
|
+
|
|
867
|
+
fw = fig.get_size_inches()[0] * fig.dpi
|
|
868
|
+
fh = fig.get_size_inches()[1] * fig.dpi
|
|
869
|
+
axes_seen = self._get_managed_axes()
|
|
870
|
+
|
|
871
|
+
left_px = 0.0; bot_px = 0.0
|
|
872
|
+
title_bbs = []
|
|
873
|
+
|
|
874
|
+
for ax in axes_seen:
|
|
875
|
+
axb = ax.get_window_extent(r)
|
|
876
|
+
xi = self._get_item("xlabel", ax=ax)
|
|
877
|
+
yi = self._get_item("ylabel", ax=ax)
|
|
878
|
+
ti = self._get_item("title", ax=ax)
|
|
879
|
+
xb = self._total_x_tick_bbox(ax, r)
|
|
880
|
+
yb = self._total_y_tick_bbox(ax, r)
|
|
881
|
+
xbb = self._artist_bbox(xi.artist, r) if xi else None
|
|
882
|
+
ybb = self._artist_bbox(yi.artist, r) if yi else None
|
|
883
|
+
title_bbs.append(self._artist_bbox(ti.artist, r) if ti else None)
|
|
884
|
+
si = self._get_item("suptitle", fig=self.fig)
|
|
885
|
+
if si is not None:
|
|
886
|
+
title_bbs.append(self._artist_bbox(si.artist, r))
|
|
887
|
+
|
|
888
|
+
lu = _union_bboxes([yb, ybb])
|
|
889
|
+
if lu:
|
|
890
|
+
left_px = max(left_px,
|
|
891
|
+
max(0.0, axb.x0 - lu.x0)
|
|
892
|
+
+ self.LEFT_MARGIN_SAFETY_PX + extra_left_px)
|
|
893
|
+
|
|
894
|
+
bu = _union_bboxes([xb, xbb])
|
|
895
|
+
if bu:
|
|
896
|
+
bot_px = max(bot_px,
|
|
897
|
+
max(0.0, axb.y0 - bu.y0)
|
|
898
|
+
+ self.BOTTOM_MARGIN_SAFETY_PX + extra_bottom_px)
|
|
899
|
+
|
|
900
|
+
left = max(min_left, left_px / max(1.0, fw) + 0.01)
|
|
901
|
+
bottom = max(min_bottom, bot_px / max(1.0, fh) + 0.01)
|
|
902
|
+
right = min_right
|
|
903
|
+
top = min_top
|
|
904
|
+
|
|
905
|
+
tu = _union_bboxes(title_bbs)
|
|
906
|
+
if tu:
|
|
907
|
+
prot = max(0.0, tu.y1 - fh)
|
|
908
|
+
if prot > 0:
|
|
909
|
+
top = min(top,
|
|
910
|
+
1.0 - (prot + self.TOP_MARGIN_SAFETY_PX + extra_top_px)
|
|
911
|
+
/ max(1.0, fh))
|
|
912
|
+
|
|
913
|
+
# Reserve headroom for the title/suptitle stack. Without this,
|
|
914
|
+
# a suptitle hits the figure-edge clamp in _place_suptitle and
|
|
915
|
+
# gets squashed against the title instead of sitting above it.
|
|
916
|
+
stack_px = 0.0
|
|
917
|
+
for ax in axes_seen:
|
|
918
|
+
ti = self._get_item("title", ax=ax)
|
|
919
|
+
if ti is not None:
|
|
920
|
+
_, _, _, _, rh = self._render_item_image(ti)
|
|
921
|
+
stack_px = max(
|
|
922
|
+
stack_px,
|
|
923
|
+
rh + self.TITLE_TO_AXES_GAP_PX + ti.extra_pad_px,
|
|
924
|
+
)
|
|
925
|
+
si = self._get_item("suptitle", fig=self.fig)
|
|
926
|
+
if si is not None:
|
|
927
|
+
_, _, _, _, rh_s = self._render_item_image(si)
|
|
928
|
+
stack_px += rh_s + self.SUPTITLE_GAP_PX + si.extra_pad_px
|
|
929
|
+
if stack_px > 0.0:
|
|
930
|
+
top = min(top, 1.0 - (stack_px + self.TOP_MARGIN_SAFETY_PX) / max(1.0, fh))
|
|
931
|
+
|
|
932
|
+
left = min(max(left, 0.0), 0.95) # wide upper bound for multi-subplot
|
|
933
|
+
right = min(max(right, 0.05), 1.0)
|
|
934
|
+
bottom = min(max(bottom, 0.0), 0.45)
|
|
935
|
+
top = min(max(top, 0.45), 1.0)
|
|
936
|
+
|
|
937
|
+
fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
|
|
938
|
+
self._place_all_artists()
|
|
939
|
+
try: fig.canvas.draw_idle()
|
|
940
|
+
except Exception: pass
|
|
941
|
+
|
|
942
|
+
def summary(self):
|
|
943
|
+
return {
|
|
944
|
+
"figure_id": id(self.fig),
|
|
945
|
+
"managed_items": [it.kind for it in self.items],
|
|
946
|
+
"managed_tick_groups":[g.kind for g in self.tick_groups],
|
|
947
|
+
"num_items": len(self.items),
|
|
948
|
+
"num_tick_groups": len(self.tick_groups),
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
953
|
+
# Public helpers
|
|
954
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
955
|
+
|
|
956
|
+
def get_layout_manager(fig) -> UniversalLayoutManager:
|
|
957
|
+
key = id(fig)
|
|
958
|
+
if key not in _MANAGER_REGISTRY:
|
|
959
|
+
_MANAGER_REGISTRY[key] = UniversalLayoutManager(fig)
|
|
960
|
+
return _MANAGER_REGISTRY[key]
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def clear_layout_manager(fig) -> None:
|
|
964
|
+
key = id(fig)
|
|
965
|
+
m = _MANAGER_REGISTRY.pop(key, None)
|
|
966
|
+
if m is not None:
|
|
967
|
+
m.clear(); m.disconnect()
|