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.
- batplot/__init__.py +1 -1
- batplot/args.py +2 -0
- batplot/batch.py +23 -0
- batplot/batplot.py +101 -12
- batplot/cpc_interactive.py +25 -3
- batplot/electrochem_interactive.py +20 -4
- batplot/interactive.py +19 -15
- batplot/modes.py +12 -12
- batplot/operando_ec_interactive.py +4 -4
- batplot/session.py +218 -0
- batplot/style.py +21 -2
- batplot/version_check.py +1 -1
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/METADATA +1 -1
- batplot-1.8.3.dist-info/RECORD +75 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/top_level.txt +1 -0
- batplot_backup_20251221_101150/__init__.py +5 -0
- batplot_backup_20251221_101150/args.py +625 -0
- batplot_backup_20251221_101150/batch.py +1176 -0
- batplot_backup_20251221_101150/batplot.py +3589 -0
- batplot_backup_20251221_101150/cif.py +823 -0
- batplot_backup_20251221_101150/cli.py +149 -0
- batplot_backup_20251221_101150/color_utils.py +547 -0
- batplot_backup_20251221_101150/config.py +198 -0
- batplot_backup_20251221_101150/converters.py +204 -0
- batplot_backup_20251221_101150/cpc_interactive.py +4409 -0
- batplot_backup_20251221_101150/electrochem_interactive.py +4520 -0
- batplot_backup_20251221_101150/interactive.py +3894 -0
- batplot_backup_20251221_101150/manual.py +323 -0
- batplot_backup_20251221_101150/modes.py +799 -0
- batplot_backup_20251221_101150/operando.py +603 -0
- batplot_backup_20251221_101150/operando_ec_interactive.py +5487 -0
- batplot_backup_20251221_101150/plotting.py +228 -0
- batplot_backup_20251221_101150/readers.py +2607 -0
- batplot_backup_20251221_101150/session.py +2951 -0
- batplot_backup_20251221_101150/style.py +1441 -0
- batplot_backup_20251221_101150/ui.py +790 -0
- batplot_backup_20251221_101150/utils.py +1046 -0
- batplot_backup_20251221_101150/version_check.py +253 -0
- batplot-1.8.1.dist-info/RECORD +0 -52
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/WHEEL +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/entry_points.txt +0 -0
- {batplot-1.8.1.dist-info → batplot-1.8.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,1441 @@
|
|
|
1
|
+
"""Style helpers for batplot: print diagnostics, export/import style configs.
|
|
2
|
+
|
|
3
|
+
These utilities keep batplot.py slimmer by centralizing style logic.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import List, Dict, Any, Callable, Optional
|
|
9
|
+
import json
|
|
10
|
+
import importlib
|
|
11
|
+
import sys
|
|
12
|
+
import numpy as np
|
|
13
|
+
import matplotlib.pyplot as plt
|
|
14
|
+
import matplotlib.colors as mcolors
|
|
15
|
+
|
|
16
|
+
from .utils import _confirm_overwrite
|
|
17
|
+
from .color_utils import color_block
|
|
18
|
+
from .ui import (
|
|
19
|
+
ensure_text_visibility as _ui_ensure_text_visibility,
|
|
20
|
+
update_tick_visibility as _ui_update_tick_visibility,
|
|
21
|
+
position_top_xlabel as _ui_position_top_xlabel,
|
|
22
|
+
position_right_ylabel as _ui_position_right_ylabel,
|
|
23
|
+
position_bottom_xlabel as _ui_position_bottom_xlabel,
|
|
24
|
+
position_left_ylabel as _ui_position_left_ylabel,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _color_to_hex(value):
|
|
29
|
+
"""
|
|
30
|
+
Convert any color representation to hexadecimal format (e.g., '#FF0000').
|
|
31
|
+
|
|
32
|
+
HOW IT WORKS:
|
|
33
|
+
------------
|
|
34
|
+
Colors can be represented in many ways:
|
|
35
|
+
- Named colors: 'red', 'blue', 'green'
|
|
36
|
+
- Hex codes: '#FF0000', '#00FF00'
|
|
37
|
+
- RGB tuples: (1.0, 0.0, 0.0) or (255, 0, 0)
|
|
38
|
+
- RGBA tuples: (1.0, 0.0, 0.0, 1.0)
|
|
39
|
+
|
|
40
|
+
This function normalizes all of these to hex format for consistent storage.
|
|
41
|
+
|
|
42
|
+
WHY HEX FORMAT?
|
|
43
|
+
--------------
|
|
44
|
+
Hex format (#RRGGBB) is:
|
|
45
|
+
- Human-readable (easy to see what color it is)
|
|
46
|
+
- Standard format (works in CSS, HTML, etc.)
|
|
47
|
+
- Compact (6 characters for any color)
|
|
48
|
+
- Easy to store in JSON files
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
value: Color in any format (string, tuple, matplotlib color object, etc.)
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Hex color string (e.g., '#FF0000'), or original value if conversion fails
|
|
55
|
+
"""
|
|
56
|
+
# None values stay None (no conversion needed)
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
# Handle special string values that shouldn't be converted
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
low = value.lower()
|
|
63
|
+
if low in ('none', 'auto'):
|
|
64
|
+
# 'none' and 'auto' are special matplotlib values - keep as-is
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
# Try direct conversion (works for most matplotlib color formats)
|
|
68
|
+
try:
|
|
69
|
+
return mcolors.to_hex(value)
|
|
70
|
+
except Exception:
|
|
71
|
+
# Direct conversion failed, try alternative methods
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
# Already a string - might be hex or named color, return as-is
|
|
74
|
+
return value
|
|
75
|
+
try:
|
|
76
|
+
# Try converting to RGBA first, then to hex
|
|
77
|
+
# This handles RGB tuples like (1.0, 0.0, 0.0)
|
|
78
|
+
return mcolors.to_hex(mcolors.to_rgba(value))
|
|
79
|
+
except Exception:
|
|
80
|
+
# All conversion methods failed - convert to string as last resort
|
|
81
|
+
return str(value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_primary_axis_text(ax, axis: str) -> str:
|
|
85
|
+
if axis == 'x':
|
|
86
|
+
label = ax.xaxis.label
|
|
87
|
+
stored_attr = '_stored_xlabel'
|
|
88
|
+
else:
|
|
89
|
+
label = ax.yaxis.label
|
|
90
|
+
stored_attr = '_stored_ylabel'
|
|
91
|
+
text = ''
|
|
92
|
+
try:
|
|
93
|
+
text = label.get_text()
|
|
94
|
+
except Exception:
|
|
95
|
+
text = ''
|
|
96
|
+
if not text and hasattr(ax, stored_attr):
|
|
97
|
+
try:
|
|
98
|
+
stored = getattr(ax, stored_attr)
|
|
99
|
+
if stored:
|
|
100
|
+
text = stored
|
|
101
|
+
except Exception:
|
|
102
|
+
text = ''
|
|
103
|
+
return text or ''
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _get_duplicate_axis_text(ax, artist_attr: str, fallback: str = '') -> str:
|
|
107
|
+
override_attr = '_top_xlabel_text_override' if 'top' in artist_attr else '_right_ylabel_text_override'
|
|
108
|
+
if hasattr(ax, override_attr):
|
|
109
|
+
try:
|
|
110
|
+
override_val = getattr(ax, override_attr)
|
|
111
|
+
if override_val:
|
|
112
|
+
return override_val
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
art = getattr(ax, artist_attr, None)
|
|
116
|
+
if art is not None and hasattr(art, 'get_text'):
|
|
117
|
+
try:
|
|
118
|
+
txt = art.get_text()
|
|
119
|
+
if txt:
|
|
120
|
+
return txt
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
return fallback or ''
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_palette_cmap(palette_name: str):
|
|
127
|
+
"""
|
|
128
|
+
Resolve a palette name to a matplotlib colormap object.
|
|
129
|
+
|
|
130
|
+
HOW IT WORKS:
|
|
131
|
+
------------
|
|
132
|
+
This function tries multiple sources to find a colormap:
|
|
133
|
+
|
|
134
|
+
1. **Matplotlib built-in**: Try plt.get_cmap() first (fastest)
|
|
135
|
+
- Examples: 'viridis', 'plasma', 'tab10'
|
|
136
|
+
|
|
137
|
+
2. **cmcrameri package**: Try to load from cmcrameri.cm module
|
|
138
|
+
- cmcrameri is an optional package with scientific colormaps
|
|
139
|
+
- Examples: 'batlow', 'batlowk', 'batloww'
|
|
140
|
+
- Only tried if palette name starts with 'batlow'
|
|
141
|
+
|
|
142
|
+
3. **Custom colormaps**: Try to create from _CUSTOM_CMAPS dictionary
|
|
143
|
+
- Fallback if cmcrameri not installed
|
|
144
|
+
- Creates colormap from hardcoded color lists
|
|
145
|
+
|
|
146
|
+
REVERSED COLORMAPS:
|
|
147
|
+
------------------
|
|
148
|
+
Colormaps can be reversed by adding '_r' suffix:
|
|
149
|
+
- 'viridis' → normal (dark to bright)
|
|
150
|
+
- 'viridis_r' → reversed (bright to dark)
|
|
151
|
+
|
|
152
|
+
The function handles this by:
|
|
153
|
+
1. Removing '_r' suffix to get base name
|
|
154
|
+
2. Getting the base colormap
|
|
155
|
+
3. Calling .reversed() if '_r' was present
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
palette_name: Name of colormap (e.g., 'viridis', 'batlow', 'viridis_r')
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Matplotlib colormap object, or None if not found
|
|
162
|
+
"""
|
|
163
|
+
# Empty name - return None
|
|
164
|
+
if not palette_name:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
# METHOD 1: Try matplotlib's built-in colormaps (most common case)
|
|
168
|
+
try:
|
|
169
|
+
return plt.get_cmap(palette_name)
|
|
170
|
+
except ValueError:
|
|
171
|
+
# Not a built-in colormap, try other sources
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
# Normalize name to lowercase for case-insensitive matching
|
|
175
|
+
name_lower = palette_name.lower()
|
|
176
|
+
# Extract base name (remove '_r' suffix if present)
|
|
177
|
+
# Example: 'viridis_r' → base_name = 'viridis'
|
|
178
|
+
base_name = name_lower[:-2] if name_lower.endswith('_r') else name_lower
|
|
179
|
+
|
|
180
|
+
# METHOD 2: Try cmcrameri package (for 'batlow' variants)
|
|
181
|
+
if name_lower.startswith('batlow'):
|
|
182
|
+
try:
|
|
183
|
+
# Try to import cmcrameri package (optional dependency)
|
|
184
|
+
cmc = importlib.import_module('cmcrameri.cm')
|
|
185
|
+
# Check if exact name exists (e.g., 'batlow', 'batlowk')
|
|
186
|
+
if hasattr(cmc, name_lower):
|
|
187
|
+
cmap = getattr(cmc, name_lower)
|
|
188
|
+
# Reverse if '_r' suffix was present
|
|
189
|
+
if name_lower.endswith('_r'):
|
|
190
|
+
cmap = cmap.reversed()
|
|
191
|
+
return cmap
|
|
192
|
+
# Fallback: try generic 'batlow' if specific variant not found
|
|
193
|
+
if hasattr(cmc, 'batlow'):
|
|
194
|
+
cmap = getattr(cmc, 'batlow')
|
|
195
|
+
if name_lower.endswith('_r'):
|
|
196
|
+
cmap = cmap.reversed()
|
|
197
|
+
return cmap
|
|
198
|
+
except Exception:
|
|
199
|
+
# cmcrameri not installed or colormap not found, continue to next method
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
# METHOD 3: Fallback to custom colormaps defined in this package
|
|
203
|
+
try:
|
|
204
|
+
from .color_utils import _CUSTOM_CMAPS
|
|
205
|
+
custom_colors = _CUSTOM_CMAPS.get(base_name)
|
|
206
|
+
if custom_colors:
|
|
207
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
208
|
+
# Create colormap from list of colors
|
|
209
|
+
# N=256 means create 256 intermediate colors by interpolation
|
|
210
|
+
cmap = LinearSegmentedColormap.from_list(base_name, custom_colors, N=256)
|
|
211
|
+
# Reverse if '_r' suffix was present
|
|
212
|
+
if name_lower.endswith('_r'):
|
|
213
|
+
cmap = cmap.reversed()
|
|
214
|
+
return cmap
|
|
215
|
+
except Exception:
|
|
216
|
+
# Custom colormap creation failed
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
# All methods failed - colormap not found
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _apply_curve_palette(ax, record: Dict[str, Any]) -> bool:
|
|
224
|
+
"""
|
|
225
|
+
Apply a color palette to curves when loading a saved style/session file.
|
|
226
|
+
|
|
227
|
+
HOW IT WORKS:
|
|
228
|
+
------------
|
|
229
|
+
This function is called when you load a style file (p i s command) that contains
|
|
230
|
+
palette information. It restores the exact same colors that were used when the
|
|
231
|
+
style was saved.
|
|
232
|
+
|
|
233
|
+
The style file stores:
|
|
234
|
+
- palette_name: Which colormap was used (e.g., 'viridis')
|
|
235
|
+
- indices: Which curves were colored (e.g., [1, 2, 3, 4, 5])
|
|
236
|
+
- low_clip, high_clip: The sampling range used (e.g., 0.08 to 0.85)
|
|
237
|
+
|
|
238
|
+
This function:
|
|
239
|
+
1. Gets the same colormap that was used originally
|
|
240
|
+
2. Samples colors at the same positions (using stored low_clip/high_clip)
|
|
241
|
+
3. Applies colors to the same curves (using stored indices)
|
|
242
|
+
|
|
243
|
+
This ensures that when you reload a style, the colors look exactly the same
|
|
244
|
+
as when you saved it.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
ax: Matplotlib axes object containing the plot lines
|
|
248
|
+
record: Dictionary from style file containing:
|
|
249
|
+
- 'palette': Colormap name (e.g., 'viridis')
|
|
250
|
+
- 'indices': List of curve indices (1-indexed, e.g., [1, 2, 3])
|
|
251
|
+
- 'low_clip': Lower bound for color sampling (default 0.08)
|
|
252
|
+
- 'high_clip': Upper bound for color sampling (default 0.85)
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
True if palette was successfully applied, False otherwise
|
|
256
|
+
"""
|
|
257
|
+
# Extract palette information from style record
|
|
258
|
+
palette_name = record.get('palette')
|
|
259
|
+
indices = record.get('indices')
|
|
260
|
+
|
|
261
|
+
# Validate that we have the required information
|
|
262
|
+
if not palette_name or not indices:
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
# Get the colormap (same one used when style was saved)
|
|
266
|
+
cmap = _resolve_palette_cmap(palette_name)
|
|
267
|
+
if cmap is None:
|
|
268
|
+
print(f"Warning: Unknown palette '{palette_name}' in style file.")
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
# Convert 1-indexed curve numbers to 0-indexed array indices
|
|
272
|
+
# Style files store curves as 1, 2, 3, ... (user-friendly)
|
|
273
|
+
# But matplotlib uses 0, 1, 2, ... (programmer-friendly)
|
|
274
|
+
try:
|
|
275
|
+
zero_based = [int(i) - 1 for i in indices]
|
|
276
|
+
except Exception:
|
|
277
|
+
zero_based = []
|
|
278
|
+
|
|
279
|
+
# Filter out invalid indices (curves that don't exist)
|
|
280
|
+
zero_based = [i for i in zero_based if 0 <= i < len(ax.lines)]
|
|
281
|
+
if not zero_based:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
# Get the color sampling range from style file (or use defaults)
|
|
285
|
+
# These values determine which part of the colormap to sample from
|
|
286
|
+
low_clip = float(record.get('low_clip', 0.08)) # Default: start at 8% into colormap
|
|
287
|
+
high_clip = float(record.get('high_clip', 0.85)) # Default: end at 85% into colormap
|
|
288
|
+
|
|
289
|
+
# Get number of curves to color
|
|
290
|
+
nsel = len(zero_based)
|
|
291
|
+
|
|
292
|
+
# Sample colors from colormap at evenly spaced positions
|
|
293
|
+
# This recreates the exact same color assignment as when the style was saved
|
|
294
|
+
if nsel == 1:
|
|
295
|
+
# Single curve: use middle of colormap
|
|
296
|
+
colors = [cmap(0.55)]
|
|
297
|
+
elif nsel == 2:
|
|
298
|
+
# Two curves: use clipped range endpoints for maximum contrast
|
|
299
|
+
colors = [cmap(low_clip), cmap(high_clip)]
|
|
300
|
+
else:
|
|
301
|
+
# Multiple curves: sample evenly across the stored range
|
|
302
|
+
# np.linspace creates the same positions that were used originally
|
|
303
|
+
positions = np.linspace(low_clip, high_clip, nsel)
|
|
304
|
+
# Sample color at each position
|
|
305
|
+
colors = [cmap(p) for p in positions]
|
|
306
|
+
|
|
307
|
+
# Apply colors to the curves
|
|
308
|
+
# Loop through curve indices and assign corresponding color
|
|
309
|
+
for idx, color in zip(zero_based, colors):
|
|
310
|
+
try:
|
|
311
|
+
ax.lines[idx].set_color(color)
|
|
312
|
+
except Exception:
|
|
313
|
+
# Skip if curve doesn't exist (shouldn't happen due to filtering above)
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def print_style_info(
|
|
320
|
+
fig,
|
|
321
|
+
ax,
|
|
322
|
+
y_data_list: List[np.ndarray],
|
|
323
|
+
labels: List[str],
|
|
324
|
+
offsets_list: List[float],
|
|
325
|
+
x_full_list: List[np.ndarray],
|
|
326
|
+
raw_y_full_list: List[np.ndarray],
|
|
327
|
+
args,
|
|
328
|
+
delta: float,
|
|
329
|
+
label_text_objects: List,
|
|
330
|
+
tick_state: Dict[str, bool],
|
|
331
|
+
cif_tick_series: Optional[List[tuple]] = None,
|
|
332
|
+
show_cif_hkl: Optional[bool] = None,
|
|
333
|
+
) -> None:
|
|
334
|
+
print("\n--- Style / Diagnostics ---")
|
|
335
|
+
fw, fh = fig.get_size_inches()
|
|
336
|
+
print(f"Figure size (inches): {fw:.3f} x {fh:.3f}")
|
|
337
|
+
# DPI omitted from compact style print
|
|
338
|
+
bbox = ax.get_position()
|
|
339
|
+
print(
|
|
340
|
+
f"Axes position (figure fraction): x0={bbox.x0:.3f}, y0={bbox.y0:.3f}, w={bbox.width:.3f}, h={bbox.height:.3f}"
|
|
341
|
+
)
|
|
342
|
+
frame_w_in = bbox.width * fw
|
|
343
|
+
frame_h_in = bbox.height * fh
|
|
344
|
+
print(f"Plot frame size (inches): {frame_w_in:.3f} x {frame_h_in:.3f}")
|
|
345
|
+
sp = fig.subplotpars
|
|
346
|
+
print(
|
|
347
|
+
f"Margins (subplot fractions): left={sp.left:.3f}, right={sp.right:.3f}, bottom={sp.bottom:.3f}, top={sp.top:.3f}"
|
|
348
|
+
)
|
|
349
|
+
# Omit ranges and axis labels from style print
|
|
350
|
+
# Font info
|
|
351
|
+
if label_text_objects:
|
|
352
|
+
fs_any = label_text_objects[0].get_fontsize()
|
|
353
|
+
ff_any = label_text_objects[0].get_fontfamily()
|
|
354
|
+
else:
|
|
355
|
+
fs_any = plt.rcParams.get("font.size")
|
|
356
|
+
ff_any = plt.rcParams.get("font.family")
|
|
357
|
+
print(f"Effective font size (labels/ticks): {fs_any}")
|
|
358
|
+
print(f"Font family chain (rcParams['font.sans-serif']): {plt.rcParams.get('font.sans-serif')}")
|
|
359
|
+
print(f"Mathtext fontset: {plt.rcParams.get('mathtext.fontset')}")
|
|
360
|
+
|
|
361
|
+
# Rotation angle
|
|
362
|
+
rotation_angle = getattr(ax, '_rotation_angle', 0)
|
|
363
|
+
if rotation_angle != 0:
|
|
364
|
+
print(f"Rotation angle (ro): {rotation_angle}°")
|
|
365
|
+
|
|
366
|
+
# Per-side matrix summary (spine, major, minor, labels, title)
|
|
367
|
+
def _onoff(v):
|
|
368
|
+
return 'ON ' if bool(v) else 'off'
|
|
369
|
+
def _label_visible(axis_obj, primary: bool) -> bool:
|
|
370
|
+
try:
|
|
371
|
+
if primary:
|
|
372
|
+
return bool(axis_obj.label.get_visible())
|
|
373
|
+
return bool(axis_obj)
|
|
374
|
+
except Exception:
|
|
375
|
+
return bool(axis_obj)
|
|
376
|
+
|
|
377
|
+
sides = (
|
|
378
|
+
('bottom',
|
|
379
|
+
ax.spines.get('bottom').get_visible() if ax.spines.get('bottom') else False,
|
|
380
|
+
tick_state.get('b_ticks', tick_state.get('bx', True)),
|
|
381
|
+
tick_state.get('mbx', False),
|
|
382
|
+
tick_state.get('b_labels', tick_state.get('bx', True)),
|
|
383
|
+
bool(ax.xaxis.label.get_visible())),
|
|
384
|
+
('top',
|
|
385
|
+
ax.spines.get('top').get_visible() if ax.spines.get('top') else False,
|
|
386
|
+
tick_state.get('t_ticks', tick_state.get('tx', False)),
|
|
387
|
+
tick_state.get('mtx', False),
|
|
388
|
+
tick_state.get('t_labels', tick_state.get('tx', False)),
|
|
389
|
+
bool(getattr(ax, '_top_xlabel_on', False))),
|
|
390
|
+
('left',
|
|
391
|
+
ax.spines.get('left').get_visible() if ax.spines.get('left') else False,
|
|
392
|
+
tick_state.get('l_ticks', tick_state.get('ly', True)),
|
|
393
|
+
tick_state.get('mly', False),
|
|
394
|
+
tick_state.get('l_labels', tick_state.get('ly', True)),
|
|
395
|
+
bool(ax.yaxis.label.get_visible())),
|
|
396
|
+
('right',
|
|
397
|
+
ax.spines.get('right').get_visible() if ax.spines.get('right') else False,
|
|
398
|
+
tick_state.get('r_ticks', tick_state.get('ry', False)),
|
|
399
|
+
tick_state.get('mry', False),
|
|
400
|
+
tick_state.get('r_labels', tick_state.get('ry', False)),
|
|
401
|
+
bool(getattr(ax, '_right_ylabel_on', False))),
|
|
402
|
+
)
|
|
403
|
+
print("Per-side: spine, major, minor, labels, title")
|
|
404
|
+
for name, spine, mj, mn, lbl, title in sides:
|
|
405
|
+
print(f" {name:<6}: spine={_onoff(spine)} major={_onoff(mj)} minor={_onoff(mn)} labels={_onoff(lbl)} title={_onoff(title)}")
|
|
406
|
+
|
|
407
|
+
# Tick widths helper
|
|
408
|
+
def axis_tick_width(axis, which):
|
|
409
|
+
ticks = axis.get_major_ticks() if which == "major" else axis.get_minor_ticks()
|
|
410
|
+
for t in ticks:
|
|
411
|
+
line = t.tick1line
|
|
412
|
+
if line.get_visible():
|
|
413
|
+
return line.get_linewidth()
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
x_major_w = axis_tick_width(ax.xaxis, "major")
|
|
417
|
+
x_minor_w = axis_tick_width(ax.xaxis, "minor")
|
|
418
|
+
y_major_w = axis_tick_width(ax.yaxis, "major")
|
|
419
|
+
y_minor_w = axis_tick_width(ax.yaxis, "minor")
|
|
420
|
+
print(
|
|
421
|
+
f"Tick widths (major/minor): X=({x_major_w}, {x_minor_w}) Y=({y_major_w}, {y_minor_w})"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# Spines
|
|
425
|
+
print("Spines:")
|
|
426
|
+
for name, spn in ax.spines.items():
|
|
427
|
+
print(
|
|
428
|
+
f" {name:<5} lw={spn.get_linewidth()} color={spn.get_edgecolor()} visible={spn.get_visible()}"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Tick colors
|
|
432
|
+
try:
|
|
433
|
+
x_color = ax.xaxis.get_tick_params()['color'] if ax.xaxis.get_tick_params() else 'black'
|
|
434
|
+
y_color = ax.yaxis.get_tick_params()['color'] if ax.yaxis.get_tick_params() else 'black'
|
|
435
|
+
print(f"Tick colors: X={x_color} Y={y_color}")
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
# Axis label colors
|
|
440
|
+
try:
|
|
441
|
+
x_label_color = ax.xaxis.label.get_color()
|
|
442
|
+
y_label_color = ax.yaxis.label.get_color()
|
|
443
|
+
print(f"Axis label colors: X={x_label_color} Y={y_label_color}")
|
|
444
|
+
except Exception:
|
|
445
|
+
pass
|
|
446
|
+
|
|
447
|
+
# Omit CIF/HKL details from compact style print
|
|
448
|
+
|
|
449
|
+
# Omit non-style global flags (mode/raw/autoscale/delta)
|
|
450
|
+
|
|
451
|
+
# Curve names visibility
|
|
452
|
+
names_visible = True
|
|
453
|
+
if label_text_objects and len(label_text_objects) > 0:
|
|
454
|
+
try:
|
|
455
|
+
names_visible = bool(label_text_objects[0].get_visible())
|
|
456
|
+
except Exception:
|
|
457
|
+
names_visible = True
|
|
458
|
+
print(f"Curve names (h): {'shown' if names_visible else 'hidden'}")
|
|
459
|
+
|
|
460
|
+
# Legend/label anchor summary
|
|
461
|
+
stack_label_at_bottom = getattr(fig, '_stack_label_at_bottom', False)
|
|
462
|
+
label_anchor_left = getattr(fig, '_label_anchor_left', False)
|
|
463
|
+
legend_pos = f"{'bottom' if stack_label_at_bottom else 'top'}-{'left' if label_anchor_left else 'right'}"
|
|
464
|
+
print(f"Curve label anchor: {legend_pos} (stack mode={getattr(args, 'stack', False)})")
|
|
465
|
+
|
|
466
|
+
# Curves
|
|
467
|
+
print("Lines (style):")
|
|
468
|
+
for i, ln in enumerate(ax.lines):
|
|
469
|
+
col_val = ln.get_color()
|
|
470
|
+
col_hex = _color_to_hex(col_val)
|
|
471
|
+
col_disp = f"{color_block(col_hex)} {col_hex}" if col_hex else str(col_val)
|
|
472
|
+
lw = ln.get_linewidth(); ls = ln.get_linestyle()
|
|
473
|
+
mk = ln.get_marker(); ms = ln.get_markersize(); a = ln.get_alpha()
|
|
474
|
+
base_label = labels[i] if i < len(labels) else ""
|
|
475
|
+
offset_val = offsets_list[i] if i < len(offsets_list) else 0.0
|
|
476
|
+
offset_str = f" offset={offset_val:.4g}" if offset_val != 0.0 else ""
|
|
477
|
+
print(f" {i+1:02d}: label='{base_label}' color={col_disp} lw={lw} ls={ls} marker={mk} ms={ms} alpha={a}{offset_str}")
|
|
478
|
+
palette_hist = getattr(fig, '_curve_palette_history', None)
|
|
479
|
+
if palette_hist:
|
|
480
|
+
print("Palette history:")
|
|
481
|
+
for entry in palette_hist:
|
|
482
|
+
palette_name = entry.get('palette', '')
|
|
483
|
+
idxs = entry.get('indices', [])
|
|
484
|
+
idx_str = ", ".join(str(i) for i in idxs)
|
|
485
|
+
print(f" {palette_name or 'unknown'} -> [{idx_str}]")
|
|
486
|
+
print("--- End diagnostics ---\n")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def export_style_config(
|
|
490
|
+
filename: str,
|
|
491
|
+
fig,
|
|
492
|
+
ax,
|
|
493
|
+
y_data_list: List[np.ndarray],
|
|
494
|
+
labels: List[str],
|
|
495
|
+
delta: float,
|
|
496
|
+
args,
|
|
497
|
+
tick_state: Dict[str, bool],
|
|
498
|
+
offsets_list: List[float],
|
|
499
|
+
cif_tick_series: Optional[List[tuple]] = None,
|
|
500
|
+
label_text_objects: Optional[List] = None,
|
|
501
|
+
base_path: Optional[str] = None,
|
|
502
|
+
show_cif_titles: Optional[bool] = None,
|
|
503
|
+
overwrite_path: Optional[str] = None,
|
|
504
|
+
) -> Optional[str]:
|
|
505
|
+
"""Export style configuration after displaying a summary and prompting the user.
|
|
506
|
+
|
|
507
|
+
This function now matches the EC menu workflow: display summary, then prompt for export.
|
|
508
|
+
"""
|
|
509
|
+
try:
|
|
510
|
+
fw, fh = fig.get_size_inches()
|
|
511
|
+
sp = fig.subplotpars
|
|
512
|
+
|
|
513
|
+
def axis_tick_width(axis, which):
|
|
514
|
+
ticks = axis.get_major_ticks() if which == "major" else axis.get_minor_ticks()
|
|
515
|
+
for t in ticks:
|
|
516
|
+
line = t.tick1line
|
|
517
|
+
if line.get_visible():
|
|
518
|
+
return line.get_linewidth()
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
spine_vis = {name: spn.get_visible() for name, spn in ax.spines.items()}
|
|
522
|
+
|
|
523
|
+
bbox = ax.get_position()
|
|
524
|
+
frame_w_in = bbox.width * fw
|
|
525
|
+
frame_h_in = bbox.height * fh
|
|
526
|
+
|
|
527
|
+
# Build WASD state (20 parameters: 4 sides × 5 properties each)
|
|
528
|
+
def _get_spine_visible(which: str) -> bool:
|
|
529
|
+
sp = ax.spines.get(which)
|
|
530
|
+
try:
|
|
531
|
+
return bool(sp.get_visible()) if sp is not None else False
|
|
532
|
+
except Exception:
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
wasd_state = {
|
|
536
|
+
'top': {
|
|
537
|
+
'spine': _get_spine_visible('top'),
|
|
538
|
+
'ticks': bool(tick_state.get('t_ticks', tick_state.get('tx', False))),
|
|
539
|
+
'minor': bool(tick_state.get('mtx', False)),
|
|
540
|
+
'labels': bool(tick_state.get('t_labels', tick_state.get('tx', False))),
|
|
541
|
+
'title': bool(getattr(ax, '_top_xlabel_on', False))
|
|
542
|
+
},
|
|
543
|
+
'bottom': {
|
|
544
|
+
'spine': _get_spine_visible('bottom'),
|
|
545
|
+
'ticks': bool(tick_state.get('b_ticks', tick_state.get('bx', True))),
|
|
546
|
+
'minor': bool(tick_state.get('mbx', False)),
|
|
547
|
+
'labels': bool(tick_state.get('b_labels', tick_state.get('bx', True))),
|
|
548
|
+
'title': bool(ax.xaxis.label.get_visible())
|
|
549
|
+
},
|
|
550
|
+
'left': {
|
|
551
|
+
'spine': _get_spine_visible('left'),
|
|
552
|
+
'ticks': bool(tick_state.get('l_ticks', tick_state.get('ly', True))),
|
|
553
|
+
'minor': bool(tick_state.get('mly', False)),
|
|
554
|
+
'labels': bool(tick_state.get('l_labels', tick_state.get('ly', True))),
|
|
555
|
+
'title': bool(ax.yaxis.label.get_visible())
|
|
556
|
+
},
|
|
557
|
+
'right': {
|
|
558
|
+
'spine': _get_spine_visible('right'),
|
|
559
|
+
'ticks': bool(tick_state.get('r_ticks', tick_state.get('ry', False))),
|
|
560
|
+
'minor': bool(tick_state.get('mry', False)),
|
|
561
|
+
'labels': bool(tick_state.get('r_labels', tick_state.get('ry', False))),
|
|
562
|
+
'title': bool(getattr(ax, '_right_ylabel_on', False))
|
|
563
|
+
},
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
cfg = {
|
|
567
|
+
"figure": {
|
|
568
|
+
"size": [fw, fh],
|
|
569
|
+
"dpi": fig.dpi,
|
|
570
|
+
"frame_size": [frame_w_in, frame_h_in],
|
|
571
|
+
"axes_fraction": [bbox.x0, bbox.y0, bbox.width, bbox.height],
|
|
572
|
+
},
|
|
573
|
+
"margins": {
|
|
574
|
+
"left": sp.left,
|
|
575
|
+
"right": sp.right,
|
|
576
|
+
"bottom": sp.bottom,
|
|
577
|
+
"top": sp.top,
|
|
578
|
+
},
|
|
579
|
+
"font": {
|
|
580
|
+
"size": plt.rcParams.get("font.size"),
|
|
581
|
+
"family_chain": plt.rcParams.get("font.sans-serif"),
|
|
582
|
+
},
|
|
583
|
+
"ticks": {
|
|
584
|
+
"x_major_width": axis_tick_width(ax.xaxis, "major"),
|
|
585
|
+
"x_minor_width": axis_tick_width(ax.xaxis, "minor"),
|
|
586
|
+
"y_major_width": axis_tick_width(ax.yaxis, "major"),
|
|
587
|
+
"y_minor_width": axis_tick_width(ax.yaxis, "minor"),
|
|
588
|
+
},
|
|
589
|
+
"wasd_state": wasd_state,
|
|
590
|
+
"spines": {
|
|
591
|
+
name: {
|
|
592
|
+
"linewidth": spn.get_linewidth(),
|
|
593
|
+
"color": spn.get_edgecolor(),
|
|
594
|
+
"visible": spine_vis.get(name, True),
|
|
595
|
+
}
|
|
596
|
+
for name, spn in ax.spines.items()
|
|
597
|
+
},
|
|
598
|
+
"tick_colors": {
|
|
599
|
+
"x": _color_to_hex((ax.xaxis.get_tick_params() or {}).get('color', 'black')),
|
|
600
|
+
"y": _color_to_hex((ax.yaxis.get_tick_params() or {}).get('color', 'black')),
|
|
601
|
+
},
|
|
602
|
+
"axis_label_colors": {
|
|
603
|
+
"x": _color_to_hex(ax.xaxis.label.get_color()),
|
|
604
|
+
"y": _color_to_hex(ax.yaxis.label.get_color()),
|
|
605
|
+
},
|
|
606
|
+
"grid": ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False,
|
|
607
|
+
"lines": [
|
|
608
|
+
{
|
|
609
|
+
"index": i,
|
|
610
|
+
# label text is not a style item (handled by 'r'), don't export it
|
|
611
|
+
"color": _color_to_hex(ln.get_color()),
|
|
612
|
+
"linewidth": ln.get_linewidth(),
|
|
613
|
+
"linestyle": ln.get_linestyle(),
|
|
614
|
+
"marker": ln.get_marker(),
|
|
615
|
+
"markersize": ln.get_markersize(),
|
|
616
|
+
"markerfacecolor": _color_to_hex(ln.get_markerfacecolor()),
|
|
617
|
+
"markeredgecolor": _color_to_hex(ln.get_markeredgecolor()),
|
|
618
|
+
"alpha": ln.get_alpha(),
|
|
619
|
+
"offset": offsets_list[i] if i < len(offsets_list) else 0.0,
|
|
620
|
+
}
|
|
621
|
+
for i, ln in enumerate(ax.lines)
|
|
622
|
+
],
|
|
623
|
+
}
|
|
624
|
+
bottom_label_text = _get_primary_axis_text(ax, 'x')
|
|
625
|
+
left_label_text = _get_primary_axis_text(ax, 'y')
|
|
626
|
+
axis_title_texts = {
|
|
627
|
+
"top_x": _get_duplicate_axis_text(ax, '_top_xlabel_artist', bottom_label_text),
|
|
628
|
+
"bottom_x": bottom_label_text,
|
|
629
|
+
"left_y": left_label_text,
|
|
630
|
+
"right_y": _get_duplicate_axis_text(ax, '_right_ylabel_artist', left_label_text),
|
|
631
|
+
}
|
|
632
|
+
cfg["axis_titles"] = {
|
|
633
|
+
"top_x": bool(getattr(ax, "_top_xlabel_on", False)),
|
|
634
|
+
"right_y": bool(getattr(ax, "_right_ylabel_on", False)),
|
|
635
|
+
"has_bottom_x": bool(ax.xaxis.label.get_visible()),
|
|
636
|
+
"has_left_y": bool(ax.yaxis.label.get_visible()),
|
|
637
|
+
}
|
|
638
|
+
cfg["axis_title_texts"] = axis_title_texts
|
|
639
|
+
cfg["title_offsets"] = {
|
|
640
|
+
"top_y": float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
641
|
+
"top_x": float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
642
|
+
"bottom_y": float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
643
|
+
"left_x": float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
644
|
+
"right_x": float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
645
|
+
"right_y": float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
646
|
+
}
|
|
647
|
+
# Save rotation angle
|
|
648
|
+
cfg["rotation_angle"] = getattr(ax, '_rotation_angle', 0)
|
|
649
|
+
|
|
650
|
+
# Save curve names visibility
|
|
651
|
+
cfg["curve_names_visible"] = True # Default to visible
|
|
652
|
+
if label_text_objects and len(label_text_objects) > 0:
|
|
653
|
+
try:
|
|
654
|
+
cfg["curve_names_visible"] = bool(label_text_objects[0].get_visible())
|
|
655
|
+
except Exception:
|
|
656
|
+
pass
|
|
657
|
+
|
|
658
|
+
# Save stack/legend anchor preferences
|
|
659
|
+
cfg["stack_label_at_bottom"] = getattr(fig, '_stack_label_at_bottom', False)
|
|
660
|
+
cfg["label_anchor_left"] = getattr(fig, '_label_anchor_left', False)
|
|
661
|
+
# Save CIF title visibility
|
|
662
|
+
if show_cif_titles is not None:
|
|
663
|
+
cfg["show_cif_titles"] = bool(show_cif_titles)
|
|
664
|
+
if cif_tick_series:
|
|
665
|
+
cfg["cif_ticks"] = [
|
|
666
|
+
{"index": i, "color": color}
|
|
667
|
+
for i, (lab, fname, peaksQ, wl, qmax_sim, color) in enumerate(cif_tick_series)
|
|
668
|
+
]
|
|
669
|
+
palette_history = getattr(fig, '_curve_palette_history', None)
|
|
670
|
+
if palette_history:
|
|
671
|
+
serialized_palettes = []
|
|
672
|
+
for entry in palette_history:
|
|
673
|
+
palette_name = entry.get('palette')
|
|
674
|
+
indices = entry.get('indices', [])
|
|
675
|
+
if not palette_name or not indices:
|
|
676
|
+
continue
|
|
677
|
+
serialized_palettes.append({
|
|
678
|
+
'palette': palette_name,
|
|
679
|
+
'indices': list(indices),
|
|
680
|
+
'low_clip': float(entry.get('low_clip', 0.08)),
|
|
681
|
+
'high_clip': float(entry.get('high_clip', 0.85)),
|
|
682
|
+
})
|
|
683
|
+
if serialized_palettes:
|
|
684
|
+
cfg['curve_palettes'] = serialized_palettes
|
|
685
|
+
|
|
686
|
+
# If overwrite_path is provided, determine export type from existing file
|
|
687
|
+
if overwrite_path:
|
|
688
|
+
try:
|
|
689
|
+
with open(overwrite_path, 'r', encoding='utf-8') as f:
|
|
690
|
+
old_cfg = json.load(f)
|
|
691
|
+
old_kind = old_cfg.get('kind', '')
|
|
692
|
+
if old_kind == 'xy_style_geom':
|
|
693
|
+
exp_choice = 'psg'
|
|
694
|
+
else:
|
|
695
|
+
exp_choice = 'ps'
|
|
696
|
+
except Exception:
|
|
697
|
+
exp_choice = 'ps' # Default to style-only if can't read
|
|
698
|
+
else:
|
|
699
|
+
# Ask user for style-only or style+geometry
|
|
700
|
+
print("\nExport options:")
|
|
701
|
+
print(" ps = style only (.bps)")
|
|
702
|
+
print(" psg = style + geometry (.bpsg)")
|
|
703
|
+
exp_choice = input("Export choice (ps/psg, q=cancel): ").strip().lower()
|
|
704
|
+
if not exp_choice or exp_choice == 'q':
|
|
705
|
+
print("Style export canceled.")
|
|
706
|
+
return None
|
|
707
|
+
|
|
708
|
+
# Determine file extension and add geometry if requested
|
|
709
|
+
if exp_choice == 'ps':
|
|
710
|
+
cfg['kind'] = 'xy_style'
|
|
711
|
+
default_ext = '.bps'
|
|
712
|
+
elif exp_choice == 'psg':
|
|
713
|
+
cfg['kind'] = 'xy_style_geom'
|
|
714
|
+
# Add geometry information
|
|
715
|
+
cfg['geometry'] = {
|
|
716
|
+
'xlabel': ax.get_xlabel() or '',
|
|
717
|
+
'ylabel': ax.get_ylabel() or '',
|
|
718
|
+
'xlim': list(ax.get_xlim()),
|
|
719
|
+
'ylim': list(ax.get_ylim()),
|
|
720
|
+
# Store the x/y ranges that the current data was normalized to
|
|
721
|
+
'norm_xlim': list(getattr(ax, '_norm_xlim', ax.get_xlim())),
|
|
722
|
+
'norm_ylim': list(getattr(ax, '_norm_ylim', ax.get_ylim())),
|
|
723
|
+
}
|
|
724
|
+
default_ext = '.bpsg'
|
|
725
|
+
else:
|
|
726
|
+
print(f"Unknown option: {exp_choice}")
|
|
727
|
+
return
|
|
728
|
+
|
|
729
|
+
# If overwrite_path is provided, use it directly
|
|
730
|
+
if overwrite_path:
|
|
731
|
+
target_path = overwrite_path
|
|
732
|
+
else:
|
|
733
|
+
# List existing files for user convenience (from Styles subdirectory)
|
|
734
|
+
import os
|
|
735
|
+
from .utils import list_files_in_subdirectory, get_organized_path
|
|
736
|
+
|
|
737
|
+
if base_path:
|
|
738
|
+
print(f"\nChosen path: {base_path}")
|
|
739
|
+
file_list = list_files_in_subdirectory((default_ext, '.bpcfg'), 'style', base_path=base_path)
|
|
740
|
+
style_files = [f[0] for f in file_list]
|
|
741
|
+
|
|
742
|
+
if style_files:
|
|
743
|
+
styles_root = base_path if base_path else os.getcwd()
|
|
744
|
+
styles_dir = os.path.join(styles_root, 'Styles')
|
|
745
|
+
print(f"\nExisting {default_ext} files in {styles_dir}:")
|
|
746
|
+
for i, f in enumerate(style_files, 1):
|
|
747
|
+
print(f" {i}: {f}")
|
|
748
|
+
|
|
749
|
+
last_style_path = getattr(fig, '_last_style_export_path', None)
|
|
750
|
+
if last_style_path:
|
|
751
|
+
choice = input("Export to file? Enter filename, number to overwrite, or o to overwrite last (q=cancel): ").strip()
|
|
752
|
+
else:
|
|
753
|
+
choice = input("Export to file? Enter filename or number to overwrite (q=cancel): ").strip()
|
|
754
|
+
if not choice or choice.lower() == 'q':
|
|
755
|
+
print("Style export canceled.")
|
|
756
|
+
return None
|
|
757
|
+
if choice.lower() == 'o':
|
|
758
|
+
# Overwrite last exported style file - handled by caller
|
|
759
|
+
if not last_style_path:
|
|
760
|
+
print("No previous export found.")
|
|
761
|
+
return None
|
|
762
|
+
if not os.path.exists(last_style_path):
|
|
763
|
+
print(f"Previous export file not found: {last_style_path}")
|
|
764
|
+
return None
|
|
765
|
+
target_path = last_style_path
|
|
766
|
+
else:
|
|
767
|
+
# Determine the target path
|
|
768
|
+
if choice.isdigit() and style_files and 1 <= int(choice) <= len(style_files):
|
|
769
|
+
target_path = file_list[int(choice) - 1][1] # Full path from list
|
|
770
|
+
else:
|
|
771
|
+
# Add default extension if no extension provided
|
|
772
|
+
if not any(choice.lower().endswith(ext) for ext in ['.bps', '.bpsg', '.bpcfg']):
|
|
773
|
+
filename_with_ext = f"{choice}{default_ext}"
|
|
774
|
+
else:
|
|
775
|
+
filename_with_ext = choice
|
|
776
|
+
|
|
777
|
+
# Use organized path unless it's an absolute path
|
|
778
|
+
if os.path.isabs(filename_with_ext):
|
|
779
|
+
target_path = filename_with_ext
|
|
780
|
+
else:
|
|
781
|
+
target_path = get_organized_path(filename_with_ext, 'style', base_path=base_path)
|
|
782
|
+
|
|
783
|
+
# Only prompt ONCE for overwrite if the file exists
|
|
784
|
+
if os.path.exists(target_path):
|
|
785
|
+
yn = input(f"Overwrite '{os.path.basename(target_path)}'? (y/n): ").strip().lower()
|
|
786
|
+
if yn != 'y':
|
|
787
|
+
print("Style export canceled.")
|
|
788
|
+
return None
|
|
789
|
+
|
|
790
|
+
# Ensure exact case is preserved (important for macOS case-insensitive filesystem)
|
|
791
|
+
from .utils import ensure_exact_case_filename
|
|
792
|
+
target_path = ensure_exact_case_filename(target_path)
|
|
793
|
+
|
|
794
|
+
with open(target_path, "w", encoding="utf-8") as f:
|
|
795
|
+
json.dump(cfg, f, indent=2)
|
|
796
|
+
print(f"Exported style to {target_path}")
|
|
797
|
+
return target_path
|
|
798
|
+
except Exception as e:
|
|
799
|
+
print(f"Error exporting style: {e}")
|
|
800
|
+
return None
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def apply_style_config(
|
|
804
|
+
filename: str,
|
|
805
|
+
fig,
|
|
806
|
+
ax,
|
|
807
|
+
x_data_list: List[np.ndarray] | None,
|
|
808
|
+
y_data_list: List[np.ndarray],
|
|
809
|
+
orig_y: List[np.ndarray] | None,
|
|
810
|
+
offsets_list: List[float] | None,
|
|
811
|
+
label_text_objects: List,
|
|
812
|
+
args,
|
|
813
|
+
tick_state: Dict[str, bool],
|
|
814
|
+
labels: List[str],
|
|
815
|
+
update_labels_func: Callable[[Any, List[np.ndarray], List, bool], None],
|
|
816
|
+
cif_tick_series: Optional[List[tuple]] = None,
|
|
817
|
+
cif_hkl_label_map: Optional[Dict[str, Dict[float, str]]] = None,
|
|
818
|
+
adjust_margins_cb: Optional[Callable[[], None]] = None,
|
|
819
|
+
keep_canvas_fixed: bool = False,
|
|
820
|
+
) -> None:
|
|
821
|
+
def _apply_spine_color(spine_name: str, color) -> None:
|
|
822
|
+
if color is None:
|
|
823
|
+
return
|
|
824
|
+
try:
|
|
825
|
+
if spine_name in ('top', 'bottom'):
|
|
826
|
+
ax.tick_params(axis='x', which='both', colors=color)
|
|
827
|
+
ax.xaxis.label.set_color(color)
|
|
828
|
+
ax._stored_xlabel_color = color
|
|
829
|
+
if spine_name == 'top':
|
|
830
|
+
ax._stored_top_xlabel_color = color
|
|
831
|
+
artist = getattr(ax, '_top_xlabel_artist', None)
|
|
832
|
+
if artist is not None:
|
|
833
|
+
artist.set_color(color)
|
|
834
|
+
else:
|
|
835
|
+
ax._stored_xlabel = ax.get_xlabel()
|
|
836
|
+
else:
|
|
837
|
+
ax.tick_params(axis='y', which='both', colors=color)
|
|
838
|
+
ax.yaxis.label.set_color(color)
|
|
839
|
+
ax._stored_ylabel_color = color
|
|
840
|
+
if spine_name == 'right':
|
|
841
|
+
ax._stored_right_ylabel_color = color
|
|
842
|
+
artist = getattr(ax, '_right_ylabel_artist', None)
|
|
843
|
+
if artist is not None:
|
|
844
|
+
artist.set_color(color)
|
|
845
|
+
else:
|
|
846
|
+
ax._stored_ylabel = ax.get_ylabel()
|
|
847
|
+
except Exception:
|
|
848
|
+
pass
|
|
849
|
+
|
|
850
|
+
try:
|
|
851
|
+
with open(filename, "r", encoding="utf-8") as f:
|
|
852
|
+
cfg = json.load(f)
|
|
853
|
+
except Exception as e:
|
|
854
|
+
print(f"Could not read config: {e}")
|
|
855
|
+
return
|
|
856
|
+
# Save current labelpad values BEFORE any style changes
|
|
857
|
+
saved_xlabelpad = None
|
|
858
|
+
saved_ylabelpad = None
|
|
859
|
+
try:
|
|
860
|
+
saved_xlabelpad = getattr(ax.xaxis, 'labelpad', None)
|
|
861
|
+
except Exception:
|
|
862
|
+
pass
|
|
863
|
+
try:
|
|
864
|
+
saved_ylabelpad = getattr(ax.yaxis, 'labelpad', None)
|
|
865
|
+
except Exception:
|
|
866
|
+
pass
|
|
867
|
+
try:
|
|
868
|
+
figure_cfg = cfg.get("figure", {})
|
|
869
|
+
# Get axes_fraction BEFORE changing canvas size (to preserve exact position)
|
|
870
|
+
axes_frac = figure_cfg.get("axes_fraction")
|
|
871
|
+
frame_size = figure_cfg.get("frame_size")
|
|
872
|
+
|
|
873
|
+
sz = figure_cfg.get("canvas_size") or figure_cfg.get("size")
|
|
874
|
+
if isinstance(sz, (list, tuple)) and len(sz) == 2:
|
|
875
|
+
try:
|
|
876
|
+
fw = float(sz[0])
|
|
877
|
+
fh = float(sz[1])
|
|
878
|
+
if not keep_canvas_fixed:
|
|
879
|
+
# Use forward=False to prevent automatic subplot adjustment that can shift the plot
|
|
880
|
+
fig.set_size_inches(fw, fh, forward=False)
|
|
881
|
+
else:
|
|
882
|
+
print("(Canvas fixed) Ignoring style figure size request.")
|
|
883
|
+
except Exception as e:
|
|
884
|
+
print(f"Warning: could not parse figure size: {e}")
|
|
885
|
+
try:
|
|
886
|
+
if axes_frac and isinstance(axes_frac, (list, tuple)) and len(axes_frac) == 4:
|
|
887
|
+
x0, y0, w, h = axes_frac
|
|
888
|
+
left = float(x0)
|
|
889
|
+
bottom = float(y0)
|
|
890
|
+
right = left + float(w)
|
|
891
|
+
top = bottom + float(h)
|
|
892
|
+
if 0 < left < right <= 1 and 0 < bottom < top <= 1:
|
|
893
|
+
fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
|
|
894
|
+
elif frame_size and isinstance(frame_size, (list, tuple)) and len(frame_size) == 2:
|
|
895
|
+
cur_fw, cur_fh = fig.get_size_inches()
|
|
896
|
+
des_w, des_h = float(frame_size[0]), float(frame_size[1])
|
|
897
|
+
min_margin = 0.05
|
|
898
|
+
w_frac = min(des_w / cur_fw, 1 - 2 * min_margin)
|
|
899
|
+
h_frac = min(des_h / cur_fh, 1 - 2 * min_margin)
|
|
900
|
+
left = (1 - w_frac) / 2
|
|
901
|
+
bottom = (1 - h_frac) / 2
|
|
902
|
+
fig.subplots_adjust(left=left, right=left + w_frac, bottom=bottom, top=bottom + h_frac)
|
|
903
|
+
except Exception as e:
|
|
904
|
+
print(f"[DEBUG] Exception in frame/axes fraction adjustment: {e}")
|
|
905
|
+
# Don't restore DPI from style - use system default to avoid display-dependent issues
|
|
906
|
+
# (Retina displays, Windows scaling, etc. can cause saved DPI to differ)
|
|
907
|
+
|
|
908
|
+
# Font
|
|
909
|
+
font_cfg = cfg.get("font", {})
|
|
910
|
+
fam_chain = font_cfg.get("family_chain")
|
|
911
|
+
if not fam_chain:
|
|
912
|
+
# Accept legacy/simple form: { "family": "Arial" }
|
|
913
|
+
fam = font_cfg.get("family")
|
|
914
|
+
if isinstance(fam, str) and fam.strip():
|
|
915
|
+
fam_chain = [fam.strip(), 'DejaVu Sans', 'Arial', 'Helvetica']
|
|
916
|
+
size_val = font_cfg.get("size")
|
|
917
|
+
if fam_chain:
|
|
918
|
+
plt.rcParams["font.family"] = "sans-serif"
|
|
919
|
+
plt.rcParams["font.sans-serif"] = fam_chain
|
|
920
|
+
numeric_size = None
|
|
921
|
+
if size_val is not None:
|
|
922
|
+
try:
|
|
923
|
+
numeric_size = float(size_val)
|
|
924
|
+
plt.rcParams["font.size"] = numeric_size
|
|
925
|
+
except Exception as e:
|
|
926
|
+
print(f"[DEBUG] Exception parsing font size: {e}")
|
|
927
|
+
numeric_size = None
|
|
928
|
+
|
|
929
|
+
# Do not change axis labels or limits in Styles import
|
|
930
|
+
|
|
931
|
+
# Apply font changes to existing text objects
|
|
932
|
+
if fam_chain or numeric_size is not None:
|
|
933
|
+
for txt in label_text_objects:
|
|
934
|
+
if numeric_size is not None:
|
|
935
|
+
txt.set_fontsize(numeric_size)
|
|
936
|
+
if fam_chain:
|
|
937
|
+
txt.set_fontfamily(fam_chain[0])
|
|
938
|
+
for axis_label in (ax.xaxis.label, ax.yaxis.label):
|
|
939
|
+
if numeric_size is not None:
|
|
940
|
+
axis_label.set_fontsize(numeric_size)
|
|
941
|
+
if fam_chain:
|
|
942
|
+
axis_label.set_fontfamily(fam_chain[0])
|
|
943
|
+
for lbl in ax.get_xticklabels() + ax.get_yticklabels():
|
|
944
|
+
if numeric_size is not None:
|
|
945
|
+
lbl.set_fontsize(numeric_size)
|
|
946
|
+
if fam_chain:
|
|
947
|
+
lbl.set_fontfamily(fam_chain[0])
|
|
948
|
+
# Also update top/right tick labels (label2)
|
|
949
|
+
try:
|
|
950
|
+
for t in ax.xaxis.get_major_ticks():
|
|
951
|
+
if hasattr(t, 'label2'):
|
|
952
|
+
if numeric_size is not None:
|
|
953
|
+
t.label2.set_fontsize(numeric_size)
|
|
954
|
+
if fam_chain:
|
|
955
|
+
t.label2.set_fontfamily(fam_chain[0])
|
|
956
|
+
for t in ax.yaxis.get_major_ticks():
|
|
957
|
+
if hasattr(t, 'label2'):
|
|
958
|
+
if numeric_size is not None:
|
|
959
|
+
t.label2.set_fontsize(numeric_size)
|
|
960
|
+
if fam_chain:
|
|
961
|
+
t.label2.set_fontfamily(fam_chain[0])
|
|
962
|
+
except Exception:
|
|
963
|
+
pass
|
|
964
|
+
# Also update duplicate top/right artists if they exist
|
|
965
|
+
try:
|
|
966
|
+
art = getattr(ax, '_top_xlabel_artist', None)
|
|
967
|
+
if art is not None:
|
|
968
|
+
if numeric_size is not None:
|
|
969
|
+
art.set_fontsize(numeric_size)
|
|
970
|
+
if fam_chain:
|
|
971
|
+
art.set_fontfamily(fam_chain[0])
|
|
972
|
+
except Exception:
|
|
973
|
+
pass
|
|
974
|
+
try:
|
|
975
|
+
art = getattr(ax, '_right_ylabel_artist', None)
|
|
976
|
+
if art is not None:
|
|
977
|
+
if numeric_size is not None:
|
|
978
|
+
art.set_fontsize(numeric_size)
|
|
979
|
+
if fam_chain:
|
|
980
|
+
art.set_fontfamily(fam_chain[0])
|
|
981
|
+
except Exception:
|
|
982
|
+
pass
|
|
983
|
+
|
|
984
|
+
# Tick visibility + widths
|
|
985
|
+
ticks_cfg = cfg.get("ticks", {})
|
|
986
|
+
|
|
987
|
+
# Try wasd_state first (version 2), fall back to visibility dict (version 1)
|
|
988
|
+
wasd = cfg.get("wasd_state", {})
|
|
989
|
+
if wasd:
|
|
990
|
+
# Apply WASD state (20 parameters)
|
|
991
|
+
try:
|
|
992
|
+
# Apply spines from wasd
|
|
993
|
+
for side in ('top', 'bottom', 'left', 'right'):
|
|
994
|
+
side_cfg = wasd.get(side, {})
|
|
995
|
+
if 'spine' in side_cfg and side in ax.spines:
|
|
996
|
+
ax.spines[side].set_visible(bool(side_cfg['spine']))
|
|
997
|
+
|
|
998
|
+
# Apply ticks and labels
|
|
999
|
+
top_cfg = wasd.get('top', {})
|
|
1000
|
+
bot_cfg = wasd.get('bottom', {})
|
|
1001
|
+
left_cfg = wasd.get('left', {})
|
|
1002
|
+
right_cfg = wasd.get('right', {})
|
|
1003
|
+
|
|
1004
|
+
ax.tick_params(axis='x',
|
|
1005
|
+
top=bool(top_cfg.get('ticks', False)),
|
|
1006
|
+
bottom=bool(bot_cfg.get('ticks', True)),
|
|
1007
|
+
labeltop=bool(top_cfg.get('labels', False)),
|
|
1008
|
+
labelbottom=bool(bot_cfg.get('labels', True)))
|
|
1009
|
+
ax.tick_params(axis='y',
|
|
1010
|
+
left=bool(left_cfg.get('ticks', True)),
|
|
1011
|
+
right=bool(right_cfg.get('ticks', False)),
|
|
1012
|
+
labelleft=bool(left_cfg.get('labels', True)),
|
|
1013
|
+
labelright=bool(right_cfg.get('labels', False)))
|
|
1014
|
+
|
|
1015
|
+
# Apply minor ticks
|
|
1016
|
+
if top_cfg.get('minor') or bot_cfg.get('minor'):
|
|
1017
|
+
from matplotlib.ticker import AutoMinorLocator, NullFormatter
|
|
1018
|
+
ax.xaxis.set_minor_locator(AutoMinorLocator())
|
|
1019
|
+
ax.xaxis.set_minor_formatter(NullFormatter())
|
|
1020
|
+
ax.tick_params(axis='x', which='minor',
|
|
1021
|
+
top=bool(top_cfg.get('minor', False)),
|
|
1022
|
+
bottom=bool(bot_cfg.get('minor', False)),
|
|
1023
|
+
labeltop=False, labelbottom=False)
|
|
1024
|
+
|
|
1025
|
+
if left_cfg.get('minor') or right_cfg.get('minor'):
|
|
1026
|
+
from matplotlib.ticker import AutoMinorLocator, NullFormatter
|
|
1027
|
+
ax.yaxis.set_minor_locator(AutoMinorLocator())
|
|
1028
|
+
ax.yaxis.set_minor_formatter(NullFormatter())
|
|
1029
|
+
ax.tick_params(axis='y', which='minor',
|
|
1030
|
+
left=bool(left_cfg.get('minor', False)),
|
|
1031
|
+
right=bool(right_cfg.get('minor', False)),
|
|
1032
|
+
labelleft=False, labelright=False)
|
|
1033
|
+
|
|
1034
|
+
# Apply titles
|
|
1035
|
+
ax._top_xlabel_on = bool(top_cfg.get('title', False))
|
|
1036
|
+
ax._right_ylabel_on = bool(right_cfg.get('title', False))
|
|
1037
|
+
|
|
1038
|
+
# Update tick_state for consistency
|
|
1039
|
+
tick_state['t_ticks'] = bool(top_cfg.get('ticks', False))
|
|
1040
|
+
tick_state['t_labels'] = bool(top_cfg.get('labels', False))
|
|
1041
|
+
tick_state['b_ticks'] = bool(bot_cfg.get('ticks', True))
|
|
1042
|
+
tick_state['b_labels'] = bool(bot_cfg.get('labels', True))
|
|
1043
|
+
tick_state['l_ticks'] = bool(left_cfg.get('ticks', True))
|
|
1044
|
+
tick_state['l_labels'] = bool(left_cfg.get('labels', True))
|
|
1045
|
+
tick_state['r_ticks'] = bool(right_cfg.get('ticks', False))
|
|
1046
|
+
tick_state['r_labels'] = bool(right_cfg.get('labels', False))
|
|
1047
|
+
tick_state['mtx'] = bool(top_cfg.get('minor', False))
|
|
1048
|
+
tick_state['mbx'] = bool(bot_cfg.get('minor', False))
|
|
1049
|
+
tick_state['mly'] = bool(left_cfg.get('minor', False))
|
|
1050
|
+
tick_state['mry'] = bool(right_cfg.get('minor', False))
|
|
1051
|
+
|
|
1052
|
+
except Exception as e:
|
|
1053
|
+
print(f"Warning: Could not apply WASD tick visibility: {e}")
|
|
1054
|
+
else:
|
|
1055
|
+
# Fall back to old visibility dict
|
|
1056
|
+
vis_cfg = ticks_cfg.get("visibility", {})
|
|
1057
|
+
changed_visibility = False
|
|
1058
|
+
for k, v in vis_cfg.items():
|
|
1059
|
+
if k in tick_state and isinstance(v, bool):
|
|
1060
|
+
tick_state[k] = v
|
|
1061
|
+
changed_visibility = True
|
|
1062
|
+
if changed_visibility:
|
|
1063
|
+
try:
|
|
1064
|
+
_ui_update_tick_visibility(ax, tick_state)
|
|
1065
|
+
except Exception as e:
|
|
1066
|
+
print(f"[DEBUG] Exception updating tick visibility: {e}")
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
xmaj = ticks_cfg.get("x_major_width")
|
|
1070
|
+
xminr = ticks_cfg.get("x_minor_width")
|
|
1071
|
+
ymaj = ticks_cfg.get("y_major_width")
|
|
1072
|
+
yminr = ticks_cfg.get("y_minor_width")
|
|
1073
|
+
if any(v is not None for v in (xmaj, xminr, ymaj, yminr)):
|
|
1074
|
+
try:
|
|
1075
|
+
if xmaj is not None:
|
|
1076
|
+
ax.tick_params(axis="x", which="major", width=xmaj)
|
|
1077
|
+
if xminr is not None:
|
|
1078
|
+
ax.tick_params(axis="x", which="minor", width=xminr)
|
|
1079
|
+
if ymaj is not None:
|
|
1080
|
+
ax.tick_params(axis="y", which="major", width=ymaj)
|
|
1081
|
+
if yminr is not None:
|
|
1082
|
+
ax.tick_params(axis="y", which="minor", width=yminr)
|
|
1083
|
+
except Exception as e:
|
|
1084
|
+
print(f"[DEBUG] Exception setting tick widths: {e}")
|
|
1085
|
+
|
|
1086
|
+
# Spines
|
|
1087
|
+
for name, sp_dict in cfg.get("spines", {}).items():
|
|
1088
|
+
if name in ax.spines:
|
|
1089
|
+
if "linewidth" in sp_dict:
|
|
1090
|
+
ax.spines[name].set_linewidth(sp_dict["linewidth"])
|
|
1091
|
+
if "color" in sp_dict:
|
|
1092
|
+
try:
|
|
1093
|
+
ax.spines[name].set_edgecolor(sp_dict["color"])
|
|
1094
|
+
except Exception:
|
|
1095
|
+
pass
|
|
1096
|
+
_apply_spine_color(name, sp_dict.get("color"))
|
|
1097
|
+
if "visible" in sp_dict:
|
|
1098
|
+
ax.spines[name].set_visible(sp_dict["visible"])
|
|
1099
|
+
|
|
1100
|
+
# Tick colors
|
|
1101
|
+
tick_colors = cfg.get("tick_colors", {})
|
|
1102
|
+
if tick_colors:
|
|
1103
|
+
try:
|
|
1104
|
+
if "x" in tick_colors:
|
|
1105
|
+
ax.tick_params(axis='x', which='both', colors=tick_colors["x"])
|
|
1106
|
+
if "y" in tick_colors:
|
|
1107
|
+
ax.tick_params(axis='y', which='both', colors=tick_colors["y"])
|
|
1108
|
+
except Exception as e:
|
|
1109
|
+
print(f"[DEBUG] Exception setting tick colors: {e}")
|
|
1110
|
+
|
|
1111
|
+
# Axis label colors
|
|
1112
|
+
axis_label_colors = cfg.get("axis_label_colors", {})
|
|
1113
|
+
if axis_label_colors:
|
|
1114
|
+
try:
|
|
1115
|
+
if "x" in axis_label_colors:
|
|
1116
|
+
ax.xaxis.label.set_color(axis_label_colors["x"])
|
|
1117
|
+
if "y" in axis_label_colors:
|
|
1118
|
+
ax.yaxis.label.set_color(axis_label_colors["y"])
|
|
1119
|
+
except Exception as e:
|
|
1120
|
+
print(f"[DEBUG] Exception setting axis label colors: {e}")
|
|
1121
|
+
|
|
1122
|
+
# Lines
|
|
1123
|
+
for entry in cfg.get("lines", []):
|
|
1124
|
+
idx = entry.get("index")
|
|
1125
|
+
if idx is None or not (0 <= idx < len(ax.lines)):
|
|
1126
|
+
continue
|
|
1127
|
+
ln = ax.lines[idx]
|
|
1128
|
+
if "color" in entry and entry["color"] is not None:
|
|
1129
|
+
ln.set_color(entry["color"])
|
|
1130
|
+
if "linewidth" in entry:
|
|
1131
|
+
ln.set_linewidth(entry["linewidth"])
|
|
1132
|
+
if "linestyle" in entry:
|
|
1133
|
+
try:
|
|
1134
|
+
ln.set_linestyle(entry["linestyle"])
|
|
1135
|
+
except Exception:
|
|
1136
|
+
pass
|
|
1137
|
+
if "marker" in entry:
|
|
1138
|
+
try:
|
|
1139
|
+
ln.set_marker(entry["marker"])
|
|
1140
|
+
except Exception:
|
|
1141
|
+
pass
|
|
1142
|
+
if "markersize" in entry:
|
|
1143
|
+
try:
|
|
1144
|
+
ln.set_markersize(entry["markersize"])
|
|
1145
|
+
except Exception:
|
|
1146
|
+
pass
|
|
1147
|
+
if "markerfacecolor" in entry and entry["markerfacecolor"] is not None:
|
|
1148
|
+
try:
|
|
1149
|
+
ln.set_markerfacecolor(entry["markerfacecolor"])
|
|
1150
|
+
except Exception:
|
|
1151
|
+
pass
|
|
1152
|
+
if "markeredgecolor" in entry and entry["markeredgecolor"] is not None:
|
|
1153
|
+
try:
|
|
1154
|
+
ln.set_markeredgecolor(entry["markeredgecolor"])
|
|
1155
|
+
except Exception:
|
|
1156
|
+
pass
|
|
1157
|
+
if "alpha" in entry and entry["alpha"] is not None:
|
|
1158
|
+
try:
|
|
1159
|
+
ln.set_alpha(entry["alpha"])
|
|
1160
|
+
except Exception:
|
|
1161
|
+
pass
|
|
1162
|
+
# Restore offset if available
|
|
1163
|
+
if "offset" in entry and offsets_list is not None and orig_y is not None and x_data_list is not None:
|
|
1164
|
+
try:
|
|
1165
|
+
offset_val = float(entry["offset"])
|
|
1166
|
+
if idx < len(offsets_list):
|
|
1167
|
+
offsets_list[idx] = offset_val
|
|
1168
|
+
# Reapply offset to the curve
|
|
1169
|
+
if idx < len(orig_y) and idx < len(y_data_list) and idx < len(x_data_list):
|
|
1170
|
+
y_norm = orig_y[idx]
|
|
1171
|
+
y_with_offset = y_norm + offset_val
|
|
1172
|
+
y_data_list[idx] = y_with_offset
|
|
1173
|
+
ln.set_data(x_data_list[idx], y_with_offset)
|
|
1174
|
+
except Exception as e:
|
|
1175
|
+
print(f"Warning: Could not restore offset for curve {idx+1}: {e}")
|
|
1176
|
+
palette_cfg = cfg.get("curve_palettes", [])
|
|
1177
|
+
if palette_cfg:
|
|
1178
|
+
sanitized_history = []
|
|
1179
|
+
for rec in palette_cfg:
|
|
1180
|
+
if _apply_curve_palette(ax, rec):
|
|
1181
|
+
sanitized_history.append({
|
|
1182
|
+
'palette': rec.get('palette'),
|
|
1183
|
+
'indices': list(rec.get('indices', [])),
|
|
1184
|
+
'low_clip': float(rec.get('low_clip', 0.08)),
|
|
1185
|
+
'high_clip': float(rec.get('high_clip', 0.85)),
|
|
1186
|
+
})
|
|
1187
|
+
if sanitized_history:
|
|
1188
|
+
fig._curve_palette_history = sanitized_history
|
|
1189
|
+
elif hasattr(fig, '_curve_palette_history'):
|
|
1190
|
+
delattr(fig, '_curve_palette_history')
|
|
1191
|
+
else:
|
|
1192
|
+
if hasattr(fig, '_curve_palette_history'):
|
|
1193
|
+
delattr(fig, '_curve_palette_history')
|
|
1194
|
+
# CIF tick sets (labels & colors)
|
|
1195
|
+
cif_cfg = cfg.get("cif_ticks", [])
|
|
1196
|
+
if cif_cfg and cif_tick_series is not None:
|
|
1197
|
+
for entry in cif_cfg:
|
|
1198
|
+
idx = entry.get("index")
|
|
1199
|
+
if idx is None:
|
|
1200
|
+
continue
|
|
1201
|
+
if 0 <= idx < len(cif_tick_series):
|
|
1202
|
+
lab, fname, peaksQ, wl, qmax_sim, color_old = cif_tick_series[idx]
|
|
1203
|
+
lab_new = entry.get("label", lab)
|
|
1204
|
+
color_new = entry.get("color", color_old)
|
|
1205
|
+
cif_tick_series[idx] = (lab_new, fname, peaksQ, wl, qmax_sim, color_new)
|
|
1206
|
+
# Restore CIF title visibility
|
|
1207
|
+
if "show_cif_titles" in cfg:
|
|
1208
|
+
try:
|
|
1209
|
+
_bp_module = sys.modules.get('__main__')
|
|
1210
|
+
if _bp_module is not None:
|
|
1211
|
+
setattr(_bp_module, 'show_cif_titles', bool(cfg["show_cif_titles"]))
|
|
1212
|
+
except Exception:
|
|
1213
|
+
pass
|
|
1214
|
+
# Redraw CIF ticks after applying changes
|
|
1215
|
+
if (cif_cfg and cif_tick_series is not None) or "show_cif_titles" in cfg:
|
|
1216
|
+
if hasattr(ax, "_cif_draw_func"):
|
|
1217
|
+
try:
|
|
1218
|
+
ax._cif_draw_func()
|
|
1219
|
+
except Exception:
|
|
1220
|
+
pass
|
|
1221
|
+
|
|
1222
|
+
# Restore curve names visibility
|
|
1223
|
+
if "curve_names_visible" in cfg:
|
|
1224
|
+
try:
|
|
1225
|
+
visible = bool(cfg["curve_names_visible"])
|
|
1226
|
+
for txt in label_text_objects:
|
|
1227
|
+
txt.set_visible(visible)
|
|
1228
|
+
# Store on figure for persistence
|
|
1229
|
+
fig._curve_names_visible = visible
|
|
1230
|
+
except Exception as e:
|
|
1231
|
+
print(f"Warning: Could not restore curve names visibility: {e}")
|
|
1232
|
+
|
|
1233
|
+
# Restore stack/legend anchor preferences
|
|
1234
|
+
if "stack_label_at_bottom" in cfg:
|
|
1235
|
+
try:
|
|
1236
|
+
fig._stack_label_at_bottom = bool(cfg["stack_label_at_bottom"])
|
|
1237
|
+
except Exception as e:
|
|
1238
|
+
print(f"Warning: Could not restore stack label position: {e}")
|
|
1239
|
+
if "label_anchor_left" in cfg:
|
|
1240
|
+
try:
|
|
1241
|
+
fig._label_anchor_left = bool(cfg["label_anchor_left"])
|
|
1242
|
+
except Exception as e:
|
|
1243
|
+
print(f"Warning: Could not restore legend horizontal anchor: {e}")
|
|
1244
|
+
|
|
1245
|
+
# Restore rotation angle
|
|
1246
|
+
if "rotation_angle" in cfg:
|
|
1247
|
+
try:
|
|
1248
|
+
ax._rotation_angle = int(cfg["rotation_angle"])
|
|
1249
|
+
except Exception as e:
|
|
1250
|
+
print(f"Warning: Could not restore rotation angle: {e}")
|
|
1251
|
+
|
|
1252
|
+
# Restore title offsets BEFORE positioning titles
|
|
1253
|
+
title_offsets = cfg.get("title_offsets", {})
|
|
1254
|
+
if title_offsets:
|
|
1255
|
+
try:
|
|
1256
|
+
if 'top_y' in title_offsets:
|
|
1257
|
+
ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top_y', 0.0) or 0.0)
|
|
1258
|
+
else:
|
|
1259
|
+
# Backward compatibility: old format used 'top' for y-offset
|
|
1260
|
+
ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top', 0.0) or 0.0)
|
|
1261
|
+
ax._top_xlabel_manual_offset_x_pts = float(title_offsets.get('top_x', 0.0) or 0.0)
|
|
1262
|
+
ax._bottom_xlabel_manual_offset_y_pts = float(title_offsets.get('bottom_y', 0.0) or 0.0)
|
|
1263
|
+
ax._left_ylabel_manual_offset_x_pts = float(title_offsets.get('left_x', 0.0) or 0.0)
|
|
1264
|
+
if 'right_x' in title_offsets:
|
|
1265
|
+
ax._right_ylabel_manual_offset_x_pts = float(title_offsets.get('right_x', 0.0) or 0.0)
|
|
1266
|
+
else:
|
|
1267
|
+
# Backward compatibility: old format used 'right' for x-offset
|
|
1268
|
+
ax._right_ylabel_manual_offset_x_pts = float(title_offsets.get('right', 0.0) or 0.0)
|
|
1269
|
+
ax._right_ylabel_manual_offset_y_pts = float(title_offsets.get('right_y', 0.0) or 0.0)
|
|
1270
|
+
except Exception as e:
|
|
1271
|
+
print(f"Warning: Could not restore title offsets: {e}")
|
|
1272
|
+
|
|
1273
|
+
# Restore grid state
|
|
1274
|
+
if "grid" in cfg:
|
|
1275
|
+
try:
|
|
1276
|
+
if bool(cfg["grid"]):
|
|
1277
|
+
ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
|
|
1278
|
+
else:
|
|
1279
|
+
ax.grid(False)
|
|
1280
|
+
except Exception as e:
|
|
1281
|
+
print(f"Warning: Could not restore grid state: {e}")
|
|
1282
|
+
|
|
1283
|
+
# Re-run label placement with current mode (no mode changes via Styles)
|
|
1284
|
+
stack_label_bottom = getattr(fig, '_stack_label_at_bottom', False)
|
|
1285
|
+
update_labels_func(ax, y_data_list, label_text_objects, args.stack, stack_label_bottom)
|
|
1286
|
+
|
|
1287
|
+
# Margin / overflow handling
|
|
1288
|
+
try:
|
|
1289
|
+
overflow = _ui_ensure_text_visibility(fig, ax, label_text_objects, check_only=True)
|
|
1290
|
+
except Exception:
|
|
1291
|
+
overflow = False
|
|
1292
|
+
if overflow and adjust_margins_cb is not None:
|
|
1293
|
+
try:
|
|
1294
|
+
adjust_margins_cb()
|
|
1295
|
+
except Exception as e:
|
|
1296
|
+
print(f"[DEBUG] Exception in adjust_margins callback: {e}")
|
|
1297
|
+
try:
|
|
1298
|
+
_ui_ensure_text_visibility(fig, ax, label_text_objects)
|
|
1299
|
+
except Exception as e:
|
|
1300
|
+
print(f"[DEBUG] Exception in ensure_text_visibility: {e}")
|
|
1301
|
+
|
|
1302
|
+
# Apply geometry if present (for .bpsg files)
|
|
1303
|
+
kind = cfg.get('kind', '')
|
|
1304
|
+
if kind == 'xy_style_geom' and 'geometry' in cfg:
|
|
1305
|
+
try:
|
|
1306
|
+
geom = cfg.get('geometry', {})
|
|
1307
|
+
if 'xlabel' in geom and geom['xlabel']:
|
|
1308
|
+
ax.set_xlabel(geom['xlabel'])
|
|
1309
|
+
if 'ylabel' in geom and geom['ylabel']:
|
|
1310
|
+
ax.set_ylabel(geom['ylabel'])
|
|
1311
|
+
|
|
1312
|
+
# Restore normalization ranges (if saved)
|
|
1313
|
+
if 'norm_xlim' in geom and isinstance(geom['norm_xlim'], list) and len(geom['norm_xlim']) == 2:
|
|
1314
|
+
ax._norm_xlim = tuple(geom['norm_xlim'])
|
|
1315
|
+
if 'norm_ylim' in geom and isinstance(geom['norm_ylim'], list) and len(geom['norm_ylim']) == 2:
|
|
1316
|
+
ax._norm_ylim = tuple(geom['norm_ylim'])
|
|
1317
|
+
|
|
1318
|
+
# Restore display limits
|
|
1319
|
+
if 'xlim' in geom and isinstance(geom['xlim'], list) and len(geom['xlim']) == 2:
|
|
1320
|
+
ax.set_xlim(geom['xlim'][0], geom['xlim'][1])
|
|
1321
|
+
if 'ylim' in geom and isinstance(geom['ylim'], list) and len(geom['ylim']) == 2:
|
|
1322
|
+
ax.set_ylim(geom['ylim'][0], geom['ylim'][1])
|
|
1323
|
+
print("Applied geometry (labels and limits)")
|
|
1324
|
+
except Exception as e:
|
|
1325
|
+
print(f"Warning: Could not apply geometry: {e}")
|
|
1326
|
+
|
|
1327
|
+
try:
|
|
1328
|
+
fig.canvas.draw_idle()
|
|
1329
|
+
except Exception as e:
|
|
1330
|
+
print(f"[DEBUG] Exception in fig.canvas.draw_idle: {e}")
|
|
1331
|
+
print(f"Applied style from {filename}")
|
|
1332
|
+
|
|
1333
|
+
# Axis title toggle state
|
|
1334
|
+
try:
|
|
1335
|
+
# Preserve current pads to avoid drift when toggling presence via styles
|
|
1336
|
+
# Use saved values from before style changes, or current if not saved
|
|
1337
|
+
try:
|
|
1338
|
+
if saved_xlabelpad is not None:
|
|
1339
|
+
ax._pending_xlabelpad = saved_xlabelpad
|
|
1340
|
+
else:
|
|
1341
|
+
ax._pending_xlabelpad = getattr(ax.xaxis, 'labelpad', None)
|
|
1342
|
+
except Exception:
|
|
1343
|
+
pass
|
|
1344
|
+
try:
|
|
1345
|
+
if saved_ylabelpad is not None:
|
|
1346
|
+
ax._pending_ylabelpad = saved_ylabelpad
|
|
1347
|
+
else:
|
|
1348
|
+
ax._pending_ylabelpad = getattr(ax.yaxis, 'labelpad', None)
|
|
1349
|
+
except Exception:
|
|
1350
|
+
pass
|
|
1351
|
+
at_cfg = cfg.get("axis_titles", {})
|
|
1352
|
+
title_texts = cfg.get("axis_title_texts", {})
|
|
1353
|
+
bottom_text = title_texts.get("bottom_x")
|
|
1354
|
+
left_text = title_texts.get("left_y")
|
|
1355
|
+
top_text = title_texts.get("top_x")
|
|
1356
|
+
right_text = title_texts.get("right_y")
|
|
1357
|
+
if bottom_text is not None:
|
|
1358
|
+
ax._stored_xlabel = bottom_text
|
|
1359
|
+
if left_text is not None:
|
|
1360
|
+
ax._stored_ylabel = left_text
|
|
1361
|
+
if top_text is not None:
|
|
1362
|
+
if top_text:
|
|
1363
|
+
ax._top_xlabel_text_override = top_text
|
|
1364
|
+
elif hasattr(ax, '_top_xlabel_text_override'):
|
|
1365
|
+
delattr(ax, '_top_xlabel_text_override')
|
|
1366
|
+
if right_text is not None:
|
|
1367
|
+
if right_text:
|
|
1368
|
+
ax._right_ylabel_text_override = right_text
|
|
1369
|
+
elif hasattr(ax, '_right_ylabel_text_override'):
|
|
1370
|
+
delattr(ax, '_right_ylabel_text_override')
|
|
1371
|
+
# Top X duplicate via artist
|
|
1372
|
+
ax._top_xlabel_on = bool(at_cfg.get("top_x", False))
|
|
1373
|
+
try:
|
|
1374
|
+
_ui_position_top_xlabel(ax, fig, tick_state)
|
|
1375
|
+
except Exception:
|
|
1376
|
+
pass
|
|
1377
|
+
# Bottom X presence
|
|
1378
|
+
if not at_cfg.get("has_bottom_x", True):
|
|
1379
|
+
ax.xaxis.label.set_visible(False)
|
|
1380
|
+
else:
|
|
1381
|
+
ax.xaxis.label.set_visible(True)
|
|
1382
|
+
if bottom_text is not None:
|
|
1383
|
+
ax.set_xlabel(bottom_text)
|
|
1384
|
+
elif not ax.get_xlabel() and hasattr(ax, "_stored_xlabel"):
|
|
1385
|
+
ax.set_xlabel(ax._stored_xlabel)
|
|
1386
|
+
# Always re-position bottom xlabel to consume pending pad or set deterministic pad
|
|
1387
|
+
try:
|
|
1388
|
+
_ui_position_bottom_xlabel(ax, fig, tick_state)
|
|
1389
|
+
except Exception:
|
|
1390
|
+
pass
|
|
1391
|
+
# Right Y duplicate via artist
|
|
1392
|
+
ax._right_ylabel_on = bool(at_cfg.get("right_y", False))
|
|
1393
|
+
try:
|
|
1394
|
+
_ui_position_right_ylabel(ax, fig, tick_state)
|
|
1395
|
+
except Exception:
|
|
1396
|
+
pass
|
|
1397
|
+
# Left Y presence
|
|
1398
|
+
if not at_cfg.get("has_left_y", True):
|
|
1399
|
+
ax.yaxis.label.set_visible(False)
|
|
1400
|
+
else:
|
|
1401
|
+
ax.yaxis.label.set_visible(True)
|
|
1402
|
+
if left_text is not None:
|
|
1403
|
+
ax.set_ylabel(left_text)
|
|
1404
|
+
elif not ax.get_ylabel() and hasattr(ax, "_stored_ylabel"):
|
|
1405
|
+
ax.set_ylabel(ax._stored_ylabel)
|
|
1406
|
+
# Always re-position left ylabel to consume pending pad or set deterministic pad
|
|
1407
|
+
try:
|
|
1408
|
+
_ui_position_left_ylabel(ax, fig, tick_state)
|
|
1409
|
+
except Exception:
|
|
1410
|
+
pass
|
|
1411
|
+
# After positioning, ensure duplicate top/right title artists adopt imported font
|
|
1412
|
+
try:
|
|
1413
|
+
if numeric_size is not None:
|
|
1414
|
+
art = getattr(ax, '_top_xlabel_artist', None)
|
|
1415
|
+
if art is not None:
|
|
1416
|
+
art.set_fontsize(numeric_size)
|
|
1417
|
+
art = getattr(ax, '_right_ylabel_artist', None)
|
|
1418
|
+
if art is not None:
|
|
1419
|
+
art.set_fontsize(numeric_size)
|
|
1420
|
+
if fam_chain:
|
|
1421
|
+
fam0 = fam_chain[0]
|
|
1422
|
+
art = getattr(ax, '_top_xlabel_artist', None)
|
|
1423
|
+
if art is not None:
|
|
1424
|
+
art.set_fontfamily(fam0)
|
|
1425
|
+
art = getattr(ax, '_right_ylabel_artist', None)
|
|
1426
|
+
if art is not None:
|
|
1427
|
+
art.set_fontfamily(fam0)
|
|
1428
|
+
except Exception:
|
|
1429
|
+
pass
|
|
1430
|
+
fig.canvas.draw_idle()
|
|
1431
|
+
except Exception as e:
|
|
1432
|
+
print(f"[DEBUG] Exception in axis title toggle: {e}")
|
|
1433
|
+
except Exception as e:
|
|
1434
|
+
print(f"Error applying config: {e}")
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
__all__ = [
|
|
1438
|
+
"print_style_info",
|
|
1439
|
+
"export_style_config",
|
|
1440
|
+
"apply_style_config",
|
|
1441
|
+
]
|