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,3894 @@
|
|
|
1
|
+
"""Interactive menu for normal XY plots (moved from monolithic batplot.py).
|
|
2
|
+
|
|
3
|
+
This module provides interactive_menu(fig, ax, ...). It mirrors the previous
|
|
4
|
+
implementation but lives outside batplot.py to match the pattern used by other
|
|
5
|
+
interactive modes (EC, Operando).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import json
|
|
12
|
+
import random
|
|
13
|
+
import sys
|
|
14
|
+
from typing import List, Optional, Tuple, Dict, Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
from matplotlib.ticker import AutoMinorLocator, NullFormatter, NullLocator
|
|
19
|
+
from matplotlib import colors as mcolors
|
|
20
|
+
|
|
21
|
+
from .plotting import update_labels
|
|
22
|
+
from .utils import (
|
|
23
|
+
_confirm_overwrite,
|
|
24
|
+
normalize_label_text,
|
|
25
|
+
choose_save_path,
|
|
26
|
+
choose_style_file,
|
|
27
|
+
list_files_in_subdirectory,
|
|
28
|
+
convert_label_shortcuts,
|
|
29
|
+
get_organized_path,
|
|
30
|
+
)
|
|
31
|
+
import time
|
|
32
|
+
from .session import dump_session as _bp_dump_session
|
|
33
|
+
from .ui import (
|
|
34
|
+
apply_font_changes as _ui_apply_font_changes,
|
|
35
|
+
sync_fonts as _ui_sync_fonts,
|
|
36
|
+
position_top_xlabel as _ui_position_top_xlabel,
|
|
37
|
+
position_right_ylabel as _ui_position_right_ylabel,
|
|
38
|
+
position_bottom_xlabel as _ui_position_bottom_xlabel,
|
|
39
|
+
position_left_ylabel as _ui_position_left_ylabel,
|
|
40
|
+
update_tick_visibility as _ui_update_tick_visibility,
|
|
41
|
+
ensure_text_visibility as _ui_ensure_text_visibility,
|
|
42
|
+
resize_plot_frame as _ui_resize_plot_frame,
|
|
43
|
+
resize_canvas as _ui_resize_canvas,
|
|
44
|
+
)
|
|
45
|
+
from .style import (
|
|
46
|
+
print_style_info as _bp_print_style_info,
|
|
47
|
+
export_style_config as _bp_export_style_config,
|
|
48
|
+
apply_style_config as _bp_apply_style_config,
|
|
49
|
+
)
|
|
50
|
+
from .color_utils import (
|
|
51
|
+
color_block,
|
|
52
|
+
color_bar,
|
|
53
|
+
palette_preview,
|
|
54
|
+
manage_user_colors,
|
|
55
|
+
get_user_color_list,
|
|
56
|
+
resolve_color_token,
|
|
57
|
+
ensure_colormap,
|
|
58
|
+
_CUSTOM_CMAPS,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _FilterIMKWarning:
|
|
63
|
+
"""Filter that suppresses macOS IMKCFRunLoopWakeUpReliable warnings while preserving other errors."""
|
|
64
|
+
def __init__(self, original_stderr):
|
|
65
|
+
self.original_stderr = original_stderr
|
|
66
|
+
|
|
67
|
+
def write(self, message):
|
|
68
|
+
# Filter out the harmless macOS IMK warning
|
|
69
|
+
if 'IMKCFRunLoopWakeUpReliable' not in message:
|
|
70
|
+
self.original_stderr.write(message)
|
|
71
|
+
|
|
72
|
+
def flush(self):
|
|
73
|
+
self.original_stderr.flush()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _safe_input(prompt: str = "") -> str:
|
|
77
|
+
"""Wrapper around input() that suppresses macOS IMKCFRunLoopWakeUpReliable warnings.
|
|
78
|
+
|
|
79
|
+
This is a harmless macOS system message that appears when using input() in terminals.
|
|
80
|
+
"""
|
|
81
|
+
# Filter stderr to hide macOS IMK warnings while preserving other errors
|
|
82
|
+
original_stderr = sys.stderr
|
|
83
|
+
sys.stderr = _FilterIMKWarning(original_stderr)
|
|
84
|
+
try:
|
|
85
|
+
result = input(prompt)
|
|
86
|
+
return result
|
|
87
|
+
except (KeyboardInterrupt, EOFError):
|
|
88
|
+
raise
|
|
89
|
+
finally:
|
|
90
|
+
sys.stderr = original_stderr
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def interactive_menu(fig, ax, y_data_list, x_data_list, labels, orig_y,
|
|
94
|
+
label_text_objects, delta, x_label, args,
|
|
95
|
+
x_full_list, raw_y_full_list, offsets_list,
|
|
96
|
+
use_Q, use_r, use_E, use_k, use_rft,
|
|
97
|
+
cif_globals: Optional[Dict[str, Any]] = None):
|
|
98
|
+
"""Interactive menu for XY plots.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
fig: matplotlib Figure
|
|
102
|
+
ax: matplotlib Axes
|
|
103
|
+
y_data_list: List of y-data arrays (with offsets applied)
|
|
104
|
+
x_data_list: List of x-data arrays (cropped to current view)
|
|
105
|
+
labels: List of curve labels
|
|
106
|
+
orig_y: List of baseline y-data (normalized, no offset)
|
|
107
|
+
label_text_objects: List of matplotlib Text objects for curve labels
|
|
108
|
+
delta: Current offset spacing value
|
|
109
|
+
x_label: X-axis label string
|
|
110
|
+
args: Argument namespace from CLI
|
|
111
|
+
x_full_list: List of full x-data arrays (uncropped)
|
|
112
|
+
raw_y_full_list: List of full raw y-data arrays
|
|
113
|
+
offsets_list: List of current offset values per curve
|
|
114
|
+
use_Q, use_r, use_E, use_k, use_rft: Boolean flags for axis mode
|
|
115
|
+
cif_globals: Optional dict containing CIF-related state:
|
|
116
|
+
- 'cif_tick_series': list of CIF tick data
|
|
117
|
+
- 'cif_hkl_map': dict mapping filenames to hkl reflections
|
|
118
|
+
- 'cif_hkl_label_map': dict mapping Q to hkl label strings
|
|
119
|
+
- 'show_cif_hkl': bool flag for hkl label visibility
|
|
120
|
+
- 'cif_extend_suspended': bool flag to prevent re-entrant extension
|
|
121
|
+
- 'keep_canvas_fixed': bool flag for canvas resize behavior
|
|
122
|
+
"""
|
|
123
|
+
# Use the provided fig/ax as-is; do not close or switch figures to avoid spawning new windows
|
|
124
|
+
|
|
125
|
+
# Handle CIF globals - prefer explicit parameter, fallback to __main__ for backward compatibility
|
|
126
|
+
if cif_globals is None:
|
|
127
|
+
# Legacy path: try to access __main__ module for CIF state
|
|
128
|
+
_bp = sys.modules.get('__main__')
|
|
129
|
+
if _bp is not None and hasattr(_bp, 'cif_tick_series'):
|
|
130
|
+
cif_globals = {
|
|
131
|
+
'cif_tick_series': getattr(_bp, 'cif_tick_series', None),
|
|
132
|
+
'cif_hkl_map': getattr(_bp, 'cif_hkl_map', None),
|
|
133
|
+
'cif_hkl_label_map': getattr(_bp, 'cif_hkl_label_map', None),
|
|
134
|
+
'show_cif_hkl': getattr(_bp, 'show_cif_hkl', False),
|
|
135
|
+
'show_cif_titles': getattr(_bp, 'show_cif_titles', True),
|
|
136
|
+
'cif_extend_suspended': getattr(_bp, 'cif_extend_suspended', False),
|
|
137
|
+
'keep_canvas_fixed': getattr(_bp, 'keep_canvas_fixed', False),
|
|
138
|
+
}
|
|
139
|
+
else:
|
|
140
|
+
cif_globals = {}
|
|
141
|
+
|
|
142
|
+
# Provide a consistent interface for accessing CIF state
|
|
143
|
+
_bp = type('CIFState', (), cif_globals)() if cif_globals else None
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
raw_source_paths = list(getattr(args, 'files', []) or [])
|
|
147
|
+
except Exception:
|
|
148
|
+
raw_source_paths = []
|
|
149
|
+
source_file_paths = []
|
|
150
|
+
seen_source_paths = set()
|
|
151
|
+
for _p in raw_source_paths:
|
|
152
|
+
if not _p:
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
abs_p = os.path.abspath(_p)
|
|
156
|
+
except Exception:
|
|
157
|
+
continue
|
|
158
|
+
if not os.path.isfile(abs_p):
|
|
159
|
+
continue
|
|
160
|
+
if abs_p in seen_source_paths:
|
|
161
|
+
continue
|
|
162
|
+
seen_source_paths.add(abs_p)
|
|
163
|
+
source_file_paths.append(abs_p)
|
|
164
|
+
try:
|
|
165
|
+
fig._bp_source_paths = list(source_file_paths)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
# Initialize rotation state (0, 90, 180, or 270 degrees)
|
|
170
|
+
if not hasattr(ax, '_rotation_angle'):
|
|
171
|
+
ax._rotation_angle = 0
|
|
172
|
+
|
|
173
|
+
# Initialize stack label position state (True = bottom, False = top/max)
|
|
174
|
+
if not hasattr(fig, '_stack_label_at_bottom'):
|
|
175
|
+
fig._stack_label_at_bottom = False
|
|
176
|
+
# Track horizontal anchor (False=right, True=left)
|
|
177
|
+
if not hasattr(fig, '_label_anchor_left'):
|
|
178
|
+
fig._label_anchor_left = False
|
|
179
|
+
|
|
180
|
+
# ANSI color codes for menu highlighting
|
|
181
|
+
def colorize_menu(text):
|
|
182
|
+
"""Colorize menu items: command in cyan, colon in white, description in default."""
|
|
183
|
+
if ':' not in text:
|
|
184
|
+
return text
|
|
185
|
+
parts = text.split(':', 1)
|
|
186
|
+
cmd = parts[0].strip()
|
|
187
|
+
desc = parts[1].strip() if len(parts) > 1 else ''
|
|
188
|
+
return f"\033[96m{cmd}\033[0m: {desc}" # Cyan for command, default for description
|
|
189
|
+
|
|
190
|
+
def colorize_prompt(text):
|
|
191
|
+
"""Colorize commands within input prompts. Handles formats like (s=size, f=family, q=return) or (y/n) or (q=cancel)."""
|
|
192
|
+
import re
|
|
193
|
+
# Pattern to match parenthesized command lists like (s=size, f=family, q=return) or (y/n) or (m/p/s/t/q) or (q=cancel)
|
|
194
|
+
pattern = r'\(([a-z]+=[^,)]+(?:,\s*[a-z]+=[^,)]+)*|[a-z]+(?:/[a-z]+)+)\)'
|
|
195
|
+
|
|
196
|
+
def colorize_match(match):
|
|
197
|
+
content = match.group(1)
|
|
198
|
+
# Check if it's slash-separated (like y/n or m/p/s/t/q)
|
|
199
|
+
if '/' in content:
|
|
200
|
+
parts = content.split('/')
|
|
201
|
+
colored_parts = [f"\033[96m{p.strip()}\033[0m" for p in parts]
|
|
202
|
+
return f"({'/'.join(colored_parts)})"
|
|
203
|
+
# Otherwise it's equals-separated (like s=size, f=family or q=cancel)
|
|
204
|
+
else:
|
|
205
|
+
parts = content.split(',')
|
|
206
|
+
colored_parts = []
|
|
207
|
+
for part in parts:
|
|
208
|
+
part = part.strip()
|
|
209
|
+
if '=' in part:
|
|
210
|
+
cmd, desc = part.split('=', 1)
|
|
211
|
+
colored_parts.append(f"\033[96m{cmd.strip()}\033[0m={desc.strip()}")
|
|
212
|
+
else:
|
|
213
|
+
colored_parts.append(part)
|
|
214
|
+
return f"({', '.join(colored_parts)})"
|
|
215
|
+
|
|
216
|
+
return re.sub(pattern, colorize_match, text)
|
|
217
|
+
|
|
218
|
+
def format_file_timestamp(filepath: str) -> str:
|
|
219
|
+
"""Format file modification time for display.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
filepath: Full path to the file
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Formatted timestamp string (e.g., "2024-01-15 14:30") or empty string if error
|
|
226
|
+
"""
|
|
227
|
+
try:
|
|
228
|
+
mtime = os.path.getmtime(filepath)
|
|
229
|
+
# Format as YYYY-MM-DD HH:MM
|
|
230
|
+
return time.strftime("%Y-%m-%d %H:%M", time.localtime(mtime))
|
|
231
|
+
except Exception:
|
|
232
|
+
return ""
|
|
233
|
+
|
|
234
|
+
def colorize_inline_commands(text):
|
|
235
|
+
"""Colorize inline command examples in help text. Colors quoted examples and specific known commands."""
|
|
236
|
+
import re
|
|
237
|
+
# Color quoted command examples (like 's2 w5 a4', 'w2 w5', or 'all magma_r')
|
|
238
|
+
text = re.sub(r"'([a-z0-9\s_-]+)'", lambda m: f"'\033[96m{m.group(1)}\033[0m'", text)
|
|
239
|
+
# Color specific known single-letter commands: q, i, l, when they appear as standalone commands
|
|
240
|
+
# Pattern: word boundary + (q|i|l|list|help|all) + space/equals/comma/end
|
|
241
|
+
text = re.sub(r'\b(q|i|l|list|help|all)\b(?=\s*[=,]|\s*$)', lambda m: f"\033[96m{m.group(1)}\033[0m", text)
|
|
242
|
+
return text
|
|
243
|
+
|
|
244
|
+
# REPLACED print_main_menu with column layout (now hides 'd' and 'y' in --stack)
|
|
245
|
+
is_diffraction = use_Q or (not use_r and not use_E and not use_k and not use_rft) # 2θ or Q
|
|
246
|
+
def print_main_menu():
|
|
247
|
+
has_cif = False
|
|
248
|
+
try:
|
|
249
|
+
# Check for CIF files in args.files (handle colon syntax like file.cif:0.25448)
|
|
250
|
+
has_cif = any(f.split(':')[0].lower().endswith('.cif') for f in args.files)
|
|
251
|
+
# Also check if CIF tick series exists (more reliable)
|
|
252
|
+
if not has_cif and _bp is not None:
|
|
253
|
+
has_cif = bool(getattr(_bp, 'cif_tick_series', None))
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
col1 = ["c: colors", "f: font", "l: line", "t: toggle axes", "g: size", "h: legend"]
|
|
257
|
+
if has_cif:
|
|
258
|
+
col1.append("z: hkl")
|
|
259
|
+
col1.append("j: CIF titles")
|
|
260
|
+
col2 = ["a: rearrange", "d: offset", "r: rename", "x: change X", "y: change Y"]
|
|
261
|
+
col3 = ["v: find peaks", "n: crosshair", "p: print(export) style/geom", "i: import style/geom", "e: export figure", "s: save project", "b: undo", "q: quit"]
|
|
262
|
+
|
|
263
|
+
# Hide offset/y-range in stack mode
|
|
264
|
+
if args.stack:
|
|
265
|
+
col2 = [item for item in col2 if not item.startswith("d:") and not item.startswith("y:")]
|
|
266
|
+
|
|
267
|
+
if not is_diffraction:
|
|
268
|
+
col3 = [item for item in col3 if not item.startswith("n:")]
|
|
269
|
+
# Dynamic widths for cleaner alignment across terminals (account for ANSI codes)
|
|
270
|
+
# Use plain text length for width calculations
|
|
271
|
+
w1 = max(len("(Styles)"), *(len(s) for s in col1), 16)
|
|
272
|
+
w2 = max(len("(Geometries)"), *(len(s) for s in col2), 16)
|
|
273
|
+
w3 = max(len("(Options)"), *(len(s) for s in col3), 16)
|
|
274
|
+
rows = max(len(col1), len(col2), len(col3))
|
|
275
|
+
print("\n\033[1mInteractive menu:\033[0m") # Bold title
|
|
276
|
+
print(f" \033[93m{'(Styles)':<{w1}}\033[0m \033[93m{'(Geometries)':<{w2}}\033[0m \033[93m{'(Options)':<{w3}}\033[0m") # Yellow headers
|
|
277
|
+
for i in range(rows):
|
|
278
|
+
p1 = colorize_menu(col1[i]) if i < len(col1) else ""
|
|
279
|
+
p2 = colorize_menu(col2[i]) if i < len(col2) else ""
|
|
280
|
+
p3 = colorize_menu(col3[i]) if i < len(col3) else ""
|
|
281
|
+
# Add padding to account for ANSI escape codes (9 chars per colorized item)
|
|
282
|
+
pad1 = w1 + (9 if i < len(col1) else 0)
|
|
283
|
+
pad2 = w2 + (9 if i < len(col2) else 0)
|
|
284
|
+
pad3 = w3 + (9 if i < len(col3) else 0)
|
|
285
|
+
print(f" {p1:<{pad1}} {p2:<{pad2}} {p3:<{pad3}}")
|
|
286
|
+
|
|
287
|
+
# --- Helper for spine visibility ---
|
|
288
|
+
def set_spine_visible(which, visible):
|
|
289
|
+
if which in ax.spines:
|
|
290
|
+
ax.spines[which].set_visible(visible)
|
|
291
|
+
fig.canvas.draw_idle()
|
|
292
|
+
|
|
293
|
+
def get_spine_visible(which):
|
|
294
|
+
if which in ax.spines:
|
|
295
|
+
return ax.spines[which].get_visible()
|
|
296
|
+
return False
|
|
297
|
+
# Initial menu display REMOVED to avoid double print
|
|
298
|
+
ax.set_aspect('auto', adjustable='datalim')
|
|
299
|
+
|
|
300
|
+
def on_xlim_change(event_ax):
|
|
301
|
+
stack_label_bottom = getattr(fig, '_stack_label_at_bottom', False)
|
|
302
|
+
update_labels(event_ax, y_data_list, label_text_objects, args.stack, stack_label_bottom)
|
|
303
|
+
# Extend CIF ticks if needed when user pans/zooms horizontally
|
|
304
|
+
try:
|
|
305
|
+
if (
|
|
306
|
+
_bp is not None
|
|
307
|
+
and (not getattr(_bp, 'cif_extend_suspended', False))
|
|
308
|
+
and hasattr(ax, '_cif_extend_func') and hasattr(ax, '_cif_draw_func') and callable(ax._cif_extend_func)
|
|
309
|
+
):
|
|
310
|
+
current_xlim = ax.get_xlim()
|
|
311
|
+
xmax = current_xlim[1]
|
|
312
|
+
ax._cif_extend_func(xmax)
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
fig.canvas.draw()
|
|
316
|
+
ax.callbacks.connect('xlim_changed', on_xlim_change)
|
|
317
|
+
|
|
318
|
+
# --------- UPDATED unified font update helper ----------
|
|
319
|
+
def apply_font_changes(new_size=None, new_family=None):
|
|
320
|
+
return _ui_apply_font_changes(ax, fig, label_text_objects, normalize_label_text, new_size, new_family)
|
|
321
|
+
|
|
322
|
+
# Generic font sync (even when size/family unchanged) so newly created labels/twin axes inherit the rcParams size
|
|
323
|
+
def sync_fonts():
|
|
324
|
+
return _ui_sync_fonts(ax, fig, label_text_objects)
|
|
325
|
+
|
|
326
|
+
# Adjust vertical position of duplicate top X label depending on top tick visibility
|
|
327
|
+
def position_top_xlabel():
|
|
328
|
+
return _ui_position_top_xlabel(ax, fig, tick_state)
|
|
329
|
+
|
|
330
|
+
def position_right_ylabel():
|
|
331
|
+
return _ui_position_right_ylabel(ax, fig, tick_state)
|
|
332
|
+
|
|
333
|
+
def position_bottom_xlabel():
|
|
334
|
+
return _ui_position_bottom_xlabel(ax, fig, tick_state)
|
|
335
|
+
|
|
336
|
+
def position_left_ylabel():
|
|
337
|
+
return _ui_position_left_ylabel(ax, fig, tick_state)
|
|
338
|
+
|
|
339
|
+
def _current_label_position() -> str:
|
|
340
|
+
vertical = "bottom" if getattr(fig, '_stack_label_at_bottom', False) else "top"
|
|
341
|
+
horizontal = "left" if getattr(fig, '_label_anchor_left', False) else "right"
|
|
342
|
+
return f"{vertical}-{horizontal}"
|
|
343
|
+
|
|
344
|
+
def _apply_legend_position(bottom: bool, left: bool) -> None:
|
|
345
|
+
fig._stack_label_at_bottom = bottom
|
|
346
|
+
fig._label_anchor_left = left
|
|
347
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, bottom)
|
|
348
|
+
try:
|
|
349
|
+
fig.canvas.draw_idle()
|
|
350
|
+
except Exception:
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
def _title_offset_menu():
|
|
354
|
+
"""Interactive nudging for duplicate top/right titles."""
|
|
355
|
+
def _dpi():
|
|
356
|
+
try:
|
|
357
|
+
return float(fig.dpi)
|
|
358
|
+
except Exception:
|
|
359
|
+
return 72.0
|
|
360
|
+
|
|
361
|
+
def _px_value(attr):
|
|
362
|
+
try:
|
|
363
|
+
pts = float(getattr(ax, attr, 0.0) or 0.0)
|
|
364
|
+
except Exception:
|
|
365
|
+
pts = 0.0
|
|
366
|
+
return pts * _dpi() / 72.0
|
|
367
|
+
|
|
368
|
+
def _set_attr(attr, pts):
|
|
369
|
+
try:
|
|
370
|
+
setattr(ax, attr, float(pts))
|
|
371
|
+
except Exception:
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
def _nudge(attr, delta_px):
|
|
375
|
+
try:
|
|
376
|
+
current_pts = float(getattr(ax, attr, 0.0) or 0.0)
|
|
377
|
+
except Exception:
|
|
378
|
+
current_pts = 0.0
|
|
379
|
+
delta_pts = float(delta_px) * 72.0 / _dpi()
|
|
380
|
+
_set_attr(attr, current_pts + delta_pts)
|
|
381
|
+
|
|
382
|
+
snapshot_taken = False
|
|
383
|
+
|
|
384
|
+
def _ensure_snapshot():
|
|
385
|
+
nonlocal snapshot_taken
|
|
386
|
+
if not snapshot_taken:
|
|
387
|
+
push_state("title-offset")
|
|
388
|
+
snapshot_taken = True
|
|
389
|
+
|
|
390
|
+
def _top_menu():
|
|
391
|
+
if not getattr(ax, '_top_xlabel_on', False):
|
|
392
|
+
print("Top duplicate title is currently hidden (toggle with w5).")
|
|
393
|
+
return
|
|
394
|
+
while True:
|
|
395
|
+
current_y_px = _px_value('_top_xlabel_manual_offset_y_pts')
|
|
396
|
+
current_x_px = _px_value('_top_xlabel_manual_offset_x_pts')
|
|
397
|
+
print(f"Top title offset: Y={current_y_px:+.2f} px (positive=up), X={current_x_px:+.2f} px (positive=right)")
|
|
398
|
+
sub = _safe_input(colorize_prompt("top (w=up, s=down, a=left, d=right, 0=reset, q=back): ")).strip().lower()
|
|
399
|
+
if not sub:
|
|
400
|
+
continue
|
|
401
|
+
if sub == 'q':
|
|
402
|
+
break
|
|
403
|
+
if sub == '0':
|
|
404
|
+
_ensure_snapshot()
|
|
405
|
+
_set_attr('_top_xlabel_manual_offset_y_pts', 0.0)
|
|
406
|
+
_set_attr('_top_xlabel_manual_offset_x_pts', 0.0)
|
|
407
|
+
elif sub == 'w':
|
|
408
|
+
_ensure_snapshot()
|
|
409
|
+
_nudge('_top_xlabel_manual_offset_y_pts', +1.0)
|
|
410
|
+
elif sub == 's':
|
|
411
|
+
_ensure_snapshot()
|
|
412
|
+
_nudge('_top_xlabel_manual_offset_y_pts', -1.0)
|
|
413
|
+
elif sub == 'a':
|
|
414
|
+
_ensure_snapshot()
|
|
415
|
+
_nudge('_top_xlabel_manual_offset_x_pts', -1.0)
|
|
416
|
+
elif sub == 'd':
|
|
417
|
+
_ensure_snapshot()
|
|
418
|
+
_nudge('_top_xlabel_manual_offset_x_pts', +1.0)
|
|
419
|
+
else:
|
|
420
|
+
print("Unknown choice (use w/s/a/d/0/q).")
|
|
421
|
+
continue
|
|
422
|
+
position_top_xlabel()
|
|
423
|
+
try:
|
|
424
|
+
fig.canvas.draw_idle()
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
def _right_menu():
|
|
429
|
+
if not getattr(ax, '_right_ylabel_on', False):
|
|
430
|
+
print("Right duplicate title is currently hidden (toggle with d5).")
|
|
431
|
+
return
|
|
432
|
+
while True:
|
|
433
|
+
current_x_px = _px_value('_right_ylabel_manual_offset_x_pts')
|
|
434
|
+
current_y_px = _px_value('_right_ylabel_manual_offset_y_pts')
|
|
435
|
+
print(f"Right title offset: X={current_x_px:+.2f} px (positive=right), Y={current_y_px:+.2f} px (positive=up)")
|
|
436
|
+
sub = _safe_input(colorize_prompt("right (d=right, a=left, w=up, s=down, 0=reset, q=back): ")).strip().lower()
|
|
437
|
+
if not sub:
|
|
438
|
+
continue
|
|
439
|
+
if sub == 'q':
|
|
440
|
+
break
|
|
441
|
+
if sub == '0':
|
|
442
|
+
_ensure_snapshot()
|
|
443
|
+
_set_attr('_right_ylabel_manual_offset_x_pts', 0.0)
|
|
444
|
+
_set_attr('_right_ylabel_manual_offset_y_pts', 0.0)
|
|
445
|
+
elif sub == 'd':
|
|
446
|
+
_ensure_snapshot()
|
|
447
|
+
_nudge('_right_ylabel_manual_offset_x_pts', +1.0)
|
|
448
|
+
elif sub == 'a':
|
|
449
|
+
_ensure_snapshot()
|
|
450
|
+
_nudge('_right_ylabel_manual_offset_x_pts', -1.0)
|
|
451
|
+
elif sub == 'w':
|
|
452
|
+
_ensure_snapshot()
|
|
453
|
+
_nudge('_right_ylabel_manual_offset_y_pts', +1.0)
|
|
454
|
+
elif sub == 's':
|
|
455
|
+
_ensure_snapshot()
|
|
456
|
+
_nudge('_right_ylabel_manual_offset_y_pts', -1.0)
|
|
457
|
+
else:
|
|
458
|
+
print("Unknown choice (use d/a/w/s/0/q).")
|
|
459
|
+
continue
|
|
460
|
+
position_right_ylabel()
|
|
461
|
+
try:
|
|
462
|
+
fig.canvas.draw_idle()
|
|
463
|
+
except Exception:
|
|
464
|
+
pass
|
|
465
|
+
|
|
466
|
+
def _bottom_menu():
|
|
467
|
+
if not ax.get_xlabel():
|
|
468
|
+
print("Bottom title is currently hidden.")
|
|
469
|
+
return
|
|
470
|
+
while True:
|
|
471
|
+
current_y_px = _px_value('_bottom_xlabel_manual_offset_y_pts')
|
|
472
|
+
print(f"Bottom title offset: Y={current_y_px:+.2f} px (positive=down)")
|
|
473
|
+
sub = _safe_input(colorize_prompt("bottom (s=down, w=up, 0=reset, q=back): ")).strip().lower()
|
|
474
|
+
if not sub:
|
|
475
|
+
continue
|
|
476
|
+
if sub == 'q':
|
|
477
|
+
break
|
|
478
|
+
if sub == '0':
|
|
479
|
+
_ensure_snapshot()
|
|
480
|
+
_set_attr('_bottom_xlabel_manual_offset_y_pts', 0.0)
|
|
481
|
+
elif sub == 's':
|
|
482
|
+
_ensure_snapshot()
|
|
483
|
+
_nudge('_bottom_xlabel_manual_offset_y_pts', +1.0)
|
|
484
|
+
elif sub == 'w':
|
|
485
|
+
_ensure_snapshot()
|
|
486
|
+
_nudge('_bottom_xlabel_manual_offset_y_pts', -1.0)
|
|
487
|
+
else:
|
|
488
|
+
print("Unknown choice (use s/w/0/q).")
|
|
489
|
+
continue
|
|
490
|
+
position_bottom_xlabel()
|
|
491
|
+
try:
|
|
492
|
+
fig.canvas.draw_idle()
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
def _left_menu():
|
|
497
|
+
if not ax.get_ylabel():
|
|
498
|
+
print("Left title is currently hidden.")
|
|
499
|
+
return
|
|
500
|
+
while True:
|
|
501
|
+
current_x_px = _px_value('_left_ylabel_manual_offset_x_pts')
|
|
502
|
+
print(f"Left title offset: X={current_x_px:+.2f} px (positive=left)")
|
|
503
|
+
sub = _safe_input(colorize_prompt("left (a=left, d=right, 0=reset, q=back): ")).strip().lower()
|
|
504
|
+
if not sub:
|
|
505
|
+
continue
|
|
506
|
+
if sub == 'q':
|
|
507
|
+
break
|
|
508
|
+
if sub == '0':
|
|
509
|
+
_ensure_snapshot()
|
|
510
|
+
_set_attr('_left_ylabel_manual_offset_x_pts', 0.0)
|
|
511
|
+
elif sub == 'a':
|
|
512
|
+
_ensure_snapshot()
|
|
513
|
+
_nudge('_left_ylabel_manual_offset_x_pts', +1.0)
|
|
514
|
+
elif sub == 'd':
|
|
515
|
+
_ensure_snapshot()
|
|
516
|
+
_nudge('_left_ylabel_manual_offset_x_pts', -1.0)
|
|
517
|
+
else:
|
|
518
|
+
print("Unknown choice (use a/d/0/q).")
|
|
519
|
+
continue
|
|
520
|
+
position_left_ylabel()
|
|
521
|
+
try:
|
|
522
|
+
fig.canvas.draw_idle()
|
|
523
|
+
except Exception:
|
|
524
|
+
pass
|
|
525
|
+
|
|
526
|
+
while True:
|
|
527
|
+
print(colorize_inline_commands("Title offsets:"))
|
|
528
|
+
print(" " + colorize_menu('w : adjust top title (w=up, s=down, a=left, d=right)'))
|
|
529
|
+
print(" " + colorize_menu('s : adjust bottom title (s=down, w=up)'))
|
|
530
|
+
print(" " + colorize_menu('a : adjust left title (a=left, d=right)'))
|
|
531
|
+
print(" " + colorize_menu('d : adjust right title (d=right, a=left, w=up, s=down)'))
|
|
532
|
+
print(" " + colorize_menu('r : reset all offsets'))
|
|
533
|
+
print(" " + colorize_menu('q : back to toggle menu'))
|
|
534
|
+
choice = _safe_input(colorize_prompt("p> ")).strip().lower()
|
|
535
|
+
if not choice:
|
|
536
|
+
continue
|
|
537
|
+
if choice == 'q':
|
|
538
|
+
break
|
|
539
|
+
if choice == 'w':
|
|
540
|
+
_top_menu()
|
|
541
|
+
continue
|
|
542
|
+
if choice == 's':
|
|
543
|
+
_bottom_menu()
|
|
544
|
+
continue
|
|
545
|
+
if choice == 'a':
|
|
546
|
+
_left_menu()
|
|
547
|
+
continue
|
|
548
|
+
if choice == 'd':
|
|
549
|
+
_right_menu()
|
|
550
|
+
continue
|
|
551
|
+
if choice == 'r':
|
|
552
|
+
_ensure_snapshot()
|
|
553
|
+
_set_attr('_top_xlabel_manual_offset_y_pts', 0.0)
|
|
554
|
+
_set_attr('_top_xlabel_manual_offset_x_pts', 0.0)
|
|
555
|
+
_set_attr('_bottom_xlabel_manual_offset_y_pts', 0.0)
|
|
556
|
+
_set_attr('_left_ylabel_manual_offset_x_pts', 0.0)
|
|
557
|
+
_set_attr('_right_ylabel_manual_offset_x_pts', 0.0)
|
|
558
|
+
_set_attr('_right_ylabel_manual_offset_y_pts', 0.0)
|
|
559
|
+
position_top_xlabel()
|
|
560
|
+
position_bottom_xlabel()
|
|
561
|
+
position_left_ylabel()
|
|
562
|
+
position_right_ylabel()
|
|
563
|
+
try:
|
|
564
|
+
fig.canvas.draw_idle()
|
|
565
|
+
except Exception:
|
|
566
|
+
pass
|
|
567
|
+
print("Reset manual offsets for all titles.")
|
|
568
|
+
continue
|
|
569
|
+
print("Unknown option. Use w/s/a/d/r/q.")
|
|
570
|
+
|
|
571
|
+
def play_jump_game():
|
|
572
|
+
"""
|
|
573
|
+
Simple terminal 'jumping bird' (Flappy-style) game.
|
|
574
|
+
Controls: j = jump, Enter = let bird fall, q = quit game.
|
|
575
|
+
Avoid hitting '#' pillars. Score increases when you pass a pillar.
|
|
576
|
+
Difficulty lowered: bigger gaps, stronger jump, sparser pillars.
|
|
577
|
+
"""
|
|
578
|
+
# Board/config
|
|
579
|
+
WIDTH = 32
|
|
580
|
+
HEIGHT = 12
|
|
581
|
+
BIRD_X = 5
|
|
582
|
+
GRAVITY = 1
|
|
583
|
+
JUMP_VEL = -3 # stronger jump for easier play
|
|
584
|
+
GAP_SIZE = 5 # larger gap for easier passage
|
|
585
|
+
MIN_OBS_SPACING = 12 # more spacing between obstacles
|
|
586
|
+
|
|
587
|
+
class Obstacle:
|
|
588
|
+
__slots__ = ("x", "gap_start", "scored")
|
|
589
|
+
def __init__(self, x):
|
|
590
|
+
self.x = x
|
|
591
|
+
self.gap_start = random.randint(1, max(1, HEIGHT - GAP_SIZE - 1))
|
|
592
|
+
self.scored = False
|
|
593
|
+
|
|
594
|
+
bird_y = HEIGHT // 2
|
|
595
|
+
vel = 0
|
|
596
|
+
tick = 0
|
|
597
|
+
score = 0
|
|
598
|
+
obstacles = [Obstacle(WIDTH - 1)]
|
|
599
|
+
|
|
600
|
+
def need_new():
|
|
601
|
+
if not obstacles:
|
|
602
|
+
return True
|
|
603
|
+
rightmost = max(o.x for o in obstacles)
|
|
604
|
+
return rightmost < WIDTH - MIN_OBS_SPACING
|
|
605
|
+
|
|
606
|
+
def new_obstacle():
|
|
607
|
+
obstacles.append(Obstacle(WIDTH - 1))
|
|
608
|
+
|
|
609
|
+
def collision():
|
|
610
|
+
# Out of bounds
|
|
611
|
+
if bird_y < 0 or bird_y >= HEIGHT:
|
|
612
|
+
return True
|
|
613
|
+
# Pillar collisions at or just before bird column unless within gap
|
|
614
|
+
for o in obstacles:
|
|
615
|
+
if o.x in (BIRD_X, BIRD_X - 1):
|
|
616
|
+
if not (o.gap_start <= bird_y < o.gap_start + GAP_SIZE):
|
|
617
|
+
return True
|
|
618
|
+
return False
|
|
619
|
+
|
|
620
|
+
def move_obstacles():
|
|
621
|
+
for o in obstacles:
|
|
622
|
+
o.x -= 1
|
|
623
|
+
|
|
624
|
+
def purge_obstacles():
|
|
625
|
+
while obstacles and obstacles[0].x < -1:
|
|
626
|
+
obstacles.pop(0)
|
|
627
|
+
|
|
628
|
+
def render():
|
|
629
|
+
border = "+" + ("-" * WIDTH) + "+"
|
|
630
|
+
print("\n" + border)
|
|
631
|
+
for y in range(HEIGHT):
|
|
632
|
+
row = []
|
|
633
|
+
for x in range(WIDTH):
|
|
634
|
+
ch = " "
|
|
635
|
+
if x == BIRD_X and y == bird_y:
|
|
636
|
+
ch = "@"
|
|
637
|
+
else:
|
|
638
|
+
for o in obstacles:
|
|
639
|
+
if x == o.x and not (o.gap_start <= y < o.gap_start + GAP_SIZE):
|
|
640
|
+
ch = "#"
|
|
641
|
+
break
|
|
642
|
+
row.append(ch)
|
|
643
|
+
print("|" + "".join(row) + "|")
|
|
644
|
+
print(border)
|
|
645
|
+
print(f"Score: {score} (j=jump, Enter=fall, q=quit)")
|
|
646
|
+
|
|
647
|
+
# One-time instructions
|
|
648
|
+
print("\nJumping Bird: pass through the gaps!")
|
|
649
|
+
print("Controls: j = jump, Enter = fall, q = quit\n")
|
|
650
|
+
|
|
651
|
+
while True:
|
|
652
|
+
render()
|
|
653
|
+
cmd = _safe_input("> ").strip().lower()
|
|
654
|
+
if cmd == 'q':
|
|
655
|
+
print("Exited game. Returning to interactive menu.\n")
|
|
656
|
+
break
|
|
657
|
+
if cmd == 'j':
|
|
658
|
+
vel = JUMP_VEL
|
|
659
|
+
else:
|
|
660
|
+
vel += GRAVITY
|
|
661
|
+
|
|
662
|
+
bird_y += vel
|
|
663
|
+
|
|
664
|
+
move_obstacles()
|
|
665
|
+
if need_new():
|
|
666
|
+
new_obstacle()
|
|
667
|
+
purge_obstacles()
|
|
668
|
+
|
|
669
|
+
# Scoring: mark a pillar once it moves left of bird
|
|
670
|
+
for o in obstacles:
|
|
671
|
+
if not o.scored and o.x < BIRD_X:
|
|
672
|
+
o.scored = True
|
|
673
|
+
score += 1
|
|
674
|
+
|
|
675
|
+
tick += 1
|
|
676
|
+
if collision():
|
|
677
|
+
render()
|
|
678
|
+
print(f"Game Over! Final score: {score}\n")
|
|
679
|
+
break
|
|
680
|
+
|
|
681
|
+
# -------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
# --------- NEW: Resize only the plotting frame (axes), keep canvas (figure) size fixed ----------
|
|
684
|
+
def resize_plot_frame():
|
|
685
|
+
return _ui_resize_plot_frame(fig, ax, y_data_list, label_text_objects, args, update_labels)
|
|
686
|
+
|
|
687
|
+
def resize_canvas():
|
|
688
|
+
return _ui_resize_canvas(fig, ax)
|
|
689
|
+
# -------------------------------------------------
|
|
690
|
+
|
|
691
|
+
# ---- Tick / label visibility state ----
|
|
692
|
+
# New model: separate tick marks vs tick labels per side
|
|
693
|
+
# Keys:
|
|
694
|
+
# b_ticks, b_labels, t_ticks, t_labels, l_ticks, l_labels, r_ticks, r_labels
|
|
695
|
+
# Minor ticks remain: mbx, mtx, mly, mry
|
|
696
|
+
# Back-compat: also maintain synthetic bx/tx/ly/ry (mapped to *_ticks) for helpers.
|
|
697
|
+
saved_ts = getattr(ax, '_saved_tick_state', None)
|
|
698
|
+
def _make_default_tick_state():
|
|
699
|
+
return {
|
|
700
|
+
# Major ticks vs labels (defaults: bottom/left on, top/right off)
|
|
701
|
+
'b_ticks': True, 'b_labels': True,
|
|
702
|
+
't_ticks': False, 't_labels': False,
|
|
703
|
+
'l_ticks': True, 'l_labels': True,
|
|
704
|
+
'r_ticks': False, 'r_labels': False,
|
|
705
|
+
# Minor ticks
|
|
706
|
+
'mbx': False, 'mtx': False, 'mly': False, 'mry': False,
|
|
707
|
+
# Legacy mirrors (filled by _sync_legacy_tick_keys)
|
|
708
|
+
'bx': True, 'tx': False, 'ly': True, 'ry': False,
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
def _from_legacy(legacy: dict):
|
|
712
|
+
ts = _make_default_tick_state()
|
|
713
|
+
bx = bool(legacy.get('bx', ts['bx']))
|
|
714
|
+
tx = bool(legacy.get('tx', ts['tx']))
|
|
715
|
+
ly = bool(legacy.get('ly', ts['ly']))
|
|
716
|
+
ry = bool(legacy.get('ry', ts['ry']))
|
|
717
|
+
ts.update({
|
|
718
|
+
'b_ticks': bx, 'b_labels': bx,
|
|
719
|
+
't_ticks': tx, 't_labels': tx,
|
|
720
|
+
'l_ticks': ly, 'l_labels': ly,
|
|
721
|
+
'r_ticks': ry, 'r_labels': ry,
|
|
722
|
+
'mbx': bool(legacy.get('mbx', False)),
|
|
723
|
+
'mtx': bool(legacy.get('mtx', False)),
|
|
724
|
+
'mly': bool(legacy.get('mly', False)),
|
|
725
|
+
'mry': bool(legacy.get('mry', False)),
|
|
726
|
+
})
|
|
727
|
+
return ts
|
|
728
|
+
|
|
729
|
+
def _sync_legacy_tick_keys():
|
|
730
|
+
# Mirror current *_ticks into legacy bx/tx/ly/ry keys for code that reads them
|
|
731
|
+
tick_state['bx'] = bool(tick_state.get('b_ticks', True))
|
|
732
|
+
tick_state['tx'] = bool(tick_state.get('t_ticks', False))
|
|
733
|
+
tick_state['ly'] = bool(tick_state.get('l_ticks', True))
|
|
734
|
+
tick_state['ry'] = bool(tick_state.get('r_ticks', False))
|
|
735
|
+
|
|
736
|
+
if isinstance(saved_ts, dict):
|
|
737
|
+
if any(k in saved_ts for k in ('b_ticks','t_ticks','l_ticks','r_ticks')):
|
|
738
|
+
# Already new-format; start from defaults then overlay
|
|
739
|
+
tick_state = _make_default_tick_state()
|
|
740
|
+
for k,v in saved_ts.items():
|
|
741
|
+
if k in tick_state:
|
|
742
|
+
tick_state[k] = v
|
|
743
|
+
else:
|
|
744
|
+
tick_state = _from_legacy(saved_ts)
|
|
745
|
+
else:
|
|
746
|
+
tick_state = _make_default_tick_state()
|
|
747
|
+
_sync_legacy_tick_keys()
|
|
748
|
+
|
|
749
|
+
if hasattr(ax, '_saved_tick_state'):
|
|
750
|
+
try:
|
|
751
|
+
delattr(ax, '_saved_tick_state')
|
|
752
|
+
except Exception:
|
|
753
|
+
pass
|
|
754
|
+
|
|
755
|
+
# NEW: dynamic margin adjustment for top/right ticks
|
|
756
|
+
# Flag to preserve a manual/initial interactive top margin override
|
|
757
|
+
if not hasattr(fig, '_interactive_top_locked'):
|
|
758
|
+
fig._interactive_top_locked = False
|
|
759
|
+
|
|
760
|
+
def adjust_margins():
|
|
761
|
+
"""Lightweight margin tweak based on tick visibility.
|
|
762
|
+
|
|
763
|
+
Unlike the old version this DOES NOT try to aggressively reallocate
|
|
764
|
+
space or change apparent plot size; it only adds a small padding on
|
|
765
|
+
sides that show ticks so labels have breathing room. Intended to be
|
|
766
|
+
idempotent and minimally invasive. Called during initial setup & some
|
|
767
|
+
style operations, but not on every tick toggle anymore.
|
|
768
|
+
"""
|
|
769
|
+
sp = fig.subplotpars
|
|
770
|
+
# Start from current to avoid jumping
|
|
771
|
+
left, right, bottom, top = sp.left, sp.right, sp.bottom, sp.top
|
|
772
|
+
pad = 0.01 # modest expansion per active side
|
|
773
|
+
max_pad = 0.10
|
|
774
|
+
# Expand outward (shrinks axes) only if room
|
|
775
|
+
if tick_state['ly'] and left < 0.25:
|
|
776
|
+
left = min(left + pad, 0.40)
|
|
777
|
+
if tick_state['ry'] and (1 - right) < 0.25:
|
|
778
|
+
right = max(right - pad, 0.60)
|
|
779
|
+
if tick_state['bx'] and bottom < 0.25:
|
|
780
|
+
bottom = min(bottom + pad, 0.40)
|
|
781
|
+
if tick_state['tx'] and (1 - top) < 0.25:
|
|
782
|
+
top = max(top - pad, 0.60)
|
|
783
|
+
|
|
784
|
+
# Keep minimum plot span
|
|
785
|
+
if right - left < 0.25:
|
|
786
|
+
# Undo horizontal change proportionally
|
|
787
|
+
mid = (left + right) / 2
|
|
788
|
+
left = mid - 0.125
|
|
789
|
+
right = mid + 0.125
|
|
790
|
+
if top - bottom < 0.25:
|
|
791
|
+
mid = (bottom + top) / 2
|
|
792
|
+
bottom = mid - 0.125
|
|
793
|
+
top = mid + 0.125
|
|
794
|
+
|
|
795
|
+
fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
|
|
796
|
+
|
|
797
|
+
def ensure_text_visibility(max_iterations=4, check_only=False):
|
|
798
|
+
return _ui_ensure_text_visibility(fig, ax, label_text_objects, max_iterations, check_only)
|
|
799
|
+
|
|
800
|
+
def update_tick_visibility():
|
|
801
|
+
# Apply major ticks and labels independently per side
|
|
802
|
+
ax.tick_params(axis='x',
|
|
803
|
+
bottom=bool(tick_state['b_ticks']), labelbottom=bool(tick_state['b_labels']),
|
|
804
|
+
top=bool(tick_state['t_ticks']), labeltop=bool(tick_state['t_labels']))
|
|
805
|
+
ax.tick_params(axis='y',
|
|
806
|
+
left=bool(tick_state['l_ticks']), labelleft=bool(tick_state['l_labels']),
|
|
807
|
+
right=bool(tick_state['r_ticks']), labelright=bool(tick_state['r_labels']))
|
|
808
|
+
|
|
809
|
+
if tick_state['mbx'] or tick_state['mtx']:
|
|
810
|
+
ax.xaxis.set_minor_locator(AutoMinorLocator())
|
|
811
|
+
ax.xaxis.set_minor_formatter(NullFormatter())
|
|
812
|
+
ax.tick_params(axis='x', which='minor',
|
|
813
|
+
bottom=tick_state['mbx'],
|
|
814
|
+
top=tick_state['mtx'],
|
|
815
|
+
labelbottom=False, labeltop=False)
|
|
816
|
+
else:
|
|
817
|
+
# Clear minor locator if no minor ticks are enabled
|
|
818
|
+
ax.xaxis.set_minor_locator(NullLocator())
|
|
819
|
+
ax.xaxis.set_minor_formatter(NullFormatter())
|
|
820
|
+
ax.tick_params(axis='x', which='minor',
|
|
821
|
+
bottom=False, top=False,
|
|
822
|
+
labelbottom=False, labeltop=False)
|
|
823
|
+
|
|
824
|
+
if tick_state['mly'] or tick_state['mry']:
|
|
825
|
+
ax.yaxis.set_minor_locator(AutoMinorLocator())
|
|
826
|
+
ax.yaxis.set_minor_formatter(NullFormatter())
|
|
827
|
+
ax.tick_params(axis='y', which='minor',
|
|
828
|
+
left=tick_state['mly'],
|
|
829
|
+
right=tick_state['mry'],
|
|
830
|
+
labelleft=False, labelright=False)
|
|
831
|
+
else:
|
|
832
|
+
# Clear minor locator if no minor ticks are enabled
|
|
833
|
+
ax.yaxis.set_minor_locator(NullLocator())
|
|
834
|
+
ax.yaxis.set_minor_formatter(NullFormatter())
|
|
835
|
+
ax.tick_params(axis='y', which='minor',
|
|
836
|
+
left=False, right=False,
|
|
837
|
+
labelleft=False, labelright=False)
|
|
838
|
+
|
|
839
|
+
# NOTE: We keep margins stable (no auto-adjust on every toggle)
|
|
840
|
+
if getattr(fig, '_skip_initial_text_visibility', False):
|
|
841
|
+
try:
|
|
842
|
+
delattr(fig, '_skip_initial_text_visibility')
|
|
843
|
+
except Exception:
|
|
844
|
+
pass
|
|
845
|
+
else:
|
|
846
|
+
ensure_text_visibility()
|
|
847
|
+
fig.canvas.draw_idle()
|
|
848
|
+
|
|
849
|
+
# NEW helper (was referenced in 'h' menu but not defined previously)
|
|
850
|
+
def print_tick_state():
|
|
851
|
+
def onoff(v):
|
|
852
|
+
return 'ON ' if bool(v) else 'off'
|
|
853
|
+
summary = []
|
|
854
|
+
sides = (
|
|
855
|
+
('bottom',
|
|
856
|
+
get_spine_visible('bottom'),
|
|
857
|
+
tick_state.get('b_ticks', True),
|
|
858
|
+
tick_state.get('mbx', False),
|
|
859
|
+
tick_state.get('b_labels', True),
|
|
860
|
+
bool(ax.get_xlabel())),
|
|
861
|
+
('top',
|
|
862
|
+
get_spine_visible('top'),
|
|
863
|
+
tick_state.get('t_ticks', False),
|
|
864
|
+
tick_state.get('mtx', False),
|
|
865
|
+
tick_state.get('t_labels', False),
|
|
866
|
+
bool(getattr(ax, '_top_xlabel_on', False))),
|
|
867
|
+
('left',
|
|
868
|
+
get_spine_visible('left'),
|
|
869
|
+
tick_state.get('l_ticks', True),
|
|
870
|
+
tick_state.get('mly', False),
|
|
871
|
+
tick_state.get('l_labels', True),
|
|
872
|
+
bool(ax.get_ylabel())),
|
|
873
|
+
('right',
|
|
874
|
+
get_spine_visible('right'),
|
|
875
|
+
tick_state.get('r_ticks', False),
|
|
876
|
+
tick_state.get('mry', False),
|
|
877
|
+
tick_state.get('r_labels', False),
|
|
878
|
+
bool(getattr(ax, '_right_ylabel_on', False))),
|
|
879
|
+
)
|
|
880
|
+
print(colorize_inline_commands("State (per side: spine, major, minor, labels, title):"))
|
|
881
|
+
for name, spine, mj, mn, lbl, title in sides:
|
|
882
|
+
print(colorize_inline_commands(f" {name:<6}: spine={onoff(spine)} major={onoff(mj)} minor={onoff(mn)} labels={onoff(lbl)} title={onoff(title)}"))
|
|
883
|
+
|
|
884
|
+
# NEW: style / diagnostics printer (clean version)
|
|
885
|
+
def print_style_info():
|
|
886
|
+
cts = getattr(_bp, 'cif_tick_series', None) if _bp is not None else None
|
|
887
|
+
show_hkl = bool(getattr(_bp, 'show_cif_hkl', False)) if _bp is not None else None
|
|
888
|
+
return _bp_print_style_info(
|
|
889
|
+
fig, ax,
|
|
890
|
+
y_data_list, labels,
|
|
891
|
+
offsets_list,
|
|
892
|
+
x_full_list, raw_y_full_list,
|
|
893
|
+
args, delta,
|
|
894
|
+
label_text_objects,
|
|
895
|
+
tick_state,
|
|
896
|
+
cts,
|
|
897
|
+
show_hkl,
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
# NEW: export current style to .bpcfg
|
|
901
|
+
def export_style_config(filename, base_path=None, overwrite_path=None):
|
|
902
|
+
cts = getattr(_bp, 'cif_tick_series', None) if _bp is not None else None
|
|
903
|
+
show_titles = bool(getattr(_bp, 'show_cif_titles', True)) if _bp is not None else True
|
|
904
|
+
from .style import export_style_config as _export_style_config
|
|
905
|
+
return _export_style_config(
|
|
906
|
+
filename,
|
|
907
|
+
fig,
|
|
908
|
+
ax,
|
|
909
|
+
y_data_list,
|
|
910
|
+
labels,
|
|
911
|
+
delta,
|
|
912
|
+
args,
|
|
913
|
+
tick_state,
|
|
914
|
+
offsets_list,
|
|
915
|
+
cts,
|
|
916
|
+
label_text_objects,
|
|
917
|
+
base_path,
|
|
918
|
+
show_cif_titles=show_titles,
|
|
919
|
+
overwrite_path=overwrite_path,
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
# NEW: apply imported style config (restricted application)
|
|
923
|
+
def apply_style_config(filename):
|
|
924
|
+
cts = getattr(_bp, 'cif_tick_series', None) if _bp is not None else None
|
|
925
|
+
hkl_map = getattr(_bp, 'cif_hkl_label_map', None) if _bp is not None else None
|
|
926
|
+
res = _bp_apply_style_config(
|
|
927
|
+
filename,
|
|
928
|
+
fig,
|
|
929
|
+
ax,
|
|
930
|
+
x_data_list,
|
|
931
|
+
y_data_list,
|
|
932
|
+
orig_y,
|
|
933
|
+
offsets_list,
|
|
934
|
+
label_text_objects,
|
|
935
|
+
args,
|
|
936
|
+
tick_state,
|
|
937
|
+
labels,
|
|
938
|
+
update_labels,
|
|
939
|
+
cts,
|
|
940
|
+
hkl_map,
|
|
941
|
+
adjust_margins,
|
|
942
|
+
)
|
|
943
|
+
# Sync top/right tick label2 fonts with current rcParams after style import
|
|
944
|
+
try:
|
|
945
|
+
fam_chain = plt.rcParams.get('font.sans-serif')
|
|
946
|
+
fam0 = fam_chain[0] if isinstance(fam_chain, list) and fam_chain else None
|
|
947
|
+
size0 = plt.rcParams.get('font.size', None)
|
|
948
|
+
if fam0 or size0 is not None:
|
|
949
|
+
for t in ax.xaxis.get_major_ticks():
|
|
950
|
+
if hasattr(t, 'label2'):
|
|
951
|
+
if size0 is not None: t.label2.set_size(size0)
|
|
952
|
+
if fam0: t.label2.set_family(fam0)
|
|
953
|
+
for t in ax.yaxis.get_major_ticks():
|
|
954
|
+
if hasattr(t, 'label2'):
|
|
955
|
+
if size0 is not None: t.label2.set_size(size0)
|
|
956
|
+
if fam0: t.label2.set_family(fam0)
|
|
957
|
+
except Exception:
|
|
958
|
+
pass
|
|
959
|
+
return res
|
|
960
|
+
|
|
961
|
+
# Initialize with current defaults
|
|
962
|
+
update_tick_visibility()
|
|
963
|
+
|
|
964
|
+
# --- Crosshair state & toggle function (UPDATED) ---
|
|
965
|
+
# Get wavelength info from cif_globals if available
|
|
966
|
+
file_wavelength_info = cif_globals.get('file_wavelength_info', []) if cif_globals else []
|
|
967
|
+
|
|
968
|
+
crosshair = {
|
|
969
|
+
'active': False,
|
|
970
|
+
'hline': None,
|
|
971
|
+
'vline': None,
|
|
972
|
+
'text': None,
|
|
973
|
+
'cid_motion': None,
|
|
974
|
+
'wavelength': None # only used when axis is 2theta (fallback if no file info)
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
def toggle_crosshair():
|
|
978
|
+
if not crosshair['active']:
|
|
979
|
+
# Only ask for wavelength if it's diffraction data, not using Q, and no file wavelength info
|
|
980
|
+
if is_diffraction and not use_Q and not file_wavelength_info:
|
|
981
|
+
try:
|
|
982
|
+
wl_in = _safe_input("Enter wavelength in Å for Q,d display (blank=skip, q=cancel): ").strip()
|
|
983
|
+
if wl_in.lower() == 'q':
|
|
984
|
+
print("Canceled.")
|
|
985
|
+
return
|
|
986
|
+
if wl_in:
|
|
987
|
+
crosshair['wavelength'] = float(wl_in)
|
|
988
|
+
else:
|
|
989
|
+
crosshair['wavelength'] = None
|
|
990
|
+
except ValueError:
|
|
991
|
+
print("Invalid wavelength. Skipping Q,d calculation.")
|
|
992
|
+
crosshair['wavelength'] = None
|
|
993
|
+
vline = ax.axvline(x=ax.get_xlim()[0], color='0.35', ls='--', lw=0.8, alpha=0.85, zorder=9999)
|
|
994
|
+
hline = ax.axhline(y=ax.get_ylim()[0], color='0.35', ls='--', lw=0.8, alpha=0.85, zorder=9999)
|
|
995
|
+
txt = ax.text(1.0, 1.0, "",
|
|
996
|
+
ha='right', va='bottom',
|
|
997
|
+
transform=ax.transAxes,
|
|
998
|
+
fontsize=max(9, int(0.6 * plt.rcParams.get('font.size', 16))),
|
|
999
|
+
color='0.15',
|
|
1000
|
+
bbox=dict(boxstyle='round,pad=0.25', fc='white', ec='0.7', alpha=0.8))
|
|
1001
|
+
|
|
1002
|
+
def on_move(event):
|
|
1003
|
+
if event.inaxes != ax or event.xdata is None or event.ydata is None:
|
|
1004
|
+
return
|
|
1005
|
+
x = float(event.xdata)
|
|
1006
|
+
y = float(event.ydata)
|
|
1007
|
+
vline.set_xdata([x, x])
|
|
1008
|
+
hline.set_ydata([y, y])
|
|
1009
|
+
|
|
1010
|
+
# For diffraction data, show Q/d calculations
|
|
1011
|
+
if is_diffraction:
|
|
1012
|
+
if use_Q:
|
|
1013
|
+
Q = x
|
|
1014
|
+
if Q != 0:
|
|
1015
|
+
d = 2 * np.pi / Q
|
|
1016
|
+
txt.set_text(f"Q={Q:.6g}\nd={d:.6g} Å\ny={y:.6g}")
|
|
1017
|
+
else:
|
|
1018
|
+
txt.set_text(f"Q={Q:.6g}\nd=∞\ny={y:.6g}")
|
|
1019
|
+
elif use_r:
|
|
1020
|
+
txt.set_text(f"r={x:.6g} Å\ny={y:.6g}")
|
|
1021
|
+
else:
|
|
1022
|
+
# 2θ mode
|
|
1023
|
+
# Check if we have file wavelength info (dual wavelength conversion)
|
|
1024
|
+
wl_info = file_wavelength_info[0] if file_wavelength_info else None
|
|
1025
|
+
if wl_info and wl_info.get('original_wl') is not None and wl_info.get('conversion_wl') is not None:
|
|
1026
|
+
# Dual wavelength: show original 2theta and current 2theta
|
|
1027
|
+
orig_wl = wl_info['original_wl']
|
|
1028
|
+
conv_wl = wl_info['conversion_wl']
|
|
1029
|
+
# Current 2theta is x
|
|
1030
|
+
# Calculate original 2theta: current 2theta -> Q -> original 2theta
|
|
1031
|
+
theta_rad = np.radians(x / 2.0)
|
|
1032
|
+
Q = 4 * np.pi * np.sin(theta_rad) / conv_wl
|
|
1033
|
+
# Convert Q back to original 2theta
|
|
1034
|
+
sin_theta_orig = Q * orig_wl / (4 * np.pi)
|
|
1035
|
+
sin_theta_orig = np.clip(sin_theta_orig, -1.0, 1.0)
|
|
1036
|
+
theta_orig_rad = np.arcsin(sin_theta_orig)
|
|
1037
|
+
orig_2theta = np.degrees(2 * theta_orig_rad)
|
|
1038
|
+
if Q != 0:
|
|
1039
|
+
d = 2 * np.pi / Q
|
|
1040
|
+
txt.set_text(f"2θ={x:.6g}° (λ₂={conv_wl:.5f})\n2θ₀={orig_2theta:.6g}° (λ₁={orig_wl:.5f})\nQ={Q:.6g}\nd={d:.6g} Å\ny={y:.6g}")
|
|
1041
|
+
else:
|
|
1042
|
+
txt.set_text(f"2θ={x:.6g}° (λ₂={conv_wl:.5f})\n2θ₀={orig_2theta:.6g}° (λ₁={orig_wl:.5f})\nQ=0\nd=∞\ny={y:.6g}")
|
|
1043
|
+
elif crosshair['wavelength'] is not None:
|
|
1044
|
+
lam = crosshair['wavelength']
|
|
1045
|
+
theta_rad = np.radians(x / 2.0)
|
|
1046
|
+
Q = 4 * np.pi * np.sin(theta_rad) / lam
|
|
1047
|
+
if Q != 0:
|
|
1048
|
+
d = 2 * np.pi / Q
|
|
1049
|
+
txt.set_text(f"2θ={x:.6g}°\nQ={Q:.6g}\nd={d:.6g} Å\ny={y:.6g}")
|
|
1050
|
+
else:
|
|
1051
|
+
txt.set_text(f"2θ={x:.6g}°\nQ=0\nd=∞\ny={y:.6g}")
|
|
1052
|
+
else:
|
|
1053
|
+
txt.set_text(f"2θ={x:.6g}°\ny={y:.6g}")
|
|
1054
|
+
else:
|
|
1055
|
+
# For non-diffraction data, just show x and y values
|
|
1056
|
+
txt.set_text(f"x={x:.6g}\ny={y:.6g}")
|
|
1057
|
+
|
|
1058
|
+
fig.canvas.draw_idle()
|
|
1059
|
+
|
|
1060
|
+
cid = fig.canvas.mpl_connect('motion_notify_event', on_move)
|
|
1061
|
+
crosshair.update({'active': True, 'hline': hline, 'vline': vline,
|
|
1062
|
+
'text': txt, 'cid_motion': cid})
|
|
1063
|
+
print("Crosshair ON. Move mouse over axes. Press 'n' again to turn off.")
|
|
1064
|
+
else:
|
|
1065
|
+
if crosshair['cid_motion'] is not None:
|
|
1066
|
+
fig.canvas.mpl_disconnect(crosshair['cid_motion'])
|
|
1067
|
+
for k in ('hline', 'vline', 'text'):
|
|
1068
|
+
art = crosshair[k]
|
|
1069
|
+
if art is not None:
|
|
1070
|
+
try:
|
|
1071
|
+
art.remove()
|
|
1072
|
+
except Exception:
|
|
1073
|
+
pass
|
|
1074
|
+
crosshair.update({'active': False, 'hline': None, 'vline': None,
|
|
1075
|
+
'text': None, 'cid_motion': None})
|
|
1076
|
+
fig.canvas.draw_idle()
|
|
1077
|
+
print("Crosshair OFF.")
|
|
1078
|
+
# --- End crosshair additions (UPDATED) ---
|
|
1079
|
+
|
|
1080
|
+
# -------- Session helper now provided by batplot.session (dump only here) --------
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
# history management:
|
|
1084
|
+
state_history = []
|
|
1085
|
+
|
|
1086
|
+
def push_state(note=""):
|
|
1087
|
+
"""Snapshot current editable state (before a modifying action)."""
|
|
1088
|
+
try:
|
|
1089
|
+
# Helper to capture a representative tick line width
|
|
1090
|
+
def _tick_width(axis_obj, which):
|
|
1091
|
+
try:
|
|
1092
|
+
tick_kw = axis_obj._major_tick_kw if which == 'major' else axis_obj._minor_tick_kw
|
|
1093
|
+
width = tick_kw.get('width')
|
|
1094
|
+
if width is None:
|
|
1095
|
+
axis_name = getattr(axis_obj, 'axis_name', 'x')
|
|
1096
|
+
rc_key = f"{axis_name}tick.{which}.width"
|
|
1097
|
+
width = plt.rcParams.get(rc_key)
|
|
1098
|
+
if width is not None:
|
|
1099
|
+
return float(width)
|
|
1100
|
+
except Exception:
|
|
1101
|
+
return None
|
|
1102
|
+
return None
|
|
1103
|
+
snap = {
|
|
1104
|
+
"note": note,
|
|
1105
|
+
"xlim": ax.get_xlim(),
|
|
1106
|
+
"ylim": ax.get_ylim(),
|
|
1107
|
+
"tick_state": tick_state.copy(),
|
|
1108
|
+
"font_size": plt.rcParams.get('font.size'),
|
|
1109
|
+
"font_chain": list(plt.rcParams.get('font.sans-serif', [])),
|
|
1110
|
+
"labels": list(labels),
|
|
1111
|
+
"delta": delta,
|
|
1112
|
+
"lines": [],
|
|
1113
|
+
"fig_size": list(fig.get_size_inches()),
|
|
1114
|
+
"fig_dpi": fig.dpi,
|
|
1115
|
+
"axes_bbox": [float(v) for v in ax.get_position().bounds], # x0,y0,w,h
|
|
1116
|
+
"axis_labels": {"xlabel": ax.get_xlabel(), "ylabel": ax.get_ylabel()},
|
|
1117
|
+
"axis_titles": {"top_x": bool(getattr(ax, '_top_xlabel_on', False)),
|
|
1118
|
+
"right_y": bool(getattr(ax, '_right_ylabel_on', False))},
|
|
1119
|
+
"title_offsets": {
|
|
1120
|
+
"top_y": float(getattr(ax, '_top_xlabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
1121
|
+
"top_x": float(getattr(ax, '_top_xlabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
1122
|
+
"bottom_y": float(getattr(ax, '_bottom_xlabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
1123
|
+
"left_x": float(getattr(ax, '_left_ylabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
1124
|
+
"right_x": float(getattr(ax, '_right_ylabel_manual_offset_x_pts', 0.0) or 0.0),
|
|
1125
|
+
"right_y": float(getattr(ax, '_right_ylabel_manual_offset_y_pts', 0.0) or 0.0),
|
|
1126
|
+
},
|
|
1127
|
+
"spines": {name: {"lw": sp.get_linewidth(), "color": sp.get_edgecolor(), "visible": sp.get_visible()} for name, sp in ax.spines.items()},
|
|
1128
|
+
"tick_widths": {
|
|
1129
|
+
"x_major": _tick_width(ax.xaxis, 'major'),
|
|
1130
|
+
"x_minor": _tick_width(ax.xaxis, 'minor'),
|
|
1131
|
+
"y_major": _tick_width(ax.yaxis, 'major'),
|
|
1132
|
+
"y_minor": _tick_width(ax.yaxis, 'minor')
|
|
1133
|
+
},
|
|
1134
|
+
"tick_lengths": dict(getattr(fig, '_tick_lengths', {'major': None, 'minor': None})),
|
|
1135
|
+
"tick_direction": getattr(fig, '_tick_direction', 'out'),
|
|
1136
|
+
"cif_tick_series": (list(getattr(_bp, 'cif_tick_series')) if (_bp is not None and hasattr(_bp, 'cif_tick_series')) else None),
|
|
1137
|
+
"show_cif_hkl": (bool(getattr(_bp, 'show_cif_hkl')) if _bp is not None and hasattr(_bp, 'show_cif_hkl') else False),
|
|
1138
|
+
"show_cif_titles": (bool(getattr(_bp, 'show_cif_titles')) if _bp is not None and hasattr(_bp, 'show_cif_titles') else True),
|
|
1139
|
+
"rotation_angle": getattr(ax, '_rotation_angle', 0),
|
|
1140
|
+
"stack_label_at_bottom": getattr(fig, '_stack_label_at_bottom', False),
|
|
1141
|
+
"label_anchor_left": getattr(fig, '_label_anchor_left', False),
|
|
1142
|
+
"grid": ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
|
|
1143
|
+
}
|
|
1144
|
+
# Line + data arrays
|
|
1145
|
+
for i, ln in enumerate(ax.lines):
|
|
1146
|
+
snap["lines"].append({
|
|
1147
|
+
"index": i,
|
|
1148
|
+
"x": np.array(ln.get_xdata(), copy=True),
|
|
1149
|
+
"y": np.array(ln.get_ydata(), copy=True),
|
|
1150
|
+
"color": ln.get_color(),
|
|
1151
|
+
"lw": ln.get_linewidth(),
|
|
1152
|
+
"ls": ln.get_linestyle(),
|
|
1153
|
+
"marker": ln.get_marker(),
|
|
1154
|
+
"markersize": getattr(ln, 'get_markersize', lambda: None)(),
|
|
1155
|
+
"mfc": getattr(ln, 'get_markerfacecolor', lambda: None)(),
|
|
1156
|
+
"mec": getattr(ln, 'get_markeredgecolor', lambda: None)(),
|
|
1157
|
+
"alpha": ln.get_alpha()
|
|
1158
|
+
})
|
|
1159
|
+
# Data lists
|
|
1160
|
+
snap["x_data_list"] = [np.array(a, copy=True) for a in x_data_list]
|
|
1161
|
+
snap["y_data_list"] = [np.array(a, copy=True) for a in y_data_list]
|
|
1162
|
+
snap["orig_y"] = [np.array(a, copy=True) for a in orig_y]
|
|
1163
|
+
snap["offsets"] = list(offsets_list)
|
|
1164
|
+
# Label text content
|
|
1165
|
+
snap["label_texts"] = [t.get_text() for t in label_text_objects]
|
|
1166
|
+
state_history.append(snap)
|
|
1167
|
+
if len(state_history) > 40:
|
|
1168
|
+
state_history.pop(0)
|
|
1169
|
+
except Exception as e:
|
|
1170
|
+
print(f"Warning: could not snapshot state: {e}")
|
|
1171
|
+
|
|
1172
|
+
def restore_state():
|
|
1173
|
+
nonlocal delta
|
|
1174
|
+
if not state_history:
|
|
1175
|
+
print("No undo history.")
|
|
1176
|
+
return
|
|
1177
|
+
snap = state_history.pop()
|
|
1178
|
+
try:
|
|
1179
|
+
# Basic numeric state
|
|
1180
|
+
ax.set_xlim(*snap["xlim"])
|
|
1181
|
+
ax.set_ylim(*snap["ylim"])
|
|
1182
|
+
# Tick state
|
|
1183
|
+
snap_ts = snap.get("tick_state", {})
|
|
1184
|
+
for k, v in snap_ts.items():
|
|
1185
|
+
if k in tick_state:
|
|
1186
|
+
tick_state[k] = v
|
|
1187
|
+
# If snapshot was legacy-only, map bx/tx/ly/ry into new keys
|
|
1188
|
+
if not any(k in snap_ts for k in ('b_ticks','t_ticks','l_ticks','r_ticks')):
|
|
1189
|
+
if 'bx' in snap_ts:
|
|
1190
|
+
tick_state['b_ticks'] = bool(snap_ts.get('bx', tick_state['bx']))
|
|
1191
|
+
tick_state['b_labels'] = bool(snap_ts.get('bx', tick_state['bx']))
|
|
1192
|
+
if 'tx' in snap_ts:
|
|
1193
|
+
tick_state['t_ticks'] = bool(snap_ts.get('tx', tick_state['tx']))
|
|
1194
|
+
tick_state['t_labels'] = bool(snap_ts.get('tx', tick_state['tx']))
|
|
1195
|
+
if 'ly' in snap_ts:
|
|
1196
|
+
tick_state['l_ticks'] = bool(snap_ts.get('ly', tick_state['ly']))
|
|
1197
|
+
tick_state['l_labels'] = bool(snap_ts.get('ly', tick_state['ly']))
|
|
1198
|
+
if 'ry' in snap_ts:
|
|
1199
|
+
tick_state['r_ticks'] = bool(snap_ts.get('ry', tick_state['ry']))
|
|
1200
|
+
tick_state['r_labels'] = bool(snap_ts.get('ry', tick_state['ry']))
|
|
1201
|
+
_sync_legacy_tick_keys()
|
|
1202
|
+
update_tick_visibility()
|
|
1203
|
+
|
|
1204
|
+
# Fonts
|
|
1205
|
+
if snap["font_chain"]:
|
|
1206
|
+
plt.rcParams['font.family'] = 'sans-serif'
|
|
1207
|
+
plt.rcParams['font.sans-serif'] = snap["font_chain"]
|
|
1208
|
+
if snap["font_size"]:
|
|
1209
|
+
try:
|
|
1210
|
+
plt.rcParams['font.size'] = snap["font_size"]
|
|
1211
|
+
except Exception:
|
|
1212
|
+
pass
|
|
1213
|
+
|
|
1214
|
+
# Figure size & dpi
|
|
1215
|
+
if snap.get("fig_size") and isinstance(snap["fig_size"], (list, tuple)) and len(snap["fig_size"])==2:
|
|
1216
|
+
if not (getattr(_bp, 'keep_canvas_fixed', True) if _bp is not None else True):
|
|
1217
|
+
try:
|
|
1218
|
+
fig.set_size_inches(snap["fig_size"][0], snap["fig_size"][1], forward=True)
|
|
1219
|
+
except Exception:
|
|
1220
|
+
pass
|
|
1221
|
+
else:
|
|
1222
|
+
print("(Canvas fixed) Ignoring undo figure size restore.")
|
|
1223
|
+
# Don't restore DPI from undo - use system default to avoid display-dependent issues
|
|
1224
|
+
|
|
1225
|
+
# Restore axes (plot frame) via stored bbox if present
|
|
1226
|
+
if snap.get("axes_bbox") and isinstance(snap["axes_bbox"], (list, tuple)) and len(snap["axes_bbox"])==4:
|
|
1227
|
+
try:
|
|
1228
|
+
x0,y0,w,h = snap["axes_bbox"]
|
|
1229
|
+
left = x0; bottom = y0; right = x0 + w; top = y0 + h
|
|
1230
|
+
if 0 < left < right <=1 and 0 < bottom < top <=1:
|
|
1231
|
+
fig.subplots_adjust(left=left, right=right, bottom=bottom, top=top)
|
|
1232
|
+
except Exception:
|
|
1233
|
+
pass
|
|
1234
|
+
|
|
1235
|
+
# Axis labels (use low-level API to avoid layout recalculation)
|
|
1236
|
+
axis_labels = snap.get("axis_labels", {})
|
|
1237
|
+
if axis_labels.get("xlabel") is not None:
|
|
1238
|
+
ax.xaxis.label.set_text(axis_labels["xlabel"])
|
|
1239
|
+
if axis_labels.get("ylabel") is not None:
|
|
1240
|
+
ax.yaxis.label.set_text(axis_labels["ylabel"])
|
|
1241
|
+
# Manual offsets for all titles - support both old and new format
|
|
1242
|
+
title_offsets = snap.get("title_offsets", {})
|
|
1243
|
+
try:
|
|
1244
|
+
if 'top_y' in title_offsets:
|
|
1245
|
+
ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top_y', 0.0) or 0.0)
|
|
1246
|
+
else:
|
|
1247
|
+
# Backward compatibility: old format used 'top' for y-offset
|
|
1248
|
+
ax._top_xlabel_manual_offset_y_pts = float(title_offsets.get('top', 0.0) or 0.0)
|
|
1249
|
+
except Exception:
|
|
1250
|
+
ax._top_xlabel_manual_offset_y_pts = 0.0
|
|
1251
|
+
try:
|
|
1252
|
+
ax._top_xlabel_manual_offset_x_pts = float(title_offsets.get('top_x', 0.0) or 0.0)
|
|
1253
|
+
except Exception:
|
|
1254
|
+
ax._top_xlabel_manual_offset_x_pts = 0.0
|
|
1255
|
+
try:
|
|
1256
|
+
ax._bottom_xlabel_manual_offset_y_pts = float(title_offsets.get('bottom_y', 0.0) or 0.0)
|
|
1257
|
+
except Exception:
|
|
1258
|
+
ax._bottom_xlabel_manual_offset_y_pts = 0.0
|
|
1259
|
+
try:
|
|
1260
|
+
ax._left_ylabel_manual_offset_x_pts = float(title_offsets.get('left_x', 0.0) or 0.0)
|
|
1261
|
+
except Exception:
|
|
1262
|
+
ax._left_ylabel_manual_offset_x_pts = 0.0
|
|
1263
|
+
try:
|
|
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
|
+
except Exception:
|
|
1270
|
+
ax._right_ylabel_manual_offset_x_pts = 0.0
|
|
1271
|
+
try:
|
|
1272
|
+
ax._right_ylabel_manual_offset_y_pts = float(title_offsets.get('right_y', 0.0) or 0.0)
|
|
1273
|
+
except Exception:
|
|
1274
|
+
ax._right_ylabel_manual_offset_y_pts = 0.0
|
|
1275
|
+
|
|
1276
|
+
# Axis title duplicates (top X / right Y)
|
|
1277
|
+
at = snap.get("axis_titles", {})
|
|
1278
|
+
# Top X
|
|
1279
|
+
try:
|
|
1280
|
+
ax._top_xlabel_on = bool(at.get('top_x', False))
|
|
1281
|
+
position_top_xlabel()
|
|
1282
|
+
except Exception:
|
|
1283
|
+
pass
|
|
1284
|
+
# Right Y
|
|
1285
|
+
try:
|
|
1286
|
+
ax._right_ylabel_on = bool(at.get('right_y', False))
|
|
1287
|
+
position_right_ylabel()
|
|
1288
|
+
except Exception:
|
|
1289
|
+
pass
|
|
1290
|
+
# Also reposition bottom/left titles to consume pending pads and match tick label visibility
|
|
1291
|
+
try:
|
|
1292
|
+
position_bottom_xlabel()
|
|
1293
|
+
except Exception:
|
|
1294
|
+
pass
|
|
1295
|
+
try:
|
|
1296
|
+
position_left_ylabel()
|
|
1297
|
+
except Exception:
|
|
1298
|
+
pass
|
|
1299
|
+
|
|
1300
|
+
# Spines (linewidth, color, visibility)
|
|
1301
|
+
for name, spec in snap.get("spines", {}).items():
|
|
1302
|
+
sp_obj = ax.spines.get(name)
|
|
1303
|
+
if sp_obj is None:
|
|
1304
|
+
continue
|
|
1305
|
+
try:
|
|
1306
|
+
if "lw" in spec:
|
|
1307
|
+
sp_obj.set_linewidth(spec["lw"])
|
|
1308
|
+
if "color" in spec and spec["color"] is not None:
|
|
1309
|
+
sp_obj.set_edgecolor(spec["color"])
|
|
1310
|
+
if name in ('top', 'bottom'):
|
|
1311
|
+
ax.tick_params(axis='x', which='both', colors=spec['color'])
|
|
1312
|
+
ax.xaxis.label.set_color(spec['color'])
|
|
1313
|
+
else:
|
|
1314
|
+
ax.tick_params(axis='y', which='both', colors=spec['color'])
|
|
1315
|
+
ax.yaxis.label.set_color(spec['color'])
|
|
1316
|
+
if "visible" in spec:
|
|
1317
|
+
try:
|
|
1318
|
+
sp_obj.set_visible(bool(spec["visible"]))
|
|
1319
|
+
except Exception:
|
|
1320
|
+
pass
|
|
1321
|
+
except Exception:
|
|
1322
|
+
pass
|
|
1323
|
+
|
|
1324
|
+
# Tick widths
|
|
1325
|
+
tw = snap.get("tick_widths", {})
|
|
1326
|
+
try:
|
|
1327
|
+
if tw.get("x_major") is not None:
|
|
1328
|
+
ax.tick_params(axis='x', which='major', width=tw["x_major"])
|
|
1329
|
+
if tw.get("x_minor") is not None:
|
|
1330
|
+
ax.tick_params(axis='x', which='minor', width=tw["x_minor"])
|
|
1331
|
+
if tw.get("y_major") is not None:
|
|
1332
|
+
ax.tick_params(axis='y', which='major', width=tw["y_major"])
|
|
1333
|
+
if tw.get("y_minor") is not None:
|
|
1334
|
+
ax.tick_params(axis='y', which='minor', width=tw["y_minor"])
|
|
1335
|
+
except Exception:
|
|
1336
|
+
pass
|
|
1337
|
+
|
|
1338
|
+
# Tick lengths
|
|
1339
|
+
tl = snap.get("tick_lengths", {})
|
|
1340
|
+
try:
|
|
1341
|
+
if tl.get("major") is not None:
|
|
1342
|
+
ax.tick_params(axis='both', which='major', length=tl["major"])
|
|
1343
|
+
if tl.get("minor") is not None:
|
|
1344
|
+
ax.tick_params(axis='both', which='minor', length=tl["minor"])
|
|
1345
|
+
if tl:
|
|
1346
|
+
fig._tick_lengths = dict(tl)
|
|
1347
|
+
except Exception:
|
|
1348
|
+
pass
|
|
1349
|
+
|
|
1350
|
+
# Tick direction
|
|
1351
|
+
try:
|
|
1352
|
+
tick_dir = snap.get("tick_direction", 'out')
|
|
1353
|
+
ax.tick_params(axis='both', which='both', direction=tick_dir)
|
|
1354
|
+
fig._tick_direction = tick_dir
|
|
1355
|
+
except Exception:
|
|
1356
|
+
pass
|
|
1357
|
+
|
|
1358
|
+
# Labels list
|
|
1359
|
+
labels[:] = snap["labels"]
|
|
1360
|
+
|
|
1361
|
+
# Data & lines
|
|
1362
|
+
if len(snap["lines"]) == len(ax.lines):
|
|
1363
|
+
for item in snap["lines"]:
|
|
1364
|
+
i = item["index"]
|
|
1365
|
+
ln = ax.lines[i]
|
|
1366
|
+
ln.set_data(item["x"], item["y"])
|
|
1367
|
+
ln.set_color(item["color"])
|
|
1368
|
+
ln.set_linewidth(item["lw"])
|
|
1369
|
+
ln.set_linestyle(item["ls"])
|
|
1370
|
+
if item["marker"] is not None:
|
|
1371
|
+
ln.set_marker(item["marker"])
|
|
1372
|
+
if item.get("markersize") is not None:
|
|
1373
|
+
try: ln.set_markersize(item["markersize"])
|
|
1374
|
+
except Exception: pass
|
|
1375
|
+
if item.get("mfc") is not None:
|
|
1376
|
+
try: ln.set_markerfacecolor(item["mfc"])
|
|
1377
|
+
except Exception: pass
|
|
1378
|
+
if item.get("mec") is not None:
|
|
1379
|
+
try: ln.set_markeredgecolor(item["mec"])
|
|
1380
|
+
except Exception: pass
|
|
1381
|
+
if item["alpha"] is not None:
|
|
1382
|
+
ln.set_alpha(item["alpha"])
|
|
1383
|
+
|
|
1384
|
+
# Replace lists
|
|
1385
|
+
x_data_list[:] = [np.array(a, copy=True) for a in snap["x_data_list"]]
|
|
1386
|
+
y_data_list[:] = [np.array(a, copy=True) for a in snap["y_data_list"]]
|
|
1387
|
+
orig_y[:] = [np.array(a, copy=True) for a in snap["orig_y"]]
|
|
1388
|
+
offsets_list[:] = list(snap["offsets"])
|
|
1389
|
+
delta = snap.get("delta", delta)
|
|
1390
|
+
|
|
1391
|
+
# Recalculate y_data_list from orig_y and offsets_list to ensure consistency
|
|
1392
|
+
for i in range(len(orig_y)):
|
|
1393
|
+
if i < len(offsets_list):
|
|
1394
|
+
y_data_list[i] = orig_y[i] + offsets_list[i]
|
|
1395
|
+
else:
|
|
1396
|
+
y_data_list[i] = orig_y[i].copy()
|
|
1397
|
+
|
|
1398
|
+
# Update line data with restored values
|
|
1399
|
+
for i in range(min(len(ax.lines), len(x_data_list), len(y_data_list))):
|
|
1400
|
+
try:
|
|
1401
|
+
ax.lines[i].set_data(x_data_list[i], y_data_list[i])
|
|
1402
|
+
except Exception:
|
|
1403
|
+
pass
|
|
1404
|
+
|
|
1405
|
+
# Restore rotation angle
|
|
1406
|
+
if 'rotation_angle' in snap:
|
|
1407
|
+
ax._rotation_angle = snap['rotation_angle']
|
|
1408
|
+
|
|
1409
|
+
# Restore legend position (stack_label_at_bottom)
|
|
1410
|
+
if 'stack_label_at_bottom' in snap:
|
|
1411
|
+
fig._stack_label_at_bottom = bool(snap['stack_label_at_bottom'])
|
|
1412
|
+
if 'label_anchor_left' in snap:
|
|
1413
|
+
fig._label_anchor_left = bool(snap['label_anchor_left'])
|
|
1414
|
+
|
|
1415
|
+
# Restore grid state
|
|
1416
|
+
if 'grid' in snap:
|
|
1417
|
+
try:
|
|
1418
|
+
if snap['grid']:
|
|
1419
|
+
ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
|
|
1420
|
+
else:
|
|
1421
|
+
ax.grid(False)
|
|
1422
|
+
except Exception:
|
|
1423
|
+
pass
|
|
1424
|
+
|
|
1425
|
+
# CIF tick sets & label visibility (write back to batplot module globals)
|
|
1426
|
+
if _bp is not None and snap.get("cif_tick_series") is not None and hasattr(_bp, 'cif_tick_series'):
|
|
1427
|
+
try:
|
|
1428
|
+
_bp.cif_tick_series[:] = [tuple(t) for t in snap["cif_tick_series"]]
|
|
1429
|
+
except Exception:
|
|
1430
|
+
pass
|
|
1431
|
+
if _bp is not None and 'show_cif_hkl' in snap:
|
|
1432
|
+
try:
|
|
1433
|
+
setattr(_bp, 'show_cif_hkl', bool(snap['show_cif_hkl']))
|
|
1434
|
+
except Exception:
|
|
1435
|
+
pass
|
|
1436
|
+
if _bp is not None and 'show_cif_titles' in snap:
|
|
1437
|
+
try:
|
|
1438
|
+
new_state = bool(snap['show_cif_titles'])
|
|
1439
|
+
setattr(_bp, 'show_cif_titles', new_state)
|
|
1440
|
+
# Also update figure attribute and __main__ module
|
|
1441
|
+
fig._bp_show_cif_titles = new_state
|
|
1442
|
+
try:
|
|
1443
|
+
_bp_module = sys.modules.get('__main__')
|
|
1444
|
+
if _bp_module is not None:
|
|
1445
|
+
setattr(_bp_module, 'show_cif_titles', new_state)
|
|
1446
|
+
except Exception:
|
|
1447
|
+
pass
|
|
1448
|
+
except Exception:
|
|
1449
|
+
pass
|
|
1450
|
+
# Redraw CIF ticks after restoration if available
|
|
1451
|
+
if hasattr(ax, '_cif_draw_func'):
|
|
1452
|
+
try:
|
|
1453
|
+
ax._cif_draw_func()
|
|
1454
|
+
except Exception:
|
|
1455
|
+
pass
|
|
1456
|
+
|
|
1457
|
+
# Restore label texts (keep numbering style)
|
|
1458
|
+
for i, txt in enumerate(label_text_objects):
|
|
1459
|
+
base = labels[i] if i < len(labels) else ""
|
|
1460
|
+
txt.set_text(f"{i+1}: {base}")
|
|
1461
|
+
|
|
1462
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
1463
|
+
try:
|
|
1464
|
+
globals()['tick_state'] = tick_state
|
|
1465
|
+
except Exception:
|
|
1466
|
+
pass
|
|
1467
|
+
try:
|
|
1468
|
+
fig.canvas.draw()
|
|
1469
|
+
except Exception:
|
|
1470
|
+
try: fig.canvas.draw_idle()
|
|
1471
|
+
except Exception: pass
|
|
1472
|
+
print("Undo: restored previous state.")
|
|
1473
|
+
except Exception as e:
|
|
1474
|
+
print(f"Error restoring state: {e}")
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
while True:
|
|
1478
|
+
try:
|
|
1479
|
+
print_main_menu()
|
|
1480
|
+
key = _safe_input("Press a key: ").strip().lower()
|
|
1481
|
+
except (KeyboardInterrupt, EOFError):
|
|
1482
|
+
print("\n\nExiting interactive menu...")
|
|
1483
|
+
break
|
|
1484
|
+
|
|
1485
|
+
if not key:
|
|
1486
|
+
continue
|
|
1487
|
+
|
|
1488
|
+
# NEW: disable 'y' and 'd' in stack mode
|
|
1489
|
+
if args.stack and key in ('y', 'd'):
|
|
1490
|
+
print("Option disabled in --stack mode.")
|
|
1491
|
+
continue
|
|
1492
|
+
|
|
1493
|
+
if key == 'q':
|
|
1494
|
+
try:
|
|
1495
|
+
confirm = _safe_input(colorize_prompt("Quit interactive? Remember to save (e=export, s=save). Quit now? (y/n): ")).strip().lower()
|
|
1496
|
+
except (KeyboardInterrupt, EOFError):
|
|
1497
|
+
print("\nExiting interactive menu...")
|
|
1498
|
+
break
|
|
1499
|
+
if confirm == 'y':
|
|
1500
|
+
break
|
|
1501
|
+
else:
|
|
1502
|
+
continue
|
|
1503
|
+
elif key == 'z': # toggle hkl labels on CIF ticks (non-blocking)
|
|
1504
|
+
# Check if CIF files exist before allowing this command
|
|
1505
|
+
has_cif = False
|
|
1506
|
+
try:
|
|
1507
|
+
has_cif = any(f.split(':')[0].lower().endswith('.cif') for f in args.files)
|
|
1508
|
+
if not has_cif and _bp is not None:
|
|
1509
|
+
has_cif = bool(getattr(_bp, 'cif_tick_series', None))
|
|
1510
|
+
except Exception:
|
|
1511
|
+
pass
|
|
1512
|
+
if not has_cif:
|
|
1513
|
+
print("Unknown option.")
|
|
1514
|
+
continue
|
|
1515
|
+
try:
|
|
1516
|
+
# Flip visibility flag in batplot module
|
|
1517
|
+
cur = bool(getattr(_bp, 'show_cif_hkl', False)) if _bp is not None else False
|
|
1518
|
+
if _bp is not None:
|
|
1519
|
+
setattr(_bp, 'show_cif_hkl', not cur)
|
|
1520
|
+
# Avoid re-entrant extension while redrawing
|
|
1521
|
+
prev_ext = bool(getattr(_bp, 'cif_extend_suspended', False)) if _bp is not None else False
|
|
1522
|
+
if _bp is not None:
|
|
1523
|
+
setattr(_bp, 'cif_extend_suspended', True)
|
|
1524
|
+
if hasattr(ax, '_cif_draw_func'):
|
|
1525
|
+
ax._cif_draw_func()
|
|
1526
|
+
if _bp is not None:
|
|
1527
|
+
setattr(_bp, 'cif_extend_suspended', prev_ext)
|
|
1528
|
+
# Count visible labels
|
|
1529
|
+
n_labels = 0
|
|
1530
|
+
if bool(getattr(_bp, 'show_cif_hkl', False)) and hasattr(ax, '_cif_tick_art'):
|
|
1531
|
+
for art in getattr(ax, '_cif_tick_art'):
|
|
1532
|
+
try:
|
|
1533
|
+
if hasattr(art, 'get_text') and '(' in art.get_text():
|
|
1534
|
+
n_labels += 1
|
|
1535
|
+
except Exception:
|
|
1536
|
+
pass
|
|
1537
|
+
print(f"CIF hkl labels {'ON' if bool(getattr(_bp,'show_cif_hkl', False)) else 'OFF'} (visible labels: {n_labels}).")
|
|
1538
|
+
except Exception as e:
|
|
1539
|
+
print(f"Error toggling hkl labels: {e}")
|
|
1540
|
+
continue
|
|
1541
|
+
elif key == 'h': # legend submenu
|
|
1542
|
+
try:
|
|
1543
|
+
while True:
|
|
1544
|
+
print("\n\033[1mLegend submenu:\033[0m")
|
|
1545
|
+
print(f" {colorize_menu('v: show/hide curve names')}")
|
|
1546
|
+
current_pos = _current_label_position()
|
|
1547
|
+
print(f" {colorize_menu(f's: legend position (current: {current_pos})')}")
|
|
1548
|
+
print(f" {colorize_menu('q: back to main menu')}")
|
|
1549
|
+
sub_key = _safe_input("Choose: ").strip().lower()
|
|
1550
|
+
|
|
1551
|
+
if sub_key == 'q':
|
|
1552
|
+
break
|
|
1553
|
+
elif sub_key == 'v':
|
|
1554
|
+
# Toggle curve name labels visibility
|
|
1555
|
+
push_state("legend-visibility")
|
|
1556
|
+
first_visible = label_text_objects[0].get_visible() if label_text_objects else True
|
|
1557
|
+
new_state = not first_visible
|
|
1558
|
+
for lbl in label_text_objects:
|
|
1559
|
+
lbl.set_visible(new_state)
|
|
1560
|
+
fig._curve_names_visible = new_state
|
|
1561
|
+
stack_label_bottom = getattr(fig, '_stack_label_at_bottom', False)
|
|
1562
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, stack_label_bottom)
|
|
1563
|
+
fig.canvas.draw_idle()
|
|
1564
|
+
print(f"Curve name labels {'ON' if new_state else 'OFF'}.")
|
|
1565
|
+
elif sub_key == 's':
|
|
1566
|
+
print("\nChoose legend position:")
|
|
1567
|
+
print(" 1: top-right")
|
|
1568
|
+
print(" 2: top-left")
|
|
1569
|
+
print(" 3: bottom-right")
|
|
1570
|
+
print(" 4: bottom-left")
|
|
1571
|
+
choice = _safe_input("Position (1-4, q=cancel): ").strip().lower()
|
|
1572
|
+
options = {
|
|
1573
|
+
'1': (False, False),
|
|
1574
|
+
'2': (False, True),
|
|
1575
|
+
'3': (True, False),
|
|
1576
|
+
'4': (True, True),
|
|
1577
|
+
}
|
|
1578
|
+
if not choice or choice == 'q':
|
|
1579
|
+
continue
|
|
1580
|
+
if choice in options:
|
|
1581
|
+
push_state("legend-position")
|
|
1582
|
+
bottom, left = options[choice]
|
|
1583
|
+
_apply_legend_position(bottom, left)
|
|
1584
|
+
new_pos = f"{'bottom' if bottom else 'top'}-{'left' if left else 'right'}"
|
|
1585
|
+
print(f"Legend position changed to {new_pos}.")
|
|
1586
|
+
else:
|
|
1587
|
+
print("Unknown option.")
|
|
1588
|
+
else:
|
|
1589
|
+
print("Unknown option.")
|
|
1590
|
+
except Exception as e:
|
|
1591
|
+
print(f"Error in legend submenu: {e}")
|
|
1592
|
+
continue
|
|
1593
|
+
elif key == 'j': # toggle CIF title labels (filename labels)
|
|
1594
|
+
# Check if CIF files exist before allowing this command
|
|
1595
|
+
has_cif = False
|
|
1596
|
+
try:
|
|
1597
|
+
has_cif = any(f.split(':')[0].lower().endswith('.cif') for f in args.files)
|
|
1598
|
+
if not has_cif and _bp is not None:
|
|
1599
|
+
has_cif = bool(getattr(_bp, 'cif_tick_series', None))
|
|
1600
|
+
except Exception:
|
|
1601
|
+
pass
|
|
1602
|
+
if not has_cif:
|
|
1603
|
+
print("Unknown option.")
|
|
1604
|
+
continue
|
|
1605
|
+
try:
|
|
1606
|
+
# Preserve both x and y-axis limits to prevent movement
|
|
1607
|
+
prev_xlim = ax.get_xlim()
|
|
1608
|
+
prev_ylim = ax.get_ylim()
|
|
1609
|
+
# Flip visibility flag for CIF titles
|
|
1610
|
+
cur = bool(getattr(_bp, 'show_cif_titles', True)) if _bp is not None else True
|
|
1611
|
+
new_state = not cur
|
|
1612
|
+
if _bp is not None:
|
|
1613
|
+
setattr(_bp, 'show_cif_titles', new_state)
|
|
1614
|
+
# Also store on figure for draw_cif_ticks to access
|
|
1615
|
+
fig._bp_show_cif_titles = new_state
|
|
1616
|
+
# Also update __main__ module for backward compatibility
|
|
1617
|
+
try:
|
|
1618
|
+
_bp_module = sys.modules.get('__main__')
|
|
1619
|
+
if _bp_module is not None:
|
|
1620
|
+
setattr(_bp_module, 'show_cif_titles', new_state)
|
|
1621
|
+
except Exception:
|
|
1622
|
+
pass
|
|
1623
|
+
# Avoid re-entrant extension while redrawing
|
|
1624
|
+
prev_ext = bool(getattr(_bp, 'cif_extend_suspended', False)) if _bp is not None else False
|
|
1625
|
+
if _bp is not None:
|
|
1626
|
+
setattr(_bp, 'cif_extend_suspended', True)
|
|
1627
|
+
if hasattr(ax, '_cif_draw_func'):
|
|
1628
|
+
ax._cif_draw_func()
|
|
1629
|
+
if _bp is not None:
|
|
1630
|
+
setattr(_bp, 'cif_extend_suspended', prev_ext)
|
|
1631
|
+
print(f"CIF title labels {'ON' if new_state else 'OFF'}.")
|
|
1632
|
+
# Push state for undo
|
|
1633
|
+
push_state("toggle-cif-titles")
|
|
1634
|
+
except Exception as e:
|
|
1635
|
+
print(f"Error toggling CIF titles: {e}")
|
|
1636
|
+
continue
|
|
1637
|
+
elif key == 'b': # <-- UNDO
|
|
1638
|
+
restore_state()
|
|
1639
|
+
continue
|
|
1640
|
+
elif key == 'n':
|
|
1641
|
+
try:
|
|
1642
|
+
toggle_crosshair()
|
|
1643
|
+
except Exception as e:
|
|
1644
|
+
print(f"Error toggling crosshair: {e}")
|
|
1645
|
+
continue
|
|
1646
|
+
elif key == 's':
|
|
1647
|
+
# Save current interactive session with numbered overwrite picker
|
|
1648
|
+
try:
|
|
1649
|
+
folder = choose_save_path(source_file_paths, purpose="project save")
|
|
1650
|
+
if not folder:
|
|
1651
|
+
print("Save canceled.")
|
|
1652
|
+
continue
|
|
1653
|
+
print(f"\nChosen path: {folder}")
|
|
1654
|
+
files = []
|
|
1655
|
+
try:
|
|
1656
|
+
files = sorted([f for f in os.listdir(folder) if f.lower().endswith('.pkl')])
|
|
1657
|
+
except Exception:
|
|
1658
|
+
files = []
|
|
1659
|
+
if files:
|
|
1660
|
+
print("Existing .pkl files:")
|
|
1661
|
+
for i, f in enumerate(files, 1):
|
|
1662
|
+
filepath = os.path.join(folder, f)
|
|
1663
|
+
timestamp = format_file_timestamp(filepath)
|
|
1664
|
+
if timestamp:
|
|
1665
|
+
print(f" {i}: {f} ({timestamp})")
|
|
1666
|
+
else:
|
|
1667
|
+
print(f" {i}: {f}")
|
|
1668
|
+
last_session_path = getattr(fig, '_last_session_save_path', None)
|
|
1669
|
+
if last_session_path:
|
|
1670
|
+
prompt = "Enter new filename (no ext needed), number to overwrite, or o to overwrite last (q=cancel): "
|
|
1671
|
+
else:
|
|
1672
|
+
prompt = "Enter new filename (no ext needed) or number to overwrite (q=cancel): "
|
|
1673
|
+
choice = _safe_input(prompt).strip()
|
|
1674
|
+
if not choice or choice.lower() == 'q':
|
|
1675
|
+
print("Canceled.")
|
|
1676
|
+
continue
|
|
1677
|
+
if choice.lower() == 'o':
|
|
1678
|
+
# Overwrite last saved session
|
|
1679
|
+
if not last_session_path:
|
|
1680
|
+
print("No previous save found.")
|
|
1681
|
+
continue
|
|
1682
|
+
if not os.path.exists(last_session_path):
|
|
1683
|
+
print(f"Previous save file not found: {last_session_path}")
|
|
1684
|
+
continue
|
|
1685
|
+
yn = _safe_input(f"Overwrite '{os.path.basename(last_session_path)}'? (y/n): ").strip().lower()
|
|
1686
|
+
if yn != 'y':
|
|
1687
|
+
continue
|
|
1688
|
+
_bp_dump_session(
|
|
1689
|
+
last_session_path,
|
|
1690
|
+
fig=fig,
|
|
1691
|
+
ax=ax,
|
|
1692
|
+
x_data_list=x_data_list,
|
|
1693
|
+
y_data_list=y_data_list,
|
|
1694
|
+
orig_y=orig_y,
|
|
1695
|
+
offsets_list=offsets_list,
|
|
1696
|
+
labels=labels,
|
|
1697
|
+
delta=delta,
|
|
1698
|
+
args=args,
|
|
1699
|
+
tick_state=tick_state,
|
|
1700
|
+
cif_tick_series=(getattr(_bp, 'cif_tick_series', None) if _bp is not None else None),
|
|
1701
|
+
cif_hkl_map=(getattr(_bp, 'cif_hkl_map', None) if _bp is not None else None),
|
|
1702
|
+
cif_hkl_label_map=(getattr(_bp, 'cif_hkl_label_map', None) if _bp is not None else None),
|
|
1703
|
+
show_cif_hkl=(bool(getattr(_bp,'show_cif_hkl', False)) if _bp is not None else False),
|
|
1704
|
+
show_cif_titles=(bool(getattr(_bp,'show_cif_titles', True)) if _bp is not None else True),
|
|
1705
|
+
skip_confirm=True,
|
|
1706
|
+
)
|
|
1707
|
+
print(f"Overwritten session to {last_session_path}")
|
|
1708
|
+
continue
|
|
1709
|
+
target_path = None
|
|
1710
|
+
# Overwrite by number
|
|
1711
|
+
if choice.isdigit() and files:
|
|
1712
|
+
idx = int(choice)
|
|
1713
|
+
if 1 <= idx <= len(files):
|
|
1714
|
+
name = files[idx-1]
|
|
1715
|
+
yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
|
|
1716
|
+
if yn != 'y':
|
|
1717
|
+
print("Canceled.")
|
|
1718
|
+
continue
|
|
1719
|
+
target_path = os.path.join(folder, name)
|
|
1720
|
+
skip_confirm = True # Already confirmed above
|
|
1721
|
+
_bp_dump_session(
|
|
1722
|
+
target_path,
|
|
1723
|
+
fig=fig,
|
|
1724
|
+
ax=ax,
|
|
1725
|
+
x_data_list=x_data_list,
|
|
1726
|
+
y_data_list=y_data_list,
|
|
1727
|
+
orig_y=orig_y,
|
|
1728
|
+
offsets_list=offsets_list,
|
|
1729
|
+
labels=labels,
|
|
1730
|
+
delta=delta,
|
|
1731
|
+
args=args,
|
|
1732
|
+
tick_state=tick_state,
|
|
1733
|
+
cif_tick_series=(getattr(_bp, 'cif_tick_series', None) if _bp is not None else None),
|
|
1734
|
+
cif_hkl_map=(getattr(_bp, 'cif_hkl_map', None) if _bp is not None else None),
|
|
1735
|
+
cif_hkl_label_map=(getattr(_bp, 'cif_hkl_label_map', None) if _bp is not None else None),
|
|
1736
|
+
show_cif_hkl=(bool(getattr(_bp,'show_cif_hkl', False)) if _bp is not None else False),
|
|
1737
|
+
show_cif_titles=(bool(getattr(_bp,'show_cif_titles', True)) if _bp is not None else True),
|
|
1738
|
+
skip_confirm=skip_confirm,
|
|
1739
|
+
)
|
|
1740
|
+
print(f"Saved session to {target_path}")
|
|
1741
|
+
fig._last_session_save_path = target_path
|
|
1742
|
+
continue
|
|
1743
|
+
else:
|
|
1744
|
+
print("Invalid number.")
|
|
1745
|
+
continue
|
|
1746
|
+
if choice.lower() != 'o':
|
|
1747
|
+
# New name, allow relative or absolute
|
|
1748
|
+
name = choice
|
|
1749
|
+
root, ext = os.path.splitext(name)
|
|
1750
|
+
if ext == '':
|
|
1751
|
+
name = name + '.pkl'
|
|
1752
|
+
target_path = name if os.path.isabs(name) else os.path.join(folder, name)
|
|
1753
|
+
skip_confirm = False # Let dump_session ask
|
|
1754
|
+
if os.path.exists(target_path):
|
|
1755
|
+
yn = _safe_input(f"'{os.path.basename(target_path)}' exists. Overwrite? (y/n): ").strip().lower()
|
|
1756
|
+
if yn != 'y':
|
|
1757
|
+
print("Canceled.")
|
|
1758
|
+
continue
|
|
1759
|
+
skip_confirm = True # Already confirmed
|
|
1760
|
+
# Delegate to session dumper
|
|
1761
|
+
_bp_dump_session(
|
|
1762
|
+
target_path,
|
|
1763
|
+
fig=fig,
|
|
1764
|
+
ax=ax,
|
|
1765
|
+
x_data_list=x_data_list,
|
|
1766
|
+
y_data_list=y_data_list,
|
|
1767
|
+
orig_y=orig_y,
|
|
1768
|
+
offsets_list=offsets_list,
|
|
1769
|
+
labels=labels,
|
|
1770
|
+
delta=delta,
|
|
1771
|
+
args=args,
|
|
1772
|
+
tick_state=tick_state,
|
|
1773
|
+
cif_tick_series=(getattr(_bp, 'cif_tick_series', None) if _bp is not None else None),
|
|
1774
|
+
cif_hkl_map=(getattr(_bp, 'cif_hkl_map', None) if _bp is not None else None),
|
|
1775
|
+
cif_hkl_label_map=(getattr(_bp, 'cif_hkl_label_map', None) if _bp is not None else None),
|
|
1776
|
+
show_cif_hkl=(bool(getattr(_bp,'show_cif_hkl', False)) if _bp is not None else False),
|
|
1777
|
+
show_cif_titles=(bool(getattr(_bp,'show_cif_titles', True)) if _bp is not None else True),
|
|
1778
|
+
skip_confirm=skip_confirm,
|
|
1779
|
+
)
|
|
1780
|
+
print(f"Saved session to {target_path}")
|
|
1781
|
+
fig._last_session_save_path = target_path
|
|
1782
|
+
except Exception as e:
|
|
1783
|
+
print(f"Error saving session: {e}")
|
|
1784
|
+
continue
|
|
1785
|
+
elif key == 'w': # hidden game remains on 'i'
|
|
1786
|
+
play_jump_game(); continue
|
|
1787
|
+
elif key == 'c':
|
|
1788
|
+
try:
|
|
1789
|
+
has_cif = False
|
|
1790
|
+
try:
|
|
1791
|
+
# Check for CIF files in args.files (handle colon syntax like file.cif:0.25448)
|
|
1792
|
+
has_cif = any(f.split(':')[0].lower().endswith('.cif') for f in args.files)
|
|
1793
|
+
# Also check if CIF tick series exists (more reliable)
|
|
1794
|
+
if not has_cif and _bp is not None:
|
|
1795
|
+
has_cif = bool(getattr(_bp, 'cif_tick_series', None))
|
|
1796
|
+
except Exception:
|
|
1797
|
+
pass
|
|
1798
|
+
while True:
|
|
1799
|
+
print("\033[1mColor menu:\033[0m")
|
|
1800
|
+
print(f" {colorize_menu('m : set curve colors (e.g., 1 red 2:u3 or 1:red 2:#00B006)')}")
|
|
1801
|
+
print(f" {colorize_menu('p : apply colormap palette to a range (e.g., 1-3 viridis)')}")
|
|
1802
|
+
print(f" {colorize_menu('s : spine/tick colors (e.g., w red a u3 or w:red a:#4561F7)')}")
|
|
1803
|
+
if has_cif and (_bp is not None and getattr(_bp, 'cif_tick_series', None)):
|
|
1804
|
+
print(f" {colorize_menu('t : change CIF tick set color (e.g., 1:red 2:#888888)')}")
|
|
1805
|
+
print(f" {colorize_menu('u : manage saved colors (use in m/p via number or u#)')}")
|
|
1806
|
+
print(f" {colorize_menu('q : return to main menu')}")
|
|
1807
|
+
sub = _safe_input(colorize_prompt("Choose (m/p/s/t/u/q): ")).strip().lower()
|
|
1808
|
+
if sub == 'q':
|
|
1809
|
+
break
|
|
1810
|
+
if sub == '':
|
|
1811
|
+
continue
|
|
1812
|
+
if sub == 'm':
|
|
1813
|
+
print("Current curves (q to cancel):")
|
|
1814
|
+
for idx, label in enumerate(labels):
|
|
1815
|
+
try:
|
|
1816
|
+
current_color = ax.lines[idx].get_color()
|
|
1817
|
+
except Exception:
|
|
1818
|
+
current_color = None
|
|
1819
|
+
print(f"{idx+1}: {color_block(current_color)} {label} ({current_color})")
|
|
1820
|
+
user_colors = get_user_color_list(fig)
|
|
1821
|
+
if user_colors:
|
|
1822
|
+
print("\nSaved colors (refer as number or u#):")
|
|
1823
|
+
for idx, color in enumerate(user_colors, 1):
|
|
1824
|
+
print(f" {idx}: {color_block(color)} {color}")
|
|
1825
|
+
color_input = _safe_input("Enter curve+color pairs (e.g., 1 red 2:u3) or q: ").strip()
|
|
1826
|
+
if not color_input or color_input.lower() == 'q':
|
|
1827
|
+
print("Canceled.")
|
|
1828
|
+
else:
|
|
1829
|
+
push_state("color-manual")
|
|
1830
|
+
entries = color_input.split()
|
|
1831
|
+
def _apply_manual_entries(tokens):
|
|
1832
|
+
idx_color_pairs = []
|
|
1833
|
+
i = 0
|
|
1834
|
+
while i < len(tokens):
|
|
1835
|
+
tok = tokens[i]
|
|
1836
|
+
if ':' in tok:
|
|
1837
|
+
idx_str, color = tok.split(':', 1)
|
|
1838
|
+
else:
|
|
1839
|
+
if i + 1 >= len(tokens):
|
|
1840
|
+
print(f"Skip incomplete entry: {tok}")
|
|
1841
|
+
break
|
|
1842
|
+
idx_str = tok
|
|
1843
|
+
color = tokens[i + 1]
|
|
1844
|
+
i += 1
|
|
1845
|
+
idx_color_pairs.append((idx_str, color))
|
|
1846
|
+
i += 1
|
|
1847
|
+
for idx_str, color in idx_color_pairs:
|
|
1848
|
+
try:
|
|
1849
|
+
line_idx = int(idx_str) - 1
|
|
1850
|
+
except ValueError:
|
|
1851
|
+
print(f"Bad index: {idx_str}")
|
|
1852
|
+
continue
|
|
1853
|
+
if not (0 <= line_idx < len(ax.lines)):
|
|
1854
|
+
print(f"Index out of range: {idx_str}")
|
|
1855
|
+
continue
|
|
1856
|
+
resolved = resolve_color_token(color, fig)
|
|
1857
|
+
ax.lines[line_idx].set_color(resolved)
|
|
1858
|
+
_apply_manual_entries(entries)
|
|
1859
|
+
# Update label colors to match new curve colors
|
|
1860
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
1861
|
+
# Manual edits override any palette history
|
|
1862
|
+
try:
|
|
1863
|
+
fig._curve_palette_history = []
|
|
1864
|
+
except Exception:
|
|
1865
|
+
pass
|
|
1866
|
+
fig.canvas.draw()
|
|
1867
|
+
elif sub == 'u':
|
|
1868
|
+
manage_user_colors(fig)
|
|
1869
|
+
continue
|
|
1870
|
+
elif sub == 's':
|
|
1871
|
+
print("Set spine/tick colors (w=top, a=left, s=bottom, d=right).")
|
|
1872
|
+
print(colorize_inline_commands("Example: w red a u3 OR w:red a:#4561F7"))
|
|
1873
|
+
user_colors = get_user_color_list(fig)
|
|
1874
|
+
if user_colors:
|
|
1875
|
+
print("\nSaved colors (enter number or u# in place of a color):")
|
|
1876
|
+
for idx, color in enumerate(user_colors, 1):
|
|
1877
|
+
print(f" {idx}: {color_block(color)} {color}")
|
|
1878
|
+
print("Type 'u' to edit saved colors.")
|
|
1879
|
+
line = _safe_input("Enter mappings (e.g., w red a u3) or q: ").strip()
|
|
1880
|
+
if line.lower() == 'u':
|
|
1881
|
+
manage_user_colors(fig)
|
|
1882
|
+
continue
|
|
1883
|
+
if not line or line.lower() == 'q':
|
|
1884
|
+
print("Canceled.")
|
|
1885
|
+
else:
|
|
1886
|
+
push_state("color-spine")
|
|
1887
|
+
key_to_spine = {'w': 'top', 'a': 'left', 's': 'bottom', 'd': 'right'}
|
|
1888
|
+
tokens = line.split()
|
|
1889
|
+
pairs = []
|
|
1890
|
+
i = 0
|
|
1891
|
+
while i < len(tokens):
|
|
1892
|
+
tok = tokens[i]
|
|
1893
|
+
if ':' in tok:
|
|
1894
|
+
key_part, color = tok.split(':', 1)
|
|
1895
|
+
else:
|
|
1896
|
+
if i + 1 >= len(tokens):
|
|
1897
|
+
print(f"Skip incomplete entry: {tok}")
|
|
1898
|
+
break
|
|
1899
|
+
key_part = tok
|
|
1900
|
+
color = tokens[i + 1]
|
|
1901
|
+
i += 1
|
|
1902
|
+
pairs.append((key_part.lower(), color))
|
|
1903
|
+
i += 1
|
|
1904
|
+
for key_part, color in pairs:
|
|
1905
|
+
key_part = key_part.lower()
|
|
1906
|
+
if key_part not in key_to_spine:
|
|
1907
|
+
print(f"Unknown key: {key_part} (use w/a/s/d)")
|
|
1908
|
+
continue
|
|
1909
|
+
spine_name = key_to_spine[key_part]
|
|
1910
|
+
if spine_name not in ax.spines:
|
|
1911
|
+
print(f"Spine '{spine_name}' not found.")
|
|
1912
|
+
continue
|
|
1913
|
+
try:
|
|
1914
|
+
resolved = resolve_color_token(color, fig)
|
|
1915
|
+
ax.spines[spine_name].set_edgecolor(resolved)
|
|
1916
|
+
if spine_name in ('top', 'bottom'):
|
|
1917
|
+
ax.tick_params(axis='x', which='both', colors=resolved)
|
|
1918
|
+
ax.xaxis.label.set_color(resolved)
|
|
1919
|
+
else:
|
|
1920
|
+
ax.tick_params(axis='y', which='both', colors=resolved)
|
|
1921
|
+
ax.yaxis.label.set_color(resolved)
|
|
1922
|
+
print(f"Set {spine_name} spine to {color_block(resolved)} {resolved}")
|
|
1923
|
+
if spine_name == 'top':
|
|
1924
|
+
position_top_xlabel()
|
|
1925
|
+
elif spine_name == 'right':
|
|
1926
|
+
position_right_ylabel()
|
|
1927
|
+
except Exception as e:
|
|
1928
|
+
print(f"Error setting {spine_name} color: {e}")
|
|
1929
|
+
fig.canvas.draw()
|
|
1930
|
+
elif sub == 't' and has_cif and (_bp is not None and getattr(_bp, 'cif_tick_series', None)):
|
|
1931
|
+
cts = getattr(_bp, 'cif_tick_series', [])
|
|
1932
|
+
print("Current CIF tick sets:")
|
|
1933
|
+
for i,(lab, fname, *_rest) in enumerate(cts):
|
|
1934
|
+
print(f" {i+1}: {lab} ({os.path.basename(fname)})")
|
|
1935
|
+
line = _safe_input("Enter mappings (e.g., 1:red 2:#555555) or q: ").strip()
|
|
1936
|
+
if not line or line.lower()=='q':
|
|
1937
|
+
print("Canceled.")
|
|
1938
|
+
else:
|
|
1939
|
+
mappings = line.split()
|
|
1940
|
+
for token in mappings:
|
|
1941
|
+
if ':' not in token:
|
|
1942
|
+
print(f"Skip malformed token: {token}")
|
|
1943
|
+
continue
|
|
1944
|
+
idx_s, col = token.split(':',1)
|
|
1945
|
+
try:
|
|
1946
|
+
idx_i = int(idx_s)-1
|
|
1947
|
+
if 0 <= idx_i < len(cts):
|
|
1948
|
+
lab,fname,peaksQ,wl,qmax_sim,_c = cts[idx_i]
|
|
1949
|
+
cts[idx_i] = (lab,fname,peaksQ,wl,qmax_sim,col)
|
|
1950
|
+
else:
|
|
1951
|
+
print(f"Index out of range: {idx_s}")
|
|
1952
|
+
except ValueError:
|
|
1953
|
+
print(f"Bad index: {idx_s}")
|
|
1954
|
+
setattr(_bp, 'cif_tick_series', cts)
|
|
1955
|
+
if hasattr(ax,'_cif_draw_func'):
|
|
1956
|
+
ax._cif_draw_func()
|
|
1957
|
+
fig.canvas.draw()
|
|
1958
|
+
elif sub == 'p':
|
|
1959
|
+
# Show current palette if one is applied
|
|
1960
|
+
history = getattr(fig, '_curve_palette_history', [])
|
|
1961
|
+
current_palette = history[-1]['palette'] if history else None
|
|
1962
|
+
if current_palette:
|
|
1963
|
+
print(f"Current palette: {current_palette}")
|
|
1964
|
+
base_palettes = ['viridis', 'cividis', 'plasma', 'inferno', 'magma', 'batlow']
|
|
1965
|
+
extras = []
|
|
1966
|
+
def _palette_available(name: str) -> bool:
|
|
1967
|
+
if name in plt.colormaps():
|
|
1968
|
+
return True
|
|
1969
|
+
lower = name.lower()
|
|
1970
|
+
if lower.startswith('batlow'):
|
|
1971
|
+
return ensure_colormap(name)
|
|
1972
|
+
return False
|
|
1973
|
+
if 'turbo' in plt.colormaps():
|
|
1974
|
+
extras.append('turbo')
|
|
1975
|
+
for extra in ('batlowK', 'batlowW'):
|
|
1976
|
+
if _palette_available(extra):
|
|
1977
|
+
extras.append(extra)
|
|
1978
|
+
palette_options = base_palettes + extras[:3]
|
|
1979
|
+
desc_map = {
|
|
1980
|
+
'viridis': 'Perceptually uniform (blue→yellow)',
|
|
1981
|
+
'cividis': 'Perceptually uniform (blue→olive)',
|
|
1982
|
+
'plasma': 'Perceptually uniform (purple→yellow)',
|
|
1983
|
+
'inferno': 'High-contrast (dark→bright)',
|
|
1984
|
+
'magma': 'Soft dark-to-light purple',
|
|
1985
|
+
'batlow': 'Colorblind-friendly sequential',
|
|
1986
|
+
'turbo': 'Vibrant rainbow (Google Turbo)',
|
|
1987
|
+
'batlowK': 'Dark-to-light variant of batlow',
|
|
1988
|
+
'batlowW': 'Warm variant of batlow',
|
|
1989
|
+
}
|
|
1990
|
+
palette_index = {str(i): name for i, name in enumerate(palette_options, 1)}
|
|
1991
|
+
print("Common perceptually uniform palettes (numbers optional):")
|
|
1992
|
+
for idx, name in enumerate(palette_options, 1):
|
|
1993
|
+
bar = palette_preview(name)
|
|
1994
|
+
desc = desc_map.get(name, '')
|
|
1995
|
+
extra = f" - {desc}" if desc else ''
|
|
1996
|
+
print(f" {idx}. {name}{extra}")
|
|
1997
|
+
if bar:
|
|
1998
|
+
print(f" {bar}")
|
|
1999
|
+
print(colorize_inline_commands("Example: 1-4 viridis or: all magma_r or: 1-3,5 plasma, _r for reverse"))
|
|
2000
|
+
line = _safe_input("Enter range(s) and palette (number or name, e.g., '1-3 2' or 'all 1_r') or q: ").strip()
|
|
2001
|
+
if not line or line.lower() == 'q':
|
|
2002
|
+
print("Canceled.")
|
|
2003
|
+
else:
|
|
2004
|
+
parts = line.split()
|
|
2005
|
+
if len(parts) < 2:
|
|
2006
|
+
print("Need range(s) and palette.")
|
|
2007
|
+
else:
|
|
2008
|
+
palette_name = parts[-1]
|
|
2009
|
+
def _resolve_palette_token(token: str) -> str:
|
|
2010
|
+
suffix = ''
|
|
2011
|
+
base = token
|
|
2012
|
+
if token.lower().endswith('_r'):
|
|
2013
|
+
suffix = '_r'
|
|
2014
|
+
base = token[:-2]
|
|
2015
|
+
if base in palette_index:
|
|
2016
|
+
return palette_index[base] + suffix
|
|
2017
|
+
return token
|
|
2018
|
+
palette_name = _resolve_palette_token(palette_name)
|
|
2019
|
+
range_part = " ".join(parts[:-1]).replace(" ", "")
|
|
2020
|
+
def parse_ranges(spec, total):
|
|
2021
|
+
spec = spec.lower()
|
|
2022
|
+
if spec == 'all':
|
|
2023
|
+
return list(range(total))
|
|
2024
|
+
result = set()
|
|
2025
|
+
tokens = spec.split(',')
|
|
2026
|
+
for tok in tokens:
|
|
2027
|
+
if not tok:
|
|
2028
|
+
continue
|
|
2029
|
+
if '-' in tok:
|
|
2030
|
+
try:
|
|
2031
|
+
a, b = tok.split('-', 1)
|
|
2032
|
+
start = int(a) - 1
|
|
2033
|
+
end = int(b) - 1
|
|
2034
|
+
if start > end:
|
|
2035
|
+
start, end = end, start
|
|
2036
|
+
for i in range(start, end + 1):
|
|
2037
|
+
if 0 <= i < total:
|
|
2038
|
+
result.add(i)
|
|
2039
|
+
except ValueError:
|
|
2040
|
+
print(f"Bad range token: {tok}")
|
|
2041
|
+
else:
|
|
2042
|
+
try:
|
|
2043
|
+
i = int(tok) - 1
|
|
2044
|
+
if 0 <= i < total:
|
|
2045
|
+
result.add(i)
|
|
2046
|
+
else:
|
|
2047
|
+
print(f"Index out of range: {tok}")
|
|
2048
|
+
except ValueError:
|
|
2049
|
+
print(f"Bad index token: {tok}")
|
|
2050
|
+
return sorted(result)
|
|
2051
|
+
indices = parse_ranges(range_part, len(ax.lines))
|
|
2052
|
+
if not indices:
|
|
2053
|
+
print("No valid indices parsed.")
|
|
2054
|
+
else:
|
|
2055
|
+
# ====================================================================
|
|
2056
|
+
# APPLY COLOR PALETTE TO MULTIPLE CURVES
|
|
2057
|
+
# ====================================================================
|
|
2058
|
+
# This section applies a colormap (like 'viridis') to selected curves,
|
|
2059
|
+
# assigning each curve a different color that smoothly transitions
|
|
2060
|
+
# across the colormap.
|
|
2061
|
+
#
|
|
2062
|
+
# HOW IT WORKS:
|
|
2063
|
+
# 1. Get the continuous colormap (e.g., 'viridis')
|
|
2064
|
+
# 2. Sample colors at evenly spaced positions along the colormap
|
|
2065
|
+
# 3. Assign each sampled color to a different curve
|
|
2066
|
+
#
|
|
2067
|
+
# Example with 5 curves and 'viridis':
|
|
2068
|
+
# Curve 1 → position 0.08 → dark purple
|
|
2069
|
+
# Curve 2 → position 0.27 → blue-purple
|
|
2070
|
+
# Curve 3 → position 0.46 → green
|
|
2071
|
+
# Curve 4 → position 0.65 → yellow-green
|
|
2072
|
+
# Curve 5 → position 0.85 → bright yellow
|
|
2073
|
+
#
|
|
2074
|
+
# WHY CLIP THE RANGE (0.08 to 0.85)?
|
|
2075
|
+
# The very start (0.0) and end (1.0) of colormaps are often too dark
|
|
2076
|
+
# or too bright. Clipping to 0.08-0.85 gives better visual contrast
|
|
2077
|
+
# and ensures all colors are visible.
|
|
2078
|
+
# ====================================================================
|
|
2079
|
+
|
|
2080
|
+
# Ensure colormap is registered (makes it available for use)
|
|
2081
|
+
ensure_colormap(palette_name)
|
|
2082
|
+
cmap = None
|
|
2083
|
+
|
|
2084
|
+
# STEP 1: Try to get colormap from matplotlib's built-in colormaps
|
|
2085
|
+
# This handles standard colormaps like 'viridis', 'plasma', 'inferno', etc.
|
|
2086
|
+
try:
|
|
2087
|
+
import matplotlib.cm as cm
|
|
2088
|
+
# Get the continuous colormap (without specifying N)
|
|
2089
|
+
# This allows us to sample directly from the continuous colormap
|
|
2090
|
+
# without quantization issues
|
|
2091
|
+
cmap = cm.get_cmap(palette_name)
|
|
2092
|
+
except (ValueError, Exception):
|
|
2093
|
+
pass
|
|
2094
|
+
|
|
2095
|
+
# STEP 2: Fallback - try cmcrameri package for scientific colormaps
|
|
2096
|
+
# cmcrameri provides colorblind-friendly colormaps like 'batlow', 'batlowk', etc.
|
|
2097
|
+
if cmap is None and palette_name.lower().startswith("batlow"):
|
|
2098
|
+
try:
|
|
2099
|
+
import importlib
|
|
2100
|
+
cmc = importlib.import_module('cmcrameri.cm')
|
|
2101
|
+
attr = palette_name.lower()
|
|
2102
|
+
if hasattr(cmc, attr):
|
|
2103
|
+
cmap = getattr(cmc, attr)
|
|
2104
|
+
elif hasattr(cmc, 'batlow'):
|
|
2105
|
+
cmap = getattr(cmc, 'batlow')
|
|
2106
|
+
except Exception:
|
|
2107
|
+
pass
|
|
2108
|
+
|
|
2109
|
+
# STEP 3: Final fallback - create from custom colormaps defined in color_utils
|
|
2110
|
+
if cmap is None:
|
|
2111
|
+
base_name = palette_name.lower()
|
|
2112
|
+
# Handle reversed colormaps (remove '_r' suffix)
|
|
2113
|
+
if base_name.endswith('_r'):
|
|
2114
|
+
base_name = base_name[:-2]
|
|
2115
|
+
custom_colors = _CUSTOM_CMAPS.get(base_name)
|
|
2116
|
+
if custom_colors:
|
|
2117
|
+
from matplotlib.colors import LinearSegmentedColormap
|
|
2118
|
+
# Create a continuous colormap by interpolating between custom colors
|
|
2119
|
+
# N=256 means create 256 intermediate colors for smooth gradient
|
|
2120
|
+
cmap = LinearSegmentedColormap.from_list(base_name, custom_colors, N=256)
|
|
2121
|
+
# If user requested reversed version, reverse it now
|
|
2122
|
+
if palette_name.lower().endswith('_r'):
|
|
2123
|
+
cmap = cmap.reversed()
|
|
2124
|
+
|
|
2125
|
+
# Check if we successfully got a colormap
|
|
2126
|
+
if cmap is None:
|
|
2127
|
+
print(f"Unknown colormap '{palette_name}'.")
|
|
2128
|
+
else:
|
|
2129
|
+
# Save current state for undo functionality
|
|
2130
|
+
push_state("color-palette")
|
|
2131
|
+
|
|
2132
|
+
# Get number of selected curves
|
|
2133
|
+
nsel = len(indices)
|
|
2134
|
+
|
|
2135
|
+
# Define color sampling range (clipped to avoid too dark/bright extremes)
|
|
2136
|
+
low_clip = 0.08 # Start sampling at 8% into colormap (avoids very dark colors)
|
|
2137
|
+
high_clip = 0.85 # End sampling at 85% into colormap (avoids very bright colors)
|
|
2138
|
+
|
|
2139
|
+
# STEP 4: Sample colors from colormap at evenly spaced positions
|
|
2140
|
+
if nsel == 1:
|
|
2141
|
+
# Single curve: use middle of colormap (good visibility)
|
|
2142
|
+
colors = [cmap(0.55)]
|
|
2143
|
+
elif nsel == 2:
|
|
2144
|
+
# Two curves: use clipped range endpoints (maximum contrast)
|
|
2145
|
+
colors = [cmap(low_clip), cmap(high_clip)]
|
|
2146
|
+
else:
|
|
2147
|
+
# Multiple curves: sample evenly across clipped range
|
|
2148
|
+
# np.linspace creates evenly spaced positions from low_clip to high_clip
|
|
2149
|
+
# Example with 5 curves: [0.08, 0.27, 0.46, 0.65, 0.85]
|
|
2150
|
+
positions = np.linspace(low_clip, high_clip, nsel)
|
|
2151
|
+
# Sample color at each position
|
|
2152
|
+
# cmap(position) returns an RGBA color tuple for that position
|
|
2153
|
+
colors = [cmap(p) for p in positions]
|
|
2154
|
+
|
|
2155
|
+
# STEP 5: Apply colors to the selected curves
|
|
2156
|
+
# Loop through selected curve indices and assign corresponding color
|
|
2157
|
+
for c_idx, line_idx in enumerate(indices):
|
|
2158
|
+
# c_idx = index in colors array (0, 1, 2, ...)
|
|
2159
|
+
# line_idx = index of line in ax.lines (the actual matplotlib line object)
|
|
2160
|
+
ax.lines[line_idx].set_color(colors[c_idx])
|
|
2161
|
+
# Update label colors to match new curve colors
|
|
2162
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2163
|
+
fig.canvas.draw()
|
|
2164
|
+
try:
|
|
2165
|
+
applied_preview = color_bar([mcolors.to_hex(c) for c in colors])
|
|
2166
|
+
except Exception:
|
|
2167
|
+
applied_preview = ""
|
|
2168
|
+
print(f"Applied '{palette_name}' to curves: " +
|
|
2169
|
+
", ".join(str(i+1) for i in indices))
|
|
2170
|
+
if applied_preview:
|
|
2171
|
+
print(f" {applied_preview}")
|
|
2172
|
+
# Record palette usage for style export
|
|
2173
|
+
try:
|
|
2174
|
+
history = list(getattr(fig, '_curve_palette_history', []))
|
|
2175
|
+
except Exception:
|
|
2176
|
+
history = []
|
|
2177
|
+
entry = {
|
|
2178
|
+
'palette': palette_name,
|
|
2179
|
+
'indices': [i + 1 for i in indices],
|
|
2180
|
+
'low_clip': low_clip,
|
|
2181
|
+
'high_clip': high_clip,
|
|
2182
|
+
}
|
|
2183
|
+
history.append(entry)
|
|
2184
|
+
fig._curve_palette_history = history
|
|
2185
|
+
else:
|
|
2186
|
+
print("Unknown color submenu option.")
|
|
2187
|
+
except Exception as e:
|
|
2188
|
+
print(f"Error in color menu: {e}")
|
|
2189
|
+
elif key == 'r':
|
|
2190
|
+
try:
|
|
2191
|
+
has_cif = False
|
|
2192
|
+
try:
|
|
2193
|
+
# Check for CIF files in args.files (handle colon syntax like file.cif:0.25448)
|
|
2194
|
+
has_cif = any(f.split(':')[0].lower().endswith('.cif') for f in args.files)
|
|
2195
|
+
# Also check if CIF tick series exists (more reliable)
|
|
2196
|
+
if not has_cif and _bp is not None:
|
|
2197
|
+
has_cif = bool(getattr(_bp, 'cif_tick_series', None))
|
|
2198
|
+
except Exception:
|
|
2199
|
+
pass
|
|
2200
|
+
while True:
|
|
2201
|
+
rename_opts = "c=curve"
|
|
2202
|
+
if has_cif:
|
|
2203
|
+
rename_opts += ", t=cif tick label"
|
|
2204
|
+
rename_opts += ", x=x-axis, y=y-axis, q=return"
|
|
2205
|
+
mode = _safe_input(f"Rename ({rename_opts}): ").strip().lower()
|
|
2206
|
+
if mode == 'q':
|
|
2207
|
+
break
|
|
2208
|
+
if mode == '':
|
|
2209
|
+
continue
|
|
2210
|
+
if mode == 'c':
|
|
2211
|
+
print("Tip: Use LaTeX/mathtext for special characters:")
|
|
2212
|
+
print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
|
|
2213
|
+
print(" Bullet: $\\bullet$ → • | Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
|
|
2214
|
+
print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
|
|
2215
|
+
idx_in = _safe_input("Curve number to rename (q=cancel): ").strip()
|
|
2216
|
+
if not idx_in or idx_in.lower() == 'q':
|
|
2217
|
+
print("Canceled.")
|
|
2218
|
+
continue
|
|
2219
|
+
try:
|
|
2220
|
+
idx = int(idx_in) - 1
|
|
2221
|
+
except ValueError:
|
|
2222
|
+
print("Invalid index.")
|
|
2223
|
+
continue
|
|
2224
|
+
if not (0 <= idx < len(labels)):
|
|
2225
|
+
print("Invalid index.")
|
|
2226
|
+
continue
|
|
2227
|
+
new_label = _safe_input("New curve label (q=cancel): ")
|
|
2228
|
+
if not new_label or new_label.lower() == 'q':
|
|
2229
|
+
print("Canceled.")
|
|
2230
|
+
continue
|
|
2231
|
+
new_label = convert_label_shortcuts(new_label)
|
|
2232
|
+
push_state("rename-curve")
|
|
2233
|
+
labels[idx] = new_label
|
|
2234
|
+
label_text_objects[idx].set_text(f"{idx+1}: {new_label}")
|
|
2235
|
+
fig.canvas.draw()
|
|
2236
|
+
elif mode == 't':
|
|
2237
|
+
cts = getattr(_bp, 'cif_tick_series', None) if _bp is not None else None
|
|
2238
|
+
if not cts:
|
|
2239
|
+
print("No CIF tick sets to rename.")
|
|
2240
|
+
continue
|
|
2241
|
+
for i,(lab, fname, *_rest) in enumerate(cts):
|
|
2242
|
+
print(f" {i+1}: {lab} ({os.path.basename(fname)})")
|
|
2243
|
+
s = _safe_input("CIF tick number to rename (q=cancel): ").strip()
|
|
2244
|
+
if not s or s.lower()=='q':
|
|
2245
|
+
print("Canceled."); continue
|
|
2246
|
+
try:
|
|
2247
|
+
idx = int(s)-1
|
|
2248
|
+
if not (0 <= idx < len(cts)):
|
|
2249
|
+
print("Index out of range."); continue
|
|
2250
|
+
except ValueError:
|
|
2251
|
+
print("Bad index."); continue
|
|
2252
|
+
print("Tip: Use LaTeX/mathtext for special characters:")
|
|
2253
|
+
print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
|
|
2254
|
+
print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
|
|
2255
|
+
print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
|
|
2256
|
+
new_name = _safe_input("New CIF tick label (q=cancel): ")
|
|
2257
|
+
if not new_name or new_name.lower()=='q':
|
|
2258
|
+
print("Canceled."); continue
|
|
2259
|
+
new_name = convert_label_shortcuts(new_name)
|
|
2260
|
+
lab,fname,peaksQ,wl,qmax_sim,color = cts[idx]
|
|
2261
|
+
# Suspend extension while updating label
|
|
2262
|
+
if _bp is not None:
|
|
2263
|
+
setattr(_bp, 'cif_extend_suspended', True)
|
|
2264
|
+
if hasattr(ax, '_cif_tick_art'):
|
|
2265
|
+
try:
|
|
2266
|
+
for art in list(getattr(ax, '_cif_tick_art', [])):
|
|
2267
|
+
try:
|
|
2268
|
+
art.remove()
|
|
2269
|
+
except Exception:
|
|
2270
|
+
pass
|
|
2271
|
+
ax._cif_tick_art = []
|
|
2272
|
+
except Exception:
|
|
2273
|
+
pass
|
|
2274
|
+
cts[idx] = (new_name, fname, peaksQ, wl, qmax_sim, color)
|
|
2275
|
+
setattr(_bp, 'cif_tick_series', cts)
|
|
2276
|
+
if hasattr(ax,'_cif_draw_func'): ax._cif_draw_func()
|
|
2277
|
+
fig.canvas.draw()
|
|
2278
|
+
if _bp is not None:
|
|
2279
|
+
setattr(_bp, 'cif_extend_suspended', False)
|
|
2280
|
+
elif mode in ('x','y'):
|
|
2281
|
+
print("Enter new axis label (q=cancel).")
|
|
2282
|
+
print("Tip: Use LaTeX/mathtext for special characters:")
|
|
2283
|
+
print(" Subscript: H$_2$O → H₂O | Superscript: m$^2$ → m²")
|
|
2284
|
+
print(" Greek: $\\alpha$, $\\beta$ | Angstrom: $\\AA$ → Å")
|
|
2285
|
+
print(" Shortcuts: g{super(-1)} → g$^{\\mathrm{-1}}$ | Li{sub(2)}O → Li$_{\\mathrm{2}}$O")
|
|
2286
|
+
new_axis = _safe_input("New axis label: ")
|
|
2287
|
+
if not new_axis or new_axis.lower() == 'q':
|
|
2288
|
+
print("Canceled.")
|
|
2289
|
+
continue
|
|
2290
|
+
new_axis = convert_label_shortcuts(new_axis)
|
|
2291
|
+
new_axis = normalize_label_text(new_axis)
|
|
2292
|
+
push_state("rename-axis")
|
|
2293
|
+
# Freeze layout and preserve current pad via one-shot pending to avoid drift
|
|
2294
|
+
try:
|
|
2295
|
+
fig.set_layout_engine('none')
|
|
2296
|
+
except Exception:
|
|
2297
|
+
try:
|
|
2298
|
+
fig.set_tight_layout(False)
|
|
2299
|
+
except Exception:
|
|
2300
|
+
pass
|
|
2301
|
+
try:
|
|
2302
|
+
fig.set_constrained_layout(False)
|
|
2303
|
+
except Exception:
|
|
2304
|
+
pass
|
|
2305
|
+
if mode == 'x':
|
|
2306
|
+
# Preserve current pad exactly once after rename
|
|
2307
|
+
try:
|
|
2308
|
+
ax._pending_xlabelpad = getattr(ax.xaxis, 'labelpad', None)
|
|
2309
|
+
except Exception:
|
|
2310
|
+
pass
|
|
2311
|
+
ax.xaxis.label.set_text(new_axis)
|
|
2312
|
+
position_top_xlabel()
|
|
2313
|
+
position_bottom_xlabel()
|
|
2314
|
+
else:
|
|
2315
|
+
try:
|
|
2316
|
+
ax._pending_ylabelpad = getattr(ax.yaxis, 'labelpad', None)
|
|
2317
|
+
except Exception:
|
|
2318
|
+
pass
|
|
2319
|
+
ax.yaxis.label.set_text(new_axis)
|
|
2320
|
+
position_right_ylabel()
|
|
2321
|
+
position_left_ylabel()
|
|
2322
|
+
sync_fonts()
|
|
2323
|
+
fig.canvas.draw()
|
|
2324
|
+
else:
|
|
2325
|
+
print("Invalid choice.")
|
|
2326
|
+
# loop continues until q
|
|
2327
|
+
except Exception as e:
|
|
2328
|
+
print(f"Error: {e}")
|
|
2329
|
+
elif key == 'a':
|
|
2330
|
+
try:
|
|
2331
|
+
if not args.stack:
|
|
2332
|
+
print('Be careful, changing the arrangement may lead to a mess! If you want to rearrange the curves, use "--stack".')
|
|
2333
|
+
print("Current curve order:")
|
|
2334
|
+
for idx, label in enumerate(labels):
|
|
2335
|
+
print(f"{idx+1}: {label}")
|
|
2336
|
+
new_order_str = _safe_input("Enter new order (space-separated indices, q=cancel): ").strip()
|
|
2337
|
+
if not new_order_str or new_order_str.lower() == 'q':
|
|
2338
|
+
print("Canceled.")
|
|
2339
|
+
continue
|
|
2340
|
+
new_order = [int(i)-1 for i in new_order_str.strip().split()]
|
|
2341
|
+
if len(new_order) != len(labels):
|
|
2342
|
+
print("Error: Number of indices does not match number of curves.")
|
|
2343
|
+
continue
|
|
2344
|
+
if any(i < 0 or i >= len(labels) for i in new_order):
|
|
2345
|
+
print("Error: Invalid index in order list.")
|
|
2346
|
+
continue
|
|
2347
|
+
|
|
2348
|
+
push_state("rearrange")
|
|
2349
|
+
|
|
2350
|
+
original_styles = []
|
|
2351
|
+
for ln in ax.lines:
|
|
2352
|
+
original_styles.append({
|
|
2353
|
+
"color": ln.get_color(),
|
|
2354
|
+
"linewidth": ln.get_linewidth(),
|
|
2355
|
+
"linestyle": ln.get_linestyle(),
|
|
2356
|
+
"alpha": ln.get_alpha(),
|
|
2357
|
+
"marker": ln.get_marker(),
|
|
2358
|
+
"markersize": ln.get_markersize(),
|
|
2359
|
+
"markerfacecolor": ln.get_markerfacecolor(),
|
|
2360
|
+
"markeredgecolor": ln.get_markeredgecolor()
|
|
2361
|
+
})
|
|
2362
|
+
reordered_styles = [original_styles[i] for i in new_order]
|
|
2363
|
+
xlim_current = ax.get_xlim()
|
|
2364
|
+
|
|
2365
|
+
x_data_list[:] = [x_data_list[i] for i in new_order]
|
|
2366
|
+
orig_y[:] = [orig_y[i] for i in new_order]
|
|
2367
|
+
y_data_list[:] = [y_data_list[i] for i in new_order]
|
|
2368
|
+
labels[:] = [labels[i] for i in new_order]
|
|
2369
|
+
label_text_objects[:] = [label_text_objects[i] for i in new_order]
|
|
2370
|
+
x_full_list[:] = [x_full_list[i] for i in new_order]
|
|
2371
|
+
raw_y_full_list[:] = [raw_y_full_list[i] for i in new_order]
|
|
2372
|
+
offsets_list[:] = [offsets_list[i] for i in new_order]
|
|
2373
|
+
|
|
2374
|
+
if args.stack:
|
|
2375
|
+
offset_local = 0.0
|
|
2376
|
+
for i, (x_plot, y_norm, style) in enumerate(zip(x_data_list, orig_y, reordered_styles)):
|
|
2377
|
+
y_plot_offset = y_norm + offset_local
|
|
2378
|
+
y_data_list[i] = y_plot_offset
|
|
2379
|
+
offsets_list[i] = offset_local
|
|
2380
|
+
ln = ax.lines[i]
|
|
2381
|
+
ln.set_data(x_plot, y_plot_offset)
|
|
2382
|
+
ln.set_color(style["color"])
|
|
2383
|
+
ln.set_linewidth(style["linewidth"])
|
|
2384
|
+
ln.set_linestyle(style["linestyle"])
|
|
2385
|
+
ln.set_alpha(style["alpha"])
|
|
2386
|
+
ln.set_marker(style["marker"])
|
|
2387
|
+
ln.set_markersize(style["markersize"])
|
|
2388
|
+
ln.set_markerfacecolor(style["markerfacecolor"])
|
|
2389
|
+
ln.set_markeredgecolor(style["markeredgecolor"])
|
|
2390
|
+
y_range = (y_norm.max() - y_norm.min()) if y_norm.size else 0.0
|
|
2391
|
+
gap = y_range + (delta * (y_range if args.autoscale else 1.0))
|
|
2392
|
+
offset_local -= gap
|
|
2393
|
+
else:
|
|
2394
|
+
offset_local = 0.0
|
|
2395
|
+
for i, (x_plot, y_norm, style) in enumerate(zip(x_data_list, orig_y, reordered_styles)):
|
|
2396
|
+
y_plot_offset = y_norm + offset_local
|
|
2397
|
+
y_data_list[i] = y_plot_offset
|
|
2398
|
+
offsets_list[i] = offset_local
|
|
2399
|
+
ln = ax.lines[i]
|
|
2400
|
+
ln.set_data(x_plot, y_plot_offset)
|
|
2401
|
+
ln.set_color(style["color"])
|
|
2402
|
+
ln.set_linewidth(style["linewidth"])
|
|
2403
|
+
ln.set_linestyle(style["linestyle"])
|
|
2404
|
+
ln.set_alpha(style["alpha"])
|
|
2405
|
+
ln.set_marker(style["marker"])
|
|
2406
|
+
ln.set_markersize(style["markersize"])
|
|
2407
|
+
ln.set_markerfacecolor(style["markerfacecolor"])
|
|
2408
|
+
ln.set_markeredgecolor(style["markeredgecolor"])
|
|
2409
|
+
increment = (y_norm.max() - y_norm.min()) * delta if (args.autoscale and y_norm.size) else delta
|
|
2410
|
+
offset_local += increment
|
|
2411
|
+
|
|
2412
|
+
for i, (txt, lab) in enumerate(zip(label_text_objects, labels)):
|
|
2413
|
+
txt.set_text(f"{i+1}: {lab}")
|
|
2414
|
+
# Preserve current axis titles (respect 't' menu toggles like bt/lt)
|
|
2415
|
+
ax.set_xlim(xlim_current)
|
|
2416
|
+
# Do not reset xlabel/ylabel here; rearrange should not change title visibility
|
|
2417
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2418
|
+
fig.canvas.draw()
|
|
2419
|
+
except Exception as e:
|
|
2420
|
+
print(f"Error rearranging curves: {e}")
|
|
2421
|
+
elif key == 'x':
|
|
2422
|
+
while True:
|
|
2423
|
+
try:
|
|
2424
|
+
current_xlim = ax.get_xlim()
|
|
2425
|
+
print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
|
|
2426
|
+
rng = _safe_input("Enter new X range (min max), w=upper only, s=lower only, 'full', or 'a'=auto (restore original) (q=back): ").strip()
|
|
2427
|
+
if not rng or rng.lower() == 'q':
|
|
2428
|
+
break
|
|
2429
|
+
if rng.lower() == 'w':
|
|
2430
|
+
# Upper only: change upper limit, fix lower - stay in loop
|
|
2431
|
+
while True:
|
|
2432
|
+
current_xlim = ax.get_xlim()
|
|
2433
|
+
print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
|
|
2434
|
+
val = _safe_input(f"Enter new upper X limit (current lower: {current_xlim[0]:.6g}, q=back): ").strip()
|
|
2435
|
+
if not val or val.lower() == 'q':
|
|
2436
|
+
break
|
|
2437
|
+
try:
|
|
2438
|
+
new_upper = float(val)
|
|
2439
|
+
except (ValueError, KeyboardInterrupt):
|
|
2440
|
+
print("Invalid value, ignored.")
|
|
2441
|
+
continue
|
|
2442
|
+
push_state("xrange")
|
|
2443
|
+
ax.set_xlim(current_xlim[0], new_upper)
|
|
2444
|
+
ax.relim()
|
|
2445
|
+
ax.autoscale_view(scalex=False, scaley=True)
|
|
2446
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2447
|
+
try:
|
|
2448
|
+
if hasattr(ax, '_cif_extend_func'):
|
|
2449
|
+
ax._cif_extend_func(ax.get_xlim()[1])
|
|
2450
|
+
except Exception:
|
|
2451
|
+
pass
|
|
2452
|
+
try:
|
|
2453
|
+
if hasattr(ax, '_cif_draw_func'):
|
|
2454
|
+
ax._cif_draw_func()
|
|
2455
|
+
except Exception:
|
|
2456
|
+
pass
|
|
2457
|
+
fig.canvas.draw()
|
|
2458
|
+
print(f"X range updated: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
|
|
2459
|
+
continue
|
|
2460
|
+
if rng.lower() == 's':
|
|
2461
|
+
# Lower only: change lower limit, fix upper - stay in loop
|
|
2462
|
+
while True:
|
|
2463
|
+
current_xlim = ax.get_xlim()
|
|
2464
|
+
print(f"Current X range: {current_xlim[0]:.6g} to {current_xlim[1]:.6g}")
|
|
2465
|
+
val = _safe_input(f"Enter new lower X limit (current upper: {current_xlim[1]:.6g}, q=back): ").strip()
|
|
2466
|
+
if not val or val.lower() == 'q':
|
|
2467
|
+
break
|
|
2468
|
+
try:
|
|
2469
|
+
new_lower = float(val)
|
|
2470
|
+
except (ValueError, KeyboardInterrupt):
|
|
2471
|
+
print("Invalid value, ignored.")
|
|
2472
|
+
continue
|
|
2473
|
+
push_state("xrange")
|
|
2474
|
+
ax.set_xlim(new_lower, current_xlim[1])
|
|
2475
|
+
ax.relim()
|
|
2476
|
+
ax.autoscale_view(scalex=False, scaley=True)
|
|
2477
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2478
|
+
try:
|
|
2479
|
+
if hasattr(ax, '_cif_extend_func'):
|
|
2480
|
+
ax._cif_extend_func(ax.get_xlim()[1])
|
|
2481
|
+
except Exception:
|
|
2482
|
+
pass
|
|
2483
|
+
try:
|
|
2484
|
+
if hasattr(ax, '_cif_draw_func'):
|
|
2485
|
+
ax._cif_draw_func()
|
|
2486
|
+
except Exception:
|
|
2487
|
+
pass
|
|
2488
|
+
fig.canvas.draw()
|
|
2489
|
+
print(f"X range updated: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
|
|
2490
|
+
continue
|
|
2491
|
+
if rng.lower() == 'a':
|
|
2492
|
+
# Auto: restore original range from x_full_list
|
|
2493
|
+
push_state("xrange-auto")
|
|
2494
|
+
if x_full_list:
|
|
2495
|
+
new_min = min(xf.min() for xf in x_full_list if xf.size)
|
|
2496
|
+
new_max = max(xf.max() for xf in x_full_list if xf.size)
|
|
2497
|
+
else:
|
|
2498
|
+
print("No original data available.")
|
|
2499
|
+
continue
|
|
2500
|
+
# Restore all data
|
|
2501
|
+
for i in range(len(labels)):
|
|
2502
|
+
xf = x_full_list[i]; yf_raw = raw_y_full_list[i]
|
|
2503
|
+
mask = (xf>=new_min) & (xf<=new_max)
|
|
2504
|
+
x_sub = xf[mask]; y_sub_raw = yf_raw[mask]
|
|
2505
|
+
if x_sub.size == 0:
|
|
2506
|
+
ax.lines[i].set_data([], [])
|
|
2507
|
+
y_data_list[i] = np.array([]); orig_y[i] = np.array([]); continue
|
|
2508
|
+
should_normalize = args.stack or getattr(args, 'norm', False)
|
|
2509
|
+
if should_normalize:
|
|
2510
|
+
if y_sub_raw.size:
|
|
2511
|
+
y_min = float(y_sub_raw.min())
|
|
2512
|
+
y_max = float(y_sub_raw.max())
|
|
2513
|
+
span = y_max - y_min
|
|
2514
|
+
if span > 0:
|
|
2515
|
+
y_sub_norm = (y_sub_raw - y_min) / span
|
|
2516
|
+
else:
|
|
2517
|
+
y_sub_norm = np.zeros_like(y_sub_raw)
|
|
2518
|
+
else:
|
|
2519
|
+
y_sub_norm = y_sub_raw
|
|
2520
|
+
else:
|
|
2521
|
+
y_sub_norm = y_sub_raw
|
|
2522
|
+
offset_val = offsets_list[i]
|
|
2523
|
+
y_with_offset = y_sub_norm + offset_val
|
|
2524
|
+
ax.lines[i].set_data(x_sub, y_with_offset)
|
|
2525
|
+
x_data_list[i] = x_sub
|
|
2526
|
+
y_data_list[i] = y_with_offset
|
|
2527
|
+
orig_y[i] = y_sub_norm
|
|
2528
|
+
ax.set_xlim(new_min, new_max)
|
|
2529
|
+
ax.relim(); ax.autoscale_view(scalex=False, scaley=True)
|
|
2530
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2531
|
+
try:
|
|
2532
|
+
if hasattr(ax, '_cif_extend_func'):
|
|
2533
|
+
ax._cif_extend_func(ax.get_xlim()[1])
|
|
2534
|
+
except Exception:
|
|
2535
|
+
pass
|
|
2536
|
+
try:
|
|
2537
|
+
if hasattr(ax, '_cif_draw_func'):
|
|
2538
|
+
ax._cif_draw_func()
|
|
2539
|
+
except Exception:
|
|
2540
|
+
pass
|
|
2541
|
+
fig.canvas.draw()
|
|
2542
|
+
print(f"X range restored to original: {ax.get_xlim()[0]:.6g} to {ax.get_xlim()[1]:.6g}")
|
|
2543
|
+
continue
|
|
2544
|
+
push_state("xrange")
|
|
2545
|
+
if rng.lower() == 'full':
|
|
2546
|
+
new_min = min(xf.min() for xf in x_full_list if xf.size)
|
|
2547
|
+
new_max = max(xf.max() for xf in x_full_list if xf.size)
|
|
2548
|
+
else:
|
|
2549
|
+
new_min, new_max = map(float, rng.split())
|
|
2550
|
+
ax.set_xlim(new_min, new_max)
|
|
2551
|
+
for i in range(len(labels)):
|
|
2552
|
+
xf = x_full_list[i]; yf_raw = raw_y_full_list[i]
|
|
2553
|
+
mask = (xf>=new_min) & (xf<=new_max)
|
|
2554
|
+
x_sub = xf[mask]; y_sub_raw = yf_raw[mask]
|
|
2555
|
+
if x_sub.size == 0:
|
|
2556
|
+
ax.lines[i].set_data([], [])
|
|
2557
|
+
y_data_list[i] = np.array([]); orig_y[i] = np.array([]); continue
|
|
2558
|
+
# Auto-normalize for --stack mode, or explicit --norm flag
|
|
2559
|
+
should_normalize = args.stack or getattr(args, 'norm', False)
|
|
2560
|
+
if should_normalize:
|
|
2561
|
+
if y_sub_raw.size:
|
|
2562
|
+
y_min = float(y_sub_raw.min())
|
|
2563
|
+
y_max = float(y_sub_raw.max())
|
|
2564
|
+
span = y_max - y_min
|
|
2565
|
+
if span > 0:
|
|
2566
|
+
y_sub_norm = (y_sub_raw - y_min) / span
|
|
2567
|
+
else:
|
|
2568
|
+
y_sub_norm = np.zeros_like(y_sub_raw)
|
|
2569
|
+
else:
|
|
2570
|
+
y_sub_norm = y_sub_raw
|
|
2571
|
+
else:
|
|
2572
|
+
y_sub_norm = y_sub_raw
|
|
2573
|
+
offset_val = offsets_list[i]
|
|
2574
|
+
y_with_offset = y_sub_norm + offset_val
|
|
2575
|
+
ax.lines[i].set_data(x_sub, y_with_offset)
|
|
2576
|
+
x_data_list[i] = x_sub
|
|
2577
|
+
y_data_list[i] = y_with_offset
|
|
2578
|
+
orig_y[i] = y_sub_norm
|
|
2579
|
+
ax.relim(); ax.autoscale_view(scalex=False, scaley=True)
|
|
2580
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2581
|
+
# Extend CIF ticks after x-range change
|
|
2582
|
+
try:
|
|
2583
|
+
if hasattr(ax, '_cif_extend_func'):
|
|
2584
|
+
ax._cif_extend_func(ax.get_xlim()[1])
|
|
2585
|
+
except Exception:
|
|
2586
|
+
pass
|
|
2587
|
+
try:
|
|
2588
|
+
if hasattr(ax, '_cif_draw_func'):
|
|
2589
|
+
ax._cif_draw_func()
|
|
2590
|
+
except Exception:
|
|
2591
|
+
pass
|
|
2592
|
+
fig.canvas.draw()
|
|
2593
|
+
except Exception as e:
|
|
2594
|
+
print(f"Error setting X-axis range: {e}")
|
|
2595
|
+
elif key == 'y': # <-- Y-RANGE HANDLER (now only reachable if not args.stack)
|
|
2596
|
+
while True:
|
|
2597
|
+
try:
|
|
2598
|
+
current_ylim = ax.get_ylim()
|
|
2599
|
+
print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
|
|
2600
|
+
rng = _safe_input("Enter new Y range (min max), w=upper only, s=lower only, 'auto', 'a'=auto (restore original), or 'full' (q=back): ").strip().lower()
|
|
2601
|
+
if not rng or rng == 'q':
|
|
2602
|
+
break
|
|
2603
|
+
if rng == 'w':
|
|
2604
|
+
# Upper only: change upper limit, fix lower - stay in loop
|
|
2605
|
+
while True:
|
|
2606
|
+
current_ylim = ax.get_ylim()
|
|
2607
|
+
print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
|
|
2608
|
+
val = _safe_input(f"Enter new upper Y limit (current lower: {current_ylim[0]:.6g}, q=back): ").strip()
|
|
2609
|
+
if not val or val.lower() == 'q':
|
|
2610
|
+
break
|
|
2611
|
+
try:
|
|
2612
|
+
new_upper = float(val)
|
|
2613
|
+
except (ValueError, KeyboardInterrupt):
|
|
2614
|
+
print("Invalid value, ignored.")
|
|
2615
|
+
continue
|
|
2616
|
+
push_state("yrange")
|
|
2617
|
+
ax.set_ylim(current_ylim[0], new_upper)
|
|
2618
|
+
ax.relim()
|
|
2619
|
+
ax.autoscale_view(scalex=False, scaley=True)
|
|
2620
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2621
|
+
fig.canvas.draw_idle()
|
|
2622
|
+
print(f"Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
|
|
2623
|
+
if rng == 'w':
|
|
2624
|
+
continue
|
|
2625
|
+
if rng == 's':
|
|
2626
|
+
# Lower only: change lower limit, fix upper - stay in loop
|
|
2627
|
+
while True:
|
|
2628
|
+
current_ylim = ax.get_ylim()
|
|
2629
|
+
print(f"Current Y range: {current_ylim[0]:.6g} to {current_ylim[1]:.6g}")
|
|
2630
|
+
val = _safe_input(f"Enter new lower Y limit (current upper: {current_ylim[1]:.6g}, q=back): ").strip()
|
|
2631
|
+
if not val or val.lower() == 'q':
|
|
2632
|
+
break
|
|
2633
|
+
try:
|
|
2634
|
+
new_lower = float(val)
|
|
2635
|
+
except (ValueError, KeyboardInterrupt):
|
|
2636
|
+
print("Invalid value, ignored.")
|
|
2637
|
+
continue
|
|
2638
|
+
push_state("yrange")
|
|
2639
|
+
ax.set_ylim(new_lower, current_ylim[1])
|
|
2640
|
+
ax.relim()
|
|
2641
|
+
ax.autoscale_view(scalex=False, scaley=True)
|
|
2642
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2643
|
+
fig.canvas.draw_idle()
|
|
2644
|
+
print(f"Y range updated: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
|
|
2645
|
+
if rng == 's':
|
|
2646
|
+
continue
|
|
2647
|
+
if rng == 'a':
|
|
2648
|
+
# Auto: restore original range from y_data_list
|
|
2649
|
+
push_state("yrange-auto")
|
|
2650
|
+
if y_data_list:
|
|
2651
|
+
all_min = None
|
|
2652
|
+
all_max = None
|
|
2653
|
+
for arr in y_data_list:
|
|
2654
|
+
if arr.size:
|
|
2655
|
+
mn = float(arr.min())
|
|
2656
|
+
mx = float(arr.max())
|
|
2657
|
+
all_min = mn if all_min is None else min(all_min, mn)
|
|
2658
|
+
all_max = mx if all_max is None else max(all_max, mx)
|
|
2659
|
+
if all_min is None or all_max is None:
|
|
2660
|
+
print("No original data available.")
|
|
2661
|
+
continue
|
|
2662
|
+
ax.set_ylim(all_min, all_max)
|
|
2663
|
+
ax.relim()
|
|
2664
|
+
ax.autoscale_view(scalex=False, scaley=True)
|
|
2665
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2666
|
+
fig.canvas.draw_idle()
|
|
2667
|
+
print(f"Y range restored to original: {ax.get_ylim()[0]:.6g} to {ax.get_ylim()[1]:.6g}")
|
|
2668
|
+
else:
|
|
2669
|
+
print("No original data available.")
|
|
2670
|
+
continue
|
|
2671
|
+
push_state("yrange")
|
|
2672
|
+
if rng == 'auto':
|
|
2673
|
+
ax.relim()
|
|
2674
|
+
ax.autoscale_view(scalex=False, scaley=True)
|
|
2675
|
+
else:
|
|
2676
|
+
if rng == 'full':
|
|
2677
|
+
all_min = None
|
|
2678
|
+
all_max = None
|
|
2679
|
+
for arr in y_data_list:
|
|
2680
|
+
if arr.size:
|
|
2681
|
+
mn = float(arr.min())
|
|
2682
|
+
mx = float(arr.max())
|
|
2683
|
+
all_min = mn if all_min is None else min(all_min, mn)
|
|
2684
|
+
all_max = mx if all_max is None else max(all_max, mx)
|
|
2685
|
+
if all_min is None or all_max is None:
|
|
2686
|
+
print("No data to compute full Y range.")
|
|
2687
|
+
continue
|
|
2688
|
+
y_min, y_max = all_min, all_max
|
|
2689
|
+
else:
|
|
2690
|
+
parts = rng.split()
|
|
2691
|
+
if len(parts) != 2:
|
|
2692
|
+
print("Need exactly two numbers for Y range.")
|
|
2693
|
+
continue
|
|
2694
|
+
y_min, y_max = map(float, parts)
|
|
2695
|
+
if y_min == y_max:
|
|
2696
|
+
print("Warning: min == max; expanding slightly.")
|
|
2697
|
+
eps = abs(y_min)*1e-6 if y_min != 0 else 1e-6
|
|
2698
|
+
y_min -= eps
|
|
2699
|
+
y_max += eps
|
|
2700
|
+
ax.set_ylim(y_min, y_max)
|
|
2701
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2702
|
+
fig.canvas.draw_idle()
|
|
2703
|
+
print(f"Y range set to {ax.get_ylim()}")
|
|
2704
|
+
except Exception as e:
|
|
2705
|
+
print(f"Error setting Y-axis range: {e}")
|
|
2706
|
+
elif key == 'd': # <-- DELTA / OFFSET HANDLER (now only reachable if not args.stack)
|
|
2707
|
+
print("\n\033[1mOffset adjustment menu:\033[0m")
|
|
2708
|
+
print(f" {colorize_menu('1-{}: adjust individual curve offset'.format(len(labels)))}")
|
|
2709
|
+
print(f" {colorize_menu('a: set spacing between curves')}")
|
|
2710
|
+
print(f" {colorize_menu('r: reset all offsets to 0')}")
|
|
2711
|
+
print(f" {colorize_menu('d: change delta spacing (original behavior)')}")
|
|
2712
|
+
print(f" {colorize_menu('q: back to main menu')}")
|
|
2713
|
+
|
|
2714
|
+
while True:
|
|
2715
|
+
offset_cmd = _safe_input("Offset> ").strip().lower()
|
|
2716
|
+
|
|
2717
|
+
if offset_cmd == 'q' or offset_cmd == '':
|
|
2718
|
+
break
|
|
2719
|
+
|
|
2720
|
+
elif offset_cmd == 'r':
|
|
2721
|
+
# Reset all offsets to 0
|
|
2722
|
+
try:
|
|
2723
|
+
push_state("reset-offsets")
|
|
2724
|
+
for i in range(len(labels)):
|
|
2725
|
+
if i >= len(ax.lines):
|
|
2726
|
+
continue
|
|
2727
|
+
# Get current x-data from the line
|
|
2728
|
+
current_x = np.asarray(ax.lines[i].get_xdata(), dtype=float)
|
|
2729
|
+
# Reset to normalized data without any offset
|
|
2730
|
+
y_norm = orig_y[i]
|
|
2731
|
+
y_data_list[i] = y_norm.copy()
|
|
2732
|
+
offsets_list[i] = 0.0
|
|
2733
|
+
# Update x_data_list to match current line data
|
|
2734
|
+
x_data_list[i] = current_x.copy()
|
|
2735
|
+
ax.lines[i].set_data(current_x, y_norm)
|
|
2736
|
+
|
|
2737
|
+
ax.relim()
|
|
2738
|
+
ax.autoscale_view(scalex=False, scaley=True)
|
|
2739
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2740
|
+
fig.canvas.draw()
|
|
2741
|
+
print("All offsets reset to 0")
|
|
2742
|
+
except Exception as e:
|
|
2743
|
+
print(f"Error resetting offsets: {e}")
|
|
2744
|
+
|
|
2745
|
+
elif offset_cmd == 'a':
|
|
2746
|
+
# Set spacing between curves (separates all curves)
|
|
2747
|
+
try:
|
|
2748
|
+
if len(labels) <= 1:
|
|
2749
|
+
print("Warning: Only one curve loaded; spacing cannot be applied.")
|
|
2750
|
+
continue
|
|
2751
|
+
|
|
2752
|
+
# Calculate current spacing (average difference between consecutive offsets)
|
|
2753
|
+
current_spacing = 0.0
|
|
2754
|
+
if len(offsets_list) > 1:
|
|
2755
|
+
spacing_diffs = []
|
|
2756
|
+
sorted_indices = sorted(range(len(offsets_list)), key=lambda i: offsets_list[i] if i < len(offsets_list) else 0.0)
|
|
2757
|
+
for j in range(len(sorted_indices) - 1):
|
|
2758
|
+
idx1, idx2 = sorted_indices[j], sorted_indices[j + 1]
|
|
2759
|
+
off1 = offsets_list[idx1] if idx1 < len(offsets_list) else 0.0
|
|
2760
|
+
off2 = offsets_list[idx2] if idx2 < len(offsets_list) else 0.0
|
|
2761
|
+
spacing_diffs.append(abs(off2 - off1))
|
|
2762
|
+
if spacing_diffs:
|
|
2763
|
+
current_spacing = sum(spacing_diffs) / len(spacing_diffs)
|
|
2764
|
+
|
|
2765
|
+
spacing_input = _safe_input("Enter spacing value between curves (current avg: {:.4g}): ".format(current_spacing)).strip()
|
|
2766
|
+
if not spacing_input:
|
|
2767
|
+
print("Canceled.")
|
|
2768
|
+
continue
|
|
2769
|
+
|
|
2770
|
+
spacing_value = float(spacing_input)
|
|
2771
|
+
push_state("curve-spacing")
|
|
2772
|
+
|
|
2773
|
+
# Apply spacing to separate all curves
|
|
2774
|
+
# Find the minimum current offset to use as baseline
|
|
2775
|
+
min_offset = min(offsets_list) if offsets_list else 0.0
|
|
2776
|
+
|
|
2777
|
+
# Sort curves by their current offset to maintain order
|
|
2778
|
+
curve_order = sorted(range(len(labels)), key=lambda i: offsets_list[i] if i < len(offsets_list) else 0.0)
|
|
2779
|
+
|
|
2780
|
+
# Apply cumulative spacing starting from the minimum offset
|
|
2781
|
+
current_offset = min_offset
|
|
2782
|
+
for i, curve_idx in enumerate(curve_order):
|
|
2783
|
+
if curve_idx >= len(ax.lines):
|
|
2784
|
+
continue
|
|
2785
|
+
# Get current x-data from the line
|
|
2786
|
+
current_x = np.asarray(ax.lines[curve_idx].get_xdata(), dtype=float)
|
|
2787
|
+
y_norm = orig_y[curve_idx]
|
|
2788
|
+
|
|
2789
|
+
# Set new offset with spacing
|
|
2790
|
+
offsets_list[curve_idx] = current_offset
|
|
2791
|
+
y_with_offset = y_norm + current_offset
|
|
2792
|
+
y_data_list[curve_idx] = y_with_offset
|
|
2793
|
+
x_data_list[curve_idx] = current_x.copy()
|
|
2794
|
+
ax.lines[curve_idx].set_data(current_x, y_with_offset)
|
|
2795
|
+
|
|
2796
|
+
# Calculate spacing for next curve based on current curve's range
|
|
2797
|
+
if i < len(curve_order) - 1: # Not the last curve
|
|
2798
|
+
y_range = (y_norm.max() - y_norm.min()) if y_norm.size else 0.0
|
|
2799
|
+
if args.stack:
|
|
2800
|
+
# In stack mode, spacing is relative to curve range
|
|
2801
|
+
gap = y_range + (spacing_value * (y_range if args.autoscale else 1.0))
|
|
2802
|
+
current_offset -= gap
|
|
2803
|
+
else:
|
|
2804
|
+
# In normal mode, spacing is absolute or relative
|
|
2805
|
+
increment = (y_range * spacing_value) if (args.autoscale and y_norm.size) else spacing_value
|
|
2806
|
+
current_offset += increment
|
|
2807
|
+
|
|
2808
|
+
ax.relim()
|
|
2809
|
+
ax.autoscale_view(scalex=False, scaley=True)
|
|
2810
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2811
|
+
fig.canvas.draw()
|
|
2812
|
+
print("Spacing of {:.4g} applied to separate all curves".format(spacing_value))
|
|
2813
|
+
|
|
2814
|
+
except ValueError:
|
|
2815
|
+
print("Invalid spacing value")
|
|
2816
|
+
except Exception as e:
|
|
2817
|
+
print(f"Error applying spacing: {e}")
|
|
2818
|
+
|
|
2819
|
+
elif offset_cmd == 'd':
|
|
2820
|
+
# Original delta spacing behavior
|
|
2821
|
+
if len(labels) <= 1:
|
|
2822
|
+
print("Warning: Only one curve loaded; applying an offset is not recommended.")
|
|
2823
|
+
try:
|
|
2824
|
+
new_delta_str = _safe_input(f"Enter new offset spacing (current={delta}): ").strip()
|
|
2825
|
+
if not new_delta_str:
|
|
2826
|
+
print("Canceled.")
|
|
2827
|
+
continue
|
|
2828
|
+
new_delta = float(new_delta_str)
|
|
2829
|
+
push_state("delta-spacing")
|
|
2830
|
+
delta = new_delta
|
|
2831
|
+
offsets_list[:] = []
|
|
2832
|
+
if args.stack:
|
|
2833
|
+
current_offset = 0.0
|
|
2834
|
+
for i, y_norm in enumerate(orig_y):
|
|
2835
|
+
if i >= len(ax.lines):
|
|
2836
|
+
continue
|
|
2837
|
+
# Get current x-data from the line
|
|
2838
|
+
current_x = np.asarray(ax.lines[i].get_xdata(), dtype=float)
|
|
2839
|
+
y_with_offset = y_norm + current_offset
|
|
2840
|
+
y_data_list[i] = y_with_offset
|
|
2841
|
+
offsets_list.append(current_offset)
|
|
2842
|
+
# Update x_data_list to match current line data
|
|
2843
|
+
x_data_list[i] = current_x.copy()
|
|
2844
|
+
ax.lines[i].set_data(current_x, y_with_offset)
|
|
2845
|
+
y_range = (y_norm.max() - y_norm.min()) if y_norm.size else 0.0
|
|
2846
|
+
gap = y_range + (delta * (y_range if args.autoscale else 1.0))
|
|
2847
|
+
current_offset -= gap
|
|
2848
|
+
else:
|
|
2849
|
+
current_offset = 0.0
|
|
2850
|
+
for i, y_norm in enumerate(orig_y):
|
|
2851
|
+
if i >= len(ax.lines):
|
|
2852
|
+
continue
|
|
2853
|
+
# Get current x-data from the line
|
|
2854
|
+
current_x = np.asarray(ax.lines[i].get_xdata(), dtype=float)
|
|
2855
|
+
y_with_offset = y_norm + current_offset
|
|
2856
|
+
y_data_list[i] = y_with_offset
|
|
2857
|
+
offsets_list.append(current_offset)
|
|
2858
|
+
# Update x_data_list to match current line data
|
|
2859
|
+
x_data_list[i] = current_x.copy()
|
|
2860
|
+
ax.lines[i].set_data(current_x, y_with_offset)
|
|
2861
|
+
increment = (y_norm.max() - y_norm.min()) * delta if (args.autoscale and y_norm.size) else delta
|
|
2862
|
+
current_offset += increment
|
|
2863
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2864
|
+
ax.relim(); ax.autoscale_view(scalex=False, scaley=True)
|
|
2865
|
+
fig.canvas.draw()
|
|
2866
|
+
print(f"Offsets updated with delta={delta}")
|
|
2867
|
+
except ValueError:
|
|
2868
|
+
print("Invalid delta value")
|
|
2869
|
+
except Exception as e:
|
|
2870
|
+
print(f"Error updating offsets: {e}")
|
|
2871
|
+
|
|
2872
|
+
elif offset_cmd.isdigit():
|
|
2873
|
+
# Adjust individual curve offset
|
|
2874
|
+
try:
|
|
2875
|
+
curve_num = int(offset_cmd)
|
|
2876
|
+
if curve_num < 1 or curve_num > len(labels):
|
|
2877
|
+
print("Invalid curve number (1-{})".format(len(labels)))
|
|
2878
|
+
continue
|
|
2879
|
+
|
|
2880
|
+
idx = curve_num - 1
|
|
2881
|
+
if idx >= len(ax.lines):
|
|
2882
|
+
print("Invalid curve number.")
|
|
2883
|
+
continue
|
|
2884
|
+
|
|
2885
|
+
current_offset = offsets_list[idx] if idx < len(offsets_list) else 0.0
|
|
2886
|
+
|
|
2887
|
+
individual_offset_input = _safe_input("Enter offset for curve {} (current: {:.4g}): ".format(
|
|
2888
|
+
curve_num, current_offset)).strip()
|
|
2889
|
+
if not individual_offset_input:
|
|
2890
|
+
print("Canceled.")
|
|
2891
|
+
continue
|
|
2892
|
+
|
|
2893
|
+
individual_offset = float(individual_offset_input)
|
|
2894
|
+
push_state("curve-{}-offset".format(curve_num))
|
|
2895
|
+
|
|
2896
|
+
# Get current x-data from the line to ensure we're working with actual displayed data
|
|
2897
|
+
current_x = np.asarray(ax.lines[idx].get_xdata(), dtype=float)
|
|
2898
|
+
# Apply individual offset to this curve
|
|
2899
|
+
y_norm = orig_y[idx]
|
|
2900
|
+
offsets_list[idx] = individual_offset
|
|
2901
|
+
y_with_offset = y_norm + individual_offset
|
|
2902
|
+
y_data_list[idx] = y_with_offset
|
|
2903
|
+
# Update x_data_list to match current line data
|
|
2904
|
+
x_data_list[idx] = current_x.copy()
|
|
2905
|
+
ax.lines[idx].set_data(current_x, y_with_offset)
|
|
2906
|
+
|
|
2907
|
+
ax.relim()
|
|
2908
|
+
ax.autoscale_view(scalex=False, scaley=True)
|
|
2909
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
2910
|
+
fig.canvas.draw()
|
|
2911
|
+
print("Curve {} offset set to: {:.4g}".format(curve_num, individual_offset))
|
|
2912
|
+
|
|
2913
|
+
except ValueError:
|
|
2914
|
+
print("Invalid offset value")
|
|
2915
|
+
except Exception as e:
|
|
2916
|
+
print(f"Error setting curve offset: {e}")
|
|
2917
|
+
else:
|
|
2918
|
+
print("Unknown command. Use 1-{}, a, r, d, or q".format(len(labels)))
|
|
2919
|
+
elif key == 'l':
|
|
2920
|
+
try:
|
|
2921
|
+
def _select_lines(ax_obj, prompt_text):
|
|
2922
|
+
total = len(ax_obj.lines)
|
|
2923
|
+
if total == 0:
|
|
2924
|
+
print("No curves to modify.")
|
|
2925
|
+
return []
|
|
2926
|
+
print(f"Total curves available: {total}")
|
|
2927
|
+
raw = _safe_input(prompt_text + " ").strip().lower()
|
|
2928
|
+
if not raw or raw in ('all', '*'):
|
|
2929
|
+
return list(range(total))
|
|
2930
|
+
import re as _re
|
|
2931
|
+
tokens = [tok for tok in _re.split(r'[,\s]+', raw) if tok]
|
|
2932
|
+
selected = []
|
|
2933
|
+
for tok in tokens:
|
|
2934
|
+
try:
|
|
2935
|
+
idx = int(tok) - 1
|
|
2936
|
+
if 0 <= idx < total:
|
|
2937
|
+
if idx not in selected:
|
|
2938
|
+
selected.append(idx)
|
|
2939
|
+
else:
|
|
2940
|
+
print(f"Index out of range: {tok}")
|
|
2941
|
+
except ValueError:
|
|
2942
|
+
print(f"Skipping invalid token: {tok}")
|
|
2943
|
+
return selected
|
|
2944
|
+
|
|
2945
|
+
def _prompt_float(prompt_text):
|
|
2946
|
+
raw = _safe_input(prompt_text).strip()
|
|
2947
|
+
if not raw:
|
|
2948
|
+
return None
|
|
2949
|
+
if raw.lower() == 'q':
|
|
2950
|
+
return None
|
|
2951
|
+
try:
|
|
2952
|
+
return float(raw)
|
|
2953
|
+
except ValueError:
|
|
2954
|
+
print("Invalid number, using default.")
|
|
2955
|
+
return None
|
|
2956
|
+
|
|
2957
|
+
def _prompt_dash_pattern(kind='dash'):
|
|
2958
|
+
if kind == 'dashdot':
|
|
2959
|
+
raw = _safe_input("Dash-dot pattern 'dash gap dot gap' (blank=6 3 1 3, q=cancel): ").strip().lower()
|
|
2960
|
+
default = (6.0, 3.0, 1.0, 3.0)
|
|
2961
|
+
else:
|
|
2962
|
+
raw = _safe_input("Dash pattern 'length gap' (blank=6 3, q=cancel): ").strip().lower()
|
|
2963
|
+
default = (6.0, 3.0)
|
|
2964
|
+
if not raw:
|
|
2965
|
+
return default
|
|
2966
|
+
if raw == 'q':
|
|
2967
|
+
print("Canceled.")
|
|
2968
|
+
return None
|
|
2969
|
+
import re as _re
|
|
2970
|
+
tokens = [tok for tok in _re.split(r'[,\s]+', raw) if tok]
|
|
2971
|
+
try:
|
|
2972
|
+
if kind == 'dashdot':
|
|
2973
|
+
if len(tokens) == 2:
|
|
2974
|
+
dash = float(tokens[0]); gap = float(tokens[1])
|
|
2975
|
+
dot = min(dash * 0.2, 2.0)
|
|
2976
|
+
return (dash, gap, dot, gap)
|
|
2977
|
+
elif len(tokens) >= 4:
|
|
2978
|
+
return tuple(float(tokens[i]) for i in range(4))
|
|
2979
|
+
else:
|
|
2980
|
+
if len(tokens) == 1:
|
|
2981
|
+
val = float(tokens[0])
|
|
2982
|
+
return (val, val)
|
|
2983
|
+
elif len(tokens) >= 2:
|
|
2984
|
+
return (float(tokens[0]), float(tokens[1]))
|
|
2985
|
+
except ValueError:
|
|
2986
|
+
print("Invalid dash pattern.")
|
|
2987
|
+
return None
|
|
2988
|
+
print("Invalid dash pattern.")
|
|
2989
|
+
return None
|
|
2990
|
+
|
|
2991
|
+
while True:
|
|
2992
|
+
print("\033[1mLine submenu:\033[0m")
|
|
2993
|
+
print(f" {colorize_menu('c : change curve line widths')}")
|
|
2994
|
+
print(f" {colorize_menu('f : change frame (axes spines) and tick widths')}")
|
|
2995
|
+
print(f" {colorize_menu('g : toggle grid lines')}")
|
|
2996
|
+
print(f" {colorize_menu('l : show only lines (no markers) for selected curves')}")
|
|
2997
|
+
print(f" {colorize_menu('ld : show line and dots for selected curves')}")
|
|
2998
|
+
print(f" {colorize_menu('d : show only dots for selected curves')}")
|
|
2999
|
+
print(f" {colorize_menu('da : dashed line for selected curves')}")
|
|
3000
|
+
print(f" {colorize_menu('dd : dashed line + dots for selected curves')}")
|
|
3001
|
+
print(f" {colorize_menu('q : return')}")
|
|
3002
|
+
sub = _safe_input(colorize_prompt("Choose (c/f/g/l/ld/d/da/dd/q): ")).strip().lower()
|
|
3003
|
+
if sub == 'q':
|
|
3004
|
+
break
|
|
3005
|
+
if sub == '':
|
|
3006
|
+
continue
|
|
3007
|
+
if sub == 'c':
|
|
3008
|
+
spec = _safe_input("Curve widths (single value OR mappings like '1:1.2 3:2', q=cancel): ").strip()
|
|
3009
|
+
if not spec or spec.lower() == 'q':
|
|
3010
|
+
print("Canceled.")
|
|
3011
|
+
else:
|
|
3012
|
+
push_state("linewidth")
|
|
3013
|
+
if ":" in spec:
|
|
3014
|
+
parts = spec.split()
|
|
3015
|
+
for p in parts:
|
|
3016
|
+
if ":" not in p:
|
|
3017
|
+
print(f"Skip malformed token: {p}")
|
|
3018
|
+
continue
|
|
3019
|
+
idx_str, lw_str = p.split(":", 1)
|
|
3020
|
+
try:
|
|
3021
|
+
idx = int(idx_str) - 1
|
|
3022
|
+
lw = float(lw_str)
|
|
3023
|
+
if 0 <= idx < len(ax.lines):
|
|
3024
|
+
ax.lines[idx].set_linewidth(lw)
|
|
3025
|
+
else:
|
|
3026
|
+
print(f"Index out of range: {idx+1}")
|
|
3027
|
+
except ValueError:
|
|
3028
|
+
print(f"Bad token: {p}")
|
|
3029
|
+
else:
|
|
3030
|
+
try:
|
|
3031
|
+
lw = float(spec)
|
|
3032
|
+
for ln in ax.lines:
|
|
3033
|
+
ln.set_linewidth(lw)
|
|
3034
|
+
except ValueError:
|
|
3035
|
+
print("Invalid width value.")
|
|
3036
|
+
fig.canvas.draw()
|
|
3037
|
+
elif sub == 'f':
|
|
3038
|
+
fw_in = _safe_input("Enter frame/tick width (e.g., 1.5) or 'm M' (major minor) or q: ").strip()
|
|
3039
|
+
if not fw_in or fw_in.lower() == 'q':
|
|
3040
|
+
print("Canceled.")
|
|
3041
|
+
else:
|
|
3042
|
+
push_state("framewidth")
|
|
3043
|
+
parts = fw_in.split()
|
|
3044
|
+
try:
|
|
3045
|
+
if len(parts) == 1:
|
|
3046
|
+
frame_w = float(parts[0])
|
|
3047
|
+
tick_major = frame_w
|
|
3048
|
+
tick_minor = frame_w * 0.6
|
|
3049
|
+
else:
|
|
3050
|
+
frame_w = float(parts[0])
|
|
3051
|
+
tick_major = float(parts[1])
|
|
3052
|
+
tick_minor = float(tick_major) * 0.7
|
|
3053
|
+
for sp in ax.spines.values():
|
|
3054
|
+
sp.set_linewidth(frame_w)
|
|
3055
|
+
ax.tick_params(which='major', width=tick_major)
|
|
3056
|
+
ax.tick_params(which='minor', width=tick_minor)
|
|
3057
|
+
fig.canvas.draw()
|
|
3058
|
+
print(f"Set frame width={frame_w}, major tick width={tick_major}, minor tick width={tick_minor}")
|
|
3059
|
+
except ValueError:
|
|
3060
|
+
print("Invalid numeric value(s).")
|
|
3061
|
+
elif sub == 'g':
|
|
3062
|
+
push_state("grid")
|
|
3063
|
+
# Toggle grid state - check if any gridlines are visible
|
|
3064
|
+
current_grid = False
|
|
3065
|
+
try:
|
|
3066
|
+
# Check if grid is currently on by looking at gridline visibility
|
|
3067
|
+
for line in ax.get_xgridlines() + ax.get_ygridlines():
|
|
3068
|
+
if line.get_visible():
|
|
3069
|
+
current_grid = True
|
|
3070
|
+
break
|
|
3071
|
+
except Exception:
|
|
3072
|
+
current_grid = ax.xaxis._gridOnMajor if hasattr(ax.xaxis, '_gridOnMajor') else False
|
|
3073
|
+
|
|
3074
|
+
new_grid_state = not current_grid
|
|
3075
|
+
if new_grid_state:
|
|
3076
|
+
# Enable grid with light styling
|
|
3077
|
+
ax.grid(True, color='0.85', linestyle='-', linewidth=0.5, alpha=0.7)
|
|
3078
|
+
else:
|
|
3079
|
+
# Disable grid (no style parameters when disabling)
|
|
3080
|
+
ax.grid(False)
|
|
3081
|
+
fig.canvas.draw()
|
|
3082
|
+
print(f"Grid {'enabled' if new_grid_state else 'disabled'}.")
|
|
3083
|
+
elif sub == 'l':
|
|
3084
|
+
targets = _select_lines(ax, "line-only targets (numbers or 'all'):")
|
|
3085
|
+
if not targets:
|
|
3086
|
+
continue
|
|
3087
|
+
push_state("line-only")
|
|
3088
|
+
for idx in targets:
|
|
3089
|
+
ln = ax.lines[idx]
|
|
3090
|
+
ln.set_linestyle('-')
|
|
3091
|
+
ln.set_marker('None')
|
|
3092
|
+
fig.canvas.draw()
|
|
3093
|
+
print(f"Applied line-only style to curves: {', '.join(str(i+1) for i in targets)}")
|
|
3094
|
+
elif sub == 'ld':
|
|
3095
|
+
targets = _select_lines(ax, "line+dots targets (numbers or 'all'):")
|
|
3096
|
+
if not targets:
|
|
3097
|
+
continue
|
|
3098
|
+
push_state("line+dots")
|
|
3099
|
+
custom_msize = _prompt_float("Marker size (blank=auto ~3*lw): ")
|
|
3100
|
+
for idx in targets:
|
|
3101
|
+
ln = ax.lines[idx]
|
|
3102
|
+
lw = ln.get_linewidth() or 1.0
|
|
3103
|
+
ln.set_linestyle('-')
|
|
3104
|
+
ln.set_marker('o')
|
|
3105
|
+
msize = custom_msize if custom_msize is not None else max(3.0, lw * 3.0)
|
|
3106
|
+
ln.set_markersize(msize)
|
|
3107
|
+
col = ln.get_color()
|
|
3108
|
+
try:
|
|
3109
|
+
ln.set_markerfacecolor(col)
|
|
3110
|
+
ln.set_markeredgecolor(col)
|
|
3111
|
+
except Exception:
|
|
3112
|
+
pass
|
|
3113
|
+
fig.canvas.draw()
|
|
3114
|
+
print(f"Applied line+dots style to curves: {', '.join(str(i+1) for i in targets)}")
|
|
3115
|
+
elif sub == 'd':
|
|
3116
|
+
targets = _select_lines(ax, "dots-only targets (numbers or 'all'):")
|
|
3117
|
+
if not targets:
|
|
3118
|
+
continue
|
|
3119
|
+
push_state("dots-only")
|
|
3120
|
+
custom_msize = _prompt_float("Marker size (blank=auto ~3*lw): ")
|
|
3121
|
+
for idx in targets:
|
|
3122
|
+
ln = ax.lines[idx]
|
|
3123
|
+
lw = ln.get_linewidth() or 1.0
|
|
3124
|
+
ln.set_linestyle('None')
|
|
3125
|
+
ln.set_marker('o')
|
|
3126
|
+
msize = custom_msize if custom_msize is not None else max(3.0, lw * 3.0)
|
|
3127
|
+
ln.set_markersize(msize)
|
|
3128
|
+
col = ln.get_color()
|
|
3129
|
+
try:
|
|
3130
|
+
ln.set_markerfacecolor(col)
|
|
3131
|
+
ln.set_markeredgecolor(col)
|
|
3132
|
+
except Exception:
|
|
3133
|
+
pass
|
|
3134
|
+
fig.canvas.draw()
|
|
3135
|
+
print(f"Applied dots-only style to curves: {', '.join(str(i+1) for i in targets)}")
|
|
3136
|
+
elif sub == 'da':
|
|
3137
|
+
targets = _select_lines(ax, "dashed-line targets (numbers or 'all'):")
|
|
3138
|
+
if not targets:
|
|
3139
|
+
continue
|
|
3140
|
+
dash_vals = _prompt_dash_pattern()
|
|
3141
|
+
if dash_vals is None:
|
|
3142
|
+
continue
|
|
3143
|
+
dash_len, gap_len = dash_vals
|
|
3144
|
+
push_state("dashed-line")
|
|
3145
|
+
for idx in targets:
|
|
3146
|
+
ln = ax.lines[idx]
|
|
3147
|
+
ln.set_marker('None')
|
|
3148
|
+
ln.set_linestyle((0, (dash_len, gap_len)))
|
|
3149
|
+
fig.canvas.draw()
|
|
3150
|
+
print(f"Applied dashed lines to curves: {', '.join(str(i+1) for i in targets)}")
|
|
3151
|
+
elif sub == 'dd':
|
|
3152
|
+
targets = _select_lines(ax, "dash-dot targets (numbers or 'all'):")
|
|
3153
|
+
if not targets:
|
|
3154
|
+
continue
|
|
3155
|
+
dash_vals = _prompt_dash_pattern(kind='dashdot')
|
|
3156
|
+
if dash_vals is None:
|
|
3157
|
+
continue
|
|
3158
|
+
push_state("dash-dot")
|
|
3159
|
+
for idx in targets:
|
|
3160
|
+
ln = ax.lines[idx]
|
|
3161
|
+
ln.set_marker('None')
|
|
3162
|
+
ln.set_linestyle((0, dash_vals))
|
|
3163
|
+
fig.canvas.draw()
|
|
3164
|
+
print(f"Applied dash-dot style to curves: {', '.join(str(i+1) for i in targets)}")
|
|
3165
|
+
else:
|
|
3166
|
+
print("Unknown submenu option.")
|
|
3167
|
+
except Exception as e:
|
|
3168
|
+
print(f"Error setting widths: {e}")
|
|
3169
|
+
elif key == 'f':
|
|
3170
|
+
cur_family = plt.rcParams.get('font.sans-serif', [''])[0]
|
|
3171
|
+
cur_size = plt.rcParams.get('font.size', None)
|
|
3172
|
+
while True:
|
|
3173
|
+
subkey = _safe_input(colorize_prompt(f"Font submenu (current: family='{cur_family}', size={cur_size}) - s=size, f=family, q=return: ")).strip().lower()
|
|
3174
|
+
if subkey == 'q':
|
|
3175
|
+
break
|
|
3176
|
+
if subkey == '':
|
|
3177
|
+
continue
|
|
3178
|
+
if subkey == 's':
|
|
3179
|
+
try:
|
|
3180
|
+
cur_size = plt.rcParams.get('font.size', None)
|
|
3181
|
+
fs = _safe_input(f"Enter new font size (current: {cur_size}, q=cancel): ").strip()
|
|
3182
|
+
if not fs or fs.lower() == 'q':
|
|
3183
|
+
print("Canceled.")
|
|
3184
|
+
else:
|
|
3185
|
+
push_state("font-change")
|
|
3186
|
+
fs_val = float(fs)
|
|
3187
|
+
apply_font_changes(new_size=fs_val)
|
|
3188
|
+
# Reposition top/right labels to match new tick label sizes
|
|
3189
|
+
position_top_xlabel()
|
|
3190
|
+
position_right_ylabel()
|
|
3191
|
+
fig.canvas.draw()
|
|
3192
|
+
except Exception as e:
|
|
3193
|
+
print(f"Error changing font size: {e}")
|
|
3194
|
+
elif subkey == 'f':
|
|
3195
|
+
try:
|
|
3196
|
+
cur_family = plt.rcParams.get('font.sans-serif', [''])[0]
|
|
3197
|
+
print("Common publication fonts:")
|
|
3198
|
+
print(" 1) Arial")
|
|
3199
|
+
print(" 2) Helvetica")
|
|
3200
|
+
print(" 3) Times New Roman")
|
|
3201
|
+
print(" 4) STIXGeneral")
|
|
3202
|
+
print(" 5) DejaVu Sans")
|
|
3203
|
+
ft_raw = _safe_input(f"Enter font number or family name (current: '{cur_family}', q=cancel): ").strip()
|
|
3204
|
+
if not ft_raw or ft_raw.lower() == 'q':
|
|
3205
|
+
print("Canceled.")
|
|
3206
|
+
else:
|
|
3207
|
+
font_map = {
|
|
3208
|
+
'1': 'Arial',
|
|
3209
|
+
'2': 'Helvetica',
|
|
3210
|
+
'3': 'Times New Roman',
|
|
3211
|
+
'4': 'STIXGeneral',
|
|
3212
|
+
'5': 'DejaVu Sans'
|
|
3213
|
+
}
|
|
3214
|
+
ft = font_map.get(ft_raw, ft_raw)
|
|
3215
|
+
push_state("font-change")
|
|
3216
|
+
print(f"Setting font family to: {ft}")
|
|
3217
|
+
apply_font_changes(new_family=ft)
|
|
3218
|
+
# Reposition top/right labels to match new tick label sizes
|
|
3219
|
+
position_top_xlabel()
|
|
3220
|
+
position_right_ylabel()
|
|
3221
|
+
fig.canvas.draw()
|
|
3222
|
+
except Exception as e:
|
|
3223
|
+
print(f"Error changing font family: {e}")
|
|
3224
|
+
else:
|
|
3225
|
+
print("Invalid font submenu option.")
|
|
3226
|
+
elif key == 'g':
|
|
3227
|
+
try:
|
|
3228
|
+
while True:
|
|
3229
|
+
choice = _safe_input(colorize_prompt("Resize submenu: (p=plot frame, c=canvas, q=cancel): ")).strip().lower()
|
|
3230
|
+
if not choice:
|
|
3231
|
+
continue
|
|
3232
|
+
if choice == 'q':
|
|
3233
|
+
break
|
|
3234
|
+
if choice == 'p':
|
|
3235
|
+
push_state("resize-frame")
|
|
3236
|
+
resize_plot_frame()
|
|
3237
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
3238
|
+
elif choice == 'c':
|
|
3239
|
+
push_state("resize-canvas")
|
|
3240
|
+
resize_canvas()
|
|
3241
|
+
else:
|
|
3242
|
+
print("Unknown option.")
|
|
3243
|
+
except Exception as e:
|
|
3244
|
+
print(f"Error in resize submenu: {e}")
|
|
3245
|
+
elif key == 'h':
|
|
3246
|
+
# Legend submenu
|
|
3247
|
+
try:
|
|
3248
|
+
while True:
|
|
3249
|
+
print("\nLegend submenu:")
|
|
3250
|
+
print(" v: show/hide curve names")
|
|
3251
|
+
current_pos = "bottom-right" if getattr(fig, '_stack_label_at_bottom', False) else "top-right"
|
|
3252
|
+
print(f" s: legend position (current: {current_pos})")
|
|
3253
|
+
print(" q: back to main menu")
|
|
3254
|
+
sub_key = _safe_input("Choose: ").strip().lower()
|
|
3255
|
+
|
|
3256
|
+
if sub_key == 'q':
|
|
3257
|
+
break
|
|
3258
|
+
elif sub_key == 'v':
|
|
3259
|
+
push_state("curve-names")
|
|
3260
|
+
# Check current visibility from first label
|
|
3261
|
+
current_visible = True
|
|
3262
|
+
if label_text_objects and len(label_text_objects) > 0:
|
|
3263
|
+
try:
|
|
3264
|
+
current_visible = label_text_objects[0].get_visible()
|
|
3265
|
+
except Exception:
|
|
3266
|
+
current_visible = True
|
|
3267
|
+
|
|
3268
|
+
# Toggle all labels
|
|
3269
|
+
new_visible = not current_visible
|
|
3270
|
+
for txt in label_text_objects:
|
|
3271
|
+
try:
|
|
3272
|
+
txt.set_visible(new_visible)
|
|
3273
|
+
except Exception:
|
|
3274
|
+
pass
|
|
3275
|
+
|
|
3276
|
+
# Store state on figure for persistence
|
|
3277
|
+
fig._curve_names_visible = new_visible
|
|
3278
|
+
|
|
3279
|
+
status = "shown" if new_visible else "hidden"
|
|
3280
|
+
print(f"Curve names {status}")
|
|
3281
|
+
stack_label_bottom = getattr(fig, '_stack_label_at_bottom', False)
|
|
3282
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, stack_label_bottom)
|
|
3283
|
+
try:
|
|
3284
|
+
fig.canvas.draw()
|
|
3285
|
+
except Exception:
|
|
3286
|
+
fig.canvas.draw_idle()
|
|
3287
|
+
elif sub_key == 's':
|
|
3288
|
+
push_state("label-position")
|
|
3289
|
+
# Toggle label position between top-right and bottom-right
|
|
3290
|
+
current_bottom = getattr(fig, '_stack_label_at_bottom', False)
|
|
3291
|
+
fig._stack_label_at_bottom = not current_bottom
|
|
3292
|
+
new_pos = "bottom-right" if fig._stack_label_at_bottom else "top-right"
|
|
3293
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, fig._stack_label_at_bottom)
|
|
3294
|
+
print(f"Legend position changed to {new_pos}.")
|
|
3295
|
+
try:
|
|
3296
|
+
fig.canvas.draw()
|
|
3297
|
+
except Exception:
|
|
3298
|
+
fig.canvas.draw_idle()
|
|
3299
|
+
else:
|
|
3300
|
+
print("Unknown option.")
|
|
3301
|
+
except Exception as e:
|
|
3302
|
+
print(f"Error in legend submenu: {e}")
|
|
3303
|
+
elif key == 't':
|
|
3304
|
+
try:
|
|
3305
|
+
while True:
|
|
3306
|
+
print("\033[1mToggle help:\033[0m")
|
|
3307
|
+
print(colorize_inline_commands(" wasd choose side: w=top, a=left, s=bottom, d=right"))
|
|
3308
|
+
print(colorize_inline_commands(" 1..5 choose what: 1=spine line, 2=major ticks, 3=minor ticks, 4=labels, 5=axis title"))
|
|
3309
|
+
print(colorize_inline_commands(" Combine letter+number to toggle, e.g. 's2 w5 a4' (case-insensitive)"))
|
|
3310
|
+
print(colorize_inline_commands(" i = invert tick direction, l = change tick length, list = show state, q = return"))
|
|
3311
|
+
print(colorize_inline_commands(" p = adjust title offsets (w=top, s=bottom, a=left, d=right)"))
|
|
3312
|
+
cmd = _safe_input(colorize_prompt("Enter code(s): ")).strip().lower()
|
|
3313
|
+
if not cmd:
|
|
3314
|
+
continue
|
|
3315
|
+
if cmd == 'q':
|
|
3316
|
+
break
|
|
3317
|
+
if cmd == 'i':
|
|
3318
|
+
# Invert tick direction (toggle between 'out' and 'in')
|
|
3319
|
+
push_state("tick-direction")
|
|
3320
|
+
current_dir = getattr(fig, '_tick_direction', 'out')
|
|
3321
|
+
new_dir = 'in' if current_dir == 'out' else 'out'
|
|
3322
|
+
setattr(fig, '_tick_direction', new_dir)
|
|
3323
|
+
ax.tick_params(axis='both', which='both', direction=new_dir)
|
|
3324
|
+
print(f"Tick direction: {new_dir}")
|
|
3325
|
+
try:
|
|
3326
|
+
fig.canvas.draw()
|
|
3327
|
+
except Exception:
|
|
3328
|
+
fig.canvas.draw_idle()
|
|
3329
|
+
continue
|
|
3330
|
+
if cmd == 'p':
|
|
3331
|
+
_title_offset_menu()
|
|
3332
|
+
continue
|
|
3333
|
+
if cmd == 'l':
|
|
3334
|
+
# Change tick length (major and minor automatically set to 70%)
|
|
3335
|
+
try:
|
|
3336
|
+
# Get current major tick length from axes
|
|
3337
|
+
current_major = ax.xaxis.get_major_ticks()[0].tick1line.get_markersize() if ax.xaxis.get_major_ticks() else 4.0
|
|
3338
|
+
print(f"Current major tick length: {current_major}")
|
|
3339
|
+
new_length_str = _safe_input("Enter new major tick length (e.g., 6.0): ").strip()
|
|
3340
|
+
if not new_length_str:
|
|
3341
|
+
continue
|
|
3342
|
+
new_major = float(new_length_str)
|
|
3343
|
+
if new_major <= 0:
|
|
3344
|
+
print("Length must be positive.")
|
|
3345
|
+
continue
|
|
3346
|
+
new_minor = new_major * 0.7 # Auto-set minor to 70%
|
|
3347
|
+
push_state("tick-length")
|
|
3348
|
+
# Apply to all four axes
|
|
3349
|
+
ax.tick_params(axis='both', which='major', length=new_major)
|
|
3350
|
+
ax.tick_params(axis='both', which='minor', length=new_minor)
|
|
3351
|
+
# Store for persistence
|
|
3352
|
+
if not hasattr(fig, '_tick_lengths'):
|
|
3353
|
+
fig._tick_lengths = {}
|
|
3354
|
+
fig._tick_lengths.update({'major': new_major, 'minor': new_minor})
|
|
3355
|
+
print(f"Set major tick length: {new_major}, minor: {new_minor:.2f}")
|
|
3356
|
+
try:
|
|
3357
|
+
fig.canvas.draw()
|
|
3358
|
+
except Exception:
|
|
3359
|
+
fig.canvas.draw_idle()
|
|
3360
|
+
except ValueError:
|
|
3361
|
+
print("Invalid number.")
|
|
3362
|
+
except Exception as e:
|
|
3363
|
+
print(f"Error setting tick length: {e}")
|
|
3364
|
+
continue
|
|
3365
|
+
parts = cmd.split()
|
|
3366
|
+
if parts == ['list']:
|
|
3367
|
+
print_tick_state()
|
|
3368
|
+
continue
|
|
3369
|
+
push_state("tick-toggle")
|
|
3370
|
+
# Track which sides need re-positioning of axis titles
|
|
3371
|
+
need_pos = {
|
|
3372
|
+
'bottom': False, # bottom X title spacing
|
|
3373
|
+
'top': False, # top X duplicate title
|
|
3374
|
+
'left': False, # left Y title spacing
|
|
3375
|
+
'right': False, # right Y duplicate title
|
|
3376
|
+
}
|
|
3377
|
+
# New key aliases -> legacy/internal codes
|
|
3378
|
+
alias_map = {
|
|
3379
|
+
# Spines
|
|
3380
|
+
's1':'bl', 'w1':'tl', 'a1':'ll', 'd1':'rl',
|
|
3381
|
+
# Major tick marks
|
|
3382
|
+
's2':'btcs', 'w2':'ttcs', 'a2':'ltcs', 'd2':'rtcs',
|
|
3383
|
+
# Minor ticks
|
|
3384
|
+
's3':'mbx', 'w3':'mtx', 'a3':'mly', 'd3':'mry',
|
|
3385
|
+
# Labels
|
|
3386
|
+
's4':'blb', 'w4':'tlb', 'a4':'llb', 'd4':'rlb',
|
|
3387
|
+
# Axis titles
|
|
3388
|
+
's5':'bt', 'w5':'tt', 'a5':'lt', 'd5':'rt',
|
|
3389
|
+
# Small typo tolerance
|
|
3390
|
+
'tics':'ttcs',
|
|
3391
|
+
}
|
|
3392
|
+
for p in parts:
|
|
3393
|
+
if p in alias_map:
|
|
3394
|
+
p = alias_map[p]
|
|
3395
|
+
# Axis title toggles
|
|
3396
|
+
if p in ('bt','tt','lt','rt'):
|
|
3397
|
+
if p == 'bt':
|
|
3398
|
+
# Use visibility toggle to avoid layout recalculation
|
|
3399
|
+
label_obj = ax.xaxis.label
|
|
3400
|
+
if label_obj.get_visible():
|
|
3401
|
+
# Store text before hiding
|
|
3402
|
+
if not hasattr(ax, '_stored_xlabel'):
|
|
3403
|
+
ax._stored_xlabel = label_obj.get_text()
|
|
3404
|
+
# Store current labelpad to restore later
|
|
3405
|
+
try:
|
|
3406
|
+
ax._stored_xlabelpad = getattr(ax.xaxis, 'labelpad', None)
|
|
3407
|
+
except Exception:
|
|
3408
|
+
pass
|
|
3409
|
+
label_obj.set_visible(False)
|
|
3410
|
+
print("Hid bottom X axis title")
|
|
3411
|
+
else:
|
|
3412
|
+
# Restore text if needed before showing
|
|
3413
|
+
if hasattr(ax, '_stored_xlabel') and ax._stored_xlabel:
|
|
3414
|
+
label_obj.set_text(ax._stored_xlabel)
|
|
3415
|
+
label_obj.set_visible(True)
|
|
3416
|
+
# Freeze any automatic layout to prevent margin reflow on toggle
|
|
3417
|
+
try:
|
|
3418
|
+
fig.set_layout_engine('none')
|
|
3419
|
+
except Exception:
|
|
3420
|
+
try:
|
|
3421
|
+
fig.set_tight_layout(False)
|
|
3422
|
+
except Exception:
|
|
3423
|
+
pass
|
|
3424
|
+
try:
|
|
3425
|
+
# On some MPL versions this exists; harmless otherwise
|
|
3426
|
+
fig.set_constrained_layout(False)
|
|
3427
|
+
except Exception:
|
|
3428
|
+
pass
|
|
3429
|
+
# Reapply a deterministic pad based on current bottom label visibility
|
|
3430
|
+
try:
|
|
3431
|
+
# Prefer exact stored pad if available; else compute from tick visibility
|
|
3432
|
+
if hasattr(ax, '_stored_xlabelpad') and ax._stored_xlabelpad is not None:
|
|
3433
|
+
desired_pad = ax._stored_xlabelpad
|
|
3434
|
+
# Set a one-shot pending pad for ui.position_bottom_xlabel to consume
|
|
3435
|
+
ax._pending_xlabelpad = desired_pad
|
|
3436
|
+
else:
|
|
3437
|
+
desired_pad = 14 if bool(tick_state.get('b_labels', tick_state.get('bx', False))) else 6
|
|
3438
|
+
ax.xaxis.labelpad = desired_pad
|
|
3439
|
+
except Exception:
|
|
3440
|
+
pass
|
|
3441
|
+
print("Shown bottom X axis title")
|
|
3442
|
+
need_pos['bottom'] = True
|
|
3443
|
+
elif p == 'tt':
|
|
3444
|
+
vis = getattr(ax, '_top_xlabel_on', False)
|
|
3445
|
+
if not vis:
|
|
3446
|
+
# Just set the flag and let position_top_xlabel() create/update the artist
|
|
3447
|
+
ax._top_xlabel_on = True
|
|
3448
|
+
need_pos['top'] = True
|
|
3449
|
+
print("Shown duplicate top X axis title")
|
|
3450
|
+
else:
|
|
3451
|
+
if hasattr(ax,'_top_xlabel_artist') and ax._top_xlabel_artist is not None:
|
|
3452
|
+
ax._top_xlabel_artist.set_visible(False)
|
|
3453
|
+
ax._top_xlabel_on = False
|
|
3454
|
+
need_pos['top'] = True
|
|
3455
|
+
print("Hid top X axis title duplicate")
|
|
3456
|
+
elif p == 'lt':
|
|
3457
|
+
# Use visibility toggle to avoid layout recalculation
|
|
3458
|
+
label_obj = ax.yaxis.label
|
|
3459
|
+
if label_obj.get_visible():
|
|
3460
|
+
# Store text before hiding
|
|
3461
|
+
if not hasattr(ax, '_stored_ylabel'):
|
|
3462
|
+
ax._stored_ylabel = label_obj.get_text()
|
|
3463
|
+
# Store current labelpad to restore later
|
|
3464
|
+
try:
|
|
3465
|
+
ax._stored_ylabelpad = getattr(ax.yaxis, 'labelpad', None)
|
|
3466
|
+
except Exception:
|
|
3467
|
+
pass
|
|
3468
|
+
label_obj.set_visible(False)
|
|
3469
|
+
print("Hid left Y axis title")
|
|
3470
|
+
else:
|
|
3471
|
+
# Restore text if needed before showing
|
|
3472
|
+
if hasattr(ax, '_stored_ylabel') and ax._stored_ylabel:
|
|
3473
|
+
label_obj.set_text(ax._stored_ylabel)
|
|
3474
|
+
label_obj.set_visible(True)
|
|
3475
|
+
# Freeze auto layout and restore exact pad if available
|
|
3476
|
+
try:
|
|
3477
|
+
fig.set_layout_engine('none')
|
|
3478
|
+
except Exception:
|
|
3479
|
+
try:
|
|
3480
|
+
fig.set_tight_layout(False)
|
|
3481
|
+
except Exception:
|
|
3482
|
+
pass
|
|
3483
|
+
try:
|
|
3484
|
+
fig.set_constrained_layout(False)
|
|
3485
|
+
except Exception:
|
|
3486
|
+
pass
|
|
3487
|
+
try:
|
|
3488
|
+
if hasattr(ax, '_stored_ylabelpad') and ax._stored_ylabelpad is not None:
|
|
3489
|
+
ax.yaxis.labelpad = ax._stored_ylabelpad
|
|
3490
|
+
# Set a one-shot pending pad for ui.position_left_ylabel to consume
|
|
3491
|
+
ax._pending_ylabelpad = ax._stored_ylabelpad
|
|
3492
|
+
else:
|
|
3493
|
+
desired_pad = 14 if bool(tick_state.get('l_labels', tick_state.get('ly', False))) else 6
|
|
3494
|
+
ax.yaxis.labelpad = desired_pad
|
|
3495
|
+
except Exception:
|
|
3496
|
+
pass
|
|
3497
|
+
print("Shown left Y axis title")
|
|
3498
|
+
need_pos['left'] = True
|
|
3499
|
+
elif p == 'rt':
|
|
3500
|
+
vis = getattr(ax, '_right_ylabel_on', False)
|
|
3501
|
+
if not vis:
|
|
3502
|
+
# Just set the flag and let position_right_ylabel() create/update the artist
|
|
3503
|
+
ax._right_ylabel_on = True
|
|
3504
|
+
need_pos['right'] = True
|
|
3505
|
+
print("Shown duplicate right Y axis title")
|
|
3506
|
+
else:
|
|
3507
|
+
if hasattr(ax,'_right_ylabel_artist') and ax._right_ylabel_artist is not None:
|
|
3508
|
+
try:
|
|
3509
|
+
ax._right_ylabel_artist.set_visible(False)
|
|
3510
|
+
except Exception:
|
|
3511
|
+
pass
|
|
3512
|
+
ax._right_ylabel_on = False
|
|
3513
|
+
need_pos['right'] = True
|
|
3514
|
+
print("Hid right Y axis title")
|
|
3515
|
+
continue
|
|
3516
|
+
# Plot frame (spine) toggles
|
|
3517
|
+
if p in ('bl','tl','ll','rl'):
|
|
3518
|
+
spine_map = {'bl':'bottom','tl':'top','ll':'left','rl':'right'}
|
|
3519
|
+
spine = spine_map[p]
|
|
3520
|
+
vis = get_spine_visible(spine)
|
|
3521
|
+
set_spine_visible(spine, not vis)
|
|
3522
|
+
print(f"Toggled {spine} spine -> {'ON' if not vis else 'off'}")
|
|
3523
|
+
continue
|
|
3524
|
+
# New granular tick/label toggles
|
|
3525
|
+
if p in ('btcs','blb','ttcs','tlb','ltcs','llb','rtcs','rlb'):
|
|
3526
|
+
if p == 'btcs':
|
|
3527
|
+
tick_state['b_ticks'] = not tick_state['b_ticks']
|
|
3528
|
+
print(f"Toggled bottom ticks -> {'ON' if tick_state['b_ticks'] else 'off'}")
|
|
3529
|
+
elif p == 'blb':
|
|
3530
|
+
tick_state['b_labels'] = not tick_state['b_labels']
|
|
3531
|
+
print(f"Toggled bottom labels -> {'ON' if tick_state['b_labels'] else 'off'}")
|
|
3532
|
+
need_pos['bottom'] = True
|
|
3533
|
+
elif p == 'ttcs':
|
|
3534
|
+
tick_state['t_ticks'] = not tick_state['t_ticks']
|
|
3535
|
+
print(f"Toggled top ticks -> {'ON' if tick_state['t_ticks'] else 'off'}")
|
|
3536
|
+
elif p == 'tlb':
|
|
3537
|
+
tick_state['t_labels'] = not tick_state['t_labels']
|
|
3538
|
+
print(f"Toggled top labels -> {'ON' if tick_state['t_labels'] else 'off'}")
|
|
3539
|
+
need_pos['top'] = True
|
|
3540
|
+
elif p == 'ltcs':
|
|
3541
|
+
tick_state['l_ticks'] = not tick_state['l_ticks']
|
|
3542
|
+
print(f"Toggled left ticks -> {'ON' if tick_state['l_ticks'] else 'off'}")
|
|
3543
|
+
elif p == 'llb':
|
|
3544
|
+
tick_state['l_labels'] = not tick_state['l_labels']
|
|
3545
|
+
print(f"Toggled left labels -> {'ON' if tick_state['l_labels'] else 'off'}")
|
|
3546
|
+
need_pos['left'] = True
|
|
3547
|
+
elif p == 'rtcs':
|
|
3548
|
+
tick_state['r_ticks'] = not tick_state['r_ticks']
|
|
3549
|
+
print(f"Toggled right ticks -> {'ON' if tick_state['r_ticks'] else 'off'}")
|
|
3550
|
+
elif p == 'rlb':
|
|
3551
|
+
tick_state['r_labels'] = not tick_state['r_labels']
|
|
3552
|
+
print(f"Toggled right labels -> {'ON' if tick_state['r_labels'] else 'off'}")
|
|
3553
|
+
need_pos['right'] = True
|
|
3554
|
+
_sync_legacy_tick_keys()
|
|
3555
|
+
continue
|
|
3556
|
+
# Minor tick toggles
|
|
3557
|
+
if p in ('mbx','mtx','mly','mry'):
|
|
3558
|
+
tick_state[p] = not tick_state[p]
|
|
3559
|
+
print(f"Toggled {p} -> {'ON' if tick_state[p] else 'off'}")
|
|
3560
|
+
continue
|
|
3561
|
+
# Legacy combined toggles
|
|
3562
|
+
if p in ('bx','tx','ly','ry'):
|
|
3563
|
+
if p == 'bx':
|
|
3564
|
+
newv = not (tick_state['b_ticks'] or tick_state['b_labels'])
|
|
3565
|
+
tick_state['b_ticks'] = newv; tick_state['b_labels'] = newv
|
|
3566
|
+
print(f"Toggled bottom (ticks+labels) -> {'ON' if newv else 'off'}")
|
|
3567
|
+
need_pos['bottom'] = True
|
|
3568
|
+
elif p == 'tx':
|
|
3569
|
+
newv = not (tick_state['t_ticks'] or tick_state['t_labels'])
|
|
3570
|
+
tick_state['t_ticks'] = newv; tick_state['t_labels'] = newv
|
|
3571
|
+
print(f"Toggled top (ticks+labels) -> {'ON' if newv else 'off'}")
|
|
3572
|
+
need_pos['top'] = True
|
|
3573
|
+
elif p == 'ly':
|
|
3574
|
+
newv = not (tick_state['l_ticks'] or tick_state['l_labels'])
|
|
3575
|
+
tick_state['l_ticks'] = newv; tick_state['l_labels'] = newv
|
|
3576
|
+
print(f"Toggled left (ticks+labels) -> {'ON' if newv else 'off'}")
|
|
3577
|
+
need_pos['left'] = True
|
|
3578
|
+
elif p == 'ry':
|
|
3579
|
+
newv = not (tick_state['r_ticks'] or tick_state['r_labels'])
|
|
3580
|
+
tick_state['r_ticks'] = newv; tick_state['r_labels'] = newv
|
|
3581
|
+
print(f"Toggled right (ticks+labels) -> {'ON' if newv else 'off'}")
|
|
3582
|
+
need_pos['right'] = True
|
|
3583
|
+
_sync_legacy_tick_keys()
|
|
3584
|
+
continue
|
|
3585
|
+
# Unknown code
|
|
3586
|
+
print(f"Unknown code: {p}")
|
|
3587
|
+
# After tick toggles, update visibility and reposition ALL axis labels for independence
|
|
3588
|
+
update_tick_visibility()
|
|
3589
|
+
update_labels(ax, y_data_list, label_text_objects, args.stack, getattr(fig, '_stack_label_at_bottom', False))
|
|
3590
|
+
sync_fonts()
|
|
3591
|
+
# Only reposition sides that were actually affected by the toggles
|
|
3592
|
+
if need_pos['bottom']:
|
|
3593
|
+
position_bottom_xlabel()
|
|
3594
|
+
if need_pos['left']:
|
|
3595
|
+
position_left_ylabel()
|
|
3596
|
+
if need_pos['top']:
|
|
3597
|
+
position_top_xlabel()
|
|
3598
|
+
if need_pos['right']:
|
|
3599
|
+
position_right_ylabel()
|
|
3600
|
+
# Single draw at the end after all positioning is complete
|
|
3601
|
+
fig.canvas.draw_idle()
|
|
3602
|
+
except Exception as e:
|
|
3603
|
+
print(f"Error in tick visibility menu: {e}")
|
|
3604
|
+
elif key == 'p':
|
|
3605
|
+
try:
|
|
3606
|
+
style_menu_active = True
|
|
3607
|
+
while style_menu_active:
|
|
3608
|
+
print_style_info()
|
|
3609
|
+
# List available style files (.bps, .bpsg, .bpcfg) in Styles/ subdirectory
|
|
3610
|
+
style_file_list = list_files_in_subdirectory(('.bps', '.bpsg', '.bpcfg'), 'style')
|
|
3611
|
+
_bpcfg_files = [f[0] for f in style_file_list]
|
|
3612
|
+
if _bpcfg_files:
|
|
3613
|
+
print("Existing style files in Styles/ (.bps/.bpsg):")
|
|
3614
|
+
for _i, (fname, fpath) in enumerate(style_file_list, 1):
|
|
3615
|
+
timestamp = format_file_timestamp(fpath)
|
|
3616
|
+
if timestamp:
|
|
3617
|
+
print(f" {_i}: {fname} ({timestamp})")
|
|
3618
|
+
else:
|
|
3619
|
+
print(f" {_i}: {fname}")
|
|
3620
|
+
last_style_path = getattr(fig, '_last_style_export_path', None)
|
|
3621
|
+
if last_style_path:
|
|
3622
|
+
sub = _safe_input(colorize_prompt("Style submenu: (e=export, o=overwrite last, q=return, r=refresh): ")).strip().lower()
|
|
3623
|
+
else:
|
|
3624
|
+
sub = _safe_input(colorize_prompt("Style submenu: (e=export, q=return, r=refresh): ")).strip().lower()
|
|
3625
|
+
if sub == 'q':
|
|
3626
|
+
break
|
|
3627
|
+
if sub == 'r' or sub == '':
|
|
3628
|
+
continue
|
|
3629
|
+
if sub == 'o':
|
|
3630
|
+
# Overwrite last exported style file
|
|
3631
|
+
if not last_style_path:
|
|
3632
|
+
print("No previous export found.")
|
|
3633
|
+
continue
|
|
3634
|
+
if not os.path.exists(last_style_path):
|
|
3635
|
+
print(f"Previous export file not found: {last_style_path}")
|
|
3636
|
+
continue
|
|
3637
|
+
yn = _safe_input(f"Overwrite '{os.path.basename(last_style_path)}'? (y/n): ").strip().lower()
|
|
3638
|
+
if yn != 'y':
|
|
3639
|
+
continue
|
|
3640
|
+
# Call export_style_config with overwrite_path to skip dialog
|
|
3641
|
+
exported_path = export_style_config(None, base_path=None, overwrite_path=last_style_path)
|
|
3642
|
+
if exported_path:
|
|
3643
|
+
fig._last_style_export_path = exported_path
|
|
3644
|
+
style_menu_active = False
|
|
3645
|
+
break
|
|
3646
|
+
if sub == 'e':
|
|
3647
|
+
save_base = choose_save_path(source_file_paths, purpose="style export")
|
|
3648
|
+
if not save_base:
|
|
3649
|
+
print("Style export canceled.")
|
|
3650
|
+
continue
|
|
3651
|
+
print(f"\nChosen path: {save_base}")
|
|
3652
|
+
# Call export_style_config which handles the entire export dialog
|
|
3653
|
+
exported_path = export_style_config(None, base_path=save_base) # filename parameter ignored
|
|
3654
|
+
if exported_path:
|
|
3655
|
+
fig._last_style_export_path = exported_path
|
|
3656
|
+
style_menu_active = False # Exit style submenu and return to main menu
|
|
3657
|
+
break
|
|
3658
|
+
else:
|
|
3659
|
+
print("Unknown choice.")
|
|
3660
|
+
except Exception as e:
|
|
3661
|
+
print(f"Error in style submenu: {e}")
|
|
3662
|
+
elif key == 'i':
|
|
3663
|
+
try:
|
|
3664
|
+
fname = choose_style_file(source_file_paths, purpose="style import")
|
|
3665
|
+
if not fname:
|
|
3666
|
+
print("Style import canceled.")
|
|
3667
|
+
continue
|
|
3668
|
+
push_state("style-import")
|
|
3669
|
+
apply_style_config(fname)
|
|
3670
|
+
except Exception as e:
|
|
3671
|
+
print(f"Error importing style: {e}")
|
|
3672
|
+
elif key == 'e':
|
|
3673
|
+
try:
|
|
3674
|
+
base_path = choose_save_path(source_file_paths, purpose="figure export")
|
|
3675
|
+
if not base_path:
|
|
3676
|
+
print("Export canceled.")
|
|
3677
|
+
continue
|
|
3678
|
+
print(f"\nChosen path: {base_path}")
|
|
3679
|
+
# List existing figure files in Figures/ subdirectory
|
|
3680
|
+
fig_extensions = ('.svg', '.png', '.jpg', '.jpeg', '.pdf', '.eps', '.tif', '.tiff')
|
|
3681
|
+
file_list = list_files_in_subdirectory(fig_extensions, 'figure', base_path=base_path)
|
|
3682
|
+
files = [f[0] for f in file_list]
|
|
3683
|
+
if files:
|
|
3684
|
+
figures_dir = os.path.join(base_path, 'Figures')
|
|
3685
|
+
print(f"Existing figure files in {figures_dir}:")
|
|
3686
|
+
for i, (fname, fpath) in enumerate(file_list, 1):
|
|
3687
|
+
timestamp = format_file_timestamp(fpath)
|
|
3688
|
+
if timestamp:
|
|
3689
|
+
print(f" {i}: {fname} ({timestamp})")
|
|
3690
|
+
else:
|
|
3691
|
+
print(f" {i}: {fname}")
|
|
3692
|
+
|
|
3693
|
+
last_figure_path = getattr(fig, '_last_figure_export_path', None)
|
|
3694
|
+
if last_figure_path:
|
|
3695
|
+
filename = _safe_input("Enter filename (default SVG if no extension), number to overwrite, or o to overwrite last (q=cancel): ").strip()
|
|
3696
|
+
else:
|
|
3697
|
+
filename = _safe_input("Enter filename (default SVG if no extension) or number to overwrite (q=cancel): ").strip()
|
|
3698
|
+
if not filename or filename.lower() == 'q':
|
|
3699
|
+
print("Canceled.")
|
|
3700
|
+
continue
|
|
3701
|
+
|
|
3702
|
+
already_confirmed = False # Initialize for new filename case
|
|
3703
|
+
# Check for 'o' option
|
|
3704
|
+
if filename.lower() == 'o':
|
|
3705
|
+
if not last_figure_path:
|
|
3706
|
+
print("No previous export found.")
|
|
3707
|
+
continue
|
|
3708
|
+
if not os.path.exists(last_figure_path):
|
|
3709
|
+
print(f"Previous export file not found: {last_figure_path}")
|
|
3710
|
+
continue
|
|
3711
|
+
yn = _safe_input(f"Overwrite '{os.path.basename(last_figure_path)}'? (y/n): ").strip().lower()
|
|
3712
|
+
if yn != 'y':
|
|
3713
|
+
print("Canceled.")
|
|
3714
|
+
continue
|
|
3715
|
+
export_target = last_figure_path
|
|
3716
|
+
already_confirmed = True
|
|
3717
|
+
# Check if user selected a number
|
|
3718
|
+
elif filename.isdigit() and files:
|
|
3719
|
+
already_confirmed = False
|
|
3720
|
+
idx = int(filename)
|
|
3721
|
+
if 1 <= idx <= len(files):
|
|
3722
|
+
name = files[idx-1]
|
|
3723
|
+
yn = _safe_input(f"Overwrite '{name}'? (y/n): ").strip().lower()
|
|
3724
|
+
if yn != 'y':
|
|
3725
|
+
print("Canceled.")
|
|
3726
|
+
continue
|
|
3727
|
+
export_target = file_list[idx-1][1] # Full path from list
|
|
3728
|
+
already_confirmed = True
|
|
3729
|
+
else:
|
|
3730
|
+
print("Invalid number.")
|
|
3731
|
+
continue
|
|
3732
|
+
else:
|
|
3733
|
+
if not os.path.splitext(filename)[1]:
|
|
3734
|
+
filename += ".svg"
|
|
3735
|
+
# Use organized path unless it's an absolute path
|
|
3736
|
+
if os.path.isabs(filename):
|
|
3737
|
+
export_target = filename
|
|
3738
|
+
else:
|
|
3739
|
+
export_target = get_organized_path(filename, 'figure', base_path=base_path)
|
|
3740
|
+
|
|
3741
|
+
# Confirm overwrite if file exists (and not already confirmed by number selection)
|
|
3742
|
+
if not already_confirmed:
|
|
3743
|
+
if os.path.exists(export_target):
|
|
3744
|
+
export_target = _confirm_overwrite(export_target)
|
|
3745
|
+
|
|
3746
|
+
if not export_target:
|
|
3747
|
+
print("Export canceled.")
|
|
3748
|
+
else:
|
|
3749
|
+
# Ensure exact case is preserved (important for macOS case-insensitive filesystem)
|
|
3750
|
+
from .utils import ensure_exact_case_filename
|
|
3751
|
+
export_target = ensure_exact_case_filename(export_target)
|
|
3752
|
+
|
|
3753
|
+
# Temporarily remove numbering for export
|
|
3754
|
+
for i, txt in enumerate(label_text_objects):
|
|
3755
|
+
txt.set_text(labels[i])
|
|
3756
|
+
# Transparent background for SVG exports
|
|
3757
|
+
_, _ext = os.path.splitext(export_target)
|
|
3758
|
+
if _ext.lower() == '.svg':
|
|
3759
|
+
try:
|
|
3760
|
+
_fig_fc = fig.get_facecolor()
|
|
3761
|
+
except Exception:
|
|
3762
|
+
_fig_fc = None
|
|
3763
|
+
try:
|
|
3764
|
+
_ax_fc = ax.get_facecolor()
|
|
3765
|
+
except Exception:
|
|
3766
|
+
_ax_fc = None
|
|
3767
|
+
try:
|
|
3768
|
+
if getattr(fig, 'patch', None) is not None:
|
|
3769
|
+
fig.patch.set_alpha(0.0); fig.patch.set_facecolor('none')
|
|
3770
|
+
if getattr(ax, 'patch', None) is not None:
|
|
3771
|
+
ax.patch.set_alpha(0.0); ax.patch.set_facecolor('none')
|
|
3772
|
+
except Exception:
|
|
3773
|
+
pass
|
|
3774
|
+
try:
|
|
3775
|
+
fig.savefig(export_target, dpi=300, transparent=True, facecolor='none', edgecolor='none')
|
|
3776
|
+
finally:
|
|
3777
|
+
try:
|
|
3778
|
+
if _fig_fc is not None and getattr(fig, 'patch', None) is not None:
|
|
3779
|
+
fig.patch.set_alpha(1.0); fig.patch.set_facecolor(_fig_fc)
|
|
3780
|
+
except Exception:
|
|
3781
|
+
pass
|
|
3782
|
+
try:
|
|
3783
|
+
if _ax_fc is not None and getattr(ax, 'patch', None) is not None:
|
|
3784
|
+
ax.patch.set_alpha(1.0); ax.patch.set_facecolor(_ax_fc)
|
|
3785
|
+
except Exception:
|
|
3786
|
+
pass
|
|
3787
|
+
else:
|
|
3788
|
+
fig.savefig(export_target, dpi=300)
|
|
3789
|
+
print(f"Figure saved to {export_target}")
|
|
3790
|
+
fig._last_figure_export_path = export_target
|
|
3791
|
+
for i, txt in enumerate(label_text_objects):
|
|
3792
|
+
txt.set_text(f"{i+1}: {labels[i]}")
|
|
3793
|
+
fig.canvas.draw()
|
|
3794
|
+
except Exception as e:
|
|
3795
|
+
print(f"Error saving figure: {e}")
|
|
3796
|
+
elif key == 'v':
|
|
3797
|
+
while True:
|
|
3798
|
+
try:
|
|
3799
|
+
rng_in = _safe_input("Peak X range (min max, 'current' for axes limits, q=back): ").strip().lower()
|
|
3800
|
+
if not rng_in or rng_in == 'q':
|
|
3801
|
+
break
|
|
3802
|
+
if rng_in == 'current':
|
|
3803
|
+
x_min, x_max = ax.get_xlim()
|
|
3804
|
+
else:
|
|
3805
|
+
parts = rng_in.split()
|
|
3806
|
+
if len(parts) != 2:
|
|
3807
|
+
print("Need exactly two numbers or 'current'.")
|
|
3808
|
+
continue
|
|
3809
|
+
x_min, x_max = map(float, parts)
|
|
3810
|
+
if x_min > x_max:
|
|
3811
|
+
x_min, x_max = x_max, x_min
|
|
3812
|
+
|
|
3813
|
+
frac_in = _safe_input("Min relative peak height (0–1, default 0.1): ").strip()
|
|
3814
|
+
min_frac = float(frac_in) if frac_in else 0.1
|
|
3815
|
+
if min_frac < 0: min_frac = 0.0
|
|
3816
|
+
if min_frac > 1: min_frac = 1.0
|
|
3817
|
+
|
|
3818
|
+
swin = _safe_input("Smoothing window (odd int >=3, blank=none): ").strip()
|
|
3819
|
+
if swin:
|
|
3820
|
+
try:
|
|
3821
|
+
win = int(swin)
|
|
3822
|
+
if win < 3 or win % 2 == 0:
|
|
3823
|
+
print("Invalid window; disabling smoothing.")
|
|
3824
|
+
win = 0
|
|
3825
|
+
else:
|
|
3826
|
+
print(f"Using moving-average smoothing (window={win}).")
|
|
3827
|
+
except ValueError:
|
|
3828
|
+
print("Bad window value; no smoothing.")
|
|
3829
|
+
win = 0
|
|
3830
|
+
else:
|
|
3831
|
+
win = 0
|
|
3832
|
+
|
|
3833
|
+
print("\n--- Peak Report ---")
|
|
3834
|
+
print(f"X range used: {x_min} .. {x_max} (relative height threshold={min_frac})")
|
|
3835
|
+
for i, (x_arr, y_off) in enumerate(zip(x_data_list, y_data_list)):
|
|
3836
|
+
# Recover original curve (remove vertical offset)
|
|
3837
|
+
if i < len(offsets_list):
|
|
3838
|
+
y_arr = y_off - offsets_list[i]
|
|
3839
|
+
else:
|
|
3840
|
+
y_arr = y_off.copy()
|
|
3841
|
+
|
|
3842
|
+
# Restrict to selected window
|
|
3843
|
+
mask = (x_arr >= x_min) & (x_arr <= x_max)
|
|
3844
|
+
x_sel = x_arr[mask]
|
|
3845
|
+
y_sel = y_arr[mask]
|
|
3846
|
+
|
|
3847
|
+
label = labels[i] if i < len(labels) else f"Curve {i+1}"
|
|
3848
|
+
print(f"\nCurve {i+1}: {label}")
|
|
3849
|
+
if x_sel.size < 3:
|
|
3850
|
+
print(" (Insufficient points)")
|
|
3851
|
+
continue
|
|
3852
|
+
|
|
3853
|
+
# Optional smoothing
|
|
3854
|
+
if win >= 3 and x_sel.size >= win:
|
|
3855
|
+
kernel = np.ones(win, dtype=float) / win
|
|
3856
|
+
y_sm = np.convolve(y_sel, kernel, mode='same')
|
|
3857
|
+
else:
|
|
3858
|
+
y_sm = y_sel
|
|
3859
|
+
|
|
3860
|
+
# Determine threshold
|
|
3861
|
+
ymax = float(np.max(y_sm))
|
|
3862
|
+
if ymax <= 0:
|
|
3863
|
+
print(" (Non-positive data)")
|
|
3864
|
+
continue
|
|
3865
|
+
min_height = ymax * min_frac
|
|
3866
|
+
|
|
3867
|
+
# Simple local maxima detection
|
|
3868
|
+
y_prev = y_sm[:-2]
|
|
3869
|
+
y_mid = y_sm[1:-1]
|
|
3870
|
+
y_next = y_sm[2:]
|
|
3871
|
+
core_mask = (y_mid > y_prev) & (y_mid >= y_next) & (y_mid >= min_height)
|
|
3872
|
+
if not np.any(core_mask):
|
|
3873
|
+
print(" (No peaks)")
|
|
3874
|
+
continue
|
|
3875
|
+
peak_indices = np.where(core_mask)[0] + 1 # shift because we looked at 1..n-2
|
|
3876
|
+
|
|
3877
|
+
# Optional refine: keep only distinct peaks (skip adjacent equal plateau)
|
|
3878
|
+
peaks = []
|
|
3879
|
+
last_idx = -10
|
|
3880
|
+
for pi in peak_indices:
|
|
3881
|
+
if pi - last_idx == 1 and y_sm[pi] == y_sm[last_idx]:
|
|
3882
|
+
# same plateau, keep first
|
|
3883
|
+
continue
|
|
3884
|
+
peaks.append(pi)
|
|
3885
|
+
last_idx = pi
|
|
3886
|
+
|
|
3887
|
+
print(" Peaks (x, y):")
|
|
3888
|
+
for pi in peaks:
|
|
3889
|
+
print(f" x={x_sel[pi]:.6g}, y={y_sel[pi]:.6g}")
|
|
3890
|
+
print("\n--- End Peak Report ---\n")
|
|
3891
|
+
except Exception as e:
|
|
3892
|
+
print(f"Error finding peaks: {e}")
|
|
3893
|
+
|
|
3894
|
+
__all__ = ["interactive_menu"]
|