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.
@@ -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()