batplot 1.8.1__py3-none-any.whl → 1.8.3__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.

Potentially problematic release.


This version of batplot might be problematic. Click here for more details.

Files changed (42) hide show
  1. batplot/__init__.py +1 -1
  2. batplot/args.py +2 -0
  3. batplot/batch.py +23 -0
  4. batplot/batplot.py +101 -12
  5. batplot/cpc_interactive.py +25 -3
  6. batplot/electrochem_interactive.py +20 -4
  7. batplot/interactive.py +19 -15
  8. batplot/modes.py +12 -12
  9. batplot/operando_ec_interactive.py +4 -4
  10. batplot/session.py +218 -0
  11. batplot/style.py +21 -2
  12. batplot/version_check.py +1 -1
  13. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/METADATA +1 -1
  14. batplot-1.8.3.dist-info/RECORD +75 -0
  15. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/top_level.txt +1 -0
  16. batplot_backup_20251221_101150/__init__.py +5 -0
  17. batplot_backup_20251221_101150/args.py +625 -0
  18. batplot_backup_20251221_101150/batch.py +1176 -0
  19. batplot_backup_20251221_101150/batplot.py +3589 -0
  20. batplot_backup_20251221_101150/cif.py +823 -0
  21. batplot_backup_20251221_101150/cli.py +149 -0
  22. batplot_backup_20251221_101150/color_utils.py +547 -0
  23. batplot_backup_20251221_101150/config.py +198 -0
  24. batplot_backup_20251221_101150/converters.py +204 -0
  25. batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
  26. batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
  27. batplot_backup_20251221_101150/interactive.py +3894 -0
  28. batplot_backup_20251221_101150/manual.py +323 -0
  29. batplot_backup_20251221_101150/modes.py +799 -0
  30. batplot_backup_20251221_101150/operando.py +603 -0
  31. batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
  32. batplot_backup_20251221_101150/plotting.py +228 -0
  33. batplot_backup_20251221_101150/readers.py +2607 -0
  34. batplot_backup_20251221_101150/session.py +2951 -0
  35. batplot_backup_20251221_101150/style.py +1441 -0
  36. batplot_backup_20251221_101150/ui.py +790 -0
  37. batplot_backup_20251221_101150/utils.py +1046 -0
  38. batplot_backup_20251221_101150/version_check.py +253 -0
  39. batplot-1.8.1.dist-info/RECORD +0 -52
  40. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/WHEEL +0 -0
  41. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/entry_points.txt +0 -0
  42. {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,790 @@
1
+ """UI utilities for batplot: font/tick helpers and resize operations.
2
+
3
+ This module provides functions for managing fonts, tick labels, and axis labels
4
+ in batplot plots. It handles:
5
+ - Font family and size changes across all text elements
6
+ - Positioning of duplicate axis labels (top x-axis, right y-axis)
7
+ - Font synchronization to ensure consistency
8
+
9
+ HOW FONT MANAGEMENT WORKS:
10
+ --------------------------
11
+ When you change fonts in the interactive menu, we need to update fonts for:
12
+ - Curve labels (text objects identifying each curve)
13
+ - Axis labels (x-axis and y-axis titles)
14
+ - Duplicate axis labels (top x-axis, right y-axis)
15
+ - Tick labels (numbers on axes)
16
+ - All of these must stay synchronized
17
+
18
+ This module provides functions to apply font changes consistently across
19
+ all these elements.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import List, Dict, Any
25
+ import numpy as np
26
+ import matplotlib.pyplot as plt
27
+ from matplotlib.ticker import AutoMinorLocator, NullFormatter
28
+ import matplotlib.transforms as mtransforms
29
+
30
+
31
+ def apply_font_changes(ax, fig, label_text_objects: List, normalize_label_text, new_size=None, new_family=None):
32
+ """
33
+ Apply font size and/or family changes to all text elements in the plot.
34
+
35
+ HOW IT WORKS:
36
+ ------------
37
+ This function updates fonts for all text elements in the plot:
38
+ 1. Curve labels (text objects next to curves)
39
+ 2. Axis labels (x-axis and y-axis titles)
40
+ 3. Duplicate axis labels (top x-axis, right y-axis)
41
+ 4. Tick labels (numbers on axes, including top/right ticks)
42
+
43
+ FONT FAMILY HANDLING:
44
+ --------------------
45
+ When changing font family, we:
46
+ 1. Build a fallback chain (primary font + common fallbacks)
47
+ 2. Update matplotlib's rcParams (affects new text)
48
+ 3. Update all existing text objects
49
+
50
+ FONT SIZE HANDLING:
51
+ -------------------
52
+ When changing font size, we:
53
+ 1. Update matplotlib's rcParams (affects new text)
54
+ 2. Update all existing text objects directly
55
+
56
+ MATH TEXT FONT SET:
57
+ ------------------
58
+ If font family contains "stix", "times", or "roman", we use STIX math font
59
+ (better for mathematical notation). Otherwise, use DejaVu Sans math font.
60
+
61
+ Args:
62
+ ax: Matplotlib axes object
63
+ fig: Matplotlib figure object
64
+ label_text_objects: List of Text objects for curve labels
65
+ normalize_label_text: Function to normalize label text (handles LaTeX)
66
+ new_size: New font size (None = don't change)
67
+ new_family: New font family name (None = don't change)
68
+ """
69
+ if new_family:
70
+ fallback_chain = ['DejaVu Sans', 'Arial Unicode MS', 'Liberation Sans']
71
+ existing = plt.rcParams.get('font.sans-serif', [])
72
+ new_list = [new_family] + [f for f in fallback_chain if f != new_family] + \
73
+ [f for f in existing if f not in fallback_chain and f != new_family]
74
+ plt.rcParams['font.family'] = 'sans-serif'
75
+ plt.rcParams['font.sans-serif'] = new_list
76
+ lf = new_family.lower()
77
+ if any(k in lf for k in ('stix', 'times', 'roman')):
78
+ plt.rcParams['mathtext.fontset'] = 'stix'
79
+ else:
80
+ plt.rcParams['mathtext.fontset'] = 'dejavusans'
81
+ if new_size is not None:
82
+ plt.rcParams['font.size'] = new_size
83
+ for txt in label_text_objects:
84
+ if new_size is not None:
85
+ txt.set_fontsize(new_size)
86
+ if new_family:
87
+ txt.set_fontfamily(new_family)
88
+ for axis_label in (ax.xaxis.label, ax.yaxis.label):
89
+ cur = axis_label.get_text()
90
+ norm = normalize_label_text(cur)
91
+ if norm != cur:
92
+ axis_label.set_text(norm)
93
+ if new_size is not None:
94
+ axis_label.set_fontsize(new_size)
95
+ if new_family:
96
+ axis_label.set_fontfamily(new_family)
97
+ if hasattr(ax, '_top_xlabel_artist') and ax._top_xlabel_artist is not None:
98
+ if new_size is not None:
99
+ ax._top_xlabel_artist.set_fontsize(new_size)
100
+ if new_family:
101
+ ax._top_xlabel_artist.set_fontfamily(new_family)
102
+ if hasattr(ax, '_right_ylabel_artist') and ax._right_ylabel_artist is not None:
103
+ if new_size is not None:
104
+ ax._right_ylabel_artist.set_fontsize(new_size)
105
+ if new_family:
106
+ ax._right_ylabel_artist.set_fontfamily(new_family)
107
+ for lbl in ax.get_xticklabels() + ax.get_yticklabels():
108
+ if new_size is not None:
109
+ lbl.set_fontsize(new_size)
110
+ if new_family:
111
+ lbl.set_fontfamily(new_family)
112
+ # Also update top/right tick labels (label2)
113
+ try:
114
+ for t in ax.xaxis.get_major_ticks():
115
+ if hasattr(t, 'label2'):
116
+ if new_size is not None: t.label2.set_size(new_size)
117
+ if new_family: t.label2.set_family(new_family)
118
+ for t in ax.yaxis.get_major_ticks():
119
+ if hasattr(t, 'label2'):
120
+ if new_size is not None: t.label2.set_size(new_size)
121
+ if new_family: t.label2.set_family(new_family)
122
+ except Exception:
123
+ pass
124
+ fig.canvas.draw_idle()
125
+
126
+
127
+ def sync_fonts(ax, fig, label_text_objects: List):
128
+ try:
129
+ base_size = plt.rcParams.get('font.size')
130
+ if base_size is None:
131
+ return
132
+ for txt in label_text_objects:
133
+ txt.set_fontsize(base_size)
134
+ if ax.xaxis.label: ax.xaxis.label.set_fontsize(base_size)
135
+ if ax.yaxis.label: ax.yaxis.label.set_fontsize(base_size)
136
+ if hasattr(ax, '_top_xlabel_artist') and ax._top_xlabel_artist is not None:
137
+ ax._top_xlabel_artist.set_fontsize(base_size)
138
+ if hasattr(ax, '_right_ylabel_artist') and ax._right_ylabel_artist is not None:
139
+ ax._right_ylabel_artist.set_fontsize(base_size)
140
+ for tl in ax.get_xticklabels() + ax.get_yticklabels():
141
+ tl.set_fontsize(base_size)
142
+ fig.canvas.draw_idle()
143
+ except Exception:
144
+ pass
145
+
146
+
147
+ def position_top_xlabel(ax, fig, tick_state: Dict[str, bool]):
148
+ """
149
+ Position the duplicate x-axis label at the top of the plot.
150
+
151
+ HOW IT WORKS:
152
+ ------------
153
+ This function creates or updates a text label at the top of the plot that
154
+ duplicates the bottom x-axis label. This is useful when you want the same
155
+ label visible at both top and bottom.
156
+
157
+ POSITIONING LOGIC:
158
+ -----------------
159
+ 1. Measure height of top tick labels (if visible)
160
+ 2. Add spacing gap (14 points) to avoid overlap
161
+ 3. Apply any manual offsets (if user nudged the label)
162
+ 4. Position label above the plot area
163
+
164
+ COORDINATE SYSTEMS:
165
+ ------------------
166
+ - transAxes: Coordinates relative to axes (0,0 = bottom-left, 1,1 = top-right)
167
+ - points: Physical units (72 points = 1 inch)
168
+ - We use offset_copy() to shift from axes coordinates by a fixed point distance
169
+
170
+ Args:
171
+ ax: Matplotlib axes object
172
+ fig: Matplotlib figure object
173
+ tick_state: Dictionary tracking which ticks/labels are visible
174
+ """
175
+ try:
176
+ # Check if top xlabel should be visible
177
+ on = bool(getattr(ax, '_top_xlabel_on', False))
178
+ if on:
179
+ # Try multiple sources for label text (in order of priority):
180
+ # 1. Override text (if explicitly set)
181
+ # 2. Current bottom xlabel (most common case)
182
+ # 3. Stored xlabel (backup if bottom label was cleared)
183
+ # 4. Existing artist text (preserve if already exists)
184
+ base = getattr(ax, '_top_xlabel_text_override', None)
185
+ if not base:
186
+ base = ax.get_xlabel() # Get current bottom x-axis label
187
+ if not base and hasattr(ax, '_stored_xlabel'):
188
+ try:
189
+ base = ax._stored_xlabel # Fallback to stored label
190
+ except Exception:
191
+ pass
192
+ if not base:
193
+ # Last resort: get text from existing artist (if it exists)
194
+ prev = getattr(ax, '_top_xlabel_artist', None)
195
+ if prev is not None and hasattr(prev, 'get_text'):
196
+ base = prev.get_text() or ''
197
+ else:
198
+ base = '' # No text available
199
+
200
+ # Get renderer to measure text dimensions
201
+ # Renderer converts text to pixels so we can measure its size
202
+ # We wrap in try/except because renderer might not be available in all contexts
203
+ try:
204
+ renderer = fig.canvas.get_renderer()
205
+ except Exception:
206
+ renderer = None
207
+
208
+ # Measure tick label height - ONLY use top labels for top title (independence)
209
+ # DPI = dots per inch (resolution of the figure)
210
+ # We need this to convert pixels to points (72 points = 1 inch)
211
+ dpi = float(fig.dpi) if hasattr(fig, 'dpi') else 100.0
212
+ max_h_px = 0.0 # Maximum height in pixels (will find tallest tick label)
213
+
214
+ # Measure TOP tick labels only (for independence from bottom side)
215
+ # tick_state dictionary tracks which ticks/labels are visible
216
+ # 't_labels' or 'tx' means top x-axis labels are visible
217
+ top_labels_on = bool(tick_state.get('t_labels', tick_state.get('tx', False)))
218
+ if top_labels_on and renderer is not None:
219
+ try:
220
+ # Loop through all major ticks on x-axis
221
+ for t in ax.xaxis.get_major_ticks():
222
+ # label2 is the top tick label (label1 is bottom)
223
+ lab = getattr(t, 'label2', None)
224
+ if lab is not None and lab.get_visible():
225
+ # Get bounding box (size) of this label in pixels
226
+ bb = lab.get_window_extent(renderer=renderer)
227
+ if bb is not None:
228
+ # Track the tallest label (we need space for the tallest one)
229
+ max_h_px = max(max_h_px, float(bb.height))
230
+ except Exception:
231
+ pass
232
+
233
+ # Convert pixels to points and add gap
234
+ # 72 points = 1 inch, so: points = pixels * 72 / dpi
235
+ # We add 14 points of gap to match matplotlib's default labelpad
236
+ if max_h_px > 0:
237
+ tick_height_pts = max_h_px * 72.0 / dpi # Convert pixels → points
238
+ dy_pts = tick_height_pts + 14.0 # 14pt gap to match bottom labelpad
239
+ else:
240
+ # No tick labels visible - use minimal spacing
241
+ dy_pts = 6.0 # Minimal spacing when no tick labels (match small labelpad)
242
+ # Apply manual offsets (stored in points) if user nudged the duplicate title
243
+ # Users can manually adjust label position in interactive menu (WASD keys)
244
+ # These offsets are stored as figure attributes and applied here
245
+ try:
246
+ manual_y_pts = float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0)
247
+ except Exception:
248
+ manual_y_pts = 0.0
249
+ try:
250
+ manual_x_pts = float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0)
251
+ except Exception:
252
+ manual_x_pts = 0.0
253
+ dy_pts += manual_y_pts # Add user's manual vertical offset
254
+
255
+ # Create coordinate transformation
256
+ # transAxes: coordinates relative to axes (0,0 = bottom-left, 1,1 = top-right)
257
+ # offset_copy: creates a new transform that's offset by fixed point distances
258
+ # This lets us position text at (0.5, 1.0) in axes coords, then shift by points
259
+ base_trans = ax.transAxes # Base coordinate system (axes-relative)
260
+ off_trans = mtransforms.offset_copy(base_trans, fig=fig, x=manual_x_pts, y=dy_pts, units='points')
261
+ art = getattr(ax, '_top_xlabel_artist', None)
262
+ try:
263
+ dup_color = ax.xaxis.label.get_color()
264
+ except Exception:
265
+ dup_color = None
266
+ if art is None:
267
+ ax._top_xlabel_artist = ax.text(
268
+ 0.5, 1.0, base, ha='center', va='bottom',
269
+ transform=off_trans, clip_on=False, zorder=10,
270
+ color=dup_color
271
+ )
272
+ else:
273
+ ax._top_xlabel_artist.set_transform(off_trans)
274
+ ax._top_xlabel_artist.set_text(base)
275
+ ax._top_xlabel_artist.set_visible(True)
276
+ if dup_color is not None:
277
+ try:
278
+ ax._top_xlabel_artist.set_color(dup_color)
279
+ except Exception:
280
+ pass
281
+ else:
282
+ if hasattr(ax, '_top_xlabel_artist') and ax._top_xlabel_artist is not None:
283
+ try:
284
+ ax._top_xlabel_artist.set_visible(False)
285
+ except Exception:
286
+ pass
287
+ # Do NOT call draw_idle() here - let the main loop handle drawing
288
+ except Exception:
289
+ pass
290
+
291
+
292
+ def position_right_ylabel(ax, fig, tick_state: Dict[str, bool]):
293
+ try:
294
+ on = bool(getattr(ax, '_right_ylabel_on', False))
295
+ if on:
296
+ # Try multiple sources for label text: override, left ylabel, stored, or existing artist
297
+ base = getattr(ax, '_right_ylabel_text_override', None)
298
+ if not base:
299
+ base = ax.get_ylabel()
300
+ if not base and hasattr(ax, '_stored_ylabel'):
301
+ try:
302
+ base = ax._stored_ylabel
303
+ except Exception:
304
+ pass
305
+ if not base:
306
+ prev = getattr(ax, '_right_ylabel_artist', None)
307
+ if prev is not None and hasattr(prev, 'get_text'):
308
+ base = prev.get_text() or ''
309
+ else:
310
+ base = ''
311
+
312
+ # Get renderer without forcing draws (let main loop handle drawing)
313
+ try:
314
+ renderer = fig.canvas.get_renderer()
315
+ except Exception:
316
+ renderer = None
317
+
318
+ # Measure tick label width - ONLY use right labels for right title (independence)
319
+ dpi = float(fig.dpi) if hasattr(fig, 'dpi') else 100.0
320
+ max_w_px = 0.0
321
+
322
+ # Measure RIGHT tick labels only (for independence from left side)
323
+ right_labels_on = bool(tick_state.get('r_labels', tick_state.get('ry', False)))
324
+ if right_labels_on and renderer is not None:
325
+ try:
326
+ for t in ax.yaxis.get_major_ticks():
327
+ lab = getattr(t, 'label2', None)
328
+ if lab is not None and lab.get_visible():
329
+ bb = lab.get_window_extent(renderer=renderer)
330
+ if bb is not None:
331
+ max_w_px = max(max_w_px, float(bb.width))
332
+ except Exception:
333
+ pass
334
+
335
+ # Convert to points and add gap (6pt gap to visually match left labelpad=8pt)
336
+ if max_w_px > 0:
337
+ tick_width_pts = max_w_px * 72.0 / dpi
338
+ dx_pts = tick_width_pts + 6.0 # 6pt gap to visually match left labelpad
339
+ else:
340
+ dx_pts = 6.0 # Minimal spacing when no tick labels (match small labelpad)
341
+ # Apply manual offsets (stored in points) if user nudged the duplicate title
342
+ try:
343
+ manual_x_pts = float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0)
344
+ except Exception:
345
+ manual_x_pts = 0.0
346
+ try:
347
+ manual_y_pts = float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0)
348
+ except Exception:
349
+ manual_y_pts = 0.0
350
+ dx_pts += manual_x_pts
351
+
352
+ # Place at (1.0, 0.5) in axes with a points-based offset to the right
353
+ base_trans = ax.transAxes
354
+ off_trans = mtransforms.offset_copy(base_trans, fig=fig, x=dx_pts, y=manual_y_pts, units='points')
355
+ art = getattr(ax, '_right_ylabel_artist', None)
356
+ try:
357
+ dup_color = ax.yaxis.label.get_color()
358
+ except Exception:
359
+ dup_color = None
360
+ if art is None:
361
+ ax._right_ylabel_artist = ax.text(
362
+ 1.0, 0.5, base,
363
+ rotation=90, va='center', ha='left', transform=off_trans,
364
+ clip_on=False, zorder=10,
365
+ color=dup_color
366
+ )
367
+ else:
368
+ ax._right_ylabel_artist.set_transform(off_trans)
369
+ ax._right_ylabel_artist.set_text(base)
370
+ ax._right_ylabel_artist.set_visible(True)
371
+ if dup_color is not None:
372
+ try:
373
+ ax._right_ylabel_artist.set_color(dup_color)
374
+ except Exception:
375
+ pass
376
+ else:
377
+ if hasattr(ax, '_right_ylabel_artist') and ax._right_ylabel_artist is not None:
378
+ try:
379
+ ax._right_ylabel_artist.set_visible(False)
380
+ except Exception:
381
+ pass
382
+ # Do NOT call draw_idle() here - let the main loop handle drawing
383
+ except Exception:
384
+ pass
385
+
386
+
387
+ def position_bottom_xlabel(ax, fig, tick_state: Dict[str, bool]):
388
+ """Adjust bottom X label spacing based on bottom tick label visibility.
389
+
390
+ Uses labelpad (in points). Larger pad when bottom tick labels are visible,
391
+ smaller when hidden. Also applies manual offsets if set.
392
+ """
393
+ try:
394
+ lbl = ax.get_xlabel()
395
+ if not lbl:
396
+ return
397
+ # If a one-shot pad restore is pending (after hide->show), honor it once to avoid drift
398
+ if hasattr(ax, '_pending_xlabelpad') and ax._pending_xlabelpad is not None:
399
+ try:
400
+ ax.xaxis.labelpad = ax._pending_xlabelpad
401
+ finally:
402
+ try:
403
+ delattr(ax, '_pending_xlabelpad')
404
+ except Exception:
405
+ pass
406
+ return
407
+ # Otherwise choose pad based on current tick label visibility
408
+ pad = 8 if bool(tick_state.get('b_labels', tick_state.get('bx', False))) else 6
409
+ # Apply manual y-offset (affects labelpad - positive = down, negative = up)
410
+ try:
411
+ manual_y_pts = float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0)
412
+ except Exception:
413
+ manual_y_pts = 0.0
414
+ pad += manual_y_pts
415
+ try:
416
+ ax.xaxis.labelpad = pad
417
+ except Exception:
418
+ pass
419
+ # Do NOT call draw_idle() here - let the main loop handle drawing
420
+ except Exception:
421
+ pass
422
+
423
+
424
+ def position_left_ylabel(ax, fig, tick_state: Dict[str, bool]):
425
+ """Adjust left Y label spacing based on left tick label visibility.
426
+
427
+ Uses labelpad (in points). Larger pad when left tick labels are visible,
428
+ smaller when hidden.
429
+ """
430
+ try:
431
+ lbl = ax.get_ylabel()
432
+ if not lbl:
433
+ return
434
+ # If a one-shot pad restore is pending (after hide->show), honor it once to avoid drift
435
+ if hasattr(ax, '_pending_ylabelpad') and ax._pending_ylabelpad is not None:
436
+ try:
437
+ ax.yaxis.labelpad = ax._pending_ylabelpad
438
+ finally:
439
+ try:
440
+ delattr(ax, '_pending_ylabelpad')
441
+ except Exception:
442
+ pass
443
+ return
444
+ pad = 8 if bool(tick_state.get('l_labels', tick_state.get('ly', False))) else 6
445
+ # Apply manual x-offset (affects labelpad - positive = left, negative = right)
446
+ try:
447
+ manual_x_pts = float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0)
448
+ except Exception:
449
+ manual_x_pts = 0.0
450
+ pad += manual_x_pts
451
+ try:
452
+ ax.yaxis.labelpad = pad
453
+ except Exception:
454
+ pass
455
+ # Do NOT call draw_idle() here - let the main loop handle drawing
456
+ except Exception:
457
+ pass
458
+
459
+
460
+ def update_tick_visibility(ax, tick_state: Dict[str, bool]):
461
+ # Support new separate tick/label keys; fallback to legacy when absent
462
+ if 'b_ticks' in tick_state or 'b_labels' in tick_state:
463
+ ax.tick_params(axis='x',
464
+ bottom=bool(tick_state.get('b_ticks', True)), labelbottom=bool(tick_state.get('b_labels', True)),
465
+ top=bool(tick_state.get('t_ticks', False)), labeltop=bool(tick_state.get('t_labels', False)))
466
+ ax.tick_params(axis='y',
467
+ left=bool(tick_state.get('l_ticks', True)), labelleft=bool(tick_state.get('l_labels', True)),
468
+ right=bool(tick_state.get('r_ticks', False)), labelright=bool(tick_state.get('r_labels', False)))
469
+ else:
470
+ ax.tick_params(axis='x',
471
+ bottom=tick_state['bx'], labelbottom=tick_state['bx'],
472
+ top=tick_state['tx'], labeltop=tick_state['tx'])
473
+ ax.tick_params(axis='y',
474
+ left=tick_state['ly'], labelleft=tick_state['ly'],
475
+ right=tick_state['ry'], labelright=tick_state['ry'])
476
+ if tick_state['mbx'] or tick_state['mtx']:
477
+ ax.xaxis.set_minor_locator(AutoMinorLocator())
478
+ ax.xaxis.set_minor_formatter(NullFormatter())
479
+ ax.tick_params(axis='x', which='minor',
480
+ bottom=tick_state['mbx'],
481
+ top=tick_state['mtx'],
482
+ labelbottom=False, labeltop=False)
483
+ else:
484
+ ax.tick_params(axis='x', which='minor', bottom=False, top=False,
485
+ labelbottom=False, labeltop=False)
486
+ if tick_state['mly'] or tick_state['mry']:
487
+ ax.yaxis.set_minor_locator(AutoMinorLocator())
488
+ ax.yaxis.set_minor_formatter(NullFormatter())
489
+ ax.tick_params(axis='y', which='minor',
490
+ left=tick_state['mly'],
491
+ right=tick_state['mry'],
492
+ labelleft=False, labelright=False)
493
+ else:
494
+ ax.tick_params(axis='y', which='minor', left=False, right=False,
495
+ labelleft=False, labelright=False)
496
+ # After visibility changes, sync tick label fonts (label1 and label2) to rcParams
497
+ try:
498
+ fam_chain = plt.rcParams.get('font.sans-serif')
499
+ fam0 = fam_chain[0] if isinstance(fam_chain, list) and fam_chain else None
500
+ size0 = plt.rcParams.get('font.size', None)
501
+ # Standard tick labels (bottom/left)
502
+ for lbl in ax.get_xticklabels() + ax.get_yticklabels():
503
+ if size0 is not None:
504
+ try: lbl.set_fontsize(size0)
505
+ except Exception: pass
506
+ if fam0:
507
+ try: lbl.set_fontfamily(fam0)
508
+ except Exception: pass
509
+ # Top/right labels (label2)
510
+ for t in ax.xaxis.get_major_ticks():
511
+ lab2 = getattr(t, 'label2', None)
512
+ if lab2 is not None:
513
+ if size0 is not None:
514
+ try: lab2.set_fontsize(size0)
515
+ except Exception: pass
516
+ if fam0:
517
+ try: lab2.set_fontfamily(fam0)
518
+ except Exception: pass
519
+ for t in ax.yaxis.get_major_ticks():
520
+ lab2 = getattr(t, 'label2', None)
521
+ if lab2 is not None:
522
+ if size0 is not None:
523
+ try: lab2.set_fontsize(size0)
524
+ except Exception: pass
525
+ if fam0:
526
+ try: lab2.set_fontfamily(fam0)
527
+ except Exception: pass
528
+ except Exception:
529
+ pass
530
+
531
+
532
+ def ensure_text_visibility(fig, ax, label_text_objects: List, max_iterations=4, check_only=False):
533
+ try:
534
+ renderer = fig.canvas.get_renderer()
535
+ except Exception:
536
+ fig.canvas.draw()
537
+ try:
538
+ renderer = fig.canvas.get_renderer()
539
+ except Exception:
540
+ return
541
+ if renderer is None:
542
+ return
543
+
544
+ def collect(renderer_obj):
545
+ items = []
546
+ # CRITICAL: Check visibility to avoid measuring hidden labels
547
+ if ax.xaxis.label.get_text() and ax.xaxis.label.get_visible():
548
+ try: items.append(ax.xaxis.label.get_window_extent(renderer=renderer_obj))
549
+ except Exception: pass
550
+ if ax.yaxis.label.get_text() and ax.yaxis.label.get_visible():
551
+ try: items.append(ax.yaxis.label.get_window_extent(renderer=renderer_obj))
552
+ except Exception: pass
553
+ # Include duplicate top/right title artists if present
554
+ if getattr(ax, '_top_xlabel_on', False) and hasattr(ax, '_top_xlabel_artist') and ax._top_xlabel_artist is not None:
555
+ try: items.append(ax._top_xlabel_artist.get_window_extent(renderer=renderer_obj))
556
+ except Exception: pass
557
+ if getattr(ax, '_right_ylabel_on', False) and hasattr(ax, '_right_ylabel_artist') and ax._right_ylabel_artist is not None:
558
+ try: items.append(ax._right_ylabel_artist.get_window_extent(renderer=renderer_obj))
559
+ except Exception: pass
560
+ for t in label_text_objects:
561
+ try: items.append(t.get_window_extent(renderer=renderer_obj))
562
+ except Exception: pass
563
+ return items
564
+
565
+ fig_w, fig_h = fig.get_size_inches(); dpi = fig.dpi
566
+ W, H = fig_w * dpi, fig_h * dpi
567
+ pad = 2
568
+
569
+ def is_out(bb):
570
+ return (bb.x0 < -pad or bb.y0 < -pad or bb.x1 > W + pad or bb.y1 > H + pad)
571
+
572
+ initial = collect(renderer)
573
+ overflow = any(is_out(bb) for bb in initial)
574
+ if check_only:
575
+ return overflow
576
+ if not overflow:
577
+ return False
578
+
579
+ for _ in range(max_iterations):
580
+ sp = fig.subplotpars
581
+ left, right, bottom, top = sp.left, sp.right, sp.bottom, sp.top
582
+ changed = False
583
+ for bb in collect(renderer):
584
+ if not is_out(bb):
585
+ continue
586
+ if bb.x0 < 0 and left < 0.40:
587
+ left = min(left + 0.01, 0.40); changed = True
588
+ if bb.x1 > W and right > left + 0.25:
589
+ right = max(right - 0.01, left + 0.25); changed = True
590
+ if bb.y0 < 0 and bottom < 0.40:
591
+ bottom = min(bottom + 0.01, 0.40); changed = True
592
+ if bb.y1 > H and top > bottom + 0.25:
593
+ top = max(top - 0.01, bottom + 0.25); changed = True
594
+ if not changed:
595
+ break
596
+ fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
597
+ fig.canvas.draw()
598
+ try:
599
+ renderer = fig.canvas.get_renderer()
600
+ except Exception:
601
+ break
602
+ if not any(is_out(bb) for bb in collect(renderer)):
603
+ break
604
+ return True
605
+
606
+
607
+ def resize_plot_frame(fig, ax, y_data_list: List, label_text_objects: List, args, update_labels_func):
608
+ while True:
609
+ try:
610
+ fig_w_in, fig_h_in = fig.get_size_inches()
611
+ ax_bbox = ax.get_position()
612
+ cur_ax_w_in = ax_bbox.width * fig_w_in
613
+ cur_ax_h_in = ax_bbox.height * fig_h_in
614
+ print(f"Current canvas: {fig_w_in:.2f} x {fig_h_in:.2f} in")
615
+ print(f"Current plot frame: {cur_ax_w_in:.2f} x {cur_ax_h_in:.2f} in (W x H)")
616
+ try:
617
+ spec = input("Enter new plot frame size (e.g. '6 4', '6x4', 'w=6 h=4', 'scale=1.2', single width, q=back): ").strip().lower()
618
+ except KeyboardInterrupt:
619
+ print("Canceled.")
620
+ return
621
+ if not spec or spec == 'q':
622
+ return
623
+ new_w_in, new_h_in = cur_ax_w_in, cur_ax_h_in
624
+ if 'scale=' in spec:
625
+ try:
626
+ factor = float(spec.split('scale=')[1].strip())
627
+ new_w_in = cur_ax_w_in * factor
628
+ new_h_in = cur_ax_h_in * factor
629
+ except Exception:
630
+ print("Invalid scale factor.")
631
+ continue
632
+ else:
633
+ parts = spec.replace('x', ' ').split()
634
+ kv = {}; numbers = []
635
+ for p in parts:
636
+ if '=' in p:
637
+ k, v = p.split('=', 1)
638
+ kv[k.strip()] = v.strip()
639
+ else:
640
+ numbers.append(p)
641
+ if kv:
642
+ if 'w' in kv: new_w_in = float(kv['w'])
643
+ if 'h' in kv: new_h_in = float(kv['h'])
644
+ elif len(numbers) == 2:
645
+ new_w_in, new_h_in = float(numbers[0]), float(numbers[1])
646
+ elif len(numbers) == 1:
647
+ new_w_in = float(numbers[0])
648
+ aspect = cur_ax_h_in / cur_ax_w_in if cur_ax_w_in else 1.0
649
+ new_h_in = new_w_in * aspect
650
+ else:
651
+ print("Could not parse specification.")
652
+ continue
653
+ req_w_in, req_h_in = new_w_in, new_h_in
654
+ # Apply exact requested size without any clamping
655
+ # Only enforce minimum size to prevent division by zero
656
+ min_ax_in = 0.01
657
+ new_w_in = max(min_ax_in, new_w_in)
658
+ new_h_in = max(min_ax_in, new_h_in)
659
+ tol = 1e-3
660
+ requesting_full_canvas = (abs(req_w_in - fig_w_in) < tol and abs(req_h_in - fig_h_in) < tol)
661
+ w_frac = new_w_in / fig_w_in
662
+ h_frac = new_h_in / fig_h_in
663
+ same_axes = False
664
+ if hasattr(fig, '_last_user_axes_inches'):
665
+ pw, ph = fig._last_user_axes_inches
666
+ if abs(pw - new_w_in) < tol and abs(ph - new_h_in) < tol:
667
+ same_axes = True
668
+ if same_axes and hasattr(fig, '_last_user_margins'):
669
+ lm, bm, rm, tm = fig._last_user_margins
670
+ fig.subplots_adjust(left=lm, bottom=bm, right=rm, top=tm)
671
+ update_labels_func(ax, y_data_list, label_text_objects, args.stack)
672
+ fig.canvas.draw_idle()
673
+ print(f"Plot frame unchanged ({new_w_in:.2f} x {new_h_in:.2f} in); layout preserved.")
674
+ continue
675
+ left = (1 - w_frac) / 2
676
+ right = left + w_frac
677
+ bottom = (1 - h_frac) / 2
678
+ top = bottom + h_frac
679
+ # Apply exact size without margin clamping
680
+ fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
681
+ update_labels_func(ax, y_data_list, label_text_objects, args.stack)
682
+ # Store the final size (exactly as requested, no text visibility adjustments)
683
+ sp = fig.subplotpars
684
+ fig._last_user_axes_inches = (new_w_in, new_h_in)
685
+ fig._last_user_margins = (sp.left, sp.bottom, sp.right, sp.top)
686
+ final_w_in = (sp.right - sp.left) * fig_w_in
687
+ final_h_in = (sp.top - sp.bottom) * fig_h_in
688
+ # Show the requested size (which is what was applied)
689
+ print(f"Plot frame set to {req_w_in:.2f} x {req_h_in:.2f} in inside canvas {fig_w_in:.2f} x {fig_h_in:.2f} in.")
690
+ except KeyboardInterrupt:
691
+ print("Canceled.")
692
+ return
693
+ except Exception as e:
694
+ print(f"Error resizing plot frame: {e}")
695
+
696
+
697
+ def resize_canvas(fig, ax):
698
+ while True:
699
+ try:
700
+ cur_w, cur_h = fig.get_size_inches()
701
+ bbox_before = ax.get_position()
702
+ frame_w_in_before = bbox_before.width * cur_w
703
+ frame_h_in_before = bbox_before.height * cur_h
704
+ print(f"Current canvas size: {cur_w:.2f} x {cur_h:.2f} in (frame {frame_w_in_before:.2f} x {frame_h_in_before:.2f} in)")
705
+ try:
706
+ spec = input("Enter new canvas size (e.g. '8 6', '6x4', 'w=6 h=5', 'scale=1.2', q=back): ").strip().lower()
707
+ except KeyboardInterrupt:
708
+ print("Canceled.")
709
+ return
710
+ if not spec or spec == 'q':
711
+ return
712
+ new_w, new_h = cur_w, cur_h
713
+ if 'scale=' in spec:
714
+ try:
715
+ fct = float(spec.split('scale=')[1])
716
+ new_w, new_h = cur_w * fct, cur_h * fct
717
+ except Exception:
718
+ print("Invalid scale factor.")
719
+ continue
720
+ else:
721
+ parts = spec.replace('x',' ').split()
722
+ kv = {}; nums = []
723
+ for p in parts:
724
+ if '=' in p:
725
+ k,v = p.split('=',1); kv[k.strip()] = v.strip()
726
+ else:
727
+ nums.append(p)
728
+ if kv:
729
+ if 'w' in kv: new_w = float(kv['w'])
730
+ if 'h' in kv: new_h = float(kv['h'])
731
+ elif len(nums)==2:
732
+ new_w, new_h = float(nums[0]), float(nums[1])
733
+ elif len(nums)==1:
734
+ new_w = float(nums[0]); aspect = cur_h/cur_w if cur_w else 1.0; new_h = new_w * aspect
735
+ else:
736
+ print("Could not parse specification.")
737
+ continue
738
+ min_size = 1.0
739
+ new_w = max(min_size, new_w)
740
+ new_h = max(min_size, new_h)
741
+ tol = 1e-3
742
+ same = hasattr(fig,'_last_canvas_size') and all(abs(a-b)<tol for a,b in zip(fig._last_canvas_size,(new_w,new_h)))
743
+ fig.set_size_inches(new_w, new_h, forward=True)
744
+ bbox_after = ax.get_position()
745
+ desired_w_frac = frame_w_in_before / new_w
746
+ desired_h_frac = frame_h_in_before / new_h
747
+ min_margin = 0.05
748
+ max_w_frac = 1 - 2*min_margin
749
+ max_h_frac = 1 - 2*min_margin
750
+ if desired_w_frac > max_w_frac:
751
+ desired_w_frac = max_w_frac
752
+ if desired_h_frac > max_h_frac:
753
+ desired_h_frac = max_h_frac
754
+ left = (1 - desired_w_frac) / 2
755
+ bottom = (1 - desired_h_frac) / 2
756
+ right = left + desired_w_frac
757
+ top = bottom + desired_h_frac
758
+ if right - left > 0.05 and top - bottom > 0.05:
759
+ fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
760
+ fig._last_canvas_size = (new_w, new_h)
761
+ bbox_final = ax.get_position()
762
+ final_frame_w_in = bbox_final.width * new_w
763
+ final_frame_h_in = bbox_final.height * new_h
764
+ if same:
765
+ print(f"Canvas unchanged ({new_w:.2f} x {new_h:.2f} in). Frame {final_frame_w_in:.2f} x {final_frame_h_in:.2f} in.")
766
+ else:
767
+ note = ""
768
+ if abs(final_frame_w_in - frame_w_in_before) > 1e-3 or abs(final_frame_h_in - frame_h_in_before) > 1e-3:
769
+ note = " (clamped to fit)" if final_frame_w_in < frame_w_in_before or final_frame_h_in < frame_h_in_before else ""
770
+ print(f"Canvas resized to {new_w:.2f} x {new_h:.2f} in; frame preserved at {final_frame_w_in:.2f} x {final_frame_h_in:.2f} in{note} (was {frame_w_in_before:.2f} x {frame_h_in_before:.2f}).")
771
+ fig.canvas.draw_idle()
772
+ except KeyboardInterrupt:
773
+ print("Canceled.")
774
+ return
775
+ except Exception as e:
776
+ print(f"Error resizing canvas: {e}")
777
+
778
+
779
+ __all__ = [
780
+ 'apply_font_changes',
781
+ 'sync_fonts',
782
+ 'position_top_xlabel',
783
+ 'position_right_ylabel',
784
+ 'position_bottom_xlabel',
785
+ 'position_left_ylabel',
786
+ 'update_tick_visibility',
787
+ 'ensure_text_visibility',
788
+ 'resize_plot_frame',
789
+ 'resize_canvas',
790
+ ]